Support re-import for recipes without a source URL

Recipes imported by drag & drop / file-picker record no source_path and
were rejected by re-import. Fall back to the recipe's own saved image,
which still carries the original embedded generation metadata.

Re-import now re-parses that original metadata instead of the appended
recipe JSON block, so parser upgrades produce fresh results. The
already-optimized preview image is kept verbatim: only its WebP EXIF
chunk is rewritten in place to replace the recipe metadata block, and
the recipe JSON is rewritten with the new analysis plus carried-over
user edits.
This commit is contained in:
Will Miao
2026-08-31 10:01:18 +08:00
parent 2a3c632dc5
commit 39e7c1376c
9 changed files with 537 additions and 41 deletions
+79 -1
View File
@@ -197,6 +197,7 @@ class StubAnalysisService:
self.upload_calls: List[bytes] = []
self.remote_calls: List[Optional[str]] = []
self.local_calls: List[Optional[str]] = []
self.local_ignore_recipe_metadata_calls: List[bool] = []
self.result = SimpleNamespace(payload={"loras": []}, status=200)
self._recipe_parser_factory: Any = None
StubAnalysisService.instances.append(self)
@@ -218,11 +219,16 @@ class StubAnalysisService:
return self.result
async def analyze_local_image(
self, *, file_path: Optional[str], recipe_scanner
self,
*,
file_path: Optional[str],
recipe_scanner,
ignore_recipe_metadata: bool = False,
) -> SimpleNamespace: # noqa: D401
if self.raise_for_local:
raise self.raise_for_local
self.local_calls.append(file_path)
self.local_ignore_recipe_metadata_calls.append(ignore_recipe_metadata)
return self.result
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
@@ -257,6 +263,7 @@ class StubPersistenceService:
extension=None,
recipe_id=None,
target_dir=None,
skip_optimize=False,
) -> SimpleNamespace: # noqa: D401
self.save_calls.append(
{
@@ -269,6 +276,7 @@ class StubPersistenceService:
"extension": extension,
"recipe_id": recipe_id,
"target_dir": target_dir,
"skip_optimize": skip_optimize,
}
)
return self.save_result
@@ -2168,3 +2176,73 @@ async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id(
json={},
)
assert response.status == 400
async def test_reimport_without_source_path_falls_back_to_recipe_file(
monkeypatch, tmp_path: Path
) -> None:
"""Drag & drop imports record no source_path; re-import must fall back to
the recipe's own saved image and re-parse ignoring the recipe metadata."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_file = harness.tmp_dir / "recipes" / "rec1.webp"
recipe_file.parent.mkdir(parents=True, exist_ok=True)
recipe_file.write_bytes(b"fake-image")
harness.scanner.recipes["rec1"] = {
"id": "rec1",
"title": "Old title",
"file_path": str(recipe_file),
"tags": ["tag1"],
# no source_path on purpose
}
harness.analysis.result = SimpleNamespace(
payload={
"success": True,
"recipe_id": "new-rec",
"loras": [],
},
status=200,
)
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec"}, status=200
)
response = await harness.client.post("/api/lm/recipe/rec1/reimport")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["old_recipe_id"] == "rec1"
assert payload["recipe_id"] == "new-rec"
# Local analysis is used on the saved image, ignoring recipe metadata.
assert harness.analysis.local_calls == [str(recipe_file)]
assert harness.analysis.local_ignore_recipe_metadata_calls == [True]
# The old recipe is deleted after the fresh save.
assert harness.persistence.delete_calls == ["rec1"]
# 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"
# 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_without_any_source_returns_400(
monkeypatch, tmp_path: Path
) -> None:
"""Recipes with neither source_path nor an accessible image cannot re-import."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.recipes["rec2"] = {
"id": "rec2",
"title": "No source",
"file_path": str(harness.tmp_dir / "recipes" / "missing.webp"),
}
response = await harness.client.post("/api/lm/recipe/rec2/reimport")
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert harness.analysis.local_calls == []
assert harness.persistence.delete_calls == []