video enrichment: add AniList (anime score) — keyless GraphQL worker, opt-in

Third service. Keyless GraphQL (new _http_post_json helper) → enable toggle in the
'Community Data (No Key)' frame, OFF by default (anime-niche + title-search match).
- AniListWorker: TV-only, searches AniList by title and stores the anime averageScore
  (0-100), with a conservative normalized-title guard so a fuzzy anime search can't
  attach a score to a non-anime show. anilist_enabled toggle (default off).
- DB: anilist_score/status/attempted on shows + _BACKFILL/_BACKFILL_COLS (keyed on
  title); show_detail returns anilist_score.
- config GET/POST anilist_enabled (default 0); manager orb (blue 🎌) + status poll;
  detail page 'AniList 85%' chip (+ CSS).
- 4 new tests (incl. the title-mismatch rejection) + fixed-set/config assertions.

27 backfill tests green, ruff clean. (Note: the youtube_status_route test still
flaps on the sandbox WSL WAL disk-I/O — environmental, unrelated.)
video
BoulderBadgeDad 1 week ago
parent 5a95619e22
commit 6f8a2a3f7a

@ -57,6 +57,7 @@ def register_routes(bp):
"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",
"anilist_enabled": (db.get_setting("anilist_enabled") or "0") == "1",
"billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1",
"watch_region": (db.get_setting("watch_region") or "US").upper(),
})
@ -92,7 +93,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", "tvmaze_enabled"):
for flag in ("ryd_enabled", "sponsorblock_enabled", "tvmaze_enabled", "anilist_enabled"):
if flag in body:
db.set_setting(flag, "1" if body.get(flag) else "0")
if "billboard_autoplay" in body:

@ -17,6 +17,7 @@ Isolated: imports only video.db helpers + requests; no music code.
from __future__ import annotations
import json
import re
import threading
import time
@ -64,6 +65,37 @@ def _http_get_json(url, params=None, headers=None, timeout=12):
return None
def _http_post_json(url, json_body, headers=None, timeout=12):
"""POST JSON → parsed JSON, with the same status semantics as _http_get_json
(for GraphQL services like AniList). 404 None; 401/403 _Unauthorized;
429 _RateLimited; other non-2xx raises."""
import requests
h = {"User-Agent": _UA, "Content-Type": "application/json", "Accept": "application/json"}
if headers:
h.update(headers)
r = requests.post(url, json=json_body, headers=h, timeout=timeout)
if r.status_code == 404:
return None
if r.status_code in (401, 403):
raise _Unauthorized()
if r.status_code == 429:
try:
ra = int(r.headers.get("Retry-After") or 60)
except (TypeError, ValueError):
ra = 60
raise _RateLimited(ra)
r.raise_for_status()
try:
return r.json()
except Exception:
return None
def _norm_title(s):
"""Lowercase alphanumerics only — for conservative title matching."""
return re.sub(r"[^a-z0-9]+", "", str(s or "").lower())
# ── base worker (lifecycle + loop + status; mirrors VideoEnrichmentWorker) ────
class VideoBackfillWorker:
is_ratings = False
@ -603,9 +635,72 @@ class TVmazeWorker(VideoBackfillWorker):
return self.db.backfill_breakdown("tvmaze")
# ── AniList (no key, GraphQL) — anime average score ───────────────────────────
_ANILIST_QUERY = (
"query($search:String){Media(search:$search,type:ANIME){"
"averageScore title{romaji english}}}"
)
class AniListWorker(VideoBackfillWorker):
BASE = "https://graphql.anilist.co"
def __init__(self, db):
super().__init__(db, "anilist", "AniList", interval=1.0)
def _enabled(self):
# OFF by default — anime-only + title-search matching, so it's opt-in to
# avoid touching every show in a non-anime library.
return str(self.db.get_setting("anilist_enabled") or "0") == "1"
def test(self):
try:
j = _http_post_json(self.BASE, {"query": _ANILIST_QUERY,
"variables": {"search": "Cowboy Bebop"}})
ok = isinstance(j, dict) and (j.get("data") or {}).get("Media") is not None
return (ok, "AniList reachable" if ok else "No response")
except Exception as e:
return (False, str(e))
def next_item(self):
return self.db.backfill_next("anilist")
def fetch(self, item):
title = item.get("title")
if not title:
return None
j = _http_post_json(self.BASE, {"query": _ANILIST_QUERY, "variables": {"search": title}})
media = (j.get("data") or {}).get("Media") if isinstance(j, dict) else None
if not isinstance(media, dict):
return None
# Conservative guard: only accept when AniList's title actually matches ours
# (anime search is fuzzy and would otherwise score random non-anime shows).
names = media.get("title") or {}
want = _norm_title(title)
got = {_norm_title(names.get("romaji")), _norm_title(names.get("english"))}
if not any(g and (g == want or g in want or want in g) for g in got):
return None
score = media.get("averageScore")
if isinstance(score, int) and 0 < score <= 100:
return {"anilist_score": score}
return None
def record_ok(self, item, data):
self.db.backfill_mark("anilist", item["kind"], item["id"], "ok", columns=data)
def record_empty(self, item):
self.db.backfill_mark("anilist", item["kind"], item["id"], "not_found")
def record_error(self, item):
self.db.backfill_mark("anilist", item["kind"], item["id"], "error")
def breakdown(self):
return self.db.backfill_breakdown("anilist")
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), TVmazeWorker(db),
TraktWorker(db), TVmazeWorker(db), AniListWorker(db),
)}

@ -95,6 +95,10 @@ _BACKFILL = {
"show": ("shows", "tvmaze_status", "tvmaze_attempted",
"(imdb_id IS NOT NULL OR tvdb_id IS NOT NULL)"),
},
"anilist": { # anime-only, matched by title (shows only)
"show": ("shows", "anilist_status", "anilist_attempted",
"title IS NOT NULL AND title <> ''"),
},
}
# 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
@ -104,6 +108,7 @@ _BACKFILL_COLS = {
"opensubtitles": {"subtitle_langs"},
"trakt": {"trakt_rating", "trakt_votes"},
"tvmaze": {"tvmaze_rating"},
"anilist": {"anilist_score"},
}
# Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent).
@ -168,6 +173,9 @@ _COLUMN_MIGRATIONS = [
# TVmaze community rating backfill (TV only)
("shows", "tvmaze_rating", "REAL"),
("shows", "tvmaze_status", "TEXT"), ("shows", "tvmaze_attempted", "TEXT"),
# AniList anime average score backfill (TV only, 0-100)
("shows", "anilist_score", "INTEGER"),
("shows", "anilist_status", "TEXT"), ("shows", "anilist_attempted", "TEXT"),
]
@ -1582,6 +1590,7 @@ class VideoDatabase:
"metacritic": show["metacritic"],
"trakt_rating": show["trakt_rating"], "trakt_votes": show["trakt_votes"],
"tvmaze_rating": show["tvmaze_rating"],
"anilist_score": show["anilist_score"],
"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"]),

@ -336,6 +336,7 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
"tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "",
"fanart_api_key": "", "opensubtitles_api_key": "", "trakt_api_key": "",
"ryd_enabled": True, "sponsorblock_enabled": True, "tvmaze_enabled": True,
"anilist_enabled": False,
"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",
@ -346,6 +347,7 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
"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, "tvmaze_enabled": True,
"anilist_enabled": False,
"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() == {

@ -13,7 +13,7 @@ import pytest
from database.video_database import VideoDatabase
from core.video.enrichment.backfill import (
RydWorker, SponsorBlockWorker, FanartWorker, OpenSubtitlesWorker, TraktWorker, TVmazeWorker,
VideoBackfillWorker, _RateLimited, _Unauthorized, build_backfill_workers,
AniListWorker, VideoBackfillWorker, _RateLimited, _Unauthorized, build_backfill_workers,
)
@ -179,7 +179,7 @@ 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", "tvmaze"}
"ryd", "sponsorblock", "fanart", "opensubtitles", "trakt", "tvmaze", "anilist"}
# ── Trakt (community rating backfill, keyed on imdb id) ────────────────────────
@ -265,6 +265,45 @@ def test_tvmaze_fetch_parses_lookup(db, monkeypatch):
assert out == {"tvmaze_rating": 9.3}
# ── AniList (no-key GraphQL, anime score, opt-in + title-match guard) ──────────
def test_anilist_off_by_default(db):
w = AniListWorker(db)
assert w.enabled is False # opt-in (anime-niche)
db.set_setting("anilist_enabled", "1")
assert w.enabled is True
def test_anilist_fetch_matches_title_and_scores(db, monkeypatch):
import core.video.enrichment.backfill as bf
payload = {"data": {"Media": {"averageScore": 85,
"title": {"romaji": "Cowboy Bebop", "english": "Cowboy Bebop"}}}}
monkeypatch.setattr(bf, "_http_post_json", lambda url, body, headers=None, timeout=12: payload)
out = AniListWorker(db).fetch({"kind": "show", "title": "Cowboy Bebop"})
assert out == {"anilist_score": 85}
def test_anilist_rejects_title_mismatch(db, monkeypatch):
# AniList returns SOME anime for a non-anime title → the guard must reject it.
import core.video.enrichment.backfill as bf
payload = {"data": {"Media": {"averageScore": 90,
"title": {"romaji": "Naruto", "english": "Naruto"}}}}
monkeypatch.setattr(bf, "_http_post_json", lambda url, body, headers=None, timeout=12: payload)
assert AniListWorker(db).fetch({"kind": "show", "title": "The Office"}) is None
def test_anilist_worker_records_score(db):
with db.connect() as c:
c.execute("INSERT INTO shows (title, year) VALUES ('Bebop', 1998)")
sid = c.execute("SELECT id FROM shows WHERE title='Bebop'").fetchone()["id"]
c.commit()
w = AniListWorker(db)
w.fetch = lambda item: {"anilist_score": 87}
assert w.process_one() is True
with db.connect() as c:
r = c.execute("SELECT anilist_score, anilist_status FROM shows WHERE id=?", (sid,)).fetchone()
assert r["anilist_score"] == 87 and r["anilist_status"] == "ok"
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():

@ -5595,6 +5595,10 @@
<input type="checkbox" id="video-tvmaze-enabled" checked>
<span>TVmaze (TV community rating)</span>
</label>
<label class="vid-pref-row">
<input type="checkbox" id="video-anilist-enabled">
<span>AniList (anime score) — off by default; enable if you have anime</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>

@ -336,6 +336,10 @@
items.push('<span class="vd-rt vd-rt--tvmaze"><span class="vd-rt-tag">TVmaze</span>' +
(Math.round(d.tvmaze_rating * 10) / 10) + '</span>');
}
if (d.anilist_score) {
items.push('<span class="vd-rt vd-rt--anilist"><span class="vd-rt-tag">AniList</span>' +
d.anilist_score + '%</span>');
}
host.innerHTML = items.join('');
host.hidden = !items.length;
}

@ -27,6 +27,7 @@
{ 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: '📺' },
{ id: 'anilist', name: 'AniList', color: '#02a9ff', rgb: '2, 169, 255', kinds: ['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', 'trakt', 'tvmaze'];
'fanart', 'opensubtitles', 'ryd', 'sponsorblock', 'trakt', 'tvmaze', 'anilist'];
function onVideoSide() {
return document.body.getAttribute('data-side') === 'video';

@ -224,6 +224,8 @@
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 anl = document.getElementById('video-anilist-enabled');
if (anl && d.anilist_enabled != null) anl.checked = !!d.anilist_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');
@ -255,6 +257,7 @@
var ryd = document.getElementById('video-ryd-enabled');
var sb = document.getElementById('video-sponsorblock-enabled');
var tvm = document.getElementById('video-tvmaze-enabled');
var anl = document.getElementById('video-anilist-enabled');
return fetch(CONFIG_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
@ -267,6 +270,7 @@
ryd_enabled: ryd ? ryd.checked : true,
sponsorblock_enabled: sb ? sb.checked : true,
tvmaze_enabled: tvm ? tvm.checked : true,
anilist_enabled: anl ? anl.checked : false,
})
}).then(function () { if (!silent) toast('API keys saved', 'success'); })
.catch(function () { /* ignore */ });
@ -310,7 +314,8 @@
// 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', 'video-tvmaze-enabled'].forEach(function (id) {
'video-ryd-enabled', 'video-sponsorblock-enabled',
'video-tvmaze-enabled', 'video-anilist-enabled'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('change', function () { saveKeys(); });
});

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

Loading…
Cancel
Save