feat(recipes): add recipe rematch API endpoints

This commit is contained in:
Will Miao
2026-08-09 11:30:50 +08:00
parent b2a1307d23
commit 3001f0f0ef
4 changed files with 468 additions and 0 deletions

View File

@@ -112,6 +112,11 @@ class RecipeHandlerSet:
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk, "repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress, "get_repair_progress": self.management.get_repair_progress,
"rematch_recipes": self.management.rematch_recipes,
"cancel_rematch": self.management.cancel_rematch,
"rematch_recipe": self.management.rematch_recipe,
"rematch_recipes_bulk": self.management.rematch_recipes_bulk,
"get_rematch_progress": self.management.get_rematch_progress,
"start_batch_import": self.batch_import.start_batch_import, "start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress, "get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import, "cancel_batch_import": self.batch_import.cancel_batch_import,
@@ -887,6 +892,159 @@ class RecipeManagementHandler:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True) self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Mutual exclusion: a global rematch cannot start while a rematch
# OR a repair is already running — both mutate recipes under the
# same mutation lock.
if (
self._ws_manager.is_recipe_rematch_running()
or self._ws_manager.is_recipe_repair_running()
):
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
recipe_scanner.reset_cancellation()
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
# Run in background to avoid timeout
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(
f"Error in recipe rematch task: {e}", exc_info=True
)
await self._ws_manager.broadcast_recipe_rematch_progress(
{"status": "error", "error": str(e)}
)
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
self._ws_manager.cleanup_recipe_rematch_progress()
asyncio.create_task(run_rematch())
return web.json_response(
{"success": True, "message": "Recipe rematch started"}
)
except Exception as exc:
self._logger.error("Error starting recipe rematch: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_rematch(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task()
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling recipe rematch: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes_bulk(self, request: web.Request) -> web.Response:
"""Rematch deleted resources for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array. The per-recipe loop is
delegated to the scanner's rematch_recipes_bulk; this handler only
parses the request and returns the scanner's summary.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# A bulk rematch must not queue behind a running global rematch's
# mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
data = await request.json()
recipe_ids = data.get("recipe_ids", [])
if not recipe_ids:
return web.json_response(
{"success": False, "error": "recipe_ids are required"},
status=400,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error performing bulk rematch: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def rematch_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Reject per-recipe rematches while a global run is in progress so
# they do not queue behind the mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error rematching single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_rematch_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_rematch_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response(
{"success": False, "message": "No rematch in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting rematch progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reimport_recipe(self, request: web.Request) -> web.Response: async def reimport_recipe(self, request: web.Request) -> web.Response:
"""Delete a recipe and re-import it from its source URL. """Delete a recipe and re-import it from its source URL.

View File

@@ -61,6 +61,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
RouteDefinition("POST", "/api/lm/recipes/cancel-rematch", "cancel_rematch"),
RouteDefinition("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"), RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress" "GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"

View File

@@ -214,3 +214,78 @@ def test_recipe_routes_setup_routes_uses_registrar(monkeypatch: pytest.MonkeyPat
} }
assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes.RecipeRoutes} assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes.RecipeRoutes}
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies"} assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies"}
# --- Rematch route scaffolding ----------------------------------------------
_REMATCH_ROUTE_DEFS = {
("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
("POST", "/api/lm/recipes/cancel-rematch", "cancel_rematch"),
("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
}
_REPAIR_ROUTE_DEFS = {
("POST", "/api/lm/recipes/repair", "repair_recipes"),
("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
}
def test_rematch_route_definitions_registered():
registered = {
(d.method, d.path, d.handler_name)
for d in recipe_route_registrar.ROUTE_DEFINITIONS
}
assert _REMATCH_ROUTE_DEFS <= registered
def test_repair_route_definitions_still_registered():
registered = {
(d.method, d.path, d.handler_name)
for d in recipe_route_registrar.ROUTE_DEFINITIONS
}
assert _REPAIR_ROUTE_DEFS <= registered
def test_rematch_handler_names_resolve_in_to_route_mapping(monkeypatch: pytest.MonkeyPatch):
"""Oracle R1-F4: register_routes KeyErrors at startup if to_route_mapping
lacks any name present in ROUTE_DEFINITIONS, so the real handler set must
resolve every rematch name.
"""
registry = service_registry.ServiceRegistry
scanner = _make_stub_scanner()
civitai_client = object()
async def fake_get_recipe_scanner():
return scanner
async def fake_get_civitai_client():
return civitai_client
async def fake_get_downloader():
return object()
class _DummyService:
def __init__(self, **_: Any) -> None:
pass
monkeypatch.setattr(registry, "get_recipe_scanner", fake_get_recipe_scanner)
monkeypatch.setattr(registry, "get_civitai_client", fake_get_civitai_client)
monkeypatch.setattr(base_recipe_routes, "RecipeAnalysisService", _DummyService)
monkeypatch.setattr(base_recipe_routes, "RecipePersistenceService", _DummyService)
monkeypatch.setattr(base_recipe_routes, "RecipeSharingService", _DummyService)
monkeypatch.setattr(base_recipe_routes, "get_downloader", fake_get_downloader)
async def scenario():
routes = base_recipe_routes.BaseRecipeRoutes()
await routes.attach_dependencies()
mapping = routes.to_route_mapping()
for _, _, name in _REMATCH_ROUTE_DEFS:
assert name in mapping
assert asyncio.iscoroutinefunction(mapping[name])
asyncio.run(scenario())

View File

@@ -14,6 +14,8 @@ from aiohttp import FormData, web
from aiohttp.test_utils import TestClient, TestServer from aiohttp.test_utils import TestClient, TestServer
from PIL import Image from PIL import Image
import pytest
from py.config import config from py.config import config
from py.routes import base_recipe_routes from py.routes import base_recipe_routes
from py.routes.handlers import recipe_handlers from py.routes.handlers import recipe_handlers
@@ -21,6 +23,7 @@ from py.routes.recipe_routes import RecipeRoutes
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
from py.services.recipes import RecipeValidationError, RecipeNotFoundError from py.services.recipes import RecipeValidationError, RecipeNotFoundError
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.services.websocket_manager import ws_manager
@dataclass @dataclass
@@ -51,6 +54,13 @@ class StubRecipeScanner:
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {} self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
self.image_id_map_override: Dict[str, str] = {} self.image_id_map_override: Dict[str, str] = {}
self.local_hash_cache: Dict[str, Dict[str, Any]] | None = None self.local_hash_cache: Dict[str, Dict[str, Any]] | None = None
# Rematch double bookkeeping
self.cancel_calls = 0
self.reset_calls = 0
self.rematch_all_calls: List[Any] = []
self.rematch_by_id_calls: List[str] = []
self.rematch_bulk_calls: List[List[str]] = []
self.rematch_results: Dict[str, Dict[str, Any]] = {}
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
return None return None
@@ -106,6 +116,65 @@ class StubRecipeScanner:
self.removed.append(recipe_id) self.removed.append(recipe_id)
self.recipes.pop(recipe_id, None) self.recipes.pop(recipe_id, None)
def cancel_task(self) -> None:
self.cancel_calls += 1
def reset_cancellation(self) -> None:
self.reset_calls += 1
async def rematch_all_recipes(self, progress_callback=None):
"""Run a canned rematch-all run, mirroring the real progress events."""
if progress_callback:
await progress_callback({"status": "started"})
await progress_callback(
{"status": "processing", "current": 1, "total": 1, "recipe_name": "demo"}
)
await progress_callback(
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
)
self.rematch_all_calls.append(progress_callback)
return {
"success": True,
"status": "completed",
"rematched": 1,
"skipped": 0,
"errors": 0,
"total": 1,
}
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
self.rematch_by_id_calls.append(recipe_id)
if recipe_id not in self.rematch_results:
raise RecipeNotFoundError(f"Recipe not found: {recipe_id}")
return self.rematch_results[recipe_id]
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
self.rematch_bulk_calls.append(list(recipe_ids))
total = len(recipe_ids)
rematched = 0
skipped = 0
errors = 0
recipes: List[Dict[str, Any]] = []
for recipe_id in recipe_ids:
result = self.rematch_results.get(recipe_id)
if result is None:
skipped += 1
elif result.get("success"):
rematched += result.get("rematched", 0)
skipped += result.get("skipped", 0)
if result.get("recipe"):
recipes.append(result["recipe"])
else:
errors += 1
return {
"success": True,
"total": total,
"rematched": rematched,
"skipped": skipped,
"errors": errors,
"recipes": recipes,
}
class StubAnalysisService: class StubAnalysisService:
"""Captures calls made by analysis routes while returning canned responses.""" """Captures calls made by analysis routes while returning canned responses."""
@@ -1721,3 +1790,164 @@ async def test_create_from_example_does_not_recompute_stored_autov3(
assert parser.received_cache is harness.scanner.local_hash_cache assert parser.received_cache is harness.scanner.local_hash_cache
assert parser.received_cache is not None assert parser.received_cache is not None
assert parser.received_cache["existing123456"] is parent_item assert parser.received_cache["existing123456"] is parent_item
# --- Rematch endpoints ------------------------------------------------------
@pytest.fixture(autouse=True)
def _clean_recipe_run_progress_state():
"""Keep the shared WS manager run-state isolated between tests."""
ws_manager._recipe_rematch_progress = None
ws_manager._recipe_repair_progress = None
yield
ws_manager._recipe_rematch_progress = None
ws_manager._recipe_repair_progress = None
def _set_rematch_running(status: str = "processing") -> None:
ws_manager._recipe_rematch_progress = {"status": status}
def _set_repair_running(status: str = "processing") -> None:
ws_manager._recipe_repair_progress = {"status": status}
async def test_rematch_recipes_starts_background_run(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/rematch")
payload = await response.json()
assert response.status == 200, payload
assert payload["success"] is True
assert payload["message"] == "Recipe rematch started"
assert harness.scanner.reset_calls == 1
# Allow the spawned background task to reach its progress broadcasts.
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_calls == [harness.scanner.rematch_all_calls[0]]
async def test_rematch_recipes_409_when_rematch_running(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
_set_rematch_running()
response = await harness.client.post("/api/lm/recipes/rematch")
payload = await response.json()
assert response.status == 409
assert payload["success"] is False
assert "already in progress" in payload["error"].lower()
async def test_rematch_recipes_409_when_repair_running(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
_set_repair_running()
response = await harness.client.post("/api/lm/recipes/rematch")
payload = await response.json()
assert response.status == 409
assert payload["success"] is False
assert "already in progress" in payload["error"].lower()
async def test_rematch_recipe_409_when_rematch_running(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
_set_rematch_running()
response = await harness.client.post("/api/lm/recipe/abc123/rematch")
payload = await response.json()
assert response.status == 409
assert payload["success"] is False
assert harness.scanner.rematch_by_id_calls == []
async def test_rematch_recipes_bulk_409_when_rematch_running(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
_set_rematch_running()
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk", json={"recipe_ids": ["abc123"]}
)
payload = await response.json()
assert response.status == 409
assert payload["success"] is False
assert harness.scanner.rematch_bulk_calls == []
async def test_cancel_rematch_calls_scanner_cancel_task(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/cancel-rematch")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["message"] == "Cancellation requested"
assert harness.scanner.cancel_calls == 1
async def test_rematch_recipes_bulk_parses_ids_and_returns_summary(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.rematch_results = {
"r1": {
"success": True,
"rematched": 2,
"skipped": 0,
"errors": 0,
"recipe": {"id": "r1", "title": "Found"},
},
}
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk",
json={"recipe_ids": ["r1", "missing-id"]},
)
payload = await response.json()
assert response.status == 200, payload
# The loop is delegated to the scanner, not re-implemented in the handler.
assert harness.scanner.rematch_bulk_calls == [["r1", "missing-id"]]
assert harness.scanner.rematch_by_id_calls == []
assert payload["success"] is True
assert payload["total"] == 2
assert payload["rematched"] == 2
assert payload["skipped"] == 1 # missing-id counted as skipped
assert payload["errors"] == 0
assert payload["recipes"] == [{"id": "r1", "title": "Found"}]
async def test_rematch_recipes_bulk_missing_recipe_ids_400(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk", json={"recipe_ids": []}
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert "recipe_ids" in payload["error"].lower()
async def test_rematch_recipe_maps_not_found_to_404(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipe/ghost/rematch")
payload = await response.json()
assert response.status == 404
assert payload["success"] is False
assert harness.scanner.rematch_by_id_calls == ["ghost"]
async def test_get_rematch_progress_404_when_no_progress(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get("/api/lm/recipes/rematch-progress")
payload = await response.json()
assert response.status == 404
assert payload["success"] is False
async def test_get_rematch_progress_returns_stored_progress(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
_set_rematch_running("processing")
response = await harness.client.get("/api/lm/recipes/rematch-progress")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["progress"]["status"] == "processing"