video enrichment 1b: worker engine + TMDB/TVDB clients
Isolated enrichment subsystem mirroring the music worker pattern, on video.db.
- VideoEnrichmentWorker: daemon loop pulling enrichment_next -> client.match ->
enrichment_apply; pause/resume/stop; get_stats in the same shape the music
enrichment API returns (enabled/running/paused/idle/current_item/stats/
progress/breakdown). Client injected -> loop fully unit-tested with a fake.
- TMDB/TVDB clients (thin adapters, keys from video_settings): .enabled +
match(kind,title,year) -> {id, metadata}; validated live, worker logic tested.
- VideoEnrichmentEngine registry + lazy get_video_enrichment_engine() singleton.
8 tests (match/not_found/error-resilience/stats/disabled/engine/isolation).
This commit is contained in:
parent
093e14bd5d
commit
c17138bf7d
5 changed files with 408 additions and 0 deletions
7
core/video/enrichment/__init__.py
Normal file
7
core/video/enrichment/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""SoulSync — isolated VIDEO enrichment subsystem.
|
||||||
|
|
||||||
|
Mirrors the music enrichment-worker pattern (per-source match status, a worker
|
||||||
|
loop, a registry) but operates entirely on video.db and never imports the music
|
||||||
|
enrichment code. The shared Manage-Workers modal / worker-orbs visuals are made
|
||||||
|
kind-aware separately; this package is the backend half.
|
||||||
|
"""
|
||||||
111
core/video/enrichment/clients.py
Normal file
111
core/video/enrichment/clients.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""TMDB / TVDB match clients for the video enrichment workers.
|
||||||
|
|
||||||
|
Thin adapters: ``.enabled`` (an API key is configured) and ``.match(kind, title,
|
||||||
|
year) -> {"id", "metadata"} | None``. These talk to real TMDB/TVDB APIs and are
|
||||||
|
validated against the live services; the worker LOGIC is unit-tested with a fake
|
||||||
|
client. Keys come from video_settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("video_enrichment.clients")
|
||||||
|
|
||||||
|
|
||||||
|
def _int(val):
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TMDBClient:
|
||||||
|
BASE = "https://api.themoviedb.org/3"
|
||||||
|
IMG = "https://image.tmdb.org/t/p/original"
|
||||||
|
|
||||||
|
def __init__(self, api_key):
|
||||||
|
self.api_key = api_key or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
def match(self, kind, title, year):
|
||||||
|
if not self.api_key or not title:
|
||||||
|
return None
|
||||||
|
import requests
|
||||||
|
path = "/search/movie" if kind == "movie" else "/search/tv"
|
||||||
|
params = {"api_key": self.api_key, "query": title}
|
||||||
|
if year:
|
||||||
|
params["year" if kind == "movie" else "first_air_date_year"] = year
|
||||||
|
resp = requests.get(self.BASE + path, params=params, timeout=15)
|
||||||
|
results = (resp.json() or {}).get("results") or []
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
top = results[0]
|
||||||
|
tmdb_id = top.get("id")
|
||||||
|
meta = {"overview": top.get("overview")}
|
||||||
|
try:
|
||||||
|
detail_path = "/movie/" if kind == "movie" else "/tv/"
|
||||||
|
dr = requests.get(self.BASE + detail_path + str(tmdb_id),
|
||||||
|
params={"api_key": self.api_key, "append_to_response": "external_ids"},
|
||||||
|
timeout=15).json() or {}
|
||||||
|
meta["overview"] = dr.get("overview") or meta["overview"]
|
||||||
|
if dr.get("backdrop_path"):
|
||||||
|
meta["backdrop_url"] = self.IMG + dr["backdrop_path"]
|
||||||
|
ext = dr.get("external_ids") or {}
|
||||||
|
meta["imdb_id"] = ext.get("imdb_id") or dr.get("imdb_id")
|
||||||
|
if kind == "movie":
|
||||||
|
meta["release_date"] = dr.get("release_date")
|
||||||
|
else:
|
||||||
|
meta["status"] = dr.get("status")
|
||||||
|
meta["tvdb_id"] = _int(ext.get("tvdb_id"))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("TMDB details fetch failed for %s", title)
|
||||||
|
return {"id": tmdb_id, "metadata": {k: v for k, v in meta.items() if v}}
|
||||||
|
|
||||||
|
|
||||||
|
class TVDBClient:
|
||||||
|
BASE = "https://api4.thetvdb.com/v4"
|
||||||
|
|
||||||
|
def __init__(self, api_key):
|
||||||
|
self.api_key = api_key or None
|
||||||
|
self._token = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
def _auth(self):
|
||||||
|
if self._token:
|
||||||
|
return self._token
|
||||||
|
import requests
|
||||||
|
r = requests.post(self.BASE + "/login", json={"apikey": self.api_key}, timeout=15).json() or {}
|
||||||
|
self._token = (r.get("data") or {}).get("token")
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def match(self, kind, title, year):
|
||||||
|
if kind != "show" or not self.api_key or not title:
|
||||||
|
return None
|
||||||
|
import requests
|
||||||
|
token = self._auth()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
r = requests.get(self.BASE + "/search", headers={"Authorization": "Bearer " + token},
|
||||||
|
params={"query": title, "type": "series"}, timeout=15).json() or {}
|
||||||
|
results = r.get("data") or []
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
top = results[0]
|
||||||
|
tvdb_id = _int(top.get("tvdb_id") or top.get("id"))
|
||||||
|
meta = {"overview": top.get("overview")}
|
||||||
|
return {"id": tvdb_id, "metadata": {k: v for k, v in meta.items() if v}}
|
||||||
|
|
||||||
|
|
||||||
|
def build_clients(db) -> dict:
|
||||||
|
"""Construct the source clients from the saved API keys (in video_settings)."""
|
||||||
|
return {
|
||||||
|
"tmdb": TMDBClient(db.get_setting("tmdb_api_key")),
|
||||||
|
"tvdb": TVDBClient(db.get_setting("tvdb_api_key")),
|
||||||
|
}
|
||||||
60
core/video/enrichment/engine.py
Normal file
60
core/video/enrichment/engine.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Video enrichment engine — owns the per-source workers (registry).
|
||||||
|
|
||||||
|
Parallels music's enrichment registry but is isolated to the video side. Built
|
||||||
|
lazily as a process-wide singleton; starts the workers (each idles until its API
|
||||||
|
key is configured). Imports only video.db + this package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
from .worker import VideoEnrichmentWorker
|
||||||
|
|
||||||
|
logger = get_logger("video_enrichment.engine")
|
||||||
|
|
||||||
|
_DISPLAY = {"tmdb": "TMDB", "tvdb": "TVDB"}
|
||||||
|
|
||||||
|
|
||||||
|
class VideoEnrichmentEngine:
|
||||||
|
def __init__(self, db, clients: dict):
|
||||||
|
self.db = db
|
||||||
|
self.workers = {
|
||||||
|
service: VideoEnrichmentWorker(db, service, client, display_name=_DISPLAY.get(service))
|
||||||
|
for service, client in clients.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def start_all(self):
|
||||||
|
for w in self.workers.values():
|
||||||
|
w.start()
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
for w in self.workers.values():
|
||||||
|
w.stop()
|
||||||
|
|
||||||
|
def worker(self, service):
|
||||||
|
return self.workers.get(service)
|
||||||
|
|
||||||
|
def services(self) -> list:
|
||||||
|
return [{"id": s, "display_name": w.display_name} for s, w in self.workers.items()]
|
||||||
|
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_enrichment_engine():
|
||||||
|
"""Process-wide engine, created (and started) on first use."""
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
with _lock:
|
||||||
|
if _engine is None:
|
||||||
|
from database.video_database import VideoDatabase
|
||||||
|
from .clients import build_clients
|
||||||
|
db = VideoDatabase()
|
||||||
|
eng = VideoEnrichmentEngine(db, build_clients(db))
|
||||||
|
eng.start_all()
|
||||||
|
_engine = eng
|
||||||
|
return _engine
|
||||||
125
core/video/enrichment/worker.py
Normal file
125
core/video/enrichment/worker.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Video enrichment worker — one per source (TMDB, TVDB).
|
||||||
|
|
||||||
|
Mirrors the music worker: a daemon loop that pulls the next item needing
|
||||||
|
enrichment from video.db, asks its CLIENT to match it, and records the result.
|
||||||
|
The client is injected (a thin TMDB/TVDB adapter), so the worker's loop/queue/
|
||||||
|
status logic is fully testable with a fake client. Isolated: imports only
|
||||||
|
video.db helpers; no music code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("video_enrichment.worker")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoEnrichmentWorker:
|
||||||
|
def __init__(self, db, service, client, display_name=None, interval=2.0, retry_days=30):
|
||||||
|
self.db = db
|
||||||
|
self.service = service
|
||||||
|
self.client = client
|
||||||
|
self.display_name = display_name or service.upper()
|
||||||
|
self.interval = interval
|
||||||
|
self.retry_days = retry_days
|
||||||
|
|
||||||
|
self.running = False
|
||||||
|
self.paused = False
|
||||||
|
self.should_stop = False
|
||||||
|
self._thread = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self.current_item = None
|
||||||
|
self.stats = {"matched": 0, "not_found": 0, "errors": 0}
|
||||||
|
|
||||||
|
# ── lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
def start(self):
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
self.should_stop = False
|
||||||
|
self._stop.clear()
|
||||||
|
self.running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.should_stop = True
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=1.0)
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
self.paused = True
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
self.paused = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
return bool(getattr(self.client, "enabled", False))
|
||||||
|
|
||||||
|
# ── loop ──────────────────────────────────────────────────────────────────
|
||||||
|
def _run(self):
|
||||||
|
while not self.should_stop:
|
||||||
|
if self.paused or not self.enabled:
|
||||||
|
self._stop.wait(1.0)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
did = self.process_one()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("video enrichment %s loop error", self.service)
|
||||||
|
self.stats["errors"] += 1
|
||||||
|
self._stop.wait(5.0)
|
||||||
|
continue
|
||||||
|
if did:
|
||||||
|
self._stop.wait(self.interval) # rate-limit between items
|
||||||
|
else:
|
||||||
|
self.current_item = None
|
||||||
|
self._stop.wait(10.0) # nothing to do — back off
|
||||||
|
|
||||||
|
def process_one(self) -> bool:
|
||||||
|
"""Process a single item. Returns True if one was processed."""
|
||||||
|
item = self.db.enrichment_next(self.service, self.retry_days)
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
self.current_item = {"type": item["kind"], "name": item["title"]}
|
||||||
|
try:
|
||||||
|
result = self.client.match(item["kind"], item["title"], item.get("year"))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("video enrichment %s match failed for %s", self.service, item["title"])
|
||||||
|
self.stats["errors"] += 1
|
||||||
|
self.db.enrichment_apply(self.service, item["kind"], item["id"], matched=False)
|
||||||
|
return True
|
||||||
|
if result and result.get("id"):
|
||||||
|
self.db.enrichment_apply(self.service, item["kind"], item["id"], matched=True,
|
||||||
|
external_id=result["id"], metadata=result.get("metadata"))
|
||||||
|
self.stats["matched"] += 1
|
||||||
|
else:
|
||||||
|
self.db.enrichment_apply(self.service, item["kind"], item["id"], matched=False)
|
||||||
|
self.stats["not_found"] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── status (same shape the music enrichment API returns) ──────────────────
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
breakdown = self.db.enrichment_breakdown(self.service)
|
||||||
|
pending = sum(b["pending"] for b in breakdown.values())
|
||||||
|
running = self.running and not self.paused and self.enabled
|
||||||
|
idle = running and pending == 0 and self.current_item is None
|
||||||
|
progress = {}
|
||||||
|
for kind, b in breakdown.items():
|
||||||
|
total = b["matched"] + b["not_found"] + b["pending"]
|
||||||
|
done = b["matched"] + b["not_found"]
|
||||||
|
progress[kind] = {"matched": b["matched"], "total": total,
|
||||||
|
"percent": round(done / total * 100) if total else 0}
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"running": running,
|
||||||
|
"paused": self.paused,
|
||||||
|
"idle": idle,
|
||||||
|
"current_item": self.current_item,
|
||||||
|
"stats": {**self.stats, "pending": pending},
|
||||||
|
"progress": progress,
|
||||||
|
"breakdown": breakdown,
|
||||||
|
}
|
||||||
105
tests/test_video_enrichment.py
Normal file
105
tests/test_video_enrichment.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Seam tests for the video enrichment workers (experimental branch).
|
||||||
|
|
||||||
|
The worker's loop/queue/status logic is driven by a FAKE client so it's tested
|
||||||
|
without hitting TMDB/TVDB. Also guards that the enrichment package imports
|
||||||
|
nothing from the music side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from database.video_database import VideoDatabase
|
||||||
|
from core.video.enrichment.worker import VideoEnrichmentWorker
|
||||||
|
from core.video.enrichment.engine import VideoEnrichmentEngine
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
enabled = True
|
||||||
|
|
||||||
|
def __init__(self, result):
|
||||||
|
self._result = result
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def match(self, kind, title, year):
|
||||||
|
self.calls.append((kind, title, year))
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db(tmp_path):
|
||||||
|
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_process_one_matches(db):
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021})
|
||||||
|
client = FakeClient({"id": 438631, "metadata": {"overview": "O", "backdrop_url": "/b.jpg"}})
|
||||||
|
w = VideoEnrichmentWorker(db, "tmdb", client)
|
||||||
|
assert w.process_one() is True
|
||||||
|
assert client.calls == [("movie", "Dune", 2021)]
|
||||||
|
with db.connect() as c:
|
||||||
|
row = c.execute("SELECT tmdb_id, tmdb_match_status, overview FROM movies").fetchone()
|
||||||
|
assert (row["tmdb_id"], row["tmdb_match_status"], row["overview"]) == (438631, "matched", "O")
|
||||||
|
assert w.stats["matched"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_process_one_not_found(db):
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
|
||||||
|
w = VideoEnrichmentWorker(db, "tmdb", FakeClient(None))
|
||||||
|
assert w.process_one() is True
|
||||||
|
with db.connect() as c:
|
||||||
|
assert c.execute("SELECT tmdb_match_status FROM movies").fetchone()["tmdb_match_status"] == "not_found"
|
||||||
|
assert w.stats["not_found"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_process_one_no_items_returns_false(db):
|
||||||
|
assert VideoEnrichmentWorker(db, "tmdb", FakeClient(None)).process_one() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_match_exception_marks_not_found_and_counts_error(db):
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
|
||||||
|
|
||||||
|
class Boom:
|
||||||
|
enabled = True
|
||||||
|
def match(self, *a): raise RuntimeError("api down")
|
||||||
|
|
||||||
|
w = VideoEnrichmentWorker(db, "tmdb", Boom())
|
||||||
|
assert w.process_one() is True # doesn't crash the loop
|
||||||
|
assert w.stats["errors"] == 1
|
||||||
|
with db.connect() as c:
|
||||||
|
assert c.execute("SELECT tmdb_match_status FROM movies").fetchone()["tmdb_match_status"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_get_stats_shape(db):
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
|
||||||
|
s = VideoEnrichmentWorker(db, "tmdb", FakeClient(None)).get_stats()
|
||||||
|
assert {"enabled", "running", "paused", "idle", "current_item", "stats",
|
||||||
|
"progress", "breakdown"} <= set(s)
|
||||||
|
assert s["enabled"] is True
|
||||||
|
assert s["stats"]["pending"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_disabled_without_key(db):
|
||||||
|
class NoKey:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
w = VideoEnrichmentWorker(db, "tmdb", NoKey())
|
||||||
|
assert w.enabled is False
|
||||||
|
assert w.get_stats()["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_builds_and_lists_workers(db):
|
||||||
|
eng = VideoEnrichmentEngine(db, {"tmdb": FakeClient(None), "tvdb": FakeClient(None)})
|
||||||
|
assert {s["id"] for s in eng.services()} == {"tmdb", "tvdb"}
|
||||||
|
assert eng.worker("tmdb") is not None and eng.worker("nope") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrichment_package_imports_nothing_from_music():
|
||||||
|
base = Path(__file__).resolve().parent.parent / "core" / "video" / "enrichment"
|
||||||
|
for py in base.glob("*.py"):
|
||||||
|
for line in py.read_text(encoding="utf-8").splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s.startswith("import ") or s.startswith("from "):
|
||||||
|
assert "music" not in s.lower(), f"{py.name}: music import leaked: {s!r}"
|
||||||
Loading…
Reference in a new issue