diff --git a/AGENTS.md b/AGENTS.md index e9b3a604..7e4543cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,10 @@ The system runs in two modes: - Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc. - Request handlers in `py/routes/handlers/` implement route logic - All routes use aiohttp, return `web.json_response` or `web.Response` +- Endpoints consumed by the companion browser extension (lm-civitai-extension) + MUST also accept `GET` with query-string params: the extension is GET-only by + convention (see its AGENTS.md), even for state-changing operations such as + `GET /api/lm/recipe/{recipe_id}/reimport` ### Recipe System diff --git a/docs/recipe-civitai-image-no-metadata.md b/docs/recipe-civitai-image-no-metadata.md new file mode 100644 index 00000000..d8dcac41 --- /dev/null +++ b/docs/recipe-civitai-image-no-metadata.md @@ -0,0 +1,58 @@ +# CivitAI image imports can end up with 0 LoRAs + +## Symptom + +Importing a CivitAI image URL can produce a recipe with **zero LoRA +entries**, even though the image page lists LoRAs in its resource panel. + +Reported example: `https://civitai.red/images/140818889` was imported as a +local recipe with 0 LoRAs, while the page shows 3 LoRAs. Some images (e.g. +NSFW / higher browsing level) additionally require a login to view, so their +data is not publicly reachable at all. + +## Root cause + +URL imports use only two data sources: + +1. **CivitAI REST image API** — `GET /api/v1/images?imageId=&nsfw=X&withMeta=true` → `meta` +2. **Embedded image metadata** — EXIF/XMP read from the downloaded bytes + +For the same image both sources can be empty, and the one source that does +contain the data is never queried. Verified for image 140818889: + +| Source | What it returned | +|---|---| +| REST image API | `meta` holds only a prompt; `modelVersionIds: []`; no `resources`/`hashes`; `baseModel: null` | +| Downloaded image | PNG with **no EXIF/XMP** (the CDN URL ends in `.jpeg`, the body is PNG) | +| Image page HTML | `__NEXT_DATA__` embeds the trpc `image.getGenerationData` result → full `resources` list: 3 LoRAs, each with `modelId`, `modelVersionId`, `modelName`, `modelType`, `versionName`, `baseModel` | + +Key points: + +- The page's resource panel is fed by an **internal, non-public trpc + endpoint**, not by the public REST image API. +- That internal endpoint is **login-gated** for some content — the + "requires login" symptom. +- Even with the version IDs in hand, `/model-versions/{id}` for these + (Krea) versions returns **no `sha256`**, so an exact local-file hash match + is impossible; only model/version identity is recoverable. + +## Conclusion / status + +0-LoRA imports are a data-source gap: public REST meta and image EXIF are +both empty, while the only complete source (page generation data) is +internal, sometimes login-gated, and not used by the importer. + +Such imports **cannot be reliably auto-repaired/completed** by the backend +alone. The old "Repair Metadata" feature only re-fetched the same incomplete +REST meta and could not fix them; it was deprecated and has been removed. + +**Fixed via the companion browser extension.** When the extension is +installed with a valid license, it scrapes the image page's internal trpc +generation data with the user's session and calls the payload-capable +re-import endpoint (`POST /api/lm/recipe/{recipe_id}/reimport` with +`image_url`/`name`/`resources`/`gen_params`/`base_model`/`tags` query +params), which rebuilds the recipe from the caller-supplied metadata. The +web UI delegates re-import of CivitAI-image-sourced recipes to the extension +automatically (probe + `lm:reimport*` DOM events); without the extension, +re-import silently falls back to the native path, which remains limited by +the data-source gap documented above. diff --git a/locales/de.json b/locales/de.json index 886eedc2..70b74fbf 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich", "rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}", "reimporting": "Rezept wird aus Quelle neu importiert...", + "reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...", "reimportSuccess": "Rezept erfolgreich neu importiert", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen", diff --git a/locales/en.json b/locales/en.json index cc94991f..ad1bd3c7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "No rematch needed for any of the {total} selected recipes", "rematchFailed": "Failed to rematch selected recipes: {message}", "reimporting": "Re-importing recipe from source...", + "reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...", "reimportSuccess": "Recipe re-imported successfully", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkFailed": "Failed to re-import some recipes", diff --git a/locales/es.json b/locales/es.json index 0441d33b..52e64c39 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación", "rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}", "reimporting": "Reimportando receta desde origen...", + "reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...", "reimportSuccess": "Receta reimportada exitosamente", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkFailed": "Error al reimportar algunas recetas", diff --git a/locales/fr.json b/locales/fr.json index 05a5346b..247ff456 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation", "rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}", "reimporting": "Ré-import de la Recipe depuis la source...", + "reimportingViaExtension": "Ré-import de la Recipe {current}/{total} via l’extension du navigateur...", "reimportSuccess": "Recette ré-importée avec succès", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkFailed": "Échec du ré-import de certaines Recipes", diff --git a/locales/he.json b/locales/he.json index b39373da..d3ed0634 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו", "rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}", "reimporting": "מייבא מתכון מחדש מהמקור...", + "reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...", "reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל", diff --git a/locales/ja.json b/locales/ja.json index 3cbeab33..2a0a76c0 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした", "rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}", "reimporting": "ソースからレシピを再インポート中...", + "reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...", "reimportSuccess": "レシピの再インポートが完了しました", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkFailed": "一部のレシピの再インポートに失敗しました", diff --git a/locales/ko.json b/locales/ko.json index c9865b10..f1eac0d0 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다", "rematchFailed": "선택한 레시피 재매칭 실패: {message}", "reimporting": "소스에서 레시피를 다시 가져오는 중...", + "reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...", "reimportSuccess": "레시피를 다시 가져왔습니다", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다", diff --git a/locales/ru.json b/locales/ru.json index 8d48840c..ac04450e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления", "rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}", "reimporting": "Переимпорт рецепта из источника...", + "reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...", "reimportSuccess": "Рецепт успешно переимпортирован", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 50712a5b..dcfc3ec9 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "{total} 个所选配方均无需重新匹配", "rematchFailed": "重新匹配所选配方失败:{message}", "reimporting": "正在从源重新导入配方...", + "reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...", "reimportSuccess": "配方已从源重新导入成功", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkFailed": "重新导入某些配方失败", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 633e1d59..e98428cd 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2229,6 +2229,7 @@ "rematchSkipped": "{total} 個所選配方均無需重新匹配", "rematchFailed": "重新匹配所選配方失敗:{message}", "reimporting": "正在從來源重新匯入配方...", + "reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...", "reimportSuccess": "配方已從來源重新匯入成功", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkFailed": "重新匯入某些配方失敗", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index e21334eb..a5d9383d 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -1020,12 +1020,55 @@ class RecipeManagementHandler: persisted_source_path=persisted_source_path, ) - async with self._import_semaphore: - import_response = await self._do_import_from_url( - source_path, - recipe_scanner, - target_dir=old_folder, - ) + # Optional caller-supplied metadata payload (companion browser + # extension re-import). Only honored for CivitAI image page + # sources; everything else uses the native URL import below. + params = request.rel_url.query + payload_image_url = params.get("image_url") + payload_name = params.get("name") + payload_resources = params.get("resources") + has_import_payload = bool( + payload_image_url and payload_name and payload_resources + ) + + import_response: web.Response | None = None + if has_import_payload and image_id: + try: + async with self._import_semaphore: + import_response = await self._import_remote_recipe_impl( + image_url=payload_image_url, + name=payload_name, + resources_raw=payload_resources, + gen_params_raw=params.get("gen_params"), + tags_raw=params.get("tags"), + base_model=params.get("base_model", "") or "", + source_path=source_path, + target_dir=old_folder, + ) + except RecipeValidationError as exc: + # Malformed resources/gen_params JSON: treat as "no + # payload" and use the legacy URL re-import. + self._logger.warning( + "Ignoring malformed re-import payload for recipe %s " + "(%s); falling back to source URL re-import", + recipe_id, + exc, + ) + except Exception as exc: + self._logger.warning( + "Payload-based re-import failed for recipe %s: %s; " + "falling back to source URL re-import", + recipe_id, + exc, + ) + + if import_response is None: + async with self._import_semaphore: + import_response = await self._do_import_from_url( + source_path, + recipe_scanner, + target_dir=old_folder, + ) await self._persistence_service.delete_recipe( recipe_scanner=recipe_scanner, recipe_id=recipe_id @@ -1052,14 +1095,19 @@ class RecipeManagementHandler: exc, ) - return web.json_response( - { - "success": True, - "old_recipe_id": recipe_id, - "recipe_id": new_recipe_id, - "source_path": source_path, - } + response_body: Dict[str, Any] = { + "success": True, + "old_recipe_id": recipe_id, + "recipe_id": new_recipe_id, + "source_path": source_path, + } + loras_count = await self._count_recipe_loras( + recipe_scanner, new_recipe_id ) + if loras_count is not None: + response_body["loras_count"] = loras_count + + return web.json_response(response_body) except RecipeNotFoundError as exc: return web.json_response({"success": False, "error": str(exc)}, status=404) except RecipeValidationError as exc: @@ -1092,31 +1140,14 @@ class RecipeManagementHandler: if not resources_raw: raise RecipeValidationError("Missing required field: resources") - checkpoint_entry, lora_entries = self._parse_resources_payload( - resources_raw - ) - gen_params_request = self._parse_gen_params(params.get("gen_params")) - - self._logger.info( - "Remote recipe import received: url=%s, lora_count=%d", - image_url, - len(lora_entries), - ) - self._logger.debug( - " gen_params_keys=%s, checkpoint_keys=%s", - sorted(gen_params_request.keys()) if gen_params_request else [], - sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [], - ) - # Throttle concurrent imports to avoid starving ComfyUI's event loop async with self._import_semaphore: - return await self._do_import_remote_recipe( + return await self._import_remote_recipe_impl( image_url=image_url, name=name, - lora_entries=lora_entries, - checkpoint_entry=checkpoint_entry, - gen_params_request=gen_params_request, - tags=self._parse_tags(params.get("tags")), + resources_raw=resources_raw, + gen_params_raw=params.get("gen_params"), + tags_raw=params.get("tags"), base_model=params.get("base_model", "") or "", source_path=params.get("source_path") or image_url, ) @@ -1130,6 +1161,52 @@ class RecipeManagementHandler: ) return web.json_response({"error": str(exc)}, status=500) + async def _import_remote_recipe_impl( + self, + *, + image_url: str, + name: str, + resources_raw: str, + gen_params_raw: Optional[str], + tags_raw: Optional[str], + base_model: str, + source_path: str, + target_dir: str | None = None, + ) -> web.Response: + """Payload-based remote import engine shared by import-remote and the + extension-driven re-import path. + + Parses the caller-supplied payloads and delegates to + :meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on + malformed payloads so callers can decide how to handle them (the + re-import path falls back to the legacy URL import). + """ + checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw) + gen_params_request = self._parse_gen_params(gen_params_raw) + + self._logger.info( + "Remote recipe import received: url=%s, lora_count=%d", + image_url, + len(lora_entries), + ) + self._logger.debug( + " gen_params_keys=%s, checkpoint_keys=%s", + sorted(gen_params_request.keys()) if gen_params_request else [], + sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [], + ) + + return await self._do_import_remote_recipe( + image_url=image_url, + name=name, + lora_entries=lora_entries, + checkpoint_entry=checkpoint_entry, + gen_params_request=gen_params_request, + tags=self._parse_tags(tags_raw), + base_model=base_model, + source_path=source_path, + target_dir=target_dir, + ) + async def _do_import_remote_recipe( self, *, @@ -1141,6 +1218,7 @@ class RecipeManagementHandler: tags: list[Any], base_model: str, source_path: str, + target_dir: str | None = None, ) -> web.Response: recipe_scanner = self._recipe_scanner_getter() if recipe_scanner is None: @@ -1304,6 +1382,7 @@ class RecipeManagementHandler: tags=tags, metadata=metadata, extension=extension, + target_dir=target_dir, ) return web.json_response(result.payload, status=result.status) @@ -1768,6 +1847,25 @@ class RecipeManagementHandler: return [] return [tag.strip() for tag in tag_text.split(",") if tag.strip()] + async def _count_recipe_loras( + self, recipe_scanner: Any, recipe_id: Optional[str] + ) -> Optional[int]: + """Best-effort LoRA count for a freshly saved recipe (for the + re-import response). Returns None when the recipe cannot be read.""" + if not recipe_id: + return None + try: + recipe = await recipe_scanner.get_recipe_by_id(recipe_id) + except Exception as exc: + self._logger.debug( + "Could not read new recipe %s for loras_count: %s", + recipe_id, + exc, + ) + return None + loras = (recipe or {}).get("loras") + return len(loras) if isinstance(loras, list) else None + def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]: if payload is None: return None diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 52df45a0..f58ecb9d 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -110,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" ), + # The companion browser extension only ever issues GET requests, so the + # payload-based re-import variant must also be reachable via GET. + RouteDefinition( + "GET", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" + ), RouteDefinition( "POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow" ), diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 53ce06ee..4ad965d0 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { state } from '../../state/index.js'; import { moveManager } from '../../managers/MoveManager.js'; +import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js'; export class RecipeContextMenu extends BaseContextMenu { constructor() { @@ -355,6 +356,24 @@ export class RecipeContextMenu extends BaseContextMenu { return; } + // Recipes imported from a CivitAI image page can carry incomplete + // metadata (0 LoRAs); the companion browser extension can re-import + // them with the full page data. Fall back to the native path whenever + // the extension is absent, unlicensed, or the delegation fails. + const recipeItem = state.virtualScroller?.items?.find(item => item?.id === recipeId); + const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path); + if (civitaiImage) { + try { + const probe = await probeExtension(); + if (probe?.supported && probe?.licenseValid) { + await this.reimportViaExtension(recipeId, civitaiImage, recipeItem?.title || ''); + return; + } + } catch (error) { + console.warn('Extension re-import unavailable, using native path:', error); + } + } + state.loadingManager.showSimpleLoading('Re-importing recipe from source...'); try { @@ -377,6 +396,34 @@ export class RecipeContextMenu extends BaseContextMenu { showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error'); } } + + // Re-import a single CivitAI-image recipe through the companion browser + // extension. Throws on delegation failure so the caller can fall back to + // the native path. + async reimportViaExtension(recipeId, civitaiImage, title) { + state.loadingManager.showSimpleLoading('Re-importing recipe via browser extension...'); + + try { + const { failed } = await delegateReimport([{ + recipeId, + imageId: civitaiImage.imageId, + imageUrl: civitaiImage.imageUrl, + title, + }]); + + state.loadingManager.hide(); + if (failed > 0) { + showToast('recipes.contextMenu.reimport.failed', { message: 'Extension re-import failed' }, 'error'); + } else { + showToast('toast.recipes.reimportSuccess', {}, 'success'); + } + const { resetAndReload } = await import('../../api/recipeApi.js'); + resetAndReload(false, { preserveScroll: false }); + } catch (error) { + state.loadingManager.hide(); + throw error; + } + } } // Mix in shared methods from ModelContextMenuMixin diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index fdcc413b..bbbb12f3 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -10,6 +10,7 @@ import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../componen import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js'; import { eventManager } from '../utils/EventManager.js'; import { translate } from '../utils/i18nHelpers.js'; +import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js'; import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js'; export class BulkManager { @@ -857,17 +858,74 @@ export class BulkManager { `Re-importing recipe 1/${total}...` ); + // Partition the selection: recipes sourced from a CivitAI image page + // can be delegated to the companion browser extension (which scrapes + // the full page metadata); everything else uses the native endpoint. + const delegatable = []; + const nativeFilePaths = []; + for (const filePath of filePaths) { + const recipeItem = recipeMap.get(filePath); + const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path); + if (civitaiImage && recipeItem?.id) { + delegatable.push({ + filePath, + recipeId: recipeItem.id, + imageId: civitaiImage.imageId, + imageUrl: civitaiImage.imageUrl, + title: recipeItem.title || '', + }); + } else { + nativeFilePaths.push(filePath); + } + } + + // Probe once; on any probe/delegate failure the delegatable recipes + // fall back to the native sequential loop below. + if (delegatable.length > 0) { + try { + const probe = await probeExtension(); + if (probe?.supported && probe?.licenseValid) { + const batchResult = await delegateReimport( + delegatable.map(({ recipeId, imageId, imageUrl, title }) => ({ + recipeId, imageId, imageUrl, title, + })), + { + onProgress: (progress) => { + progressUI.updateProgress( + Math.floor(((progress.current || 0) / total) * 100), + progress.title || '', + translate('toast.recipes.reimportingViaExtension', { + current: progress.current || 0, + total, + }) + ); + }, + } + ); + completed += batchResult.completed; + failed += batchResult.failed; + } else { + nativeFilePaths.push(...delegatable.map(entry => entry.filePath)); + } + } catch (error) { + console.warn('[reimportSelectedRecipes] extension delegation failed, using native path:', error); + nativeFilePaths.push(...delegatable.map(entry => entry.filePath)); + } + } + try { - for (let i = 0; i < filePaths.length; i++) { - const filePath = filePaths[i]; + const processedBeforeNative = completed + failed; + for (let i = 0; i < nativeFilePaths.length; i++) { + const filePath = nativeFilePaths[i]; const recipeItem = recipeMap.get(filePath); const recipeId = recipeItem?.id; const recipeName = recipeItem?.title || recipeId || 'Unknown'; + const processed = processedBeforeNative + i; progressUI.updateProgress( - Math.floor((i / total) * 100), + Math.floor((processed / total) * 100), recipeName, - `Re-importing recipe ${Math.min(i + 1, total)}/${total}...` + `Re-importing recipe ${Math.min(processed + 1, total)}/${total}...` ); if (!recipeId) { diff --git a/static/js/utils/extensionReimportBridge.js b/static/js/utils/extensionReimportBridge.js new file mode 100644 index 00000000..3cc2e741 --- /dev/null +++ b/static/js/utils/extensionReimportBridge.js @@ -0,0 +1,220 @@ +/** + * Bridge to the companion LoRA Manager browser extension. + * + * The extension can re-import recipes sourced from CivitAI image pages with + * the complete page metadata (internal trpc data scraped with the user's + * session), fixing recipes that the native import (REST API + EXIF only) + * saved with 0 LoRAs. + * + * Protocol: DOM CustomEvents on `document`; `detail` is ALWAYS a JSON + * string on both sides. + * + * LM page -> extension: `lm:reimportProbe`, detail `{}`. + * extension -> LM page: `lm:reimportProbeResult`, + * detail `{supported, licenseValid, extensionVersion?, reason?}`. + * LM page -> extension: `lm:reimportViaExtension`, + * detail `{requestId, recipes: [{recipeId, imageId, imageUrl, title}]}`. + * extension -> LM page: `lm:reimportProgress`, + * detail `{requestId, current, total, recipeId, title, status, message?}`. + * extension -> LM page: `lm:reimportBatchDone`, + * detail `{requestId, completed, failed}`. + */ + +const PROBE_EVENT = 'lm:reimportProbe'; +const PROBE_RESULT_EVENT = 'lm:reimportProbeResult'; +const REIMPORT_EVENT = 'lm:reimportViaExtension'; +const PROGRESS_EVENT = 'lm:reimportProgress'; +const BATCH_DONE_EVENT = 'lm:reimportBatchDone'; + +const DEFAULT_PROBE_TIMEOUT_MS = 500; +// Generous batch timeout; any progress event resets it (heartbeat). +const DEFAULT_REIMPORT_TIMEOUT_MS = 3 * 60 * 1000; + +// Mirrors py/utils/civitai_utils.py (_SUPPORTED_CIVITAI_PAGE_HOSTS). +const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([ + 'civitai.com', + 'civitai.red', + 'civitai.green', +]); + +/** + * Parse the JSON-string `detail` of a protocol event. + * @param {CustomEvent} event + * @returns {object|null} Parsed detail object, or null when absent/invalid. + */ +function parseDetail(event) { + try { + const detail = JSON.parse(event?.detail ?? 'null'); + return detail && typeof detail === 'object' ? detail : null; + } catch { + return null; + } +} + +/** + * Dispatch a protocol event with a JSON-stringified detail. + * @param {string} type - Event name. + * @param {object} payload - Detail payload (JSON-stringified). + */ +function dispatchProtocolEvent(type, payload) { + document.dispatchEvent( + new CustomEvent(type, { detail: JSON.stringify(payload ?? {}) }) + ); +} + +/** + * Generate a correlation id for a re-import batch. + * @returns {string} + */ +function generateRequestId() { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + return `lm-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Probe whether the companion extension is installed and usable. + * + * @param {{timeoutMs?: number}} [options] + * @returns {Promise<{supported: boolean, licenseValid: boolean, extensionVersion?: string, reason?: string}|null>} + * Resolves with the probe result, or null when the extension is absent or + * too old to answer (timeout). + */ +export function probeExtension({ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) { + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => finish(null), timeoutMs); + + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + document.removeEventListener(PROBE_RESULT_EVENT, onResult); + resolve(value); + }; + + const onResult = (event) => { + const detail = parseDetail(event); + if (!detail) return; + finish({ + supported: Boolean(detail.supported), + licenseValid: Boolean(detail.licenseValid), + extensionVersion: detail.extensionVersion, + reason: detail.reason, + }); + }; + + document.addEventListener(PROBE_RESULT_EVENT, onResult); + dispatchProtocolEvent(PROBE_EVENT, {}); + }); +} + +/** + * Delegate a batch of recipe re-imports to the companion extension. + * + * @param {Array<{recipeId: string, imageId: number, imageUrl: string, title: string}>} recipes + * @param {{onProgress?: (progress: object) => void, timeoutMs?: number}} [options] + * @returns {Promise<{completed: number, failed: number}>} Resolves on + * `lm:reimportBatchDone`; rejects on timeout. Listeners are cleaned up in + * all outcomes. + */ +export function delegateReimport(recipes, { onProgress, timeoutMs = DEFAULT_REIMPORT_TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + if (!Array.isArray(recipes) || recipes.length === 0) { + reject(new Error('delegateReimport requires a non-empty recipe list')); + return; + } + + const requestId = generateRequestId(); + let settled = false; + let timer = null; + + const cleanup = () => { + clearTimeout(timer); + document.removeEventListener(PROGRESS_EVENT, onProgressEvent); + document.removeEventListener(BATCH_DONE_EVENT, onBatchDone); + }; + const succeed = (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const armTimer = () => { + clearTimeout(timer); + timer = setTimeout( + () => fail(new Error('Extension re-import timed out')), + timeoutMs + ); + }; + + const onProgressEvent = (event) => { + const detail = parseDetail(event); + if (!detail || detail.requestId !== requestId) return; + // Heartbeat: any progress for this batch resets the timeout. + armTimer(); + if (typeof onProgress === 'function') { + try { + onProgress(detail); + } catch (error) { + console.error('[extensionReimportBridge] onProgress callback failed:', error); + } + } + }; + const onBatchDone = (event) => { + const detail = parseDetail(event); + if (!detail || detail.requestId !== requestId) return; + succeed({ + completed: Number.isInteger(detail.completed) ? detail.completed : 0, + failed: Number.isInteger(detail.failed) ? detail.failed : 0, + }); + }; + + document.addEventListener(PROGRESS_EVENT, onProgressEvent); + document.addEventListener(BATCH_DONE_EVENT, onBatchDone); + armTimer(); + dispatchProtocolEvent(REIMPORT_EVENT, { requestId, recipes }); + }); +} + +/** + * Extract CivitAI image page info from a recipe source_path. + * Mirrors py/utils/civitai_utils.py `extract_civitai_image_id`. + * + * @param {string|null} sourcePath - Recipe source_path. + * @returns {{imageId: number, imageUrl: string}|null} Null when the path is + * not a `/images/` URL on civitai.com/.red/.green. + */ +export function getCivitaiImageInfo(sourcePath) { + if (!sourcePath || typeof sourcePath !== 'string') { + return null; + } + + let parsed; + try { + parsed = new URL(sourcePath); + } catch { + return null; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + if (!SUPPORTED_CIVITAI_PAGE_HOSTS.has(parsed.hostname.toLowerCase())) { + return null; + } + + const pathMatch = parsed.pathname.match(/\/images\/(\d+)/); + if (!pathMatch) { + return null; + } + + return { imageId: Number(pathMatch[1]), imageUrl: sourcePath }; +} diff --git a/tests/frontend/components/recipeContextMenu.reimport.test.js b/tests/frontend/components/recipeContextMenu.reimport.test.js new file mode 100644 index 00000000..8e644567 --- /dev/null +++ b/tests/frontend/components/recipeContextMenu.reimport.test.js @@ -0,0 +1,181 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const showSimpleLoadingMock = vi.fn(); +const hideLoadingMock = vi.fn(); +const resetAndReloadMock = vi.fn(); +const probeExtensionMock = vi.fn(); +const delegateReimportMock = vi.fn(); + +const stateStub = { + virtualScroller: { items: [] }, + loadingManager: { + showSimpleLoading: showSimpleLoadingMock, + hide: hideLoadingMock, + }, +}; + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + updateRecipeMetadata: vi.fn(), + resetAndReload: resetAndReloadMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/managers/MoveManager.js', () => ({ + moveManager: { showMoveModal: vi.fn() }, +})); + +vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({ + ModelContextMenuMixin: { + handleCommonMenuActions: vi.fn(() => false), + initNSFWSelector: vi.fn(), + }, +})); + +// Keep the real getCivitaiImageInfo (gating logic under test); mock only the +// extension communication. +vi.mock('../../../static/js/utils/extensionReimportBridge.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeExtension: probeExtensionMock, + delegateReimport: delegateReimportMock, + }; +}); + +describe('RecipeContextMenu.reimportRecipe extension delegation', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ` + + `; + stateStub.virtualScroller.items = [ + { + id: 'recipe-1', + file_path: '/recipes/recipe-1.webp', + title: 'Civitai Recipe', + source_path: 'https://civitai.com/images/12345', + }, + { + id: 'recipe-2', + file_path: '/recipes/recipe-2.webp', + title: 'Local Recipe', + source_path: '/data/imports/local.png', + }, + ]; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, recipe_id: 'new-id', loras_count: 2 }), + }); + }); + + afterEach(() => { + delete global.fetch; + }); + + async function createMenu() { + const { RecipeContextMenu } = await import( + '../../../static/js/components/ContextMenu/RecipeContextMenu.js' + ); + return new RecipeContextMenu(); + } + + it('delegates to the extension for a CivitAI image source when licensed', async () => { + probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true }); + delegateReimportMock.mockResolvedValue({ completed: 1, failed: 0 }); + + const menu = await createMenu(); + await menu.reimportRecipe('recipe-1'); + + expect(delegateReimportMock).toHaveBeenCalledWith([{ + recipeId: 'recipe-1', + imageId: 12345, + imageUrl: 'https://civitai.com/images/12345', + title: 'Civitai Recipe', + }]); + expect(global.fetch).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success'); + expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false }); + }); + + it('shows the failure toast when the extension reports failures', async () => { + probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true }); + delegateReimportMock.mockResolvedValue({ completed: 0, failed: 1 }); + + const menu = await createMenu(); + await menu.reimportRecipe('recipe-1'); + + expect(showToastMock).toHaveBeenCalledWith( + 'recipes.contextMenu.reimport.failed', + { message: 'Extension re-import failed' }, + 'error' + ); + expect(global.fetch).not.toHaveBeenCalled(); + expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false }); + }); + + it('uses the native path for non-CivitAI sources without probing', async () => { + const menu = await createMenu(); + await menu.reimportRecipe('recipe-2'); + + expect(probeExtensionMock).not.toHaveBeenCalled(); + expect(delegateReimportMock).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-2/reimport', { + method: 'POST', + }); + expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success'); + }); + + it('uses the native path when the extension is absent (probe timeout)', async () => { + probeExtensionMock.mockResolvedValue(null); + + const menu = await createMenu(); + await menu.reimportRecipe('recipe-1'); + + expect(delegateReimportMock).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', { + method: 'POST', + }); + }); + + it('uses the native path when the license is invalid', async () => { + probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: false }); + + const menu = await createMenu(); + await menu.reimportRecipe('recipe-1'); + + expect(delegateReimportMock).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', { + method: 'POST', + }); + }); + + it('falls back to the native path when delegation fails', async () => { + probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true }); + delegateReimportMock.mockRejectedValue(new Error('Extension re-import timed out')); + + const menu = await createMenu(); + await menu.reimportRecipe('recipe-1'); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', { + method: 'POST', + }); + expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success'); + }); +}); diff --git a/tests/frontend/utils/extensionReimportBridge.test.js b/tests/frontend/utils/extensionReimportBridge.test.js new file mode 100644 index 00000000..833dc2a0 --- /dev/null +++ b/tests/frontend/utils/extensionReimportBridge.test.js @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { + probeExtension, + delegateReimport, + getCivitaiImageInfo, +} from '../../../static/js/utils/extensionReimportBridge.js'; + +const dispatchedEvents = []; + +function dispatchProtocolEvent(type, payload) { + document.dispatchEvent( + new CustomEvent(type, { detail: JSON.stringify(payload) }) + ); +} + +// Installs a fake extension that answers probes with the given result. +function installProbeResponder(result) { + const listener = () => dispatchProtocolEvent('lm:reimportProbeResult', result); + document.addEventListener('lm:reimportProbe', listener); + return () => document.removeEventListener('lm:reimportProbe', listener); +} + +afterEach(() => { + dispatchedEvents.length = 0; +}); + +describe('probeExtension', () => { + it('resolves null when no extension answers within the timeout', async () => { + const result = await probeExtension({ timeoutMs: 20 }); + expect(result).toBeNull(); + }); + + it('resolves the probe result when the extension answers', async () => { + const uninstall = installProbeResponder({ + supported: true, + licenseValid: true, + extensionVersion: '1.2.3', + }); + try { + const result = await probeExtension({ timeoutMs: 1000 }); + expect(result).toEqual({ + supported: true, + licenseValid: true, + extensionVersion: '1.2.3', + reason: undefined, + }); + } finally { + uninstall(); + } + }); + + it('reports unsupported/unlicensed answers verbatim', async () => { + const uninstall = installProbeResponder({ + supported: false, + licenseValid: false, + reason: 'license expired', + }); + try { + const result = await probeExtension({ timeoutMs: 1000 }); + expect(result.supported).toBe(false); + expect(result.licenseValid).toBe(false); + expect(result.reason).toBe('license expired'); + } finally { + uninstall(); + } + }); + + it('ignores malformed probe results and times out', async () => { + const listener = () => { + document.dispatchEvent( + new CustomEvent('lm:reimportProbeResult', { detail: '{broken json' }) + ); + }; + document.addEventListener('lm:reimportProbe', listener); + try { + const result = await probeExtension({ timeoutMs: 20 }); + expect(result).toBeNull(); + } finally { + document.removeEventListener('lm:reimportProbe', listener); + } + }); +}); + +describe('delegateReimport', () => { + const recipes = [ + { recipeId: 'r1', imageId: 123, imageUrl: 'https://civitai.com/images/123', title: 'One' }, + { recipeId: 'r2', imageId: 456, imageUrl: 'https://civitai.com/images/456', title: 'Two' }, + ]; + + it('rejects immediately for an empty recipe list', async () => { + await expect(delegateReimport([])).rejects.toThrow('non-empty'); + }); + + it('rejects on timeout when the extension never answers', async () => { + await expect( + delegateReimport(recipes, { timeoutMs: 20 }) + ).rejects.toThrow('timed out'); + }); + + it('dispatches the batch with a requestId and resolves on batchDone', async () => { + const progressEvents = []; + let seenRequest = null; + + const listener = (event) => { + seenRequest = JSON.parse(event.detail); + const { requestId } = seenRequest; + // Progress for a DIFFERENT batch must be ignored. + dispatchProtocolEvent('lm:reimportProgress', { + requestId: 'other-batch', + current: 99, + total: 99, + recipeId: 'nope', + title: 'nope', + status: 'success', + }); + dispatchProtocolEvent('lm:reimportProgress', { + requestId, + current: 1, + total: 2, + recipeId: 'r1', + title: 'One', + status: 'success', + }); + dispatchProtocolEvent('lm:reimportProgress', { + requestId, + current: 2, + total: 2, + recipeId: 'r2', + title: 'Two', + status: 'failed', + message: 'boom', + }); + dispatchProtocolEvent('lm:reimportBatchDone', { + requestId, + completed: 1, + failed: 1, + }); + }; + document.addEventListener('lm:reimportViaExtension', listener); + try { + const result = await delegateReimport(recipes, { + onProgress: (progress) => progressEvents.push(progress), + timeoutMs: 1000, + }); + + expect(seenRequest.recipes).toEqual(recipes); + expect(typeof seenRequest.requestId).toBe('string'); + expect(seenRequest.requestId.length).toBeGreaterThan(0); + expect(result).toEqual({ completed: 1, failed: 1 }); + // Only this batch's progress events reach the callback. + expect(progressEvents.map((p) => p.recipeId)).toEqual(['r1', 'r2']); + expect(progressEvents[1].status).toBe('failed'); + } finally { + document.removeEventListener('lm:reimportViaExtension', listener); + } + }); + + it('resets the timeout on every progress heartbeat', async () => { + vi.useFakeTimers(); + let requestId = null; + const listener = (event) => { + requestId = JSON.parse(event.detail).requestId; + }; + document.addEventListener('lm:reimportViaExtension', listener); + try { + const promise = delegateReimport(recipes, { timeoutMs: 1000 }); + + // At t=900ms a progress event arrives, pushing the deadline to t=1900ms. + await vi.advanceTimersByTimeAsync(900); + dispatchProtocolEvent('lm:reimportProgress', { + requestId, + current: 1, + total: 2, + recipeId: 'r1', + title: 'One', + status: 'started', + }); + // t=1800ms: past the original deadline, still alive thanks to heartbeat. + await vi.advanceTimersByTimeAsync(900); + dispatchProtocolEvent('lm:reimportBatchDone', { + requestId, + completed: 2, + failed: 0, + }); + + await expect(promise).resolves.toEqual({ completed: 2, failed: 0 }); + } finally { + document.removeEventListener('lm:reimportViaExtension', listener); + vi.useRealTimers(); + } + }); +}); + +describe('getCivitaiImageInfo', () => { + it.each([ + 'https://civitai.com/images/12345', + 'https://civitai.red/images/12345', + 'https://civitai.green/images/12345', + 'https://civitai.com/images/12345?foo=bar', + ])('extracts the image id from %s', (url) => { + expect(getCivitaiImageInfo(url)).toEqual({ imageId: 12345, imageUrl: url }); + }); + + it.each([ + null, + '', + 'not a url', + 'ftp://civitai.com/images/12345', + 'https://civitai.com/models/12345', + 'https://example.com/images/12345', + 'https://image.civitai.com/x/y/original=true/pic.png', + ])('returns null for %s', (url) => { + expect(getCivitaiImageInfo(url)).toBeNull(); + }); +}); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 40e78cab..3629a96a 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -2341,3 +2341,147 @@ async def test_get_recipe_detail_includes_recipe_json_path( assert response.status == 200 payload = await response.json() assert "recipe_json_path" not in payload + + +async def test_reimport_with_extension_payload_uses_payload_path( + monkeypatch, tmp_path: Path +) -> None: + """A re-import carrying the companion extension's metadata payload must + use the payload-based import engine (caller-supplied LoRAs) instead of + the legacy CivitAI image URL import, and report loras_count.""" + provider_calls: list[str | int] = [] + + class Provider: + async def get_model_version_info(self, model_version_id): + provider_calls.append(model_version_id) + return {}, None + + async def fake_get_default_metadata_provider(): + return Provider() + + monkeypatch.setattr( + "py.recipes.enrichment.get_default_metadata_provider", + fake_get_default_metadata_provider, + ) + + async with recipe_harness(monkeypatch, tmp_path) as harness: + old_file = harness.tmp_dir / "recipes" / "sub" / "rec-ext.webp" + harness.scanner.recipes["rec-ext"] = { + "id": "rec-ext", + "title": "Old title", + "file_path": str(old_file), + "tags": ["tag1"], + "source_path": "https://civitai.com/images/12345", + } + harness.civitai.image_info["12345"] = { + "id": 12345, + "url": "https://image.civitai.com/x/y/original=true/pic.png", + "type": "image", + } + harness.persistence.save_result = SimpleNamespace( + payload={"success": True, "recipe_id": "new-rec-ext"}, status=200 + ) + # The freshly saved recipe as the scanner would see it (for loras_count). + harness.scanner.recipes["new-rec-ext"] = { + "id": "new-rec-ext", + "loras": [{"file_name": "Painterly"}], + } + + resources = [ + { + "type": "lora", + "modelId": 20, + "modelVersionId": 44, + "modelName": "Painterly", + "modelVersionName": "v2", + "weight": 0.5, + }, + ] + # The extension only issues GET requests (per its API convention). + response = await harness.client.get( + "/api/lm/recipe/rec-ext/reimport", + params={ + "image_url": "https://civitai.com/images/12345", + "name": "Extension Recipe", + "resources": json.dumps(resources), + "gen_params": json.dumps({"prompt": "from extension"}), + "base_model": "Flux", + }, + ) + payload = await response.json() + + assert response.status == 200 + assert payload["success"] is True + assert payload["old_recipe_id"] == "rec-ext" + assert payload["recipe_id"] == "new-rec-ext" + assert payload["loras_count"] == 1 + + save_call = harness.persistence.save_calls[-1] + # Caller-supplied payload data wins: name, LoRAs, gen params. + assert save_call["name"] == "Extension Recipe" + assert save_call["metadata"]["loras"][0]["file_name"] == "Painterly" + assert save_call["metadata"]["loras"][0]["weight"] == 0.5 + assert save_call["metadata"]["gen_params"]["prompt"] == "from extension" + # Reimport semantics: original source_path and folder are preserved. + assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345" + assert save_call["target_dir"] == str(harness.tmp_dir / "recipes" / "sub") + # The old recipe is deleted and user edits carried over. + assert harness.persistence.delete_calls == ["rec-ext"] + assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec-ext" + assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title" + assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"] + + +async def test_reimport_with_malformed_payload_falls_back_to_legacy( + monkeypatch, tmp_path: Path +) -> None: + """Malformed resources JSON must be treated as "no payload": the legacy + source-URL import runs and the request still succeeds.""" + async def fake_get_default_metadata_provider(): + return SimpleNamespace(get_model_version_info=lambda id: ({}, None)) + + monkeypatch.setattr( + "py.recipes.enrichment.get_default_metadata_provider", + fake_get_default_metadata_provider, + ) + + async with recipe_harness(monkeypatch, tmp_path) as harness: + harness.scanner.recipes["rec-bad"] = { + "id": "rec-bad", + "title": "Broken payload", + "file_path": str(harness.tmp_dir / "recipes" / "rec-bad.webp"), + "tags": [], + "source_path": "https://civitai.com/images/12345", + } + harness.civitai.image_info["12345"] = { + "id": 12345, + "url": "https://image.civitai.com/x/y/original=true/pic.png", + "type": "image", + } + harness.persistence.save_result = SimpleNamespace( + payload={"success": True, "recipe_id": "legacy-new"}, status=200 + ) + harness.scanner.recipes["legacy-new"] = {"id": "legacy-new", "loras": []} + + response = await harness.client.get( + "/api/lm/recipe/rec-bad/reimport", + params={ + "image_url": "https://civitai.com/images/12345", + "name": "Ignored Name", + "resources": "{not valid json", + }, + ) + payload = await response.json() + + assert response.status == 200 + assert payload["success"] is True + assert payload["recipe_id"] == "legacy-new" + assert payload["loras_count"] == 0 + + save_call = harness.persistence.save_calls[-1] + # Legacy URL path: the payload name is ignored and the title is + # derived from the (empty) metadata, and no caller LoRAs are used. + assert save_call["name"] == "Civitai Image 12345" + assert save_call["metadata"]["loras"] == [] + assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345" + assert harness.persistence.delete_calls == ["rec-bad"]