diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index e990ee3c..9732b404 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -1128,15 +1128,21 @@ class RecipeManagementHandler: image_id = extract_civitai_image_id(source_path) if source_path else None # Local re-import sources: an explicit local source_path, or — when - # no source_path was recorded (drag & drop / file-picker imports) — - # the recipe's own saved image, which still carries the original + # no usable source_path was recorded (drag & drop / file-picker + # imports, or a dangling path left by an earlier re-import) — the + # recipe's own saved image, which still carries the original # embedded generation metadata next to the recipe metadata block. + # In the fallback case nothing is persisted as source_path: the + # recipe's own previous preview is not an external source, and it + # is deleted together with the old recipe below. local_source = None + persisted_source_path = "" if not image_id and source_path and os.path.isfile(source_path): local_source = source_path + persisted_source_path = source_path elif ( not image_id - and not source_path + and not source_path.startswith(("http://", "https://")) and old_file_path and os.path.isfile(old_file_path) ): @@ -1170,6 +1176,7 @@ class RecipeManagementHandler: target_dir=old_folder, user_edits=user_edits, old_title=old_recipe.get("title", ""), + persisted_source_path=persisted_source_path, ) async with self._import_semaphore: @@ -2512,6 +2519,7 @@ class RecipeManagementHandler: target_dir: str | None, user_edits: dict[str, Any], old_title: str, + persisted_source_path: str, ) -> web.Response: """Re-import a recipe from a local image file. @@ -2519,6 +2527,12 @@ class RecipeManagementHandler: generation metadata (the appended recipe metadata block is ignored so the current parser gets a fresh pass), saves a new recipe, then deletes the old one. + + ``persisted_source_path`` is the source_path recorded on the new + recipe: the external source file when one exists, or empty when the + re-import fell back to the recipe's own previous preview image (that + file is deleted with the old recipe, so recording it would leave a + dangling path that blocks future re-imports). """ normalized = os.path.normpath(file_path) if not os.path.isfile(normalized): @@ -2547,7 +2561,7 @@ class RecipeManagementHandler: "base_model": base_model, "loras": loras, "gen_params": gen_params, - "source_path": normalized, + "source_path": persisted_source_path, } if checkpoint: metadata["checkpoint"] = checkpoint @@ -2610,7 +2624,7 @@ class RecipeManagementHandler: "success": True, "old_recipe_id": recipe_id, "recipe_id": new_recipe_id, - "source_path": normalized, + "source_path": persisted_source_path, } ) diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index b2d5846c..12e532ec 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -2222,12 +2222,94 @@ async def test_reimport_without_source_path_falls_back_to_recipe_file( # The already-optimized preview image must be stored verbatim. assert harness.persistence.save_calls[-1]["skip_optimize"] is True assert harness.persistence.save_calls[-1]["image_bytes"] == b"fake-image" + # The fallback source is the recipe's own previous preview, which gets + # deleted with the old recipe — it must not be recorded as source_path. + assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == "" # User edits (title, tags) are carried over to the new recipe. assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec" assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title" assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"] +async def test_reimport_with_dangling_source_path_falls_back_to_recipe_file( + monkeypatch, tmp_path: Path +) -> None: + """A source_path pointing to a deleted file (left by an earlier re-import) + must not block re-import: fall back to the recipe's own saved image and + clear the dangling source_path.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + recipe_file = harness.tmp_dir / "recipes" / "rec3.webp" + recipe_file.parent.mkdir(parents=True, exist_ok=True) + recipe_file.write_bytes(b"fake-image") + + harness.scanner.recipes["rec3"] = { + "id": "rec3", + "title": "Dangling source", + "file_path": str(recipe_file), + "tags": [], + # Dangling local path: the file no longer exists. + "source_path": str(harness.tmp_dir / "recipes" / "deleted.webp"), + } + harness.analysis.result = SimpleNamespace( + payload={"success": True, "recipe_id": "new-rec-3", "loras": []}, + status=200, + ) + harness.persistence.save_result = SimpleNamespace( + payload={"success": True, "recipe_id": "new-rec-3"}, status=200 + ) + + response = await harness.client.post("/api/lm/recipe/rec3/reimport") + payload = await response.json() + + assert response.status == 200 + assert payload["success"] is True + assert payload["recipe_id"] == "new-rec-3" + assert harness.analysis.local_calls == [str(recipe_file)] + assert harness.persistence.delete_calls == ["rec3"] + # The dangling path is not carried over to the new recipe. + assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == "" + + +async def test_reimport_with_accessible_local_source_keeps_source_path( + monkeypatch, tmp_path: Path +) -> None: + """When the recorded source_path is an existing external file, it remains + the source of truth and stays recorded on the new recipe.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + source_file = harness.tmp_dir / "imports" / "original.png" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_bytes(b"original-image") + recipe_file = harness.tmp_dir / "recipes" / "rec4.webp" + recipe_file.parent.mkdir(parents=True, exist_ok=True) + recipe_file.write_bytes(b"fake-image") + + harness.scanner.recipes["rec4"] = { + "id": "rec4", + "title": "External source", + "file_path": str(recipe_file), + "tags": [], + "source_path": str(source_file), + } + harness.analysis.result = SimpleNamespace( + payload={"success": True, "recipe_id": "new-rec-4", "loras": []}, + status=200, + ) + harness.persistence.save_result = SimpleNamespace( + payload={"success": True, "recipe_id": "new-rec-4"}, status=200 + ) + + response = await harness.client.post("/api/lm/recipe/rec4/reimport") + payload = await response.json() + + assert response.status == 200 + assert payload["success"] is True + # The external source file is re-parsed, not the recipe preview. + assert harness.analysis.local_calls == [str(source_file)] + assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == str( + source_file + ) + + async def test_reimport_without_any_source_returns_400( monkeypatch, tmp_path: Path ) -> None: