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 <service>/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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 11:23:47 -07:00
parent c17138bf7d
commit 80679b02ba
3 changed files with 134 additions and 0 deletions

View file

@ -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

89
api/video/enrichment.py Normal file
View file

@ -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/<service>/status
POST /api/video/enrichment/<service>/pause | /resume
GET /api/video/enrichment/<service>/breakdown
GET /api/video/enrichment/<service>/unmatched?kind=&status=&q=&limit=&offset=
POST /api/video/enrichment/<service>/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/<service>/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/<service>/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/<service>/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/<service>/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/<service>/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/<service>/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})

View file

@ -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/<service>/status" in rules
assert "/api/video/enrichment/<service>/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"):