From 941ebd42d9da54de4919b7c17432ecb6602bfc40 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 12:04:05 -0700 Subject: [PATCH] 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. --- tests/test_video_side_shell.py | 15 + webui/index.html | 2 + .../static/video/video-enrichment-manager.js | 315 ++++++++++++++++++ webui/static/video/video-side.css | 92 +++++ 4 files changed, 424 insertions(+) create mode 100644 webui/static/video/video-enrichment-manager.js 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 = + ''; + 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