soulsync/webui/static/video/video-scan.js
BoulderBadgeDad b26f664326 Video tools: scan a Movies OR TV library (not just both)
The video Library Scan tool only scanned 'all' — but movies and TV are
independent libraries (unlike music's single library). The scanner backend
already supported media_type='movie'|'show'|'all'; this just wires it up:
- /api/video/scan/request now reads media_type and threads it to request_scan
- the Tools card gains a target selector (All / Movies Only / TV Shows Only)
  alongside the existing mode dropdown, matching the music scan's UX
- the live status detail reflects the target (no confusing '0 shows' on a
  movies-only scan)

Seam test: the endpoint passes both mode and media_type through (default all/full,
explicit movie/deep, TV-only). Existing scanner media-type/scope tests unchanged.
2026-06-21 23:27:35 -07:00

223 lines
9.3 KiB
JavaScript

/*
* SoulSync — shared VIDEO scan controller (isolated).
*
* One place triggers + polls library scans for every surface (Library page,
* Tools page, Dashboard card), so nothing duplicates the fetch/poll logic. It
* reuses the music CSS/markup — it just targets /api/video/* and updates the
* video DOM.
*
* Triggers:
* - click on [data-video-scan-mode="full|incremental|deep"] (or [data-video-scan] = full)
* - click on [data-video-scan-run], reading the mode from [data-video-scan-select]
* - a 'soulsync:video-scan-start' event {detail:{mode}}
*
* Updates the Tools progress widgets ([data-video-scan-phase|bar|detail]) and
* emits 'soulsync:video-scan-progress' / 'soulsync:video-scan-done' so the
* Library page and Dashboard can react too. Self-contained IIFE, no globals.
*/
(function () {
'use strict';
var REQUEST_URL = '/api/video/scan/request';
var STATUS_URL = '/api/video/scan/status';
var scanning = false;
function emit(name, detail) {
document.dispatchEvent(new CustomEvent(name, { detail: detail }));
}
function setText(sel, text) {
var n = document.querySelector(sel);
if (n) n.textContent = text;
}
function setBar(pct) {
var bar = document.querySelector('[data-video-scan-bar]');
if (bar) bar.style.width = pct + '%';
}
function counts(s) {
// Reflect the scan target so a movies-only / TV-only scan doesn't show a
// confusing "0 shows" half. 'all' shows both.
var mt = s.media_type || 'all';
if (mt === 'movie') return (s.movies || 0) + ' movies';
if (mt === 'show') return (s.shows || 0) + ' shows, ' + (s.episodes || 0) + ' episodes';
return (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows';
}
function formatSize(bytes) {
bytes = Number(bytes) || 0;
var mb = bytes / 1048576;
if (mb >= 1048576) return (mb / 1048576).toFixed(2) + ' TB';
if (mb >= 1024) return (mb / 1024).toFixed(2) + ' GB';
return mb.toFixed(1) + ' MB';
}
// Populate the Tools card stat grid (Movies/Shows/Episodes/Size) from the
// same dashboard endpoint — reuses existing data, no new API.
function loadToolStats() {
if (!document.querySelector('[data-video-scan-stat]')) return;
fetch('/api/video/dashboard', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || d.error) return;
var lib = d.library || {};
var set = function (k, v) {
var n = document.querySelector('[data-video-scan-stat="' + k + '"]');
if (n) n.textContent = v;
};
set('movies', lib.movies || 0);
set('shows', lib.shows || 0);
set('episodes', lib.episodes || 0);
set('size', formatSize(lib.size_bytes));
// Server-prefixed title, like music ("Plex Database Updater").
var titleEl = document.querySelector('[data-video-scan-title]');
if (titleEl && d.server) {
titleEl.textContent =
d.server.charAt(0).toUpperCase() + d.server.slice(1) + ' Library Scan';
}
})
.catch(function () { /* leave defaults */ });
}
function setRunLabel(text) {
var run = document.querySelector('[data-video-scan-run]');
if (run) run.textContent = text;
}
function reflectProgress(s) {
var phase = (s.phase || 'scanning');
setText('[data-video-scan-phase]', phase.charAt(0).toUpperCase() + phase.slice(1));
var detail = counts(s);
if (s.percent != null) detail += ' · ' + s.percent + '%';
setText('[data-video-scan-detail]', detail);
// Real percentage when we know the total; otherwise a full bar as a
// "working" indicator.
setBar(s.percent != null ? s.percent : 100);
setRunLabel('Cancel'); // button doubles as cancel while scanning
}
function reflectDone(s) {
setRunLabel('Scan Library');
if (s.state === 'error') {
setText('[data-video-scan-phase]', 'Failed');
setText('[data-video-scan-detail]', s.error || 'Scan failed');
setBar(0);
} else if (s.state === 'cancelled') {
setText('[data-video-scan-phase]', 'Cancelled');
setText('[data-video-scan-detail]', counts(s) + ' (cancelled)');
setBar(0);
loadToolStats();
} else {
setText('[data-video-scan-phase]', 'Complete');
setText('[data-video-scan-detail]',
counts(s) + (s.removed ? ', ' + s.removed + ' removed' : ''));
setBar(100);
loadToolStats();
try { setText('[data-video-scan-last]', new Date().toLocaleString()); } catch (e) { /* ignore */ }
}
}
function poll() {
fetch(STATUS_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (s) {
if (s && s.state === 'scanning') {
reflectProgress(s);
emit('soulsync:video-scan-progress', s);
setTimeout(poll, 1500);
} else {
scanning = false;
reflectDone(s || { state: 'idle' });
emit('soulsync:video-scan-done', s || { state: 'idle' });
}
})
.catch(function () {
scanning = false;
reflectDone({ state: 'error', error: 'Could not reach server' });
emit('soulsync:video-scan-done', { state: 'error' });
});
}
function start(mode, mediaType) {
if (scanning) return;
scanning = true;
mediaType = mediaType || 'all'; // 'all' | 'movie' | 'show'
var pending = { state: 'scanning', phase: 'starting', mode: mode,
media_type: mediaType, movies: 0, shows: 0, percent: 0 };
reflectProgress(pending);
emit('soulsync:video-scan-progress', pending);
fetch(REQUEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ mode: mode, media_type: mediaType })
})
.then(function () { setTimeout(poll, 600); })
.catch(function () {
scanning = false;
reflectDone({ state: 'error' });
emit('soulsync:video-scan-done', { state: 'error' });
});
}
function stop() {
setText('[data-video-scan-phase]', 'Cancelling…');
fetch('/api/video/scan/stop', { method: 'POST', headers: { 'Accept': 'application/json' } })
.catch(function () { /* poll will reconcile */ });
}
// Rehydrate after a page refresh: the scan keeps running server-side, so if
// one is in progress, restore the UI (Cancel button, moving bar, phase) and
// resume polling — parity with the music tools.
function resumeIfScanning() {
if (scanning) return;
fetch(STATUS_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (s) {
if (s && s.state === 'scanning' && !scanning) {
scanning = true;
reflectProgress(s);
emit('soulsync:video-scan-progress', s);
setTimeout(poll, 1200);
}
})
.catch(function () { /* ignore */ });
}
function init() {
var triggers = document.querySelectorAll('[data-video-scan-mode],[data-video-scan]');
for (var i = 0; i < triggers.length; i++) {
(function (el) {
el.addEventListener('click', function (e) {
e.preventDefault();
start(el.getAttribute('data-video-scan-mode') || 'full');
});
})(triggers[i]);
}
var run = document.querySelector('[data-video-scan-run]');
if (run) {
run.addEventListener('click', function (e) {
e.preventDefault();
if (scanning) { stop(); return; } // button doubles as Cancel mid-scan
var sel = document.querySelector('[data-video-scan-select]');
var tgt = document.querySelector('[data-video-scan-target]');
start(sel ? sel.value : 'full', tgt ? tgt.value : 'all');
});
}
document.addEventListener('soulsync:video-scan-start', function (e) {
start((e.detail && e.detail.mode) || 'full', (e.detail && e.detail.media_type) || 'all');
});
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e && e.detail === 'video-tools') { loadToolStats(); resumeIfScanning(); }
});
// On load: if a scan is already running (page was refreshed mid-scan),
// restore the live state instead of showing Idle.
resumeIfScanning();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();