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.
421 lines
22 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ── 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();
|
|
}
|
|
})();
|