From 641a61f80447f5fe3bea296711086cf71d94afc4 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 26 Aug 2026 21:31:30 +0800 Subject: [PATCH] feat(relink): accept CivitArchive URLs when linking models --- locales/de.json | 16 +- locales/en.json | 18 +- locales/es.json | 16 +- locales/fr.json | 16 +- locales/he.json | 16 +- locales/ja.json | 16 +- locales/ko.json | 16 +- locales/ru.json | 16 +- locales/zh-CN.json | 16 +- locales/zh-TW.json | 16 +- py/routes/handlers/model_handlers.py | 39 +++- py/services/metadata_sync_service.py | 29 ++- .../ContextMenu/ModelContextMenuMixin.js | 71 +++++--- static/js/utils/civitaiUtils.js | 41 +++++ .../modals/relink_civitai_modal.html | 1 + .../contextMenu.interactions.test.js | 168 ++++++++++++++++++ .../components/modelContextMenuMixin.test.js | 21 +-- .../frontend/regression/modalRegistry.test.js | 61 +++++++ tests/frontend/utils/civitaiUtils.test.js | 45 +++++ tests/routes/test_model_update_handler.py | 114 +++++++++++- tests/services/test_metadata_sync_service.py | 125 +++++++++++++ 21 files changed, 756 insertions(+), 121 deletions(-) create mode 100644 tests/frontend/regression/modalRegistry.test.js diff --git a/locales/de.json b/locales/de.json index 9fce14bb..91cf7c74 100644 --- a/locales/de.json +++ b/locales/de.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "Suche nach doppelten Rezepten...", "found": "{count} Duplikat-Gruppen gefunden", "noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden", "keepLatest": "Neueste Versionen behalten", @@ -1422,13 +1422,14 @@ }, "proceedText": "Fahren Sie nur fort, wenn Sie sicher sind, dass Sie das wollen.", "urlLabel": "Civitai-Modell-URL:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "Fügen Sie eine beliebige Civitai-Modell-URL ein. Unterstützte Formate:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "Hinweis: Wenn keine modelVersionId angegeben ist, wird die neueste Version verwendet." + "title": "Fügen Sie eine beliebige Civitai- oder CivitArchive-Modell-URL ein. Unterstützte Formate:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "Hinweis: Wenn keine modelVersionId angegeben ist, wird die neueste Version verwendet.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "Neu-Verknüpfung bestätigen" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "Fehler: {message}", "linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft", "linkHfFailed": "Fehler: {message}", + "linkCivArchSuccess": "Modell erfolgreich über CivitArchive neu verknüpft", "fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab", "noCivitaiInfo": "Keine CivitAI-Informationen verfügbar", "missingHash": "Modell-Hash nicht verfügbar" diff --git a/locales/en.json b/locales/en.json index 233e7463..5c1cc8c1 100644 --- a/locales/en.json +++ b/locales/en.json @@ -864,8 +864,8 @@ }, "navigation": { "label": "Recipe navigation", - "previousWithShortcut": "Previous recipe (\u2190)", - "nextWithShortcut": "Next recipe (\u2192)" + "previousWithShortcut": "Previous recipe (←)", + "nextWithShortcut": "Next recipe (→)" }, "workflow": { "sendWorkflow": "Send Workflow to ComfyUI", @@ -1422,13 +1422,14 @@ }, "proceedText": "Only proceed if you're sure this is what you want.", "urlLabel": "Civitai Model URL:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 or https://civitai.red/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "Note: If no modelVersionId is provided, the latest version will be used." + "title": "Paste any Civitai or CivitArchive model URL. Supported formats:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "Note: If no modelVersionId is provided, the latest version will be used.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "Confirm Re-link" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "Error: {message}", "linkHfSuccess": "Model successfully linked to HuggingFace", "linkHfFailed": "Error: {message}", + "linkCivArchSuccess": "Model successfully re-linked via CivitArchive", "fetchMetadataFirst": "Please fetch metadata from CivitAI first", "noCivitaiInfo": "No CivitAI information available", "missingHash": "Model hash not available" diff --git a/locales/es.json b/locales/es.json index 5d9de398..b6c412f3 100644 --- a/locales/es.json +++ b/locales/es.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "Buscando recetas duplicadas...", "found": "Se encontraron {count} grupos de duplicados", "noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual", "keepLatest": "Mantener versiones más recientes", @@ -1422,13 +1422,14 @@ }, "proceedText": "Solo procede si estás seguro de que esto es lo que quieres.", "urlLabel": "URL del modelo de Civitai:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "Pega cualquier URL de modelo de Civitai. Formatos soportados:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "Nota: Si no se proporciona modelVersionId, se usará la versión más reciente." + "title": "Pega cualquier URL de modelo de Civitai o CivitArchive. Formatos soportados:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "Nota: Si no se proporciona modelVersionId, se usará la versión más reciente.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "Confirmar re-vinculación" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "Error: {message}", "linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente", "linkHfFailed": "Error: {message}", + "linkCivArchSuccess": "Modelo re-vinculado exitosamente mediante CivitArchive", "fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero", "noCivitaiInfo": "No hay información de CivitAI disponible", "missingHash": "Hash del modelo no disponible" diff --git a/locales/fr.json b/locales/fr.json index cda8458a..582bcb16 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "Recherche de recettes en doublon...", "found": "Trouvé {count} groupes de doublons", "noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel", "keepLatest": "Garder les dernières versions", @@ -1422,13 +1422,14 @@ }, "proceedText": "Ne procédez que si vous êtes sûr que c'est ce que vous voulez.", "urlLabel": "URL du modèle Civitai :", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "Collez n'importe quelle URL de modèle Civitai. Formats supportés :", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "Note : Si aucun modelVersionId n'est fourni, la dernière version sera utilisée." + "title": "Collez n'importe quelle URL de modèle Civitai ou CivitArchive. Formats supportés :", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "Note : Si aucun modelVersionId n'est fourni, la dernière version sera utilisée.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "Confirmer la re-liaison" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "Erreur : {message}", "linkHfSuccess": "Modèle lié à HuggingFace avec succès", "linkHfFailed": "Erreur : {message}", + "linkCivArchSuccess": "Modèle relié via CivitArchive avec succès", "fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI", "noCivitaiInfo": "Aucune information CivitAI disponible", "missingHash": "Hash du modèle non disponible" diff --git a/locales/he.json b/locales/he.json index 347f8577..69406420 100644 --- a/locales/he.json +++ b/locales/he.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "סורק למציאת מתכונים כפולים...", "found": "נמצאו {count} קבוצות כפולות", "noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי", "keepLatest": "שמור גרסאות אחרונות", @@ -1422,13 +1422,14 @@ }, "proceedText": "המשך רק אם אתה בטוח שזה מה שאתה רוצה.", "urlLabel": "כתובת URL של מודל ב-Civitai:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "הדבק כל כתובת URL של מודל מ-Civitai. פורמטים נתמכים:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "הערה: אם לא סופק modelVersionId, תילקח הגרסה האחרונה." + "title": "הדבק כל כתובת URL של מודל מ-Civitai או מ-CivitArchive. פורמטים נתמכים:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "הערה: אם לא סופק modelVersionId, תילקח הגרסה האחרונה.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "אשר קישור מחדש" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "שגיאה: {message}", "linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace", "linkHfFailed": "שגיאה: {message}", + "linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה", "fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה", "noCivitaiInfo": "אין מידע מ-CivitAI זמין", "missingHash": "ה-hash של המודל אינו זמין" diff --git a/locales/ja.json b/locales/ja.json index 4c5b31b0..874af76d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "重複レシピをスキャンしています...", "found": "{count} 個の重複グループが見つかりました", "noGroups": "現在の一致基準では重複グループが見つかりませんでした", "keepLatest": "最新バージョンを保持", @@ -1422,13 +1422,14 @@ }, "proceedText": "これが本当に必要な場合のみ続行してください。", "urlLabel": "CivitaiモデルURL:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "CivitaiモデルURLを貼り付けてください。対応形式:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。" + "title": "CivitaiまたはCivitArchiveのモデルURLを貼り付けてください。対応形式:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "再リンクを確認" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "エラー:{message}", "linkHfSuccess": "モデルを HuggingFace にリンクしました", "linkHfFailed": "エラー:{message}", + "linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました", "fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください", "noCivitaiInfo": "CivitAI情報が利用できません", "missingHash": "モデルハッシュが利用できません" diff --git a/locales/ko.json b/locales/ko.json index 96d70bc5..a33811dd 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "중복 레시피를 스캔하는 중...", "found": "{count}개의 중복 그룹 발견", "noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다", "keepLatest": "최신 버전 유지", @@ -1422,13 +1422,14 @@ }, "proceedText": "원하는 작업이 확실한 경우에만 진행하세요.", "urlLabel": "Civitai 모델 URL:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "모든 Civitai 모델 URL을 붙여넣으세요. 지원되는 형식:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "참고: modelVersionId가 제공되지 않으면 최신 버전이 사용됩니다." + "title": "Civitai 또는 CivitArchive 모델 URL을 붙여넣으세요. 지원되는 형식:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "참고: modelVersionId가 제공되지 않으면 최신 버전이 사용됩니다.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "다시 연결 확인" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "오류: {message}", "linkHfSuccess": "모델이 HuggingFace에 연결되었습니다", "linkHfFailed": "오류: {message}", + "linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다", "fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요", "noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다", "missingHash": "모델 해시를 사용할 수 없습니다" diff --git a/locales/ru.json b/locales/ru.json index a5d2f046..e14cb4cc 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "Поиск дублирующихся рецептов...", "found": "Найдено {count} групп дубликатов", "noGroups": "Дубликатов с текущим критерием не найдено", "keepLatest": "Оставить последние версии", @@ -1422,13 +1422,14 @@ }, "proceedText": "Продолжайте только если вы уверены, что это то, что вам нужно.", "urlLabel": "URL модели Civitai:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "Вставьте любой URL модели Civitai. Поддерживаемые форматы:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "Примечание: Если modelVersionId не указан, будет использована последняя версия." + "title": "Вставьте любой URL модели Civitai или CivitArchive. Поддерживаемые форматы:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "Примечание: Если modelVersionId не указан, будет использована последняя версия.", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "Подтвердить пересвязывание" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "Ошибка: {message}", "linkHfSuccess": "Модель успешно связана с HuggingFace", "linkHfFailed": "Ошибка: {message}", + "linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive", "fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI", "noCivitaiInfo": "Информация CivitAI недоступна", "missingHash": "Хеш модели недоступен" diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 3fa81625..7c47be49 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "正在扫描重复配方...", "found": "发现 {count} 个重复组", "noGroups": "按当前判重依据未找到重复组", "keepLatest": "保留最新版本", @@ -1422,13 +1422,14 @@ }, "proceedText": "仅在你确定需要此操作时继续。", "urlLabel": "Civitai 模型 URL:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "注意:如果未提供 modelVersionId,将使用最新版本。" + "title": "粘贴任意 Civitai 或 CivitArchive 模型 URL。支持格式:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "注意:如果未提供 modelVersionId,将使用最新版本。", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "确认重新关联" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "错误:{message}", "linkHfSuccess": "模型已成功链接到 HuggingFace", "linkHfFailed": "错误:{message}", + "linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联", "fetchMetadataFirst": "请先从 CivitAI 获取元数据", "noCivitaiInfo": "无 CivitAI 信息", "missingHash": "模型哈希不可用" diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 8b5004b0..39773d12 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -963,7 +963,7 @@ } }, "duplicates": { - "finding": "[TODO: Translate] Scanning for duplicate recipes...", + "finding": "正在掃描重複配方...", "found": "發現 {count} 組重複項", "noGroups": "按目前判重依據未找到重複組", "keepLatest": "保留最新版本", @@ -1422,13 +1422,14 @@ }, "proceedText": "僅在確定需要執行時才繼續。", "urlLabel": "Civitai 模型網址:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890", "helpText": { - "title": "貼上任意 Civitai 模型網址。支援格式:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "注意:若未提供 modelVersionId,將使用最新版本。" + "title": "貼上任意 Civitai 或 CivitArchive 模型網址。支援格式:", + "format1": "https://civitai.com/models/12345", + "format2": "https://civitai.com/models/12345?modelVersionId=67890", + "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890", + "note": "注意:若未提供 modelVersionId,將使用最新版本。", + "format4": "https://civarchive.com/models/12345 (CivitArchive)" }, "confirmAction": "確認重新連結" }, @@ -2225,6 +2226,7 @@ "relinkFailed": "錯誤:{message}", "linkHfSuccess": "模型已成功連結到 HuggingFace", "linkHfFailed": "錯誤:{message}", + "linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結", "fetchMetadataFirst": "請先從 CivitAI 取得 metadata", "noCivitaiInfo": "無 CivitAI 資訊", "missingHash": "模型雜湊不可用" diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index a09b2ffd..8b3784fd 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -634,6 +634,16 @@ class ModelManagementHandler: file_path = data.get("file_path") model_id = data.get("model_id") model_version_id = data.get("model_version_id") + source = data.get("source") + + if source not in (None, "", "civarchive"): + return web.json_response( + { + "success": False, + "error": f"Unsupported relink source: {source}", + }, + status=400, + ) if not file_path or model_id is None: return web.json_response( @@ -649,20 +659,33 @@ class ModelManagementHandler: metadata_path ) + relink_kwargs = { + "file_path": file_path, + "metadata": local_metadata, + "model_id": int(model_id), + "model_version_id": int(model_version_id) if model_version_id else None, + } + if source == "civarchive": + relink_kwargs["provider_name"] = "civarchive_api" + updated_metadata = await self._metadata_sync.relink_metadata( - file_path=file_path, - metadata=local_metadata, - model_id=int(model_id), - model_version_id=int(model_version_id) if model_version_id else None, + **relink_kwargs ) await self._service.scanner.update_single_model_cache( file_path, file_path, updated_metadata ) - message = f"Model successfully re-linked to Civitai model {model_id}" + ( - f" version {model_version_id}" if model_version_id else "" - ) + if source == "civarchive": + message = ( + f"Model successfully re-linked to CivArchive model {model_id}" + + (f" version {model_version_id}" if model_version_id else "") + ) + else: + message = ( + f"Model successfully re-linked to Civitai model {model_id}" + + (f" version {model_version_id}" if model_version_id else "") + ) return web.json_response( { "success": True, @@ -670,6 +693,8 @@ class ModelManagementHandler: "hash": updated_metadata.get("sha256", ""), } ) + except ValueError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) except Exception as exc: if is_expected_offline_error(str(exc)): return web.json_response( diff --git a/py/services/metadata_sync_service.py b/py/services/metadata_sync_service.py index 13e8c671..e441a8cc 100644 --- a/py/services/metadata_sync_service.py +++ b/py/services/metadata_sync_service.py @@ -419,14 +419,37 @@ class MetadataSyncService: metadata: Dict[str, Any], model_id: int, model_version_id: Optional[int], + provider_name: Optional[str] = None, ) -> Dict[str, Any]: - """Relink a local metadata record to a specific CivitAI model version.""" + """Relink a local metadata record to a specific CivitAI model version. + + When ``provider_name`` is given, the named provider is resolved via the + metadata provider selector instead of the default fallback chain. A + missing/disabled provider surfaces a user-friendly error instead of the + raw selector exception. + """ + + if provider_name: + try: + provider = await self._get_provider(provider_name) + except ValueError as exc: + logger.warning( + "Unable to resolve metadata provider %s: %s", provider_name, exc + ) + raise ValueError( + "CivitArchive is not available or not enabled. " + "Enable the CivitArchive API in settings to relink via CivArchive." + ) from exc + else: + provider = await self._get_default_provider() - provider = await self._get_default_provider() civitai_metadata = await provider.get_model_version(model_id, model_version_id) if not civitai_metadata: + provider_label = ( + "CivitArchive" if provider_name == "civarchive_api" else "CivitAI" + ) raise ValueError( - f"Model version not found on CivitAI for ID: {model_id}" + f"Model version not found on {provider_label} for ID: {model_id}" + (f" with version: {model_version_id}" if model_version_id else "") ) diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js index b6771060..abd33ed9 100644 --- a/static/js/components/ContextMenu/ModelContextMenuMixin.js +++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js @@ -6,7 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js'; import { MODEL_CONFIG } from '../../api/apiConfig.js'; import { translate } from '../../utils/i18nHelpers.js'; import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js'; -import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js'; +import { classifyModelRelinkUrl } from '../../utils/civitaiUtils.js'; // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu export const ModelContextMenuMixin = { @@ -106,6 +106,17 @@ export const ModelContextMenuMixin = { }, // Civitai re-linking methods + getModelTypePrefix() { + // Map the mixin model type to its API route prefix; the relink route + // exists for all model types via COMMON_ROUTE_DEFINITIONS. + const prefixMap = { + lora: 'loras', + checkpoint: 'checkpoints', + embedding: 'embeddings' + }; + return prefixMap[this.modelType] || 'loras'; + }, + showRelinkCivitaiModal() { const filePath = this.currentCard.dataset.filepath; if (!filePath) return; @@ -123,43 +134,55 @@ export const ModelContextMenuMixin = { // Create new bound handler this._boundRelinkHandler = async () => { const url = urlInput.value.trim(); - const { modelId, modelVersionId } = this.extractModelVersionId(url); - - if (!modelId) { - errorDiv.textContent = 'Invalid URL format. Must include model ID.'; + const { source, modelId, modelVersionId } = classifyModelRelinkUrl(url); + + if (!source || !modelId) { + errorDiv.textContent = 'Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}'; return; } - + errorDiv.textContent = ''; modalManager.closeModal('relinkCivitaiModal'); - + try { - state.loadingManager.showSimpleLoading('Re-linking to Civitai...'); - - const endpoint = this.modelType === 'checkpoint' ? - '/api/lm/checkpoints/relink-civitai' : - '/api/lm/loras/relink-civitai'; - + const isCivArchive = source === 'civarchive'; + state.loadingManager.showSimpleLoading( + isCivArchive ? 'Re-linking via CivitArchive...' : 'Re-linking to Civitai...' + ); + + const endpoint = `/api/lm/${this.getModelTypePrefix()}/relink-civitai`; + + const payload = { + file_path: filePath, + model_id: modelId, + model_version_id: modelVersionId + }; + // Omitted source keeps backend default-provider behaviour; only + // civarchive pins the provider explicitly. + if (isCivArchive) { + payload.source = source; + } + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - file_path: filePath, - model_id: modelId, - model_version_id: modelVersionId - }) + body: JSON.stringify(payload) }); - + if (!response.ok) { throw new Error(`Failed to re-link model: ${response.statusText}`); } - + const data = await response.json(); - + if (data.success) { - showToast('toast.contextMenu.relinkSuccess', {}, 'success'); + showToast( + isCivArchive ? 'toast.contextMenu.linkCivArchSuccess' : 'toast.contextMenu.relinkSuccess', + {}, + 'success' + ); // Reload the current view to show updated data await this.resetAndReload(); } else { @@ -255,10 +278,6 @@ export const ModelContextMenuMixin = { setTimeout(() => urlInput.focus(), 50); }, - extractModelVersionId(url) { - return extractCivitaiModelUrlParts(url); - }, - parseModelId(value) { if (value === undefined || value === null || value === '') { return null; diff --git a/static/js/utils/civitaiUtils.js b/static/js/utils/civitaiUtils.js index 29550e78..a5272eeb 100644 --- a/static/js/utils/civitaiUtils.js +++ b/static/js/utils/civitaiUtils.js @@ -207,6 +207,47 @@ export function extractCivitaiModelUrlParts(url) { } } +const CIVITARCHIVE_PAGE_HOSTS = new Set([ + 'civitaiarchive.com', + 'civarchive.com', +]); + +/** + * Classify a relink URL by its hosting source and extract ids. + * CivitArchive mirrors the Civitai id namespace, so both sources resolve to + * the same {modelId, modelVersionId} shape; only `source` differs. + */ +export function classifyModelRelinkUrl(url) { + if (!url || typeof url !== 'string') { + return { source: null, modelId: null, modelVersionId: null }; + } + + let parsedUrl; + try { + parsedUrl = new URL(url.trim()); + } catch (e) { + return { source: null, modelId: null, modelVersionId: null }; + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return { source: null, modelId: null, modelVersionId: null }; + } + + const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, ''); + const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/); + const modelId = pathMatch ? pathMatch[1] : null; + const modelVersionId = parsedUrl.searchParams.get('modelVersionId'); + + if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname)) { + return { source: 'civitai', modelId, modelVersionId }; + } + if (CIVITARCHIVE_PAGE_HOSTS.has(hostname) && modelId) { + return { source: 'civarchive', modelId, modelVersionId }; + } + + return { source: null, modelId: null, modelVersionId: null }; +} + export function extractCivitaiImageId(url) { if (!url) { return null; diff --git a/templates/components/modals/relink_civitai_modal.html b/templates/components/modals/relink_civitai_modal.html index 933eac2b..97f57641 100644 --- a/templates/components/modals/relink_civitai_modal.html +++ b/templates/components/modals/relink_civitai_modal.html @@ -22,6 +22,7 @@ • {{ t('modals.relinkCivitai.helpText.format1') }}
• {{ t('modals.relinkCivitai.helpText.format2') }}
• {{ t('modals.relinkCivitai.helpText.format3') }}
+ • {{ t('modals.relinkCivitai.helpText.format4') }}
{{ t('modals.relinkCivitai.helpText.note') }} diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 9b498c90..37c54669 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -2504,4 +2504,172 @@ describe('Interaction-level regression coverage', () => { delete stateStub.currentPageType; }); + + it('opens the relink modal from the relink-civitai menu action', async () => { + document.body.innerHTML = ` +
+
+
+
+
+
+
+ + `; + + const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js'); + const contextMenu = new LoraContextMenu(); + const showModalSpy = vi.spyOn(contextMenu, 'showRelinkCivitaiModal').mockImplementation(() => {}); + + const card = document.createElement('div'); + card.className = 'model-card'; + card.dataset.filepath = '/models/test.safetensors'; + document.body.appendChild(card); + + contextMenu.showMenu(100, 100, card); + document.querySelector('[data-action="relink-civitai"]').dispatchEvent(new Event('click', { bubbles: true })); + + expect(showModalSpy).toHaveBeenCalledTimes(1); + }); + + it('rejects an unsupported relink URL with an inline error and no fetch', async () => { + document.body.innerHTML = ` +
+ + `; + + const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js'); + const contextMenu = new LoraContextMenu(); + + const card = document.createElement('div'); + card.className = 'model-card'; + card.dataset.filepath = '/models/test.safetensors'; + document.body.appendChild(card); + + contextMenu.showMenu(100, 100, card); + contextMenu.showRelinkCivitaiModal(); + + document.getElementById('civitaiModelUrl').value = 'https://example.com/models/123456'; + await contextMenu._boundRelinkHandler(); + + expect(document.getElementById('civitaiModelUrlError').textContent) + .toBe('Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(modalManagerMock.closeModal).not.toHaveBeenCalled(); + }); + + it('posts a valid CivitArchive URL to the relink endpoint with the civarchive source', async () => { + document.body.innerHTML = ` +
+ + `; + + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true }), + })); + + const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js'); + const contextMenu = new LoraContextMenu(); + + const card = document.createElement('div'); + card.className = 'model-card'; + card.dataset.filepath = '/models/test.safetensors'; + document.body.appendChild(card); + + contextMenu.showMenu(100, 100, card); + contextMenu.showRelinkCivitaiModal(); + + document.getElementById('civitaiModelUrl').value = 'https://civarchive.com/models/123456?modelVersionId=789012'; + await contextMenu._boundRelinkHandler(); + await flushAsyncTasks(); + + expect(modalManagerMock.closeModal).toHaveBeenCalledWith('relinkCivitaiModal'); + expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Re-linking via CivitArchive...'); + expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: '/models/test.safetensors', + model_id: '123456', + model_version_id: '789012', + source: 'civarchive', + }), + }); + expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.linkCivArchSuccess', {}, 'success'); + expect(resetAndReloadMock).toHaveBeenCalledTimes(1); + expect(loadingManagerStub.hide).toHaveBeenCalled(); + }); + + it('posts a Civitai URL without a source key so backend defaults apply', async () => { + document.body.innerHTML = ` +
+ + `; + + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true }), + })); + + const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js'); + const contextMenu = new LoraContextMenu(); + + const card = document.createElement('div'); + card.className = 'model-card'; + card.dataset.filepath = '/models/test.safetensors'; + document.body.appendChild(card); + + contextMenu.showMenu(100, 100, card); + contextMenu.showRelinkCivitaiModal(); + + document.getElementById('civitaiModelUrl').value = 'https://civitai.com/models/65423?modelVersionId=777'; + await contextMenu._boundRelinkHandler(); + await flushAsyncTasks(); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: '/models/test.safetensors', + model_id: '65423', + model_version_id: '777', + }), + }); + expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.relinkSuccess', {}, 'success'); + }); + + it('derives relink endpoint prefixes for all model types', async () => { + document.body.innerHTML = ` +
+ `; + + const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js'); + const contextMenu = new LoraContextMenu(); + + contextMenu.modelType = 'lora'; + expect(contextMenu.getModelTypePrefix()).toBe('loras'); + contextMenu.modelType = 'checkpoint'; + expect(contextMenu.getModelTypePrefix()).toBe('checkpoints'); + contextMenu.modelType = 'embedding'; + expect(contextMenu.getModelTypePrefix()).toBe('embeddings'); + contextMenu.modelType = 'unknown'; + expect(contextMenu.getModelTypePrefix()).toBe('loras'); + }); }); diff --git a/tests/frontend/components/modelContextMenuMixin.test.js b/tests/frontend/components/modelContextMenuMixin.test.js index 00736ed1..7147e59a 100644 --- a/tests/frontend/components/modelContextMenuMixin.test.js +++ b/tests/frontend/components/modelContextMenuMixin.test.js @@ -2,20 +2,15 @@ import { describe, expect, it } from 'vitest'; import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js'; -describe('ModelContextMenuMixin.extractModelVersionId', () => { - it('accepts civitai.red model URLs', () => { - expect( - ModelContextMenuMixin.extractModelVersionId( - 'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777' - ) - ).toEqual({ modelId: '65423', modelVersionId: '777' }); +describe('ModelContextMenuMixin.getModelTypePrefix', () => { + it('maps every known model type to its API route prefix', () => { + expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'lora' })).toBe('loras'); + expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'checkpoint' })).toBe('checkpoints'); + expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'embedding' })).toBe('embeddings'); }); - it('rejects model-like URLs from unsupported hosts', () => { - expect( - ModelContextMenuMixin.extractModelVersionId( - 'https://example.com/models/65423?modelVersionId=777' - ) - ).toEqual({ modelId: null, modelVersionId: null }); + it('falls back to the loras prefix for unknown types', () => { + expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'unknown' })).toBe('loras'); + expect(ModelContextMenuMixin.getModelTypePrefix.call({})).toBe('loras'); }); }); diff --git a/tests/frontend/regression/modalRegistry.test.js b/tests/frontend/regression/modalRegistry.test.js new file mode 100644 index 00000000..676c9806 --- /dev/null +++ b/tests/frontend/regression/modalRegistry.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import path from 'path'; + +// Regression guard: every `.modal` element shipped via components/modals.html +// must be registered in ModalManager.initialize(). An unregistered modal makes +// modalManager.showModal(id) silently no-op (see getModal returning undefined), +// which manifests as "clicking the menu item does nothing" with no console +// error — exactly the Link-to-CivitArchive bug this file guards against. +describe('ModalManager registry parity', () => { + const repoRoot = path.resolve(__dirname, '../../..'); + const modalsHtml = readFileSync( + path.join(repoRoot, 'templates/components/modals.html'), + 'utf-8' + ); + const modalManagerSrc = readFileSync( + path.join(repoRoot, 'static/js/managers/ModalManager.js'), + 'utf-8' + ); + + const collectModalIds = (target, seen = new Set()) => { + if (statSync(target).isFile()) { + extractIds(readFileSync(target, 'utf-8'), seen); + return seen; + } + for (const entry of readdirSync(target, { withFileTypes: true })) { + collectModalIds(path.join(target, entry.name), seen); + } + return seen; + }; + + const extractIds = (content, seen) => { + for (const match of content.matchAll(/id="([A-Za-z][\w-]*)"[^>]*class="modal"/g)) { + seen.add(match[1]); + } + }; + + it('registers every modal declared in templates', () => { + const includeFiles = [ + ...modalsHtml.matchAll(/\{%\s*include\s*'([^']+\.html)'\s*%\}/g), + ].map((m) => m[1]); + + expect(includeFiles.length).toBeGreaterThan(0); + + const declaredIds = new Set(); + for (const relPath of includeFiles) { + collectModalIds(path.join(repoRoot, 'templates', relPath), declaredIds); + } + + expect(declaredIds.size).toBeGreaterThan(0); + + const unregistered = [...declaredIds].filter( + (id) => !modalManagerSrc.includes(`registerModal('${id}'`) + ); + + expect( + unregistered, + 'Modal ids rendered on pages but never registered in ModalManager.initialize() — showModal() will silently do nothing for them' + ).toEqual([]); + }); +}); diff --git a/tests/frontend/utils/civitaiUtils.test.js b/tests/frontend/utils/civitaiUtils.test.js index 11ec2c7e..d1e99d53 100644 --- a/tests/frontend/utils/civitaiUtils.test.js +++ b/tests/frontend/utils/civitaiUtils.test.js @@ -11,6 +11,7 @@ import { getThumbnailUrl, extractCivitaiImageId, extractCivitaiModelUrlParts, + classifyModelRelinkUrl, isCivitaiUrl, isSupportedCivitaiPageHost, OptimizationMode @@ -305,4 +306,48 @@ describe('civitaiUtils', () => { expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null); }); }); + + describe('classifyModelRelinkUrl', () => { + it('classifies civitai.com model URLs', () => { + expect( + classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676') + ).toEqual({ source: 'civitai', modelId: '649516', modelVersionId: '726676' }); + }); + + it('classifies civitai.red model URLs without a version id', () => { + expect( + classifyModelRelinkUrl('https://civitai.red/models/65423/') + ).toEqual({ source: 'civitai', modelId: '65423', modelVersionId: null }); + }); + + it('classifies civarchive and civitaiarchive model URLs', () => { + expect( + classifyModelRelinkUrl('https://civarchive.com/models/1746460') + ).toEqual({ source: 'civarchive', modelId: '1746460', modelVersionId: null }); + expect( + classifyModelRelinkUrl('http://www.civitaiarchive.com/models/42?modelVersionId=43') + ).toEqual({ source: 'civarchive', modelId: '42', modelVersionId: '43' }); + }); + + it('rejects archive hosts when the path has no numeric model id', () => { + expect( + classifyModelRelinkUrl('https://civarchive.com/images/123') + ).toEqual({ source: null, modelId: null, modelVersionId: null }); + }); + + it('rejects unsupported hosts and malformed input', () => { + expect( + classifyModelRelinkUrl('https://example.com/models/65423') + ).toEqual({ source: null, modelId: null, modelVersionId: null }); + expect( + classifyModelRelinkUrl('not a url') + ).toEqual({ source: null, modelId: null, modelVersionId: null }); + expect( + classifyModelRelinkUrl('') + ).toEqual({ source: null, modelId: null, modelVersionId: null }); + expect( + classifyModelRelinkUrl(null) + ).toEqual({ source: null, modelId: null, modelVersionId: null }); + }); + }); }); diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index 0837ae05..5aea007e 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -3,11 +3,16 @@ import json import logging from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock import pytest from py.config import config -from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler +from py.routes.handlers.model_handlers import ( + ModelCivitaiHandler, + ModelManagementHandler, + ModelUpdateHandler, +) from py.services.service_registry import ServiceRegistry from py.utils.metadata_manager import MetadataManager from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord @@ -965,3 +970,110 @@ def test_serialize_version_file_count_defaults_to_none(): ) serialized = ModelUpdateHandler._serialize_version(version, None) assert serialized["fileCount"] is None + + +def _build_relink_handler(metadata_sync): + service = SimpleNamespace( + scanner=SimpleNamespace(update_single_model_cache=AsyncMock()) + ) + return ModelManagementHandler( + service=service, + logger=logging.getLogger(__name__), + metadata_sync=metadata_sync, + preview_service=SimpleNamespace(), + tag_update_service=SimpleNamespace(), + lifecycle_service=SimpleNamespace(), + ) + + +@pytest.mark.asyncio +async def test_relink_civitai_rejects_unsupported_source(): + metadata_sync = SimpleNamespace( + load_local_metadata=AsyncMock(return_value={}), + relink_metadata=AsyncMock(), + ) + handler = _build_relink_handler(metadata_sync) + + request = SimpleNamespace( + json=AsyncMock( + return_value={ + "file_path": "/tmp/model.safetensors", + "model_id": "123", + "model_version_id": "456", + "source": "huggingface", + } + ) + ) + + response = await handler.relink_civitai(request) + assert response.status == 400 + payload = json.loads(response.text) + assert payload["success"] is False + assert "Unsupported relink source" in payload["error"] + metadata_sync.relink_metadata.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_relink_civitai_passes_provider_name_for_civarchive_source(): + metadata_sync = SimpleNamespace( + load_local_metadata=AsyncMock(return_value={"model_name": "Local"}), + relink_metadata=AsyncMock( + return_value={"model_name": "Archived", "sha256": "abc"} + ), + ) + handler = _build_relink_handler(metadata_sync) + + request = SimpleNamespace( + json=AsyncMock( + return_value={ + "file_path": "/tmp/model.safetensors", + "model_id": "123", + "model_version_id": "456", + "source": "civarchive", + } + ) + ) + + response = await handler.relink_civitai(request) + assert response.status == 200 + payload = json.loads(response.text) + assert payload["success"] is True + assert "CivArchive" in payload["message"] + metadata_sync.relink_metadata.assert_awaited_once_with( + file_path="/tmp/model.safetensors", + metadata={"model_name": "Local"}, + model_id=123, + model_version_id=456, + provider_name="civarchive_api", + ) + + +@pytest.mark.asyncio +async def test_relink_civitai_surfaces_provider_unavailable_without_500(): + metadata_sync = SimpleNamespace( + load_local_metadata=AsyncMock(return_value={}), + relink_metadata=AsyncMock( + side_effect=ValueError( + "CivitArchive is not available or not enabled. " + "Enable the CivitArchive API in settings to relink via CivArchive." + ) + ), + ) + handler = _build_relink_handler(metadata_sync) + + request = SimpleNamespace( + json=AsyncMock( + return_value={ + "file_path": "/tmp/model.safetensors", + "model_id": "123", + "model_version_id": None, + "source": "civarchive", + } + ) + ) + + response = await handler.relink_civitai(request) + assert response.status == 400 + payload = json.loads(response.text) + assert payload["success"] is False + assert "CivitArchive" in payload["error"] diff --git a/tests/services/test_metadata_sync_service.py b/tests/services/test_metadata_sync_service.py index 0a360238..813b5deb 100644 --- a/tests/services/test_metadata_sync_service.py +++ b/tests/services/test_metadata_sync_service.py @@ -560,6 +560,131 @@ async def test_relink_metadata_raises_when_version_missing(): model_version_id=None, ) + +@pytest.mark.asyncio +async def test_relink_metadata_uses_named_civarchive_provider(tmp_path): + default_provider = SimpleNamespace( + get_model_by_hash=AsyncMock(), + get_model_version=AsyncMock(), + ) + civarchive_provider = SimpleNamespace( + get_model_by_hash=AsyncMock(), + get_model_version=AsyncMock( + return_value={ + "files": [ + { + "primary": True, + "type": "Model", + "hashes": {"SHA256": "ABCDEF"}, + } + ], + "model": {"name": "Archived"}, + "images": [], + } + ), + ) + + async def select_provider(name: str): + return civarchive_provider if name == "civarchive_api" else default_provider + + provider_selector = AsyncMock(side_effect=select_provider) + helpers = build_service( + default_provider=default_provider, + provider_selector=provider_selector, + ) + + metadata = {"model_name": "Local", "sha256": "original"} + result = await helpers.service.relink_metadata( + file_path=str(tmp_path / "model.safetensors"), + metadata=metadata, + model_id=1, + model_version_id=2, + provider_name="civarchive_api", + ) + + assert result["model_name"] == "Archived" + assert result["sha256"] == "original" + provider_selector.assert_awaited_with("civarchive_api") + civarchive_provider.get_model_version.assert_awaited_once_with(1, 2) + helpers.default_provider_factory.assert_not_awaited() + helpers.metadata_manager.save_metadata.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_relink_metadata_raises_when_version_missing_with_civarchive(): + default_provider = SimpleNamespace( + get_model_by_hash=AsyncMock(), + get_model_version=AsyncMock(), + ) + civarchive_provider = SimpleNamespace( + get_model_by_hash=AsyncMock(), + get_model_version=AsyncMock(return_value=None), + ) + + async def select_provider(name: str): + return civarchive_provider if name == "civarchive_api" else default_provider + + provider_selector = AsyncMock(side_effect=select_provider) + helpers = build_service( + default_provider=default_provider, + provider_selector=provider_selector, + ) + + with pytest.raises(ValueError, match="CivitArchive"): + await helpers.service.relink_metadata( + file_path="/tmp/model.safetensors", + metadata={}, + model_id=9, + model_version_id=None, + provider_name="civarchive_api", + ) + + +@pytest.mark.asyncio +async def test_relink_metadata_raises_friendly_error_when_provider_unavailable(): + provider_selector = AsyncMock( + side_effect=ValueError("Provider 'civarchive_api' is not registered") + ) + helpers = build_service(provider_selector=provider_selector) + + with pytest.raises(ValueError, match="CivitArchive is not available or not enabled"): + await helpers.service.relink_metadata( + file_path="/tmp/model.safetensors", + metadata={}, + model_id=9, + model_version_id=None, + provider_name="civarchive_api", + ) + + +@pytest.mark.asyncio +async def test_relink_metadata_default_call_uses_default_provider_factory(tmp_path): + helpers = build_service() + helpers.default_provider.get_model_version.return_value = { + "files": [ + { + "primary": True, + "type": "Model", + "hashes": {"SHA256": "ABCDEF"}, + } + ], + "model": {"name": "Remote"}, + "images": [], + } + + result = await helpers.service.relink_metadata( + file_path=str(tmp_path / "model.safetensors"), + metadata={"model_name": "Local", "sha256": "original"}, + model_id=1, + model_version_id=None, + ) + + assert result["model_name"] == "Remote" + assert result["sha256"] == "original" + helpers.default_provider_factory.assert_awaited_once() + helpers.provider_selector.assert_not_awaited() + helpers.metadata_manager.save_metadata.assert_awaited_once() + @pytest.mark.asyncio async def test_fetch_and_update_model_persists_db_checked_when_sqlite_fails(tmp_path): """