video enrichment 3a: isolated Manage-Workers modal

The dashboard 'Manage Workers' button now opens a video enrichment modal that
reuses music's global .em-* modal CSS (identical look) but is entirely its own,
isolated JS: own #vem-overlay, event-delegated (no inline handlers, no music
function calls), targets /api/video/enrichment, shows only TMDB/TVDB with
movie/show coverage.
- Rail of workers (status dot + coverage), panel with pause/resume, per-kind
  coverage cards (matched/not-found/pending segmented bars), and a paged
  unmatched browser with retry (item + retry-failed).
- Polls the selected worker every 3s. The few invented sub-classes are styled
  scoped to #vem-overlay so music is never affected. 87 tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-14 12:04:05 -07:00
parent 0afd37351d
commit 941ebd42d9
4 changed files with 424 additions and 0 deletions

View file

@ -256,6 +256,21 @@ def test_video_enrichment_module_referenced_and_isolated():
assert "openEnrichmentManager" not in src
def test_video_enrichment_manager_isolated():
assert "video/video-enrichment-manager.js" in _INDEX
src = (_ROOT / "webui" / "static" / "video" / "video-enrichment-manager.js").read_text(encoding="utf-8")
assert "(function" in src and "})();" in src
assert "window." not in src
assert "soulsync:video-open-workers" in src # opened by the dashboard button
assert "/api/video/enrichment/" in src # targets the video API
assert "/api/enrichment/" not in src # NOT the music API
assert "openEnrichmentManager" not in src # never calls the music modal
# Reuses the shared music modal CSS classes (design parity).
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
def test_video_settings_module_referenced_and_isolated():
assert "video/video-settings.js" in _INDEX
stripped = _VSETTINGS_JS.strip()

View file

@ -9046,6 +9046,8 @@
<script src="{{ url_for('static', filename='video/video-settings.js', v=static_v) }}"></script>
<!-- Video enrichment dashboard buttons (isolated; polls /api/video/enrichment) -->
<script src="{{ url_for('static', filename='video/video-enrichment.js', v=static_v) }}"></script>
<!-- Video Manage-Workers modal (isolated; reuses music's .em-* CSS, own JS) -->
<script src="{{ url_for('static', filename='video/video-enrichment-manager.js', v=static_v) }}"></script>
<!-- Video dashboard data layer (isolated; populates the dashboard from the video DB) -->
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->

View file

@ -0,0 +1,315 @@
/*
* SoulSync Video "Manage Workers" modal (isolated).
*
* Reuses the music modal's CSS (.enrichment-manager-modal / .em-* shared
* design) but is entirely its own JS: it never calls music functions, targets
* /api/video/enrichment, shows only the video workers (TMDB/TVDB), and uses
* movie/show as the entity kinds. Opened by the dashboard "Manage Workers"
* button via the 'soulsync:video-open-workers' event. Event-delegated (no inline
* handlers); self-contained IIFE, no globals.
*/
(function () {
'use strict';
var WORKERS = [
{ id: 'tmdb', name: 'TMDB' },
{ id: 'tvdb', name: 'TVDB' },
];
var GLYPH = { movie: '🎬', show: '📺' };
var KIND_LABEL = { movie: 'Movies', show: 'Shows' };
var state = {
open: false, selected: 'tmdb', statuses: {}, breakdown: null,
unmatched: null, kind: 'movie', page: 0, pageSize: 50,
statusFilter: 'unmatched', pollTimer: null,
};
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function byId(id) { return document.getElementById(id); }
function statusInfo(s) {
if (!s || !s.enabled) return { cls: 'disabled', label: 'Not configured' };
if (s.running && !s.paused && !s.idle) return { cls: 'running', label: 'Running' };
if (s.paused) return { cls: 'paused', label: 'Paused' };
if (s.idle) return { cls: 'idle', label: 'Complete' };
return { cls: 'idle', label: 'Idle' };
}
function overallPct(s) {
if (!s || !s.progress) return null;
var m = 0, t = 0;
for (var k in s.progress) {
if (Object.prototype.hasOwnProperty.call(s.progress, k)) {
m += s.progress[k].matched || 0; t += s.progress[k].total || 0;
}
}
return t ? Math.round(m / t * 100) : 0;
}
function railSub(s) {
if (!s || !s.enabled) return 'Not configured';
if (s.idle) return 'All matched';
if (s.running && !s.paused && s.current_item && s.current_item.name) return s.current_item.name;
return (s.stats ? (s.stats.pending || 0) : 0) + ' pending';
}
// ── data ─────────────────────────────────────────────────────────────────
function getJSON(url) {
return fetch(url, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.catch(function () { return null; });
}
function refreshAll() {
return Promise.all(WORKERS.map(function (w) {
return getJSON('/api/video/enrichment/' + w.id + '/status').then(function (d) {
state.statuses[w.id] = d || { enabled: false };
});
}));
}
function loadBreakdown(id) {
return getJSON('/api/video/enrichment/' + id + '/breakdown').then(function (d) {
state.breakdown = d ? d.breakdown : null;
});
}
function loadUnmatched() {
var id = state.selected;
var params = new URLSearchParams({
kind: state.kind, status: state.statusFilter,
limit: String(state.pageSize), offset: String(state.page * state.pageSize),
});
return getJSON('/api/video/enrichment/' + id + '/unmatched?' + params).then(function (d) {
state.unmatched = d || { total: 0, items: [] };
});
}
// ── render ─────────────────────────────────────────────────────────────────
function renderRail() {
var rail = byId('vem-rail');
if (!rail) return;
rail.innerHTML = WORKERS.map(function (w) {
var s = state.statuses[w.id];
var info = statusInfo(s);
var pct = overallPct(s);
var cov = pct == null ? '' :
'<span class="em-rail-cov"><span class="em-rail-cov-fill" style="width:' + pct + '%"></span></span>';
return '<button class="em-worker-row" data-em-select="' + w.id + '">' +
'<span class="em-worker-icon vem-icon">' + esc(w.name) + '</span>' +
'<span class="em-worker-meta"><span class="em-worker-name">' + esc(w.name) + '</span>' +
'<span class="em-worker-sub">' + esc(railSub(s)) + '</span>' + cov + '</span>' +
'<span class="em-dot em-dot--' + info.cls + '" title="' + info.label + '"></span></button>';
}).join('');
WORKERS.forEach(function (w) {
var row = rail.querySelector('[data-em-select="' + w.id + '"]');
if (row) row.classList.toggle('active', w.id === state.selected);
});
}
function renderPanel() {
var panel = byId('vem-panel');
if (!panel) return;
panel.innerHTML =
'<div class="em-panel-header" id="vem-panel-header"></div>' +
'<div class="em-section-label em-section-label--row"><span>Coverage</span>' +
'<span class="em-coverage-overall" id="vem-coverage-overall"></span></div>' +
'<div class="em-cards" id="vem-cards"></div>' +
'<div class="em-unmatched">' +
'<div class="em-unmatched-controls" id="vem-unmatched-controls"></div>' +
'<div class="em-unmatched-list" id="vem-unmatched-list"></div>' +
'<div class="em-pager" id="vem-pager"></div></div>';
renderHeader();
renderCards();
renderControls();
renderList();
}
function renderHeader() {
var host = byId('vem-panel-header');
if (!host) return;
var s = state.statuses[state.selected] || {};
var info = statusInfo(s);
var w = WORKERS.find(function (x) { return x.id === state.selected; }) || {};
var pauseLabel = s.paused ? '▶ Resume' : '⏸ Pause';
var current = (s.current_item && s.current_item.name)
? esc((s.current_item.type || '') + ': ' + s.current_item.name) : '';
host.innerHTML =
'<div class="em-ph-main"><span class="em-dot em-dot--' + info.cls + '"></span>' +
'<strong>' + esc(w.name) + '</strong>' +
'<span class="em-ph-status">' + info.label + '</span>' +
(current ? '<span class="em-ph-current">' + current + '</span>' : '') + '</div>' +
'<button class="em-pause-btn" data-em-pause' + (s.enabled ? '' : ' disabled') + '>' + pauseLabel + '</button>';
}
function renderCards() {
var host = byId('vem-cards');
if (!host) return;
var bd = state.breakdown;
if (!bd) { host.innerHTML = ''; return; }
var kinds = Object.keys(bd);
host.innerHTML = kinds.map(function (e) {
var d = bd[e] || {};
var total = (d.matched || 0) + (d.not_found || 0) + (d.pending || 0);
var matched = d.matched || 0, nf = d.not_found || 0, pend = d.pending || 0;
var pct = total ? Math.round(matched / total * 100) : 0;
var seg = function (n) { return total ? (n / total) * 100 : 0; };
var active = e === state.kind ? ' em-card--current' : '';
return '<button class="em-card' + active + '" data-em-kind="' + e + '">' +
'<div class="em-card-top"><span class="em-card-glyph">' + (GLYPH[e] || '•') + '</span>' +
'<span class="em-card-title">' + (KIND_LABEL[e] || e) + '</span>' +
'<span class="em-card-pct">' + pct + '<span class="em-stat-pct-sym">%</span></span></div>' +
'<div class="em-seg"><div class="em-seg-fill em-seg--matched" style="width:' + seg(matched) + '%"></div>' +
'<div class="em-seg-fill em-seg--nf" style="width:' + seg(nf) + '%"></div>' +
'<div class="em-seg-fill em-seg--pend" style="width:' + seg(pend) + '%"></div></div>' +
'<div class="em-stat-legend"><span class="em-leg em-leg--matched"><i></i>' + matched + '</span>' +
'<span class="em-leg em-leg--nf"><i></i>' + nf + '</span>' +
'<span class="em-leg em-leg--pend"><i></i>' + pend + '</span></div></button>';
}).join('');
var overall = byId('vem-coverage-overall');
if (overall) {
var m = 0, t = 0;
kinds.forEach(function (e) {
var d = bd[e] || {}; m += d.matched || 0;
t += (d.matched || 0) + (d.not_found || 0) + (d.pending || 0);
});
overall.innerHTML = t ? '<strong>' + (t ? Math.round(m / t * 100) : 0) + '%</strong> matched · '
+ m + ' of ' + t : '';
}
}
function renderControls() {
var host = byId('vem-unmatched-controls');
if (!host) return;
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>';
}
function renderList() {
var host = byId('vem-unmatched-list');
if (!host) return;
var data = state.unmatched || { items: [], total: 0 };
if (!data.items.length) {
host.innerHTML = '<div class="em-empty">Nothing unmatched here 🎉</div>';
} else {
host.innerHTML = data.items.map(function (it) {
var poster = it.has_poster
? '<img class="em-item-img" src="/api/video/poster/' + state.kind + '/' + it.id + '" alt="" loading="lazy">'
: '<span class="em-item-img em-item-img--ph">' + (GLYPH[state.kind] || '•') + '</span>';
return '<div class="em-item">' + poster +
'<span class="em-item-meta"><span class="em-item-name">' + esc(it.title) + '</span>' +
'<span class="em-item-sub">' + (it.year || '') + '</span></span>' +
'<button class="em-item-retry" data-em-retry-item="' + it.id + '">Retry</button></div>';
}).join('');
}
var pager = byId('vem-pager');
if (pager) {
var pages = Math.max(1, Math.ceil((data.total || 0) / state.pageSize));
pager.innerHTML = (data.total || 0) > state.pageSize
? '<button class="em-pg" data-em-page="prev"' + (state.page <= 0 ? ' disabled' : '') + '></button>' +
'<span class="em-pg-info">' + (state.page + 1) + ' / ' + pages + '</span>' +
'<button class="em-pg" data-em-page="next"' + (state.page + 1 >= pages ? ' disabled' : '') + '></button>'
: '';
}
}
// ── selection / actions ────────────────────────────────────────────────────
function selectWorker(id) {
state.selected = id; state.breakdown = null; state.unmatched = null;
state.kind = 'movie'; state.page = 0;
renderRail(); renderPanel();
Promise.all([loadBreakdown(id), loadUnmatched()]).then(function () { renderPanel(); });
}
function switchKind(kind) {
state.kind = kind; state.page = 0;
renderCards();
loadUnmatched().then(function () { renderControls(); renderList(); });
}
function togglePause() {
var s = state.statuses[state.selected] || {};
if (!s.enabled) return;
var action = s.paused ? 'resume' : 'pause';
fetch('/api/video/enrichment/' + state.selected + '/' + action,
{ method: 'POST', headers: { 'Accept': 'application/json' } })
.then(function () { return refreshAll(); })
.then(function () { renderRail(); renderHeader(); });
}
function retry(scope, itemId) {
fetch('/api/video/enrichment/' + state.selected + '/retry', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ kind: state.kind, scope: scope, item_id: itemId }),
}).then(function () {
return Promise.all([loadBreakdown(state.selected), loadUnmatched()]);
}).then(function () { renderCards(); renderList(); });
}
// ── open/close + delegation ────────────────────────────────────────────────
function ensureOverlay() {
var overlay = byId('vem-overlay');
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.id = 'vem-overlay';
overlay.className = 'modal-overlay em-overlay hidden';
overlay.innerHTML =
'<div class="enrichment-manager-modal" role="dialog" aria-modal="true" aria-label="Manage Video Enrichment Workers">' +
'<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-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);
document.body.appendChild(overlay);
return overlay;
}
function onOverlayClick(e) {
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]');
if (!t) return;
if (t.hasAttribute('data-em-close')) close();
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();
else if (t.hasAttribute('data-em-kind')) switchKind(t.getAttribute('data-em-kind'));
else if (t.hasAttribute('data-em-retry-all')) retry('failed', null);
else if (t.hasAttribute('data-em-retry-item')) retry('item', Number(t.getAttribute('data-em-retry-item')));
else if (t.hasAttribute('data-em-page')) {
state.page += (t.getAttribute('data-em-page') === 'next') ? 1 : -1;
if (state.page < 0) state.page = 0;
loadUnmatched().then(renderList);
}
}
function open() {
var overlay = ensureOverlay();
overlay.classList.remove('hidden');
document.body.classList.add('em-scroll-lock');
state.open = true;
refreshAll().then(function () {
renderRail();
selectWorker(state.selected);
});
if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = setInterval(function () {
if (!state.open) return;
getJSON('/api/video/enrichment/' + state.selected + '/status').then(function (d) {
if (d) { state.statuses[state.selected] = d; renderHeader(); renderRail(); }
});
}, 3000);
}
function close() {
var overlay = byId('vem-overlay');
state.open = false;
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
if (overlay) overlay.classList.add('hidden');
document.body.classList.remove('em-scroll-lock');
}
document.addEventListener('soulsync:video-open-workers', open);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && state.open) close();
});
})();

View file

@ -229,6 +229,98 @@ body[data-side="video"] .dashboard-header-sweep {
.video-manage-workers-btn:hover { transform: translateY(-1px); border-color: rgba(var(--accent-rgb, 88 101 242), 0.55); }
.video-manage-workers-icon img { width: 22px; height: 22px; display: block; }
/* Video Manage-Workers modal: only the bits not covered by music's global
.em-* classes, ALL scoped to #vem-overlay so music is never affected. */
#vem-overlay .vem-icon {
width: 34px;
height: 34px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
#vem-overlay .em-panel-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
#vem-overlay .em-ph-main { display: flex; align-items: center; gap: 8px; font-size: 16px; }
#vem-overlay .em-ph-status { color: var(--text-secondary, #9aa0aa); font-size: 13px; }
#vem-overlay .em-ph-current { color: var(--text-secondary, #9aa0aa); font-size: 12px; opacity: 0.85; }
#vem-overlay .em-pause-btn {
margin-left: auto;
padding: 7px 16px;
border-radius: 9px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary, #e8e8ea);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
#vem-overlay .em-pause-btn:disabled { opacity: 0.45; cursor: default; }
#vem-overlay .em-unmatched-controls { display: flex; align-items: center; justify-content: space-between; margin: 14px 0 8px; }
#vem-overlay .em-section-sub { color: var(--text-secondary, #9aa0aa); font-size: 13px; }
#vem-overlay .em-retry-all-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid rgba(var(--accent-rgb, 88 101 242), 0.4);
background: rgba(var(--accent-rgb, 88 101 242), 0.12);
color: rgb(var(--accent-light-rgb, var(--accent-rgb, 88 101 242)));
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
#vem-overlay .em-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
margin-bottom: 6px;
}
#vem-overlay .em-item-img {
width: 38px;
height: 56px;
object-fit: cover;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
#vem-overlay .em-item-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
#vem-overlay .em-item-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#vem-overlay .em-item-sub { font-size: 12px; color: var(--text-secondary, #9aa0aa); }
#vem-overlay .em-item-retry {
padding: 5px 12px;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: var(--text-secondary, #9aa0aa);
font-size: 12px;
cursor: pointer;
}
#vem-overlay .em-item-retry:hover { color: #fff; border-color: rgba(255, 255, 255, 0.25); }
#vem-overlay .em-empty { padding: 30px; text-align: center; color: var(--text-secondary, #9aa0aa); }
#vem-overlay .em-pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 12px; }
#vem-overlay .em-pg {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary, #e8e8ea);
cursor: pointer;
}
#vem-overlay .em-pg:disabled { opacity: 0.4; cursor: default; }
#vem-overlay .em-pg-info { font-size: 13px; color: var(--text-secondary, #9aa0aa); }
/* ── Subpages: one built video page shown at a time ────────────────────── */
/* Each real page is a .video-subpage inside #video-page-host; the controller
toggles the [hidden] attribute. Be explicit so no reused .dash-* rule can