diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 361edfb9..4fa6a41b 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -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: diff --git a/core/video/enrichment/backfill.py b/core/video/enrichment/backfill.py index 7d778721..858436b5 100644 --- a/core/video/enrichment/backfill.py +++ b/core/video/enrichment/backfill.py @@ -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), )} diff --git a/database/video_database.py b/database/video_database.py index 2458d356..a5e1d8f1 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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"]), diff --git a/tests/test_video_api.py b/tests/test_video_api.py index b97f133c..77ea2105 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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() == { diff --git a/tests/test_video_backfill.py b/tests/test_video_backfill.py index b9041bf5..b2cb17e8 100644 --- a/tests/test_video_backfill.py +++ b/tests/test_video_backfill.py @@ -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(): diff --git a/webui/index.html b/webui/index.html index b55259f5..a5664996 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5595,6 +5595,10 @@ TVmaze (TV community rating) +
Free community APIs — no key needed. Gap-fill extra data on your library in the background.
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index ae2b6e7e..7031680e 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -336,6 +336,10 @@ items.push('TVmaze' + (Math.round(d.tvmaze_rating * 10) / 10) + ''); } + if (d.anilist_score) { + items.push('AniList' + + d.anilist_score + '%'); + } 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 963e8eab..3e2d964b 100644 --- a/webui/static/video/video-enrichment-manager.js +++ b/webui/static/video/video-enrichment-manager.js @@ -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) { diff --git a/webui/static/video/video-enrichment.js b/webui/static/video/video-enrichment.js index 02f70b73..0bd70f6d 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', 'trakt', 'tvmaze']; + 'fanart', 'opensubtitles', 'ryd', 'sponsorblock', 'trakt', 'tvmaze', 'anilist']; 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 e0016dca..7c5e2fa0 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -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(); }); }); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 51958c89..0f6cd245 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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 {