From 3001f0f0ef44a077c7bc68b9408b3f161e05eec7 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 9 Aug 2026 11:30:50 +0800 Subject: [PATCH] feat(recipes): add recipe rematch API endpoints --- py/routes/handlers/recipe_handlers.py | 158 ++++++++++++ py/routes/recipe_route_registrar.py | 5 + tests/routes/test_recipe_route_scaffolding.py | 75 ++++++ tests/routes/test_recipe_routes.py | 230 ++++++++++++++++++ 4 files changed, 468 insertions(+) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 931f5d31..9f74b720 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -112,6 +112,11 @@ class RecipeHandlerSet: "repair_recipe": self.management.repair_recipe, "repair_recipes_bulk": self.management.repair_recipes_bulk, "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, "get_batch_import_progress": self.batch_import.get_batch_import_progress, "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) 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: """Delete a recipe and re-import it from its source URL. diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 76dca31c..17220126 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -61,6 +61,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"), 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( "GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress" diff --git a/tests/routes/test_recipe_route_scaffolding.py b/tests/routes/test_recipe_route_scaffolding.py index 20f3506a..e36da54e 100644 --- a/tests/routes/test_recipe_route_scaffolding.py +++ b/tests/routes/test_recipe_route_scaffolding.py @@ -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 {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()) diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index b5c04165..c0085c80 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -14,6 +14,8 @@ from aiohttp import FormData, web from aiohttp.test_utils import TestClient, TestServer from PIL import Image +import pytest + from py.config import config from py.routes import base_recipe_routes 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.services.recipes import RecipeValidationError, RecipeNotFoundError from py.services.service_registry import ServiceRegistry +from py.services.websocket_manager import ws_manager @dataclass @@ -51,6 +54,13 @@ class StubRecipeScanner: self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {} self.image_id_map_override: Dict[str, str] = {} 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 return None @@ -106,6 +116,65 @@ class StubRecipeScanner: self.removed.append(recipe_id) 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: """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 not None 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"