diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 4fa6a41b..34a153a0 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -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", + "dearrow_enabled": (db.get_setting("dearrow_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", @@ -93,7 +94,8 @@ 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", "anilist_enabled"): + for flag in ("ryd_enabled", "sponsorblock_enabled", "dearrow_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/api/video/youtube.py b/api/video/youtube.py index 313938cf..2b53b2d0 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -322,6 +322,12 @@ def register_routes(bp): if not v or not v.get("youtube_id"): return jsonify({"success": False, "error": "Video not found"}), 404 db = get_video_db() + # DeArrow crowd-sourced better title (if enriched) — surfaced as an + # alternative on the detail page. + try: + v["dearrow_title"] = db.youtube_video_dearrow_title(v["youtube_id"]) + except Exception: + v["dearrow_title"] = None if v.get("description"): try: db.set_wishlist_video_overview(v["youtube_id"], v["description"]) diff --git a/core/video/enrichment/backfill.py b/core/video/enrichment/backfill.py index 858436b5..262152ab 100644 --- a/core/video/enrichment/backfill.py +++ b/core/video/enrichment/backfill.py @@ -698,9 +698,55 @@ class AniListWorker(VideoBackfillWorker): return self.db.backfill_breakdown("anilist") +# ── DeArrow (no key) — crowd-sourced better titles for YouTube videos ───────── +class DeArrowWorker(VideoBackfillWorker): + URL = "https://sponsor.ajay.app/api/branding" + + def __init__(self, db): + super().__init__(db, "dearrow", "DeArrow", interval=0.6) + + def _enabled(self): + return str(self.db.get_setting("dearrow_enabled") or "1") != "0" + + def test(self): + try: + j = _http_get_json(self.URL, {"videoID": "dQw4w9WgXcQ"}) + return (j is not None, "DeArrow reachable" if j is not None else "No response") + except Exception as e: + return (False, str(e)) + + def next_item(self): + return self.db.youtube_enrich_next("dearrow_status") + + def fetch(self, item): + j = _http_get_json(self.URL, {"videoID": item["youtube_id"]}) + if not isinstance(j, dict): + return None + # DeArrow returns crowd titles in preference order; take the first that + # isn't the YouTube original. + for t in (j.get("titles") or []): + if isinstance(t, dict) and not t.get("original"): + title = (t.get("title") or "").strip() + if title: + return {"title": title} + return None + + def record_ok(self, item, data): + self.db.apply_youtube_dearrow(item["youtube_id"], data.get("title"), "ok") + + def record_empty(self, item): + self.db.apply_youtube_dearrow(item["youtube_id"], None, "not_found") + + def record_error(self, item): + self.db.apply_youtube_dearrow(item["youtube_id"], None, "error") + + def breakdown(self): + return self.db.youtube_enrich_breakdown("dearrow_status") + + 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), AniListWorker(db), + TraktWorker(db), TVmazeWorker(db), AniListWorker(db), DeArrowWorker(db), )} diff --git a/database/video_database.py b/database/video_database.py index a5e1d8f1..b8fd0275 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -176,6 +176,10 @@ _COLUMN_MIGRATIONS = [ # AniList anime average score backfill (TV only, 0-100) ("shows", "anilist_score", "INTEGER"), ("shows", "anilist_status", "TEXT"), ("shows", "anilist_attempted", "TEXT"), + # DeArrow crowd-sourced better titles for cached YouTube videos + ("youtube_video_stats", "dearrow_title", "TEXT"), + ("youtube_video_stats", "dearrow_status", "TEXT"), + ("youtube_video_stats", "dearrow_attempted", "TEXT"), ] @@ -509,7 +513,7 @@ class VideoDatabase: """Next cached YouTube video missing a per-video enrichment. status_col is 'ryd_status' or 'sb_status'. Distinct by youtube_id (a video shared across playlists is enriched once). Returns {kind:'video', id, name, youtube_id}.""" - if status_col not in ("ryd_status", "sb_status"): + if status_col not in ("ryd_status", "sb_status", "dearrow_status"): return None conn = self._get_connection() try: @@ -568,8 +572,38 @@ class VideoDatabase: finally: conn.close() + def apply_youtube_dearrow(self, youtube_id, title, status: str) -> None: + """Record DeArrow's crowd-sourced better title (+ status) for a video.""" + yid = str(youtube_id or "").strip() + if not yid: + return + conn = self._get_connection() + try: + conn.execute( + "INSERT INTO youtube_video_stats (youtube_id, dearrow_title, dearrow_status, dearrow_attempted) " + "VALUES (?,?,?,CURRENT_TIMESTAMP) ON CONFLICT(youtube_id) DO UPDATE SET " + "dearrow_title=COALESCE(excluded.dearrow_title, dearrow_title), " + "dearrow_status=excluded.dearrow_status, dearrow_attempted=CURRENT_TIMESTAMP", + (yid, title, status)) + conn.commit() + finally: + conn.close() + + def youtube_video_dearrow_title(self, youtube_id) -> str | None: + """The DeArrow crowd title for a video, if one was recorded (detail UI).""" + yid = str(youtube_id or "").strip() + if not yid: + return None + conn = self._get_connection() + try: + row = conn.execute( + "SELECT dearrow_title FROM youtube_video_stats WHERE youtube_id=?", (yid,)).fetchone() + return row["dearrow_title"] if row and row["dearrow_title"] else None + finally: + conn.close() + def youtube_enrich_breakdown(self, status_col: str) -> dict: - if status_col not in ("ryd_status", "sb_status"): + if status_col not in ("ryd_status", "sb_status", "dearrow_status"): return {} conn = self._get_connection() try: diff --git a/database/video_schema.sql b/database/video_schema.sql index 3e9e06de..295b6325 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -317,13 +317,16 @@ CREATE TABLE IF NOT EXISTS youtube_channel_meta ( -- sb_* SponsorBlock -> crowd-sourced segments (in youtube_video_segments) -- status columns: NULL = pending, 'ok' | 'not_found' | 'error'. CREATE TABLE IF NOT EXISTS youtube_video_stats ( - youtube_id TEXT PRIMARY KEY, - like_count INTEGER, - dislike_count INTEGER, - ryd_status TEXT, - ryd_attempted TEXT, - sb_status TEXT, - sb_attempted TEXT + youtube_id TEXT PRIMARY KEY, + like_count INTEGER, + dislike_count INTEGER, + ryd_status TEXT, + ryd_attempted TEXT, + sb_status TEXT, + sb_attempted TEXT, + dearrow_title TEXT, -- DeArrow crowd-sourced better title + dearrow_status TEXT, + dearrow_attempted TEXT ); -- SponsorBlock crowd segments (sponsor/intro/outro/selfpromo/…) for a video. diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 77ea2105..21a90d49 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -335,8 +335,8 @@ 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, "tvmaze_enabled": True, - "anilist_enabled": False, + "ryd_enabled": True, "sponsorblock_enabled": True, "dearrow_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,8 +346,8 @@ 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, "tvmaze_enabled": True, - "anilist_enabled": False, + "ryd_enabled": False, "sponsorblock_enabled": True, "dearrow_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 b2cb17e8..e72ce85d 100644 --- a/tests/test_video_backfill.py +++ b/tests/test_video_backfill.py @@ -13,7 +13,8 @@ import pytest from database.video_database import VideoDatabase from core.video.enrichment.backfill import ( RydWorker, SponsorBlockWorker, FanartWorker, OpenSubtitlesWorker, TraktWorker, TVmazeWorker, - AniListWorker, VideoBackfillWorker, _RateLimited, _Unauthorized, build_backfill_workers, + AniListWorker, DeArrowWorker, VideoBackfillWorker, _RateLimited, _Unauthorized, + build_backfill_workers, ) @@ -179,7 +180,38 @@ 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", "anilist"} + "ryd", "sponsorblock", "fanart", "opensubtitles", "trakt", "tvmaze", "anilist", "dearrow"} + + +# ── DeArrow (no-key, YouTube crowd titles) ──────────────────────────────────── +def test_dearrow_queue_and_apply(db): + db.cache_channel_videos("UC1", [{"youtube_id": "v", "title": "clickbait TITLE!!!"}]) + nxt = db.youtube_enrich_next("dearrow_status") + assert nxt and nxt["youtube_id"] == "v" and nxt["kind"] == "video" + db.apply_youtube_dearrow("v", "A calm, accurate title", "ok") + assert db.youtube_video_dearrow_title("v") == "A calm, accurate title" + assert db.youtube_enrich_breakdown("dearrow_status")["video"]["matched"] == 1 + + +def test_dearrow_worker_records_title(db): + db.cache_channel_videos("UC1", [{"youtube_id": "v", "title": "X"}]) + w = DeArrowWorker(db) + assert w.enabled is True + w.fetch = lambda item: {"title": "Better Crowd Title"} + assert w.process_one() is True + assert db.youtube_video_dearrow_title("v") == "Better Crowd Title" + + +def test_dearrow_fetch_parses_branding(db, monkeypatch): + import core.video.enrichment.backfill as bf + branding = {"titles": [ + {"title": "Original", "original": True}, + {"title": "Crowd Title", "original": False}, + ]} + monkeypatch.setattr(bf, "_http_get_json", + lambda url, params=None, headers=None, timeout=12: branding) + out = DeArrowWorker(db).fetch({"youtube_id": "v"}) + assert out == {"title": "Crowd Title"} # ── Trakt (community rating backfill, keyed on imdb id) ──────────────────────── diff --git a/webui/index.html b/webui/index.html index a5664996..e009044f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5619,6 +5619,10 @@ SponsorBlock (crowd-sourced segments) +
' + esc(v.description || 'No description.') + '