From 314e587094cfb030866a2ff779ab4997ccf1483c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 18 Jun 2026 22:33:36 -0700 Subject: [PATCH] =?UTF-8?q?video=20enrichment:=20add=20Trakt=20(community?= =?UTF-8?q?=20audience=20rating)=20=E2=80=94=20full=20worker=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the new enrichment services, wired to the SAME standard as the rest: - TraktWorker backfill (core/video/enrichment/backfill.py): looks a title up by its IMDb id (?extended=full) and gap-fills the community rating + vote count. Registered in build_backfill_workers. - DB: trakt_rating/trakt_votes/trakt_status/trakt_attempted columns + _BACKFILL / _BACKFILL_COLS registration; detail payloads return trakt_rating/votes. - Connections tab: Trakt service frame (Client ID field + Test button), wired into video-settings.js load/save/bindings and the /enrichment/config GET+POST. - Worker-manager modal + orb: WORKERS registry entry (red ★) + status-poll SERVICES list, so it gets the same orb animation + per-service matched/pending/error card. - Detail page: a 'Trakt 8.2' rating chip alongside IMDb/RT/Metacritic (+ CSS). 5 new tests + 2 fixed-set assertions updated; 249 video tests green, ruff clean. --- api/video/enrichment.py | 2 + core/video/enrichment/backfill.py | 67 +++++++++++++++++++ database/video_database.py | 14 ++++ tests/test_video_api.py | 4 +- tests/test_video_backfill.py | 48 ++++++++++++- webui/index.html | 18 +++++ webui/static/video/video-detail.js | 5 ++ .../static/video/video-enrichment-manager.js | 1 + webui/static/video/video-enrichment.js | 2 +- webui/static/video/video-settings.js | 6 +- webui/static/video/video-side.css | 1 + 11 files changed, 162 insertions(+), 6 deletions(-) diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 5f4add1c..df6e6de9 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -53,6 +53,7 @@ def register_routes(bp): # Backfill-worker keys (free, optional) + no-key toggles. "fanart_api_key": db.get_setting("fanart_api_key") or "", "opensubtitles_api_key": db.get_setting("opensubtitles_api_key") or "", + "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", "billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1", @@ -88,6 +89,7 @@ def register_routes(bp): put_key("tvdb_api_key") put_key("fanart_api_key") 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"): if flag in body: diff --git a/core/video/enrichment/backfill.py b/core/video/enrichment/backfill.py index 774385cf..f8e07a88 100644 --- a/core/video/enrichment/backfill.py +++ b/core/video/enrichment/backfill.py @@ -486,8 +486,75 @@ class OpenSubtitlesWorker(VideoBackfillWorker): return self.db.backfill_breakdown("opensubtitles") +# ── Trakt (free API key) — community audience rating + vote count ───────────── +class TraktWorker(VideoBackfillWorker): + BASE = "https://api.trakt.tv" + + def __init__(self, db): + super().__init__(db, "trakt", "Trakt", interval=1.0) + + def _key(self): + return (self.db.get_setting("trakt_api_key") or "").strip() + + def _enabled(self): + return bool(self._key()) + + def _headers(self): + return {"trakt-api-key": self._key(), "trakt-api-version": "2", + "Content-Type": "application/json"} + + def test(self): + if not self._key(): + return (False, "No Trakt API key") + try: + j = _http_get_json(self.BASE + "/shows/trending", {"limit": 1}, headers=self._headers()) + return (j is not None, "Trakt key OK" if j is not None else "No response") + except _Unauthorized: + return (False, "Trakt rejected the API key (check the Client ID)") + except Exception as e: + return (False, str(e)) + + def next_item(self): + return self.db.backfill_next("trakt") + + def fetch(self, item): + # Trakt accepts an IMDb id directly as the {id} slug; ?extended=full carries + # the community rating + vote count on the summary. + if not self._key(): + return None + imdb = str(item.get("imdb_id") or "").strip() + if not imdb.lower().startswith("tt"): + return None + typ = "movies" if item["kind"] == "movie" else "shows" + j = _http_get_json(self.BASE + "/" + typ + "/" + imdb, {"extended": "full"}, + headers=self._headers()) + if not isinstance(j, dict): + return None + out = {} + rating = j.get("rating") + if isinstance(rating, (int, float)) and rating > 0: + out["trakt_rating"] = round(float(rating), 1) + votes = j.get("votes") + if isinstance(votes, int) and votes > 0: + out["trakt_votes"] = votes + return out or None + + def record_ok(self, item, data): + self.db.backfill_mark("trakt", item["kind"], item["id"], "ok", columns=data) + + def record_empty(self, item): + self.db.backfill_mark("trakt", item["kind"], item["id"], "not_found") + + def record_error(self, item): + self.db.backfill_mark("trakt", item["kind"], item["id"], "error") + + def breakdown(self): + return self.db.backfill_breakdown("trakt") + + 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), )} diff --git a/database/video_database.py b/database/video_database.py index 3641c3e7..2f548094 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -87,11 +87,18 @@ _BACKFILL = { "show": ("shows", "subs_status", "subs_attempted", "(imdb_id IS NOT NULL OR tmdb_id IS NOT NULL)"), }, + "trakt": { + "movie": ("movies", "trakt_status", "trakt_attempted", "imdb_id IS NOT NULL"), + "show": ("shows", "trakt_status", "trakt_attempted", "imdb_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 +# on that single pass. _BACKFILL_COLS = { "fanart": {"logo_url", "backdrop_url", "poster_url", "clearart_url", "banner_url"}, "opensubtitles": {"subtitle_langs"}, + "trakt": {"trakt_rating", "trakt_votes"}, } # Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent). @@ -148,6 +155,11 @@ _COLUMN_MIGRATIONS = [ ("movies", "subs_status", "TEXT"), ("movies", "subs_attempted", "TEXT"), ("shows", "subtitle_langs", "TEXT"), ("shows", "subs_status", "TEXT"), ("shows", "subs_attempted", "TEXT"), + # Trakt community rating backfill (by imdb id) — a distinct audience score + vote count + ("movies", "trakt_rating", "REAL"), ("movies", "trakt_votes", "INTEGER"), + ("movies", "trakt_status", "TEXT"), ("movies", "trakt_attempted", "TEXT"), + ("shows", "trakt_rating", "REAL"), ("shows", "trakt_votes", "INTEGER"), + ("shows", "trakt_status", "TEXT"), ("shows", "trakt_attempted", "TEXT"), ] @@ -1560,6 +1572,7 @@ class VideoDatabase: "first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"], "imdb_rating": show["imdb_rating"], "rt_rating": show["rt_rating"], "metacritic": show["metacritic"], + "trakt_rating": show["trakt_rating"], "trakt_votes": show["trakt_votes"], "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"]), @@ -2608,6 +2621,7 @@ class VideoDatabase: "content_rating": m["content_rating"], "tagline": m["tagline"], "rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres, "imdb_rating": m["imdb_rating"], "rt_rating": m["rt_rating"], "metacritic": m["metacritic"], + "trakt_rating": m["trakt_rating"], "trakt_votes": m["trakt_votes"], "cast": credits["cast"], "crew": credits["crew"], "tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"], "has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]), diff --git a/tests/test_video_api.py b/tests/test_video_api.py index bf28c923..529b18f7 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -334,7 +334,7 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch): try: assert client.get("/api/video/enrichment/config").get_json() == { "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "", - "fanart_api_key": "", "opensubtitles_api_key": "", + "fanart_api_key": "", "opensubtitles_api_key": "", "trakt_api_key": "", "ryd_enabled": True, "sponsorblock_enabled": True, "billboard_autoplay": True, "watch_region": "US"} client.post("/api/video/enrichment/config", @@ -344,7 +344,7 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch): "billboard_autoplay": False, "watch_region": "gb"}) 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", + "fanart_api_key": "fa", "opensubtitles_api_key": "os", "trakt_api_key": "", "ryd_enabled": False, "sponsorblock_enabled": True, "billboard_autoplay": False, "watch_region": "GB"} assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om" diff --git a/tests/test_video_backfill.py b/tests/test_video_backfill.py index 748cc720..56f05179 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, + RydWorker, SponsorBlockWorker, FanartWorker, OpenSubtitlesWorker, TraktWorker, VideoBackfillWorker, _RateLimited, _Unauthorized, build_backfill_workers, ) @@ -178,7 +178,51 @@ 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"} + assert set(build_backfill_workers(db)) == {"ryd", "sponsorblock", "fanart", "opensubtitles", "trakt"} + + +# ── Trakt (community rating backfill, keyed on imdb id) ──────────────────────── +def test_trakt_queue_keyed_on_imdb(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Fight Club", "year": 1999}) + assert db.backfill_next("trakt") is None # no imdb id → not queued + with db.connect() as c: + c.execute("UPDATE movies SET imdb_id='tt0137523' WHERE id=?", (mid,)) + c.commit() + nxt = db.backfill_next("trakt") + assert nxt["kind"] == "movie" and nxt["imdb_id"] == "tt0137523" + + +def test_trakt_worker_records_rating(db): + 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() + w = TraktWorker(db) + w.fetch = lambda item: {"trakt_rating": 8.2, "trakt_votes": 1234} + assert w.process_one() is True + with db.connect() as c: + r = c.execute("SELECT trakt_rating, trakt_votes, trakt_status FROM movies WHERE id=?", (mid,)).fetchone() + assert r["trakt_rating"] == 8.2 and r["trakt_votes"] == 1234 and r["trakt_status"] == "ok" + assert db.backfill_breakdown("trakt")["movie"]["matched"] == 1 + + +def test_trakt_fetch_parses_summary(db, monkeypatch): + db.set_setting("trakt_api_key", "client-id") + import core.video.enrichment.backfill as bf + monkeypatch.setattr(bf, "_http_get_json", + lambda url, params=None, headers=None, timeout=12: {"rating": 8.234, "votes": 5000}) + w = TraktWorker(db) + out = w.fetch({"kind": "movie", "imdb_id": "tt0137523"}) + assert out == {"trakt_rating": 8.2, "trakt_votes": 5000} # rounded to 1dp + + +def test_trakt_fetch_needs_imdb_and_key(db, monkeypatch): + import core.video.enrichment.backfill as bf + monkeypatch.setattr(bf, "_http_get_json", lambda *a, **k: {"rating": 9}) + w = TraktWorker(db) + assert w.fetch({"kind": "movie", "imdb_id": "tt1"}) is None # no key configured + db.set_setting("trakt_api_key", "k") + assert w.fetch({"kind": "movie", "imdb_id": "550"}) is None # not a tt-id def test_backfill_module_imports_nothing_from_music(): diff --git a/webui/index.html b/webui/index.html index 749251ae..11032111 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5566,6 +5566,24 @@ +
+
+ +

Trakt (Audience Rating)

+ +
+
+
+ + +
+
+
Create a free API app at trakt.tv and paste its Client ID.
+
Adds Trakt's community audience score (and vote count) to movies & shows.
+
+ +
+
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 6f3d5e1b..db226c55 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -327,6 +327,11 @@ items.push('' + 'MC' + d.metacritic + ''); } + if (d.trakt_rating) { + var tv = d.trakt_votes ? ' title="' + esc(d.trakt_votes) + ' Trakt votes"' : ''; + items.push('Trakt' + + (Math.round(d.trakt_rating * 10) / 10) + ''); + } host.innerHTML = items.join(''); host.hidden = !items.length; } diff --git a/webui/static/video/video-enrichment-manager.js b/webui/static/video/video-enrichment-manager.js index 49f3ea94..f2252da5 100644 --- a/webui/static/video/video-enrichment-manager.js +++ b/webui/static/video/video-enrichment-manager.js @@ -25,6 +25,7 @@ { id: 'opensubtitles', name: 'OpenSubtitles', color: '#22a079', rgb: '34, 160, 121', kinds: ['movie', 'show'], glyph: '💬' }, { id: 'ryd', name: 'YouTube Votes', color: '#ef4444', rgb: '239, 68, 68', kinds: ['video'], glyph: '👍' }, { id: 'sponsorblock', name: 'SponsorBlock', color: '#00b4a0', rgb: '0, 180, 160', kinds: ['video'], glyph: '⏭' }, + { id: 'trakt', name: 'Trakt', color: '#ed1c24', rgb: '237, 28, 36', kinds: ['movie', 'show'], glyph: '★' }, ]; function workerDef(id) { diff --git a/webui/static/video/video-enrichment.js b/webui/static/video/video-enrichment.js index eedb06a5..4ece8f0b 100644 --- a/webui/static/video/video-enrichment.js +++ b/webui/static/video/video-enrichment.js @@ -18,7 +18,7 @@ // 'enrichment:') — including the standalone YouTube date enricher — so the // browser never polls /api/video/enrichment//status. var SERVICES = ['tmdb', 'tvdb', 'omdb', 'youtube', - 'fanart', 'opensubtitles', 'ryd', 'sponsorblock']; + 'fanart', 'opensubtitles', 'ryd', 'sponsorblock', 'trakt']; function onVideoSide() { return document.body.getAttribute('data-side') === 'video'; diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 0dd1b425..90bb6a37 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -216,6 +216,8 @@ if (fa && d.fanart_api_key != null) fa.value = d.fanart_api_key; var sub = document.getElementById('opensubtitles-api-key'); if (sub && d.opensubtitles_api_key != null) sub.value = d.opensubtitles_api_key; + var trakt = document.getElementById('trakt-api-key'); + if (trakt && d.trakt_api_key != null) trakt.value = d.trakt_api_key; var ryd = document.getElementById('video-ryd-enabled'); if (ryd && d.ryd_enabled != null) ryd.checked = !!d.ryd_enabled; var sb = document.getElementById('video-sponsorblock-enabled'); @@ -247,6 +249,7 @@ var o = document.getElementById('omdb-api-key'); var fa = document.getElementById('fanart-api-key'); var sub = document.getElementById('opensubtitles-api-key'); + var trakt = document.getElementById('trakt-api-key'); var ryd = document.getElementById('video-ryd-enabled'); var sb = document.getElementById('video-sponsorblock-enabled'); return fetch(CONFIG_URL, { @@ -257,6 +260,7 @@ omdb_api_key: o ? o.value : '', fanart_api_key: fa ? fa.value : '', opensubtitles_api_key: sub ? sub.value : '', + trakt_api_key: trakt ? trakt.value : '', ryd_enabled: ryd ? ryd.checked : true, sponsorblock_enabled: sb ? sb.checked : true, }) @@ -301,7 +305,7 @@ } // Enrichment keys save on blur/change (turns the workers on). ['tmdb-api-key', 'tvdb-api-key', 'omdb-api-key', - 'fanart-api-key', 'opensubtitles-api-key', + 'fanart-api-key', 'opensubtitles-api-key', 'trakt-api-key', 'video-ryd-enabled', 'video-sponsorblock-enabled'].forEach(function (id) { var el = document.getElementById(id); if (el) el.addEventListener('change', function () { saveKeys(); }); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index f9c02452..168cd11c 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -787,6 +787,7 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a .vd-rt--mc-good .vd-rt-tag { background: #6c3; color: #000; } .vd-rt--mc-mid .vd-rt-tag { background: #fc3; color: #000; } .vd-rt--mc-bad .vd-rt-tag { background: #f00; color: #fff; } +.vd-rt--trakt .vd-rt-tag { background: #ed1c24; color: #fff; } /* ── OMDb worker uses a ★ glyph (no clean brand logo) ─────────────────────── */ .video-enrich-glyph {