diff --git a/backend/src/media_library_viewer_api/services/service_data.py b/backend/src/media_library_viewer_api/services/service_data.py index 5b4d6fa..8dcf86d 100644 --- a/backend/src/media_library_viewer_api/services/service_data.py +++ b/backend/src/media_library_viewer_api/services/service_data.py @@ -95,8 +95,9 @@ class ServiceDataHarness: try: conn.execute(stmt) except sqlite3.OperationalError as exc: - if "duplicate column name" in str(exc).lower(): - logger.debug("Skipping already-applied migration: %s", stmt[:80]) + lowered = str(exc).lower() + if "duplicate column name" in lowered or "no such table" in lowered: + logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80]) else: raise conn.commit() diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index 104e61f..2043fc9 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -1740,7 +1740,7 @@ class SettingsStore: return self.get_service(service["id"]) or service def delete_service(self, service_id: str) -> None: - """Delete a service and cascade-delete widgets referencing it.""" + """Delete a service and cascade-delete widgets + harness data.""" self.init_schema() with self.connect() as conn: # The service_id column on dashboard_widgets is added in a later @@ -1753,6 +1753,15 @@ class SettingsStore: ) conn.execute("DELETE FROM services WHERE id = ?", (service_id,)) + # Cascade-delete harness-managed data (best-effort: the service row is + # already removed; data cleanup must not block service deletion). + try: + from media_library_viewer_api.services.service_data import get_service_data_harness + + get_service_data_harness().cascade_delete(service_id) + except Exception: + logger.exception("Failed to cascade-delete harness data for service %s", service_id) + def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]: """Append a service task run history row.""" self.init_schema() diff --git a/backend/tests/test_media_index.py b/backend/tests/test_media_index.py index 725a14d..7653d24 100644 --- a/backend/tests/test_media_index.py +++ b/backend/tests/test_media_index.py @@ -228,16 +228,20 @@ class TestMediaIndexQuery: class TestMediaIndexReplace: def test_replace_clears_old(self, index): - index.replace_items([ - {"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"}, - ]) + index.replace_items( + [ + {"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"}, + ] + ) status = index.status() assert status.item_count == 1 - index.replace_items([ - {"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"}, - {"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"}, - ]) + index.replace_items( + [ + {"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"}, + {"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"}, + ] + ) status = index.status() assert status.item_count == 2 @@ -335,15 +339,11 @@ class TestMediaIndexBuildPaths: self.calls.append(kwargs.get("start_index", 0)) if kwargs.get("start_index", 0) == 0: return { - "Items": [ - {"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"} - ], + "Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}], "TotalRecordCount": 2, } return { - "Items": [ - {"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"} - ], + "Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}], "TotalRecordCount": 2, } @@ -379,15 +379,11 @@ class TestMediaIndexBuildPaths: self.calls.append(kwargs.get("start_index", 0)) if kwargs.get("start_index", 0) == 0: return { - "Items": [ - {"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"} - ], + "Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}], "TotalRecordCount": 2, } return { - "Items": [ - {"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"} - ], + "Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}], "TotalRecordCount": 2, } diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 95707ff..7e677c5 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -396,6 +396,77 @@ def test_delete_service_cascades_to_widgets(client, tmp_path): assert int(remaining[0]) == 0 +# --------------------------------------------------------------------------- +# Harness cascade-delete (Slice 4) +# --------------------------------------------------------------------------- + + +def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypatch): + """Deleting a service cascades to both qbittorrent samples and media items. + + Proves end-to-end cascade across both harness-managed concerns, and that + deleting one service preserves another service's data (multi-instance). + """ + monkeypatch.setenv("BACKEND_CACHE_DIR", str(tmp_path)) + + from media_library_viewer_api.services.media_index_impl import MediaIndex + from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore + from media_library_viewer_api.services.service_data import ( + get_service_data_harness, + reset_service_data_harness, + ) + + reset_service_data_harness() + harness = get_service_data_harness() + + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + # --- qBit: create service, add samples, delete, verify gone --- + qbit_a = store.upsert_service( + {"service_type": "qbittorrent", "name": "qA", "config": {"base_url": "http://a"}, "enabled": True} + ) + qbit_b = store.upsert_service( + {"service_type": "qbittorrent", "name": "qB", "config": {"base_url": "http://b"}, "enabled": True} + ) + sample_store = QbittorrentSampleStore(harness) + sample_store.append(qbit_a["id"], ts=1000, dl_speed=500, up_speed=100) + sample_store.append(qbit_b["id"], ts=1000, dl_speed=200, up_speed=50) + + assert len(sample_store.window(qbit_a["id"])) == 1 + assert len(sample_store.window(qbit_b["id"])) == 1 + + store.delete_service(qbit_a["id"]) + + assert sample_store.window(qbit_a["id"]) == [] + assert len(sample_store.window(qbit_b["id"])) == 1 # B survives + + # --- MediaIndex: create services, add items, delete, verify scoped --- + jelly_a = store.upsert_service( + {"service_type": "jellyfin", "name": "jA", "config": {"base_url": "http://ja"}, "enabled": True} + ) + jelly_b = store.upsert_service( + {"service_type": "jellyfin", "name": "jB", "config": {"base_url": "http://jb"}, "enabled": True} + ) + index = MediaIndex(harness.db_path("media_index")) + index.init_schema() + index.replace_items([{"id": "m1", "title": "A1"}], service_id=jelly_a["id"]) + index.replace_items([{"id": "m2", "title": "B1"}], service_id=jelly_b["id"]) + + rows_a, total_a = index.query(service_id=jelly_a["id"]) + rows_b, total_b = index.query(service_id=jelly_b["id"]) + assert total_a == 1 and total_b == 1 + + store.delete_service(jelly_a["id"]) + + rows_a_after, total_a_after = index.query(service_id=jelly_a["id"]) + rows_b_after, total_b_after = index.query(service_id=jelly_b["id"]) + assert total_a_after == 0 # deleted + assert total_b_after == 1 # survives + + reset_service_data_harness() + + # --------------------------------------------------------------------------- # Service task run history # ---------------------------------------------------------------------------