soulsync/webui/static/video/video-side.js
BoulderBadgeDad efa64db04d Video downloads: best-in-class post-process pipeline
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.

- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
  samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
  move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
  (transfer mode, verify, replace, carry subs, save artwork, write NFO,
  download subtitles + langs). Stored in video.db; matches the music File
  Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
  clearlogo, season posters) from on-demand TMDB detail, and external .srt from
  OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
  bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
  -> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.

All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
2026-06-21 01:44:35 -07:00

461 lines
22 KiB
JavaScript

/*
* SoulSync — Video side shell controller.
*
* ISOLATION CONTRACT: the music side never imports or references anything here.
* This file is a self-contained IIFE (no globals) wired entirely via
* addEventListener (no inline onclick), so it cannot affect the music side and
* a merge can't touch it. It only drives shared SHELL behaviour:
* - the Music ↔ Video header toggle (+ remembers the side in localStorage)
* - showing/hiding the video sidebar nav vs the music nav (CSS does the work
* off body[data-side]; this just flips the attribute)
* - a placeholder content host for the video pages (real pages land later)
*
* The actual video domain (data model, services, pages, DB) lives elsewhere and
* is built on top of this shell.
*/
(function () {
'use strict';
// Captured at SCRIPT-EVAL time — before music's router boots (on
// DOMContentLoaded) and may rewrite an unknown /video-detail/... URL to
// /dashboard. This is the real path the user reloaded/deep-linked.
var BOOT_PATH = window.location.pathname;
var SIDE_KEY = 'soulsync_side';
var MUSIC_SUBTITLE = 'Music Sync & Manager';
var VIDEO_SUBTITLE = 'Movies, TV & YouTube';
var DEFAULT_VIDEO_PAGE = 'video-dashboard';
// ── URL routing — REAL links, mirroring music's /artist-detail/<source>/<id>.
// A drill-in is a genuine <a href="/video-detail/<source>/<kind>/<id>"> so
// reload / Back / Forward / open-in-new-tab all work. ``source`` is 'library'
// (a video.db id) today; 'tmdb' (a search result not yet in the library) later.
var DETAIL_BASE = '/video-detail/';
var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1, 'video-person-detail': 1 };
function buildDetailPath(source, kind, id) {
return DETAIL_BASE + encodeURIComponent(source || 'library') + '/' + kind + '/' + encodeURIComponent(id);
}
function parseDetailPath(pathname) {
if (!pathname || pathname.indexOf(DETAIL_BASE) !== 0) return null;
var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean);
if (p.length < 3) return null;
var kind = p[1];
if (kind === 'channel' || kind === 'playlist') { // YouTube ids are strings (UC… / PL…), not numeric
return { source: decodeURIComponent(p[0]), kind: kind, id: decodeURIComponent(p[2]) };
}
var id = parseInt(p[2], 10);
if ((kind !== 'movie' && kind !== 'show' && kind !== 'person') || isNaN(id)) return null;
return { source: decodeURIComponent(p[0]), kind: kind, id: id };
}
// Restore a detail from the URL (popstate / initial load) WITHOUT re-pushing.
function restoreDetail(r) {
if (readSide() !== 'video') { persistSide('video'); applySide('video'); }
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: r.kind, id: r.id, source: r.source, _restore: true } }));
}
// ── Top-level pages get a real URL too: '/' + pageId (e.g. /video-search) ──
// Mirrors music's '/<page>' and our /video-detail/ scheme so every sidebar
// option is deep-linkable (reload / Back / Forward / new-tab). Only a single
// segment whose id is a real (non-detail) video page counts; detail URLs are
// matched by parseDetailPath above and never collide.
function isPageId(pageId) {
if (!pageId || DETAIL_PAGES[pageId]) return false;
for (var i = 0; i < VIDEO_PAGES.length; i++) {
if (VIDEO_PAGES[i].id === pageId) return true;
}
return false;
}
function buildPagePath(pageId) { return '/' + pageId; }
function parsePagePath(pathname) {
if (!pathname) return null;
var seg = pathname.replace(/^\/+|\/+$/g, '');
if (!seg || seg.indexOf('/') >= 0) return null; // single segment only
return isPageId(seg) ? seg : null;
}
// ── Smart back button (mirrors music's artist-detail) ─────────────────────
// Browser history does the real (multi-layer) navigation; this stack only
// tracks WHERE EACH detail layer was opened FROM, so the button can label
// itself ("← Back to Search" / "← Back to The Bear") and so backing out of
// the first layer returns to the right page — not always the library. Each
// entry: {type:'page', pageId} or {type:'detail', label}. The pushed history
// state carries its layer depth so browser Back stays in sync too.
var _backStack = [];
function detailTitleOf(pageId) {
if (pageId === 'video-person-detail') {
var n = document.querySelector('[data-video-person] [data-vp-name]');
return n ? (n.textContent || '').trim() : '';
}
var host = pageId === 'video-movie-detail' ? '[data-video-detail="movie"]' : '[data-video-detail="show"]';
var t = document.querySelector(host + ' [data-vd-title]');
return t ? (t.textContent || '').trim() : '';
}
function currentOrigin() {
var page = document.body.getAttribute('data-video-page');
if (DETAIL_PAGES[page]) return { type: 'detail', label: detailTitleOf(page) };
return { type: 'page', pageId: page };
}
function backLabelText() {
var top = _backStack[_backStack.length - 1];
if (!top) return 'Back';
if (top.type === 'detail') return top.label ? ('Back to ' + top.label) : 'Back';
return 'Back to ' + pageMeta(top.pageId).label;
}
function updateBackLabels() {
var text = backLabelText();
var labels = document.querySelectorAll('[data-vd-back-label]');
for (var i = 0; i < labels.length; i++) labels[i].textContent = text;
}
function backFallback() {
// No browser history to pop (deep link) — go to the recorded first origin.
var dest = (_backStack[0] && _backStack[0].type === 'page') ? _backStack[0].pageId : 'video-library';
_backStack = [];
navigate(dest);
updateBackLabels();
}
function onPopState() {
var path = window.location.pathname;
var r = parseDetailPath(path);
if (r) {
// Synced to the layer depth stamped on the history entry (handles both
// our back button and the browser's Back).
var st = window.history.state;
var layer = (st && st.videoDetail && st.videoDetail.layer) || _backStack.length;
if (_backStack.length > layer) _backStack.length = layer;
restoreDetail(r);
updateBackLabels();
return;
}
var pg = parsePagePath(path);
if (pg) {
// Back/Forward landed on a video page URL → ensure the video side, show
// the page; the URL is already correct so don't re-push it.
if (readSide() !== 'video') { persistSide('video'); applySide('video'); }
navigate(pg, 'restore');
_backStack = [];
updateBackLabels();
return;
}
// The URL is no longer a video path (Back crossed back into music). Hand the
// side back so music's own router can show the page for this URL.
if (document.body.getAttribute('data-side') === 'video') {
persistSide('music');
applySide('music');
_backStack = [];
}
}
// The video sidebar pages. Pages flagged shared: true are "same as music"
// (Import / Issues / Help) — wired to reuse the music pages in a later step;
// for now every page renders the placeholder.
var VIDEO_PAGES = [
{ id: 'video-dashboard', label: 'Dashboard' },
{ id: 'video-search', label: 'Search' },
{ id: 'video-discover', label: 'Discover' },
{ id: 'video-library', label: 'Library' },
{ id: 'video-watchlist', label: 'Watchlist' },
{ id: 'video-wishlist', label: 'Wishlist' },
{ id: 'video-downloads', label: 'Downloads' },
{ id: 'video-calendar', label: 'Calendar' },
{ id: 'video-automations', label: 'Automations' },
{ id: 'video-tools', label: 'Tools' },
{ id: 'video-import', label: 'Import' },
{ id: 'video-settings', label: 'Settings' },
{ id: 'video-issues', label: 'Issues', shared: true },
{ id: 'video-help', label: 'Help & Docs', shared: true },
// Drill-in detail pages — reachable from cards, not the sidebar nav.
{ id: 'video-show-detail', label: 'Show' },
{ id: 'video-movie-detail', label: 'Movie' },
{ id: 'video-person-detail', label: 'Person' },
];
// "Shared" video pages reuse the REAL music page (shown identically on the
// video side for now) instead of a video subpage: video page id -> music
// page id. CSS reveals the music page; we trigger its loader once shown.
var SHARED_PAGES = { 'video-settings': 'settings' };
function readSide() {
try {
return localStorage.getItem(SIDE_KEY) === 'video' ? 'video' : 'music';
} catch (e) {
return 'music';
}
}
function persistSide(side) {
try { localStorage.setItem(SIDE_KEY, side); } catch (e) { /* ignore */ }
}
function pageMeta(pageId) {
for (var i = 0; i < VIDEO_PAGES.length; i++) {
if (VIDEO_PAGES[i].id === pageId) return VIDEO_PAGES[i];
}
return VIDEO_PAGES[0];
}
function setActiveNav(pageId) {
var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]');
for (var i = 0; i < navButtons.length; i++) {
navButtons[i].classList.toggle(
'active', navButtons[i].getAttribute('data-video-page') === pageId);
}
}
function renderPlaceholder(slot, meta) {
// Built from our own static constants only — no user input.
var h2 = document.createElement('h2');
h2.className = 'header-title';
var span = document.createElement('span');
span.textContent = 'Video · ' + meta.label;
h2.appendChild(span);
var note = document.createElement('p');
note.className = 'video-placeholder-note';
note.textContent = 'The ' + meta.label + ' page for the video side is coming soon.';
slot.textContent = '';
slot.appendChild(h2);
slot.appendChild(note);
}
// Show one video page: reveal its built .video-subpage if one exists, else
// fall back to the placeholder slot. Then announce it so per-page data
// modules (e.g. video-dashboard.js) can populate themselves — they listen
// for this event instead of being called directly, keeping each isolated.
function showPage(pageId) {
var meta = pageMeta(pageId);
// Drives the CSS that reveals shared music pages (e.g. Settings) and
// hides the video host for them.
document.body.setAttribute('data-video-page', meta.id);
var sharedMusicId = SHARED_PAGES[meta.id];
if (sharedMusicId) {
// The real music page is shown by CSS; load its data the same way a
// music-side navigation would. (loadPageData is a shared global.)
if (typeof loadPageData === 'function') loadPageData(sharedMusicId);
document.dispatchEvent(new CustomEvent('soulsync:video-page-shown', { detail: meta.id }));
return;
}
var host = document.getElementById('video-page-host');
if (!host) return;
var matched = null;
var subpages = host.querySelectorAll('.video-subpage');
for (var i = 0; i < subpages.length; i++) {
var isMatch = subpages[i].getAttribute('data-video-subpage') === meta.id;
subpages[i].hidden = !isMatch;
if (isMatch) matched = subpages[i];
}
var slot = document.getElementById('video-placeholder-slot');
if (slot) {
slot.hidden = !!matched;
if (!matched) renderPlaceholder(slot, meta);
}
document.dispatchEvent(new CustomEvent('soulsync:video-page-shown', { detail: meta.id }));
}
// Show a page + keep the URL in sync. Detail pages own their /video-detail/
// URL (pushed by the open-detail handler); every other page deep-links to
// '/' + pageId. mode: undefined = push a new history entry (user nav),
// 'replace' = swap the current entry, 'restore' = URL already correct (popstate
// / boot) so don't touch history.
function navigate(pageId, mode) {
setActiveNav(pageId);
showPage(pageId);
if (DETAIL_PAGES[pageId] || mode === 'restore') return;
var path = buildPagePath(pageId);
try {
if (mode === 'replace' || window.location.pathname === path) {
history.replaceState({ videoPage: pageId }, '', path);
} else {
history.pushState({ videoPage: pageId }, '', path);
}
} catch (e) { /* ignore */ }
}
// Flip the shell chrome to a side (data-side drives the CSS). Does NOT navigate
// — callers decide which page to show and whether to push a URL.
function applySide(side) {
document.body.setAttribute('data-side', side);
var subtitle = document.querySelector('.sidebar-header .app-subtitle');
if (subtitle) subtitle.textContent = side === 'video' ? VIDEO_SUBTITLE : MUSIC_SUBTITLE;
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
for (var i = 0; i < toggleButtons.length; i++) {
toggleButtons[i].classList.toggle(
'active', toggleButtons[i].getAttribute('data-side-target') === side);
}
}
function switchSide(side) {
if (side !== 'music' && side !== 'video') return;
persistSide(side);
applySide(side);
if (side === 'video') {
// Land on the last-active video page and give it a real URL.
var active = document.querySelector('.video-nav .nav-button.active');
navigate(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE);
} else if (parseDetailPath(window.location.pathname) || parsePagePath(window.location.pathname)) {
// Back to music from a video URL → drop it so a reload stays on music.
try { history.replaceState(null, '', '/'); } catch (e) { /* ignore */ }
}
}
function init() {
// Deep-linked detail OR page path captured at eval time (music may already
// have rewritten window.location to /dashboard by now).
var bootDetail = parseDetailPath(BOOT_PATH);
var bootPage = bootDetail ? null : parsePagePath(BOOT_PATH);
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
for (var i = 0; i < toggleButtons.length; i++) {
(function (btn) {
btn.addEventListener('click', function () {
switchSide(btn.getAttribute('data-side-target'));
});
})(toggleButtons[i]);
}
var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]');
for (var j = 0; j < navButtons.length; j++) {
(function (btn) {
btn.addEventListener('click', function (e) {
// Let ⌘/Ctrl/middle-click open the real href (/video-<page>) in a
// new tab, exactly like the music nav + the video cards.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
_backStack = []; // sidebar nav is a fresh entry point
navigate(btn.getAttribute('data-video-page'));
});
})(navButtons[j]);
}
// In-page jumps (e.g. dashboard Quick Action tiles) navigate the same
// way as the sidebar nav, via data-video-goto. No inline onclick.
var gotos = document.querySelectorAll('[data-video-goto]');
for (var k = 0; k < gotos.length; k++) {
(function (el) {
el.addEventListener('click', function (e) {
e.preventDefault();
_backStack = []; // in-page jump is a fresh entry point
navigate(el.getAttribute('data-video-goto'));
});
})(gotos[k]);
}
// Jump to a top-level video page from anywhere (e.g. a "Track on Downloads"
// button in the download modal / a detail page's live-progress chip).
document.addEventListener('soulsync:video-navigate', function (e) {
var pageId = e && e.detail && (e.detail.page || e.detail);
if (typeof pageId === 'string') navigate(pageId);
});
// Drill-in: a card fires soulsync:video-open-detail {kind, id, source}. We
// navigate to the matching detail subpage (video-detail.js loads the data)
// and push a real URL — unless we're restoring from the URL (_restore).
document.addEventListener('soulsync:video-open-detail', function (e) {
var d = e && e.detail; if (!d) return;
// Capture the origin BEFORE navigating away from the current page.
var origin = (d._restore || d._replace) ? null : currentOrigin();
if (d.kind === 'movie') navigate('video-movie-detail');
else if (d.kind === 'show') navigate('video-show-detail');
else if (d.kind === 'person') navigate('video-person-detail');
else if (d.kind === 'channel') navigate('video-show-detail'); // channels reuse the show detail page
else if (d.kind === 'playlist') navigate('video-show-detail'); // playlists too (flat list)
else return;
if (d._replace) {
// A redirect (e.g. a tmdb link that's actually owned) → replace the
// entry being redirected FROM, keeping its layer + origin, so Back
// doesn't bounce back onto the redirecting URL.
var rstate = { videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library',
layer: _backStack.length } };
try { history.replaceState(rstate, '', buildDetailPath(d.source, d.kind, d.id)); } catch (e2) { /* ignore */ }
} else if (!d._restore) {
_backStack.push(origin);
var state = { videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library',
layer: _backStack.length } };
var path = buildDetailPath(d.source, d.kind, d.id);
if (window.location.pathname !== path) history.pushState(state, '', path);
else history.replaceState(state, '', path);
}
updateBackLabels();
});
// The detail back button is real browser Back (so it unwinds the whole
// drill-in chain, layer by layer); only when there's no in-app history to
// pop does it fall back to the recorded first origin.
document.addEventListener('click', function (e) {
var back = e.target.closest('[data-video-detail-back]');
if (!back) return;
e.preventDefault();
if (window.history.length > 1 && window.history.state && window.history.state.videoDetail) {
window.history.back();
} else {
backFallback();
}
});
window.addEventListener('popstate', onPopState);
var defaultNav = document.querySelector(
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
if (defaultNav) defaultNav.classList.add('active');
var bootSide = (bootDetail || bootPage) ? 'video' : readSide();
applySide(bootSide);
// On the video side without a detail deep link, show the initial page (the
// page-path deep link if any, else the last-active/default nav). Deferred to
// a macrotask so every page module's listeners are registered first. Only a
// genuine page deep link re-asserts its URL against music's boot clobber;
// the plain last-side-was-video case leaves the URL untouched (as before).
if (bootSide === 'video' && !bootDetail) {
var activeBtn = document.querySelector('.video-nav .nav-button.active');
var initialPage = bootPage || (activeBtn && activeBtn.getAttribute('data-video-page')) || DEFAULT_VIDEO_PAGE;
setActiveNav(initialPage);
var pagePath = buildPagePath(initialPage);
var reassertPage = function () {
if (bootPage && document.body.getAttribute('data-side') === 'video' &&
!DETAIL_PAGES[document.body.getAttribute('data-video-page')] &&
window.location.pathname !== pagePath) {
try { history.replaceState({ videoPage: initialPage }, '', pagePath); } catch (e) { /* ignore */ }
}
};
setTimeout(function () {
reassertPage();
navigate(initialPage, 'restore');
if (bootPage) [120, 350, 700].forEach(function (ms) { setTimeout(reassertPage, ms); });
}, 0);
}
// Deep link / reload straight to a detail URL → restore it. Deferred to a
// macrotask so EVERY script's DOMContentLoaded handler has registered
// first (video-detail.js loads after us and must be listening for the
// open-detail event), and so it lands AFTER music's initial routing — then
// we re-assert the real URL it clobbered.
if (bootDetail) {
var bootPath = buildDetailPath(bootDetail.source, bootDetail.kind, bootDetail.id);
var reassert = function () {
// Re-assert the URL only while we're still showing this detail (so a
// late async music redirect can't strand us on /dashboard) — never
// fights real navigation away.
if (DETAIL_PAGES[document.body.getAttribute('data-video-page')] &&
window.location.pathname !== bootPath) {
try { history.replaceState({ videoDetail: bootDetail }, '', bootPath); } catch (e) { /* ignore */ }
}
};
setTimeout(function () {
reassert();
restoreDetail(bootDetail);
[120, 350, 700].forEach(function (ms) { setTimeout(reassert, ms); });
}, 0);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();