diff --git a/api/video/enrichment.py b/api/video/enrichment.py index b8ef869c..b80fb586 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -86,6 +86,19 @@ def register_routes(bp): w.resume() return jsonify({"status": "running"}) + @bp.route("/enrichment/priority", methods=["GET", "POST"]) + def video_enrichment_priority(): + from . import get_video_db + db = get_video_db() + if request.method == "POST": + body = request.get_json(silent=True) or {} + kind = body.get("priority") or "" + if kind not in ("", "movie", "show"): + return jsonify({"error": "bad priority"}), 400 + db.set_setting("enrichment_priority", kind) + return jsonify({"success": True, "priority": kind}) + return jsonify({"priority": db.get_setting("enrichment_priority") or ""}) + @bp.route("/enrichment//test", methods=["POST"]) def video_enrichment_test(service): w = engine().worker(service) diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index b033ede3..0d032fb4 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -98,7 +98,12 @@ class VideoEnrichmentWorker: def process_one(self) -> bool: """Process a single item. Returns True if one was processed.""" - item = self.db.enrichment_next(self.service, self.retry_days) + priority = None + try: + priority = self.db.get_setting("enrichment_priority") or None + except Exception: + pass + item = self.db.enrichment_next(self.service, self.retry_days, priority=priority) if not item: return False self.current_item = {"type": item["kind"], "name": item["title"]} diff --git a/database/video_database.py b/database/video_database.py index a5bc21ba..1d3bb6f5 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -158,15 +158,21 @@ class VideoDatabase: conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {coltype}") # ── enrichment plumbing (per-source match status, like music) ───────────── - def enrichment_next(self, service: str, retry_days: int = 30) -> dict | None: + def enrichment_next(self, service: str, retry_days: int = 30, priority=None) -> dict | None: """Next item that needs enrichment for a service: pending (never tried) first, then a not_found item older than retry_days. Returns {kind, id, title, year, known_id} or None. ``known_id`` is the provider id the media server already supplied (e.g. tmdb_id/tvdb_id) so the worker - can enrich BY ID instead of re-searching by title.""" + can enrich BY ID instead of re-searching by title. + + ``priority`` ('movie'/'show') pins a kind to be processed first across the + queue — drives the modal's 'Process first everywhere' control.""" kinds = _ENRICH.get(service) if not kinds: return None + items = list(kinds.items()) + if priority in kinds: + items.sort(key=lambda kv: 0 if kv[0] == priority else 1) cutoff = (datetime.now(timezone.utc) - timedelta(days=retry_days)).strftime("%Y-%m-%d %H:%M:%S") conn = self._get_connection() @@ -175,12 +181,12 @@ class VideoDatabase: "year": row["year"], "known_id": row[idc]} try: - for kind, (tbl, idc, sc, _ac) in kinds.items(): + for kind, (tbl, idc, sc, _ac) in items: row = conn.execute( f"SELECT id, title, year, {idc} FROM {tbl} WHERE {sc} IS NULL ORDER BY id LIMIT 1").fetchone() if row: return _row(row, kind, idc) - for kind, (tbl, idc, sc, ac) in kinds.items(): + for kind, (tbl, idc, sc, ac) in items: row = conn.execute( f"SELECT id, title, year, {idc} FROM {tbl} " f"WHERE {sc} IN ('not_found','error') " diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 1df42319..70a5030f 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -63,6 +63,18 @@ def test_show_detail_endpoint(tmp_path): videoapi._video_db = None +def test_enrichment_priority_endpoint(tmp_path): + client, videoapi = _make_client(tmp_path) + try: + assert client.get("/api/video/enrichment/priority").get_json()["priority"] == "" + r = client.post("/api/video/enrichment/priority", json={"priority": "show"}) + assert r.status_code == 200 and r.get_json()["priority"] == "show" + assert client.get("/api/video/enrichment/priority").get_json()["priority"] == "show" + assert client.post("/api/video/enrichment/priority", json={"priority": "bogus"}).status_code == 400 + finally: + videoapi._video_db = None + + def test_monitor_toggle_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index a7d27e56..c77f5113 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -98,6 +98,23 @@ def test_tmdb_pulls_full_metadata(monkeypatch): assert m["genres"] == ["Sci-Fi", "Drama"] and m["status"] == "Released" and m["imdb_id"] == "tt1" +def test_enrichment_next_priority_pins_kind_first(db): + db.upsert_movie("plex", {"server_id": "m1", "title": "M"}) + db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) + assert db.enrichment_next("tmdb")["kind"] == "movie" # default: movie first + assert db.enrichment_next("tmdb", priority="show")["kind"] == "show" # pinned + assert db.enrichment_next("tmdb", priority="movie")["kind"] == "movie" + + +def test_worker_respects_global_priority_setting(db): + db.upsert_movie("plex", {"server_id": "m1", "title": "M"}) + db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) + db.set_setting("enrichment_priority", "show") + client = FakeClient({"id": 1, "metadata": {}}) + VideoEnrichmentWorker(db, "tmdb", client).process_one() + assert client.calls[0][0] == "show" # processed shows first + + def test_show_worker_cascades_episode_backfill(db): # A matched show backfills its episodes' art via the client's season_episodes # cascade (episodes ride along with their show — no separate queue). diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index eae398f3..8859887a 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -285,6 +285,11 @@ def test_video_enrichment_manager_isolated(): assert "enrichment-manager-modal" in src and "em-rail" in src # Its own overlay id (not music's) so the two never collide. assert "vem-overlay" in src and "enrichment-manager-overlay" not in src + # Feature parity with the music modal: "Process first everywhere", search, + # and the status filter (reusing em-global / em-search / em-select). + assert "em-global" in src and "data-em-priority" in src + assert "/api/video/enrichment/priority" in src + assert "data-em-search" in src and "data-em-status" in src # TVDB is shows-only: each worker declares its kinds and the panel defaults # to the worker's first kind — never a hardcoded 'movie' (which would show a # bogus empty Movies view for TVDB). diff --git a/webui/static/video/video-enrichment-manager.js b/webui/static/video/video-enrichment-manager.js index 2b24af75..4592590c 100644 --- a/webui/static/video/video-enrichment-manager.js +++ b/webui/static/video/video-enrichment-manager.js @@ -37,7 +37,7 @@ var state = { open: false, selected: 'tmdb', statuses: {}, breakdown: null, unmatched: null, kind: 'movie', page: 0, pageSize: 50, - statusFilter: 'unmatched', pollTimer: null, + statusFilter: 'unmatched', search: '', priority: '', pollTimer: null, searchTimer: null, }; function esc(s) { @@ -94,10 +94,17 @@ kind: state.kind, status: state.statusFilter, limit: String(state.pageSize), offset: String(state.page * state.pageSize), }); + if (state.search) params.set('q', state.search); return getJSON('/api/video/enrichment/' + id + '/unmatched?' + params).then(function (d) { state.unmatched = d || { total: 0, items: [] }; }); } + function loadPriority() { + return getJSON('/api/video/enrichment/priority').then(function (d) { + state.priority = (d && d.priority) || ''; + renderGlobalTabs(); + }); + } // ── render ───────────────────────────────────────────────────────────────── function renderRail() { @@ -199,13 +206,37 @@ } } + function renderGlobalTabs() { + var host = byId('vem-global-tabs'); + if (!host) return; + var btns = host.querySelectorAll('[data-em-priority]'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle('active', btns[i].getAttribute('data-em-priority') === state.priority); + } + } + function renderControls() { var host = byId('vem-unmatched-controls'); if (!host) return; + var total = state.unmatched ? state.unmatched.total : null; + var opt = function (v, label) { + return ''; + }; + var isEpisode = state.kind === 'episode'; host.innerHTML = - '' + (KIND_LABEL[state.kind] || state.kind) + - ' not yet matched' + - ''; + '
' + + '' + + '
' + + (isEpisode ? '' : + '') + + '
' + + '
' + + '
'; } function renderList() { @@ -239,7 +270,7 @@ // ── selection / actions ──────────────────────────────────────────────────── function selectWorker(id) { state.selected = id; state.breakdown = null; state.unmatched = null; - state.kind = defaultKind(id); state.page = 0; + state.kind = defaultKind(id); state.page = 0; state.search = ''; renderRail(); renderPanel(); Promise.all([loadBreakdown(id), loadUnmatched()]).then(function () { renderPanel(); }); } @@ -265,6 +296,29 @@ return Promise.all([loadBreakdown(state.selected), loadUnmatched()]); }).then(function () { renderCards(); renderList(); }); } + function setPriority(kind) { + state.priority = kind; + renderGlobalTabs(); + fetch('/api/video/enrichment/priority', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ priority: kind }), + }).catch(function () { /* ignore */ }); + } + function onSearchInput(value) { + state.search = value; state.page = 0; + if (state.searchTimer) clearTimeout(state.searchTimer); + state.searchTimer = setTimeout(function () { + loadUnmatched().then(function () { renderControls(); renderList(); restoreSearchFocus(); }); + }, 300); + } + function restoreSearchFocus() { + var inp = document.querySelector('#vem-overlay [data-em-search]'); + if (inp) { inp.focus(); var v = inp.value; inp.value = ''; inp.value = v; } + } + function setStatusFilter(value) { + state.statusFilter = value; state.page = 0; + loadUnmatched().then(function () { renderControls(); renderList(); }); + } // ── open/close + delegation ──────────────────────────────────────────────── function ensureOverlay() { @@ -278,10 +332,21 @@ '
' + '

Video Enrichment Workers

' + '
Match your library to TMDB & TVDB
' + + '
Process first
everywhere
' + + '
' + + '' + + '' + + '
' + '
' + '
' + '
'; overlay.addEventListener('click', onOverlayClick); + overlay.addEventListener('input', function (e) { + if (e.target.hasAttribute('data-em-search')) onSearchInput(e.target.value); + }); + overlay.addEventListener('change', function (e) { + if (e.target.hasAttribute('data-em-status')) setStatusFilter(e.target.value); + }); document.body.appendChild(overlay); return overlay; } @@ -290,9 +355,10 @@ var overlay = byId('vem-overlay'); if (e.target === overlay) { close(); return; } var t = e.target.closest('[data-em-select],[data-em-pause],[data-em-kind],[data-em-retry-all],' + - '[data-em-retry-item],[data-em-page],[data-em-refresh],[data-em-close]'); + '[data-em-retry-item],[data-em-page],[data-em-refresh],[data-em-close],[data-em-priority]'); if (!t) return; if (t.hasAttribute('data-em-close')) close(); + else if (t.hasAttribute('data-em-priority')) setPriority(t.getAttribute('data-em-priority')); else if (t.hasAttribute('data-em-refresh')) { refreshAll().then(renderRail); selectWorker(state.selected); } else if (t.hasAttribute('data-em-select')) selectWorker(t.getAttribute('data-em-select')); else if (t.hasAttribute('data-em-pause')) togglePause(); @@ -313,6 +379,7 @@ state.open = true; refreshAll().then(function () { renderRail(); + loadPriority(); selectWorker(state.selected); }); if (state.pollTimer) clearInterval(state.pollTimer); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 3b5c0eb3..2204de40 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -656,3 +656,28 @@ body[data-side="video"] .dashboard-header-sweep { } .video-card--clickable { cursor: pointer; } + +/* ── Manage-Workers modal: live status + glow (scoped to #vem-overlay; the + music modal's shared .em-* styles are never touched). ───────────────────── */ +#vem-overlay .em-dot--running { animation: vemDotPulse 1.5s ease-in-out infinite; } +@keyframes vemDotPulse { + 0%, 100% { box-shadow: 0 0 8px #1db954; } + 50% { box-shadow: 0 0 16px 3px #1db954; } +} +/* The selected worker's rail row carries a live accent glow. */ +#vem-overlay .em-worker-row.active { + box-shadow: inset 0 0 0 1px rgba(var(--row-accent), 0.45), + 0 0 22px rgba(var(--row-accent), 0.20); +} +/* "Now processing" chip pulses in the worker's accent — clear live signal. */ +#vem-overlay .em-ph-current { + color: rgb(var(--em-accent-rgb)); + text-shadow: 0 0 12px rgba(var(--em-accent-rgb), 0.55); + animation: vemNowPulse 1.8s ease-in-out infinite; +} +@keyframes vemNowPulse { 0%, 100% { opacity: 0.78; } 50% { opacity: 1; } } +/* Active "Process first" / kind selection glow in the worker accent. */ +#vem-overlay .em-global-tabs button.active, +#vem-overlay .em-card--current { + box-shadow: 0 0 18px rgba(var(--em-accent-rgb), 0.28); +}