/*
* 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';
// `kinds` = the entity kinds each service actually enriches (must match the
// backend's _ENRICH map). TVDB is shows-only, so it must NOT default to the
// Movies view β it would query tvdb+movie (always empty) and look broken.
var WORKERS = [
{ id: 'tmdb', name: 'TMDB', color: '#38bdf8', rgb: '56, 189, 248', kinds: ['movie', 'show'] },
{ id: 'tvdb', name: 'TVDB', color: '#a855f7', rgb: '168, 85, 247', kinds: ['show'] },
{ id: 'omdb', name: 'OMDb', color: '#f5c518', rgb: '245, 197, 24', kinds: ['movie', 'show'] },
];
function workerDef(id) {
for (var i = 0; i < WORKERS.length; i++) { if (WORKERS[i].id === id) return WORKERS[i]; }
return null;
}
function defaultKind(id) {
var w = workerDef(id);
return (w && w.kinds && w.kinds[0]) || 'movie';
}
var LOGOS = {
tmdb: 'https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg',
tvdb: 'https://www.svgrepo.com/show/443500/brand-tvdb.svg',
};
var GLYPH = { movie: 'π¬', show: 'πΊ', episode: 'ποΈ' };
var KIND_LABEL = { movie: 'Movies', show: 'Shows', episode: 'Episodes' };
var state = {
open: false, selected: 'tmdb', statuses: {}, breakdown: null,
unmatched: null, kind: 'movie', page: 0, pageSize: 50,
statusFilter: 'unmatched', search: '', priority: '', pollTimer: null, searchTimer: 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),
});
if (state.search) params.set('q', state.search);
return getJSON('/api/video/enrichment/' + id + '/unmatched?' + params).then(function (d) {
state.unmatched = d || { total: 0, items: [] };
});
}
function loadPriority() {
return getJSON('/api/video/enrichment/priority').then(function (d) {
state.priority = (d && d.priority) || '';
renderGlobalTabs();
});
}
// ββ 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 ? '' :
' ';
var icon = LOGOS[w.id]
? ' '
: 'β
';
return '' +
'' + icon + ' ' +
'' + esc(w.name) + ' ' +
'' + esc(railSub(s)) + ' ' + cov + ' ' +
' ';
}).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;
// Theme the panel to the selected worker's accent (like the music modal).
var w = WORKERS.find(function (x) { return x.id === state.selected; }) || WORKERS[0];
panel.style.setProperty('--em-accent', w.color);
panel.style.setProperty('--em-accent-rgb', w.rgb);
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 + ' ' : '') + '
' +
'' + pauseLabel + ' ';
}
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] || {};
// Errored items are outstanding (retried later) β show them with the
// pending bucket so the bar/total stay honest.
var matched = d.matched || 0, nf = d.not_found || 0;
var pend = (d.pending || 0) + (d.errors || 0);
var total = matched + nf + pend;
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 '' +
'' + (GLYPH[e] || 'β’') + ' ' +
'' + (KIND_LABEL[e] || e) + ' ' +
'' + pct + '%
' +
'' +
' ' + matched + ' ' +
' ' + nf + ' ' +
' ' + pend + '
';
}).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) + (d.errors || 0);
});
overall.innerHTML = t ? '' + (t ? Math.round(m / t * 100) : 0) + '% matched Β· '
+ m + ' of ' + t : '';
}
}
function renderGlobalTabs() {
var host = byId('vem-global-tabs');
if (!host) return;
var btns = host.querySelectorAll('[data-em-priority]');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.toggle('active', btns[i].getAttribute('data-em-priority') === state.priority);
}
}
function renderControls() {
var host = byId('vem-unmatched-controls');
if (!host) return;
var total = state.unmatched ? state.unmatched.total : null;
var opt = function (v, label) {
return '' + label + ' ';
};
var isEpisode = state.kind === 'episode';
host.innerHTML =
'' +
'
' +
(KIND_LABEL[state.kind] || state.kind) + ' not yet matched' +
(total != null ? '' + total.toLocaleString() + ' ' : '') +
(isEpisode ? '' : 'β» Retry all failed ') +
'
' +
'
' +
(isEpisode ? '' :
'
' + opt('unmatched', 'All unmatched') +
opt('not_found', 'Not found') + opt('pending', 'Pending') + ' ') +
'
β ' +
'
' +
'
';
}
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 || '') + ' ' +
'Retry
';
}).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 + ' ' +
'= pages ? ' disabled' : '') + '>βΊ '
: '';
}
}
// ββ selection / actions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
function selectWorker(id) {
state.selected = id; state.breakdown = null; state.unmatched = null;
state.kind = defaultKind(id); state.page = 0; state.search = '';
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(); });
}
function setPriority(kind) {
state.priority = kind;
renderGlobalTabs();
fetch('/api/video/enrichment/priority', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ priority: kind }),
}).catch(function () { /* ignore */ });
}
function onSearchInput(value) {
state.search = value; state.page = 0;
if (state.searchTimer) clearTimeout(state.searchTimer);
state.searchTimer = setTimeout(function () {
loadUnmatched().then(function () { renderControls(); renderList(); restoreSearchFocus(); });
}, 300);
}
function restoreSearchFocus() {
var inp = document.querySelector('#vem-overlay [data-em-search]');
if (inp) { inp.focus(); var v = inp.value; inp.value = ''; inp.value = v; }
}
function setStatusFilter(value) {
state.statusFilter = value; state.page = 0;
loadUnmatched().then(function () { renderControls(); 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
' +
'
Process firsteverywhere ' +
'
' +
'Movies ' +
'Shows ' +
'Auto
' +
'
β³ ' +
'×
' +
'
';
overlay.addEventListener('click', onOverlayClick);
overlay.addEventListener('input', function (e) {
if (e.target.hasAttribute('data-em-search')) onSearchInput(e.target.value);
});
overlay.addEventListener('change', function (e) {
if (e.target.hasAttribute('data-em-status')) setStatusFilter(e.target.value);
});
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],[data-em-priority]');
if (!t) return;
if (t.hasAttribute('data-em-close')) close();
else if (t.hasAttribute('data-em-priority')) setPriority(t.getAttribute('data-em-priority'));
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();
loadPriority();
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();
});
})();