diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py
index 4477424d..29bf5e5b 100644
--- a/tests/test_video_side_shell.py
+++ b/tests/test_video_side_shell.py
@@ -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()
diff --git a/webui/index.html b/webui/index.html
index 11c998a8..fbbc800c 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -9046,6 +9046,8 @@
+
+
diff --git a/webui/static/video/video-enrichment-manager.js b/webui/static/video/video-enrichment-manager.js
new file mode 100644
index 00000000..c1b49d76
--- /dev/null
+++ b/webui/static/video/video-enrichment-manager.js
@@ -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, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+ 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 ? '' :
+ '';
+ return '';
+ }).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 =
+ '
' +
+ 'Coverage' +
+ '
' +
+ '' +
+ '';
+ 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 =
+ '' +
+ '' + esc(w.name) + '' +
+ '' + info.label + '' +
+ (current ? '' + current + '' : '') + '
' +
+ '';
+ }
+
+ 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 '';
+ }).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 ? '' + (t ? Math.round(m / t * 100) : 0) + '% matched ยท '
+ + m + ' of ' + t : '';
+ }
+ }
+
+ function renderControls() {
+ var host = byId('vem-unmatched-controls');
+ if (!host) return;
+ host.innerHTML =
+ '' + (KIND_LABEL[state.kind] || state.kind) +
+ ' not yet matched' +
+ '';
+ }
+
+ function renderList() {
+ var host = byId('vem-unmatched-list');
+ if (!host) return;
+ var data = state.unmatched || { items: [], total: 0 };
+ if (!data.items.length) {
+ host.innerHTML = 'Nothing unmatched here ๐
';
+ } else {
+ host.innerHTML = data.items.map(function (it) {
+ var poster = it.has_poster
+ ? '
'
+ : '' + (GLYPH[state.kind] || 'โข') + '';
+ return '' + poster +
+ '' + esc(it.title) + '' +
+ '' + (it.year || '') + '' +
+ '
';
+ }).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
+ ? '' +
+ '' + (state.page + 1) + ' / ' + pages + '' +
+ ''
+ : '';
+ }
+ }
+
+ // โโ 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 =
+ '' +
+ '
' +
+ '
Video Enrichment Workers
' +
+ '
Match your library to TMDB & TVDB
' +
+ '
' +
+ '
' +
+ '
';
+ 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();
+ });
+})();
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index ec0e0e99..7c9ff01c 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -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