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:
parent
80f1051e8a
commit
fcd4af0efd
8 changed files with 161 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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') "
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 & 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">×</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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue