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:
parent
65ff84aa6a
commit
aef80eb52d
4 changed files with 233 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
99
webui/static/video/video-enrichment.js
Normal file
99
webui/static/video/video-enrichment.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue