From a2b2d11003ea4b50d13cd7e347384748e5530f6e Mon Sep 17 00:00:00 2001 From: binarygeek119 <72405030+binarygeek119@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:43:43 -0500 Subject: [PATCH 1/2] Add multi media-server support with cache-first slides. Allow up to 10 servers per type, migrate legacy plex* settings, tag poster cache by server_id, and add a dedicated Media Server settings page. Co-authored-by: Cursor --- .nvmrc | 1 + classes/core/mediaServers.js | 368 +++++++++++++ classes/core/posterMetadataDb.js | 246 +++++++-- classes/core/posterSyncRetry.js | 31 +- classes/core/settings.js | 154 ++++-- consts.js | 9 +- index.js | 781 +++++++++++++++++++-------- myviews/settings-ads.ejs | 2 + myviews/settings-cache.ejs | 4 + myviews/settings-holidays.ejs | 2 + myviews/settings-media-server.ejs | 380 +++++++++++++ myviews/settings-now-showing.ejs | 2 + myviews/settings-sync.ejs | 25 +- myviews/settings-tmdb.ejs | 2 + myviews/settings.ejs | 115 +--- test/mediaServers.multi.test.js | 159 ++++++ test/posterMetadata.serverId.test.js | 140 +++++ 17 files changed, 1997 insertions(+), 424 deletions(-) create mode 100644 .nvmrc create mode 100644 classes/core/mediaServers.js create mode 100644 myviews/settings-media-server.ejs create mode 100644 test/mediaServers.multi.test.js create mode 100644 test/posterMetadata.serverId.test.js diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/classes/core/mediaServers.js b/classes/core/mediaServers.js new file mode 100644 index 0000000..c3ce6a1 --- /dev/null +++ b/classes/core/mediaServers.js @@ -0,0 +1,368 @@ +/** + * Multi media-server model: normalize, migrate, cap, and build clients. + * Up to MEDIA_SERVER_MAX_PER_TYPE of each type (plex/jellyfin/emby/kodi). + */ +const crypto = require("crypto"); +const { + getMediaServerClass, + getMediaServerKind, + requiresMediaServerCredential, + getMediaServerShortLabel, +} = require("../mediaservers/mediaServerFactory"); + +const MEDIA_SERVER_TYPES = ["plex", "jellyfin", "emby", "kodi"]; +const MEDIA_SERVER_MAX_PER_TYPE = 10; + +const ARR_SERVER_IDS = { + sonarr: "arr-sonarr", + radarr: "arr-radarr", + lidarr: "arr-lidarr", + readarr: "arr-readarr", + chaptarr: "arr-chaptarr", +}; + +function newMediaServerId() { + if (crypto.randomUUID) return crypto.randomUUID(); + return ( + "ms-" + + Date.now().toString(36) + + "-" + + Math.random().toString(36).slice(2, 10) + ); +} + +function toBoolStr(value, fallback) { + if (value === undefined || value === null || value === "") { + if (fallback === undefined || fallback === null) return "false"; + return fallback === true || fallback === "true" ? "true" : "false"; + } + if ( + value === true || + value === "true" || + value === "on" || + value === 1 || + value === "1" + ) { + return "true"; + } + return "false"; +} + +function defaultPortForType(type) { + const t = getMediaServerKind(type); + if (t === "jellyfin" || t === "emby") return 8096; + if (t === "kodi") return 8080; + return 32400; +} + +/** + * @param {object} raw + * @returns {object} + */ +function normalizeMediaServerEntry(raw) { + const src = raw && typeof raw === "object" ? raw : {}; + let type = getMediaServerKind(src.type || src.mediaServerType || "plex"); + if (!MEDIA_SERVER_TYPES.includes(type)) type = "plex"; + const portRaw = src.port != null ? src.port : src.plexPort; + let port = parseInt(portRaw, 10); + if (isNaN(port) || port <= 0) port = defaultPortForType(type); + const host = String(src.host != null ? src.host : src.plexIP || "").trim(); + const token = String( + src.token != null ? src.token : src.plexToken != null ? src.plexToken : "" + ).trim(); + const https = toBoolStr( + src.https != null ? src.https : src.plexHTTPS, + "false" + ); + const id = String(src.id || "").trim() || newMediaServerId(); + let name = String(src.name || "").trim(); + if (!name) { + name = + getMediaServerShortLabel(type) + (host ? " @ " + host : " (new)"); + } + return { + id, + type, + name, + https, + host, + port, + token, + enabled: toBoolStr(src.enabled, "true"), + display: toBoolStr(src.display, "true"), + enableNowPlaying: toBoolStr(src.enableNowPlaying, "true"), + enableOnDemand: toBoolStr( + src.enableOnDemand != null ? src.enableOnDemand : src.enableOD, + "true" + ), + enableSync: toBoolStr(src.enableSync, "true"), + libraries: String( + src.libraries != null + ? src.libraries + : src.onDemandLibraries != null + ? src.onDemandLibraries + : "" + ).trim(), + libraries3d: String( + src.libraries3d != null + ? src.libraries3d + : src.onDemand3dLibraries != null + ? src.onDemand3dLibraries + : "" + ).trim(), + }; +} + +/** + * Cap at MEDIA_SERVER_MAX_PER_TYPE per type; drop incomplete empty drafts only when stripEmpty. + * @param {unknown} list + * @param {{ stripEmpty?: boolean }} [opts] + */ +function normalizeMediaServersArray(list, opts) { + const stripEmpty = !!(opts && opts.stripEmpty); + const arr = Array.isArray(list) ? list : []; + const counts = { plex: 0, jellyfin: 0, emby: 0, kodi: 0 }; + const out = []; + const seenIds = new Set(); + for (const raw of arr) { + const entry = normalizeMediaServerEntry(raw); + if (stripEmpty && !entry.host && !entry.token) continue; + if (counts[entry.type] >= MEDIA_SERVER_MAX_PER_TYPE) continue; + let id = entry.id; + while (seenIds.has(id)) id = newMediaServerId(); + entry.id = id; + seenIds.add(id); + counts[entry.type]++; + out.push(entry); + } + return out; +} + +/** + * Build one entry from legacy flat plex* / mediaServerType fields. + * @param {object} settings + */ +function migrateLegacyToMediaServers(settings) { + const s = settings || {}; + if (Array.isArray(s.mediaServers) && s.mediaServers.length > 0) { + return normalizeMediaServersArray(s.mediaServers); + } + const host = String(s.plexIP || "").trim(); + if (!host) return []; + return normalizeMediaServersArray([ + { + id: newMediaServerId(), + type: s.mediaServerType || "plex", + name: "", + https: s.plexHTTPS, + host, + port: s.plexPort, + token: s.plexToken, + enabled: "true", + display: "true", + enableNowPlaying: s.enableNS !== "false" ? "true" : "false", + enableOnDemand: s.enableOD !== "false" ? "true" : "false", + enableSync: "true", + libraries: s.onDemandLibraries || "", + libraries3d: s.onDemand3dLibraries || "", + }, + ]); +} + +/** + * Keep legacy flat fields in sync with the first enabled (or first) server for older code paths. + * @param {object} settings + */ +function syncLegacyFlatFromMediaServers(settings) { + if (!settings || typeof settings !== "object") return settings; + const list = normalizeMediaServersArray(settings.mediaServers || []); + settings.mediaServers = list; + const primary = + list.find((e) => e.enabled === "true" && e.host) || list[0] || null; + if (!primary) { + settings.mediaServerType = "plex"; + settings.plexIP = ""; + settings.plexHTTPS = "false"; + settings.plexPort = 32400; + settings.plexToken = ""; + return settings; + } + settings.mediaServerType = primary.type; + settings.plexIP = primary.host; + settings.plexHTTPS = primary.https; + settings.plexPort = primary.port; + settings.plexToken = primary.token; + return settings; +} + +function isEntryConfigured(entry) { + if (!entry || !entry.host) return false; + if (requiresMediaServerCredential(entry.type) && !entry.token) return false; + return true; +} + +function listConfiguredMediaServers(settings) { + return normalizeMediaServersArray( + (settings && settings.mediaServers) || [] + ).filter((e) => e.enabled === "true" && isEntryConfigured(e)); +} + +function listDisplayMediaServers(settings) { + return listConfiguredMediaServers(settings).filter( + (e) => e.display === "true" + ); +} + +function listNowPlayingMediaServers(settings) { + return listConfiguredMediaServers(settings).filter( + (e) => e.enableNowPlaying === "true" + ); +} + +function listSyncMediaServers(settings) { + return listConfiguredMediaServers(settings).filter( + (e) => e.enableSync === "true" || e.enableOnDemand === "true" + ); +} + +function listDisplayServerIds(settings) { + const ids = listDisplayMediaServers(settings).map((e) => e.id); + const s = settings || {}; + if (s.enableSonarr !== "false" && s.sonarrURL && s.sonarrToken) { + ids.push(ARR_SERVER_IDS.sonarr); + } + if (s.enableRadarr !== "false" && s.radarrURL && s.radarrToken) { + ids.push(ARR_SERVER_IDS.radarr); + } + if (s.enableLidarr !== "false" && s.lidarrURL && s.lidarrToken) { + ids.push(ARR_SERVER_IDS.lidarr); + } + if (s.enableReadarr !== "false" && s.readarrURL && s.readarrToken) { + const kind = + s.bookArrKind === "chaptarr" ? "chaptarr" : "readarr"; + ids.push(ARR_SERVER_IDS[kind] || ARR_SERVER_IDS.readarr); + } + return ids; +} + +function createMediaServerClient(entry) { + const e = normalizeMediaServerEntry(entry); + const Pms = getMediaServerClass(e.type); + return new Pms({ + plexHTTPS: e.https, + plexIP: e.host, + plexPort: e.port, + plexToken: e.token, + }); +} + +function cacheFilePrefix(serverId) { + const id = String(serverId || "") + .trim() + .replace(/[^a-zA-Z0-9_-]/g, ""); + return id || "unknown"; +} + +/** + * Parse indexed form fields mediaServers[i][field] or JSON body.mediaServers. + * @param {object} body + */ +function parseMediaServersFromFormBody(body) { + if (!body || typeof body !== "object") return []; + if (typeof body.mediaServersJson === "string" && body.mediaServersJson.trim()) { + try { + const parsed = JSON.parse(body.mediaServersJson); + return normalizeMediaServersArray(parsed); + } catch (e) { + /* fall through */ + } + } + if (Array.isArray(body.mediaServers)) { + return normalizeMediaServersArray(body.mediaServers); + } + // Indexed: mediaServers[0][host] + const byIndex = {}; + for (const key of Object.keys(body)) { + const m = /^mediaServers\[(\d+)\]\[(\w+)\]$/.exec(key); + if (!m) continue; + const idx = m[1]; + const field = m[2]; + if (!byIndex[idx]) byIndex[idx] = {}; + byIndex[idx][field] = body[key]; + } + const indexes = Object.keys(byIndex) + .map((n) => parseInt(n, 10)) + .filter((n) => !isNaN(n)) + .sort((a, b) => a - b); + return normalizeMediaServersArray(indexes.map((i) => byIndex[String(i)])); +} + +function countByType(list) { + const counts = { plex: 0, jellyfin: 0, emby: 0, kodi: 0 }; + for (const e of list || []) { + const t = getMediaServerKind(e.type); + if (counts[t] != null) counts[t]++; + } + return counts; +} + +function validateMediaServersForSave(list) { + const errors = []; + const normalized = normalizeMediaServersArray(list); + const counts = countByType(normalized); + for (const t of MEDIA_SERVER_TYPES) { + if (counts[t] > MEDIA_SERVER_MAX_PER_TYPE) { + errors.push( + "At most " + + MEDIA_SERVER_MAX_PER_TYPE + + " " + + t + + " servers are allowed." + ); + } + } + for (const e of normalized) { + if (!e.host && !e.token && e.enabled !== "true") continue; + if (e.enabled === "true" || e.host || e.token) { + if (!e.host) { + errors.push( + (e.name || e.type) + ": server address is required when enabled." + ); + } + if (!e.port || isNaN(parseInt(e.port, 10))) { + errors.push((e.name || e.type) + ": port must be a number."); + } + if (requiresMediaServerCredential(e.type) && !e.token) { + errors.push( + (e.name || e.type) + ": token / API key is required for this type." + ); + } + } + } + return { ok: errors.length === 0, errors, mediaServers: normalized }; +} + +module.exports = { + MEDIA_SERVER_TYPES, + MEDIA_SERVER_MAX_PER_TYPE, + ARR_SERVER_IDS, + newMediaServerId, + normalizeMediaServerEntry, + normalizeMediaServersArray, + migrateLegacyToMediaServers, + syncLegacyFlatFromMediaServers, + isEntryConfigured, + listConfiguredMediaServers, + listDisplayMediaServers, + listNowPlayingMediaServers, + listSyncMediaServers, + listDisplayServerIds, + createMediaServerClient, + cacheFilePrefix, + parseMediaServersFromFormBody, + validateMediaServersForSave, + countByType, + getMediaServerKind, + getMediaServerShortLabel, + requiresMediaServerCredential, +}; diff --git a/classes/core/posterMetadataDb.js b/classes/core/posterMetadataDb.js index 6555934..7f1b239 100644 --- a/classes/core/posterMetadataDb.js +++ b/classes/core/posterMetadataDb.js @@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS poster_entries ( rating_content TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', server_kind TEXT NOT NULL DEFAULT 'plex', + server_id TEXT NOT NULL DEFAULT '', poster_ar TEXT NOT NULL DEFAULT '', dbid TEXT NOT NULL DEFAULT '', api_item_id TEXT NOT NULL DEFAULT '', @@ -63,6 +64,8 @@ CREATE INDEX IF NOT EXISTS idx_poster_server_updated ON poster_entries(server_ki CREATE INDEX IF NOT EXISTS idx_poster_server_api ON poster_entries(server_kind, api_item_id); CREATE INDEX IF NOT EXISTS idx_poster_server_dbid ON poster_entries(server_kind, dbid); `; +// Indexes on server_id are created in ensurePosterEntriesExtraColumns() after +// the column is migrated onto existing DBs (CREATE TABLE IF NOT EXISTS won't add it). /** @type {any} */ let _sqlDb = null; @@ -87,8 +90,16 @@ const EXTRA_SCHEMA_COLUMNS = [ { name: "plot", def: "TEXT NOT NULL DEFAULT ''" }, { name: "rating_score", def: "TEXT NOT NULL DEFAULT ''" }, { name: "rating_content", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "server_id", def: "TEXT NOT NULL DEFAULT ''" }, ]; +const INSERT_COLS = `cache_file, logo_cache_file, art_cache_file, banner_cache_file, + portrait_actor_cache_file, portrait_actress_cache_file, + portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, + title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, server_id, poster_ar, + dbid, api_item_id, library_kind, library_name, source_url, updated_at`; +const INSERT_PLACEHOLDERS = "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?"; + function assertDb() { if (!_sqlDb) { throw new Error( @@ -120,6 +131,16 @@ function ensurePosterEntriesExtraColumns() { changed = true; } } + try { + _sqlDb.run( + "CREATE INDEX IF NOT EXISTS idx_poster_server_id_api ON poster_entries(server_id, api_item_id)" + ); + _sqlDb.run( + "CREATE INDEX IF NOT EXISTS idx_poster_server_id_dbid ON poster_entries(server_id, dbid)" + ); + } catch (e) { + /* ignore */ + } return changed; } @@ -264,15 +285,13 @@ function migrateLegacyJson() { const raw = fs.readFileSync(LEGACY_JSON, "utf8"); const parsed = JSON.parse(raw); const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : []; - const ins = _sqlDb.prepare(` - INSERT OR REPLACE INTO poster_entries ( - cache_file, logo_cache_file, art_cache_file, banner_cache_file, - portrait_actor_cache_file, portrait_actress_cache_file, - portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, - dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const ins = _sqlDb.prepare( + "INSERT OR REPLACE INTO poster_entries (" + + INSERT_COLS + + ") VALUES (" + + INSERT_PLACEHOLDERS + + ")" + ); try { _sqlDb.run("BEGIN"); for (const e of entries) { @@ -369,6 +388,7 @@ function rowFromDb(r) { ratingContent: r.rating_content || r.content_rating || "", summary: r.summary || "", serverKind: r.server_kind || "", + serverId: r.server_id || "", posterAR: r.poster_ar || "", dbid: r.dbid || "", apiItemId: r.api_item_id || "", @@ -409,6 +429,7 @@ function entryToParams(e) { e.ratingContent || "", e.summary || "", e.serverKind || "plex", + e.serverId || "", e.posterAR || "", e.dbid || "", e.apiItemId || "", @@ -474,6 +495,20 @@ function selectByServerKind(kind) { return out; } +function selectByServerId(serverId) { + assertDb(); + const sid = String(serverId || "").trim(); + if (!sid) return []; + const out = []; + const s = _sqlDb.prepare("SELECT * FROM poster_entries WHERE server_id = ?"); + s.bind([sid]); + while (s.step()) { + out.push(rowFromDb(s.getAsObject())); + } + s.free(); + return out; +} + function getEntryByCacheFile(cacheFile) { assertDb(); const s = _sqlDb.prepare("SELECT * FROM poster_entries WHERE cache_file = ?"); @@ -484,19 +519,31 @@ function getEntryByCacheFile(cacheFile) { return row; } -function getEntryByServerAndApiItemId(serverKind, apiItemId) { +function getEntryByServerAndApiItemId(serverKind, apiItemId, serverId) { assertDb(); const sk = String(serverKind || "").toLowerCase().trim(); const aid = String(apiItemId || "").trim(); - if (!sk || !aid) return null; - const s = _sqlDb.prepare( + const sid = String(serverId || "").trim(); + if (!aid) return null; + if (sid) { + const s = _sqlDb.prepare( + "SELECT * FROM poster_entries WHERE server_id = ? AND api_item_id = ? LIMIT 1" + ); + s.bind([sid, aid]); + let row = null; + if (s.step()) row = rowFromDb(s.getAsObject()); + s.free(); + return row; + } + if (!sk) return null; + const s2 = _sqlDb.prepare( "SELECT * FROM poster_entries WHERE server_kind = ? AND api_item_id = ? LIMIT 1" ); - s.bind([sk, aid]); - let row = null; - if (s.step()) row = rowFromDb(s.getAsObject()); - s.free(); - return row; + s2.bind([sk, aid]); + let row2 = null; + if (s2.step()) row2 = rowFromDb(s2.getAsObject()); + s2.free(); + return row2; } /** @@ -505,9 +552,10 @@ function getEntryByServerAndApiItemId(serverKind, apiItemId) { * @param {string} serverKind * @param {string} apiItemId * @param {string|number|Date} sourceUpdatedAt + * @param {string} [serverId] */ -function shouldSkipSyncItem(serverKind, apiItemId, sourceUpdatedAt) { - const row = getEntryByServerAndApiItemId(serverKind, apiItemId); +function shouldSkipSyncItem(serverKind, apiItemId, sourceUpdatedAt, serverId) { + const row = getEntryByServerAndApiItemId(serverKind, apiItemId, serverId); if (!row || !row.cacheFile || !fileOk(row.cacheFile)) return false; if (sourceUpdatedAt === undefined || sourceUpdatedAt === null || sourceUpdatedAt === "") return true; @@ -540,15 +588,13 @@ function deleteByCacheFiles(files) { function replaceAllEntries(entries) { assertDb(); - const ins = _sqlDb.prepare(` - INSERT INTO poster_entries ( - cache_file, logo_cache_file, art_cache_file, banner_cache_file, - portrait_actor_cache_file, portrait_actress_cache_file, - portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, - dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const ins = _sqlDb.prepare( + "INSERT INTO poster_entries (" + + INSERT_COLS + + ") VALUES (" + + INSERT_PLACEHOLDERS + + ")" + ); try { _sqlDb.run("BEGIN"); _sqlDb.run("DELETE FROM poster_entries"); @@ -694,11 +740,12 @@ function unlinkCacheAndArt(cacheFile) { /** * Remove poster metadata + files when the library item no longer exists on the media server. - * @param {{ currentServerKind: string, isMediaServerEnabled: boolean, maxChecks?: number, minAgeBeforeChangeCheckMins?: number, probeEntryGone: function }} opts + * @param {{ currentServerKind: string, currentServerId?: string, isMediaServerEnabled: boolean, maxChecks?: number, minAgeBeforeChangeCheckMins?: number, probeEntryGone: function }} opts */ async function purgeMissingServerItems(opts) { const { currentServerKind, + currentServerId, isMediaServerEnabled, maxChecks = 35, probeEntryGone, @@ -711,14 +758,17 @@ async function purgeMissingServerItems(opts) { if ( !probeEntryGone || !isMediaServerEnabled || - !currentServerKind || + (!currentServerKind && !currentServerId) || typeof probeEntryGone !== "function" ) { return { removed: 0, checked: 0 }; } assertDb(); - const all = selectByServerKind(currentServerKind).filter( + const pool = currentServerId + ? selectByServerId(currentServerId) + : selectByServerKind(currentServerKind); + const all = pool.filter( (e) => (String(e.apiItemId || "").trim() || String(e.sourceUrl || "").trim()) && nowMs - new Date(e.updatedAt || 0).getTime() >= minAgeMs @@ -764,9 +814,10 @@ async function purgeMissingServerItems(opts) { * Record poster files + metadata from Plex/Jellyfin/Emby/Kodi now-screening and on-demand cards. * @param {object[]} nsCards * @param {object[]} odCards - * @param {string} serverKind plex|jellyfin|emby|kodi + * @param {string} serverKind plex|jellyfin|emby|kodi|sonarr|… + * @param {string} [serverId] stable instance id */ -function registerFromMediaServerCards(nsCards, odCards, serverKind) { +function registerFromMediaServerCards(nsCards, odCards, serverKind, serverId) { const cards = [] .concat(Array.isArray(nsCards) ? nsCards : []) .concat(Array.isArray(odCards) ? odCards : []); @@ -793,16 +844,19 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { const rowCountBefore = countRows(); const now = new Date().toISOString(); const kind = String(serverKind || "plex").toLowerCase(); - - const ins = _sqlDb.prepare(` - INSERT OR REPLACE INTO poster_entries ( - cache_file, logo_cache_file, art_cache_file, banner_cache_file, - portrait_actor_cache_file, portrait_actress_cache_file, - portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, - dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const sid = String( + serverId || + (cards[0] && cards[0].posterServerId) || + "" + ).trim(); + + const ins = _sqlDb.prepare( + "INSERT OR REPLACE INTO poster_entries (" + + INSERT_COLS + + ") VALUES (" + + INSERT_PLACEHOLDERS + + ")" + ); try { _sqlDb.run("BEGIN"); @@ -874,6 +928,10 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { ratingContent: String(card.contentRating || "").trim(), summary: String(card.summary || "").slice(0, 2000), serverKind: kind, + serverId: + sid || + String(card.posterServerId || "").trim() || + "", posterAR: String(card.posterAR || "").trim(), dbid: String(card.DBID || "").trim(), apiItemId, @@ -926,6 +984,7 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { old.ratingScore !== row.ratingScore || old.ratingContent !== row.ratingContent || old.serverKind !== row.serverKind || + old.serverId !== row.serverId || old.summary !== row.summary || old.posterAR !== row.posterAR || old.dbid !== row.dbid || @@ -969,14 +1028,25 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { }; } -function pickRandomEntries(count, serverKindOpt, hideContentRatings) { +function pickRandomEntries(count, serverKindOpt, hideContentRatings, serverIdsOpt) { let valid = selectAllEntries().filter((e) => e.cacheFile && fileOk(e.cacheFile)); if (valid.length === 0) return []; + const wantIds = Array.isArray(serverIdsOpt) + ? serverIdsOpt.map((x) => String(x || "").trim()).filter(Boolean) + : []; + if (wantIds.length > 0) { + const idSet = new Set(wantIds); + const filtered = valid.filter( + (e) => e.serverId && idSet.has(String(e.serverId)) + ); + // Prefer filtered; if empty (legacy rows without server_id), fall through to kind/all + if (filtered.length > 0) valid = filtered; + } const wantKind = serverKindOpt != null && String(serverKindOpt).trim() ? String(serverKindOpt).toLowerCase().trim() : ""; - if (wantKind) { + if (wantKind && wantIds.length === 0) { const filtered = valid.filter( (e) => String(e.serverKind || "").toLowerCase() === wantKind ); @@ -1038,15 +1108,18 @@ function ratingColourForContentRating(contentRating) { * For live on-demand cards: point poster (and fanart when present) at disk cache if the poster DB has a match. * @param {object[]} cards * @param {string} serverKind plex|jellyfin|emby|kodi + * @param {string} [serverId] */ -function applyCachedPostersToMediaCards(cards, serverKind) { +function applyCachedPostersToMediaCards(cards, serverKind, serverId) { if (!Array.isArray(cards) || cards.length === 0) return cards; const kind = String(serverKind || "plex").toLowerCase(); + const sid = String(serverId || "").trim(); assertDb(); const byApiId = new Map(); const byDbId = new Map(); const bySource = new Map(); - for (const e of selectByServerKind(kind)) { + const pool = sid ? selectByServerId(sid) : selectByServerKind(kind); + for (const e of pool) { if (!e.cacheFile || !fileOk(e.cacheFile)) continue; const api = String(e.apiItemId || "").trim(); const dbid = String(e.dbid || "").trim(); @@ -1111,18 +1184,19 @@ function applyCachedPostersToMediaCards(cards, serverKind) { /** * Build on-demand style cards from the poster metadata DB when nothing else is available. * @param {number} count - * @param {string} [serverKind] When set, prefer random picks from this server (plex|jellyfin|emby|kodi). + * @param {string} [serverKind] When set (and no serverIds), prefer random picks from this kind. * @param {string[]} [hideContentRatings] Hide Ratings list (normalized or raw). + * @param {string[]} [serverIds] When set, only include rows for these server_id values. * @returns {MediaCard[]} */ -function buildFallbackMediaCards(count, serverKind, hideContentRatings) { +function buildFallbackMediaCards(count, serverKind, hideContentRatings, serverIds) { const n = typeof count === "number" && count > 0 && Number.isFinite(count) ? Math.floor(count) : DEFAULT_FALLBACK_COUNT; - const rows = pickRandomEntries(n, serverKind, hideContentRatings); + const rows = pickRandomEntries(n, serverKind, hideContentRatings, serverIds); const cards = []; for (const row of rows) { const c = new MediaCard(); @@ -1138,6 +1212,7 @@ function buildFallbackMediaCards(count, serverKind, hideContentRatings) { if (row.posterAR) c.posterAR = row.posterAR; c.DBID = row.dbid || ""; c.posterApiItemId = String(row.apiItemId || "").trim(); + c.posterServerId = String(row.serverId || "").trim(); c.posterLibraryLabel = row.libraryName || ""; if (row.logoCacheFile) { c.posterLogoURL = "/imagecache/" + row.logoCacheFile; @@ -1220,6 +1295,75 @@ async function clearPosterCacheAndMetadata() { ); } +/** + * Delete all poster metadata rows and matching imagecache files for one server_id. + * @param {string} serverId + * @returns {{ removedRows: number }} + */ +function clearCacheForServerId(serverId) { + const sid = String(serverId || "").trim(); + if (!sid) return { removedRows: 0 }; + assertDb(); + const rows = selectByServerId(sid); + const files = new Set(); + for (const r of rows) { + if (r.cacheFile) files.add(r.cacheFile); + if (r.logoCacheFile) files.add(r.logoCacheFile); + if (r.artCacheFile) files.add(r.artCacheFile); + if (r.bannerCacheFile) files.add(r.bannerCacheFile); + if (r.portraitActorCacheFile) files.add(r.portraitActorCacheFile); + if (r.portraitActressCacheFile) files.add(r.portraitActressCacheFile); + if (r.portraitDirectorCacheFile) files.add(r.portraitDirectorCacheFile); + if (r.portraitAuthorCacheFile) files.add(r.portraitAuthorCacheFile); + if (r.portraitArtistCacheFile) files.add(r.portraitArtistCacheFile); + } + // Also unlink files whose basename is prefixed with server id + try { + if (fs.existsSync(IMAGECACHE)) { + const prefix = sid + "-"; + for (const name of fs.readdirSync(IMAGECACHE)) { + if (name.startsWith(prefix) || name.startsWith(sid)) { + files.add(name); + } + } + // *arr synthetic ids use arr-* file names + if (sid.startsWith("arr-")) { + const arrPrefix = sid.replace(/^arr-/, "arr-") + "-"; + // arr-sonarr → files arr-sonarr-*.jpg + const filePrefix = sid + "-"; + for (const name of fs.readdirSync(IMAGECACHE)) { + if (name.startsWith(filePrefix) || name.startsWith(sid + "-")) { + files.add(name); + } + } + void arrPrefix; + } + } + } catch (e) { + /* ignore */ + } + for (const cf of files) { + unlinkCacheAndArt(cf); + } + if (rows.length) { + deleteByCacheFiles(rows.map((r) => r.cacheFile).filter(Boolean)); + } + // Delete any remaining rows by server_id + const del = _sqlDb.prepare("DELETE FROM poster_entries WHERE server_id = ?"); + del.run([sid]); + del.free(); + persistDb(); + console.log( + new Date().toLocaleString() + + " Cleared poster cache for server_id=" + + sid + + " (" + + rows.length + + " rows)" + ); + return { removedRows: rows.length }; +} + /** * Drop orphaned DB rows, then re-download stale poster files when sourceUrl is known * (otherwise remove file + row). Timer interval should match settings.posterCacheRefreshMins. @@ -1582,6 +1726,7 @@ module.exports = { applyCachedPostersToMediaCards, buildFallbackMediaCards, clearPosterCacheAndMetadata, + clearCacheForServerId, runScheduledRefresh, purgeMissingServerItems, getCacheDashboardStats, @@ -1593,4 +1738,5 @@ module.exports = { fileOk, IMAGE_KIND_ORDER, countRows, + selectByServerId, }; diff --git a/classes/core/posterSyncRetry.js b/classes/core/posterSyncRetry.js index 3166806..1db0ad6 100644 --- a/classes/core/posterSyncRetry.js +++ b/classes/core/posterSyncRetry.js @@ -71,14 +71,17 @@ function prioritizeOdRaw(odRaw, retryKeys, serverKind) { return scored.map((s) => s.item); } -function loadRetryKeys(serverKind) { +function loadRetryKeys(serverKind, serverId) { try { if (!fs.existsSync(RETRY_FILE)) return []; const j = JSON.parse(fs.readFileSync(RETRY_FILE, "utf8")); - if ( - !j || + if (!j) return []; + const sid = String(serverId || "").trim(); + if (sid) { + if (String(j.serverId || "").trim() !== sid) return []; + } else if ( String(j.serverKind || "").toLowerCase() !== - String(serverKind || "").toLowerCase() + String(serverKind || "").toLowerCase() ) { return []; } @@ -89,7 +92,7 @@ function loadRetryKeys(serverKind) { } } -function saveRetryKeys(serverKind, keys) { +function saveRetryKeys(serverKind, keys, serverId) { const uniq = []; const seen = new Set(); for (const k of keys || []) { @@ -99,17 +102,15 @@ function saveRetryKeys(serverKind, keys) { uniq.push(s); if (uniq.length >= MAX_KEYS) break; } + const payload = { + serverKind: String(serverKind || "").toLowerCase(), + serverId: String(serverId || "").trim(), + savedAt: new Date().toISOString(), + keys: uniq, + }; try { - fs.mkdirSync(CACHE_ROOT, { recursive: true }); - fs.writeFileSync( - RETRY_FILE, - JSON.stringify({ - serverKind: String(serverKind || "").toLowerCase(), - savedAt: new Date().toISOString(), - keys: uniq, - }), - "utf8" - ); + fs.mkdirSync(path.dirname(RETRY_FILE), { recursive: true }); + fs.writeFileSync(RETRY_FILE, JSON.stringify(payload, null, 2), "utf8"); } catch (e) { /* ignore */ } diff --git a/classes/core/settings.js b/classes/core/settings.js index 3657018..f1a1cf5 100644 --- a/classes/core/settings.js +++ b/classes/core/settings.js @@ -3,6 +3,7 @@ const fsp = require("fs").promises; const DEFAULT_SETTINGS = require("../../consts"); const util = require("../core/utility"); const { requiresMediaServerCredential } = require("../mediaservers/mediaServerFactory"); +const mediaServersUtil = require("./mediaServers"); /** Normalize form/JSON values to "true" | "false" for settings file (checkboxes, toggles). */ function toSettingsBoolStr(value, fallback) { @@ -34,6 +35,9 @@ class Settings { this.plexHTTPS = DEFAULT_SETTINGS.plexHTTPS; this.plexPort = DEFAULT_SETTINGS.plexPort; this.plexToken = DEFAULT_SETTINGS.plexToken; + this.mediaServers = Array.isArray(DEFAULT_SETTINGS.mediaServers) + ? DEFAULT_SETTINGS.mediaServers.slice() + : []; this.onDemandLibraries = DEFAULT_SETTINGS.onDemandLibraries; this.onDemand3dLibraries = DEFAULT_SETTINGS.onDemand3dLibraries; this.numberOnDemand = DEFAULT_SETTINGS.numberOnDemand; @@ -166,28 +170,23 @@ class Settings { * @returns {} true / false if any value is changed */ GetChanged() { - let hasChanged = false; - let SettingChanged; - try { - // only worry about required media server settings (Kodi may omit token if no HTTP auth) - const tokenOk = - !requiresMediaServerCredential(this.mediaServerType) || - (this.plexToken !== undefined && this.plexToken !== ""); - if (this.plexIP !== "" && this.plexPort !== "" && tokenOk) { - hasChanged = true; - throw SettingChanged; - } else { - let now = new Date(); - console.log( - now.toISOString().split("T")[0] + - " INVALID MEDIA SERVER SETTINGS - Please visit setup page to resolve" - ); - } - } catch (e) { - if (e !== SettingChanged) throw e; - } - - return hasChanged; + const list = mediaServersUtil.listConfiguredMediaServers(this); + if (list.length > 0) return true; + // Zero media servers is valid (*arr / cache-only) + const hasArr = + (this.sonarrURL && this.sonarrToken) || + (this.radarrURL && this.radarrToken) || + (this.lidarrURL && this.lidarrToken) || + (this.readarrURL && this.readarrToken); + if (hasArr) return true; + // Cache-only also OK once settings file exists with intentional empty servers + if (Array.isArray(this.mediaServers)) return true; + let now = new Date(); + console.log( + now.toISOString().split("T")[0] + + " No media servers or *arr configured — using cache / other sources" + ); + return true; } /** @@ -339,6 +338,17 @@ class Settings { if(readSettings.lidarrURL==undefined) readSettings.lidarrURL = ''; if(readSettings.lidarrToken==undefined) readSettings.lidarrToken = ''; if(readSettings.lidarrCalDays==undefined) readSettings.lidarrCalDays = 30; + // Multi media-server: migrate legacy flat plex* into mediaServers[] + if (!Array.isArray(readSettings.mediaServers)) { + readSettings.mediaServers = + mediaServersUtil.migrateLegacyToMediaServers(readSettings); + } else { + readSettings.mediaServers = + mediaServersUtil.normalizeMediaServersArray( + readSettings.mediaServers + ); + } + mediaServersUtil.syncLegacyFlatFromMediaServers(readSettings); } catch (ex) { // do nothing if error as it reads ok anyhow let d = new Date(); @@ -347,6 +357,39 @@ class Settings { // populate settings object with settings from json file await Object.assign(this, readSettings); + if (!Array.isArray(this.mediaServers)) { + this.mediaServers = mediaServersUtil.migrateLegacyToMediaServers(this); + } else { + this.mediaServers = mediaServersUtil.normalizeMediaServersArray( + this.mediaServers + ); + } + mediaServersUtil.syncLegacyFlatFromMediaServers(this); + + // persist migrated mediaServers when upgraded from legacy flat fields + try { + if ( + readSettings && + Array.isArray(readSettings.mediaServers) && + fs.existsSync("config/settings.json") + ) { + const disk = JSON.parse( + fs.readFileSync("config/settings.json", "utf-8") + ); + if (!Array.isArray(disk.mediaServers)) { + await this.UpdateSettings({ + mediaServers: this.mediaServers, + mediaServerType: this.mediaServerType, + plexIP: this.plexIP, + plexHTTPS: this.plexHTTPS, + plexPort: this.plexPort, + plexToken: this.plexToken, + }); + } + } + } catch (e) { + /* non-fatal */ + } // ensure settings loaded before returning return new Promise((resolve) => { @@ -449,18 +492,50 @@ class Settings { else this.hideSettingsLinks = "false"; if (jsonObject.theaterRoomMode) this.theaterRoomMode = jsonObject.theaterRoomMode; else this.theaterRoomMode = "false"; - if (jsonObject.mediaServerType) this.mediaServerType = jsonObject.mediaServerType; - else this.mediaServerType = cs.mediaServerType != undefined ? cs.mediaServerType : DEFAULT_SETTINGS.mediaServerType; - if (jsonObject.plexIP) this.plexIP = jsonObject.plexIP; - else this.plexIP = cs.plexIP; - if (jsonObject.plexHTTPSSwitch) this.plexHTTPS = jsonObject.plexHTTPSSwitch; - else this.plexHTTPS = "false"; - if (jsonObject.plexPort) this.plexPort = jsonObject.plexPort; - else this.plexPort = cs.plexPort; - if (jsonObject.plexToken !== undefined && jsonObject.plexToken !== null) { - this.plexToken = jsonObject.plexToken; + if (Object.prototype.hasOwnProperty.call(jsonObject, "mediaServers")) { + this.mediaServers = mediaServersUtil.normalizeMediaServersArray( + jsonObject.mediaServers + ); + mediaServersUtil.syncLegacyFlatFromMediaServers(this); + } else if ( + jsonObject.mediaServerType || + jsonObject.plexIP || + jsonObject.plexPort || + jsonObject.plexToken !== undefined + ) { + // Legacy single-server form fields still accepted + if (jsonObject.mediaServerType) + this.mediaServerType = jsonObject.mediaServerType; + else + this.mediaServerType = + cs.mediaServerType != undefined + ? cs.mediaServerType + : DEFAULT_SETTINGS.mediaServerType; + if (jsonObject.plexIP) this.plexIP = jsonObject.plexIP; + else this.plexIP = cs.plexIP; + if (Object.prototype.hasOwnProperty.call(jsonObject, "plexHTTPSSwitch")) { + this.plexHTTPS = toSettingsBoolStr(jsonObject.plexHTTPSSwitch, "false"); + } else { + this.plexHTTPS = + cs.plexHTTPS !== undefined ? cs.plexHTTPS : DEFAULT_SETTINGS.plexHTTPS; + } + if (jsonObject.plexPort) this.plexPort = jsonObject.plexPort; + else this.plexPort = cs.plexPort; + if (jsonObject.plexToken !== undefined && jsonObject.plexToken !== null) { + this.plexToken = jsonObject.plexToken; + } else { + this.plexToken = cs.plexToken; + } + if (!Array.isArray(this.mediaServers) || this.mediaServers.length === 0) { + this.mediaServers = mediaServersUtil.migrateLegacyToMediaServers(this); + } } else { - this.plexToken = cs.plexToken; + this.mediaServers = mediaServersUtil.normalizeMediaServersArray( + cs.mediaServers != null + ? cs.mediaServers + : mediaServersUtil.migrateLegacyToMediaServers(cs) + ); + mediaServersUtil.syncLegacyFlatFromMediaServers(this); } if (jsonObject.plexLibraries) this.onDemandLibraries = jsonObject.plexLibraries; @@ -1025,8 +1100,17 @@ class Settings { ? cs.posterCacheMinAgeBeforeChangeCheckMins : DEFAULT_SETTINGS.posterCacheMinAgeBeforeChangeCheckMins; } - if (jsonObject.preferCachedPosters) this.preferCachedPosters = jsonObject.preferCachedPosters; - else this.preferCachedPosters = "false"; + if (Object.prototype.hasOwnProperty.call(jsonObject, "preferCachedPosters")) { + this.preferCachedPosters = toSettingsBoolStr( + jsonObject.preferCachedPosters, + "false" + ); + } else { + this.preferCachedPosters = + cs.preferCachedPosters !== undefined + ? cs.preferCachedPosters + : DEFAULT_SETTINGS.preferCachedPosters; + } if ( jsonObject.cachedPosterSlideCount !== undefined && jsonObject.cachedPosterSlideCount !== null && diff --git a/consts.js b/consts.js index 2377296..81c72c3 100644 --- a/consts.js +++ b/consts.js @@ -6,15 +6,22 @@ const DEFAULT_SETTINGS = { fade : "true", hideSettingsLinks : "false", theaterRoomMode : "false", + /** @deprecated Prefer mediaServers[]; kept for migration / legacy flat sync. */ mediaServerType : "plex", plexIP : "", plexHTTPS : "false", plexPort : 32400, plexToken : "", + /** + * Multi media-server connections (plex/jellyfin/emby/kodi). Up to 10 of each type. + * Empty array = no media servers (*arr / cache only). + */ + mediaServers : [], pinNS : "false", hideUser: "false", + /** @deprecated Prefer per-server mediaServers[].libraries */ onDemandLibraries : "", - /** On-demand library names that should be marked as 3D (comma-separated). */ + /** On-demand library names that should be marked as 3D (comma-separated). @deprecated Prefer per-server libraries3d */ onDemand3dLibraries : "", numberOnDemand : 2, onDemandRefresh : 30, diff --git a/index.js b/index.js index c7de8e6..571efde 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,7 @@ const { getMediaServerKind, requiresMediaServerCredential, } = require("./classes/mediaservers/mediaServerFactory"); +const mediaServersUtil = require("./classes/core/mediaServers"); const posterMetadata = require("./classes/core/posterMetadataDb"); const posterSyncRetry = require("./classes/core/posterSyncRetry"); const nowShowingDb = require("./classes/core/nowShowingDb"); @@ -687,6 +688,9 @@ async function loadReadarrComingSoon() { // call radarr coming soon try { csbCards = await readarr.GetComingSoon(now, ltr, loadedSettings.hasArt); + if (Array.isArray(csbCards) && csbCards.length) { + registerArrCardsIntoPosterCache(csbCards); + } if (isReadarrUnavailable) { console.log( "✅ " + bookAppLabel + " connection restored - default poll timers restored" @@ -742,6 +746,9 @@ async function loadRadarrComingSoon() { // call radarr coming soon try { csrCards = await radarr.GetComingSoon(now, ltr, loadedSettings.genericThemes, loadedSettings.hasArt); + if (Array.isArray(csrCards) && csrCards.length) { + registerArrCardsIntoPosterCache(csrCards); + } if (isRadarrUnavailable) { console.log( "✅ Radarr connection restored - defualt poll timers restored" @@ -789,6 +796,9 @@ async function loadLidarrComingSoon() { try { cslCards = await lidarr.GetComingSoon(now, ltr, loadedSettings.hasArt); + if (Array.isArray(cslCards) && cslCards.length) { + registerArrCardsIntoPosterCache(cslCards); + } if (isLidarrUnavailable) { console.log( "✅ Lidarr connection restored - default poll timers restored" @@ -843,6 +853,9 @@ async function loadSonarrComingSoon() { loadedSettings.playThemes, loadedSettings.hasArt ); + if (Array.isArray(csCards) && csCards.length) { + registerArrCardsIntoPosterCache(csCards); + } if (isSonarrUnavailable) { console.log( @@ -1483,23 +1496,32 @@ function filterCardsByHiddenContentRatings(cards) { ); } -/** Library-style slides: cached poster DB first when enabled; live on-demand only as backup. */ +/** Library slides always come from poster cache (cache-first). Live sources only fill the cache. */ function buildLibrarySlideDeckFromPosterCache() { - // Master ON-DEMAND toggle must gate both live fetches and cache-backed library slides. if (!isOnDemandEnabled) return []; - // Recently-added day filter requires a live media-server query; the poster cache has no added dates. - if (recentlyAddedDaysActive() || !preferCachedPostersEnabled()) { - return filterCardsByHiddenContentRatings(odCards); + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); + // Recently-added day filter still needs live mix when active; otherwise cache-only. + if (recentlyAddedDaysActive()) { + const live = filterCardsByHiddenContentRatings(odCards); + if (live.length > 0) return live; } - const kind = loadedSettings - ? getMediaServerKind(loadedSettings.mediaServerType) - : ""; const cached = posterMetadata.buildFallbackMediaCards( primaryCachedPosterSlideCount(), - kind, - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ); if (cached.length > 0) return cached; + // Empty display filter or empty DB: try all cache rows, then any live OD cards as last resort + const anyCached = posterMetadata.buildFallbackMediaCards( + primaryCachedPosterSlideCount(), + null, + hideContentRatingsFromSettings(), + null + ); + if (anyCached.length > 0 && (!displayIds.length || !cachedPosterDbHasRows())) { + return anyCached; + } return filterCardsByHiddenContentRatings(odCards); } @@ -1508,15 +1530,15 @@ function buildLibrarySlideDeckFromPosterCache() { * Now Playing / on-demand network work finishes (first paint on /posters). */ async function warmCachedPosterDeckEarlyIfPossible() { - if (!loadedSettings || !isOnDemandEnabled || !preferCachedPostersEnabled()) return; - if (recentlyAddedDaysActive()) return; + if (!loadedSettings || !isOnDemandEnabled) return; if (!cachedPosterDbHasRows()) return; - const kind = getMediaServerKind(loadedSettings.mediaServerType); + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); const warmCount = Math.min(12, primaryCachedPosterSlideCount()); const cached = posterMetadata.buildFallbackMediaCards( warmCount, - kind, - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ); if (!cached.length) return; globalPage.cards = cached.slice(); @@ -1609,20 +1631,10 @@ async function loadNowScreening() { } // stop timers dont run if disabled - if (!isMediaServerEnabled && !skipMediaServerNowPlayingFetch) { + const nsServers = mediaServersUtil.listNowPlayingMediaServers(loadedSettings); + if (!nsServers.length && !skipMediaServerNowPlayingFetch) { nsCards = []; - return nsCards; - } - - let ms = null; - if (!skipMediaServerNowPlayingFetch) { - const Pms = getMediaServerClass(loadedSettings.mediaServerType); - ms = new Pms({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); + // Still assemble cache library slides below when OD enabled } let excludeLibraries; @@ -1637,20 +1649,71 @@ async function loadNowScreening() { let pollInterval = nsCheckSeconds; - // call now screening method - if (!skipMediaServerNowPlayingFetch) { + // call now screening method — merge sessions from all Enable Now Playing servers + if (!skipMediaServerNowPlayingFetch && nsServers.length) { + try { + const mergedNs = []; + let anyOk = false; + for (const server of nsServers) { + try { + const ms = mediaServersUtil.createMediaServerClient(server); + const part = await ms.GetNowScreening( + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.filterRemote, + loadedSettings.filterLocal, + loadedSettings.filterDevices, + loadedSettings.filterUsers, + loadedSettings.hideUser, + excludeLibraries + ); + if (Array.isArray(part) && part.length) { + for (const c of part) { + if (c && typeof c === "object") { + c.posterServerId = server.id; + } + } + mergedNs.push(...part); + posterMetadata.applyCachedPostersToMediaCards( + part, + server.type, + server.id + ); + } + anyOk = true; + } catch (serverErr) { + let now = new Date(); + console.log( + now.toLocaleString() + + " *Now Playing — " + + (server.name || server.host) + + ": " + + (serverErr && serverErr.message ? serverErr.message : serverErr) + ); + } + } + nsCards = mergedNs; + if (anyOk) { + if (isMediaServerUnavailable) { + console.log( + new Date().toLocaleString() + + " ✔ Media server(s) recovered — Now Playing restored" + ); + } + isMediaServerUnavailable = false; + } else if (nsServers.length) { + isMediaServerUnavailable = true; + } + } catch (err) { + /* handled per-server */ + isMediaServerUnavailable = true; + } + } + + // legacy single-block Awtrix / error handling continues using nsCards + if (!skipMediaServerNowPlayingFetch && nsCards && nsCards.length) { try { - nsCards = await ms.GetNowScreening( - loadedSettings.playThemes, - loadedSettings.genericThemes, - loadedSettings.hasArt, - loadedSettings.filterRemote, - loadedSettings.filterLocal, - loadedSettings.filterDevices, - loadedSettings.filterUsers, - loadedSettings.hideUser, - excludeLibraries - ); // Send to Awtrix, if enabled if(isAwtrixEnabled){ var awt = new awtrix(); @@ -1985,30 +2048,47 @@ async function loadNowScreening() { } if (isMediaServerEnabled) { - posterMetadata.registerFromMediaServerCards( - nsCards, - odCards, - getMediaServerKind(loadedSettings.mediaServerType) - ); - posterMetadata - .purgeMissingServerItems({ - currentServerKind: getMediaServerKind(loadedSettings.mediaServerType), - isMediaServerEnabled, - maxChecks: 8, - minAgeBeforeChangeCheckMins: Math.max( - 0, - parseInt(loadedSettings.posterCacheMinAgeBeforeChangeCheckMins, 10) || - 0 - ), - probeEntryGone: probePosterMetadataEntryGone, - }) - .catch(() => {}); + const byServer = new Map(); + for (const c of [].concat(nsCards || []).concat(odCards || [])) { + const sid = c && c.posterServerId ? String(c.posterServerId) : ""; + if (!sid) continue; + if (!byServer.has(sid)) byServer.set(sid, []); + byServer.get(sid).push(c); + } + const servers = mediaServersUtil.listConfiguredMediaServers(loadedSettings); + for (const server of servers) { + const cards = byServer.get(server.id) || []; + if (!cards.length) continue; + posterMetadata.registerFromMediaServerCards( + cards.filter((c) => nsCards && nsCards.includes(c)), + cards.filter((c) => odCards && odCards.includes(c)), + server.type, + server.id + ); + posterMetadata + .purgeMissingServerItems({ + currentServerKind: server.type, + currentServerId: server.id, + isMediaServerEnabled: true, + maxChecks: 8, + minAgeBeforeChangeCheckMins: Math.max( + 0, + parseInt(loadedSettings.posterCacheMinAgeBeforeChangeCheckMins, 10) || + 0 + ), + probeEntryGone: (entry) => + probePosterMetadataEntryGone(entry, server), + }) + .catch(() => {}); + } } if (globalPage.cards.length === 0) { + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); const cached = posterMetadata.buildFallbackMediaCards( posterMetadata.DEFAULT_FALLBACK_COUNT, - getMediaServerKind(loadedSettings.mediaServerType), - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ); if (cached.length > 0) { globalPage.cards = cached; @@ -2090,14 +2170,12 @@ async function loadNowScreening() { } /** - * On-demand slide pool: when "Prefer cached poster library" is on (default), reads from the poster metadata DB - * only (filled by sync) — no live GetOnDemand. Otherwise pulls from the media server and optionally rewrites URLs - * from the poster DB when that same setting is on (legacy path is server-only when prefer-cache is off). - * @param {number} [numberOnDemandOverride] — if set, used instead of settings.numberOnDemand so fill can work when OD count is 0. + * On-demand / library slide pool: cache-first. Live GetOnDemand only when + * recently-added days need a live filter; otherwise sync fills the cache. + * @param {number} [numberOnDemandOverride] */ async function fetchOnDemandCardsFromServer(numberOnDemandOverride) { if (posterSyncProgressState.status === "running") { - // Avoid concurrent Jellyfin library scans while a full poster sync is in progress. return Array.isArray(odCards) ? odCards : []; } const count = @@ -2105,46 +2183,64 @@ async function fetchOnDemandCardsFromServer(numberOnDemandOverride) { ? numberOnDemandOverride : loadedSettings.numberOnDemand; - // Prefer-cache skips live GetOnDemand — but recently-added days need a live filter. - if (preferCachedPostersEnabled() && !recentlyAddedDaysActive()) { - const kind = loadedSettings - ? getMediaServerKind(loadedSettings.mediaServerType) - : ""; - const nRaw = - typeof count === "number" ? count : parseInt(String(count), 10); - const n = !isNaN(nRaw) && nRaw > 0 ? nRaw : primaryCachedPosterSlideCount(); + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); + const nRaw = + typeof count === "number" ? count : parseInt(String(count), 10); + const n = !isNaN(nRaw) && nRaw > 0 ? nRaw : primaryCachedPosterSlideCount(); + + // Cache-first display path (always prefer cache when available) + if (!recentlyAddedDaysActive()) { return apply3dLibraryFlagToCards( posterMetadata.buildFallbackMediaCards( n, - kind, - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ) ); } let cards = []; - if (isMediaServerEnabled) { - const PmsOd = getMediaServerClass(loadedSettings.mediaServerType); - const ms = new PmsOd({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); - cards = await ms.GetOnDemand( - loadedSettings.onDemandLibraries, - count, - loadedSettings.playThemes, - loadedSettings.genericThemes, - loadedSettings.hasArt, - loadedSettings.genres, - loadedSettings.recentlyAddedDays, - loadedSettings.contentRatings, - { - imagePull: buildImagePullOptions(), - tmdbApiKey: loadedSettings.tmdbApiKey || "", + const syncServers = mediaServersUtil.listSyncMediaServers(loadedSettings); + for (const server of syncServers) { + if (!String(server.libraries || "").trim()) continue; + try { + const ms = mediaServersUtil.createMediaServerClient(server); + const part = await ms.GetOnDemand( + server.libraries, + count, + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.genres, + loadedSettings.recentlyAddedDays, + loadedSettings.contentRatings, + { + imagePull: buildImagePullOptions(), + tmdbApiKey: loadedSettings.tmdbApiKey || "", + } + ); + if (Array.isArray(part)) { + for (const c of part) { + if (c) c.posterServerId = server.id; + } + cards = cards.concat(part); + posterMetadata.registerFromMediaServerCards( + [], + part, + server.type, + server.id + ); } - ); + } catch (e) { + console.log( + new Date().toLocaleString() + + " *On-demand — " + + (server.name || server.host) + + ": " + + (e && e.message ? e.message : e) + ); + } } // Recently-added: also pull from *arr (import/added dates outrank cache; merge with server). @@ -2153,17 +2249,97 @@ async function fetchOnDemandCardsFromServer(numberOnDemandOverride) { const arrRa = await fetchArrRecentlyAddedCards(days); if (arrRa.length) { cards = mergeRecentlyAddedServerAndArr(cards, arrRa); - const nRaw = - typeof count === "number" ? count : parseInt(String(count), 10); + registerArrCardsIntoPosterCache(arrRa); if (!isNaN(nRaw) && nRaw > 0 && cards.length > nRaw) { cards = await util.build_random_od_set(nRaw, cards, 0); } } } + if (!cards.length) { + cards = posterMetadata.buildFallbackMediaCards( + n, + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null + ); + } + return apply3dLibraryFlagToCards(cards); } +function registerArrCardsIntoPosterCache(cards) { + if (!Array.isArray(cards) || !cards.length) return; + const byKind = new Map(); + for (const c of cards) { + const kind = String((c && c.posterLibraryKind) || c._arrKind || "") + .toLowerCase() + .trim(); + let sid = ""; + let sk = ""; + const url = String((c && c.posterURL) || ""); + if (url.includes("arr-sonarr") || kind === "sonarr") { + sk = "sonarr"; + sid = mediaServersUtil.ARR_SERVER_IDS.sonarr; + } else if (url.includes("arr-radarr") || kind === "radarr") { + sk = "radarr"; + sid = mediaServersUtil.ARR_SERVER_IDS.radarr; + } else if (url.includes("arr-lidarr") || kind === "lidarr") { + sk = "lidarr"; + sid = mediaServersUtil.ARR_SERVER_IDS.lidarr; + } else if ( + url.includes("arr-readarr") || + url.includes("arr-chaptarr") || + kind === "readarr" || + kind === "chaptarr" + ) { + sk = + loadedSettings && loadedSettings.bookArrKind === "chaptarr" + ? "chaptarr" + : "readarr"; + sid = + mediaServersUtil.ARR_SERVER_IDS[sk] || + mediaServersUtil.ARR_SERVER_IDS.readarr; + } else { + continue; + } + if (!byKind.has(sid)) byKind.set(sid, { kind: sk, cards: [] }); + c.posterServerId = sid; + byKind.get(sid).cards.push(c); + } + for (const [sid, pack] of byKind) { + posterMetadata.registerFromMediaServerCards([], pack.cards, pack.kind, sid); + } +} + +async function syncArrAppsIntoPosterCache() { + try { + if (isSonarrEnabled || isRadarrEnabled || isLidarrEnabled || isReadarrEnabled) { + const days = Math.max( + parseInt(loadedSettings.sonarrCalDays, 10) || 0, + parseInt(loadedSettings.radarrCalDays, 10) || 0, + 30 + ); + const ra = await fetchArrRecentlyAddedCards(days); + if (ra.length) registerArrCardsIntoPosterCache(ra); + if (Array.isArray(csCards) && csCards.length) + registerArrCardsIntoPosterCache(csCards); + if (Array.isArray(csrCards) && csrCards.length) + registerArrCardsIntoPosterCache(csrCards); + if (Array.isArray(cslCards) && cslCards.length) + registerArrCardsIntoPosterCache(cslCards); + if (Array.isArray(csbCards) && csbCards.length) + registerArrCardsIntoPosterCache(csbCards); + } + } catch (e) { + console.log( + new Date().toLocaleString() + + " *arr cache sync: " + + (e && e.message ? e.message : e) + ); + } +} + /** * Recently imported titles from Radarr/Sonarr (*arr added/import date, not poster-cache time). */ @@ -2236,7 +2412,7 @@ async function ensureOdCardsForNowShowingFill() { loadedSettings.nowShowingFillFromServer === "true" && (Number(loadedSettings.nowShowingFillLibraryMax) || 0) > 0; if (!fillOn) return []; - if (!isMediaServerEnabled && !preferCachedPostersEnabled()) return []; + if (!isMediaServerEnabled && !cachedPosterDbHasRows()) return []; if (posterSyncProgressState.status === "running") { return Array.isArray(odCards) ? odCards : []; } @@ -2440,21 +2616,10 @@ async function checkEnabled() { isSleepEnabled = false; } - // check media server connection fields (Kodi may use empty token if HTTP auth disabled) - const _tokenOk = - !requiresMediaServerCredential(loadedSettings.mediaServerType) || - (loadedSettings.plexToken !== undefined && loadedSettings.plexToken !== ""); - if ( - loadedSettings.plexIP !== undefined && - loadedSettings.plexIP !== "" && - _tokenOk && - loadedSettings.plexPort !== undefined && - loadedSettings.plexPort !== "" - ) { - isMediaServerEnabled = true; - } else { - isMediaServerEnabled = false; - } + // Multi media-server: any configured+enabled entry counts + const configuredServers = + mediaServersUtil.listConfiguredMediaServers(loadedSettings); + isMediaServerEnabled = configuredServers.length > 0; // check Sonarr / Radarr before on-demand (recently-added can use *arr alone) if ( @@ -2479,13 +2644,12 @@ async function checkEnabled() { isRadarrEnabled = false; } - // On-demand: live library fetches need a configured media server; cache-backed slides can run - // offline when "Prefer cached poster library" is on and the poster DB has rows (filled by sync). - // Recently-added days can also run from Radarr/Sonarr imports alone. + // On-demand / library deck: cache-first. Enable when OD not disabled and + // (cache has rows OR sync sources exist OR recently-added *arr). const odNotDisabled = loadedSettings.enableOD !== "false"; - const libsConfigured = - loadedSettings.onDemandLibraries !== undefined && - String(loadedSettings.onDemandLibraries || "").trim().length > 0; + const libsConfigured = mediaServersUtil + .listSyncMediaServers(loadedSettings) + .some((s) => String(s.libraries || "").trim().length > 0); const numConfigured = loadedSettings.numberOnDemand !== undefined; const arrRecentlyAddedOk = recentlyAddedDaysActive() && (isSonarrEnabled || isRadarrEnabled); @@ -2493,12 +2657,13 @@ async function checkEnabled() { isOnDemandEnabled = false; } else if (arrRecentlyAddedOk) { isOnDemandEnabled = true; - } else if (preferCachedPostersEnabled()) { + } else { isOnDemandEnabled = cachedPosterDbHasRows() || - (isMediaServerEnabled && libsConfigured); - } else { - isOnDemandEnabled = isMediaServerEnabled && libsConfigured; + (isMediaServerEnabled && libsConfigured) || + isSonarrEnabled || + isRadarrEnabled || + false; } // check Lidarr @@ -2646,24 +2811,28 @@ async function theaterOff(theater) { console.log(d.toLocaleString() + ` ** Theatre mode deactivated`); } -async function probePosterMetadataEntryGone(entry) { - if (!isMediaServerEnabled || !loadedSettings) return false; +async function probePosterMetadataEntryGone(entry, serverEntry) { + if (!loadedSettings) return false; try { - const Pms = getMediaServerClass(loadedSettings.mediaServerType); - const ms = new Pms({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); - if (typeof ms.posterMetadataEntryGone !== "function") return false; + const ms = serverEntry + ? mediaServersUtil.createMediaServerClient(serverEntry) + : newMediaServerClient(); + if (!ms || typeof ms.posterMetadataEntryGone !== "function") return false; return await ms.posterMetadataEntryGone(entry); } catch (e) { return false; } } -function newMediaServerClient() { +function newMediaServerClient(serverEntry) { + if (serverEntry) { + return mediaServersUtil.createMediaServerClient(serverEntry); + } + const list = mediaServersUtil.listConfiguredMediaServers(loadedSettings); + if (list.length > 0) { + return mediaServersUtil.createMediaServerClient(list[0]); + } + // Legacy fallback const Pms = getMediaServerClass(loadedSettings.mediaServerType); return new Pms({ plexHTTPS: loadedSettings.plexHTTPS, @@ -2749,26 +2918,55 @@ async function syncFullPosterLibraryFromMediaServer(options) { if (!syncDebugEnabled) return; console.log(new Date().toLocaleString() + " [poster sync debug] " + msg); }; - if (posterSyncProgressState.status === "running") { + + // Multi-server: when no specific serverEntry, queue each sync-enabled server then *arr + if (!(options && options.serverEntry) && !(options && options._arrOnlyPass)) { + if (posterSyncProgressState.status === "running") { + return; + } + const servers = mediaServersUtil + .listSyncMediaServers(loadedSettings) + .filter((s) => String(s.libraries || "").trim().length > 0); + if (!servers.length) { + await syncArrAppsIntoPosterCache(); + return; + } + for (const server of servers) { + if (posterSyncAbortRequested) break; + await syncFullPosterLibraryFromMediaServer( + Object.assign({}, options || {}, { serverEntry: server }) + ); + } + await syncArrAppsIntoPosterCache(); return; } - if (!loadedSettings || !isMediaServerEnabled || isMediaServerUnavailable) return; - if ( - !loadedSettings.onDemandLibraries || - !String(loadedSettings.onDemandLibraries).trim() - ) { + + if (posterSyncProgressState.status === "running" && !(options && options.serverEntry)) { return; } - const configuredNames = getConfiguredOnDemandLibraryNames( - loadedSettings.onDemandLibraries - ); + const serverEntry = + options && options.serverEntry + ? mediaServersUtil.normalizeMediaServerEntry(options.serverEntry) + : null; + if (!loadedSettings || !serverEntry) { + return; + } + const libraryCsvForServer = String( + (serverEntry && serverEntry.libraries) || + loadedSettings.onDemandLibraries || + "" + ).trim(); + if (!libraryCsvForServer) { + return; + } + const configuredNames = getConfiguredOnDemandLibraryNames(libraryCsvForServer); const singleRaw = options && options.singleLibrary != null ? String(options.singleLibrary).trim() : ""; const metadataOnly = options && options.metadataOnlySync === true; - let libraryCsv = String(loadedSettings.onDemandLibraries).trim(); + let libraryCsv = libraryCsvForServer; if (singleRaw) { const resolved = matchConfiguredLibraryName(singleRaw, configuredNames); if (!resolved) { @@ -2778,7 +2976,7 @@ async function syncFullPosterLibraryFromMediaServer(options) { } let ms; try { - ms = newMediaServerClient(); + ms = newMediaServerClient(serverEntry); } catch (e) { return; } @@ -2789,7 +2987,7 @@ async function syncFullPosterLibraryFromMediaServer(options) { posterSyncProgressState.phase = "starting"; posterSyncProgressState.label = singleRaw ? "Starting (one library)…" - : "Starting…"; + : "Starting " + (serverEntry.name || serverEntry.host) + "…"; posterSyncProgressState.processed = 0; posterSyncProgressState.total = 0; posterSyncProgressState.libraries = []; @@ -2800,9 +2998,9 @@ async function syncFullPosterLibraryFromMediaServer(options) { posterSyncProgressState.error = ""; posterSyncProgressState.startedAt = Date.now(); posterSyncProgressState.finishedAt = null; - posterSyncProgressState.serverKind = getMediaServerKind( - loadedSettings.mediaServerType - ); + posterSyncProgressState.serverKind = serverEntry.type; + posterSyncProgressState.serverId = serverEntry.id; + posterSyncProgressState.serverName = serverEntry.name || serverEntry.host; const syncStarted = Date.now(); const runIdTag = " [run " + posterSyncProgressState.runId + "]"; @@ -2811,6 +3009,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { metadataOnly + ", serverKind=" + posterSyncProgressState.serverKind + + ", serverId=" + + serverEntry.id + ', libraryCsv="' + libraryCsv + '"' @@ -2821,6 +3021,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { runIdTag + " " + posterSyncProgressState.serverKind + + " @" + + (serverEntry.name || serverEntry.host) + ' — libraries: "' + libraryCsv + '"' + @@ -2884,7 +3086,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { : buildPosterSyncImagePullOptions(), tmdbApiKey: loadedSettings.tmdbApiKey || "", retryLibraryKeysFromLastSync: posterSyncRetry.loadRetryKeys( - posterSyncProgressState.serverKind + posterSyncProgressState.serverKind, + posterSyncProgressState.serverId ), posterSyncServerKind: posterSyncProgressState.serverKind, posterSyncAbortCheck: () => posterSyncAbortRequested, @@ -2894,10 +3097,14 @@ async function syncFullPosterLibraryFromMediaServer(options) { .toLowerCase() !== "false", onPosterSyncBatch: async (batchCards, batchMeta) => { if (!Array.isArray(batchCards) || batchCards.length === 0) return; + for (const c of batchCards) { + if (c) c.posterServerId = posterSyncProgressState.serverId; + } const rs = posterMetadata.registerFromMediaServerCards( [], batchCards, - posterSyncProgressState.serverKind + posterSyncProgressState.serverKind, + posterSyncProgressState.serverId ); streamRegisterCalls += 1; streamRegisterWritten += Number((rs && rs.written) || 0); @@ -2987,7 +3194,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { regStats = posterMetadata.registerFromMediaServerCards( [], cards, - posterSyncProgressState.serverKind + posterSyncProgressState.serverKind, + posterSyncProgressState.serverId ); } try { @@ -3022,7 +3230,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { cards, posterSyncProgressState.serverKind, buildImagePullOptions() - ) + ), + posterSyncProgressState.serverId ); posterSyncProgressState.status = "done"; posterSyncProgressState.phase = "complete"; @@ -4924,7 +5133,185 @@ app.post(BASEURL + "/settings/clear-poster-cache", async (req, res) => { text: "Could not clear poster cache: " + msg, }; } - res.redirect(302, BASEURL + "/settings"); + res.redirect(302, BASEURL + "/settings/media-server"); +}); + +app.post(BASEURL + "/settings/media-server/clear-arr-cache", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + const kind = String(req.body.arrKind || "").toLowerCase(); + let sid = mediaServersUtil.ARR_SERVER_IDS[kind]; + if (kind === "readarr" && loadedSettings.bookArrKind === "chaptarr") { + sid = mediaServersUtil.ARR_SERVER_IDS.chaptarr; + } + if (!sid) { + req.session.cacheClearNotice = { + ok: false, + text: "Unknown *arr kind.", + }; + return res.redirect(302, BASEURL + "/settings/media-server"); + } + try { + const r = posterMetadata.clearCacheForServerId(sid); + req.session.cacheClearNotice = { + ok: true, + text: "Cleared " + (r.removedRows || 0) + " cached *arr row(s) for " + kind + ".", + }; + } catch (e) { + req.session.cacheClearNotice = { + ok: false, + text: "Could not clear *arr cache: " + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/media-server"); +}); + +app.get(BASEURL + "/settings/media-server", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + const cacheClearNotice = req.session.cacheClearNotice || null; + req.session.cacheClearNotice = null; + const notice = req.session.mediaServerNotice || null; + req.session.mediaServerNotice = null; + res.render("settings-media-server", { + success: req.session.success, + user: + loadedSettings.password === undefined ? { valid: true } : userData, + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + customPicFolders: customPicFolders, + latestVersion: latestVersion, + message: message, + updateAvailable: updateAvailable, + notice: notice, + cacheClearNotice: cacheClearNotice, + ...newFeaturesBannerViewData(), + }); +}); + +app.post(BASEURL + "/settings/media-server", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + const parsed = mediaServersUtil.parseMediaServersFromFormBody(req.body); + const validated = mediaServersUtil.validateMediaServersForSave(parsed); + if (!validated.ok) { + req.session.mediaServerNotice = { + ok: false, + text: validated.errors.join(" "), + }; + return res.redirect(302, BASEURL + "/settings/media-server"); + } + + const clearRaw = String(req.body.clearCacheServerIds || "").trim(); + const clearIds = clearRaw + ? clearRaw.split(",").map((s) => s.trim()).filter(Boolean) + : []; + for (const sid of clearIds) { + try { + posterMetadata.clearCacheForServerId(sid); + } catch (e) { + console.log( + new Date().toLocaleString() + + " clearCacheForServerId failed: " + + (e && e.message ? e.message : e) + ); + } + } + + loadedSettings.mediaServers = validated.mediaServers; + mediaServersUtil.syncLegacyFlatFromMediaServers(loadedSettings); + + const refreshRaw = req.body.posterCacheRefreshMins; + const refreshN = parseInt(refreshRaw, 10); + loadedSettings.posterCacheRefreshMins = + refreshRaw === undefined || + refreshRaw === null || + refreshRaw === "" || + isNaN(refreshN) + ? 0 + : Math.max(0, refreshN); + + const minAgeRaw = req.body.posterCacheMinAgeBeforeChangeCheckMins; + const minAgeN = parseInt(minAgeRaw, 10); + loadedSettings.posterCacheMinAgeBeforeChangeCheckMins = + minAgeRaw === undefined || + minAgeRaw === null || + minAgeRaw === "" || + isNaN(minAgeN) + ? 0 + : Math.max(0, minAgeN); + + loadedSettings.preferCachedPosters = req.body.preferCachedPosters + ? "true" + : "false"; + + const slideRaw = req.body.cachedPosterSlideCount; + const slideN = parseInt(slideRaw, 10); + loadedSettings.cachedPosterSlideCount = + slideRaw === undefined || + slideRaw === null || + slideRaw === "" || + isNaN(slideN) || + slideN < 1 || + !Number.isFinite(slideN) + ? DEFAULT_SETTINGS.cachedPosterSlideCount + : Math.floor(slideN); + + await setng.UpdateSettings(loadedSettings); + + clearInterval(nowScreeningClock); + clearInterval(onDemandClock); + clearInterval(sonarrClock); + clearInterval(radarrClock); + clearInterval(readarrClock); + clearInterval(lidarrClock); + clearInterval(houseKeepingClock); + clearInterval(picturesClock); + clearInterval(triviaClock); + clearInterval(linksClock); + clearInterval(posterMetadataRefreshClock); + nsCards = []; + odCards = []; + csCards = []; + csrCards = []; + picCards = []; + adSlideCards = []; + cslCards = []; + csbCards = []; + trivCards = []; + linkCards = []; + globalPage.cards = []; + console.log( + "✘✘ WARNING ✘✘ - Restarting after media server settings save. Please wait while current jobs complete" + ); + startup(false); + + const clearedNote = clearIds.length + ? " Cleared cache for " + clearIds.length + " removed server(s)." + : ""; + req.session.mediaServerNotice = { + ok: true, + text: + "Media server settings saved (" + + validated.mediaServers.length + + " server(s)). Services are restarting." + + clearedNote, + }; + } catch (e) { + req.session.mediaServerNotice = { + ok: false, + text: + "Could not save media server settings: " + + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/media-server"); }); app.post(BASEURL + "/settings/new-features-acknowledge", (req, res) => { @@ -5060,9 +5447,20 @@ app.get(BASEURL + "/settings/sync", (req, res) => { message: message, updateAvailable: updateAvailable, syncNotice: syncNotice, - onDemandLibraryList: getConfiguredOnDemandLibraryNames( - loadedSettings.onDemandLibraries - ), + onDemandLibraryList: (function () { + const names = []; + const seen = new Set(); + for (const s of mediaServersUtil.listSyncMediaServers(loadedSettings)) { + for (const n of getConfiguredOnDemandLibraryNames(s.libraries)) { + const k = n.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + names.push(n); + } + } + return names; + })(), + mediaServerList: mediaServersUtil.listConfiguredMediaServers(loadedSettings), ...newFeaturesBannerViewData(), }); }); @@ -5072,17 +5470,21 @@ app.post(BASEURL + "/settings/sync/trigger", (req, res) => { return res.redirect(302, BASEURL + "/logon"); } if (!isMediaServerEnabled) { + // Still allow *arr-only / cache sync + syncFullPosterLibraryFromMediaServer({}).catch(() => {}); req.session.syncNotice = { - ok: false, - text: "Media server is not configured or cannot be reached. Check Settings → Media server.", + ok: true, + text: "No media servers configured — syncing *arr into poster cache if available.", }; return res.redirect(302, BASEURL + "/settings/sync"); } - const libs = loadedSettings.onDemandLibraries; - if (!libs || !String(libs).trim()) { + const syncServers = mediaServersUtil + .listSyncMediaServers(loadedSettings) + .filter((s) => String(s.libraries || "").trim()); + if (!syncServers.length) { req.session.syncNotice = { ok: false, - text: "Configure on-demand library names in Settings before running a full sync.", + text: "Add library names on each media server (Settings → Media Server) before running a full sync.", }; return res.redirect(302, BASEURL + "/settings/sync"); } @@ -6074,18 +6476,6 @@ app.post( return true; }) .withMessage("'Slide Duration' is required and must be 5 or more"), - check("plexIP").not().isEmpty().withMessage("'Media server address' is required"), - check("plexPort") - .not() - .isEmpty() - .withMessage("'Media server port' is required. (setting default)") - .custom((value) => { - if (parseInt(value) === "NaN") { - throw new Error("'Media server port' must be a number"); - } - // Indicates the success of this synchronous custom validator - return true; - }), check("onDemandRefresh") .not() .isEmpty() @@ -6134,15 +6524,6 @@ app.post( // Indicates the success of this synchronous custom validator return true; }), - check("plexToken").custom((value, { req }) => { - if (!requiresMediaServerCredential(req.body.mediaServerType || "plex")) { - return true; - } - if (value === undefined || value === null || String(value).trim() === "") { - throw new Error("'Media server token / API key' is required"); - } - return true; - }), check("enableSleep") .custom((value, { req }) => { if(value == "true"){ @@ -6208,14 +6589,6 @@ app.post( shuffleSwitch: req.body.shuffleSwitch, hideSettingsLinks: req.body.hideSettingsLinks, theaterRoomMode: req.body.theaterRoomMode, - mediaServerType: req.body.mediaServerType || "plex", - plexToken: - req.body.plexToken !== undefined && req.body.plexToken !== null - ? req.body.plexToken - : "", - plexIP: req.body.plexIP, - plexHTTPSSwitch: req.body.plexHTTPSSwitch, - plexPort: req.body.plexPort ? parseInt(req.body.plexPort) : DEFAULT_SETTINGS.plexPort, plexLibraries: req.body.plexLibraries, onDemand3dLibraries: req.body.onDemand3dLibraries, pinNSSwitch: req.body.pinNSSwitch, @@ -6368,28 +6741,6 @@ app.post( links: req.body.links, rotate: req.body.rotate, excludeLibs: req.body.excludeLibs, - posterCacheRefreshMins: (() => { - const raw = req.body.posterCacheRefreshMins; - if (raw === undefined || raw === null || raw === "") return 0; - const n = parseInt(raw, 10); - return isNaN(n) ? 0 : Math.max(0, n); - })(), - posterCacheMinAgeBeforeChangeCheckMins: (() => { - const raw = req.body.posterCacheMinAgeBeforeChangeCheckMins; - if (raw === undefined || raw === null || raw === "") return 0; - const n = parseInt(raw, 10); - return isNaN(n) ? 0 : Math.max(0, n); - })(), - preferCachedPosters: req.body.preferCachedPosters, - cachedPosterSlideCount: (() => { - const raw = req.body.cachedPosterSlideCount; - if (raw === undefined || raw === null || raw === "") - return DEFAULT_SETTINGS.cachedPosterSlideCount; - const n = parseInt(raw, 10); - return isNaN(n) || n < 1 || !Number.isFinite(n) - ? DEFAULT_SETTINGS.cachedPosterSlideCount - : Math.floor(n); - })(), saved: false }; diff --git a/myviews/settings-ads.ejs b/myviews/settings-ads.ejs index b140d3e..c41e5b2 100644 --- a/myviews/settings-ads.ejs +++ b/myviews/settings-ads.ejs @@ -30,6 +30,7 @@