diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 3de68812..e568a269 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -30,6 +30,10 @@ def register_routes(bp): from core.video.enrichment.engine import get_video_enrichment_engine return get_video_enrichment_engine() + def _yt_enricher(): + from core.video.youtube_enrichment import get_youtube_date_enricher + return get_youtube_date_enricher() + @bp.route("/enrichment/services", methods=["GET"]) def video_enrichment_services(): try: @@ -108,6 +112,8 @@ def register_routes(bp): @bp.route("/enrichment//status", methods=["GET"]) def video_enrichment_status(service): + if service == "youtube": # the standalone date enricher (not an engine worker) + return jsonify(_yt_enricher().stats()) w = engine().worker(service) if not w: return jsonify({"error": "unknown service"}), 404 @@ -115,6 +121,9 @@ def register_routes(bp): @bp.route("/enrichment//pause", methods=["POST"]) def video_enrichment_pause(service): + if service == "youtube": + _yt_enricher().pause() + return jsonify({"status": "paused"}) w = engine().worker(service) if not w: return jsonify({"error": "unknown service"}), 404 @@ -123,6 +132,9 @@ def register_routes(bp): @bp.route("/enrichment//resume", methods=["POST"]) def video_enrichment_resume(service): + if service == "youtube": + _yt_enricher().resume() + return jsonify({"status": "running"}) w = engine().worker(service) if not w: return jsonify({"error": "unknown service"}), 404 diff --git a/api/video/youtube.py b/api/video/youtube.py index c8ff5e5a..7ea6af4d 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -84,7 +84,7 @@ def register_routes(bp): if followed: # followed channels get their full upload-date catalog in the background try: from core.video.youtube_enrichment import get_youtube_date_enricher - get_youtube_date_enricher().enqueue(channel.get("youtube_id")) + get_youtube_date_enricher().enqueue(channel.get("youtube_id"), channel.get("title")) except Exception: pass return jsonify({"success": followed, "following": followed, "added_videos": added, @@ -156,7 +156,7 @@ def register_routes(bp): if following: # opening a followed channel → ensure its dates get enriched try: from core.video.youtube_enrichment import get_youtube_date_enricher - get_youtube_date_enricher().enqueue(cid) + get_youtube_date_enricher().enqueue(cid, channel.get("title")) except Exception: pass # backfill the real avatar onto wished rows (flat listing often omits it) diff --git a/core/video/youtube_enrichment.py b/core/video/youtube_enrichment.py index 42674620..21156702 100644 --- a/core/video/youtube_enrichment.py +++ b/core/video/youtube_enrichment.py @@ -31,21 +31,28 @@ class YoutubeDateEnricher: self._db_factory = db_factory or self._default_db self._q: "queue.Queue[str]" = queue.Queue() self._inflight = set() + self._titles = {} self._thread = None self._lock = threading.Lock() + self._current = None # channel being enriched right now (for the orb) + self._paused = False + self._channels_done = 0 + self._dates_total = 0 @staticmethod def _default_db(): from database.video_database import VideoDatabase return VideoDatabase() - def enqueue(self, channel_id): + def enqueue(self, channel_id, title=None): """Queue a followed channel for full date enrichment (deduped; starts the worker thread on first use).""" cid = str(channel_id or "").strip() if not cid: return with self._lock: + if title: + self._titles[cid] = title if cid in self._inflight: return self._inflight.add(cid) @@ -54,8 +61,34 @@ class YoutubeDateEnricher: self._thread = threading.Thread(target=self._run, name="yt-date-enricher", daemon=True) self._thread.start() + def pause(self): + self._paused = True + + def resume(self): + self._paused = False + + def stats(self): + """Dashboard-orb telemetry — same shape the enrichment workers report.""" + queued = self._q.qsize() + running = bool(self._current) and not self._paused + cur = self._current + return { + "enabled": True, + "idle": False, # this worker idles, it never "completes" + "running": running, + "paused": self._paused, + "current_item": {"type": "channel", "name": cur} if cur else None, + "progress": {"channels": {"matched": self._channels_done, + "total": self._channels_done + queued + (1 if cur else 0)}}, + "queued": queued, + "dates_cached": self._dates_total, + } + def _run(self): while True: + if self._paused: + time.sleep(0.5) + continue try: cid = self._q.get(timeout=45) except queue.Empty: @@ -65,6 +98,7 @@ class YoutubeDateEnricher: except Exception: logger.exception("YouTube date enrichment failed for %s", cid) finally: + self._current = None with self._lock: self._inflight.discard(cid) self._q.task_done() @@ -76,6 +110,7 @@ class YoutubeDateEnricher: cid = str(channel_id or "").strip() if not cid or db.channel_dates_enriched_recently(cid): return + self._current = self._titles.get(cid) or cid dates = {} try: @@ -103,6 +138,8 @@ class YoutubeDateEnricher: if filled: logger.info("YouTube dates: %d cached for %s (per-video fallback)", filled, cid) + self._channels_done += 1 + self._dates_total += len(dates) + filled db.mark_channel_dates_enriched(cid, len(dates) + filled) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 6ed45dff..b1922cd7 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -716,3 +716,13 @@ def test_youtube_search_endpoint_hydrates_following(tmp_path, monkeypatch): flags = {c["youtube_id"]: c["following"] for c in d["channels"]} assert flags == {"UCaaa": True, "UCbbb": False} assert client.get("/api/video/youtube/search?q=").get_json()["channels"] == [] + + +def test_enrichment_youtube_status_route(tmp_path): + client, _ = _make_client(tmp_path) + d = client.get("/api/video/enrichment/youtube/status").get_json() + assert d["enabled"] is True and "running" in d and "progress" in d + assert client.post("/api/video/enrichment/youtube/pause").get_json()["status"] == "paused" + assert client.post("/api/video/enrichment/youtube/resume").get_json()["status"] == "running" + # unknown service still 404s + assert client.get("/api/video/enrichment/bogus/status").status_code == 404 diff --git a/tests/test_video_youtube_enrichment.py b/tests/test_video_youtube_enrichment.py index b8e4fd6c..066ec893 100644 --- a/tests/test_video_youtube_enrichment.py +++ b/tests/test_video_youtube_enrichment.py @@ -49,3 +49,12 @@ def test_enrich_falls_back_to_per_video_when_proxy_empty(db, monkeypatch): def test_enricher_imports_nothing_from_music(): src = Path("core/video/youtube_enrichment.py").read_text(encoding="utf-8") assert "database.music_database" not in src and "from database import" not in src + + +def test_enricher_stats_shape_and_pause(): + e = YoutubeDateEnricher(db_factory=lambda: None) + s = e.stats() + assert s["enabled"] is True and s["running"] is False and s["paused"] is False + assert s["current_item"] is None and "progress" in s + e.pause(); assert e.stats()["paused"] is True + e.resume(); assert e.stats()["paused"] is False diff --git a/webui/index.html b/webui/index.html index 6d44701a..045141bb 100644 --- a/webui/index.html +++ b/webui/index.html @@ -488,6 +488,22 @@ +
+ +
+
+
YouTube Dates
+
+
Status: Idle
+
No channels queued
+
Progress: 0 / 0
+
+
+
+
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 0f1afae1..52856f83 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -335,10 +335,13 @@ var a = q('[data-vd-actions]'); if (!a) return; if (d.source === 'youtube') { + // Use the SAME watchlist button as shows/movies (consistency); it just + // follows the channel instead of a tmdb show. var on = !!d.following; a.innerHTML = - '' + + '' + 'Open on YouTube ↗'; return; diff --git a/webui/static/video/video-enrichment.js b/webui/static/video/video-enrichment.js index 482d4ef5..418f8750 100644 --- a/webui/static/video/video-enrichment.js +++ b/webui/static/video/video-enrichment.js @@ -14,7 +14,9 @@ (function () { 'use strict'; - var SERVICES = ['tmdb', 'tvdb', 'omdb']; + var SERVICES = ['tmdb', 'tvdb', 'omdb']; // pushed over the shared socket + var POLLED = ['youtube']; // standalone daemon → light polling + var ALL = SERVICES.concat(POLLED); function onVideoSide() { return document.body.getAttribute('data-side') === 'video'; @@ -72,7 +74,13 @@ // One-time fetch so the buttons aren't blank until the first socket push. function primeOnce() { - if (onVideoSide() && !document.hidden) SERVICES.forEach(pollOne); + if (onVideoSide() && !document.hidden) ALL.forEach(pollOne); + } + // The YouTube date enricher isn't on the socket — poll it so its orb reflects + // activity (only while the video side is foregrounded). + function pollPolledLoop() { + if (onVideoSide() && !document.hidden) POLLED.forEach(pollOne); + setTimeout(pollPolledLoop, 3000); } // Listen on the shared socket (set up by core.js) for the server-pushed @@ -104,7 +112,7 @@ } function init() { - SERVICES.forEach(function (svc) { + ALL.forEach(function (svc) { var b = btn(svc); if (b) b.addEventListener('click', function () { toggle(svc); }); }); @@ -115,7 +123,8 @@ }); } primeOnce(); // instant initial state - bindSocket(); // then the socket pushes updates — no polling + bindSocket(); // then the socket pushes updates for tmdb/tvdb/omdb + pollPolledLoop(); // ...and poll the youtube enricher // Re-prime when the user returns to the tab (socket kept pushing, but a // quick fetch snaps the buttons current immediately). document.addEventListener('visibilitychange', function () { diff --git a/webui/static/video/video-worker-orbs.js b/webui/static/video/video-worker-orbs.js index 627e7377..06c1b146 100644 --- a/webui/static/video/video-worker-orbs.js +++ b/webui/static/video/video-worker-orbs.js @@ -20,10 +20,11 @@ // VIDEO side: the two enrichment buttons + the Manage Workers hub. `sel` // matches the BUTTON; the orb's positioned element is its container. const WORKER_DEFS = [ - { sel: '[data-video-enrich="tmdb"]', color: [56, 189, 248], id: 'tmdb' }, - { sel: '[data-video-enrich="tvdb"]', color: [168, 85, 247], id: 'tvdb' }, - { sel: '[data-video-enrich="omdb"]', color: [245, 197, 24], id: 'omdb' }, - { sel: '[data-video-manage-workers]', color: [168, 85, 247], hub: true }, + { sel: '[data-video-enrich="tmdb"]', color: [56, 189, 248], id: 'tmdb' }, + { sel: '[data-video-enrich="tvdb"]', color: [168, 85, 247], id: 'tvdb' }, + { sel: '[data-video-enrich="omdb"]', color: [245, 197, 24], id: 'omdb' }, + { sel: '[data-video-enrich="youtube"]', color: [255, 59, 59], id: 'youtube' }, + { sel: '[data-video-manage-workers]', color: [168, 85, 247], hub: true }, ]; const ERROR_COLOR = [255, 80, 80]; // pulses fired on real worker errors