video enrichment: add Trakt (community audience rating) — full worker parity
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.
This commit is contained in:
parent
c55a4fa10e
commit
314e587094
11 changed files with 162 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -5566,6 +5566,24 @@
|
|||
<button class="test-button" type="button" data-video-test-service="opensubtitles">Test OpenSubtitles</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-service-frame stg-service" data-video-service="trakt">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #ed1c24;"></span>
|
||||
<h4 class="service-title">Trakt (Audience Rating)</h4>
|
||||
<span class="stg-service-chevron">›</span>
|
||||
</div>
|
||||
<div class="stg-service-body">
|
||||
<div class="form-group">
|
||||
<label>API Key (Client ID):</label>
|
||||
<input type="password" id="trakt-api-key" placeholder="Trakt Client ID">
|
||||
</div>
|
||||
<div class="callback-info">
|
||||
<div class="callback-help">Create a free API app at <a href="https://trakt.tv/oauth/applications" target="_blank" style="color: #ed1c24;">trakt.tv</a> and paste its <strong>Client ID</strong>.</div>
|
||||
<div class="callback-help">Adds Trakt's community audience score (and vote count) to movies & shows.</div>
|
||||
</div>
|
||||
<button class="test-button" type="button" data-video-test-service="trakt">Test Trakt</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-service-frame stg-service" data-video-service="ytextras">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #ef4444;"></span>
|
||||
|
|
|
|||
|
|
@ -327,6 +327,11 @@
|
|||
items.push('<span class="vd-rt vd-rt--mc vd-rt--mc-' + cls + '">' +
|
||||
'<span class="vd-rt-tag">MC</span>' + d.metacritic + '</span>');
|
||||
}
|
||||
if (d.trakt_rating) {
|
||||
var tv = d.trakt_votes ? ' title="' + esc(d.trakt_votes) + ' Trakt votes"' : '';
|
||||
items.push('<span class="vd-rt vd-rt--trakt"' + tv + '><span class="vd-rt-tag">Trakt</span>' +
|
||||
(Math.round(d.trakt_rating * 10) / 10) + '</span>');
|
||||
}
|
||||
host.innerHTML = items.join('');
|
||||
host.hidden = !items.length;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
// 'enrichment:<svc>') — including the standalone YouTube date enricher — so the
|
||||
// browser never polls /api/video/enrichment/<svc>/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';
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue