diff --git a/classes/core/posterMetadataDb.js b/classes/core/posterMetadataDb.js index 91774c8..5ce37f4 100644 --- a/classes/core/posterMetadataDb.js +++ b/classes/core/posterMetadataDb.js @@ -1,4 +1,6 @@ const fs = require("fs"); +const os = require("os"); +const crypto = require("crypto"); const path = require("path"); const Cache = require("./cache"); const MediaCard = require("../cards/MediaCard"); @@ -22,6 +24,15 @@ const MAX_ENTRIES = 100000; const MIN_FILE_BYTES = 256; const DEFAULT_FALLBACK_COUNT = 24; +const POSTER_PICK_INSTANCE_OFFSET = (() => { + const host = String(os.hostname() || ""); + let h = 0; + for (let i = 0; i < host.length; i++) { + h = (Math.imul(31, h) + host.charCodeAt(i)) >>> 0; + } + return (h + crypto.randomInt(0, 1000000)) >>> 0; +})(); + const SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS poster_entries ( cache_file TEXT PRIMARY KEY NOT NULL, @@ -1067,6 +1078,10 @@ function pickRandomEntries(count, serverKindOpt, hideContentRatings, serverIdsOp const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } + const rotateBy = POSTER_PICK_INSTANCE_OFFSET % shuffled.length; + if (rotateBy > 0) { + return shuffled.slice(rotateBy).concat(shuffled.slice(0, rotateBy)).slice(0, Math.min(count, shuffled.length)); + } return shuffled.slice(0, Math.min(count, shuffled.length)); } diff --git a/classes/core/utility.js b/classes/core/utility.js index fc307cb..516b26e 100644 --- a/classes/core/utility.js +++ b/classes/core/utility.js @@ -1,203 +1,225 @@ -/** - * @desc utility class for string and object handling - * @returns {} utility - */ -class utility { - /** - * @desc Returns true is null, empty or undefined - * @param {string} val - * @returns {Promise} boolean - true empty, undefined or null - */ - static async isEmpty(val) { - if (val == undefined || val == "" || val == null) { - return true; - } else { - return false; - } - } - - static createUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } - - /** Escape text for safe insertion into HTML attribute or body context */ - static escapeHtml(str) { - if (str == null || str === "") return ""; - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - /** - * Normalize official content ratings for hide-list matching - * (e.g. "R", "Rated R", "us:R", "R - Restricted" → "r"; keep "pg-13"). - */ - static normalizeContentRating(raw) { - let s = String(raw || "") - .toLowerCase() - .trim(); - if (!s) return ""; - s = s.replace(/^rated\s+/i, "").trim(); - // Country prefix only when followed by : or / (not hyphen — that breaks pg-13). - s = s.replace(/^[a-z]{2,3}\s*[:\/]\s*/i, "").trim(); - s = s.split(/\s+[\-–—]\s+/)[0] || s; - s = s.split(/\s*\(/)[0] || s; - return String(s).trim(); - } - - /** Parse Hide Ratings setting → lowercase normalized tokens. */ - static parseHideContentRatings(raw) { - return String(raw || "") - .split(",") - .map((s) => utility.normalizeContentRating(s)) - .filter(Boolean); - } - - /** True if itemRating matches any hide-list entry after normalization. */ - static contentRatingIsHidden(itemRating, hideList) { - if (!hideList || !hideList.length) return false; - const cr = utility.normalizeContentRating(itemRating); - if (!cr) return false; - return hideList.some( - (r) => utility.normalizeContentRating(r) === cr - ); - } - - /** Plex Genre/Role/Director-style entries: { tag } or { Tag } */ - static _plexTagNames(tagged, max) { - if (tagged == null) return ""; - const arr = Array.isArray(tagged) ? tagged : [tagged]; - const names = arr - .map((r) => (r && (r.tag != null ? r.tag : r.Tag)) || "") - .filter(Boolean); - return names.slice(0, max).join(", "); - } - - /** Comma-separated actor names from Plex Role metadata */ - static formatCastFromPlexRole(role) { - return utility._plexTagNames(role, 12); - } - - /** Comma-separated director names from Plex Director metadata */ - static formatDirectorsFromPlexDirector(director) { - return utility._plexTagNames(director, 8); - } - - static _embyPeopleByType(people, typeName, max) { - if (!people || !Array.isArray(people)) return ""; - const names = people - .filter((p) => (p.Type || p.type || "").toString() === typeName) - .map((p) => p.Name || p.name) - .filter(Boolean); - return names.slice(0, max).join(", "); - } - - /** Comma-separated actor names from Jellyfin/Emby People array */ - static formatCastFromEmbyPeople(people) { - return utility._embyPeopleByType(people, "Actor", 12); - } - - /** Comma-separated director names from Jellyfin/Emby People array */ - static formatDirectorsFromEmbyPeople(people) { - return utility._embyPeopleByType(people, "Director", 8); - } - - static _embyPeopleByTypes(people, typeNames, max) { - if (!people || !Array.isArray(people) || !typeNames.length) return ""; - const set = new Set(typeNames); - const names = people - .filter((p) => set.has((p.Type || p.type || "").toString())) - .map((p) => p.Name || p.name) - .filter(Boolean); - return names.slice(0, max).join(", "); - } - - /** Jellyfin/Emby book & audiobook: AlbumArtist plus Writer/Author from People */ - static formatAuthorsFromEmbyBookItem(item) { - if (!item) return ""; - const album = (item.AlbumArtist || item.albumArtist || "").trim(); - const fromPeople = utility._embyPeopleByTypes(item.People, ["Writer", "Author"], 8); - if (album && fromPeople) return album + ", " + fromPeople; - return album || fromPeople; - } - - /** - * @desc Returns an empty string if undefined, null or empty, else the submitted value - * @param {string} val - * @returns {Promise} string - either an empty string or the submitted string value - */ - static async emptyIfNull(val) { - if (val == undefined || val == null || val == "") { - return ""; - } else { - return val; - } - } - - /** - * @desc Gets a random item from an array - * @param {Array} items - a given array of anything - * @returns {Promise} object - returns one random item - */ - static async random_item(items) { - return items[Math.floor(Math.random() * items.length)]; - } - - /** - * @desc builds random set of on-demand cards - * @param {number} numberOnDemand - the number of on-demand cards to return - * @param {object} mediaCards - an array of on-demand mediaCards - * @returns {Promise} mediaCard[] - an array of mediaCards - */ - static async build_random_od_set( - numberOnDemand, - mediaCards, - recentlyAdded, - options - ) { - if (Number(numberOnDemand) <= 0) { - return Array.isArray(mediaCards) ? mediaCards.slice() : []; - } - if (options && options.includeAll === true) { - return Array.isArray(mediaCards) ? mediaCards.slice() : []; - } - // Recently-added pool: still honor Number to Display as a cap (0 = all matching). - const pool = Array.isArray(mediaCards) ? mediaCards.slice() : []; - if (pool.length === 0) { - return []; - } - const requested = Math.max(0, Math.floor(Number(numberOnDemand))); - const want = Math.min(requested, pool.length); - if (want === 0) { - return []; - } - // Fisher–Yates shuffle, then take first `want` — always unique (reference equality per slot). - for (let i = pool.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const t = pool[i]; - pool[i] = pool[j]; - pool[j] = t; - } - if (requested > pool.length && pool.length > 0) { - const d = new Date(); - console.log( - d.toLocaleString() + - " *On-demand — requested " + - requested + - " unique title(s) but only " + - pool.length + - " match the current library/filters; showing " + - pool.length + - ". (Lower “number to display” or widen libraries/filters.)" - ); - } - return pool.slice(0, want); - } -} - -module.exports = utility; +/** + * @desc utility class for string and object handling + * @returns {} utility + */ +class utility { + /** + * @desc Returns true is null, empty or undefined + * @param {string} val + * @returns {Promise} boolean - true empty, undefined or null + */ + static async isEmpty(val) { + if (val == undefined || val == "" || val == null) { + return true; + } else { + return false; + } + } + + static createUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + /** Fisher–Yates in-place shuffle; returns the same array reference. */ + static fisherYatesShuffle(items) { + const arr = Array.isArray(items) ? items : []; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = arr[i]; + arr[i] = arr[j]; + arr[j] = t; + } + return arr; + } + + /** Return a new array rotated left by offset (wraps). */ + static rotateArray(items, offset) { + const arr = Array.isArray(items) ? items.slice() : []; + const len = arr.length; + if (len <= 1) return arr; + const n = ((Number(offset) || 0) % len + len) % len; + if (n === 0) return arr; + return arr.slice(n).concat(arr.slice(0, n)); + } + + /** Escape text for safe insertion into HTML attribute or body context */ + static escapeHtml(str) { + if (str == null || str === "") return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + /** + * Normalize official content ratings for hide-list matching + * (e.g. "R", "Rated R", "us:R", "R - Restricted" → "r"; keep "pg-13"). + */ + static normalizeContentRating(raw) { + let s = String(raw || "") + .toLowerCase() + .trim(); + if (!s) return ""; + s = s.replace(/^rated\s+/i, "").trim(); + // Country prefix only when followed by : or / (not hyphen — that breaks pg-13). + s = s.replace(/^[a-z]{2,3}\s*[:\/]\s*/i, "").trim(); + s = s.split(/\s+[\-–—]\s+/)[0] || s; + s = s.split(/\s*\(/)[0] || s; + return String(s).trim(); + } + + /** Parse Hide Ratings setting → lowercase normalized tokens. */ + static parseHideContentRatings(raw) { + return String(raw || "") + .split(",") + .map((s) => utility.normalizeContentRating(s)) + .filter(Boolean); + } + + /** True if itemRating matches any hide-list entry after normalization. */ + static contentRatingIsHidden(itemRating, hideList) { + if (!hideList || !hideList.length) return false; + const cr = utility.normalizeContentRating(itemRating); + if (!cr) return false; + return hideList.some( + (r) => utility.normalizeContentRating(r) === cr + ); + } + + /** Plex Genre/Role/Director-style entries: { tag } or { Tag } */ + static _plexTagNames(tagged, max) { + if (tagged == null) return ""; + const arr = Array.isArray(tagged) ? tagged : [tagged]; + const names = arr + .map((r) => (r && (r.tag != null ? r.tag : r.Tag)) || "") + .filter(Boolean); + return names.slice(0, max).join(", "); + } + + /** Comma-separated actor names from Plex Role metadata */ + static formatCastFromPlexRole(role) { + return utility._plexTagNames(role, 12); + } + + /** Comma-separated director names from Plex Director metadata */ + static formatDirectorsFromPlexDirector(director) { + return utility._plexTagNames(director, 8); + } + + static _embyPeopleByType(people, typeName, max) { + if (!people || !Array.isArray(people)) return ""; + const names = people + .filter((p) => (p.Type || p.type || "").toString() === typeName) + .map((p) => p.Name || p.name) + .filter(Boolean); + return names.slice(0, max).join(", "); + } + + /** Comma-separated actor names from Jellyfin/Emby People array */ + static formatCastFromEmbyPeople(people) { + return utility._embyPeopleByType(people, "Actor", 12); + } + + /** Comma-separated director names from Jellyfin/Emby People array */ + static formatDirectorsFromEmbyPeople(people) { + return utility._embyPeopleByType(people, "Director", 8); + } + + static _embyPeopleByTypes(people, typeNames, max) { + if (!people || !Array.isArray(people) || !typeNames.length) return ""; + const set = new Set(typeNames); + const names = people + .filter((p) => set.has((p.Type || p.type || "").toString())) + .map((p) => p.Name || p.name) + .filter(Boolean); + return names.slice(0, max).join(", "); + } + + /** Jellyfin/Emby book & audiobook: AlbumArtist plus Writer/Author from People */ + static formatAuthorsFromEmbyBookItem(item) { + if (!item) return ""; + const album = (item.AlbumArtist || item.albumArtist || "").trim(); + const fromPeople = utility._embyPeopleByTypes(item.People, ["Writer", "Author"], 8); + if (album && fromPeople) return album + ", " + fromPeople; + return album || fromPeople; + } + + /** + * @desc Returns an empty string if undefined, null or empty, else the submitted value + * @param {string} val + * @returns {Promise} string - either an empty string or the submitted string value + */ + static async emptyIfNull(val) { + if (val == undefined || val == null || val == "") { + return ""; + } else { + return val; + } + } + + /** + * @desc Gets a random item from an array + * @param {Array} items - a given array of anything + * @returns {Promise} object - returns one random item + */ + static async random_item(items) { + return items[Math.floor(Math.random() * items.length)]; + } + + /** + * @desc builds random set of on-demand cards + * @param {number} numberOnDemand - the number of on-demand cards to return + * @param {object} mediaCards - an array of on-demand mediaCards + * @returns {Promise} mediaCard[] - an array of mediaCards + */ + static async build_random_od_set( + numberOnDemand, + mediaCards, + recentlyAdded, + options + ) { + if (Number(numberOnDemand) <= 0) { + return Array.isArray(mediaCards) ? mediaCards.slice() : []; + } + if (options && options.includeAll === true) { + return Array.isArray(mediaCards) ? mediaCards.slice() : []; + } + // Recently-added pool: still honor Number to Display as a cap (0 = all matching). + const pool = Array.isArray(mediaCards) ? mediaCards.slice() : []; + if (pool.length === 0) { + return []; + } + const requested = Math.max(0, Math.floor(Number(numberOnDemand))); + const want = Math.min(requested, pool.length); + if (want === 0) { + return []; + } + // Fisher–Yates shuffle, then take first `want` — always unique (reference equality per slot). + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = pool[i]; + pool[i] = pool[j]; + pool[j] = t; + } + if (requested > pool.length && pool.length > 0) { + const d = new Date(); + console.log( + d.toLocaleString() + + " *On-demand — requested " + + requested + + " unique title(s) but only " + + pool.length + + " match the current library/filters; showing " + + pool.length + + ". (Lower “number to display” or widen libraries/filters.)" + ); + } + return pool.slice(0, want); + } +} + +module.exports = utility; diff --git a/index.js b/index.js index ecbaad0..ea19635 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,6 @@ const express = require("express"); +const crypto = require("crypto"); +const os = require("os"); const path = require("path"); const app = express(); const multer = require("multer"); @@ -97,6 +99,17 @@ let csbCards = []; let trivCards = []; let linkCards = []; let globalPage = new glb(); +/** Per-process offset so multiple ScreenStage containers desync poster rotation. */ +let deckBuildGeneration = 0; +const INSTANCE_DISPLAY_OFFSET = (() => { + const host = String(os.hostname() || ""); + let h = 0; + for (let i = 0; i < host.length; i++) { + h = (Math.imul(31, h) + host.charCodeAt(i)) >>> 0; + } + return (h + crypto.randomInt(0, 1000000)) >>> 0; +})(); +console.log(" Display instance offset: " + INSTANCE_DISPLAY_OFFSET); let nowScreeningClock; let onDemandClock; let triviaClock; @@ -957,9 +970,73 @@ function libraryCardsWithoutArrOverlap(libraryCards, arrCards) { } function shuffleCardArray(cards) { - return (Array.isArray(cards) ? cards.slice() : []).sort( - () => Math.random() - 0.5 - ); + return util.fisherYatesShuffle(Array.isArray(cards) ? cards.slice() : []); +} + +function cardTypeLabel(card) { + const ct = card && card.cardType; + return Array.isArray(ct) ? String(ct[0] || "") : String(ct || ""); +} + +function isNowScreeningHomeCard(card) { + const n = cardTypeLabel(card).toLowerCase(); + return n === "now screening" || n === "playing"; +} + +function instanceTimerJitterMs(baseMs) { + const base = Math.max(1000, Math.floor(Number(baseMs) || 0)); + const cap = Math.max(1, Math.floor(base * 0.2)); + return base + (INSTANCE_DISPLAY_OFFSET % cap); +} + +/** Keep Now Playing pinned first; shuffle and rotate the rest per instance. */ +function finalizeHomeDeck(cards) { + if (!Array.isArray(cards) || cards.length <= 1) return cards; + deckBuildGeneration += 1; + const pinned = []; + const rest = []; + for (const c of cards) { + if (isNowScreeningHomeCard(c)) pinned.push(c); + else rest.push(c); + } + if (rest.length <= 1) return cards; + let varied = util.fisherYatesShuffle(rest.slice()); + const offset = (INSTANCE_DISPLAY_OFFSET + deckBuildGeneration) % varied.length; + varied = util.rotateArray(varied, offset); + return pinned.concat(varied); +} + +/** Library count for on-demand slide cap validation (main settings no longer posts plexLibraries). */ +function countOnDemandLibrariesForSaveValidation(req) { + const body = req && req.body ? req.body : {}; + const legacy = body.plexLibraries; + if (legacy !== undefined && legacy !== null && String(legacy).trim() !== "") { + return String(legacy) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .length; + } + const settings = loadedSettings || {}; + const servers = mediaServersUtil.listSyncMediaServers(settings); + let total = 0; + for (const server of servers) { + total += String(server.libraries || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .length; + } + if (total > 0) return total; + const savedLibs = String(settings.onDemandLibraries || "").trim(); + if (savedLibs) { + return savedLibs + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .length; + } + return 1; } /** @@ -1541,7 +1618,7 @@ async function warmCachedPosterDeckEarlyIfPossible() { displayIds.length ? displayIds : null ); if (!cached.length) return; - globalPage.cards = cached.slice(); + globalPage.cards = finalizeHomeDeck(cached.slice()); try { await globalPage.OrderAndRenderCards( BASEURL, @@ -1581,7 +1658,7 @@ async function warmCachedPosterDeckEarlyIfPossible() { ? loadedSettings.displayPosterArtist : "false" ); - globalPage.slideDuration = loadedSettings.slideDuration * 1000; + globalPage.slideDuration = instanceTimerJitterMs(loadedSettings.slideDuration * 1000); globalPage.playThemes = loadedSettings.playThemes; globalPage.playGenericThemes = loadedSettings.genericThemes; globalPage.fadeTransition = @@ -1883,13 +1960,13 @@ async function loadNowScreening() { if (adsOnlyOn) { mCards = adSlideCards.slice(); if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = mCards.sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(mCards); } globalPage.cards = mCards; } else if (nowShowingListOnlyOn) { mCards = tmdbNowShowingPosterCards.slice(); if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = mCards.sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(mCards); } globalPage.cards = mCards; } else { @@ -1950,7 +2027,7 @@ async function loadNowScreening() { } else { if (csCards.length > 0) { if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = csCards.concat(csrCards.concat(cslCards).concat(picCards).concat(csbCards).concat(linkCards).concat(trivCards)).sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(csCards.concat(csrCards.concat(cslCards).concat(picCards).concat(csbCards).concat(linkCards).concat(trivCards))); } else { mCards = csCards.concat(csrCards); @@ -1964,7 +2041,7 @@ async function loadNowScreening() { } else { if (csrCards.length > 0) { if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = csrCards.concat(cslCards.concat(picCards).concat(csbCards).concat(linkCards).concat(trivCards)).sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(csrCards.concat(cslCards.concat(picCards).concat(csbCards).concat(linkCards).concat(trivCards))); } else { mCards = csrCards.concat(cslCards); @@ -1980,7 +2057,7 @@ async function loadNowScreening() { else { if (cslCards.length > 0) { if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = cslCards.concat(picCards.concat(csbCards).concat(linkCards).concat(trivCards)).sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(cslCards.concat(picCards.concat(csbCards).concat(linkCards).concat(trivCards))); } else { mCards = cslCards.concat(picCards); mCards = mCards.concat(csbCards); @@ -1990,7 +2067,7 @@ async function loadNowScreening() { globalPage.cards = mCards; } else if (csbCards.length > 0) { if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = csbCards.concat(picCards.concat(trivCards)).concat(linkCards).sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(csbCards.concat(picCards.concat(trivCards)).concat(linkCards)); } else { mCards = csbCards.concat(picCards); @@ -2002,7 +2079,7 @@ async function loadNowScreening() { else { if(picCards.length > 0) { if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = picCards.concat(trivCards).concat(linkCards).sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(picCards.concat(trivCards).concat(linkCards)); } else { mCards = picCards.concat(trivCards); @@ -2012,7 +2089,7 @@ async function loadNowScreening() { } else { if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - mCards = trivCards.concat(linkCards).sort(() => Math.random() - 0.5); + mCards = shuffleCardArray(trivCards.concat(linkCards)); } else { mCards = trivCards; @@ -2110,6 +2187,7 @@ async function loadNowScreening() { // put everything into global class, ready to be passed to poster.ejs // render html for all cards + globalPage.cards = finalizeHomeDeck(globalPage.cards); await globalPage.OrderAndRenderCards( BASEURL, loadedSettings.hasArt, @@ -2150,7 +2228,7 @@ async function loadNowScreening() { ? loadedSettings.displayPosterArtist : "false" ); - globalPage.slideDuration = loadedSettings.slideDuration * 1000; + globalPage.slideDuration = instanceTimerJitterMs(loadedSettings.slideDuration * 1000); globalPage.playThemes = loadedSettings.playThemes; globalPage.playGenericThemes = loadedSettings.genericThemes; globalPage.fadeTransition = @@ -2165,7 +2243,7 @@ async function loadNowScreening() { globalPage.rotate = loadedSettings.rotate !== undefined ? loadedSettings.rotate : "false"; // restart the clock - nowScreeningClock = setInterval(loadNowScreening, pollInterval); + nowScreeningClock = setInterval(loadNowScreening, instanceTimerJitterMs(pollInterval)); return nsCards; } @@ -2465,7 +2543,7 @@ async function loadOnDemand() { const nextMs = isNaN(odCheckMinutes) ? 30 * 60 * 1000 : Math.max(10, odCheckMinutes) * 60000; - onDemandClock = setInterval(loadOnDemand, nextMs); + onDemandClock = setInterval(loadOnDemand, instanceTimerJitterMs(nextMs)); return odCards; } // stop the clock @@ -2484,7 +2562,7 @@ async function loadOnDemand() { odCheckMinutes = 1; console.log("✘✘ WARNING ✘✘ - Next on-demand query will run in 1 minute."); // restart interval timer - onDemandClock = setInterval(loadOnDemand, odCheckMinutes * 60000); + onDemandClock = setInterval(loadOnDemand, instanceTimerJitterMs(odCheckMinutes * 60000)); return odCards; } @@ -2497,15 +2575,13 @@ async function loadOnDemand() { } // restart interval timer - onDemandClock = setInterval(loadOnDemand, odCheckMinutes * 60000); + onDemandClock = setInterval(loadOnDemand, instanceTimerJitterMs(odCheckMinutes * 60000)); // randomise on-demand results for all libraries queried if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { - return odCards.sort(() => Math.random() - 0.5); - } - else { - return odCards; + return finalizeHomeDeck(shuffleCardArray(odCards)); } + return finalizeHomeDeck(odCards); } @@ -6496,9 +6572,7 @@ app.post( .isEmpty() .withMessage("'Number to Display' must be 0 or more. (setting default)") .custom((value, { req }) => { - if (value !== undefined && value !== "" && parseInt(value) !== "NaN") { - // make sure there are limited slides requested - let numOfLibraries = 0; + if (value !== undefined && value !== "" && !isNaN(parseInt(value, 10))) { let themeMessage; // double the slide count if tv and movie themes are off @@ -6509,39 +6583,56 @@ app.post( themeMessage = ""; } else { - maxSlide = MAX_OD_SLIDES; + maxSlides = MAX_OD_SLIDES; themeMessage = "(when themes enabled)"; } - if (req.body.plexLibraries !== undefined || req.body.plexLibraries !== "") { - numberOfLibraries = req.body.plexLibraries.split(",").length; - if (parseInt(value) * numberOfLibraries > maxSlides) { + try { + const numberOfLibraries = countOnDemandLibrariesForSaveValidation(req); + if ( + numberOfLibraries > 0 && + parseInt(value, 10) * numberOfLibraries > maxSlides + ) { let estimatedNumber = parseInt(maxSlides / numberOfLibraries); throw new Error("'Number to Display' cannot be more than '" + estimatedNumber + "' for '" + numberOfLibraries + "' libraries " + themeMessage); } + } catch (e) { + if (e && e.message && String(e.message).indexOf("Number to Display") !== -1) { + throw e; + } + // Ignore unexpected validation-shape errors (e.g. missing legacy form fields). } } - // Indicates the success of this synchronous custom validator return true; }), check("enableSleep") .custom((value, { req }) => { - if(value == "true"){ - if(req.body.sleepStart.length == 0) throw new Error("You must specify sleep start and end times if the sleep timer is enabled"); - } - if(value == "true"){ - if(req.body.sleepEnd.length == 0) throw new Error("You must specify sleep start and end times if the sleep timer is enabled"); + if (value == "true") { + const start = req.body.sleepStart != null ? String(req.body.sleepStart) : ""; + const end = req.body.sleepEnd != null ? String(req.body.sleepEnd) : ""; + if (start.length == 0) { + throw new Error("You must specify sleep start and end times if the sleep timer is enabled"); + } + if (end.length == 0) { + throw new Error("You must specify sleep start and end times if the sleep timer is enabled"); + } } return true; }), check("sleepStart") - .custom((value, { req }) => { - if(isNaN(Date.parse("2100-01-01T" + value)) == true && value.length !== 0) throw new Error("Sleep start time must be in 24 hour format hh:mm (eg. 07:15 or 23:30)"); + .custom((value) => { + const v = value != null ? String(value) : ""; + if (isNaN(Date.parse("2100-01-01T" + v)) == true && v.length !== 0) { + throw new Error("Sleep start time must be in 24 hour format hh:mm (eg. 07:15 or 23:30)"); + } return true; }), check("sleepEnd") - .custom((value, { req }) => { - if(isNaN(Date.parse("2100-01-01T" + value)) == true && value.length !== 0) throw new Error("Sleep end time must be in 24 hour format hh:mm (eg. 07:15 or 23:30)"); + .custom((value) => { + const v = value != null ? String(value) : ""; + if (isNaN(Date.parse("2100-01-01T" + v)) == true && v.length !== 0) { + throw new Error("Sleep end time must be in 24 hour format hh:mm (eg. 07:15 or 23:30)"); + } return true; }), check("sonarrUrl") diff --git a/myviews/posters/posters.ejs b/myviews/posters/posters.ejs index e75954d..4dbad0b 100644 --- a/myviews/posters/posters.ejs +++ b/myviews/posters/posters.ejs @@ -337,6 +337,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive }); $(".cardHolder").html(cardHtml); + startCarouselAtRandomSlide(); initialLoad(); // check if there is only one card showing and if so, schedule a data refresh after the slide duration period has passed. @@ -427,6 +428,19 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive }; // apply styles to cards and manage audio + function startCarouselAtRandomSlide() { + var items = $(".carousel-item"); + if (items.length <= 1) { + curPlayID = 1; + return; + } + var startIdx = Math.floor(Math.random() * items.length); + items.removeClass("active"); + items.eq(startIdx).addClass("active"); + var slideId = items.eq(startIdx).attr("id"); + curPlayID = slideId ? slideId : 1; + } + function initialLoad() { <%if(globals.hideSettingsLinks !== undefined && globals.hideSettingsLinks == 'true'){ // do nothing @@ -449,8 +463,8 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive StopAllAudio(); //trigger initial audio play if present - if (document.getElementById("audio1")) { - var x = document.getElementById("audio1"); + if (document.getElementById("audio" + curPlayID)) { + var x = document.getElementById("audio" + curPlayID); // set volume to 0 and position 1 second in x.volume = 0; x.currentTime = 0; @@ -463,12 +477,12 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive aud_fadeIn(x); // check if trivia slide and if so, pause carousel and start timer - isQuiz(1); + isQuiz(curPlayID); } // check if marquee is needed - var marquee = document.getElementById("marquee1"); - var tagLine = document.getElementById("tagLine1"); + var marquee = document.getElementById("marquee" + curPlayID); + var tagLine = document.getElementById("tagLine" + curPlayID); if(tagLine !== null){ if(tagLine.scrollHeight > tagLine.clientHeight || tagLine.scrollWidth > tagLine.clientWidth){ marquee.start(); @@ -478,7 +492,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive } } - Resize(1); + Resize(curPlayID); } $(function () { diff --git a/myviews/settings.ejs b/myviews/settings.ejs index 36eab7f..9976169 100644 --- a/myviews/settings.ejs +++ b/myviews/settings.ejs @@ -1314,7 +1314,7 @@ var savedCategories = []; // load posted form data <%if(typeof formData !== 'undefined' && errors){%> - formCategories = '<%=formData.triviaCategories%>'.split(',')%>; + formCategories = <%- JSON.stringify(String(formData.triviaCategories || "")) %>.split(',')%>; formCategories.forEach(function(id){ $('[name="triviaCategories"]').each( function() { if ( $(this).val() == id ){ @@ -1330,7 +1330,7 @@ }); <%}else{%> // load saved trivia selections - savedCategories = '<%=settings.triviaCategories%>'.split(',')%>; + savedCategories = <%- JSON.stringify(String(settings.triviaCategories || "")) %>.split(',')%>; savedCategories.forEach(function(id){ $('[name="triviaCategories"]').each( function() { if ( $(this).val() == id ){