video manage-workers modal: Process First Everywhere, search/filter, live glow

Brings the video modal to parity with music's:
- 'Process first everywhere' control (Movies/Shows/Auto) in the topbar — a global
  setting that pins which kind every worker processes first. enrichment_next takes
  a priority kind; the worker reads enrichment_priority each loop; GET/POST
  /api/video/enrichment/priority persists it. Reuses music's .em-global styling.
- Needs-matching bar now has a live count, status filter (All unmatched / Not
  found / Pending) and a debounced search (reusing .em-select / .em-search),
  matching the music modal. Episode view stays read-only.
- Live glow (scoped to #vem-overlay): pulsing running dot, accent glow on the
  selected worker row + active process-first/kind, and a pulsing 'now processing'
  chip in the worker accent. Music's shared .em-* styles untouched.

Seam tests: priority pins kind in enrichment_next + worker honors the setting,
priority endpoint GET/POST + validation, modal feature markup pinned.
This commit is contained in:
BoulderBadgeDad 2026-06-14 18:28:22 -07:00
parent 80f1051e8a
commit fcd4af0efd
8 changed files with 161 additions and 11 deletions

View file

@ -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/<service>/test", methods=["POST"])
def video_enrichment_test(service):
w = engine().worker(service)

View file

@ -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"]}

View file

@ -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') "

View file

@ -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:

View file

@ -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).

View file

@ -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).

View file

@ -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 '<option value="' + v + '"' + (state.statusFilter === v ? ' selected' : '') + '>' + label + '</option>';
};
var isEpisode = state.kind === 'episode';
host.innerHTML =
'<span class="em-section-sub">' + (KIND_LABEL[state.kind] || state.kind) +
' not yet matched</span>' +
'<button class="em-retry-all-btn" data-em-retry-all>Retry failed</button>';
'<div class="em-unmatched-bar">' +
'<div class="em-section-label em-section-label--inline">' +
(KIND_LABEL[state.kind] || state.kind) + ' not yet matched' +
(total != null ? '<span class="em-count">' + total.toLocaleString() + '</span>' : '') +
(isEpisode ? '' : '<button class="em-btn em-btn--sm em-btn--ghost em-retry-all-btn" data-em-retry-all>↻ Retry all failed</button>') +
'</div>' +
'<div class="em-filter-row">' +
(isEpisode ? '' :
'<select class="em-select" data-em-status>' + opt('unmatched', 'All unmatched') +
opt('not_found', 'Not found') + opt('pending', 'Pending') + '</select>') +
'<div class="em-search-wrap"><span class="em-search-ico">⌕</span>' +
'<input class="em-search" type="text" placeholder="Search…" value="' + esc(state.search) + '" data-em-search></div>' +
'</div></div>';
}
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 @@
'<div class="em-topbar"><div class="em-topbar-icon"><img src="/static/trans2.png" alt="" class="em-topbar-logo"></div>' +
'<div class="em-topbar-titles"><h3 class="em-topbar-title">Video Enrichment Workers</h3>' +
'<div class="em-topbar-sub">Match your library to TMDB &amp; TVDB</div></div>' +
'<div class="em-global"><span class="em-global-label">Process first<br><span>everywhere</span></span>' +
'<div class="em-global-tabs" id="vem-global-tabs">' +
'<button data-em-priority="movie">Movies</button>' +
'<button data-em-priority="show">Shows</button>' +
'<button data-em-priority="" class="em-global-auto">Auto</button></div></div>' +
'<div class="em-topbar-actions"><button class="em-icon-btn" data-em-refresh title="Refresh">⟳</button>' +
'<button class="em-icon-btn em-icon-btn--close" data-em-close title="Close">&times;</button></div></div>' +
'<div class="em-body"><div class="em-rail" id="vem-rail"></div><div class="em-panel" id="vem-panel"></div></div></div>';
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);

View file

@ -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);
}