soulsync/webui/static/video/video-settings.js
BoulderBadgeDad 8392de9207 video downloads (phase 1): video-specific download + transfer folders
Foundation for the isolated video download settings. The Downloads tab is almost
entirely music-specific, so on the video side the music download sections are hidden
(video-side.css) and a data-video-only 'Video Download Folders' section takes their
place — an input (download) and output (transfer/library) folder, stored SEPARATELY
from the music soulseek.* paths in video.db's video_settings KV table.

- api/video/downloads.py: GET/POST /api/video/downloads/config (download_path,
  transfer_path), registered in the video blueprint. Imports nothing from music.
- video-settings.js: loadDownloads/saveDownloads wired into onPageShown + the
  video save-button chain.
- The shared 'Indexers & Downloaders' tab is left untouched (identical for both).

2 tests (round-trip + the isolation guard). Quality profile, video hybrid, and the
shared slskd block are the next phases.
2026-06-19 00:04:27 -07:00

421 lines
22 KiB
JavaScript

/*
* SoulSync — Video settings additions (isolated).
*
* The video side shows the real music settings page; this module only drives the
* VIDEO-specific bits added to it — for now, the Movies/TV library mapping
* (which server library the scan reads). It populates the dropdowns from
* /api/video/libraries when the Settings page is shown on the video side and
* saves the choice back. Self-contained IIFE, no globals, no inline handlers.
*/
(function () {
'use strict';
var PAGE_ID = 'video-settings';
var URL = '/api/video/libraries';
var CONFIG_URL = '/api/video/enrichment/config';
var SERVER_URL = '/api/video/server';
var CONN_URL = '/api/video/server-config';
var DOWNLOADS_URL = '/api/video/downloads/config';
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Server Connection ───────────────────────────────────────────────────
// Mirrors the MUSIC server picker (toggle = select + configure), scoped to
// Plex/Jellyfin. Clicking a toggle reveals that server's creds AND sets it as
// the active video server. Creds are video's own (video.db), pre-filled from
// music; the picker writes only to /api/video/* — never the music config.
function connEl(name) { return document.querySelector('[data-video-conn="' + name + '"]'); }
function note(server, text) {
var n = document.querySelector('[data-video-conn-note="' + server + '"]');
if (n) n.textContent = text || '';
}
function showServerConfig(server) {
var btns = document.querySelectorAll('[data-video-server-toggle]');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.toggle('active', btns[i].getAttribute('data-video-server-toggle') === server);
}
var cfgs = document.querySelectorAll('[data-video-server-config]');
for (var j = 0; j < cfgs.length; j++) {
cfgs[j].classList.toggle('hidden', cfgs[j].getAttribute('data-video-server-config') !== server);
}
}
// Which server's config to show on load: the explicit pick, else the
// configured one, else Plex (so there's always a panel to fill in).
function loadServer() {
fetch(SERVER_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
d = d || {};
var active = d.server || (d.jellyfin && !d.plex ? 'jellyfin' : 'plex');
showServerConfig(active);
if (active === 'jellyfin') loadJellyfinUsers();
})
.catch(function () { showServerConfig('plex'); });
}
function loadConn() {
fetch(CONN_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var p = d.plex || {}, j = d.jellyfin || {};
var pu = connEl('plex-url'); if (pu) pu.value = p.base_url || '';
var pt = connEl('plex-token'); if (pt) pt.value = p.has_token ? p.token : '';
var ju = connEl('jellyfin-url'); if (ju) ju.value = j.base_url || '';
var jk = connEl('jellyfin-key'); if (jk) jk.value = j.has_key ? j.api_key : '';
note('plex', p.base_url
? (p.inherited ? 'Inherited from your Music Plex connection — edit to use a different server for video.'
: 'Custom video connection.')
: 'Not connected — add a server URL and token.');
note('jellyfin', j.base_url
? (j.inherited ? 'Inherited from your Music Jellyfin connection — edit to use a different server for video.'
: 'Custom video connection.')
: 'Not connected — add a server URL and API key.');
})
.catch(function () { /* ignore */ });
}
function saveConn(silent) {
var pu = connEl('plex-url'), pt = connEl('plex-token');
var ju = connEl('jellyfin-url'), jk = connEl('jellyfin-key');
return fetch(CONN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
plex: { base_url: pu ? pu.value : '', token: pt ? pt.value : '' },
jellyfin: { base_url: ju ? ju.value : '', api_key: jk ? jk.value : '' }
})
}).then(function () { loadConn(); if (!silent) toast('Connection saved', 'success'); })
.catch(function () { if (!silent) toast('Could not save connection', 'error'); });
}
// Toggle click: reveal that server's config immediately (like the music
// toggle) and persist it as the active video server pick.
function pickServer(server) {
showServerConfig(server);
if (server === 'jellyfin') loadJellyfinUsers();
fetch(SERVER_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ server: server })
}).then(function () {
load();
toast('Video server set to ' + (server === 'plex' ? 'Plex' : 'Jellyfin'), 'success');
}).catch(function () { /* ignore */ });
}
function testConn(server) {
var name = server === 'plex' ? 'Plex' : 'Jellyfin';
toast('Testing ' + name + ' connection…', 'info');
saveConn(true).then(function () {
return fetch(CONN_URL + '/test', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ server: server })
});
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.success) {
toast(res.message || (name + ' connection successful'), 'success');
if (server === 'jellyfin') { loadJellyfinUsers(); load(); } // user + libraries
} else {
toast(name + ' connection failed: ' + ((res && res.error) || 'unknown'), 'error');
}
}).catch(function () { toast('Failed to test ' + name + ' connection', 'error'); });
}
// ── Jellyfin user picker (mirrors music: pick a user, then its libraries) ──
function loadJellyfinUsers() {
var wrap = document.querySelector('[data-video-jellyfin-user-wrap]');
var sel = document.querySelector('[data-video-jellyfin-user]');
if (!wrap || !sel) return;
fetch('/api/video/jellyfin/users', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.success || !d.users || !d.users.length) { wrap.style.display = 'none'; return; }
sel.textContent = '';
var none = document.createElement('option');
none.value = ''; none.textContent = 'Select User';
sel.appendChild(none);
d.users.forEach(function (u) {
var o = document.createElement('option');
o.value = u.id;
o.textContent = u.name + (u.admin ? ' (admin)' : '');
if (u.id === d.selected) o.selected = true;
sel.appendChild(o);
});
wrap.style.display = 'block';
})
.catch(function () { wrap.style.display = 'none'; });
}
function selectJellyfinUser(id) {
fetch('/api/video/jellyfin/user', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ user: id })
}).then(function () {
load(); // refresh libraries for the user
if (id) toast('Jellyfin user updated', 'success');
}).catch(function () { /* ignore */ });
}
function status(text) {
var n = document.querySelector('[data-video-lib-status]');
if (n) n.textContent = text || '';
}
function fill(select, items, selected) {
if (!select) return;
select.textContent = '';
var none = document.createElement('option');
none.value = '';
none.textContent = '— None —';
select.appendChild(none);
for (var i = 0; i < items.length; i++) {
var opt = document.createElement('option');
opt.value = items[i].title;
opt.textContent = items[i].title;
if (items[i].title === selected) opt.selected = true;
select.appendChild(opt);
}
}
function load() {
status('Loading…');
fetch(URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || d.error) { status('Could not load libraries'); return; }
var sel = d.selected || {};
fill(document.querySelector('[data-video-lib-select="movies"]'), d.movies || [], sel.movies);
fill(document.querySelector('[data-video-lib-select="tv"]'), d.tv || [], sel.tv);
status('');
})
.catch(function () { status('Could not load libraries'); });
}
function save(silent) {
var m = document.querySelector('[data-video-lib-select="movies"]');
var t = document.querySelector('[data-video-lib-select="tv"]');
status('Saving…');
return fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ movies: m ? m.value : '', tv: t ? t.value : '' })
})
.then(function (r) { return r.json(); })
.then(function () { status('Saved'); if (!silent) toast('Library selection saved', 'success'); })
.catch(function () { status('Save failed'); if (!silent) toast('Could not save libraries', 'error'); });
}
// ── Enrichment API keys (TMDB / TVDB) ───────────────────────────────────
function loadKeys() {
fetch(CONFIG_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-api-key');
var o = document.getElementById('omdb-api-key');
if (t && d.tmdb_api_key != null) t.value = d.tmdb_api_key;
if (v && d.tvdb_api_key != null) v.value = d.tvdb_api_key;
if (o && d.omdb_api_key != null) o.value = d.omdb_api_key;
var fa = document.getElementById('fanart-api-key');
if (fa && d.fanart_api_key != null) fa.value = d.fanart_api_key;
var sub = document.getElementById('opensubtitles-api-key');
if (sub && d.opensubtitles_api_key != null) sub.value = d.opensubtitles_api_key;
var trakt = document.getElementById('trakt-api-key');
if (trakt && d.trakt_api_key != null) trakt.value = d.trakt_api_key;
var ryd = document.getElementById('video-ryd-enabled');
if (ryd && d.ryd_enabled != null) ryd.checked = !!d.ryd_enabled;
var sb = document.getElementById('video-sponsorblock-enabled');
if (sb && d.sponsorblock_enabled != null) sb.checked = !!d.sponsorblock_enabled;
var dea = document.getElementById('video-dearrow-enabled');
if (dea && d.dearrow_enabled != null) dea.checked = !!d.dearrow_enabled;
var tvm = document.getElementById('video-tvmaze-enabled');
if (tvm && d.tvmaze_enabled != null) tvm.checked = !!d.tvmaze_enabled;
var anl = document.getElementById('video-anilist-enabled');
if (anl && d.anilist_enabled != null) anl.checked = !!d.anilist_enabled;
var wkd = document.getElementById('video-wikidata-enabled');
if (wkd && d.wikidata_enabled != null) wkd.checked = !!d.wikidata_enabled;
var ap = document.getElementById('video-billboard-autoplay');
if (ap && d.billboard_autoplay != null) ap.checked = !!d.billboard_autoplay;
var wr = document.getElementById('video-watch-region');
if (wr && d.watch_region) wr.value = d.watch_region;
})
.catch(function () { /* ignore */ });
}
function savePrefs(silent) {
var ap = document.getElementById('video-billboard-autoplay');
var wr = document.getElementById('video-watch-region');
return fetch(CONFIG_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
billboard_autoplay: ap ? ap.checked : true,
watch_region: wr ? wr.value : 'US',
})
}).then(function () { if (!silent) toast('Preferences saved', 'success'); })
.catch(function () { /* ignore */ });
}
// ── Downloads tab: video-specific input/output folders ──
function loadDownloads() {
fetch(DOWNLOADS_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var dl = document.getElementById('video-download-path');
if (dl && d.download_path != null) dl.value = d.download_path;
var tr = document.getElementById('video-transfer-path');
if (tr && d.transfer_path != null) tr.value = d.transfer_path;
})
.catch(function () { /* ignore */ });
}
function saveDownloads(silent) {
var dl = document.getElementById('video-download-path');
var tr = document.getElementById('video-transfer-path');
return fetch(DOWNLOADS_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
download_path: dl ? dl.value : '',
transfer_path: tr ? tr.value : '',
})
}).then(function () { if (!silent) toast('Download folders saved', 'success'); })
.catch(function () { /* ignore */ });
}
function saveKeys(silent) {
var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-api-key');
var o = document.getElementById('omdb-api-key');
var fa = document.getElementById('fanart-api-key');
var sub = document.getElementById('opensubtitles-api-key');
var trakt = document.getElementById('trakt-api-key');
var ryd = document.getElementById('video-ryd-enabled');
var sb = document.getElementById('video-sponsorblock-enabled');
var dea = document.getElementById('video-dearrow-enabled');
var tvm = document.getElementById('video-tvmaze-enabled');
var anl = document.getElementById('video-anilist-enabled');
var wkd = document.getElementById('video-wikidata-enabled');
return fetch(CONFIG_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
tmdb_api_key: t ? t.value : '', tvdb_api_key: v ? v.value : '',
omdb_api_key: o ? o.value : '',
fanart_api_key: fa ? fa.value : '',
opensubtitles_api_key: sub ? sub.value : '',
trakt_api_key: trakt ? trakt.value : '',
ryd_enabled: ryd ? ryd.checked : true,
sponsorblock_enabled: sb ? sb.checked : true,
dearrow_enabled: dea ? dea.checked : true,
tvmaze_enabled: tvm ? tvm.checked : true,
anilist_enabled: anl ? anl.checked : false,
wikidata_enabled: wkd ? wkd.checked : true,
})
}).then(function () { if (!silent) toast('API keys saved', 'success'); })
.catch(function () { /* ignore */ });
}
function toast(msg, type) {
if (typeof showToast === 'function') showToast(msg, type); // shared shell helper
}
// Mirrors music's testConnection(): save the key, then hit the test
// endpoint, then toast the result. Isolated -> /api/video/enrichment/<svc>/test.
function testConnection(svc) {
var name = svc.toUpperCase();
toast('Testing ' + name + ' connection…', 'info');
saveKeys(true).then(function () {
return fetch('/api/video/enrichment/' + svc + '/test',
{ method: 'POST', headers: { 'Accept': 'application/json' } });
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.success) toast(res.message || (name + ' connection successful'), 'success');
else toast(name + ' connection failed: ' + ((res && res.error) || 'unknown'), 'error');
}).catch(function () { toast('Failed to test ' + name + ' connection', 'error'); });
}
function onPageShown(e) {
if (e && e.detail !== PAGE_ID) return;
loadServer();
loadConn();
load();
loadKeys();
loadDownloads();
}
function init() {
// Save the moment a library is picked — same behaviour as the music
// 'Music Library' selector above (which saves on change, no button).
// NB: wrap each handler so the DOM Event isn't passed as the function's
// first arg (which is our `silent` flag — it would suppress the toast).
var selects = document.querySelectorAll('[data-video-lib-select]');
for (var i = 0; i < selects.length; i++) {
selects[i].addEventListener('change', function () { save(); });
}
// Enrichment keys save on blur/change (turns the workers on).
['tmdb-api-key', 'tvdb-api-key', 'omdb-api-key',
'fanart-api-key', 'opensubtitles-api-key', 'trakt-api-key',
'video-ryd-enabled', 'video-sponsorblock-enabled', 'video-dearrow-enabled',
'video-tvmaze-enabled', 'video-anilist-enabled',
'video-wikidata-enabled'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('change', function () { saveKeys(); });
});
var autoplay = document.getElementById('video-billboard-autoplay');
if (autoplay) autoplay.addEventListener('change', function () { savePrefs(); });
var region = document.getElementById('video-watch-region');
if (region) region.addEventListener('change', function () { savePrefs(); });
// Server toggle (Plex/Jellyfin) — select + reveal that server's config.
var toggles = document.querySelectorAll('[data-video-server-toggle]');
for (var t = 0; t < toggles.length; t++) {
(function (b) {
b.addEventListener('click', function () {
pickServer(b.getAttribute('data-video-server-toggle'));
});
})(toggles[t]);
}
// Server Connection (video's own creds) — save on change, test on click.
var connInputs = document.querySelectorAll('[data-video-conn]');
for (var c = 0; c < connInputs.length; c++) {
connInputs[c].addEventListener('change', function () { saveConn(); });
}
var connTests = document.querySelectorAll('[data-video-conn-test]');
for (var d = 0; d < connTests.length; d++) {
(function (b) {
b.addEventListener('click', function () {
testConn(b.getAttribute('data-video-conn-test'));
});
})(connTests[d]);
}
// Jellyfin user pick → store it + refresh that user's libraries.
var userSel = document.querySelector('[data-video-jellyfin-user]');
if (userSel) userSel.addEventListener('change', function () { selectJellyfinUser(userSel.value); });
// Per-connection Test buttons (same behaviour as music's testConnection).
var testBtns = document.querySelectorAll('[data-video-test-service]');
for (var k = 0; k < testBtns.length; k++) {
(function (b) {
b.addEventListener('click', function () {
testConnection(b.getAttribute('data-video-test-service'));
});
})(testBtns[k]);
}
// The shared "Save Settings" button belongs to MUSIC (it runs music's
// saveSettings, which would fire a music-config write from the video page).
// On the video side we intercept it (capture phase, before music's bubble
// listener), block music's handler, flush all video settings, and toast —
// so the button is real here and can't reach into music. Music side: this
// does nothing, so its behaviour is unchanged.
document.addEventListener('click', function (e) {
if (document.body.getAttribute('data-side') !== 'video') return;
if (!e.target.closest('#save-settings')) return;
e.preventDefault();
e.stopImmediatePropagation();
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true)])
.then(function () { toast('Settings saved', 'success'); })
.catch(function () { toast('Some settings could not be saved', 'error'); });
}, true);
document.addEventListener('soulsync:video-page-shown', onPageShown);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();