feat(recipes): remove deprecated Repair Metadata feature

The recipe "Repair Metadata" action has been marked Deprecated in the UI
for a while and cannot reliably recover recipes imported from CivitAI URLs
whose REST meta has no resources/hashes and whose image has no embedded
metadata (e.g. CivitAI-only generation data). Drop the feature end to end.

Backend:
- remove repair routes (repair, cancel-repair, recipe/{id}/repair,
  repair-bulk, repair-progress) and their handler mappings/methods
- remove RecipeScanner repair_all_recipes / repair_recipe_by_id /
  _repair_single_recipe and REPAIR_VERSION
- remove WebSocketManager recipe-repair progress channel
- drop repair_version column from the persistent recipe cache
- rematch mutual-exclusion now only checks rematch

Frontend:
- remove repair entries from per-recipe, bulk and global context menus
- remove repairRecipe / repairSelectedRecipes / repairRecipes + cancelRepair
  and the repairBulk API client method/endpoint
- drop recipe-repair i18n keys (synced across locales; doctor keys kept)

Tests/docs: delete test_recipe_repair.py, update scaffolding/routes/ws/
persistent-cache/integration tests and i18n guideline examples.
This commit is contained in:
Will Miao
2026-09-05 16:56:20 +08:00
parent 782bb53784
commit f86b7b55d6
29 changed files with 9 additions and 1244 deletions
-1
View File
@@ -42,7 +42,6 @@ def sample_recipe_data() -> Dict[str, Any]:
"created_date": 1700000000.0,
"modified": 1700000100.0,
"favorite": False,
"repair_version": 1,
"preview_nsfw_level": 0,
"loras": [
{"hash": "lora1hash", "file_name": "test_lora1", "strength": 0.8},
@@ -226,15 +226,6 @@ _REMATCH_ROUTE_DEFS = {
("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)
@@ -243,14 +234,6 @@ def test_rematch_route_definitions_registered():
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
-15
View File
@@ -1874,20 +1874,14 @@ async def test_create_from_example_does_not_recompute_stored_autov3(
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")
@@ -1911,15 +1905,6 @@ async def test_rematch_recipes_409_when_rematch_running(monkeypatch, tmp_path: P
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:
-327
View File
@@ -1,327 +0,0 @@
import pytest
import asyncio
from typing import Any, Dict
from unittest.mock import AsyncMock, MagicMock
from py.services.recipe_scanner import RecipeScanner
from types import SimpleNamespace
# We define these here to help with spec= if needed
class MockCivitaiClient:
async def get_image_info(self, image_id, source_url=None):
pass
class MockPersistenceService:
async def save_recipe(self, recipe):
pass
@pytest.fixture
def mock_civitai_client():
client = MagicMock(spec=MockCivitaiClient)
client.get_image_info = AsyncMock()
return client
@pytest.fixture
def mock_metadata_provider():
provider = MagicMock()
provider.get_model_version_info = AsyncMock(return_value=(None, None))
provider.get_model_by_hash = AsyncMock(return_value=(None, None))
return provider
@pytest.fixture
def recipe_scanner():
lora_scanner = MagicMock()
lora_scanner.get_cached_data = AsyncMock(return_value=SimpleNamespace(raw_data=[]))
scanner = RecipeScanner(lora_scanner=lora_scanner)
return scanner
@pytest.fixture
def setup_scanner(recipe_scanner, mock_civitai_client, mock_metadata_provider, monkeypatch):
monkeypatch.setattr(recipe_scanner, "_get_civitai_client", AsyncMock(return_value=mock_civitai_client))
# Wrap the real method with a mock so we can check calls but still execute it
real_save = recipe_scanner._save_recipe_persistently
mock_save = AsyncMock(side_effect=real_save)
monkeypatch.setattr(recipe_scanner, "_save_recipe_persistently", mock_save)
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", AsyncMock(return_value=mock_metadata_provider))
# Mock get_recipe_json_path to avoid file system issues in tests
recipe_scanner.get_recipe_json_path = AsyncMock(return_value="/tmp/test_recipe.json")
# Mock open to avoid actual file writing
monkeypatch.setattr("builtins.open", MagicMock())
monkeypatch.setattr("json.dump", MagicMock())
monkeypatch.setattr("os.path.exists", MagicMock(return_value=False)) # avoid EXIF logic
return recipe_scanner, mock_civitai_client, mock_metadata_provider
@pytest.mark.asyncio
async def test_repair_all_recipes_skip_up_to_date(setup_scanner):
recipe_scanner, _, _ = setup_scanner
recipe_scanner._cache = SimpleNamespace(raw_data=[
{"id": "r1", "repair_version": RecipeScanner.REPAIR_VERSION, "title": "Up to date"}
])
# Run
results = await recipe_scanner.repair_all_recipes()
# Verify
assert results["repaired"] == 0
assert results["skipped"] == 1
recipe_scanner._save_recipe_persistently.assert_not_called()
@pytest.mark.asyncio
async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
recipe = {
"id": "r1",
"title": "Old Recipe",
"source_path": "https://civitai.com/images/12345",
"checkpoint": None,
"gen_params": {"prompt": ""}
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
# Mock image info returning modelVersionId
mock_civitai_client.get_image_info.return_value = {
"modelVersionId": 5678,
"meta": {"prompt": "a beautiful forest", "Checkpoint": "basic_name.safetensors"}
}
# Mock metadata provider returning full info
mock_metadata_provider.get_model_version_info.return_value = ({
"id": 5678,
"modelId": 1234,
"name": "v1.0",
"model": {"name": "Full Model Name", "type": "Checkpoint"},
"baseModel": "SDXL 1.0",
"images": [{"url": "https://image.url/thumb.jpg"}],
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
}, None)
# Run
results = await recipe_scanner.repair_all_recipes()
# Verify
assert results["repaired"] == 1
mock_metadata_provider.get_model_version_info.assert_called_with("5678")
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
checkpoint = saved_recipe["checkpoint"]
assert checkpoint["modelName"] == "Full Model Name"
assert checkpoint["modelVersionName"] == "v1.0"
assert checkpoint["modelId"] == 1234
assert checkpoint["modelVersionId"] == 5678
assert checkpoint["type"] == "checkpoint"
assert "name" not in checkpoint
assert "version" not in checkpoint
assert "hash" not in checkpoint
assert "file_name" not in checkpoint
@pytest.mark.asyncio
async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
recipe = {
"id": "r1",
"title": "Red Recipe",
"source_path": "https://civitai.red/images/12345",
"checkpoint": None,
"gen_params": {"prompt": ""},
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
mock_civitai_client.get_image_info.return_value = {
"modelVersionId": 5678,
"meta": {"prompt": "from red"},
}
mock_metadata_provider.get_model_version_info.return_value = (
{
"id": 5678,
"modelId": 1234,
"name": "v1.0",
"model": {"name": "Full Model Name", "type": "Checkpoint"},
"baseModel": "SDXL 1.0",
"images": [{"url": "https://image.url/thumb.jpg"}],
"files": [
{
"type": "Model",
"hashes": {"SHA256": "ABCDEF"},
"name": "full_filename.safetensors",
}
],
},
None,
)
results = await recipe_scanner.repair_all_recipes()
assert results["repaired"] == 1
mock_civitai_client.get_image_info.assert_called_with(
"12345", source_url="https://civitai.red/images/12345"
)
@pytest.mark.asyncio
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
recipe = {
"id": "r1",
"title": "Embedded Only",
"checkpoint": None,
"gen_params": {
"prompt": "",
"Model hash": "hash123"
}
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
# Mock metadata provider lookup by hash
mock_metadata_provider.get_model_by_hash.return_value = ({
"id": 999,
"modelId": 888,
"name": "v2.0",
"model": {"name": "Hashed Model", "type": "Checkpoint"},
"baseModel": "SD 1.5",
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
}, None)
# Run
results = await recipe_scanner.repair_all_recipes()
# Verify
assert results["repaired"] == 1
mock_metadata_provider.get_model_by_hash.assert_called_with("hash123")
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
checkpoint = saved_recipe["checkpoint"]
assert checkpoint["modelName"] == "Hashed Model"
assert checkpoint["modelVersionName"] == "v2.0"
assert checkpoint["modelId"] == 888
assert checkpoint["type"] == "checkpoint"
@pytest.mark.asyncio
async def test_repair_all_recipes_fallback_to_basic(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
recipe = {
"id": "r1",
"title": "No Meta Lookup",
"checkpoint": None,
"gen_params": {
"prompt": "",
"Checkpoint": "just_a_name.safetensors"
}
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
# Mock metadata provider returning nothing
mock_metadata_provider.get_model_by_hash.return_value = (None, "Model not found")
# Run
results = await recipe_scanner.repair_all_recipes()
# Verify
assert results["repaired"] == 1
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
assert saved_recipe["checkpoint"]["modelName"] == "just_a_name.safetensors"
assert saved_recipe["checkpoint"]["type"] == "checkpoint"
assert "modelId" not in saved_recipe["checkpoint"]
@pytest.mark.asyncio
async def test_repair_all_recipes_progress_callback(setup_scanner):
recipe_scanner, _, _ = setup_scanner
recipe_scanner._cache = SimpleNamespace(raw_data=[
{"id": "r1", "title": "R1", "checkpoint": None},
{"id": "r2", "title": "R2", "checkpoint": None}
])
progress_calls = []
async def progress_callback(data):
progress_calls.append(data)
# Run
await recipe_scanner.repair_all_recipes(
progress_callback=progress_callback
)
# Verify
assert len(progress_calls) >= 2
assert progress_calls[-1]["status"] == "completed"
assert progress_calls[-1]["total"] == 2
assert progress_calls[-1]["repaired"] == 2
@pytest.mark.asyncio
async def test_repair_all_recipes_strips_runtime_fields(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
# Recipe with runtime fields
recipe: Dict[str, Any] = {
"id": "r1",
"title": "Cleanup Test",
"checkpoint": {
"name": "CP",
"inLibrary": True,
"localPath": "/path/to/cp",
"thumbnailUrl": "thumb.jpg"
},
"loras": [
{
"name": "L1",
"weight": 0.8,
"inLibrary": True,
"localPath": "/path/to/l1",
"preview_url": "p.jpg"
}
],
"gen_params": {"prompt": ""}
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
# Set high version to trigger repair if needed (or just ensure it processes)
recipe["repair_version"] = 0
# Run
await recipe_scanner.repair_all_recipes()
# Verify sanitation
assert recipe_scanner._save_recipe_persistently.called
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
# 1. Check LORA
lora = saved_recipe["loras"][0]
assert "inLibrary" not in lora
assert "localPath" not in lora
assert "preview_url" not in lora
assert "strength" in lora # weight renamed to strength
assert lora["strength"] == 0.8
# 2. Check Checkpoint
cp = saved_recipe["checkpoint"]
assert "inLibrary" not in cp
assert "localPath" not in cp
assert "thumbnailUrl" not in cp
@pytest.mark.asyncio
async def test_sanitize_recipe_for_storage(recipe_scanner):
recipe = {
"loras": [{"name": "L1", "inLibrary": True, "weight": 0.5}],
"checkpoint": {"name": "CP", "localPath": "/tmp/cp"}
}
clean = recipe_scanner._sanitize_recipe_for_storage(recipe)
assert "inLibrary" not in clean["loras"][0]
assert "strength" in clean["loras"][0]
assert clean["loras"][0]["strength"] == 0.5
assert "localPath" not in clean["checkpoint"]
# Testing based on what enricher would produce if it ran,
# but here we are just testing the sanitizer which handles what is ALREADY there.
# However, the sanitizer doesn't rename fields, it just removes runtime ones.
# Since we changed the enricher to NOT put 'name' anymore, this test case
# should probably reflect the new fields if it's simulating a real recipe.
assert clean["checkpoint"]["name"] == "CP"
+4 -12
View File
@@ -242,22 +242,14 @@ async def test_is_recipe_rematch_running_by_status(manager, status, expected):
assert manager.is_recipe_rematch_running() is expected
async def test_rematch_and_repair_channels_are_independent(manager):
# Rematch progress must not leak into the repair channel
async def test_rematch_progress_channel_updates_and_cleans_up(manager):
# Rematch progress is stored and reported as running while processing.
await manager.broadcast_recipe_rematch_progress({"status": "processing", "current": 1})
assert manager.is_recipe_rematch_running() is True
assert manager.is_recipe_repair_running() is False
assert manager.get_recipe_repair_progress() is None
# Repair progress must not overwrite the rematch state
await manager.broadcast_recipe_repair_progress({"status": "processing", "current": 1})
assert manager.is_recipe_repair_running() is True
assert manager.is_recipe_rematch_running() is True
assert manager.get_recipe_rematch_progress() == {"status": "processing", "current": 1}
# Cleaning the rematch channel must leave the repair channel untouched
# Finished states clear on cleanup.
await manager.broadcast_recipe_rematch_progress({"status": "completed"})
manager.cleanup_recipe_rematch_progress()
assert manager.get_recipe_rematch_progress() is None
assert manager.get_recipe_repair_progress() == {"status": "processing", "current": 1}
assert manager.is_recipe_repair_running() is True
assert manager.is_recipe_rematch_running() is False
-5
View File
@@ -40,7 +40,6 @@ def sample_recipes() -> List[Dict[str, Any]]:
"created_date": 1700000000.0,
"modified": 1700000100.0,
"favorite": True,
"repair_version": 3,
"preview_nsfw_level": 1,
"loras": [
{"hash": "hash1", "file_name": "lora1", "strength": 0.8},
@@ -60,7 +59,6 @@ def sample_recipes() -> List[Dict[str, Any]]:
"created_date": 1700000200.0,
"modified": 1700000300.0,
"favorite": False,
"repair_version": 2,
"preview_nsfw_level": 0,
"loras": [{"hash": "hash3", "file_name": "lora3", "strength": 0.5}],
"gen_params": {"prompt": "another prompt"},
@@ -101,7 +99,6 @@ class TestPersistentRecipeCache:
assert r1["base_model"] == "SD1.5"
assert r1["fingerprint"] == "abc123"
assert r1["favorite"] is True
assert r1["repair_version"] == 3
assert len(r1["loras"]) == 2
assert r1["loras"][0]["hash"] == "hash1"
assert r1["checkpoint"]["name"] == "model.safetensors"
@@ -164,7 +161,6 @@ class TestPersistentRecipeCache:
file_mtime REAL,
file_size INTEGER,
favorite INTEGER DEFAULT 0,
repair_version INTEGER DEFAULT 0,
preview_nsfw_level INTEGER DEFAULT 0,
loras_json TEXT,
checkpoint_json TEXT,
@@ -710,7 +706,6 @@ class TestHasWorkflowColumn:
file_mtime REAL,
file_size INTEGER,
favorite INTEGER DEFAULT 0,
repair_version INTEGER DEFAULT 0,
preview_nsfw_level INTEGER DEFAULT 0,
loras_json TEXT,
checkpoint_json TEXT,