From 80679b02bae0c82af722c57b0969d083c8d81885 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 11:23:47 -0700 Subject: [PATCH] video enrichment 1c: isolated /api/video/enrichment blueprint Mirrors the music enrichment API so the shared Manage-Workers modal can drive video workers by pointing at /api/video/...: - GET services; GET /status (worker.get_stats); POST pause/resume; GET breakdown; GET unmatched (paged, kind/status/q); POST retry. - Unknown service -> 404. Engine via the lazy singleton; DB queries via the isolated video DB. 6 API tests (services/status/breakdown/unmatched/pause/ resume/retry/404) with an injected engine + fake clients. --- api/video/__init__.py | 2 + api/video/enrichment.py | 89 +++++++++++++++++++++++++++++++++++++++++ tests/test_video_api.py | 43 ++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 api/video/enrichment.py diff --git a/api/video/__init__.py b/api/video/__init__.py index b1ae7e3a..15ac2eec 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -42,10 +42,12 @@ def create_video_blueprint() -> Blueprint: from .library import register_routes as reg_library from .libraries import register_routes as reg_libraries from .poster import register_routes as reg_poster + from .enrichment import register_routes as reg_enrichment reg_dashboard(bp) reg_scan(bp) reg_library(bp) reg_libraries(bp) reg_poster(bp) + reg_enrichment(bp) return bp diff --git a/api/video/enrichment.py b/api/video/enrichment.py new file mode 100644 index 00000000..70281854 --- /dev/null +++ b/api/video/enrichment.py @@ -0,0 +1,89 @@ +"""Video enrichment API — mirrors the music enrichment endpoints so the shared +Manage-Workers modal can drive video workers by pointing at /api/video/... + + GET /api/video/enrichment/services + GET /api/video/enrichment//status + POST /api/video/enrichment//pause | /resume + GET /api/video/enrichment//breakdown + GET /api/video/enrichment//unmatched?kind=&status=&q=&limit=&offset= + POST /api/video/enrichment//retry {kind, scope, item_id} +""" + +from __future__ import annotations + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video_api.enrichment") + + +def _int(val, default): + try: + return int(val) + except (TypeError, ValueError): + return default + + +def register_routes(bp): + def engine(): + from core.video.enrichment.engine import get_video_enrichment_engine + return get_video_enrichment_engine() + + @bp.route("/enrichment/services", methods=["GET"]) + def video_enrichment_services(): + try: + return jsonify({"services": engine().services()}) + except Exception: + logger.exception("video enrichment services failed") + return jsonify({"services": []}) + + @bp.route("/enrichment//status", methods=["GET"]) + def video_enrichment_status(service): + w = engine().worker(service) + if not w: + return jsonify({"error": "unknown service"}), 404 + return jsonify(w.get_stats()) + + @bp.route("/enrichment//pause", methods=["POST"]) + def video_enrichment_pause(service): + w = engine().worker(service) + if not w: + return jsonify({"error": "unknown service"}), 404 + w.pause() + return jsonify({"status": "paused"}) + + @bp.route("/enrichment//resume", methods=["POST"]) + def video_enrichment_resume(service): + w = engine().worker(service) + if not w: + return jsonify({"error": "unknown service"}), 404 + w.resume() + return jsonify({"status": "running"}) + + @bp.route("/enrichment//breakdown", methods=["GET"]) + def video_enrichment_breakdown(service): + from . import get_video_db + return jsonify({"service": service, "breakdown": get_video_db().enrichment_breakdown(service)}) + + @bp.route("/enrichment//unmatched", methods=["GET"]) + def video_enrichment_unmatched(service): + from . import get_video_db + kind = request.args.get("kind", "movie") + res = get_video_db().enrichment_unmatched( + service, kind, + status=request.args.get("status", "not_found"), + search=request.args.get("q") or None, + limit=_int(request.args.get("limit"), 50), + offset=_int(request.args.get("offset"), 0)) + res.update({"service": service, "kind": kind}) + return jsonify(res) + + @bp.route("/enrichment//retry", methods=["POST"]) + def video_enrichment_retry(service): + from . import get_video_db + body = request.get_json(silent=True) or {} + n = get_video_db().enrichment_retry( + service, body.get("kind", "movie"), + scope=body.get("scope", "failed"), item_id=body.get("item_id")) + return jsonify({"success": True, "reset": n}) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 8719e60c..44a46946 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -35,6 +35,9 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/library" in rules assert "/api/video/libraries" in rules assert any(r.startswith("/api/video/poster/") for r in rules) + assert "/api/video/enrichment/services" in rules + assert "/api/video/enrichment//status" in rules + assert "/api/video/enrichment//unmatched" in rules def test_dashboard_endpoint_returns_zeroed_json(tmp_path): @@ -87,6 +90,46 @@ def test_libraries_endpoint_lists_and_saves(tmp_path, monkeypatch): videoapi._video_db = None +def test_enrichment_endpoints(tmp_path): + import api.video as videoapi + from database.video_database import VideoDatabase + import core.video.enrichment.engine as eng_mod + from core.video.enrichment.engine import VideoEnrichmentEngine + + class FakeClient: + enabled = True + def match(self, *a): return None + + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + videoapi._video_db = db + eng_mod._engine = VideoEnrichmentEngine(db, {"tmdb": FakeClient(), "tvdb": FakeClient()}) + app = Flask(__name__) + app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") + client = app.test_client() + try: + svc = client.get("/api/video/enrichment/services").get_json() + assert {s["id"] for s in svc["services"]} == {"tmdb", "tvdb"} + + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "X"}) + st = client.get("/api/video/enrichment/tmdb/status").get_json() + assert st["enabled"] is True and st["stats"]["pending"] == 1 + + db.enrichment_apply("tmdb", "movie", mid, matched=False) + bd = client.get("/api/video/enrichment/tmdb/breakdown").get_json() + assert bd["breakdown"]["movie"]["not_found"] == 1 + un = client.get("/api/video/enrichment/tmdb/unmatched?kind=movie&status=not_found").get_json() + assert un["total"] == 1 and un["kind"] == "movie" + + assert client.post("/api/video/enrichment/tmdb/pause").get_json()["status"] == "paused" + assert client.post("/api/video/enrichment/tmdb/resume").get_json()["status"] == "running" + assert client.post("/api/video/enrichment/tmdb/retry", + json={"kind": "movie", "scope": "failed"}).get_json()["reset"] == 1 + assert client.get("/api/video/enrichment/nope/status").status_code == 404 + finally: + videoapi._video_db = None + eng_mod._engine = None + + def test_video_api_imports_nothing_from_music(): base = Path(__file__).resolve().parent.parent / "api" / "video" for py in base.glob("*.py"):