video enrichment 2: dashboard TMDB/TVDB buttons + Manage Workers (isolated)

Brings the worker buttons back onto the video dashboard header as real, live
controls — isolated (own CSS classes + own JS + /api/video/enrichment), music
untouched.
- TMDB/TVDB round buttons with per-service accent, a spinner that spins while
  the worker runs, and a hover tooltip (status / current item / progress).
- video-enrichment.js polls /api/video/enrichment/<svc>/status (only on the
  video side, so the video engine isn't spun up on the music side); click
  toggles pause/resume. Manage Workers button fires soulsync:video-open-workers
  for the modal (Phase 3).
86 tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-14 11:52:31 -07:00
parent 65ff84aa6a
commit aef80eb52d
4 changed files with 233 additions and 2 deletions

View file

@ -235,6 +235,25 @@ def test_video_side_hides_music_api_config_and_shows_placeholders():
assert 'class="api-service-frame stg-service" data-service="tvdb"' in _INDEX
def test_dashboard_enrichment_buttons_present():
block = _block(
_INDEX, r'<section class="video-subpage" data-video-subpage="video-dashboard"', "</section>")
assert 'data-video-enrich="tmdb"' in block and 'data-video-enrich="tvdb"' in block
assert "data-video-manage-workers" in block
assert "video-enrich-spinner" in block # spins while running
assert "onclick" not in block
def test_video_enrichment_module_referenced_and_isolated():
assert "video/video-enrichment.js" in _INDEX
src = (_ROOT / "webui" / "static" / "video" / "video-enrichment.js").read_text(encoding="utf-8")
assert src.strip().startswith("/*") or src.strip().startswith("(function")
assert "(function" in src and "})();" in src
assert "window." not in src
assert "/api/video/enrichment/" in src
assert "music" not in src.lower()
def test_video_settings_module_referenced_and_isolated():
assert "video/video-settings.js" in _INDEX
stripped = _VSETTINGS_JS.strip()

View file

@ -434,8 +434,39 @@
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>System Dashboard</span></h2>
<p class="header-subtitle">Monitor your video library, downloads and services</p>
</div>
<!-- (Enrichment-worker button row will go here later, matching
music — not built yet.) -->
<!-- Video enrichment-worker buttons (isolated; poll
/api/video/enrichment, wired by video-enrichment.js).
Click toggles pause/resume; spins while running. -->
<div class="header-actions">
<div class="video-enrich-container">
<button class="video-enrich-button" type="button" data-video-enrich="tmdb" title="TMDB Enrichment" style="--ve-accent: 1 180 228;">
<span class="video-enrich-label">TMDB</span>
<span class="video-enrich-spinner" aria-hidden="true"></span>
</button>
<div class="video-enrich-tooltip" data-video-enrich-tooltip="tmdb">
<div class="video-enrich-tt-title">TMDB Enrichment</div>
<div class="video-enrich-tt-status" data-video-enrich-status>Idle</div>
<div class="video-enrich-tt-current" data-video-enrich-current>No active matches</div>
<div class="video-enrich-tt-progress" data-video-enrich-progress>0 / 0</div>
</div>
</div>
<div class="video-enrich-container">
<button class="video-enrich-button" type="button" data-video-enrich="tvdb" title="TVDB Enrichment" style="--ve-accent: 108 211 145;">
<span class="video-enrich-label">TVDB</span>
<span class="video-enrich-spinner" aria-hidden="true"></span>
</button>
<div class="video-enrich-tooltip" data-video-enrich-tooltip="tvdb">
<div class="video-enrich-tt-title">TVDB Enrichment</div>
<div class="video-enrich-tt-status" data-video-enrich-status>Idle</div>
<div class="video-enrich-tt-current" data-video-enrich-current>No active matches</div>
<div class="video-enrich-tt-progress" data-video-enrich-progress>0 / 0</div>
</div>
</div>
<button class="video-manage-workers-btn" type="button" data-video-manage-workers title="Manage video enrichment workers">
<span class="video-manage-workers-icon"><img src="/static/trans2.png" alt="" /></span>
<span class="video-manage-workers-label">Manage Workers</span>
</button>
</div>
<!-- Watchlist / Wishlist quick-nav (top-right corner) — same as
music; navigates to the video pages via data-video-goto. -->
<div class="header-quick-nav">
@ -9013,6 +9044,8 @@
<script src="{{ url_for('static', filename='video/video-scan.js', v=static_v) }}"></script>
<!-- Video settings additions (isolated; library mapping on the settings page) -->
<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 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,99 @@
/*
* SoulSync Video enrichment dashboard buttons (isolated).
*
* Polls /api/video/enrichment/<svc>/status and reflects it on the dashboard
* TMDB/TVDB buttons (spin while running, tooltip status). Click toggles
* pause/resume. The Manage Workers button fires 'soulsync:video-open-workers'
* for the (isolated) video enrichment modal. Music code is never touched.
*
* Self-contained IIFE, no globals, no inline handlers. Only polls on the video
* side so the video engine isn't spun up while the user is on the music side.
*/
(function () {
'use strict';
var SERVICES = ['tmdb', 'tvdb'];
function onVideoSide() {
return document.body.getAttribute('data-side') === 'video';
}
function btn(svc) { return document.querySelector('[data-video-enrich="' + svc + '"]'); }
function setText(root, sel, text) {
var n = root.querySelector(sel);
if (n) n.textContent = text;
}
function reflect(svc, d) {
var b = btn(svc);
if (!b) return;
b.classList.remove('active', 'paused', 'complete', 'disabled');
if (!d.enabled) b.classList.add('disabled');
else if (d.idle) b.classList.add('complete');
else if (d.running && !d.paused) b.classList.add('active');
else if (d.paused) b.classList.add('paused');
var tip = document.querySelector('[data-video-enrich-tooltip="' + svc + '"]');
if (!tip) return;
var status = !d.enabled ? 'Not configured'
: d.idle ? 'Complete'
: (d.running && !d.paused) ? 'Running'
: d.paused ? 'Paused' : 'Idle';
setText(tip, '[data-video-enrich-status]', status);
var cur = (d.current_item && d.current_item.name)
? ((d.current_item.type || 'item') + ': ' + d.current_item.name)
: (d.idle ? 'All matched' : 'No active matches');
setText(tip, '[data-video-enrich-current]', cur);
var matched = 0, total = 0, prog = d.progress || {};
for (var k in prog) {
if (Object.prototype.hasOwnProperty.call(prog, k)) {
matched += prog[k].matched || 0;
total += prog[k].total || 0;
}
}
setText(tip, '[data-video-enrich-progress]', matched + ' / ' + total);
}
function pollOne(svc) {
fetch('/api/video/enrichment/' + svc + '/status', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && !d.error) reflect(svc, d); })
.catch(function () { /* ignore */ });
}
function poll() {
if (onVideoSide() && !document.hidden) SERVICES.forEach(pollOne);
}
function toggle(svc) {
var b = btn(svc);
if (!b || b.classList.contains('disabled')) return;
var action = b.classList.contains('paused') ? 'resume' : 'pause';
fetch('/api/video/enrichment/' + svc + '/' + action,
{ method: 'POST', headers: { 'Accept': 'application/json' } })
.then(function () { setTimeout(function () { pollOne(svc); }, 200); })
.catch(function () { /* ignore */ });
}
function init() {
SERVICES.forEach(function (svc) {
var b = btn(svc);
if (b) b.addEventListener('click', function () { toggle(svc); });
});
var manage = document.querySelector('[data-video-manage-workers]');
if (manage) {
manage.addEventListener('click', function () {
document.dispatchEvent(new CustomEvent('soulsync:video-open-workers'));
});
}
poll();
setInterval(poll, 2500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -149,6 +149,86 @@ body[data-side="video"] .dashboard-header-sweep {
display: none;
}
/* ── Dashboard enrichment-worker buttons (isolated; music untouched) ────── */
.video-enrich-container { position: relative; }
.video-enrich-button {
position: relative;
width: 46px;
height: 46px;
border-radius: 50%;
border: 2px solid rgba(var(--ve-accent, 88 101 242), 0.5);
background: rgba(18, 18, 22, 0.85);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
}
.video-enrich-button:hover { transform: translateY(-1px); border-color: rgb(var(--ve-accent, 88 101 242)); }
.video-enrich-label { position: relative; z-index: 1; }
.video-enrich-spinner {
position: absolute;
inset: -2px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: rgb(var(--ve-accent, 88 101 242));
opacity: 0;
transition: opacity 0.3s ease;
}
.video-enrich-button.active { box-shadow: 0 0 14px rgba(var(--ve-accent, 88 101 242), 0.45); }
.video-enrich-button.active .video-enrich-spinner { opacity: 1; animation: video-enrich-spin 1.1s linear infinite; }
.video-enrich-button.paused { opacity: 0.6; }
.video-enrich-button.complete { border-color: rgb(var(--ve-accent, 88 101 242)); }
.video-enrich-button.disabled { opacity: 0.4; cursor: default; }
@keyframes video-enrich-spin { to { transform: rotate(360deg); } }
.video-enrich-tooltip {
position: absolute;
top: 54px;
left: 50%;
transform: translateX(-50%);
min-width: 190px;
padding: 10px 12px;
background: rgba(12, 12, 16, 0.97);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 60;
font-size: 12px;
text-align: left;
}
.video-enrich-container:hover .video-enrich-tooltip { opacity: 1; }
.video-enrich-tt-title { font-weight: 700; margin-bottom: 4px; color: var(--text-primary, #e8e8ea); }
.video-enrich-tt-status, .video-enrich-tt-current, .video-enrich-tt-progress {
color: var(--text-secondary, #9aa0aa);
line-height: 1.5;
}
.video-manage-workers-btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 46px;
padding: 0 16px;
border-radius: 23px;
background: rgba(18, 18, 22, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-primary, #e8e8ea);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.15s ease;
}
.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; }
/* ── 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