diff --git a/api/video/enrichment.py b/api/video/enrichment.py index df6e6de9..361edfb9 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -56,6 +56,7 @@ def register_routes(bp): "trakt_api_key": db.get_setting("trakt_api_key") or "", "ryd_enabled": (db.get_setting("ryd_enabled") or "1") == "1", "sponsorblock_enabled": (db.get_setting("sponsorblock_enabled") or "1") == "1", + "tvmaze_enabled": (db.get_setting("tvmaze_enabled") or "1") == "1", "billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1", "watch_region": (db.get_setting("watch_region") or "US").upper(), }) @@ -91,7 +92,7 @@ def register_routes(bp): put_key("opensubtitles_api_key") put_key("trakt_api_key") # No-key worker on/off toggles (read live by the worker — no rebuild needed). - for flag in ("ryd_enabled", "sponsorblock_enabled"): + for flag in ("ryd_enabled", "sponsorblock_enabled", "tvmaze_enabled"): if flag in body: db.set_setting(flag, "1" if body.get(flag) else "0") if "billboard_autoplay" in body: diff --git a/core/video/enrichment/backfill.py b/core/video/enrichment/backfill.py index f8e07a88..7d778721 100644 --- a/core/video/enrichment/backfill.py +++ b/core/video/enrichment/backfill.py @@ -552,9 +552,60 @@ class TraktWorker(VideoBackfillWorker): return self.db.backfill_breakdown("trakt") +# ── TVmaze (no key) — TV community rating ───────────────────────────────────── +class TVmazeWorker(VideoBackfillWorker): + BASE = "https://api.tvmaze.com" + + def __init__(self, db): + super().__init__(db, "tvmaze", "TVmaze", interval=0.8) + + def _enabled(self): + # Free, keyless — on by default; user can switch it off in settings. + return str(self.db.get_setting("tvmaze_enabled") or "1") != "0" + + def test(self): + try: + j = _http_get_json(self.BASE + "/lookup/shows", {"imdb": "tt0903747"}) # Breaking Bad + return (j is not None, "TVmaze reachable" if j is not None else "No response") + except Exception as e: + return (False, str(e)) + + def next_item(self): + return self.db.backfill_next("tvmaze") + + def fetch(self, item): + imdb = str(item.get("imdb_id") or "").strip() + tvdb = item.get("tvdb_id") + if imdb.lower().startswith("tt"): + params = {"imdb": imdb} + elif tvdb: + params = {"thetvdb": tvdb} + else: + return None + j = _http_get_json(self.BASE + "/lookup/shows", params) + if not isinstance(j, dict): + return None + rating = (j.get("rating") or {}).get("average") + if isinstance(rating, (int, float)) and rating > 0: + return {"tvmaze_rating": round(float(rating), 1)} + return None + + def record_ok(self, item, data): + self.db.backfill_mark("tvmaze", item["kind"], item["id"], "ok", columns=data) + + def record_empty(self, item): + self.db.backfill_mark("tvmaze", item["kind"], item["id"], "not_found") + + def record_error(self, item): + self.db.backfill_mark("tvmaze", item["kind"], item["id"], "error") + + def breakdown(self): + return self.db.backfill_breakdown("tvmaze") + + def build_backfill_workers(db) -> dict: """All backfill workers, keyed by service id, for the engine registry.""" return {w.service: w for w in ( RydWorker(db), SponsorBlockWorker(db), FanartWorker(db), OpenSubtitlesWorker(db), - TraktWorker(db), + TraktWorker(db), TVmazeWorker(db), )} diff --git a/database/video_database.py b/database/video_database.py index 2f548094..2458d356 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -91,6 +91,10 @@ _BACKFILL = { "movie": ("movies", "trakt_status", "trakt_attempted", "imdb_id IS NOT NULL"), "show": ("shows", "trakt_status", "trakt_attempted", "imdb_id IS NOT NULL"), }, + "tvmaze": { # TV-only: TVmaze has no movie database + "show": ("shows", "tvmaze_status", "tvmaze_attempted", + "(imdb_id IS NOT NULL OR tvdb_id IS NOT NULL)"), + }, } # Columns each backfill service may gap-fill (whitelist; never clobbers server data). # A worker visits each item once (status IS NULL), so these NULL columns are written @@ -99,6 +103,7 @@ _BACKFILL_COLS = { "fanart": {"logo_url", "backdrop_url", "poster_url", "clearart_url", "banner_url"}, "opensubtitles": {"subtitle_langs"}, "trakt": {"trakt_rating", "trakt_votes"}, + "tvmaze": {"tvmaze_rating"}, } # Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent). @@ -160,6 +165,9 @@ _COLUMN_MIGRATIONS = [ ("movies", "trakt_status", "TEXT"), ("movies", "trakt_attempted", "TEXT"), ("shows", "trakt_rating", "REAL"), ("shows", "trakt_votes", "INTEGER"), ("shows", "trakt_status", "TEXT"), ("shows", "trakt_attempted", "TEXT"), + # TVmaze community rating backfill (TV only) + ("shows", "tvmaze_rating", "REAL"), + ("shows", "tvmaze_status", "TEXT"), ("shows", "tvmaze_attempted", "TEXT"), ] @@ -1573,6 +1581,7 @@ class VideoDatabase: "imdb_rating": show["imdb_rating"], "rt_rating": show["rt_rating"], "metacritic": show["metacritic"], "trakt_rating": show["trakt_rating"], "trakt_votes": show["trakt_votes"], + "tvmaze_rating": show["tvmaze_rating"], "genres": genres, "cast": credits["cast"], "crew": credits["crew"], "tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"], "has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]), diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 529b18f7..b97f133c 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -335,7 +335,7 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch): assert client.get("/api/video/enrichment/config").get_json() == { "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "", "fanart_api_key": "", "opensubtitles_api_key": "", "trakt_api_key": "", - "ryd_enabled": True, "sponsorblock_enabled": True, + "ryd_enabled": True, "sponsorblock_enabled": True, "tvmaze_enabled": True, "billboard_autoplay": True, "watch_region": "US"} client.post("/api/video/enrichment/config", json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", @@ -345,7 +345,7 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch): assert client.get("/api/video/enrichment/config").get_json() == { "tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", "fanart_api_key": "fa", "opensubtitles_api_key": "os", "trakt_api_key": "", - "ryd_enabled": False, "sponsorblock_enabled": True, + "ryd_enabled": False, "sponsorblock_enabled": True, "tvmaze_enabled": True, "billboard_autoplay": False, "watch_region": "GB"} assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om" assert client.get("/api/video/prefs").get_json() == { diff --git a/tests/test_video_backfill.py b/tests/test_video_backfill.py index 56f05179..b9041bf5 100644 --- a/tests/test_video_backfill.py +++ b/tests/test_video_backfill.py @@ -12,7 +12,7 @@ import pytest from database.video_database import VideoDatabase from core.video.enrichment.backfill import ( - RydWorker, SponsorBlockWorker, FanartWorker, OpenSubtitlesWorker, TraktWorker, + RydWorker, SponsorBlockWorker, FanartWorker, OpenSubtitlesWorker, TraktWorker, TVmazeWorker, VideoBackfillWorker, _RateLimited, _Unauthorized, build_backfill_workers, ) @@ -178,7 +178,8 @@ def test_get_stats_shape_matches_matcher_worker(db): def test_build_backfill_workers_set(db): - assert set(build_backfill_workers(db)) == {"ryd", "sponsorblock", "fanart", "opensubtitles", "trakt"} + assert set(build_backfill_workers(db)) == { + "ryd", "sponsorblock", "fanart", "opensubtitles", "trakt", "tvmaze"} # ── Trakt (community rating backfill, keyed on imdb id) ──────────────────────── @@ -225,6 +226,45 @@ def test_trakt_fetch_needs_imdb_and_key(db, monkeypatch): assert w.fetch({"kind": "movie", "imdb_id": "550"}) is None # not a tt-id +# ── TVmaze (no-key, TV-only community rating) ───────────────────────────────── +def test_tvmaze_is_show_only_and_enabled_by_default(db): + w = TVmazeWorker(db) + assert w.enabled is True # keyless → on by default + db.set_setting("tvmaze_enabled", "0") + assert w.enabled is False + # No movie entry in the backfill map → movies are never queued for tvmaze. + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M"}) + with db.connect() as c: + c.execute("UPDATE movies SET imdb_id='tt1' WHERE id=?", (mid,)) + c.commit() + assert db.backfill_next("tvmaze") is None + + +def test_tvmaze_worker_records_rating(db): + with db.connect() as c: + c.execute("INSERT INTO shows (title, year) VALUES ('S', 2008)") + sid = c.execute("SELECT id FROM shows WHERE title='S'").fetchone()["id"] + c.execute("UPDATE shows SET imdb_id='tt0903747' WHERE id=?", (sid,)) + c.commit() + nxt = db.backfill_next("tvmaze") + assert nxt["kind"] == "show" + w = TVmazeWorker(db) + w.fetch = lambda item: {"tvmaze_rating": 9.3} + assert w.process_one() is True + with db.connect() as c: + r = c.execute("SELECT tvmaze_rating, tvmaze_status FROM shows WHERE id=?", (sid,)).fetchone() + assert r["tvmaze_rating"] == 9.3 and r["tvmaze_status"] == "ok" + assert db.backfill_breakdown("tvmaze")["show"]["matched"] == 1 + + +def test_tvmaze_fetch_parses_lookup(db, monkeypatch): + import core.video.enrichment.backfill as bf + monkeypatch.setattr(bf, "_http_get_json", + lambda url, params=None, headers=None, timeout=12: {"rating": {"average": 9.34}}) + out = TVmazeWorker(db).fetch({"kind": "show", "imdb_id": "tt0903747"}) + assert out == {"tvmaze_rating": 9.3} + + def test_backfill_module_imports_nothing_from_music(): path = Path(__file__).resolve().parent.parent / "core" / "video" / "enrichment" / "backfill.py" for line in path.read_text(encoding="utf-8").splitlines(): diff --git a/webui/index.html b/webui/index.html index 11032111..b55259f5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5584,6 +5584,22 @@ +