video enrichment: add TVmaze (TV community rating) — keyless worker, full parity

Second service. Keyless (no API key) → an enable toggle in a new 'Community Data
(No Key)' connections frame, mirroring the YouTube-Extras pattern.
- TVmazeWorker: TV-only (no movie DB), looks a show up by imdb/thetvdb id and
  gap-fills the TVmaze community rating. On by default; tvmaze_enabled toggle.
- DB: tvmaze_rating/status/attempted on shows + _BACKFILL/_BACKFILL_COLS (show only);
  show_detail returns tvmaze_rating.
- config GET/POST tvmaze_enabled; manager orb (teal 📺, show-kind) + status poll;
  detail page TVmaze rating chip (+ CSS).
- 4 new tests + the 3 fixed-set/config-exact assertions updated.

Note: one UNRELATED test (youtube_status_route) flaps on a WSL WAL 'disk I/O error'
in this sandbox (pass/fail/fail across reruns of identical code) — environmental, not
this change; the TVmaze + config tests pass consistently.
This commit is contained in:
BoulderBadgeDad 2026-06-18 22:56:02 -07:00
parent 314e587094
commit 5a95619e22
11 changed files with 135 additions and 8 deletions

View file

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

View file

@ -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),
)}

View file

@ -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"]),

View file

@ -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() == {

View file

@ -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():

View file

@ -5584,6 +5584,22 @@
<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="nokey">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #3dd6c0;"></span>
<h4 class="service-title">Community Data (No Key)</h4>
<span class="stg-service-chevron"></span>
</div>
<div class="stg-service-body">
<label class="vid-pref-row">
<input type="checkbox" id="video-tvmaze-enabled" checked>
<span>TVmaze (TV community rating)</span>
</label>
<div class="callback-info">
<div class="callback-help">Free community APIs — no key needed. Gap-fill extra data on your library in the background.</div>
</div>
</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>

View file

@ -332,6 +332,10 @@
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>');
}
if (d.tvmaze_rating) {
items.push('<span class="vd-rt vd-rt--tvmaze"><span class="vd-rt-tag">TVmaze</span>' +
(Math.round(d.tvmaze_rating * 10) / 10) + '</span>');
}
host.innerHTML = items.join('');
host.hidden = !items.length;
}

View file

@ -26,6 +26,7 @@
{ 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: '★' },
{ id: 'tvmaze', name: 'TVmaze', color: '#3dd6c0', rgb: '61, 214, 192', kinds: ['show'], glyph: '📺' },
];
function workerDef(id) {

View file

@ -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', 'trakt'];
'fanart', 'opensubtitles', 'ryd', 'sponsorblock', 'trakt', 'tvmaze'];
function onVideoSide() {
return document.body.getAttribute('data-side') === 'video';

View file

@ -222,6 +222,8 @@
if (ryd && d.ryd_enabled != null) ryd.checked = !!d.ryd_enabled;
var sb = document.getElementById('video-sponsorblock-enabled');
if (sb && d.sponsorblock_enabled != null) sb.checked = !!d.sponsorblock_enabled;
var tvm = document.getElementById('video-tvmaze-enabled');
if (tvm && d.tvmaze_enabled != null) tvm.checked = !!d.tvmaze_enabled;
var ap = document.getElementById('video-billboard-autoplay');
if (ap && d.billboard_autoplay != null) ap.checked = !!d.billboard_autoplay;
var wr = document.getElementById('video-watch-region');
@ -252,6 +254,7 @@
var trakt = document.getElementById('trakt-api-key');
var ryd = document.getElementById('video-ryd-enabled');
var sb = document.getElementById('video-sponsorblock-enabled');
var tvm = document.getElementById('video-tvmaze-enabled');
return fetch(CONFIG_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
@ -263,6 +266,7 @@
trakt_api_key: trakt ? trakt.value : '',
ryd_enabled: ryd ? ryd.checked : true,
sponsorblock_enabled: sb ? sb.checked : true,
tvmaze_enabled: tvm ? tvm.checked : true,
})
}).then(function () { if (!silent) toast('API keys saved', 'success'); })
.catch(function () { /* ignore */ });
@ -306,7 +310,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', 'trakt-api-key',
'video-ryd-enabled', 'video-sponsorblock-enabled'].forEach(function (id) {
'video-ryd-enabled', 'video-sponsorblock-enabled', 'video-tvmaze-enabled'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('change', function () { saveKeys(); });
});

View file

@ -788,6 +788,7 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
.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; }
.vd-rt--tvmaze .vd-rt-tag { background: #3dd6c0; color: #00211d; }
/* ── OMDb worker uses a ★ glyph (no clean brand logo) ─────────────────────── */
.video-enrich-glyph {