soulsync/webui/static/video/video-library.js
BoulderBadgeDad 71f126f9d2 video side: Tools page + scan controls on dashboard (3 modes)
- New Tools page (video nav + subpage, mirrors music tools styling): a Library
  Scan tool card with Incremental / Full Refresh / Deep Scan buttons + a live
  status line. Room for more maintenance jobs later.
- Dashboard Library card now has Refresh (full) + Deep Scan buttons, like the
  music dashboard.
- Shared video-scan.js controller: one place triggers + polls scans for all
  surfaces (wires any [data-video-scan-mode]/[data-video-scan]); emits
  soulsync:video-scan-progress/done. Library/Tools/Dashboard just listen — no
  duplicated fetch/poll. video-library.js refactored onto it; dashboard reloads
  stats on scan-done.
- All isolated IIFEs, data-attr wired (no inline onclick). video-tools added to
  the nav (13 pages). 110 tests green.
2026-06-13 23:34:28 -07:00

154 lines
5.4 KiB
JavaScript

/*
* SoulSync — Video Library page (isolated).
*
* Same isolation contract as the other video modules: self-contained IIFE, no
* globals, no inline handlers, lives under static/video/. Listens for the
* 'soulsync:video-page-shown' event (dispatched by video-side.js) and, when the
* Library page is shown, fetches /api/video/library and renders it. The Scan
* button asks the server to re-read the media server (source of truth) via
* /api/video/scan/request and polls /api/video/scan/status.
*/
(function () {
'use strict';
var PAGE_ID = 'video-library';
var LIBRARY_URL = '/api/video/library';
var state = { tab: 'movies', data: null, loading: false, scanning: false };
function $(sel) { return document.querySelector(sel); }
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function poster(node, title, url) {
var p = el('div', 'video-card-poster');
if (url && /^https?:\/\//.test(url)) {
var img = el('img');
img.alt = '';
img.loading = 'lazy';
img.src = url;
p.appendChild(img);
} else {
p.appendChild(el('span', 'video-card-poster-letter',
(title || '?').charAt(0).toUpperCase()));
}
node.appendChild(p);
}
function movieCard(m) {
var card = el('div', 'video-card');
poster(card, m.title, m.poster_url);
card.appendChild(el('div', 'video-card-title', m.title || 'Untitled'));
var meta = [];
if (m.year) meta.push(String(m.year));
meta.push(m.has_file ? 'Owned' : 'Wanted');
card.appendChild(el('div', 'video-card-meta', meta.join(' · ')));
return card;
}
function showCard(s) {
var card = el('div', 'video-card');
poster(card, s.title, s.poster_url);
card.appendChild(el('div', 'video-card-title', s.title || 'Untitled'));
var meta = [];
if (s.year) meta.push(String(s.year));
meta.push((s.owned_count || 0) + '/' + (s.episode_count || 0) + ' eps');
card.appendChild(el('div', 'video-card-meta', meta.join(' · ')));
return card;
}
function render() {
var grid = $('[data-video-lib-grid]');
var empty = $('[data-video-lib-empty]');
var count = $('[data-video-lib-count]');
if (!grid) return;
grid.textContent = '';
var items = state.data ? (state.data[state.tab] || []) : [];
var maker = state.tab === 'movies' ? movieCard : showCard;
for (var i = 0; i < items.length; i++) grid.appendChild(maker(items[i]));
if (empty) empty.hidden = items.length > 0;
if (count) {
count.textContent = state.loading ? 'Loading…'
: items.length + ' ' + state.tab;
}
}
function loadLibrary(force) {
if (state.data && !force) { render(); return; }
state.loading = true;
render();
fetch(LIBRARY_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
state.loading = false;
state.data = (d && !d.error) ? d : { movies: [], shows: [] };
render();
})
.catch(function () {
state.loading = false;
state.data = { movies: [], shows: [] };
render();
});
}
function setScanLabel(text) {
var label = $('[data-video-scan-label]');
if (label) label.textContent = text;
}
// Scan is triggered by the shared controller (video-scan.js wires the
// [data-video-scan] button); we just reflect its progress + reload on done.
function onScanProgress(e) {
var s = e.detail || {};
if (s.state !== 'scanning') return;
var btn = $('[data-video-scan]');
if (btn) btn.disabled = true;
setScanLabel((s.phase || 'Scanning') + '… ' + (s.movies || 0) + 'm ' + (s.shows || 0) + 's');
}
function onScanDone() {
var btn = $('[data-video-scan]');
if (btn) btn.disabled = false;
setScanLabel('Scan Library');
loadLibrary(true);
}
function wire() {
var tabs = document.querySelectorAll('[data-video-lib-tab]');
for (var i = 0; i < tabs.length; i++) {
(function (tab) {
tab.addEventListener('click', function () {
state.tab = tab.getAttribute('data-video-lib-tab');
var all = document.querySelectorAll('[data-video-lib-tab]');
for (var j = 0; j < all.length; j++) {
all[j].classList.toggle('active', all[j] === tab);
}
render();
});
})(tabs[i]);
}
}
function onPageShown(e) {
if (!e || e.detail !== PAGE_ID) return;
loadLibrary(false);
}
function init() {
wire();
document.addEventListener('soulsync:video-page-shown', onPageShown);
document.addEventListener('soulsync:video-scan-progress', onScanProgress);
document.addEventListener('soulsync:video-scan-done', onScanDone);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();