├── .dockerignore ├── version.json ├── frontend ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-384x384.png │ ├── manifest.json │ └── index.html ├── src │ ├── images │ │ ├── add-icon.png │ │ ├── loading-gif.gif │ │ ├── icons8-restart.gif │ │ └── icons8-restart-24.png │ ├── index.css │ ├── setupTests.js │ ├── reportWebVitals.js │ ├── index.js │ └── components │ │ ├── Calendar │ │ ├── Calendar.css │ │ └── Calendar.jsx │ │ ├── Buckets │ │ ├── Buckets.css │ │ └── Buckets.jsx │ │ ├── Settings │ │ ├── Settings.css │ │ └── Settings.jsx │ │ ├── Sequences │ │ ├── Sequences.css │ │ └── Sequences.jsx │ │ ├── App │ │ ├── App.css │ │ ├── Announce.jsx │ │ └── App.jsx │ │ ├── Sequence │ │ ├── Sequence.css │ │ └── Sequence.jsx │ │ ├── CreateSeq │ │ ├── CreateSeq.css │ │ └── countries.js │ │ ├── Bucket │ │ └── Bucket.jsx │ │ ├── Login │ │ └── Login.jsx │ │ └── Create │ │ └── Create.jsx ├── index.js └── package.json ├── Dockerfile ├── .gitignore ├── backend ├── save.js ├── websocket.js ├── thumb.js ├── directory.js ├── clearcache.js ├── streamer.js ├── logger.js ├── migrate.js ├── load.js ├── settings.js ├── holiday.js └── monitor.js ├── package.json ├── bin └── www ├── app.js ├── README.md ├── Jenkinsfile ├── history.md └── webhook └── index.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "branch": "dev", 3 | "version": "1.3.2" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/src/images/add-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/add-icon.png -------------------------------------------------------------------------------- /frontend/src/images/loading-gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/loading-gif.gif -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/src/images/icons8-restart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/icons8-restart.gif -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/android-chrome-384x384.png -------------------------------------------------------------------------------- /frontend/src/images/icons8-restart-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/icons8-restart-24.png -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root, #root>div { 2 | height: 100% 3 | } 4 | 5 | code { 6 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 7 | monospace; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | const path = require("path"); 4 | 5 | /* GET users listing. */ 6 | router.get("/", function (req, res, next) { 7 | res.sendFile(path.join(__dirname, "/production/index.html")); 8 | }); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.15.0-slim 2 | 3 | ARG BUILD 4 | 5 | ARG FILEDIR 6 | 7 | ENV BUILD=${BUILD} 8 | 9 | ENV FILEDIR=${FILEDIR} 10 | 11 | ENV GENERATE_SOURCEMAP=false 12 | 13 | COPY . /PrerollPlus 14 | 15 | WORKDIR /PrerollPlus/frontend 16 | 17 | RUN npm ci && npm run build 18 | 19 | WORKDIR /PrerollPlus 20 | 21 | RUN npm ci 22 | 23 | ENTRYPOINT ["npm", "start"] 24 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /frontend/node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /frontend/production 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /backend/save.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | 5 | router.post("/", function (req, res, next) { 6 | var fileData = JSON.stringify(req.body); 7 | 8 | try { 9 | fs.writeFileSync("/config/settings.js", fileData); 10 | console.info("Settings file saved"); 11 | } catch (err) { 12 | if (err) throw err; 13 | } 14 | 15 | res.send(); 16 | }); 17 | 18 | module.exports = router; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prerollplus", 3 | "private": true, 4 | "scripts": { 5 | "start": "nodemon ./bin/www" 6 | }, 7 | "dependencies": { 8 | "axios": "^1.5.1", 9 | "chokidar": "^4.0.0", 10 | "cookie-parser": "~1.4.4", 11 | "express": "^4.16.1", 12 | "http-errors": "~1.6.3", 13 | "mime-types": "^2.1.35", 14 | "multer": "^1.4.5-lts.1", 15 | "nodemon": "^3.1.9", 16 | "uuid": "^8.3.2", 17 | "winston": "^3.16.0", 18 | "winston-daily-rotate-file": "^5.0.0", 19 | "ws": "^8.18.0", 20 | "xml-js": "^1.6.11" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./components/App/App"; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById("root")); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | 13 | // If you want to start measuring performance in your app, pass a function 14 | // to log results (for example: reportWebVitals(console.log)) 15 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 16 | // reportWebVitals(console.log); 17 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Preroll+", 3 | "name": "Preroll Plus - Plex Preroll Manager", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "android-chrome-384x384.png", 17 | "type": "image/png", 18 | "sizes": "384x384" 19 | }, 20 | { 21 | "src": "apple-touch-icon.png", 22 | "type": "image/png", 23 | "sizes": "180x180" 24 | } 25 | ], 26 | "start_url": ".", 27 | "display": "standalone", 28 | "theme_color": "#000000", 29 | "background_color": "#ffffff" 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/Calendar/Calendar.css: -------------------------------------------------------------------------------- 1 | /* Kill all default FullCalendar underlines and blue links */ 2 | .fc a { 3 | text-decoration: none !important; 4 | color: inherit !important; 5 | } 6 | 7 | /* Day-of-the-week header cells (Sun, Mon, Tue, etc.) */ 8 | .fc .fc-col-header-cell { 9 | background-color: #e2e2e2 !important; /* Light mode: medium-light gray */ 10 | color: #000000 !important; /* Black text for readability */ 11 | } 12 | 13 | .dark-mode .fc .fc-col-header-cell { 14 | background-color: #2c2c2c !important; /* Dark mode: medium-dark gray */ 15 | color: #e0e0e0 !important; /* Light gray text */ 16 | } 17 | 18 | /* Optional: Improve text padding/alignment in headers */ 19 | .fc .fc-col-header-cell-cushion { 20 | padding: 8px 4px !important; 21 | } -------------------------------------------------------------------------------- /backend/websocket.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require("ws"); 2 | 3 | let wss; 4 | 5 | function initializeWebSocket(server) { 6 | wss = new WebSocket.Server({ server }); 7 | 8 | wss.on("connection", (ws) => { 9 | console.log("Client connected"); 10 | ws.on("message", (message) => { 11 | console.log("Received message from client:", message); 12 | }); 13 | }); 14 | 15 | return wss; 16 | } 17 | 18 | function broadcastUpdate() { 19 | console.info("Sending update"); 20 | if (wss && wss.clients) { 21 | wss.clients.forEach((client) => { 22 | if (client.readyState === WebSocket.OPEN) { 23 | client.send("update-config"); 24 | } 25 | }); 26 | } else { 27 | console.error("Could not send update"); 28 | } 29 | } 30 | 31 | module.exports = { initializeWebSocket, broadcastUpdate }; 32 | -------------------------------------------------------------------------------- /frontend/src/components/Buckets/Buckets.css: -------------------------------------------------------------------------------- 1 | .sort-buttons-group { 2 | display: inline-flex; 3 | gap: 10px; 4 | align-items: center; 5 | padding-left: 10px; 6 | padding-top: 20px; 7 | } 8 | 9 | .sort-button { 10 | background-color: #e0e0e0 !important; 11 | border: 1px solid #cccccc !important; 12 | border-radius: 8px !important; 13 | padding: 6px 12px !important; 14 | box-shadow: none !important; 15 | min-height: 40px; 16 | } 17 | 18 | .dark-mode .sort-button { 19 | background-color: #000000 !important; 20 | border: 1px solid #666666 !important; 21 | } 22 | 23 | .dark-mode .arrow-icon { 24 | filter: invert(100%) brightness(1.2) !important; 25 | } 26 | 27 | .sort-button.active { 28 | background-color: #c0c0c0 !important; 29 | } 30 | .dark-mode .sort-button.active { 31 | background-color: #606060 !important; 32 | } 33 | 34 | .sort-button:hover { 35 | background-color: #b0b0b0 !important; 36 | } 37 | .form-content.dark-mode .sort-button:hover { 38 | background-color: #707070 !important; 39 | } -------------------------------------------------------------------------------- /frontend/src/components/Settings/Settings.css: -------------------------------------------------------------------------------- 1 | .div-seperator { 2 | padding-bottom: 0.75rem; 3 | } 4 | 5 | .server-list { 6 | width: 100% !important; 7 | } 8 | 9 | .sched-style { 10 | width: 100px !important; 11 | } 12 | 13 | .form-content.dark-mode .repeat-icon { 14 | filter: invert(100%); 15 | } 16 | 17 | .form-content.dark-mode .btn-outline-light { 18 | --bs-btn-hover-bg: rgb(54, 54, 54); 19 | --bs-btn-active-bg: black; 20 | --bs-btn-border-color: #9f9f9f; 21 | } 22 | 23 | .form-content.dark-mode .form-control { 24 | color: white; 25 | background-color: rgb(46, 46, 46); 26 | border-color: #898989; 27 | } 28 | 29 | .form-content.dark-mode .form-select { 30 | color: white; 31 | background-color: rgb(46, 46, 46); 32 | border-color: #898989; 33 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3E%3C/svg%3E"); 34 | } 35 | 36 | .form-content.dark-mode .image-info { 37 | filter: invert(100%); 38 | } -------------------------------------------------------------------------------- /backend/thumb.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var axios = require("axios").default; 4 | var parser = require("xml-js"); 5 | 6 | router.post("/", async function (req, res, next) { 7 | var url = "https://plex.tv/users/account"; 8 | 9 | await axios 10 | .get(url, { params: { "X-Plex-Token": req.body.token } }) 11 | 12 | .then(function (response) { 13 | console.info("Retrieving thumbnail from Plex account"); 14 | 15 | let thumb = parser.xml2js(response.data, { compact: true, spaces: 4 }).user._attributes.thumb; 16 | let username = parser.xml2js(response.data, { compact: true, spaces: 4 }).user._attributes.username; 17 | let email = parser.xml2js(response.data, { compact: true, spaces: 4 }).user._attributes.email; 18 | 19 | let data = { thumb, username, email }; 20 | 21 | res.send(JSON.stringify(data)); 22 | }) 23 | .catch(function (error) { 24 | if (error.request) { 25 | console.error("Could not connect to the Plex sewrver"); 26 | res.status(403).send("Could not connect to the Plex server"); 27 | } 28 | }); 29 | }); 30 | 31 | module.exports = router; 32 | -------------------------------------------------------------------------------- /backend/directory.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | 5 | router.post("/", function (req, res, next) { 6 | try { 7 | console.info("Entering Directory: ", req.body.dir); 8 | dirData = fs.readdirSync(req.body.dir, { withFileTypes: true }); 9 | dirRet = []; 10 | console.info("Directory read"); 11 | 12 | dirData.sort((a, b) => { 13 | const aIsDir = a.isDirectory(); 14 | const bIsDir = b.isDirectory(); 15 | 16 | if (aIsDir && !bIsDir) { 17 | return -1; // a comes before b 18 | } else if (!aIsDir && bIsDir) { 19 | return 1; // b comes before a 20 | } else { 21 | const aStr = String(a.name).toLowerCase(); 22 | const bStr = String(b.name).toLowerCase(); 23 | return aStr.localeCompare(bStr); // regular alphabetical sort 24 | } 25 | }); 26 | 27 | dirData.forEach((file) => { 28 | dirRet.push({ name: file.name, isDir: file.isDirectory() }); 29 | }); 30 | 31 | res.send(JSON.stringify(dirRet)); 32 | console.debug(`Directory info: ${JSON.stringify(dirRet)}`); 33 | } catch (err) { 34 | console.error("Directory not found", err.message.split("\n")[0]); // Send only the first line 35 | res.status(200).send(JSON.stringify(null)); 36 | } 37 | }); 38 | 39 | module.exports = router; 40 | -------------------------------------------------------------------------------- /backend/clearcache.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | var path = require("path"); 5 | 6 | const cacheDir = path.join("/", "config", "cache"); 7 | 8 | function clearCache() { 9 | if (!fs.existsSync(cacheDir)) { 10 | console.info(`Cache directory does not exist: ${cacheDir}`); 11 | return { success: false, code: 404, message: "Cache directory does not exist" }; 12 | } 13 | 14 | try { 15 | const files = fs.readdirSync(cacheDir); 16 | files.forEach((file) => { 17 | const filePath = path.join(cacheDir, file); 18 | try { 19 | fs.unlinkSync(filePath); 20 | console.info(`Deleted: ${filePath}`); 21 | } catch (err) { 22 | console.error(`Failed to delete ${filePath}:`, err.message); 23 | throw err; // bubble up to outer catch 24 | } 25 | }); 26 | console.info("Cache directory cleared."); 27 | return { success: true, code: 200, message: "Cache directory cleared" }; 28 | } catch (err) { 29 | console.error("Error reading/deleting cache directory:", err.message); 30 | return { success: false, code: 500, message: err.message }; 31 | } 32 | } 33 | 34 | router.get("/", function (req, res, next) { 35 | const result = clearCache(); 36 | res.status(result.code).json(result); 37 | }); 38 | 39 | module.exports = router; 40 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "dependencies": { 5 | "@fullcalendar/core": "^6.1.19", 6 | "@fullcalendar/daygrid": "^6.1.19", 7 | "@fullcalendar/react": "^6.1.19", 8 | "@testing-library/jest-dom": "^5.16.4", 9 | "axios": "^1.7.9", 10 | "bootstrap": "^5.2.3", 11 | "bootstrap-icons": "^1.10.3", 12 | "cross-env": "^7.0.3", 13 | "date-fns": "^4.1.0", 14 | "plex-oauth": "^2.1.0", 15 | "react": "^18.1.0", 16 | "react-bootstrap": "^2.7.0", 17 | "react-dom": "^18.1.0", 18 | "react-router-bootstrap": "^0.26.2", 19 | "react-router-dom": "^6.0.0", 20 | "react-scripts": "^5.0.1", 21 | "uuid": "^9.0.0", 22 | "web-vitals": "^2.1.4" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "cross-env BUILD_PATH='./production' react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/streamer.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const mime = require("mime-types"); 5 | const router = express.Router(); 6 | 7 | router.get("/*", (req, res) => { 8 | const filename = req.params[0]; 9 | const filepath = path.join("/", filename); 10 | 11 | // Check if file exists 12 | if (!fs.existsSync(filepath)) { 13 | return res.status(404).send("File not found"); 14 | } 15 | 16 | const stat = fs.statSync(filepath); 17 | const fileSize = stat.size; 18 | const range = req.headers.range; 19 | 20 | const contentType = mime.lookup(filepath) || "application/octet-stream"; // Default to generic binary stream 21 | 22 | if (range) { 23 | const parts = range.replace(/bytes=/, "").split("-"); 24 | const start = parseInt(parts[0], 10); 25 | const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; 26 | const chunkSize = end - start + 1; 27 | const file = fs.createReadStream(filepath, { start, end }); 28 | const head = { 29 | "Content-Range": `bytes ${start}-${end}/${fileSize}`, 30 | "Accept-Ranges": "bytes", 31 | "Content-Length": chunkSize, 32 | "Content-Type": contentType, 33 | }; 34 | 35 | res.writeHead(206, head); 36 | file.pipe(res); 37 | } else { 38 | const head = { 39 | "Content-Length": fileSize, 40 | "Content-Type": contentType, 41 | }; 42 | 43 | res.writeHead(200, head); 44 | fs.createReadStream(filepath).pipe(res); 45 | } 46 | }); 47 | 48 | module.exports = router; 49 | -------------------------------------------------------------------------------- /backend/logger.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | const { createLogger, format, transports } = require("winston"); 5 | const DailyRotateFile = require("winston-daily-rotate-file"); 6 | 7 | function setLogLevel() { 8 | let level = "info"; 9 | try { 10 | if (fs.existsSync("/config/settings.js")) { 11 | const settings = JSON.parse(fs.readFileSync("/config/settings.js")); 12 | level = settings.settings?.logLevel === "1" ? "debug" : "info"; 13 | } 14 | } catch (err) { 15 | console.error("Error reading or parsing settings.js:", err); 16 | } 17 | 18 | const logger = createLogger({ 19 | level: level, 20 | format: format.combine( 21 | format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 22 | format.printf((info) => `[${info.timestamp}] [${info.level}] ${info.message}`) 23 | ), 24 | transports: [ 25 | new transports.Console(), 26 | new DailyRotateFile({ 27 | filename: "/config/logs/prerollplus-%DATE%.log", 28 | datePattern: "YYYY-MM-DD", 29 | maxSize: "20m", 30 | maxFiles: "5", 31 | }), 32 | ], 33 | }); 34 | 35 | // Override default console methods 36 | console.log = (...args) => logger.log("info", args.join(" ")); 37 | console.info = (...args) => logger.log("info", args.join(" ")); 38 | console.error = (...args) => logger.log("error", args.join(" ")); 39 | console.warn = (...args) => logger.log("warn", args.join(" ")); 40 | console.debug = (...args) => logger.log("debug", args.join(" ")); 41 | 42 | console.info(`Log level set to "${level}"`); 43 | } 44 | 45 | setLogLevel(); 46 | 47 | router.get("/", function (req, res, next) { 48 | setLogLevel(); 49 | res.status(200).send(); 50 | }); 51 | 52 | module.exports = router; 53 | -------------------------------------------------------------------------------- /backend/migrate.js: -------------------------------------------------------------------------------- 1 | var axios = require("axios").default; 2 | 3 | async function updateSequences(temp) { 4 | if (!temp || !Array.isArray(temp.sequences)) { 5 | console.warn("No sequences found to migrate."); 6 | return temp; 7 | } 8 | 9 | const today = new Date(); 10 | today.setHours(0, 0, 0, 0); 11 | const currentYear = today.getFullYear(); 12 | 13 | // helper delay 14 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 15 | 16 | for (let i = 0; i < temp.sequences.length; i++) { 17 | const sequence = temp.sequences[i]; 18 | const url = `https://date.nager.at/api/v3/publicholidays/${currentYear}/${sequence.country}`; 19 | 20 | try { 21 | const response = await axios.get(url, { timeout: 10000 }); 22 | 23 | const publicHolidays = (response.data || []).filter((h) => Array.isArray(h.types) && h.types.includes("Public")); 24 | 25 | const match = publicHolidays.find((h) => h.name === sequence.holiday); 26 | 27 | if (match) { 28 | sequence.states = match.counties === null ? "All" : match.counties.join(", "); 29 | sequence.type = sequence.type ?? "1"; 30 | sequence.holidayDate = match.date; 31 | sequence.holidaySource = sequence.holidaySource ?? "1"; 32 | 33 | console.info(`Migrated sequence "${sequence.name}" (${sequence.id}) -> holiday ${match.date}`); 34 | } else { 35 | console.info(`No holiday match for sequence "${sequence.name}" in country ${sequence.country}`); 36 | } 37 | } catch (error) { 38 | console.error( 39 | `Error fetching holidays for country=${sequence.country} (sequence="${sequence.name}"):`, 40 | error.message 41 | ); 42 | } 43 | 44 | // Add a 100ms delay before the next iteration to avoid hammering the API 45 | if (i < temp.sequences.length - 1) { 46 | await delay(100); 47 | } 48 | } 49 | 50 | return temp; 51 | } 52 | 53 | exports.updateSequences = updateSequences; 54 | -------------------------------------------------------------------------------- /frontend/src/components/Sequences/Sequences.css: -------------------------------------------------------------------------------- 1 | .row-custom { 2 | padding-left: 10px; 3 | padding-top: 20px; 4 | } 5 | 6 | .card.card-global { 7 | width: 10rem; 8 | height: 8rem; 9 | background-color: #f8f9fa; 10 | } 11 | 12 | .card.card-default { 13 | border: 1px solid black; 14 | } 15 | 16 | .card.card-error { 17 | border: 2px solid red; 18 | } 19 | 20 | .card.card-unselected { 21 | cursor: pointer; 22 | border: 1px solid black; 23 | } 24 | 25 | .card-global.dark-mode { 26 | background-color: #2c2c2c; 27 | } 28 | 29 | .card-global.dark-mode .plus-image { 30 | filter: invert(80%); 31 | } 32 | 33 | .card-default.dark-mode { 34 | border: 1px solid #898989 !important; 35 | } 36 | 37 | .card-error.dark-mode { 38 | border: 2px solid red; 39 | } 40 | 41 | .card-unselected.dark-mode { 42 | border: 1px solid #898989; 43 | } 44 | 45 | .modal.dark-mode .modal-content { 46 | background-color: #363636; 47 | } 48 | 49 | .sort-buttons-group { 50 | display: inline-flex; 51 | gap: 10px; 52 | align-items: center; 53 | padding-left: 10px; 54 | padding-top: 20px; 55 | } 56 | 57 | .sort-button { 58 | background-color: #e0e0e0 !important; 59 | border: 1px solid #cccccc !important; 60 | border-radius: 8px !important; 61 | padding: 6px 12px !important; 62 | box-shadow: none !important; 63 | min-height: 40px; 64 | } 65 | 66 | .dark-mode .sort-button { 67 | background-color: #000000 !important; 68 | border: 1px solid #666666 !important; 69 | } 70 | 71 | .dark-mode .arrow-icon { 72 | filter: invert(100%) brightness(1.2) !important; 73 | } 74 | 75 | .sort-button.active { 76 | background-color: #c0c0c0 !important; 77 | } 78 | .dark-mode .sort-button.active { 79 | background-color: #606060 !important; 80 | } 81 | 82 | .sort-button:hover { 83 | background-color: #b0b0b0 !important; 84 | } 85 | .form-content.dark-mode .sort-button:hover { 86 | background-color: #707070 !important; 87 | } -------------------------------------------------------------------------------- /frontend/src/components/App/App.css: -------------------------------------------------------------------------------- 1 | .navbar-row { 2 | border-bottom: solid; 3 | border-bottom-color: black; 4 | position: sticky; 5 | top: 0; 6 | z-index: 1000; 7 | box-shadow: 0 3px 0px 0px #e93663; 8 | } 9 | 10 | .navbar-content { 11 | background-color: #ddd; 12 | padding-left: 15px !important; 13 | padding-right: 15px !important; 14 | } 15 | 16 | .img-thumbnail { 17 | height: 40px; 18 | width: 40px; 19 | padding: 0 !important; 20 | } 21 | 22 | .nav-dropdown-update { 23 | color: red; 24 | } 25 | 26 | .main-row { 27 | padding-left: 30px; 28 | padding-top: 30px; 29 | padding-right: 30px; 30 | } 31 | 32 | .moon-icon { 33 | height: 20px; 34 | width: 20px; 35 | cursor: pointer; 36 | } 37 | 38 | /* Dark Mode Styles */ 39 | 40 | body.dark-mode { 41 | background-color: #121212; /* Dark background */ 42 | color: white; /* Light text */ 43 | } 44 | 45 | body.dark-mode a { 46 | color: rgb(185, 185, 185); 47 | } 48 | 49 | .navbar-row.dark-mode { 50 | border-bottom-color: rgb(161, 161, 161); 51 | } 52 | 53 | .navbar-content.dark-mode { 54 | background-color: #333; /* Darker navbar */ 55 | color: white; /* Light text */ 56 | } 57 | 58 | /* Ensure links are styled correctly in dark mode */ 59 | .navbar-content.dark-mode .nav-link { 60 | color: #b9b9b9; /* White text for navbar links and brand */ 61 | } 62 | 63 | .navbar-content.dark-mode .navbar-brand { 64 | color: white; /* White text for navbar links and brand */ 65 | } 66 | 67 | .navbar-content.dark-mode .nav-link:hover { 68 | color: #5c5c5c; /* Change hover color if desired */ 69 | } 70 | 71 | .navbar-content.dark-mode .nav-link.active { 72 | color: white; /* Change hover color if desired */ 73 | } 74 | 75 | .navbar-content.dark-mode .moon-icon { 76 | filter: invert(100%); 77 | } 78 | 79 | .navbar-content.dark-mode .logout-icon { 80 | filter: invert(100%); 81 | } 82 | 83 | .modal.dark-mode .modal-content { 84 | background-color: #363636; 85 | } -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require("../app"); 8 | var debug = require("debug")("prerollplus:server"); 9 | var http = require("http"); 10 | const { initializeWebSocket } = require("../backend/websocket"); 11 | 12 | /** 13 | * Get port from environment and store in Express. 14 | */ 15 | 16 | var port = normalizePort(process.env.PORT || "4949"); 17 | app.set("port", port); 18 | 19 | /** 20 | * Create HTTP server. 21 | */ 22 | 23 | var server = http.createServer(app); 24 | 25 | initializeWebSocket(server); 26 | 27 | /** 28 | * Listen on provided port, on all network interfaces. 29 | */ 30 | 31 | server.listen(port); 32 | server.on("error", onError); 33 | server.on("listening", onListening); 34 | 35 | /** 36 | * Normalize a port into a number, string, or false. 37 | */ 38 | 39 | function normalizePort(val) { 40 | var port = parseInt(val, 10); 41 | 42 | if (isNaN(port)) { 43 | // named pipe 44 | return val; 45 | } 46 | 47 | if (port >= 0) { 48 | // port number 49 | return port; 50 | } 51 | 52 | return false; 53 | } 54 | 55 | /** 56 | * Event listener for HTTP server "error" event. 57 | */ 58 | 59 | function onError(error) { 60 | if (error.syscall !== "listen") { 61 | throw error; 62 | } 63 | 64 | var bind = typeof port === "string" ? "Pipe " + port : "Port " + port; 65 | 66 | // handle specific listen errors with friendly messages 67 | switch (error.code) { 68 | case "EACCES": 69 | console.error(bind + " requires elevated privileges"); 70 | process.exit(1); 71 | break; 72 | case "EADDRINUSE": 73 | console.error(bind + " is already in use"); 74 | process.exit(1); 75 | break; 76 | default: 77 | throw error; 78 | } 79 | } 80 | 81 | /** 82 | * Event listener for HTTP server "listening" event. 83 | */ 84 | 85 | function onListening() { 86 | var addr = server.address(); 87 | var bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port; 88 | debug("Listening on " + bind); 89 | } 90 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 30 | Preroll+ 31 | 32 | 33 | 34 |
35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/Sequence/Sequence.css: -------------------------------------------------------------------------------- 1 | .card.card-default { 2 | border: 1px solid black; 3 | } 4 | 5 | .card.card-error { 6 | border: 2px solid red !important; 7 | } 8 | 9 | .card.card-current { 10 | border: 2px solid #3A7BD5 !important; 11 | } 12 | 13 | .card.card-unselected { 14 | cursor: pointer; 15 | border: 1px solid black; 16 | } 17 | 18 | .card-header.header-custom { 19 | background-color: #f8f9fa; 20 | padding: 5px; 21 | } 22 | 23 | .icon-clickable { 24 | cursor: pointer; 25 | } 26 | 27 | .icon-noclick { 28 | cursor: default; 29 | } 30 | 31 | .sub-custom { 32 | height: 4rem; 33 | padding-left: 5px; 34 | padding-right: 5px; 35 | } 36 | 37 | .card-footer.footer-custom { 38 | background-color: #f8f9fa; 39 | padding: 5px; 40 | } 41 | 42 | .div-custom { 43 | display: block; 44 | flex-direction: column; 45 | justify-content: flex-end; 46 | text-align: left; 47 | font-size: 12px; 48 | height: 100%; 49 | padding-left: 5px; 50 | white-space: nowrap; 51 | overflow: hidden; 52 | text-overflow: ellipsis; 53 | max-width: 100%; 54 | width: 100%; 55 | } 56 | 57 | 58 | .edit-button { 59 | margin: 0; 60 | padding: 0; 61 | border-width: 0px; 62 | background-color: inherit; 63 | } 64 | 65 | .card-header.header-custom.dark-mode .icon-clickable, 66 | .card-footer.footer-custom.dark-mode .icon-clickable, 67 | .card-header.header-custom.dark-mode .icon-noclick, 68 | .card-footer.footer-custom.dark-mode .icon-noclick { 69 | filter: invert(100%); 70 | } 71 | 72 | .card-global.dark-mode { 73 | background-color: #2c2c2c; 74 | } 75 | 76 | .card-global.dark-mode .plus-image { 77 | filter: invert(80%); 78 | } 79 | 80 | .card-default.dark-mode { 81 | border: 1px solid #898989; 82 | } 83 | 84 | .card-error.dark-mode { 85 | border: 2px solid red !important; 86 | } 87 | 88 | .card-current.dark-mode { 89 | border: 2px solid #3A7BD5 !important; 90 | } 91 | 92 | .card-unselected.dark-mode { 93 | border: 1px solid #898989; 94 | } 95 | 96 | .header-custom.dark-mode, 97 | .footer-custom.dark-mode { 98 | background-color: #2c2c2c; 99 | } 100 | -------------------------------------------------------------------------------- /frontend/src/components/CreateSeq/CreateSeq.css: -------------------------------------------------------------------------------- 1 | .div-seperator { 2 | padding-bottom: 0.75rem; 3 | } 4 | 5 | .listgroup-custom-s { 6 | border: none !important; 7 | } 8 | 9 | .div-font { 10 | font-size: 12px; 11 | } 12 | 13 | .form-content .form-select { 14 | width: 65px; 15 | } 16 | 17 | .card-custom { 18 | width: 22rem; 19 | background-color: white; 20 | border-radius: 0; 21 | } 22 | 23 | .card-body-custom { 24 | height: 500px; 25 | overflow-y: auto; 26 | margin: 0; 27 | } 28 | 29 | .listgroup-header { 30 | color: rgb(90, 90, 90) !important; 31 | background-color: white !important; 32 | font-size: 14px !important; 33 | } 34 | 35 | .listgroup-arrow { 36 | height: 25%; 37 | padding: 2px !important; 38 | } 39 | 40 | .listgroup-arrow-div { 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | height: 100%; 45 | } 46 | 47 | .directory-loc { 48 | font-size: 11px; 49 | color: gray; 50 | } 51 | 52 | .form-content.dark-mode .listgroup-custom-b, 53 | .form-content.dark-mode .listgroup-custom-s { 54 | color: white; 55 | background-color: rgb(46, 46, 46); 56 | } 57 | 58 | .form-content.dark-mode .listgroup-custom-active { 59 | color: white; 60 | background-color: rgb(94, 94, 94); 61 | border: 0; 62 | } 63 | 64 | .form-content.dark-mode .listgroup-header { 65 | color: rgb(172, 172, 172) !important; 66 | background-color: rgb(46, 46, 46) !important; 67 | font-size: 14px !important; 68 | } 69 | 70 | .form-content.dark-mode .card-custom { 71 | background-color: rgb(46, 46, 46); 72 | } 73 | 74 | .form-content.dark-mode .form-control { 75 | color: white; 76 | background-color: rgb(46, 46, 46); 77 | border-color: #898989; 78 | } 79 | 80 | .form-content.dark-mode .arrow-icon { 81 | filter: invert(100%); 82 | } 83 | 84 | .form-content.dark-mode .listgroup-arrow { 85 | background-color: rgb(46, 46, 46); 86 | } 87 | 88 | .form-content.dark-mode .directory-loc { 89 | font-size: 11px; 90 | color: rgb(206, 206, 206); 91 | } 92 | 93 | .form-content.dark-mode .image-info { 94 | filter: invert(100%); 95 | } 96 | 97 | .modal.dark-mode .modal-content { 98 | background-color: #363636; 99 | } -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var createError = require("http-errors"); 2 | var express = require("express"); 3 | var path = require("path"); 4 | var cookieParser = require("cookie-parser"); 5 | 6 | var logger = require("./backend/logger"); 7 | var webhookRouter = require("./webhook/index"); 8 | var uiRouter = require("./frontend/index"); 9 | var load = require("./backend/load"); 10 | var save = require("./backend/save"); 11 | var thumb = require("./backend/thumb"); 12 | var settings = require("./backend/settings"); 13 | var directory = require("./backend/directory"); 14 | var streamer = require("./backend/streamer"); 15 | var monitor = require("./backend/monitor"); 16 | var holiday = require("./backend/holiday"); 17 | var clearCache = require("./backend/clearcache"); 18 | 19 | var app = express(); 20 | 21 | // logger.token("customDate", function () { 22 | // var current_ob = new Date(); 23 | // var date = "[" + current_ob.toLocaleDateString("en-CA") + " " + current_ob.toLocaleTimeString("en-GB") + "]"; 24 | // return date; 25 | // }); 26 | // app.use(logger(":customDate [INFO] :method :url - Status: :status")); 27 | app.use(express.json()); 28 | app.use(express.urlencoded({ extended: true })); 29 | app.use(cookieParser()); 30 | app.use(express.static(path.join(__dirname, "frontend/production"))); 31 | 32 | app.use("/", uiRouter); 33 | app.use("/backend/logger", logger); 34 | app.use("/backend/load", load); 35 | app.use("/backend/save", save); 36 | app.use("/backend/thumb", thumb); 37 | app.use("/backend/settings", settings); 38 | app.use("/backend/directory", directory); 39 | app.use("/backend/streamer", streamer); 40 | app.use("/backend/monitor", monitor); 41 | app.use("/backend/holiday", holiday); 42 | app.use("/backend/clearcache", clearCache); 43 | 44 | app.use("/webhook", webhookRouter); 45 | app.use("/*", uiRouter); 46 | 47 | // catch 404 and forward to error handler 48 | app.use(function (req, res, next) { 49 | next(createError(404)); 50 | }); 51 | 52 | // error handler 53 | app.use(function (err, req, res, next) { 54 | console.error(err); 55 | // set locals, only providing error in development 56 | res.locals.message = err.message; 57 | res.locals.error = req.app.get("env") === "development" ? err : {}; 58 | 59 | // render the error page 60 | res.status(err.status || 500); 61 | res.render("error"); 62 | }); 63 | 64 | module.exports = app; 65 | -------------------------------------------------------------------------------- /frontend/src/components/Bucket/Bucket.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Card from "react-bootstrap/Card"; 3 | import Xclose from "bootstrap-icons/icons/x-square.svg"; 4 | import Edit from "bootstrap-icons/icons/pencil-square.svg"; 5 | import Row from "react-bootstrap/Row"; 6 | import Col from "react-bootstrap/Col"; 7 | import Image from "react-bootstrap/Image"; 8 | import "../Sequence/Sequence.css"; 9 | 10 | export default class Bucket extends Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | id: "", 15 | active: true, 16 | }; 17 | 18 | this.state.id = this.props.id; 19 | this.handleClick = this.handleClick.bind(this.props.id); 20 | } 21 | 22 | handleClick = (e) => { 23 | this.props.click(e.currentTarget.value); 24 | }; 25 | 26 | handleDelete = () => { 27 | this.props.delete(this.state.id); 28 | }; 29 | 30 | render() { 31 | return ( 32 | 40 | 41 | {this.props.isEdit || this.props.isCreating ? ( 42 | Close 43 | ) : ( 44 | Close 45 | )} 46 | 47 | 48 | {this.props.bucket} 49 | 50 | 51 | 52 | 53 | 54 |
55 | {this.props.isEdit || this.props.isCreating ? ( 56 | Edit 57 | ) : ( 58 | 61 | )} 62 |
63 | 64 |
65 |
66 |
67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Preroll Plus 2 | 3 | Preroll Plus is a dynamic preroll updater and scheduler. This app bypasses the limitations Plex has for combining random and sequential prerolls (using the "," and ";" delimiters). It works by using Plex webhooks to update the preroll string in Plex whenever a movie is started, allowing file sets that require randomization to remain randomized while maintaining a broader sequence. 4 | 5 | For example: 6 | 7 | Plex allows files delimited by commas to play in sequence: 8 | 9 | "preroll1,preroll2,preroll3,preroll4" 10 | 11 | This will play preroll1, followed by preroll2, followed by preroll3, etc. Four prerolls are played. 12 | 13 | Plex also allows files delimited by semi-colons to play randomly: 14 | 15 | "preroll1;preroll2;preroll3;preroll4" 16 | 17 | In this instance Plex will randomly choose **one** of the four prerolls and play it only. 18 | 19 | What you **cannot** do with Plex is create a list of prerolls that combine commas and semi-colons. 20 | 21 | For example: 22 | 23 | "preroll1;preroll2,preroll3;preroll4" 24 | 25 | The intention would be to randomly play either preroll1 or preroll2, and then randomly play preroll3 or preroll4 thus playing two prerolls total. 26 | 27 | #### Solution 28 | 29 | Preroll Plus replaces semi-colon lists with "buckets" and comma lists with "sequences". 30 | 31 | You then create sequences (with or without a schedule) that contain a sequence of buckets. A file in each bucket will play randomly and then move on to the next bucket in a sequence. 32 | 33 | Since you can create as many buckets as you'd like, this can generate an infinite amount of combinations as you desire. 34 | 35 | Scheduled sequences will automatically assert on the first day the sequence starts. Fall back to a default unscheduled sequence when no scheduled sequenced are in their timeframe. 36 | 37 | ## Getting Started 38 | 39 | ### Information 40 | 41 | - [Documentation](https://github.com/chadwpalm/PrerollPlus/wiki) 42 | - [Donate](https://www.buymeacoffee.com/lumunarr) 43 | 44 | ### Features 45 | 46 | - Easy to use web-based graphical interface that is also mobile friendly. 47 | - Combine "buckets" and "sequences" to generate your desired preroll functionality. 48 | - Sequences can be scheduled to change throughout the year. 49 | - Run a schedule based on a holiday leveraging one of the largest holiday calendar APIs. 50 | - Prioritize schedules so that you can layer schedules adding more power to what sequence plays for that day. 51 | - Can be run natively or in a Docker container. 52 | 53 | ### Support 54 | 55 | - [Discussions](https://github.com/chadwpalm/PrerollPlus/discussions) 56 | - [Issues](https://github.com/chadwpalm/PrerollPlus/issues) 57 | - [Discord](https://discord.gg/sXse6H8fMF) 58 | 59 | ### Licenses 60 | 61 | - [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) 62 | - Copyright 2025 63 | 64 | _Preroll Plus is not affiliated or endorsed by Plex Inc._ 65 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | 3 | agent any 4 | 5 | environment { 6 | REPO="chadwpalm" 7 | IMAGE_NAME="prerollplus" 8 | BUILD_CRED=credentials('c8678c85-1f8d-4dc0-b9b0-e7fe12d6a24a') 9 | } 10 | 11 | options { 12 | timeout (time: 10, unit: 'MINUTES') 13 | buildDiscarder (logRotator (numToKeepStr: '3')) 14 | } 15 | 16 | stages { 17 | stage('Login') { 18 | steps { 19 | withCredentials([usernamePassword(credentialsId: '71aeb696-0670-4267-8db4-8ee74774e051', usernameVariable: 'DOCKER_USERNAME', passwordVariable: 'DOCKER_PASSWORD')]) { 20 | sh ('echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin') 21 | } 22 | } 23 | } 24 | stage('Build Dev') { 25 | when { 26 | branch "develop" 27 | } 28 | steps { 29 | script { 30 | def JSONVersion = readJSON file: "version.json" 31 | def PulledVersion = JSONVersion.version 32 | def BuildNumber = sh ( 33 | script: 'curl https://increment.build/${BUILD_CRED}', 34 | returnStdout: true 35 | ).trim() 36 | def APPVersion = "${PulledVersion}.${BuildNumber}" 37 | sh "docker build --force-rm --pull --build-arg BUILD='${BuildNumber}' -t ${REPO}/${IMAGE_NAME}:develop-${APPVersion} ." 38 | sh "docker tag ${REPO}/${IMAGE_NAME}:develop-${APPVersion} ${REPO}/${IMAGE_NAME}:develop" 39 | sh "docker push ${REPO}/${IMAGE_NAME}:develop-${APPVersion}" 40 | sh "docker push ${REPO}/${IMAGE_NAME}:develop" 41 | sh "docker rmi ${REPO}/${IMAGE_NAME}:develop-${APPVersion}" 42 | } 43 | 44 | } 45 | } 46 | stage('Build Prod') { 47 | when { 48 | branch "main" 49 | } 50 | steps { 51 | script { 52 | def JSONVersion = readJSON file: "version.json" 53 | def PulledVersion = JSONVersion.version 54 | def BuildNumber = sh ( 55 | script: 'curl https://increment.build/${BUILD_CRED}/get', 56 | returnStdout: true 57 | ).trim() 58 | def APPVersion = "${PulledVersion}.${BuildNumber}" 59 | sh "docker build --force-rm --pull --build-arg BUILD='${BuildNumber}' -t ${REPO}/${IMAGE_NAME}:${APPVersion} ." 60 | sh "docker tag ${REPO}/${IMAGE_NAME}:${APPVersion} ${REPO}/${IMAGE_NAME}:latest" 61 | sh "docker push ${REPO}/${IMAGE_NAME}:${APPVersion}" 62 | sh "docker push ${REPO}/${IMAGE_NAME}:latest" 63 | sh "docker rmi ${REPO}/${IMAGE_NAME}:${APPVersion}" 64 | } 65 | } 66 | } 67 | stage('Build Features/Fixes') { 68 | when { 69 | not { 70 | anyOf { 71 | branch "main"; 72 | branch "develop" 73 | } 74 | } 75 | } 76 | steps { 77 | script { 78 | def JSONVersion = readJSON file: "version.json" 79 | def PulledVersion = JSONVersion.version 80 | def BuildNumber = sh ( 81 | script: 'curl https://increment.build/${BUILD_CRED}', 82 | returnStdout: true 83 | ).trim() 84 | def APPVersion = "${PulledVersion}.${BuildNumber}" 85 | sh "docker build --force-rm --pull --build-arg BUILD=${BuildNumber} -t ${REPO}/${IMAGE_NAME}:${BRANCH_NAME}-${APPVersion} ." 86 | sh "docker push ${REPO}/${IMAGE_NAME}:${BRANCH_NAME}-${APPVersion}" 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /frontend/src/components/App/Announce.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Modal from "react-bootstrap/Modal"; 3 | import Form from "react-bootstrap/Form"; 4 | import Button from "react-bootstrap/Button"; 5 | import "./App.css"; 6 | 7 | const Announce = ({ announce, fullscreenAnn, handleCloseAnn, handleDismiss, dismiss, isDarkMode }) => { 8 | return ( 9 | 17 | 18 | Announcement 19 | 20 | 21 | Major Update v1.3.0 - What are my Sequences? 22 |
23 |
24 | One thought occurred to me recently when a Preroll Plus user submitted a request: they wanted to see exactly 25 | which files were being sent to Plex for the current sequence. That thought was, “Why haven’t I thought of this 26 | sooner?” 27 |
28 |
29 | It wasn’t so much about knowing the exact files being played, but about being able to see at a glance which 30 | sequence was active. So I added two new elements to Preroll Plus that do just that! 31 |
32 |
33 | The first is simple: a blue border now appears around the sequence that is being used for that day. Now, with 34 | one quick look, you can instantly see which sequence is active at any given moment. 35 |
36 |
37 | I decided to take it a step further and add a calendar page that shows which sequence will play on each day of 38 | the month. Just open the Calendar page and you’ll see a calendar for the current month, with each day clearly 39 | labeled with the sequence that will be used — based on your schedules and priorities. This lets you verify in 40 | advance whether your schedules and priorities are working the way you intend, eliminating guesswork or having to 41 | wait for the day to arrive. You can also step through the months for the current year and the upcoming year. 42 |
43 |
44 | Major Bug Fix 45 |
46 |
47 | It’s actually a good thing I went through the exercise of adding the calendar, because it revealed a bug in the 48 | code that would have prevented the correct holiday dates for 2026 from working — the app would have kept using 49 | 2025’s dates instead. This is now fixed. 50 |
51 |
52 | We’ve come a long way with Preroll Plus, and I’m excited for whatever features come next! 53 |
54 |
55 | As always, if you have any issues please{" "} 56 | 57 | create an issue on GitHub 58 | 59 | , or if you simply have a question or want to discuss any features you and use the{" "} 60 | 61 | discussion board 62 | 63 | . 64 |
65 |
66 | 75 |
76 |
77 | 80 |
81 |
82 | ); 83 | }; 84 | 85 | export default Announce; 86 | -------------------------------------------------------------------------------- /frontend/src/components/Sequence/Sequence.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Card from "react-bootstrap/Card"; 3 | import Xclose from "bootstrap-icons/icons/x-square.svg"; 4 | import Edit from "bootstrap-icons/icons/pencil-square.svg"; 5 | import Row from "react-bootstrap/Row"; 6 | import Col from "react-bootstrap/Col"; 7 | import Image from "react-bootstrap/Image"; 8 | import "./Sequence.css"; 9 | 10 | export default class Sequence extends Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | id: "", 15 | active: true, 16 | }; 17 | 18 | this.state.id = this.props.id; 19 | this.handleClick = this.handleClick.bind(this.props.id); 20 | } 21 | 22 | handleClick = (e) => { 23 | this.props.click(e.currentTarget.value); 24 | }; 25 | 26 | handleDelete = () => { 27 | this.props.delete(this.state.id); 28 | }; 29 | 30 | render() { 31 | return ( 32 | 44 | 45 | 46 | 47 |
Priority: {this.props.priority || "N/A"}
48 | 49 | 50 | {" "} 51 | {this.props.isEdit || this.props.isCreating ? ( 52 | Close 53 | ) : ( 54 | Close 55 | )} 56 | 57 |
58 |
59 | 60 | {this.props.sequence} 61 | 62 | 63 | 64 | 65 |
66 | {this.props.schedule === "2" ? ( 67 | <>No Schedule 68 | ) : this.props.schedule === "3" ? ( 69 | <>{this.props.holiday} 70 | ) : ( 71 | <> 72 | {new Intl.DateTimeFormat(undefined, { 73 | day: "2-digit", 74 | month: "2-digit", 75 | }).format(new Date(2023, this.props.startMonth - 1, this.props.startDay))} 76 | {" - "} 77 | {new Intl.DateTimeFormat(undefined, { 78 | day: "2-digit", 79 | month: "2-digit", 80 | }).format(new Date(2023, this.props.endMonth - 1, this.props.endDay))} 81 | 82 | )} 83 |
84 | 85 | 86 |
87 | {this.props.isEdit || this.props.isCreating ? ( 88 | Edit 89 | ) : ( 90 | 93 | )} 94 |
95 | 96 |
97 |
98 |
99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /backend/load.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | var os = require("os"); 5 | var uuid = require("uuid").v4; 6 | var updates = require("./migrate.js"); 7 | var axios = require("axios").default; 8 | 9 | var appVersion, branch, UID, GID, build; 10 | 11 | try { 12 | var info = fs.readFileSync("version.json"); 13 | appVersion = JSON.parse(info).version; 14 | branch = JSON.parse(info).branch; 15 | } catch (err) { 16 | console.error("Cannot grab version and branch info", err); 17 | } 18 | 19 | if (process.env.PUID) { 20 | UID = Number(process.env.PUID); 21 | } else { 22 | UID = os.userInfo().uid; 23 | } 24 | 25 | if (process.env.PGID) { 26 | GID = Number(process.env.PGID); 27 | } else { 28 | GID = os.userInfo().gid; 29 | } 30 | 31 | if (process.env.BUILD) { 32 | build = process.env.BUILD; 33 | } else { 34 | build = "Native"; 35 | } 36 | 37 | var fileData = `{"connected": "false","platform":"${ 38 | os.platform 39 | }","uuid":"${uuid()}","version":"${appVersion}","branch":"${branch}","build":"${build}", "sequences": [], "buckets": [],"message":true}`; 40 | 41 | try { 42 | fileData = fs.readFileSync("/config/settings.js"); 43 | var temp = JSON.parse(fileData); 44 | 45 | if (build !== "Native") { 46 | temp.settings.loc = "/prerolls"; 47 | } 48 | 49 | if (temp.api !== "v2") { 50 | console.info('Backing up old settings file to "settings_v1.bak"'); 51 | fs.writeFileSync("/config/settings_v1.bak", JSON.stringify(temp)); 52 | 53 | updates.updateSequences(temp).then((newTemp) => { 54 | newTemp.api = "v2"; 55 | if (newTemp.version !== appVersion || newTemp.build !== build || newTemp.branch !== branch) { 56 | console.info( 57 | "Version updated from", 58 | newTemp.version, 59 | "build", 60 | newTemp.build, 61 | "branch", 62 | newTemp.branch, 63 | "to", 64 | appVersion, 65 | "build", 66 | build, 67 | "branch", 68 | branch 69 | ); 70 | newTemp.version = appVersion; 71 | newTemp.build = build; 72 | newTemp.branch = branch; 73 | newTemp.message = true; 74 | 75 | delete newTemp["token"]; 76 | } 77 | 78 | fs.writeFileSync("/config/settings.js", JSON.stringify(newTemp)); 79 | fs.chownSync("/config/settings.js", UID, GID, (err) => { 80 | if (err) throw err; 81 | }); 82 | console.info(`Config file updated to UID: ${UID} GID: ${GID}`); 83 | console.info("Settings file read"); 84 | }); 85 | } else { 86 | if (temp.version !== appVersion || temp.build !== build || temp.branch !== branch) { 87 | console.info( 88 | "Version updated from", 89 | temp.version, 90 | "build", 91 | temp.build, 92 | "branch", 93 | temp.branch, 94 | "to", 95 | appVersion, 96 | "build", 97 | build, 98 | "branch", 99 | branch 100 | ); 101 | temp.version = appVersion; 102 | temp.build = build; 103 | temp.branch = branch; 104 | temp.message = true; 105 | 106 | delete temp["token"]; 107 | } 108 | 109 | fs.writeFileSync("/config/settings.js", JSON.stringify(temp)); 110 | fs.chownSync("/config/settings.js", UID, GID, (err) => { 111 | if (err) throw err; 112 | }); 113 | console.info(`Config file updated to UID: ${UID} GID: ${GID}`); 114 | console.info("Settings file read"); 115 | } 116 | 117 | if (temp.settings) { 118 | try { 119 | axios 120 | .get("http://localhost:4949/webhook") // Make sure the path is correct 121 | .then((response) => {}) 122 | .catch((error) => {}); 123 | } catch { 124 | console.error("Could not create initial sequence"); 125 | } 126 | } 127 | } catch (err) { 128 | console.info("Settings file not found, creating"); 129 | try { 130 | if (!fs.existsSync("/config")) { 131 | fs.mkdirSync("/config"); 132 | } 133 | fs.writeFileSync("/config/settings.js", fileData); 134 | console.info("Settings file created"); 135 | fs.chownSync("/config/settings.js", UID, GID, (err) => { 136 | if (err) throw err; 137 | }); 138 | console.info(`Config file set to UID: ${UID} GID: ${GID}`); 139 | } catch (err) { 140 | if (err) throw err; 141 | } 142 | } 143 | 144 | router.get("/", function (req, res, next) { 145 | try { 146 | fileData = fs.readFileSync("/config/settings.js"); 147 | console.info("Settings file read"); 148 | } catch (err) { 149 | console.error("Settings file not found"); 150 | } 151 | 152 | res.send(fileData); 153 | }); 154 | 155 | module.exports = router; 156 | -------------------------------------------------------------------------------- /backend/settings.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var axios = require("axios").default; 4 | const https = require("https"); 5 | var parser = require("xml-js"); 6 | 7 | router.post("/", async function (req, res, next) { 8 | var serverList = []; 9 | var finalList = []; 10 | var unauth = false; 11 | 12 | const token = req.body.token; 13 | const plexApiUrl = "https://plex.tv/api/servers"; 14 | 15 | try { 16 | console.info("Retrieving Plex Servers"); 17 | const response = await axios.get(plexApiUrl, { timeout: 10000, params: { "X-Plex-Token": token } }); 18 | const servers = parser.xml2js(response.data, { compact: true, spaces: 4 }).MediaContainer.Server; 19 | 20 | if (Array.isArray(servers)) { 21 | serverList = servers; 22 | } else if (!servers) { 23 | serverList = []; 24 | } else { 25 | serverList.push(servers); 26 | } 27 | 28 | console.debug("Found", serverList.length, "Plex servers"); 29 | 30 | for (let i = 0; i < serverList.length; i++) { 31 | console.debug(`Server ${i + 1} info:`); 32 | console.debug("Name:", serverList[i]._attributes.name); 33 | console.debug("Owned:", serverList[i]._attributes.owned); 34 | console.debug("External Address:", serverList[i]._attributes.address); 35 | console.debug("External Port:", serverList[i]._attributes.port); 36 | console.debug("Local Address:", serverList[i]._attributes.localAddresses); 37 | console.debug("Local Port: 32400"); 38 | } 39 | } catch (error) { 40 | if (error.response && error.response.status === 401) { 41 | unauth = true; 42 | } 43 | console.error("Issue with connection to online Plex account while requesting servers:", error.message); 44 | } 45 | 46 | for (const element of serverList) { 47 | if (element._attributes.owned === "1") { 48 | const localAddresses = element._attributes.localAddresses.split(","); // Split multiple local addresses by commas 49 | 50 | for (const localIP of localAddresses) { 51 | const localUrl = `http://${localIP.trim()}:32400/:/prefs`; // Trim whitespace and form the URL 52 | console.debug("Retrieving Cert from URL:", localUrl); 53 | 54 | try { 55 | const response = await axios.get(localUrl, { timeout: 3000, params: { "X-Plex-Token": token } }); 56 | 57 | let certId = response.data.MediaContainer.Setting.find((id) => id.id === "CertificateUUID"); 58 | 59 | // Push the server info, marking certSuccessful as true if the cert is found 60 | finalList.push({ 61 | name: element._attributes.name, 62 | localIP: localIP.trim(), 63 | remoteIP: element._attributes.address, 64 | port: element._attributes.port, 65 | cert: certId ? certId.value : null, // Only include cert if it exists 66 | certSuccessful: certId !== undefined, // Flag indicating whether cert was found or not 67 | https: false, 68 | }); 69 | } catch (error) { 70 | console.info(`Could not make insecure connection to Plex Server at ${localIP}`); 71 | console.info("Attempting secure connection"); 72 | // Check with secure connection 73 | const localUrl = `https://${localIP.trim()}:32400/:/prefs`; 74 | try { 75 | const agent = new https.Agent({ 76 | rejectUnauthorized: false, // Disable certificate verification 77 | }); 78 | 79 | const response = await axios.get(localUrl, { 80 | timeout: 3000, 81 | params: { "X-Plex-Token": token }, 82 | httpsAgent: agent, 83 | }); 84 | 85 | let certId = response.data.MediaContainer.Setting.find((id) => id.id === "CertificateUUID"); 86 | 87 | // Push the server info, marking certSuccessful as true if the cert is found 88 | finalList.push({ 89 | name: element._attributes.name, 90 | localIP: localIP.trim(), 91 | remoteIP: element._attributes.address, 92 | port: element._attributes.port, 93 | cert: certId ? certId.value : null, // Only include cert if it exists 94 | certSuccessful: certId !== undefined, // Flag indicating whether cert was found or not 95 | https: true, 96 | }); 97 | } catch (error) { 98 | console.error(`Issue with secure connection to Plex Server at ${localIP}:`, error.message); 99 | } 100 | // On failure, still push the server info but set certSuccessful to false 101 | finalList.push({ 102 | name: element._attributes.name, 103 | localIP: localIP.trim(), 104 | remoteIP: element._attributes.address, 105 | port: element._attributes.port, 106 | cert: null, // No cert available on failure 107 | certSuccessful: false, // Mark the cert as unsuccessful 108 | https: false, 109 | }); 110 | } 111 | } 112 | } 113 | } 114 | console.debug("Final List: ", JSON.stringify(finalList)); 115 | res.send(JSON.stringify(finalList)); 116 | }); 117 | 118 | module.exports = router; 119 | -------------------------------------------------------------------------------- /frontend/src/components/Calendar/Calendar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import FullCalendar from "@fullcalendar/react"; 3 | import dayGridPlugin from "@fullcalendar/daygrid"; 4 | import { addMonths, startOfMonth, format, startOfYear } from "date-fns"; 5 | import "./Calendar.css"; 6 | 7 | const today = new Date(); 8 | const start = format(startOfYear(today), "yyyy-MM-dd"); 9 | const end = format(addMonths(startOfYear(today), 24), "yyyy-MM-dd"); 10 | 11 | export default function SequenceCalendar({ events, isDarkMode = false, settings }) { 12 | const [monthEvents, setMonthEvents] = useState([]); 13 | const currentMonthRef = useRef(""); 14 | 15 | const loadMonth = (year, month) => { 16 | const key = `${year}-${month}`; 17 | if (currentMonthRef.current === key) return; 18 | currentMonthRef.current = key; 19 | 20 | fetch(`/webhook/calendar?year=${year}&month=${month}&_=${Date.now()}`) 21 | .then((r) => r.json()) 22 | .then((data) => { 23 | console.log("Fresh data loaded:", year, month, data); 24 | setMonthEvents(data); 25 | }); 26 | }; 27 | 28 | useEffect(() => { 29 | const now = new Date(); 30 | loadMonth(now.getFullYear(), now.getMonth() + 1); 31 | }, []); 32 | 33 | return ( 34 | <> 35 |
44 | Hover over a Sequence name to view its buckets 45 |
46 | { 59 | const currentDate = info.view.calendar.getDate(); 60 | const year = currentDate.getFullYear(); 61 | const month = currentDate.getMonth() + 1; 62 | loadMonth(year, month); 63 | }} 64 | dayCellContent={(arg) => { 65 | const dateStr = arg.date.toISOString().split("T")[0]; 66 | const ev = monthEvents.find((e) => e.date === dateStr); 67 | 68 | const dayNumberColor = isDarkMode ? "#ffffff" : "#000000"; 69 | const textColor = isDarkMode ? "#e0e0e0" : "#000000"; 70 | const bgColor = isDarkMode ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.06)"; 71 | 72 | return ( 73 |
74 | {/* Day number — top right */} 75 |
86 | {arg.dayNumberText.replace(/\D/g, "")} 87 |
88 | 89 | {/* Sequence title */} 90 | {ev && ( 91 |
92 |
{ 104 | // Remove any existing tooltip 105 | document.querySelectorAll(".bucket-tooltip").forEach((t) => t.remove()); 106 | 107 | const rect = e.currentTarget.getBoundingClientRect(); 108 | const tooltip = document.createElement("div"); 109 | tooltip.className = "bucket-tooltip"; 110 | tooltip.innerHTML = ev.buckets.map((name, i) => `${i + 1}. ${name}`).join("
"); 111 | 112 | Object.assign(tooltip.style, { 113 | position: "fixed", 114 | top: rect.bottom + 8 + "px", 115 | left: rect.left + rect.width / 2 + "px", 116 | transform: "translateX(-50%)", 117 | background: isDarkMode ? "#1a1a1a" : "#ffffff", 118 | color: isDarkMode ? "#eee" : "#222", 119 | border: `1px solid ${isDarkMode ? "#444" : "#ccc"}`, 120 | borderRadius: "8px", 121 | padding: "10px 14px", 122 | fontSize: "13px", 123 | fontWeight: "500", 124 | zIndex: "999999", 125 | boxShadow: "0 10px 30px rgba(0,0,0,0.5)", 126 | pointerEvents: "none", 127 | whiteSpace: "nowrap", 128 | }); 129 | 130 | document.body.appendChild(tooltip); 131 | }} 132 | onMouseLeave={() => { 133 | document.querySelectorAll(".bucket-tooltip").forEach((t) => t.remove()); 134 | }} 135 | > 136 | {ev.title} 137 |
138 |
139 | )} 140 |
141 | ); 142 | }} 143 | /> 144 | 145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /frontend/src/components/Login/Login.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Container from "react-bootstrap/Container"; 3 | import Row from "react-bootstrap/Row"; 4 | import Button from "react-bootstrap/Button"; 5 | import LoginIcon from "bootstrap-icons/icons/box-arrow-in-left.svg"; 6 | // import Logo from "../../images/Logo2.png"; 7 | import Card from "react-bootstrap/Card"; 8 | import { PlexOauth } from "plex-oauth"; 9 | 10 | export default class Login extends Component { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | gotURL: false, 16 | gotToken: false, 17 | url: "", 18 | pin: null, 19 | ip: "", 20 | port: "", 21 | ssl: false, 22 | saved: false, 23 | openWindow: false, 24 | gettingToken: false, 25 | gettingIPs: false, 26 | isLoading: false, 27 | noInternet: false, 28 | error: null, 29 | }; 30 | 31 | let version; 32 | 33 | if (this.props.settings.build !== "Native") { 34 | if (this.props.settings.branch === "dev") { 35 | version = `${this.props.settings.version}.${this.props.settings.build}-dev`; 36 | } else { 37 | version = `${this.props.settings.version}.${this.props.settings.build}`; 38 | } 39 | } else { 40 | if (this.props.settings.branch === "dev") { 41 | version = `${this.props.settings.version}-dev`; 42 | } else { 43 | version = `${this.props.settings.version}`; 44 | } 45 | } 46 | 47 | let clientInformation = { 48 | clientIdentifier: `${this.props.settings.uuid}`, // This is a unique identifier used to identify your app with Plex. 49 | product: "Preroll Plus", // Name of your application 50 | device: `${this.props.settings.platform}`, // The type of device your application is running on 51 | version: `${version}`, // Version of your application 52 | forwardUrl: "", // Url to forward back to after signing in. 53 | platform: "Web", // Optional - Platform your application runs on - Defaults to 'Web' 54 | }; 55 | 56 | this.externalWindow = null; 57 | this.plexOauth = new PlexOauth(clientInformation); 58 | } 59 | 60 | componentDidMount() { 61 | if (!this.state.gotURL) { 62 | this.plexOauth 63 | .requestHostedLoginURL() 64 | .then((data) => { 65 | let [hostedUILink, pinId] = data; 66 | 67 | this.setState({ url: `${hostedUILink}`, pin: `${pinId}` }); 68 | }) 69 | .catch((err) => { 70 | this.setState({ noInternet: true }); 71 | throw err; 72 | }); 73 | this.setState({ 74 | gotURL: true, 75 | }); 76 | } 77 | } 78 | 79 | executePoll = async () => { 80 | try { 81 | var token; 82 | if (!this.state.pin) { 83 | throw new Error("Unable to poll when pin is not initialized."); 84 | } 85 | await this.plexOauth 86 | .checkForAuthToken(this.state.pin) 87 | .then((authToken) => { 88 | token = authToken; 89 | }) 90 | .catch((err) => { 91 | throw err; 92 | }); 93 | 94 | if (token) { 95 | this.setState({ gotToken: true }); 96 | this.handleGetThumb(token); 97 | this.handleSaveToken(token); 98 | this.externalWindow.close(); 99 | } else if (token === null && !this.externalWindow.closed) { 100 | setTimeout(this.executePoll, 1000); 101 | } else { 102 | this.setState({ gettingToken: false }); 103 | throw new Error("Window closed without completing login"); 104 | } 105 | } catch (e) { 106 | this.externalWindow.close(); 107 | } 108 | }; 109 | 110 | handlePlexAuth = async () => { 111 | this.setState({ gettingToken: true }); 112 | const y = window.top.outerHeight / 2 + window.top.screenY - 300; 113 | const x = window.top.outerWidth / 2 + window.top.screenX - 300; 114 | this.externalWindow = window.open( 115 | `${this.state.url}`, 116 | "", 117 | `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=600, height=600, top=${y}, left=${x}` 118 | ); 119 | 120 | await this.executePoll(); 121 | }; 122 | 123 | handleSaveToken = (token) => { 124 | var settings = { ...this.props.settings }; 125 | 126 | settings.token = token; 127 | settings.thumb = this.thumb; 128 | settings.email = this.email; 129 | settings.username = this.username; 130 | 131 | var xhr = new XMLHttpRequest(); 132 | 133 | xhr.addEventListener("readystatechange", () => { 134 | if (xhr.readyState === 4) { 135 | if (xhr.status === 200) { 136 | } else { 137 | // error 138 | this.setState({ 139 | error: xhr.responseText, 140 | }); 141 | } 142 | } 143 | }); 144 | 145 | xhr.open("POST", "/backend/save", false); 146 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 147 | xhr.send(JSON.stringify(settings)); 148 | 149 | this.props.handleLogin(); 150 | this.props.handleUpdateThumb(this.thumb, token, this.username, this.email); 151 | }; 152 | 153 | handleGetThumb = (token) => { 154 | var data = {}; 155 | data.token = token; 156 | var xhr = new XMLHttpRequest(); 157 | xhr.addEventListener("readystatechange", () => { 158 | if (xhr.readyState === 4) { 159 | if (xhr.status === 200) { 160 | // request successful 161 | var response = xhr.responseText, 162 | json = JSON.parse(response); 163 | 164 | this.thumb = json.thumb; 165 | this.email = json.email; 166 | this.username = json.username; 167 | } else { 168 | // error 169 | } 170 | } 171 | }); 172 | xhr.open("POST", "/backend/thumb", false); 173 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 174 | xhr.send(JSON.stringify(data)); 175 | }; 176 | 177 | render() { 178 | if (this.state.gotURL) { 179 | return ( 180 | 184 | 188 | 189 |

190 | Preroll Plus 191 |

192 |
193 | 194 |

Sign in to Plex account to continue

195 |
196 | 197 |
198 | {this.state.gettingToken ? ( 199 | 205 | ) : this.state.noInternet ? ( 206 | 212 | ) : ( 213 | 219 | )} 220 |
221 |
222 |
223 |
224 | {this.state.noInternet ? ( 225 |
226 | You must have an internet connection to sign into Plex 227 |
228 | ) : ( 229 | <> 230 | )} 231 |
232 | ); 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /frontend/src/components/Buckets/Buckets.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Bucket from "../Bucket/Bucket"; 3 | import AddIcon from "../../images/add-icon.png"; 4 | import Create from "../Create/Create"; 5 | import Row from "react-bootstrap/Row"; 6 | import Col from "react-bootstrap/Col"; 7 | import Card from "react-bootstrap/Card"; 8 | import Modal from "react-bootstrap/Modal"; 9 | import Button from "react-bootstrap/Button"; 10 | import SortName from "bootstrap-icons/icons/sort-alpha-up.svg"; 11 | import Image from "react-bootstrap/Image"; 12 | import OverlayTrigger from "react-bootstrap/OverlayTrigger"; 13 | import Tooltip from "react-bootstrap/Tooltip"; 14 | import "../Sequences/Sequences.css"; 15 | 16 | export default class Buckets extends Component { 17 | constructor(props) { 18 | super(props); 19 | this.state = { 20 | // buckets: this.props.settings.buckets, 21 | isCreating: false, 22 | id: "-1", 23 | isEdit: false, 24 | show: false, 25 | tempID: "", 26 | }; 27 | } 28 | 29 | refreshSettings = () => { 30 | this.props.onSettingsChanged?.(); // calls refreshConfig() in App.jsx 31 | }; 32 | 33 | handleAddBucket = () => { 34 | this.setState({ 35 | isCreating: true, 36 | isEdit: false, 37 | id: "-1", 38 | }); 39 | }; 40 | 41 | handleEditBucket = (e) => { 42 | this.setState({ 43 | isCreating: true, 44 | isEdit: true, 45 | id: e, 46 | }); 47 | }; 48 | 49 | handleCancelCreate = () => { 50 | this.setState({ isCreating: false, isEdit: false }); 51 | }; 52 | 53 | handleSaveCreate = () => { 54 | this.props.saved(); 55 | this.setState({ isCreating: false, isEdit: false }); 56 | }; 57 | 58 | handleClose = () => this.setState({ show: false }); 59 | 60 | handleOpen = (e) => { 61 | this.setState({ tempID: e, show: true, fullscreen: "md-down" }); 62 | }; 63 | 64 | handleDelete = () => { 65 | var settings = { ...this.props.settings }; 66 | 67 | const index = settings.buckets.findIndex(({ id }) => id === this.state.tempID); 68 | 69 | settings.buckets.splice(index, 1); 70 | 71 | settings.sequences = settings.sequences.map((sequence) => ({ 72 | ...sequence, 73 | buckets: sequence.buckets.filter((bucketId) => bucketId.id !== this.state.tempID), 74 | })); 75 | 76 | var xhr = new XMLHttpRequest(); 77 | 78 | xhr.addEventListener("readystatechange", async () => { 79 | if (xhr.readyState === 4) { 80 | if (xhr.status === 200) { 81 | this.setState({ show: false }); 82 | 83 | const response = await fetch("/webhook", { method: "GET" }); 84 | if (!response.ok) { 85 | throw new Error(`Response status: ${response.status}`); 86 | } 87 | } else { 88 | // error 89 | this.setState({ 90 | show: false, 91 | error: xhr.responseText, 92 | }); 93 | } 94 | } 95 | }); 96 | 97 | xhr.open("POST", "/backend/save", true); 98 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 99 | xhr.send(JSON.stringify(settings)); 100 | 101 | this.props.updateSettings(settings); 102 | this.handleSaveCreate(); 103 | }; 104 | 105 | handleSortOrder = () => { 106 | const settings = { ...this.props.settings }; 107 | let buckets = [...settings.buckets]; // Always work on a copy 108 | 109 | buckets.sort((a, b) => a.name.localeCompare(b.name)); 110 | 111 | settings.buckets = buckets; 112 | 113 | // Save to backend 114 | const xhr = new XMLHttpRequest(); 115 | xhr.addEventListener("readystatechange", () => { 116 | if (xhr.readyState === 4) { 117 | if (xhr.status === 200) { 118 | this.refreshSettings(); 119 | } else { 120 | this.setState({ 121 | error: xhr.responseText, 122 | }); 123 | } 124 | } 125 | }); 126 | xhr.open("POST", "/backend/save", true); 127 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 128 | xhr.send(JSON.stringify(settings)); 129 | 130 | // Instant UI update 131 | this.props.updateSettings(settings); 132 | this.handleSaveCreate(); 133 | }; 134 | 135 | render() { 136 | return ( 137 | <> 138 | 139 |

Buckets

140 |
141 | 142 |
143 | Sort by Name}> 144 | 151 | 152 |
153 |
154 | 155 | {this.props.settings.buckets?.map((bucket) => ( 156 | 157 | 170 |
171 | 172 | ))} 173 | 174 | 175 | {this.state.isEdit || this.state.isCreating ? ( 176 | 181 | 182 | Add 183 | 184 | 185 | ) : ( 186 | 190 | 191 | Add 192 | 193 | 194 | )} 195 | 196 |
197 | 198 | {this.state.isCreating ? ( 199 | 209 | ) : ( 210 | <> 211 |

212 | Red Border: Currently Editing Bucket 213 |

214 |
Click the plus to add a new Bucket.
215 | 216 | )} 217 |
218 | 226 | 227 |

Are you sure?

228 | 231 |     232 | 235 |
236 |
237 | 238 | ); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /backend/holiday.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | var path = require("path"); 5 | var axios = require("axios").default; 6 | 7 | const cacheDir = path.join("/", "config", "cache"); 8 | 9 | // Make sure the directory exists 10 | if (!fs.existsSync(cacheDir)) { 11 | console.info("Cache directory doesn't exist....creating...."); 12 | fs.mkdirSync(cacheDir, { recursive: true }); 13 | } 14 | 15 | router.post("/", async function (req, res, next) { 16 | const HolidayType = { 17 | 1: "national", 18 | 2: "local", 19 | 3: "religious", 20 | 4: "observance", 21 | }; 22 | 23 | const typeName = HolidayType[parseInt(req.body.type, 10)]; 24 | 25 | console.debug( 26 | `Country: ${req.body.country}, Source: ${req.body.source === "1" ? "Legacy" : "Premier"}, Type: ${typeName}` 27 | ); 28 | 29 | const today = new Date(); 30 | today.setHours(0, 0, 0, 0); 31 | const currentYear = today.getFullYear(); 32 | const userLocale = req.headers["accept-language"]?.split(",")[0] || "en-US"; // So dates are formatted based on location 33 | 34 | // Helper function to safely parse and format dates 35 | function formatHolidayDate(dateString, locale) { 36 | let year, month, day; 37 | 38 | // If it's just YYYY-MM-DD 39 | const simpleDateMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/); 40 | if (simpleDateMatch) { 41 | [, year, month, day] = simpleDateMatch; 42 | return new Date(year, month - 1, day).toLocaleDateString(locale, { 43 | year: "numeric", 44 | month: "2-digit", 45 | day: "2-digit", 46 | }); 47 | } 48 | 49 | // If it's a full ISO string with time or timezone, extract YYYY-MM-DD only 50 | const isoDateMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})/); 51 | if (isoDateMatch) { 52 | [, year, month, day] = isoDateMatch; 53 | return new Date(year, month - 1, day).toLocaleDateString(locale, { 54 | year: "numeric", 55 | month: "2-digit", 56 | day: "2-digit", 57 | }); 58 | } 59 | 60 | console.warn(`Unrecognized date format: ${dateString}`); 61 | return dateString; // fallback 62 | } 63 | 64 | const source = req.body.source; 65 | let rawData; 66 | 67 | let cacheFile; 68 | if (source === "2") { 69 | cacheFile = path.join(cacheDir, `${req.body.country}-calendarific-${typeName}-${currentYear}.json`); 70 | } 71 | 72 | if (source === "2" && fs.existsSync(cacheFile)) { 73 | console.log(`Reading Calendarific holidays from cache: ${cacheFile}`); 74 | rawData = fs.readFileSync(cacheFile, "utf-8"); 75 | } else { 76 | const url = 77 | source === "1" 78 | ? `https://date.nager.at/api/v3/publicholidays/${currentYear}/${req.body.country}` 79 | : `https://calendarific.com/api/v2/holidays?api_key=${req.body.apiKey}&country=${req.body.country}&year=${currentYear}&type=${typeName}`; 80 | 81 | if (source === "2" && !req.body.apiKey) { 82 | return res.status(400).send( 83 | JSON.stringify({ 84 | success: false, 85 | message: "Calendarific API key is not set.", 86 | apiKeyMissing: true, // frontend can use this flag to show a notice 87 | }) 88 | ); 89 | } 90 | 91 | try { 92 | const response = await axios.get(url, { timeout: 10000 }); 93 | rawData = JSON.stringify(response.data); 94 | 95 | // Only cache Calendarific data 96 | if (source === "2") { 97 | fs.writeFileSync(cacheFile, rawData, "utf-8"); 98 | } 99 | } catch (error) { 100 | let source = req.body.source; 101 | let type = HolidayType[parseInt(req.body.type, 10)]; 102 | 103 | if (error.response) { 104 | const status = error.response.status; 105 | const data = error.response.data; 106 | 107 | let message = `Error while trying to connect to the ${type} Holiday API. `; 108 | 109 | if (source === "2") { 110 | // 📌 Calendarific API 111 | switch (status) { 112 | case 401: 113 | message += "Unauthorized: Missing or incorrect API token."; 114 | break; 115 | case 422: 116 | if (data && data.meta && data.meta.error_code) { 117 | switch (data.meta.error_code) { 118 | case 600: 119 | message += "API is offline for maintenance."; 120 | break; 121 | case 601: 122 | message += "Unauthorized: Missing or incorrect API token."; 123 | break; 124 | case 602: 125 | message += "Invalid query parameters."; 126 | break; 127 | case 603: 128 | message += "Subscription level required."; 129 | break; 130 | default: 131 | message += `Unprocessable Entity: ${data.meta.error_detail || "Unknown error"}`; 132 | } 133 | } else { 134 | message += "Unprocessable Entity: Request was malformed."; 135 | } 136 | break; 137 | case 500: 138 | message += "Internal server error at Calendarific."; 139 | break; 140 | case 503: 141 | message += "Service unavailable (planned outage)."; 142 | break; 143 | case 429: 144 | message += "Too many requests: API rate limit reached."; 145 | break; 146 | default: 147 | message += `Unexpected HTTP status: ${status}`; 148 | } 149 | } else if (source === "1") { 150 | // 📌 date.nager.at API (they only use standard HTTP statuses) 151 | switch (status) { 152 | case 400: 153 | message += "Bad request: invalid parameters."; 154 | break; 155 | case 401: 156 | message += "Unauthorized: Invalid API key or missing auth."; 157 | break; 158 | case 404: 159 | message += "Not found: Invalid endpoint or country code."; 160 | break; 161 | case 429: 162 | message += "Too many requests: API rate limit reached."; 163 | break; 164 | case 500: 165 | message += "Internal server error at Nager.Date."; 166 | break; 167 | default: 168 | message += `Unexpected HTTP status: ${status}`; 169 | } 170 | } else { 171 | message += "Unknown source specified."; 172 | } 173 | 174 | console.error(message); 175 | } else if (error.request) { 176 | console.error(`No response received from API (source=${source}).`); 177 | } else { 178 | console.error(`Error setting up request (source=${source}): ${error.message}`); 179 | } 180 | } 181 | } 182 | 183 | let countries = []; 184 | if (source === "2") { 185 | try { 186 | const parsed = JSON.parse(rawData); 187 | parsed.response.holidays.forEach((country) => { 188 | countries.push({ 189 | name: country.name, 190 | date: formatHolidayDate(country.date.iso, userLocale), 191 | rawDate: country.date.iso, 192 | states: country.locations, 193 | }); 194 | }); 195 | } catch { 196 | console.error("There was not valid data returned from the Holiday API"); 197 | } 198 | } else if (source === "1") { 199 | try { 200 | const parsed = JSON.parse(rawData); 201 | parsed 202 | .filter((holiday) => holiday.types.includes("Public")) 203 | .forEach((country) => { 204 | countries.push({ 205 | name: country.name, 206 | date: formatHolidayDate(country.date, userLocale), 207 | rawDate: country.date, 208 | states: country.counties === null ? "All" : country.counties.join(", "), 209 | }); 210 | }); 211 | } catch { 212 | console.error("There was not valid data returned from the Holiday API"); 213 | } 214 | } 215 | 216 | function dedupeHolidays(holidays) { 217 | const seen = new Set(); 218 | return holidays.filter((holiday) => { 219 | const key = `${holiday.name}||${holiday.date}||${holiday.states}`; 220 | if (seen.has(key)) { 221 | console.log(`Duplicate removed: ${key}`); // optional logging 222 | return false; 223 | } 224 | seen.add(key); 225 | return true; 226 | }); 227 | } 228 | 229 | res.send(JSON.stringify(dedupeHolidays(countries))); 230 | }); 231 | 232 | module.exports = router; 233 | -------------------------------------------------------------------------------- /backend/monitor.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | var path = require("path"); 5 | var chokidar = require("chokidar"); 6 | var axios = require("axios"); 7 | const { broadcastUpdate } = require("./websocket"); 8 | 9 | let pendingAdds = new Map(); // Store added files with relevant information 10 | let pendingRemovals = new Map(); // Track removed files 11 | let renameDelay; // Delay for rename detection 12 | let pathToWatch = ""; 13 | let isRemoved = true; 14 | let isAdded = true; 15 | let watcher = null; 16 | let isInit = true; 17 | let initTime = 2000; 18 | 19 | function initializeWatcher() { 20 | if (watcher) { 21 | console.info("Closing existing watcher before reinitializing..."); 22 | watcher.close(); 23 | watcher = null; 24 | isInit = true; 25 | } 26 | 27 | try { 28 | settings = JSON.parse(fs.readFileSync("/config/settings.js")); 29 | 30 | if (settings.settings) { 31 | pathToWatch = settings.settings.loc; 32 | renameDelay = settings.settings.polling === "1" ? 500 : 1000; 33 | } 34 | } catch (err) { 35 | console.error("Cannot grab dir location", err); 36 | return; 37 | } 38 | 39 | if (fs.existsSync(pathToWatch)) { 40 | watcher = chokidar.watch(pathToWatch, { 41 | ignored: /(^|[\/\\])\../, // Ignore dotfiles 42 | persistent: true, 43 | usePolling: settings.settings.polling === "2", 44 | interval: 100, 45 | }); 46 | 47 | watcher.on("addDir", (filePath) => { 48 | if (!isInit) { 49 | console.info(`Directory ${filePath} has been added`); 50 | broadcastUpdate(); 51 | } 52 | }); 53 | 54 | watcher.on("unlinkDir", (filePath) => { 55 | if (!isInit) { 56 | console.info(`Directory ${filePath} has been removed`); 57 | broadcastUpdate(); 58 | } 59 | }); 60 | 61 | // When a file is added 62 | watcher.on("add", (filePath) => { 63 | filePath = filePath 64 | .replace(/@SynoEAStream/i, "") 65 | .replace(/@SynoResource/i, "") 66 | .replace(/@eaDir\//i, ""); // Fix for Synology issue 67 | const baseName = path.basename(filePath); 68 | const dirName = path.dirname(filePath); 69 | 70 | // Store the added file with its path 71 | pendingAdds.set(filePath, { baseName, dirName }); 72 | // console.log("PA", pendingAdds); 73 | 74 | // Check for any corresponding removals 75 | for (const [removedPath, removedFile] of pendingRemovals) { 76 | if (removedFile.dirName === dirName && removedFile.baseName !== baseName) { 77 | console.info(`File ${removedPath} was renamed to ${filePath}`); 78 | // Execute your specific rename/move handling code here 79 | handleRenameOrMove(removedPath, filePath); 80 | 81 | // Clean up both the added and removed paths 82 | pendingAdds.delete(filePath); 83 | isRemoved = false; 84 | return; 85 | } 86 | if (removedFile.baseName === baseName && removedFile.dirName !== dirName) { 87 | console.info(`File ${removedPath} was moved to ${filePath}`); 88 | // Execute your specific rename/move handling code here 89 | handleRenameOrMove(removedPath, filePath); 90 | 91 | // Clean up both the added and removed paths 92 | pendingAdds.delete(filePath); 93 | isRemoved = false; 94 | return; 95 | } 96 | } 97 | 98 | // Delay to confirm if it's a rename 99 | setTimeout(() => { 100 | if (!isInit && isAdded) { 101 | console.info(`File ${filePath} has been added`); 102 | broadcastUpdate(); 103 | } 104 | if (pendingAdds.has(filePath)) { 105 | // Confirm it's still an add 106 | 107 | pendingAdds.delete(filePath); 108 | isAdded = true; 109 | } 110 | }, renameDelay); 111 | }); 112 | 113 | // When a file is removed 114 | watcher.on("unlink", (filePath) => { 115 | filePath = filePath 116 | .replace(/@SynoEAStream/i, "") 117 | .replace(/@SynoResource/i, "") 118 | .replace(/@eaDir\//i, ""); // Fix for Synology issue 119 | const baseName = path.basename(filePath); 120 | const dirName = path.dirname(filePath); 121 | 122 | // Store the removed file for potential rename detection 123 | pendingRemovals.set(filePath, { baseName, dirName }); 124 | 125 | // Check for any corresponding adds 126 | 127 | for (const [addedPath, addedFile] of pendingAdds) { 128 | if (addedFile.dirName === dirName && addedFile.baseName !== baseName) { 129 | console.info(`File ${filePath} was renamed to ${addedPath}`); 130 | // Execute your specific rename/move handling code here 131 | handleRenameOrMove(filePath, addedPath); 132 | 133 | // Clean up both the added and removed paths 134 | pendingRemovals.delete(filePath); 135 | isAdded = false; 136 | return; 137 | } 138 | // Handle files across different directories 139 | if (addedFile.baseName === baseName && addedFile.dirName !== dirName) { 140 | console.info(`File ${filePath} was moved to ${addedPath}`); 141 | handleRenameOrMove(filePath, addedPath); 142 | 143 | pendingRemovals.delete(filePath); 144 | isAdded = false; 145 | return; 146 | } 147 | } 148 | 149 | // Delay to confirm if it's a rename 150 | setTimeout(() => { 151 | if (isRemoved) { 152 | // If no corresponding add was found, treat it as a normal removal 153 | console.info(`File ${filePath} has been removed`); 154 | 155 | // Execute your specific removal handling code here 156 | handleRemove(filePath); 157 | } 158 | pendingRemovals.delete(filePath); 159 | isRemoved = true; 160 | }, renameDelay); 161 | }); 162 | 163 | // Handle errors 164 | watcher.on("error", (error) => console.error(`Watcher error: ${error}`)); 165 | } else { 166 | console.warn(`Watcher not started. Directory ${pathToWatch} not found`); 167 | return; 168 | } 169 | 170 | setTimeout(() => { 171 | isInit = false; 172 | console.info("Ready to start monitoring directories"); 173 | }, initTime); 174 | } 175 | 176 | function handleRenameOrMove(oldPath, newPath) { 177 | settings = JSON.parse(fs.readFileSync("/config/settings.js")); 178 | console.info(`Handling rename from ${oldPath} to ${newPath} in buckets`); 179 | let settingsUpdated = false; 180 | settings.buckets.forEach((bucket) => { 181 | bucket.media.forEach((file) => { 182 | if ( 183 | file.file === path.basename(oldPath) && 184 | file.dir === path.dirname(oldPath).replace(settings.settings.loc, "") 185 | ) { 186 | file.file = path.basename(newPath); 187 | file.dir = path.dirname(newPath).replace(settings.settings.loc, ""); 188 | console.info(`Updated settings for renamed file: ${oldPath} to ${newPath} in bucket "${bucket.name}"`); 189 | settingsUpdated = true; 190 | } 191 | }); 192 | }); 193 | if (settingsUpdated) { 194 | try { 195 | fs.writeFileSync("/config/settings.js", JSON.stringify(settings)); 196 | console.info("Settings file saved"); 197 | 198 | axios 199 | .get("http://localhost:4949/webhook") // Make sure the path is correct 200 | .then((response) => {}) 201 | .catch((error) => {}); 202 | } catch (err) { 203 | console.error("Error saving settings file", err); 204 | } 205 | } 206 | broadcastUpdate(); 207 | } 208 | 209 | function handleRemove(oldPath) { 210 | settings = JSON.parse(fs.readFileSync("/config/settings.js")); 211 | console.info(`Handling removal of ${oldPath} in buckets...`); 212 | 213 | let settingsUpdated = false; 214 | 215 | settings.buckets.forEach((bucket) => { 216 | const initialMediaLength = bucket.media.length; 217 | 218 | bucket.media = bucket.media.filter( 219 | (file) => 220 | !(file.file === path.basename(oldPath) && file.dir === path.dirname(oldPath).replace(settings.settings.loc, "")) 221 | ); 222 | 223 | if (bucket.media.length !== initialMediaLength) { 224 | console.info(`Removed all occurrences of ${oldPath} from bucket "${bucket.name}"`); 225 | settingsUpdated = true; 226 | } 227 | }); 228 | 229 | if (settingsUpdated) { 230 | try { 231 | fs.writeFileSync("/config/settings.js", JSON.stringify(settings)); 232 | console.info("Settings file saved"); 233 | axios 234 | .get("http://localhost:4949/webhook") // Make sure the path is correct 235 | .then((response) => {}) 236 | .catch((error) => {}); 237 | } catch (err) { 238 | console.error("Error saving settings file", err); 239 | } 240 | } else { 241 | console.info("No changes made to settings."); 242 | } 243 | broadcastUpdate(); 244 | } 245 | 246 | initializeWatcher(); 247 | 248 | router.get("/", function (req, res, next) { 249 | initializeWatcher(); 250 | res.status(200).send(); 251 | }); 252 | 253 | module.exports = router; 254 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | # Preroll Plus Version History 2 | 3 | ## 1.3.2 4 | 5 | ### New Features 6 | 7 | 1. Added an option in the Settings page to choose the starting day of the week for the Calendar page. Can be set to Sunday, Monday, or Saturday. [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)] 8 | 2. Dates shown in the Sequence cards on the Sequences page will be formatted per user locale. (eg. 12/30 for U.S. and 30/12 for U.K.) [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)] 9 | 3. Sequences on the Sequences page can now be sorted by priority or name. [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)] 10 | 4. Buckets on the Buckets page can now be sorted by name. [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)] 11 | 12 | ### Fix 13 | 14 | 1. Added "Month" and "Day" labels when editing schedules to remove ambiguity and make it more international friendly. 15 | 16 | ## 1.3.1 17 | 18 | ### New Feature 19 | 20 | 1. Ability to extend a holiday schedule out any number of days before or after the actually holiday to create a "holiday window" rather than using a schedule. 21 | 22 | ## 1.3.0 23 | 24 | ### New Features 25 | 26 | 1. New calendar page. You can now view and cycle through calendar months to see which Sequence will play on a particular day. You can view all current and upcoming year's months. 27 | 2. Added a blue box around the currently used Sequence for that day in the Sequences page. [[#22](https://github.com/chadwpalm/PrerollPlus/discussions/22)] 28 | 29 | ### Fixes 30 | 31 | 1. Fixed a critical issue where holidays would not have worked correctly for years past 2025. 32 | 2. Fixed issue where deleting a newly made Sequence wasn't removing it from the list of cards. 33 | 34 | ## 1.2.1 35 | 36 | ### Hotfix 37 | 38 | 1. Fixed code to prevent app crash when can't connect to calendar API servers. [[#19](https://github.com/chadwpalm/PrerollPlus/issues/19)] 39 | 2. Increased timeout for calendar API retrieval. [[#19](https://github.com/chadwpalm/PrerollPlus/issues/19)] 40 | 3. Prevent the querying of the calendar APIs when creating a new Sequence and when toggling the schedule and it is not set to Holiday. [[#19](https://github.com/chadwpalm/PrerollPlus/issues/19)] 41 | 42 | ## 1.2.0 43 | 44 | ### New Features 45 | 46 | 1. Addition of a second calendar source (Calendarific) that includes hundreds more holidays to choose from. [[#15](https://github.com/chadwpalm/PrerollPlus/issues/15)] 47 | a. Calendar APIs have been broken out to two sources you can choose from: Legacy (Nager.Date) which is the original API used, and Premier (Calendarific). 48 | b. The use of the Premier (Calendarific) calendar API requires signing up to a free account with them which include up to 500 API calls per month. 49 | c. Calendarific calendars are cached locally to reduce the number of API calls to eliminate the need to pay for a higher account tier. 50 | d. Calendars are based on year/country/type with type being National, Local, Religious, and Observance. 51 | e. Caches can be cleared at any time if you wish to pull a fresh copy of the calendars (Settings/Advanced), otherwise new versions will be automatically pulled at the beginning of the calendar year when needed. 52 | f. The listings for the holidays in the UI now include dates (current year) and the dates should be formatted for your language/country. 53 | g. In areas where there are states, provinces, or other local regions, holidays may be broken up by those local regions. 54 | h. Holiday lists in the UI can now be sorted by holiday name or by date. 55 | 2. Scheduling conflicts have been removed from the UI and schedule priorities have been put in their place. [[#16](https://github.com/chadwpalm/PrerollPlus/issues/16)] 56 | a. There can still only be one Sequence that does not have a schedule. 57 | 58 | ### Changes 59 | 60 | 1. Changed menu behavior so if there are no buckets you cannot enter the Sequences page and user will be routed to Buckets to start creating one since Sequences are dependent on Buckets. 61 | 2. Configuration file scheme has been updated to accommodate changes in Sequences such as priorities and information for Calendarific. 62 | a. Existing configs will be backed up and migrated over to the new schema. Behavior should work as previously intended, but all priorities will be set to N/A until changed. 63 | 64 | ### Fixes 65 | 66 | 1. Fixed issue where not having a Bucket name was allowed when source was set to Directory. 67 | 2. Fixed issue where information that is saved in a Sequence or Bucket were not updating immediately on cards when saved. 68 | 3. Fixed issue where pop-ups were not showing properly in dark mode. [[#14](https://github.com/chadwpalm/PrerollPlus/issues/14)] 69 | 70 | ## 1.1.2 71 | 72 | ### Fixes 73 | 74 | 1. Sequence cards were not showing the scheduled holiday if "Holiday" is chosen as the schedule. 75 | 76 | ## 1.1.1 77 | 78 | ### Fixes 79 | 80 | 1. Brought internal packages up to date. 81 | 2. Log file names were using the wrong application. 82 | 3. Minor code fixes. 83 | 84 | ## 1.1.0 85 | 86 | ### New Feature 87 | 88 | 1. Added ability to select a holiday for the schedule. 89 | 90 | ## 1.0.0 91 | 92 | ### Changes 93 | 94 | 1. Bring the app out of beta testing. 95 | 2. Logging to file. Logs will be found in /config for native installs and the mounted config folder for Docker installs. 96 | 3. Removed logging of frontend to backend web router calls. 97 | 4. Added "Show/Hide Advanced" options in Settings page and included a logging level toggle. 98 | 99 | ### Fixes 100 | 101 | 1. Server list wasn't showing secure connection options when "Secure connections" in the Plex Media Server settings was set to "Required". 102 | 2. Dark mode setting was not being preserved in the save file when logging out. 103 | 3. Minor fix to file monitoring. 104 | 105 | ## 0.1.5 106 | 107 | ### New Feature 108 | 109 | 1. Added a dark mode which can be toggled on and off [[#5](https://github.com/chadwpalm/PrerollPlus/discussions/5)] 110 | 2. You can now choose if you want Buckets to be tied directly to folders, or stay with the current approach of manually selecting files for the Bucket in the UI. [[#2](https://github.com/chadwpalm/PrerollPlus/discussions/2)] 111 | 3. Bucket and file listings when editing a Bucket are updated in realtime when files/folders in the filesystem are added/deleted/renamed. [[#2](https://github.com/chadwpalm/PrerollPlus/discussions/2)] 112 | 113 | ### Minor Fix 114 | 115 | 1. Moved all inline CSS to external files to make style changes easier 116 | 2. Basic code cleanup to remove compiler warnings 117 | 118 | ## 0.1.4 119 | 120 | ### Minor Fix 121 | 122 | 1. Fixed issue where Synology is adding additional info to the file paths causing the monitoring to not work correctly. [[#4](https://github.com/chadwpalm/PrerollPlus/issues/4)] 123 | 2. Fixed bug where files added to Buckets had an extra slash if file came from root directory. This also affected the file's ability to be monitored for changes. [[#4](https://github.com/chadwpalm/PrerollPlus/issues/4)] 124 | 3. If Bucket is empty due to a backend file removal (since the UI does not allow for saving empty buckets), the Plex string no longer displays "undefined" for that Bucket. 125 | 4. Added option for file monitoring through polling for when preroll directory is mounted over an SMB (or similar) share. 126 | 127 | ## 0.1.3 128 | 129 | ### Minor Features 130 | 131 | 1. Update Buckets in settings and Plex string when files in the file system are deleted, renamed, or moved. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 132 | 2. Deleting a Bucket will update any Sequences it is in and also update the Plex string. 133 | 3. Added directory location under the file names in the "Files in buckets" list. 134 | 4. "Plex location or preroll media" text box in the Settings tab is not grayed out for Native installs anymore. This is to accommodate users that run their Plex server on a different machine than Preroll Plus. [[#3](https://github.com/chadwpalm/PrerollPlus/issues/3)] 135 | 136 | ### Bug Fixes 137 | 138 | 1. Fixed issue where opening a Sequence that contains a recently deleted bucket was generating an error. 139 | 140 | ## 0.1.2 141 | 142 | ### Minor Features 143 | 144 | 1. Make header bar "sticky" to the top of the window. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 145 | 2. Make list of files in bucket alphabetical. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 146 | 3. Change "Add" button in buckets to a "left arrow" to match the asthetic of the Sequences page. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 147 | 4. Sequences and Buckets are now highlighted when editing them. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 148 | 5. Added a video preview player in the Bucket creation/edit page. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 149 | 150 | ### Bug Fixes 151 | 152 | 1. Brought back build number which wasn't brought back in previous version after local testing. 153 | 2. The file list in Buckets was not indicating if the directory location set in Settings does not exist. 154 | 155 | ## 0.1.1 156 | 157 | ### Bug Fixes 158 | 159 | 1. Fix "Update Available" URL. 160 | 2. Fixed output string in Sequences when trying to save without having a sequence name or buckets. 161 | 3. Plex server preroll sequence string is updated when a Sequence is deleted to prevent playing an unwanted sequence. 162 | 4. Fix error when clicking the button to add a bucket to a sequence when a bucket is not selected. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 163 | 5. If two identical buckets are used in a sequence, or the same pre-roll is used in two different buckets in the same sequence, Plex will not play the same pre-roll twice. 164 | 6. Fixed double slashes in pre-roll string on some file system. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 165 | 7. Updated server retrieval logic to parse multiple IPs returned from account server list and mark the unreachable IP's as unreachable. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 166 | 167 | ## 0.1.0 168 | 169 | Initial beta release 170 | -------------------------------------------------------------------------------- /frontend/src/components/Sequences/Sequences.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Sequence from "../Sequence/Sequence"; 3 | import AddIcon from "../../images/add-icon.png"; 4 | import CreateSeq from "../CreateSeq/CreateSeq"; 5 | import Row from "react-bootstrap/Row"; 6 | import Col from "react-bootstrap/Col"; 7 | import Card from "react-bootstrap/Card"; 8 | import Modal from "react-bootstrap/Modal"; 9 | import Button from "react-bootstrap/Button"; 10 | import Image from "react-bootstrap/Image"; 11 | import SortName from "bootstrap-icons/icons/sort-alpha-up.svg"; 12 | import SortPriority from "bootstrap-icons/icons/sort-numeric-up.svg"; 13 | import OverlayTrigger from "react-bootstrap/OverlayTrigger"; 14 | import Tooltip from "react-bootstrap/Tooltip"; 15 | import "./Sequences.css"; 16 | 17 | export default class Sequences extends Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | // sequences: this.props.settings.sequences, 22 | isCreating: false, 23 | id: "-1", 24 | isEdit: false, 25 | show: false, 26 | tempID: "", 27 | }; 28 | } 29 | 30 | refreshSettings = () => { 31 | this.props.onSettingsChanged?.(); // calls refreshConfig() in App.jsx 32 | }; 33 | 34 | handleAddSequence = () => { 35 | this.setState({ 36 | isCreating: true, 37 | isEdit: false, 38 | id: "-1", 39 | }); 40 | }; 41 | 42 | handleEditSequence = (e) => { 43 | this.setState({ 44 | isCreating: true, 45 | isEdit: true, 46 | id: e, 47 | }); 48 | }; 49 | 50 | handleCancelCreate = () => { 51 | this.setState({ isCreating: false, isEdit: false }); 52 | }; 53 | 54 | handleSaveCreate = () => { 55 | this.setState({ isCreating: false, isEdit: false }); 56 | }; 57 | 58 | handleClose = () => this.setState({ show: false }); 59 | 60 | handleOpen = (e) => { 61 | this.setState({ tempID: e, show: true, fullscreen: "md-down" }); 62 | }; 63 | 64 | handleSortOrder = (order) => { 65 | const settings = { ...this.props.settings }; 66 | let sequences = [...settings.sequences]; // Always work on a copy 67 | 68 | if (order === "1") { 69 | sequences.sort((a, b) => a.priority - b.priority); 70 | } 71 | 72 | if (order === "2") { 73 | sequences.sort((a, b) => a.name.localeCompare(b.name)); 74 | } 75 | 76 | settings.sequences = sequences; 77 | 78 | // Save to backend 79 | const xhr = new XMLHttpRequest(); 80 | xhr.addEventListener("readystatechange", () => { 81 | if (xhr.readyState === 4) { 82 | if (xhr.status === 200) { 83 | this.refreshSettings(); 84 | } else { 85 | this.setState({ 86 | error: xhr.responseText, 87 | }); 88 | } 89 | } 90 | }); 91 | xhr.open("POST", "/backend/save", true); 92 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 93 | xhr.send(JSON.stringify(settings)); 94 | 95 | // Instant UI update 96 | this.props.updateSettings(settings); 97 | this.handleSaveCreate(); 98 | }; 99 | 100 | sortHolidayList = (list, order) => { 101 | if (!list) return []; 102 | if (order === "1") { 103 | return [...list].sort((a, b) => new Date(a.date) - new Date(b.date)); 104 | } 105 | if (order === "2") { 106 | return [...list].sort((a, b) => a.name.localeCompare(b.name)); 107 | } 108 | return list; 109 | }; 110 | 111 | handleDelete = (e) => { 112 | e.preventDefault(); 113 | 114 | var settings = { ...this.props.settings }; 115 | 116 | const index = settings.sequences.findIndex(({ id }) => id === this.state.tempID); 117 | 118 | settings.sequences.splice(index, 1); 119 | 120 | var xhr = new XMLHttpRequest(); 121 | 122 | xhr.addEventListener("readystatechange", () => { 123 | if (xhr.readyState === 4) { 124 | if (xhr.status === 200) { 125 | this.setState({ show: false }); 126 | 127 | var xhr2 = new XMLHttpRequest(); 128 | xhr2.addEventListener("readystatechange", () => { 129 | if (xhr2.readyState === 4) { 130 | if (xhr2.status === 200) { 131 | this.refreshSettings(); 132 | } else { 133 | this.setState({ 134 | error: xhr2.responseText, 135 | }); 136 | } 137 | } 138 | }); 139 | 140 | xhr2.open("GET", "/webhook", true); 141 | xhr2.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 142 | xhr2.send(); 143 | } else { 144 | // error 145 | this.setState({ 146 | show: false, 147 | error: xhr.responseText, 148 | }); 149 | } 150 | } 151 | }); 152 | 153 | xhr.open("POST", "/backend/save", true); 154 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 155 | xhr.send(JSON.stringify(settings)); 156 | 157 | this.props.updateSettings(settings); 158 | this.handleSaveCreate(); 159 | }; 160 | 161 | render() { 162 | return ( 163 | <> 164 | 165 |

Sequences

166 |
167 | 168 |
169 | Sort by Name}> 170 | 177 | 178 | 179 | Sort by Priority}> 180 | 187 | 188 |
189 |
190 | 191 | {this.props.settings.sequences?.map((sequence) => ( 192 | 193 | 213 |
214 | 215 | ))} 216 | 217 | 218 | {this.state.isEdit || this.state.isCreating ? ( 219 | 224 | 225 | Add 226 | 227 | 228 | ) : ( 229 | 233 | 234 | Add 235 | 236 | 237 | )} 238 | 239 |
240 | 241 | {this.state.isCreating ? ( 242 | 251 | ) : ( 252 | <> 253 |

254 | Blue Border: Current Sequence  Red Border: Currently Editing Sequence 255 |

256 |
Click the plus to add a new Sequence.
257 | 258 | )} 259 |
260 | 268 | 269 |

Are you sure?

270 | 273 |     274 | 277 |
278 |
279 | 280 | ); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /frontend/src/components/CreateSeq/countries.js: -------------------------------------------------------------------------------- 1 | export const countryNames = [ 2 | "Albania", 3 | "Andorra", 4 | "Argentina", 5 | "Armenia", 6 | "Australia", 7 | "Austria", 8 | "Bahamas", 9 | "Barbados", 10 | "Belarus", 11 | "Belgium", 12 | "Belize", 13 | "Benin", 14 | "Bolivia", 15 | "Bosnia and Herzegovina", 16 | "Botswana", 17 | "Brazil", 18 | "Bulgaria", 19 | "Canada", 20 | "Chile", 21 | "China", 22 | "Colombia", 23 | "Costa Rica", 24 | "Croatia", 25 | "Cuba", 26 | "Cyprus", 27 | "Czechia", 28 | "Denmark", 29 | "Dominican Republic", 30 | "Ecuador", 31 | "Egypt", 32 | "El Salvador", 33 | "Estonia", 34 | "Faroe Islands", 35 | "Finland", 36 | "France", 37 | "Gabon", 38 | "Gambia", 39 | "Georgia", 40 | "Germany", 41 | "Gibraltar", 42 | "Greece", 43 | "Greenland", 44 | "Grenada", 45 | "Guatemala", 46 | "Guernsey", 47 | "Guyana", 48 | "Haiti", 49 | "Honduras", 50 | "Hong Kong", 51 | "Hungary", 52 | "Iceland", 53 | "Indonesia", 54 | "Ireland", 55 | "Isle of Man", 56 | "Italy", 57 | "Jamaica", 58 | "Japan", 59 | "Jersey", 60 | "Kazakhstan", 61 | "Latvia", 62 | "Lesotho", 63 | "Liechtenstein", 64 | "Lithuania", 65 | "Luxembourg", 66 | "Madagascar", 67 | "Malta", 68 | "Mexico", 69 | "Moldova", 70 | "Monaco", 71 | "Mongolia", 72 | "Montenegro", 73 | "Montserrat", 74 | "Morocco", 75 | "Mozambique", 76 | "Namibia", 77 | "Netherlands", 78 | "New Zealand", 79 | "Nicaragua", 80 | "Niger", 81 | "Nigeria", 82 | "North Macedonia", 83 | "Norway", 84 | "Panama", 85 | "Papua New Guinea", 86 | "Paraguay", 87 | "Peru", 88 | "Poland", 89 | "Portugal", 90 | "Puerto Rico", 91 | "Romania", 92 | "Russia", 93 | "San Marino", 94 | "Serbia", 95 | "Singapore", 96 | "Slovakia", 97 | "Slovenia", 98 | "South Africa", 99 | "South Korea", 100 | "Spain", 101 | "Suriname", 102 | "Svalbard and Jan Mayen", 103 | "Sweden", 104 | "Switzerland", 105 | "Tunisia", 106 | "Turkey", 107 | "Ukraine", 108 | "United Kingdom", 109 | "United States", 110 | "Uruguay", 111 | "Vatican City", 112 | "Venezuela", 113 | "Vietnam", 114 | "Zimbabwe", 115 | ]; 116 | 117 | export const countryCodes = [ 118 | "AL", 119 | "AD", 120 | "AR", 121 | "AM", 122 | "AU", 123 | "AT", 124 | "BS", 125 | "BB", 126 | "BY", 127 | "BE", 128 | "BZ", 129 | "BJ", 130 | "BO", 131 | "BA", 132 | "BW", 133 | "BR", 134 | "BG", 135 | "CA", 136 | "CL", 137 | "CN", 138 | "CO", 139 | "CR", 140 | "HR", 141 | "CU", 142 | "CY", 143 | "CZ", 144 | "DK", 145 | "DO", 146 | "EC", 147 | "EG", 148 | "SV", 149 | "EE", 150 | "FO", 151 | "FI", 152 | "FR", 153 | "GA", 154 | "GM", 155 | "GE", 156 | "DE", 157 | "GI", 158 | "GR", 159 | "GL", 160 | "GD", 161 | "GT", 162 | "GG", 163 | "GY", 164 | "HT", 165 | "HN", 166 | "HK", 167 | "HU", 168 | "IS", 169 | "ID", 170 | "IE", 171 | "IM", 172 | "IT", 173 | "JM", 174 | "JP", 175 | "JE", 176 | "KZ", 177 | "LV", 178 | "LS", 179 | "LI", 180 | "LT", 181 | "LU", 182 | "MG", 183 | "MT", 184 | "MX", 185 | "MD", 186 | "MC", 187 | "MN", 188 | "ME", 189 | "MS", 190 | "MA", 191 | "MZ", 192 | "NA", 193 | "NL", 194 | "NZ", 195 | "NI", 196 | "NE", 197 | "NG", 198 | "MK", 199 | "NO", 200 | "PA", 201 | "PG", 202 | "PY", 203 | "PE", 204 | "PL", 205 | "PT", 206 | "PR", 207 | "RO", 208 | "RU", 209 | "SM", 210 | "RS", 211 | "SG", 212 | "SK", 213 | "SI", 214 | "ZA", 215 | "KR", 216 | "ES", 217 | "SR", 218 | "SJ", 219 | "SE", 220 | "CH", 221 | "TN", 222 | "TR", 223 | "UA", 224 | "GB", 225 | "US", 226 | "UY", 227 | "VA", 228 | "VE", 229 | "VN", 230 | "ZW", 231 | ]; 232 | 233 | export const countryNamesCalrific = [ 234 | "Afghanistan", 235 | "Albania", 236 | "Algeria", 237 | "American Samoa", 238 | "Andorra", 239 | "Angola", 240 | "Anguilla", 241 | "Antigua and Barbuda", 242 | "Argentina", 243 | "Armenia", 244 | "Aruba", 245 | "Australia", 246 | "Austria", 247 | "Azerbaijan", 248 | "Bahrain", 249 | "Bangladesh", 250 | "Barbados", 251 | "Belarus", 252 | "Belgium", 253 | "Belize", 254 | "Benin", 255 | "Bermuda", 256 | "Bhutan", 257 | "Bolivia", 258 | "Bosnia and Herzegovina", 259 | "Botswana", 260 | "Brazil", 261 | "British Indian Ocean Territory", 262 | "British Virgin Islands", 263 | "Brunei", 264 | "Bulgaria", 265 | "Burkina Faso", 266 | "Burundi", 267 | "Cambodia", 268 | "Cameroon", 269 | "Canada", 270 | "Cape Verde", 271 | "Cayman Islands", 272 | "Central African Republic", 273 | "Chad", 274 | "Chile", 275 | "China", 276 | "Christmas Island", 277 | "Cocos Islands", 278 | "Colombia", 279 | "Comoros", 280 | "Congo", 281 | "Congo (DRC)", 282 | "Cook Islands", 283 | "Costa Rica", 284 | "Croatia", 285 | "Cuba", 286 | "Curacao", 287 | "Cyprus", 288 | "Czechia", 289 | "Ivory Coast", 290 | "Denmark", 291 | "Djibouti", 292 | "Dominica", 293 | "Dominican Republic", 294 | "Ecuador", 295 | "Egypt", 296 | "El Salvador", 297 | "Equatorial Guinea", 298 | "Eritrea", 299 | "Estonia", 300 | "Eswatini", 301 | "Ethiopia", 302 | "Falkland Islands", 303 | "Faroe Islands", 304 | "Fiji", 305 | "Finland", 306 | "France", 307 | "French Guiana", 308 | "French Polynesia", 309 | "French Southern Territories", 310 | "Gabon", 311 | "Gambia", 312 | "Georgia", 313 | "Germany", 314 | "Ghana", 315 | "Gibraltar", 316 | "Greece", 317 | "Greenland", 318 | "Grenada", 319 | "Guam", 320 | "Guatemala", 321 | "Guernsey", 322 | "Guinea", 323 | "Guinea-Bissau", 324 | "Guyana", 325 | "Haiti", 326 | "Heard Island and McDonald Islands", 327 | "Honduras", 328 | "Hong Kong", 329 | "Hungary", 330 | "Iceland", 331 | "India", 332 | "Indonesia", 333 | "Iran", 334 | "Iraq", 335 | "Ireland", 336 | "Israel", 337 | "Italy", 338 | "Jamaica", 339 | "Japan", 340 | "Jersey", 341 | "Jordan", 342 | "Kazakhstan", 343 | "Kenya", 344 | "Kiribati", 345 | "South Korea", 346 | "Kuwait", 347 | "Kyrgyzstan", 348 | "Laos", 349 | "Latvia", 350 | "Lebanon", 351 | "Lesotho", 352 | "Liberia", 353 | "Libya", 354 | "Liechtenstein", 355 | "Lithuania", 356 | "Luxembourg", 357 | "Macau", 358 | "North Macedonia", 359 | "Madagascar", 360 | "Malawi", 361 | "Malaysia", 362 | "Maldives", 363 | "Mali", 364 | "Malta", 365 | "Marshall Islands", 366 | "Martinique", 367 | "Mauritania", 368 | "Mauritius", 369 | "Mayotte", 370 | "Mexico", 371 | "Micronesia", 372 | "Moldova", 373 | "Monaco", 374 | "Mongolia", 375 | "Montenegro", 376 | "Montserrat", 377 | "Morocco", 378 | "Mozambique", 379 | "Myanmar", 380 | "Namibia", 381 | "Nauru", 382 | "Nepal", 383 | "Netherlands", 384 | "New Caledonia", 385 | "New Zealand", 386 | "Nicaragua", 387 | "Niger", 388 | "Nigeria", 389 | "Niue", 390 | "Norfolk Island", 391 | "Northern Mariana Islands", 392 | "Norway", 393 | "Oman", 394 | "Pakistan", 395 | "Palau", 396 | "Palestine", 397 | "Panama", 398 | "Papua New Guinea", 399 | "Paraguay", 400 | "Peru", 401 | "Philippines", 402 | "Pitcairn Islands", 403 | "Poland", 404 | "Portugal", 405 | "Puerto Rico", 406 | "Qatar", 407 | "Reunion", 408 | "Romania", 409 | "Russia", 410 | "Rwanda", 411 | "Saint Barthelemy", 412 | "Saint Helena", 413 | "Saint Kitts and Nevis", 414 | "Saint Lucia", 415 | "Saint Martin", 416 | "Saint Pierre and Miquelon", 417 | "Saint Vincent and the Grenadines", 418 | "Samoa", 419 | "San Marino", 420 | "Sao Tome and Principe", 421 | "Saudi Arabia", 422 | "Senegal", 423 | "Serbia", 424 | "Seychelles", 425 | "Sierra Leone", 426 | "Singapore", 427 | "Sint Maarten", 428 | "Slovakia", 429 | "Slovenia", 430 | "Solomon Islands", 431 | "Somalia", 432 | "South Africa", 433 | "South Georgia and the South Sandwich Islands", 434 | "South Sudan", 435 | "Spain", 436 | "Sri Lanka", 437 | "Sudan", 438 | "Suriname", 439 | "Svalbard and Jan Mayen", 440 | "Sweden", 441 | "Switzerland", 442 | "Syria", 443 | "Taiwan", 444 | "Tajikistan", 445 | "Tanzania", 446 | "Thailand", 447 | "Timor-Leste", 448 | "Togo", 449 | "Tokelau", 450 | "Tonga", 451 | "Trinidad and Tobago", 452 | "Tunisia", 453 | "Turkey", 454 | "Turkmenistan", 455 | "Turks and Caicos Islands", 456 | "Tuvalu", 457 | "Uganda", 458 | "Ukraine", 459 | "United Arab Emirates", 460 | "United Kingdom", 461 | "United States", 462 | "Uruguay", 463 | "Uzbekistan", 464 | "Vanuatu", 465 | "Vatican City", 466 | "Venezuela", 467 | "Vietnam", 468 | "Wallis and Futuna", 469 | "Western Sahara", 470 | "Yemen", 471 | "Zambia", 472 | "Zimbabwe", 473 | ]; 474 | 475 | export const countryCodesCalrific = [ 476 | "AF", 477 | "AL", 478 | "DZ", 479 | "AS", 480 | "AD", 481 | "AO", 482 | "AI", 483 | "AG", 484 | "AR", 485 | "AM", 486 | "AW", 487 | "AU", 488 | "AT", 489 | "AZ", 490 | "BH", 491 | "BD", 492 | "BB", 493 | "BY", 494 | "BE", 495 | "BZ", 496 | "BJ", 497 | "BM", 498 | "BT", 499 | "BO", 500 | "BA", 501 | "BW", 502 | "BR", 503 | "IO", 504 | "VG", 505 | "BN", 506 | "BG", 507 | "BF", 508 | "BI", 509 | "KH", 510 | "CM", 511 | "CA", 512 | "CV", 513 | "KY", 514 | "CF", 515 | "TD", 516 | "CL", 517 | "CN", 518 | "CX", 519 | "CC", 520 | "CO", 521 | "KM", 522 | "CG", 523 | "CD", 524 | "CK", 525 | "CR", 526 | "HR", 527 | "CU", 528 | "CW", 529 | "CY", 530 | "CZ", 531 | "CI", 532 | "DK", 533 | "DJ", 534 | "DM", 535 | "DO", 536 | "EC", 537 | "EG", 538 | "SV", 539 | "GQ", 540 | "ER", 541 | "EE", 542 | "SZ", 543 | "ET", 544 | "FK", 545 | "FO", 546 | "FJ", 547 | "FI", 548 | "FR", 549 | "GF", 550 | "PF", 551 | "TF", 552 | "GA", 553 | "GM", 554 | "GE", 555 | "DE", 556 | "GH", 557 | "GI", 558 | "GR", 559 | "GL", 560 | "GD", 561 | "GU", 562 | "GT", 563 | "GG", 564 | "GN", 565 | "GW", 566 | "GY", 567 | "HT", 568 | "HM", 569 | "HN", 570 | "HK", 571 | "HU", 572 | "IS", 573 | "IN", 574 | "ID", 575 | "IR", 576 | "IQ", 577 | "IE", 578 | "IL", 579 | "IT", 580 | "JM", 581 | "JP", 582 | "JE", 583 | "JO", 584 | "KZ", 585 | "KE", 586 | "KI", 587 | "KR", 588 | "KW", 589 | "KG", 590 | "LA", 591 | "LV", 592 | "LB", 593 | "LS", 594 | "LR", 595 | "LY", 596 | "LI", 597 | "LT", 598 | "LU", 599 | "MO", 600 | "MK", 601 | "MG", 602 | "MW", 603 | "MY", 604 | "MV", 605 | "ML", 606 | "MT", 607 | "MH", 608 | "MQ", 609 | "MR", 610 | "MU", 611 | "YT", 612 | "MX", 613 | "FM", 614 | "MD", 615 | "MC", 616 | "MN", 617 | "ME", 618 | "MS", 619 | "MA", 620 | "MZ", 621 | "MM", 622 | "NA", 623 | "NR", 624 | "NP", 625 | "NL", 626 | "NC", 627 | "NZ", 628 | "NI", 629 | "NE", 630 | "NG", 631 | "NU", 632 | "NF", 633 | "MP", 634 | "NO", 635 | "OM", 636 | "PK", 637 | "PW", 638 | "PS", 639 | "PA", 640 | "PG", 641 | "PY", 642 | "PE", 643 | "PH", 644 | "PN", 645 | "PL", 646 | "PT", 647 | "PR", 648 | "QA", 649 | "RE", 650 | "RO", 651 | "RU", 652 | "RW", 653 | "BL", 654 | "SH", 655 | "KN", 656 | "LC", 657 | "MF", 658 | "PM", 659 | "VC", 660 | "WS", 661 | "SM", 662 | "ST", 663 | "SA", 664 | "SN", 665 | "RS", 666 | "SC", 667 | "SL", 668 | "SG", 669 | "SX", 670 | "SK", 671 | "SI", 672 | "SB", 673 | "SO", 674 | "ZA", 675 | "GS", 676 | "SS", 677 | "ES", 678 | "LK", 679 | "SD", 680 | "SR", 681 | "SJ", 682 | "SE", 683 | "CH", 684 | "SY", 685 | "TW", 686 | "TJ", 687 | "TZ", 688 | "TH", 689 | "TL", 690 | "TG", 691 | "TK", 692 | "TO", 693 | "TT", 694 | "TN", 695 | "TR", 696 | "TM", 697 | "TC", 698 | "TV", 699 | "UG", 700 | "UA", 701 | "AE", 702 | "GB", 703 | "US", 704 | "UY", 705 | "UZ", 706 | "VU", 707 | "VA", 708 | "VE", 709 | "VN", 710 | "WF", 711 | "EH", 712 | "YE", 713 | "ZM", 714 | "ZW", 715 | ]; 716 | -------------------------------------------------------------------------------- /webhook/index.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var multer = require("multer"); 4 | var fs = require("fs"); 5 | var path = require("path"); 6 | var axios = require("axios").default; 7 | 8 | // Global Variables 9 | 10 | var flag = false; 11 | 12 | var upload = multer({ dest: "/tmp/" }); 13 | 14 | const filePath = "/config/settings.js"; 15 | 16 | var settings; 17 | 18 | // General Functions 19 | 20 | async function isHolidayDay(country, holiday, states, type, source, apiKey, checkDate = null, pre = "0", post = "0") { 21 | const cacheDir = path.join("/", "config", "cache"); 22 | const HolidayType = { 23 | 1: "national", 24 | 2: "local", 25 | 3: "religious", 26 | 4: "observance", 27 | }; 28 | const typeName = HolidayType[parseInt(type, 10)]; 29 | 30 | const today = checkDate 31 | ? new Date( 32 | Date.UTC( 33 | Number(checkDate.split("-")[0]), // year 34 | Number(checkDate.split("-")[1]) - 1, // month (0-based) 35 | Number(checkDate.split("-")[2]) // day 36 | ) 37 | ) 38 | : new Date(); 39 | today.setUTCHours(0, 0, 0, 0); 40 | const currentYear = today.getUTCFullYear(); 41 | 42 | let rawData, cacheFile; 43 | if (source === "2") { 44 | cacheFile = path.join(cacheDir, `${country}-calendarific-${typeName}-${currentYear}.json`); 45 | } 46 | 47 | if (source === "2" && fs.existsSync(cacheFile)) { 48 | rawData = fs.readFileSync(cacheFile, "utf-8"); 49 | } else { 50 | const url = 51 | source === "1" 52 | ? `https://date.nager.at/api/v3/publicholidays/${currentYear}/${country}` 53 | : `https://calendarific.com/api/v2/holidays?api_key=${apiKey}&country=${country}&year=${currentYear}&type=${typeName}`; 54 | 55 | try { 56 | const response = await axios.get(url, { timeout: 2000 }); 57 | rawData = JSON.stringify(response.data); 58 | if (source === "2") { 59 | fs.writeFileSync(cacheFile, rawData, "utf-8"); 60 | } 61 | } catch (error) { 62 | if (error.response) { 63 | const status = error.response.status; 64 | const data = error.response.data; 65 | let message = `Error while trying to connect to the ${typeName} Holiday API. `; 66 | if (source === "2") { 67 | switch (status) { 68 | case 401: 69 | message += "Unauthorized: Missing or incorrect API token."; 70 | break; 71 | case 422: 72 | if (data && data.meta && data.meta.error_code) { 73 | switch (data.meta.error_code) { 74 | case 600: 75 | message += "API is offline for maintenance."; 76 | break; 77 | case 601: 78 | message += "Unauthorized: Missing or incorrect API token."; 79 | break; 80 | case 602: 81 | message += "Invalid query parameters."; 82 | break; 83 | case 603: 84 | message += "Subscription level required."; 85 | break; 86 | default: 87 | message += `Unprocessable Entity: ${data.meta.error_detail || "Unknown error"}`; 88 | } 89 | } else { 90 | message += "Unprocessable Entity: Request was malformed."; 91 | } 92 | break; 93 | case 500: 94 | message += "Internal server error at Calendarific."; 95 | break; 96 | case 503: 97 | message += "Service unavailable (planned outage)."; 98 | break; 99 | case 429: 100 | message += "Too many requests: API rate limit reached."; 101 | break; 102 | default: 103 | message += `Unexpected HTTP status: ${status}`; 104 | } 105 | } else if (source === "1") { 106 | switch (status) { 107 | case 400: 108 | message += "Bad request: invalid parameters."; 109 | break; 110 | case 401: 111 | message += "Unauthorized: Invalid API key or missing auth."; 112 | break; 113 | case 404: 114 | message += "Not found: Invalid endpoint or country code."; 115 | break; 116 | case 429: 117 | message += "Too many requests: API rate limit reached."; 118 | break; 119 | case 500: 120 | message += "Internal server error at Nager.Date."; 121 | break; 122 | default: 123 | message += `Unexpected HTTP status: ${status}`; 124 | } 125 | } else { 126 | message += "Unknown source specified."; 127 | } 128 | console.error(message); 129 | } else if (error.request) { 130 | console.error(`No response received from API (source=${source}).`); 131 | } else { 132 | console.error(`Error setting up request (source=${source}): ${error.message}`); 133 | } 134 | return false; 135 | } 136 | } 137 | 138 | let data; 139 | if (source === "2") { 140 | const parsed = JSON.parse(rawData); 141 | data = parsed.response.holidays.find((item) => item.name === holiday && item.locations === states); 142 | } else if (source === "1") { 143 | const parsed = JSON.parse(rawData); 144 | data = parsed.find((item) => { 145 | const stateString = item.counties === null ? "All" : item.counties.join(", "); 146 | return item.name === holiday && stateString === states; 147 | }); 148 | } 149 | 150 | if (!data) { 151 | console.log("Holiday not found:", holiday); 152 | return false; 153 | } 154 | 155 | const holidayDateStr = source === "2" ? data.date.iso : data.date; 156 | const [datePart] = holidayDateStr.split("T"); 157 | const [year, month, day] = datePart.split("-").map(Number); 158 | 159 | const holidayDate = new Date(Date.UTC(year, month - 1, day)); 160 | holidayDate.setUTCHours(0, 0, 0, 0); 161 | 162 | const preDays = parseInt(pre, 10) || 0; 163 | const postDays = parseInt(post, 10) || 0; 164 | 165 | var windowStart, windowEnd; 166 | 167 | windowStart = new Date(holidayDate); 168 | windowStart.setUTCDate(holidayDate.getUTCDate() - preDays); 169 | 170 | windowEnd = new Date(holidayDate); 171 | windowEnd.setUTCDate(holidayDate.getUTCDate() + postDays); 172 | windowEnd.setUTCHours(23, 59, 59, 999); 173 | 174 | if (today < windowStart) { 175 | windowStart.setFullYear(windowStart.getFullYear() - 1); 176 | windowEnd.setFullYear(windowEnd.getFullYear() - 1); 177 | } 178 | if (today > windowEnd) { 179 | windowStart.setFullYear(windowStart.getFullYear() + 1); 180 | windowEnd.setFullYear(windowEnd.getFullYear() + 1); 181 | } 182 | 183 | return today >= windowStart && today <= windowEnd; 184 | } 185 | 186 | async function saveId(id) { 187 | var settingsCopy = { ...settings }; 188 | 189 | settingsCopy.currentSeq = id; 190 | 191 | fs.writeFileSync("/config/settings.js", JSON.stringify(settingsCopy)); 192 | } 193 | 194 | async function checkSchedule(forceDate = null) { 195 | let bestIndex = -1; 196 | let bestPriority = Infinity; // smaller number is higher priority 197 | const today = forceDate ? new Date(forceDate + "T00:00:00") : new Date(); 198 | today.setHours(0, 0, 0, 0); 199 | const currentYear = today.getFullYear(); 200 | 201 | const todayNumber = new Date(Date.UTC(currentYear, today.getMonth(), today.getDate())).getTime(); 202 | 203 | for (let idx = 0; idx < settings.sequences.length; idx++) { 204 | const element = settings.sequences[idx]; 205 | const priority = element.priority ? parseInt(element.priority, 10) : Infinity; 206 | 207 | let isMatch = false; 208 | 209 | if (element.schedule === "3") { 210 | // Holiday 211 | const isHoliday = await isHolidayDay( 212 | element.country, 213 | element.holiday, 214 | element.states, 215 | element.type, 216 | element.holidaySource, 217 | settings.settings.apiKey, 218 | forceDate, 219 | element.preHoliday || "0", 220 | element.postHoliday || "0" 221 | ); 222 | if (isHoliday) isMatch = true; 223 | } else if (element.schedule === "2") { 224 | // Fallback 225 | isMatch = true; 226 | } else { 227 | // Date range 228 | const startNumber = new Date(Date.UTC(currentYear, element.startMonth - 1, element.startDay)).getTime(); 229 | const endNumber = new Date(Date.UTC(currentYear, element.endMonth - 1, element.endDay)).getTime(); 230 | const isWrapped = startNumber > endNumber; 231 | 232 | if ( 233 | (isWrapped && (todayNumber >= startNumber || todayNumber <= endNumber)) || 234 | (!isWrapped && todayNumber >= startNumber && todayNumber <= endNumber) 235 | ) { 236 | isMatch = true; 237 | } 238 | } 239 | 240 | // If this element matches and has a better (smaller) priority 241 | if (isMatch) { 242 | if (priority < bestPriority) { 243 | // Pick higher-priority sequence 244 | bestPriority = priority; 245 | bestIndex = idx; 246 | } else if (priority === Infinity && bestPriority === Infinity && bestIndex === -1) { 247 | // No priorities set anywhere, fall back to first match 248 | bestIndex = idx; 249 | } 250 | } 251 | } 252 | if (!forceDate) await saveId(bestIndex !== -1 ? settings.sequences[bestIndex].id : ""); 253 | return bestIndex; 254 | } 255 | 256 | async function createList(index) { 257 | let plexString = ""; 258 | if (index !== -1) { 259 | const bucketIds = settings.sequences[index].buckets; 260 | let usedFiles = new Set(); // Set to keep track of used files 261 | 262 | // Using `for...of` loop to await `axios` inside the loop 263 | for (const [idx, bucketId] of bucketIds.entries()) { 264 | let files = []; 265 | const info = settings.buckets.find(({ id }) => id === bucketId.id.toString()); 266 | 267 | if (info.source === "2") { 268 | try { 269 | const response = await axios.post( 270 | "http://localhost:4949/backend/directory", 271 | { dir: `${info.dir}` }, 272 | { 273 | headers: { 274 | "Content-Type": "application/json;charset=UTF-8", 275 | }, 276 | } 277 | ); 278 | response.data.forEach((media) => { 279 | if (!media.isDir) 280 | files.push(`${settings.settings.plexLoc}${info.dir.replace(settings.settings.loc, "")}/${media.name}`); 281 | }); 282 | } catch (error) { 283 | if (error.response) { 284 | console.error("Server responded with error:", error.response.data); 285 | } else if (error.request) { 286 | console.error("No response received:", error.request); 287 | } else { 288 | console.error("Error setting up request:", error.message); 289 | } 290 | } 291 | } else { 292 | info.media.forEach((media) => { 293 | files.push(`${settings.settings.plexLoc}${media.dir}/${media.file}`); 294 | }); 295 | } 296 | 297 | if (files.length !== 0) { 298 | let randomFile; 299 | do { 300 | randomFile = files[Math.floor(Math.random() * files.length)]; // Fix: `info.media.length` -> `files.length` 301 | } while (usedFiles.has(randomFile)); // Keep picking until an unused file is found 302 | 303 | usedFiles.add(randomFile); // Mark the selected file as used 304 | 305 | if (idx === bucketIds.length - 1) { 306 | plexString += randomFile; 307 | } else { 308 | plexString += randomFile + ","; 309 | } 310 | } 311 | } 312 | } 313 | console.log("Updating using Sequence: ", settings.sequences[index].name); 314 | return plexString; 315 | } 316 | 317 | async function sendList(string) { 318 | const url = `http${settings.settings.ssl ? "s" : ""}://${settings.settings.ip}:${settings.settings.port}/:/prefs`; 319 | 320 | await axios 321 | .put(url, null, { 322 | headers: { 323 | "X-Plex-Token": `${settings.token}`, 324 | }, 325 | params: { 326 | CinemaTrailersPrerollID: string, 327 | }, 328 | }) 329 | .then((response) => { 330 | string === "" 331 | ? console.log("No string to update in Plex") 332 | : console.log("Preroll updated successfully: ", string); 333 | }) 334 | .catch((error) => { 335 | console.error("Error updating preroll:", error); 336 | }); 337 | } 338 | 339 | async function doTask() { 340 | const index = await checkSchedule(); 341 | const string = await createList(index); 342 | sendList(string); 343 | } 344 | 345 | // Function to calculate delay until the desired time (3:00 PM) 346 | function getDelayUntilTargetTime(hour, minute) { 347 | const now = new Date(); 348 | const targetTime = new Date(); 349 | 350 | targetTime.setHours(hour, minute, 0, 0); // Set target time to 3:00 PM today 351 | 352 | if (targetTime <= now) { 353 | // If the target time has already passed today, schedule for tomorrow 354 | targetTime.setDate(targetTime.getDate() + 1); 355 | } 356 | 357 | // Calculate the delay in milliseconds 358 | return targetTime - now; 359 | } 360 | 361 | // Periodic Task to Check Schedules 362 | function myAsyncTask() { 363 | try { 364 | settings = JSON.parse(fs.readFileSync(filePath)); 365 | // Your async code here 366 | console.log("Task running..."); 367 | doTask(); 368 | } catch (error) { 369 | console.error("Error in async task:", error); 370 | } 371 | } 372 | 373 | router.post("/", upload.single("thumb"), async function (req, res, next) { 374 | var payload = JSON.parse(req.body.payload); 375 | settings = JSON.parse(fs.readFileSync(filePath)); 376 | 377 | try { 378 | if (payload.event === "media.play" && payload.Metadata.type === "movie") { 379 | console.info("Movie has started. Updating prerolls"); 380 | 381 | doTask(); 382 | } 383 | res.sendStatus(200); 384 | } catch (e) { 385 | console.log("There was an error", e); 386 | res.sendStatus(200); 387 | } 388 | }); 389 | 390 | router.get("/", function (req, res, next) { 391 | settings = JSON.parse(fs.readFileSync(filePath)); 392 | 393 | doTask(); 394 | 395 | res.sendStatus(200); 396 | }); 397 | 398 | router.get("/calendar", async (req, res) => { 399 | const { year, month } = req.query; 400 | if (!year || !month) { 401 | return res.status(400).json({ error: "year and month required" }); 402 | } 403 | 404 | const y = parseInt(year, 10); 405 | const m = parseInt(month, 10) - 1; // JS months are 0-based 406 | 407 | const events = []; 408 | 409 | // Build bucket ID → name lookup map (once per request) 410 | const bucketMap = {}; 411 | settings.buckets.forEach((bucket) => { 412 | bucketMap[bucket.id] = bucket.name; 413 | }); 414 | 415 | // Start and end of the requested month (UTC) 416 | let currentDate = new Date(Date.UTC(y, m, 1)); 417 | const endDate = new Date(Date.UTC(y, m + 1, 0)); // Last day of month 418 | 419 | console.log( 420 | `Fetching calendar: ${year}-${month} (${currentDate.toISOString().split("T")[0]} to ${ 421 | endDate.toISOString().split("T")[0] 422 | })` 423 | ); 424 | 425 | while (currentDate <= endDate) { 426 | const dateStr = currentDate.toISOString().split("T")[0]; 427 | 428 | // DEBUG: See every date being processed 429 | // console.log("Processing:", dateStr); 430 | 431 | const index = await checkSchedule(dateStr); 432 | const seq = index !== -1 ? settings.sequences[index] : null; 433 | 434 | if (seq && Array.isArray(seq.buckets) && seq.buckets.length > 0) { 435 | const bucketNames = seq.buckets 436 | .map((b) => (typeof b === "object" && b.id ? bucketMap[b.id] : null)) 437 | .filter(Boolean); 438 | 439 | events.push({ 440 | title: seq.name, 441 | date: dateStr, 442 | buckets: bucketNames, 443 | }); 444 | } 445 | 446 | // Move to next day — safely, without mutation bugs 447 | currentDate = new Date( 448 | Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth(), currentDate.getUTCDate() + 1) 449 | ); 450 | } 451 | 452 | console.log(`Returning ${events.length} events for ${year}-${month}`); 453 | // console.log(JSON.stringify(events, null, 2)); // Pretty print for debugging 454 | 455 | res.json(events); 456 | }); 457 | 458 | // Schedule the initial run 459 | const delay = getDelayUntilTargetTime(0, 0); 460 | // Set the task to run every day 461 | setTimeout(() => { 462 | myAsyncTask(); 463 | 464 | setInterval(myAsyncTask, 24 * 60 * 60 * 1000); 465 | }, delay); 466 | 467 | module.exports = router; 468 | -------------------------------------------------------------------------------- /frontend/src/components/App/App.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import Loading from "../../images/loading-gif.gif"; 4 | import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"; 5 | import Login from "../Login/Login"; 6 | import Sequences from "../Sequences/Sequences"; 7 | import Buckets from "../Buckets/Buckets"; 8 | import Settings from "../Settings/Settings"; 9 | import Announce from "./Announce"; 10 | import Container from "react-bootstrap/Container"; 11 | import Row from "react-bootstrap/Row"; 12 | import Nav from "react-bootstrap/Nav"; 13 | import Navbar from "react-bootstrap/Navbar"; 14 | import "bootstrap/dist/css/bootstrap.min.css"; 15 | import { LinkContainer } from "react-router-bootstrap"; 16 | import Logout from "bootstrap-icons/icons/box-arrow-right.svg"; 17 | import Moon from "bootstrap-icons/icons/moon-stars.svg"; 18 | import Sun from "bootstrap-icons/icons/sun.svg"; 19 | import Modal from "react-bootstrap/Modal"; 20 | import NavDropdown from "react-bootstrap/NavDropdown"; 21 | import Image from "react-bootstrap/Image"; 22 | import Badge from "react-bootstrap/Badge"; 23 | import { default as axios } from "axios"; 24 | import "./App.css"; 25 | // import FullCalendar from "@fullcalendar/react"; 26 | import SequenceCalendar from "../Calendar/Calendar"; 27 | 28 | export default class App extends Component { 29 | state = { 30 | isLoaded: false, 31 | isConnected: false, 32 | error: null, 33 | config: {}, 34 | show: false, 35 | fullscreen: true, 36 | fullscreenAnn: true, 37 | isLoggedIn: false, 38 | isUpdate: false, 39 | isOnline: true, 40 | announce: false, 41 | first: false, 42 | dismiss: false, 43 | isDarkMode: false, 44 | announcement: false, //master key to show an announcement after version update 45 | sockConnected: false, 46 | cannotConnect: false, 47 | reconnectAttempts: 0, 48 | isBucket: false, 49 | }; 50 | 51 | componentDidMount() { 52 | this.connectWebSocket(); 53 | 54 | var xhr = new XMLHttpRequest(); 55 | var state = false; 56 | var online = true; 57 | 58 | xhr.addEventListener("readystatechange", async () => { 59 | if (xhr.readyState === 4) { 60 | if (xhr.status === 200) { 61 | // request successful 62 | var response = xhr.responseText, 63 | json = JSON.parse(response); 64 | 65 | var url = ""; 66 | 67 | if (json.branch === "dev") { 68 | url = `https://raw.githubusercontent.com/chadwpalm/PrerollPlus/develop/version.json?cb=${Date.now()}`; 69 | 70 | await axios 71 | .get(url, { headers: { "Content-Type": "application/json;charset=UTF-8" } }) 72 | .then(function (response) { 73 | var data = response.data; 74 | if (data.version !== json.version) { 75 | state = true; 76 | } 77 | }) 78 | .catch(function (error) { 79 | online = false; 80 | }); 81 | } else { 82 | url = `https://raw.githubusercontent.com/chadwpalm/PrerollPlus/main/version.json`; 83 | 84 | await axios 85 | .get(url, { headers: { "Content-Type": "application/json;charset=UTF-8" } }) 86 | .then(function (response) { 87 | var data = response.data; 88 | 89 | if (data.version !== json.version) { 90 | state = true; 91 | } 92 | }) 93 | .catch(function (error) {}); 94 | } 95 | 96 | if (!online) { 97 | this.setState({ isOnline: false }); 98 | } else { 99 | this.setState({ 100 | isLoaded: true, 101 | config: json, 102 | thumb: json.thumb, 103 | }); 104 | 105 | if (json.buckets.length !== 0) this.setState({ isBucket: true }); 106 | 107 | if (state) this.setState({ isUpdate: true }); 108 | 109 | if (json.token) this.setState({ isLoggedIn: true }); 110 | 111 | if (json.connected === "true") { 112 | this.setState({ isConnected: true }); 113 | } 114 | 115 | if (json.message) this.setState({ first: true }); 116 | 117 | this.setState({ isDarkMode: json.darkMode }, () => { 118 | this.toggleBodyClass(); 119 | }); 120 | } 121 | } else { 122 | // error 123 | this.setState({ 124 | isLoaded: true, 125 | error: xhr.responseText, 126 | }); 127 | } 128 | } 129 | }); 130 | 131 | xhr.open("GET", "/backend/load", true); 132 | xhr.send(); 133 | } 134 | 135 | connectWebSocket() { 136 | const protocol = window.location.protocol === "https:" ? "wss" : "ws"; 137 | this.ws = new WebSocket(`${protocol}://${window.location.host}`); 138 | 139 | this.ws.onopen = () => { 140 | console.log("WebSocket connection opened"); 141 | this.setState({ sockConnected: true, reconnectAttempts: 0 }); 142 | }; 143 | 144 | this.ws.onmessage = (event) => { 145 | if (event.data === "update-config") { 146 | this.refreshConfig(); 147 | } 148 | }; 149 | 150 | this.ws.onclose = () => { 151 | console.log("WebSocket connection closed"); 152 | this.setState({ sockConnected: false }); 153 | 154 | if (this.state.reconnectAttempts < 2) { 155 | console.log("Attempting to reconnect..."); 156 | this.setState( 157 | (prevState) => ({ 158 | reconnectAttempts: prevState.reconnectAttempts + 1, 159 | }), 160 | () => { 161 | setTimeout(() => { 162 | this.connectWebSocket(); // Reconnect 163 | }, 3000); // Delay before reconnecting 164 | } 165 | ); 166 | } else { 167 | console.log("Max reconnect attempts reached."); 168 | this.setState({ cannotConnect: true }); 169 | } 170 | }; 171 | 172 | this.ws.onerror = (error) => { 173 | console.error("WebSocket error:", error); 174 | }; 175 | } 176 | 177 | refreshConfig() { 178 | axios 179 | .get("/backend/load") 180 | .then((response) => { 181 | this.setState({ 182 | config: response.data, 183 | }); 184 | }) 185 | .catch((error) => { 186 | console.error("Error refreshing config:", error); 187 | }); 188 | } 189 | 190 | toggleBodyClass = () => { 191 | if (this.state.isDarkMode) { 192 | document.body.classList.add("dark-mode"); 193 | } else { 194 | document.body.classList.remove("dark-mode"); 195 | } 196 | }; 197 | 198 | handleLogin = () => { 199 | window.location.reload(false); 200 | // this.setState({ isLoggedIn: true }); 201 | }; 202 | 203 | handleConnectionChange = (change) => { 204 | change ? this.setState({ isConnected: true }) : this.setState({ isConnected: false }); 205 | }; 206 | 207 | handleClose = () => this.setState({ show: false }); 208 | 209 | handleOpen = () => this.setState({ show: true, fullscreen: "md-down" }); 210 | 211 | handleCloseAnn = () => { 212 | this.setState({ announce: false }); 213 | if (this.state.dismiss) { 214 | var settings = { ...this.state.config }; 215 | 216 | settings.message = false; 217 | 218 | var xhr = new XMLHttpRequest(); 219 | 220 | xhr.addEventListener("readystatechange", () => { 221 | if (xhr.readyState === 4) { 222 | if (xhr.status === 200) { 223 | } else { 224 | // error 225 | this.setState({ 226 | error: xhr.responseText, 227 | }); 228 | } 229 | } 230 | }); 231 | 232 | xhr.open("POST", "/backend/save", true); 233 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 234 | xhr.send(JSON.stringify(settings)); 235 | } 236 | }; 237 | 238 | handleOpenAnn = () => this.setState({ announce: true, first: false, fullscreenAnn: "md-down" }); 239 | 240 | handleDismiss = () => { 241 | if (this.state.dismiss) { 242 | this.setState({ dismiss: false }); 243 | } else { 244 | this.setState({ dismiss: true }); 245 | } 246 | }; 247 | 248 | handleUpdateThumb = (thumb, token, username, email) => { 249 | var temp = this.state.config; 250 | temp.token = token; 251 | temp.thumb = thumb; 252 | temp.email = email; 253 | temp.username = username; 254 | this.setState({ config: temp }); 255 | }; 256 | 257 | handleLogout = () => { 258 | var settings = { ...this.state.config }; 259 | 260 | delete settings["token"]; 261 | delete settings["thumb"]; 262 | delete settings["email"]; 263 | delete settings["username"]; 264 | 265 | var xhr = new XMLHttpRequest(); 266 | 267 | xhr.addEventListener("readystatechange", () => { 268 | if (xhr.readyState === 4) { 269 | if (xhr.status === 200) { 270 | this.setState({ isLoggedIn: false }); 271 | } else { 272 | // error 273 | this.setState({ 274 | error: xhr.responseText, 275 | }); 276 | } 277 | } 278 | }); 279 | 280 | xhr.open("POST", "/backend/save", true); 281 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 282 | xhr.send(JSON.stringify(settings)); 283 | }; 284 | 285 | updateSettings = (newSettings) => { 286 | this.setState({ config: newSettings }); 287 | }; 288 | 289 | handleBuckets = () => { 290 | this.setState({ isBucket: true }); 291 | }; 292 | 293 | handleDark = () => { 294 | this.setState((prevState) => { 295 | const newMode = !prevState.isDarkMode; 296 | 297 | var settings = { ...prevState.config, darkMode: newMode }; 298 | 299 | var xhr = new XMLHttpRequest(); 300 | 301 | xhr.addEventListener("readystatechange", () => { 302 | if (xhr.readyState === 4) { 303 | if (xhr.status === 200) { 304 | } else { 305 | // error 306 | this.setState({ 307 | error: xhr.responseText, 308 | }); 309 | } 310 | } 311 | }); 312 | 313 | xhr.open("POST", "/backend/save", true); 314 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 315 | xhr.send(JSON.stringify(settings)); 316 | 317 | return { isDarkMode: newMode, config: settings }; 318 | }, this.toggleBodyClass); 319 | }; 320 | 321 | render() { 322 | if (!this.state.isOnline) { 323 | return ( 324 | <> 325 | Preroll Plus requires an internet connection. If you are running Preroll Plus in Docker, check your Docker 326 | network settings. 327 | 328 | ); 329 | } else { 330 | if (!this.state.isLoaded) { 331 | // is loading 332 | return ( 333 |
334 | Loading 335 |
336 | ); 337 | } else if (this.state.error) { 338 | // error 339 | return
Error occured: {this.state.error}
; 340 | } else { 341 | if (this.state.isLoggedIn) { 342 | // success 343 | return ( 344 | 345 | 346 | 347 | 348 | 349 |
350 |

351 | Preroll Plus 352 |

353 |
354 |
355 | 356 | 357 | 402 | 403 | 457 | 458 |
459 |
460 | 461 | {this.state.first ? this.handleOpenAnn() : <>} 462 | 470 | 471 | About 472 | 473 | 474 | Version:  {this.state.config.version} 475 |
476 | Branch:  {this.state.config.branch} 477 |
478 | Build:  {this.state.config.build} 479 |
480 | Config Dir:  /config 481 |
482 | App Dir:  /PrerollPlus 483 |
484 | Docker:  485 | 490 | chadwpalm/prerollplus 491 | 492 |
493 | Source:  494 | 495 | github.com/chadwpalm/PrerollPlus 496 | 497 |
498 |
499 | {this.state.announcement ? ( 500 | 508 | ) : ( 509 | <> 510 | )} 511 | 512 | 513 | 514 | 522 | } 523 | /> 524 | 525 | {!this.state.isConnected ? ( 526 | } /> 527 | ) : ( 528 | <> 529 | {!this.state.isBucket ? ( 530 | 542 | } 543 | /> 544 | ) : ( 545 | 555 | } 556 | /> 557 | )} 558 | 559 | 571 | } 572 | /> 573 | } 576 | /> 577 | } /> 578 | 579 | )} 580 | 581 | 582 |
583 |
584 | ); 585 | } else { 586 | return ( 587 | 588 | 589 | } /> 590 | 598 | } 599 | /> 600 | 601 | 602 | ); 603 | } 604 | } 605 | } 606 | } 607 | } 608 | -------------------------------------------------------------------------------- /frontend/src/components/Settings/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Row from "react-bootstrap/Row"; 3 | import Col from "react-bootstrap/Col"; 4 | import Form from "react-bootstrap/Form"; 5 | import OverlayTrigger from "react-bootstrap/OverlayTrigger"; 6 | import Tooltip from "react-bootstrap/Tooltip"; 7 | import Info from "bootstrap-icons/icons/info-circle.svg"; 8 | import Repeat from "bootstrap-icons/icons/arrow-repeat.svg"; 9 | import Button from "react-bootstrap/Button"; 10 | import Stack from "react-bootstrap/Stack"; 11 | import Image from "react-bootstrap/Image"; 12 | import "./Settings.css"; 13 | 14 | export default class Settings extends Component { 15 | constructor(props) { 16 | super(props); 17 | if (this.props.settings.settings) { 18 | this.state = { 19 | ip: this.props.settings.settings.ip, 20 | port: this.props.settings.settings.port, 21 | ssl: this.props.settings.settings.ssl, 22 | loc: this.props.settings.settings.loc ?? "/prerolls", 23 | plexLoc: this.props.settings.settings.plexLoc, 24 | servers: [], 25 | isGetting: false, 26 | isLoaded: false, 27 | isError: false, 28 | isIncomplete: false, 29 | isSaved: false, 30 | polling: this.props.settings.settings.polling ?? "1", 31 | advanced: this.props.settings.advanced ?? false, 32 | logLevel: this.props.settings.settings.logLevel ?? "0", 33 | holidaySource: this.props.settings.settings.holidaySource ?? "1", 34 | apiKey: this.props.settings.settings.apiKey ?? "", 35 | isCacheSuccess: false, 36 | isCacheFailed: false, 37 | dayOfWeek: this.props.settings.settings.dayOfWeek ?? "0", 38 | }; 39 | } else { 40 | this.state = { 41 | ip: "", 42 | port: "", 43 | ssl: false, 44 | loc: "/prerolls", 45 | plexLoc: "", 46 | servers: [], 47 | isGetting: false, 48 | isLoaded: false, 49 | isIncomplete: false, 50 | isSaved: false, 51 | polling: "1", 52 | advanced: this.props.settings.advanced ?? false, 53 | logLevel: "0", 54 | holidaySource: "1", 55 | apiKey: "", 56 | isCacheSuccess: false, 57 | isCacheFailed: false, 58 | dayOfWeek: "0", 59 | }; 60 | } 61 | } 62 | 63 | handleFormSubmit = async (e) => { 64 | e.preventDefault(); 65 | 66 | this.setState({ isIncomplete: false }); 67 | 68 | if (this.state.ip === "" || this.state.port === "") { 69 | this.setState({ isIncomplete: true }); 70 | return; 71 | } 72 | 73 | if (!this.props.settings.settings) this.props.settings.settings = {}; 74 | 75 | this.props.settings.settings.ip = this.state.ip; 76 | this.props.settings.settings.port = this.state.port; 77 | this.props.settings.settings.ssl = this.state.ssl; 78 | this.props.settings.settings.loc = this.state.loc; 79 | this.props.settings.settings.plexLoc = this.state.plexLoc; 80 | this.props.settings.connected = "true"; 81 | this.props.settings.settings.polling = this.state.polling; 82 | this.props.settings.settings.logLevel = this.state.logLevel; 83 | this.props.settings.settings.apiKey = this.state.apiKey; 84 | this.props.settings.settings.dayOfWeek = this.state.dayOfWeek; 85 | this.props.connection(1); 86 | 87 | try { 88 | const saveResponse = await fetch("/backend/save", { 89 | method: "POST", 90 | headers: { "Content-Type": "application/json;charset=UTF-8" }, 91 | body: JSON.stringify(this.props.settings), 92 | }); 93 | 94 | if (!saveResponse.ok) throw new Error(`Save failed: ${saveResponse.status}`); 95 | 96 | this.setState({ isSaved: true }); 97 | 98 | const monitorResp = await fetch("/backend/monitor"); 99 | if (!monitorResp.ok) throw new Error(`Monitor failed: ${monitorResp.status}`); 100 | 101 | const loggerResp = await fetch("/backend/logger"); 102 | if (!loggerResp.ok) throw new Error(`Logger failed: ${loggerResp.status}`); 103 | 104 | const webhookResp = await fetch("/webhook"); 105 | if (!webhookResp.ok) throw new Error(`Webhook failed: ${webhookResp.status}`); 106 | } catch (err) { 107 | console.error(err); 108 | this.setState({ error: err.message }); 109 | } 110 | }; 111 | 112 | handleServerGet = () => { 113 | var xhr = new XMLHttpRequest(); 114 | 115 | this.setState({ isGetting: true }); 116 | this.setState({ isLoaded: false }); 117 | this.setState({ isSaved: false }); 118 | 119 | xhr.addEventListener("readystatechange", async () => { 120 | if (xhr.readyState === 4) { 121 | if (xhr.status === 200) { 122 | // request successful 123 | var response = xhr.responseText, 124 | json = JSON.parse(response); 125 | 126 | var tempList = []; 127 | var index = 0; 128 | 129 | const createServerEntry = (element, index, secure, location, socket) => ({ 130 | index: index, 131 | name: element.name, 132 | ip: location === "remote" ? element.remoteIP : element.localIP, 133 | port: element.port, 134 | location, 135 | secure, 136 | cert: element.cert, 137 | certSuccessful: element.certSuccessful, 138 | socket: socket, 139 | }); 140 | 141 | for (const element of json) { 142 | if (element.certSuccessful) { 143 | if (!element.https) { 144 | tempList.push(createServerEntry(element, ++index, false, "local", false)); 145 | tempList.push(createServerEntry(element, ++index, false, "remote", false)); 146 | } 147 | tempList.push(createServerEntry(element, ++index, true, "local", false)); 148 | tempList.push(createServerEntry(element, ++index, true, "remote", false)); 149 | } else { 150 | tempList.push(createServerEntry(element, ++index, false, "local", true)); 151 | tempList.push(createServerEntry(element, ++index, false, "remote", true)); 152 | } 153 | } 154 | this.setState({ servers: tempList }); 155 | this.setState({ isLoaded: true }); 156 | } else { 157 | // error 158 | this.setState({ 159 | isLoaded: true, 160 | error: xhr.responseText, 161 | }); 162 | } 163 | } 164 | }); 165 | 166 | xhr.open("POST", "/backend/settings", true); 167 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 168 | xhr.send(JSON.stringify(this.props.settings)); 169 | }; 170 | 171 | handleServerChange = (e) => { 172 | if (e.target.value !== 0) { 173 | if (this.state.servers[e.target.value - 1].secure) { 174 | this.setState({ 175 | ip: `${this.state.servers[e.target.value - 1].ip.replace(/\./g, "-")}.${ 176 | this.state.servers[e.target.value - 1].cert 177 | }.plex.direct`, 178 | ssl: true, 179 | }); 180 | } else { 181 | this.setState({ ip: this.state.servers[e.target.value - 1].ip, ssl: false }); 182 | } 183 | if (this.state.servers[e.target.value - 1].location === "remote") { 184 | this.setState({ port: this.state.servers[e.target.value - 1].port }); 185 | } else { 186 | this.setState({ port: "32400" }); 187 | } 188 | } 189 | this.setState({ isSaved: false }); 190 | }; 191 | 192 | handleIp = (e) => { 193 | this.setState({ ip: e.target.value.toString(), isSaved: false }); 194 | }; 195 | 196 | handlePort = (e) => { 197 | this.setState({ port: e.target.value.toString(), isSaved: false }); 198 | }; 199 | 200 | handleSSL = (e) => { 201 | // console.log(e.target.checked); 202 | this.setState({ ssl: e.target.checked, isSaved: false }); 203 | }; 204 | 205 | handleLoc = (e) => { 206 | this.setState({ loc: e.target.value.toString(), isSaved: false }); 207 | }; 208 | 209 | handlePlexLoc = (e) => { 210 | this.setState({ plexLoc: e.target.value.toString(), isSaved: false }); 211 | }; 212 | 213 | handlePolling = (e) => { 214 | this.setState({ polling: e.target.value.toString() }); 215 | }; 216 | 217 | handleLogLevel = (e) => { 218 | this.setState({ logLevel: e.target.value.toString(), isSaved: false }); 219 | }; 220 | 221 | handleAPIKey = (e) => { 222 | this.setState({ apiKey: e.target.value.toString() }); 223 | }; 224 | 225 | handleDayOfWeek = (e) => { 226 | this.setState({ dayOfWeek: e.target.value.toString() }); 227 | }; 228 | 229 | handleAdvanced = () => { 230 | this.setState((prevState) => { 231 | const newMode = !prevState.advanced; 232 | 233 | var settings = { ...this.props.settings, advanced: newMode }; 234 | 235 | var xhr = new XMLHttpRequest(); 236 | 237 | xhr.addEventListener("readystatechange", () => { 238 | if (xhr.readyState === 4) { 239 | if (xhr.status === 200) { 240 | } else { 241 | // error 242 | this.setState({ 243 | error: xhr.responseText, 244 | }); 245 | } 246 | } 247 | }); 248 | 249 | xhr.open("POST", "/backend/save", true); 250 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 251 | xhr.send(JSON.stringify(settings)); 252 | 253 | return { advanced: newMode }; 254 | }); 255 | }; 256 | 257 | handleCache = () => { 258 | var xhr = new XMLHttpRequest(); 259 | 260 | xhr.addEventListener("readystatechange", () => { 261 | if (xhr.readyState === 4) { 262 | if (xhr.status === 200) { 263 | this.setState({ isCacheSuccess: true }); 264 | } else if (xhr.status === 500) { 265 | this.setState({ isCacheFailed: true }); 266 | } else { 267 | this.setState({ 268 | error: xhr.responseText, 269 | }); 270 | } 271 | } 272 | }); 273 | 274 | xhr.open("GET", "/backend/clearcache", true); 275 | xhr.send(); 276 | }; 277 | 278 | render() { 279 | return ( 280 | <> 281 | 282 | 283 |

Settings

284 | 285 | 286 | {this.state.advanced ? ( 287 | 290 | ) : ( 291 | 294 | )} 295 | 296 |
297 |
298 | 299 |
300 |
301 | Plex Server    302 | 306 | Enter the Plex server's IP address and port, or use the search function to list servers associated 307 | with your account. 308 |
309 |
310 | For remote servers, it will be the user's responsibility to set up the remote connection path. 311 | 312 | } 313 | > 314 | info 315 |
316 |
317 |
318 | {/* Server */} 319 | Server    320 | 321 | {this.state.isLoaded ? ( 322 | 330 | 331 | {this.state.servers.map((server) => { 332 | const certInfo = server.secure ? `${server.cert}.plex.direct` : ""; 333 | const ip = server.secure ? server.ip.replace(/\./g, "-") : server.ip; 334 | const location = `[${server.location}]`; 335 | const socket = server.socket ? `(socket hang up)` : ""; 336 | const secure = server.secure ? `[secure]` : ""; 337 | 338 | return ( 339 | 346 | ); 347 | })} 348 | 349 | ) : ( 350 | <> 351 | {this.state.isGetting ? ( 352 | 353 | 354 | 355 | ) : ( 356 | 364 | 365 | 366 | )} 367 | 368 | )} 369 | 372 | 373 |
374 | Hostname or IP Address    375 | 376 |
377 | Port    378 | 379 |
380 | Use SSL    381 | 382 |
383 |
Preroll Media   
384 |
385 | Location of preroll media    386 | 390 | This is the root location of your Plex preroll media files. 391 |
392 |
393 | This option is only available when running the application natively. If running from Docker, it will 394 | be grayed out and you can set your root location through mounting the internal /prerolls directory to 395 | the directory of your choosing on your host system. 396 |
397 |
398 | When creating buckets, this is the directory that Preroll Plus will look for preroll media, so make 399 | sure the root location of your media matches this location. 400 | 401 | } 402 | > 403 | Info 404 |
405 | {this.props.settings.build === "Native" ? ( 406 | 407 | ) : ( 408 | 409 | )} 410 |
411 | Plex location of preroll media    412 | 416 | This is the location of your Plex prerolls as Plex sees them. 417 |
418 |
419 | This path should corrospond to root location of your preroll files based on the location of your Plex 420 | server. If you are running Preroll Plus and Plex on the same device, this should match the above path. 421 | If you are running Plex on a different machine than Preroll Plus, this path will most likely be 422 | different than the one above. 423 | 424 | } 425 | > 426 | Info 427 |
428 | 435 |
436 | {this.state.advanced ? ( 437 | <> 438 |
439 | 440 | File Monitor Polling    441 | 445 | This setting changes backend file monitoring from using "inotify" to a polling method. 446 |
447 |
448 | If you are connecting to your prerolls directory using an SMB (or similar) share, it is more 449 | than likely that the file system's ability to be notified of file changes will not work. 450 |
451 |
452 | If you are finding that renaming, moving, or removing files in your preroll directory isn't 453 | automatically working, set this to on and Preroll Plus will monitor file changes using a 454 | constant polling of the file system. 455 |
456 |
457 | If everything is working correctly, it is recommended to keep this setting off. 458 | 459 | } 460 | > 461 | Info 462 |
463 |
464 |
465 | 476 | 487 |
488 |
489 | 490 | Log Level:   491 | 499 | 500 | 501 | 502 | 503 | 504 | ) : ( 505 | <> 506 | )} 507 |
508 |
509 |
Holidays
510 |
511 |
512 | Calendarific API Key    513 | 517 | Enter Calendarific API key here. 518 |
519 |
520 | You can find your API key on your account dashboard toward the bottom of the page. 521 | 522 | } 523 | > 524 | Info 525 |
526 |     Get account and API key at{" "} 527 | 528 | calendarific.com 529 | 530 | 538 |
539 | 540 | {this.state.advanced ? ( 541 | <> 542 | 545 |
546 | 547 | ) : ( 548 | <> 549 | )} 550 | {this.state.isCacheSuccess ? ( 551 |   Cache cleared successfully. 552 | ) : ( 553 | <> 554 | )} 555 | {this.state.isCacheFailed ? ( 556 |   Error clearing cache. Check logs. 557 | ) : ( 558 | <> 559 | )} 560 | 561 |
562 | Calendar Day of Week Start 563 |
564 | 575 | 586 | 597 |
598 |
599 | {/* Cancel/Save */} 600 | 603 |    604 | {this.state.isIncomplete ?   IP and Port must be filled. : <>} 605 | {this.state.isSaved ?   Settings saved. : <>} 606 |
607 |
608 | 609 | 610 | 611 | ); 612 | } 613 | } 614 | -------------------------------------------------------------------------------- /frontend/src/components/Create/Create.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from "react"; 2 | import { v4 as uuid } from "uuid"; 3 | import Form from "react-bootstrap/Form"; 4 | import Button from "react-bootstrap/Button"; 5 | import ListGroup from "react-bootstrap/ListGroup"; 6 | import OverlayTrigger from "react-bootstrap/OverlayTrigger"; 7 | import Tooltip from "react-bootstrap/Tooltip"; 8 | import Info from "bootstrap-icons/icons/info-circle.svg"; 9 | import Row from "react-bootstrap/Row"; 10 | import Col from "react-bootstrap/Col"; 11 | import Card from "react-bootstrap/Card"; 12 | import Badge from "react-bootstrap/Badge"; 13 | import LeftArrow from "bootstrap-icons/icons/arrow-left.svg"; 14 | import Image from "react-bootstrap/Image"; 15 | import "../CreateSeq/CreateSeq.css"; 16 | 17 | export default class Create extends Component { 18 | constructor(props) { 19 | super(props); 20 | 21 | if (this.props.isEdit) { 22 | var info = this.props.settings.buckets.find(({ id }) => id === this.props.id.toString()); 23 | 24 | this.state = { 25 | id: info.id, 26 | media: info.media, 27 | name: info.name, 28 | directoryList: [], 29 | selectedList: [], 30 | selectedFileList: [], 31 | root: this.props.settings.settings.loc, 32 | currentDir: info.source === "2" ? info.dir : this.props.settings.settings.loc, 33 | dirTree: [], 34 | isError: false, 35 | isSaved: false, 36 | isIncomplete: false, 37 | player: false, 38 | videoIndex: 0, 39 | tempList: [], 40 | tempLength: 0, 41 | source: info.source ?? "1", 42 | sourceDir: info.dir ?? "", 43 | }; 44 | } else { 45 | this.state = { 46 | media: [], 47 | name: "", 48 | directoryList: [], 49 | selectedList: [], 50 | selectedFileList: [], 51 | root: this.props.settings.settings.loc, 52 | currentDir: this.props.settings.settings.loc, 53 | dirTree: [], 54 | isError: false, 55 | isSaved: false, 56 | isIncomplete: false, 57 | player: false, 58 | videoIndex: 0, 59 | tempList: [], 60 | tempLength: 0, 61 | source: "1", 62 | sourceDir: "", 63 | }; 64 | } 65 | 66 | this.videoRef = createRef(); 67 | this.handleFormSubmit = this.handleFormSubmit.bind(this); 68 | this.handleStreamer = this.handleStreamer.bind(this); 69 | this.fetchDirectoryList = this.fetchDirectoryList.bind(this); 70 | } 71 | 72 | componentDidMount() { 73 | var xhr = new XMLHttpRequest(); 74 | xhr.timeout = 3000; 75 | xhr.addEventListener("readystatechange", () => { 76 | if (xhr.readyState === 4) { 77 | if (xhr.status === 200) { 78 | // request successful 79 | var response = xhr.responseText, 80 | json = JSON.parse(response); 81 | 82 | this.setState((prevState) => { 83 | const updatedDirTree = [ 84 | ...prevState.dirTree, 85 | this.state.root, 86 | ...(this.state.source === "2" 87 | ? this.state.currentDir 88 | .replace(this.state.root, "") 89 | .split("/") 90 | .filter((dir) => dir !== "") 91 | : []), 92 | ]; 93 | return { 94 | dirTree: updatedDirTree, 95 | directoryList: json, 96 | }; 97 | }); 98 | } else { 99 | // error 100 | this.setState({ 101 | isError: true, 102 | errorRes: JSON.parse(xhr.responseText), 103 | }); 104 | } 105 | } 106 | }); 107 | xhr.open("POST", "/backend/directory", true); 108 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 109 | xhr.send( 110 | JSON.stringify({ 111 | dir: `${this.state.source === "2" ? this.state.currentDir : this.state.root}`, 112 | }) 113 | ); 114 | } 115 | 116 | componentDidUpdate(prevProps) { 117 | // Check if the settings prop has changed 118 | if (this.props.settings !== prevProps.settings) { 119 | // Find the relevant bucket if in edit mode 120 | if (this.props.isEdit) { 121 | const info = this.props.settings.buckets.find(({ id }) => id === this.props.id.toString()); 122 | if (info) { 123 | this.setState( 124 | { 125 | media: info.media, 126 | }, 127 | () => { 128 | try { 129 | this.fetchDirectoryList(); 130 | } catch (err) { 131 | console.error("Error calling fetchDirectoryList:", err); 132 | } 133 | } 134 | ); 135 | } 136 | } else { 137 | // Handle the default state for new items 138 | this.setState( 139 | { 140 | media: [], 141 | }, 142 | () => { 143 | try { 144 | this.fetchDirectoryList(); 145 | } catch (err) { 146 | console.error("Error calling fetchDirectoryList:", err); 147 | } 148 | } 149 | ); 150 | } 151 | } 152 | } 153 | 154 | handleClickFiles = (e) => { 155 | e.preventDefault(); 156 | const target = e.currentTarget; 157 | const temp = JSON.parse(target.value); 158 | 159 | this.setState((prevState) => { 160 | const newSelectedList = prevState.selectedFileList.includes(temp) 161 | ? prevState.selectedFileList.filter((item) => item !== temp) 162 | : [...prevState.selectedFileList, temp]; 163 | return { selectedFileList: newSelectedList }; 164 | }); 165 | }; 166 | 167 | handleClick = (e) => { 168 | e.preventDefault(); 169 | const target = e.currentTarget; 170 | var temp = JSON.parse(target.value); 171 | 172 | if (temp.isDir) { 173 | if (temp.name === "..") { 174 | this.setState( 175 | (prevState) => { 176 | // Remove the last directory from the path 177 | const updatedDirTree = prevState.dirTree.slice(0, -1); 178 | const tempDir = updatedDirTree.join("/"); // Construct the new directory path 179 | return { 180 | dirTree: updatedDirTree, 181 | currentDir: tempDir, 182 | selectedList: [], // Clear selected list when navigating directories 183 | }; 184 | }, 185 | () => { 186 | // Callback to handle state changes and perform subsequent actions 187 | this.fetchDirectoryList(); 188 | } 189 | ); 190 | } else { 191 | this.setState( 192 | (prevState) => { 193 | // Add the new directory to the path 194 | const updatedDirTree = [...prevState.dirTree, temp.name]; 195 | const tempDir = updatedDirTree.join("/"); // Construct the new directory path 196 | return { 197 | dirTree: updatedDirTree, 198 | currentDir: tempDir, 199 | selectedList: [], // Clear selected list when navigating directories 200 | }; 201 | }, 202 | () => { 203 | // Callback to handle state changes and perform subsequent actions 204 | this.fetchDirectoryList(); 205 | } 206 | ); 207 | } 208 | } else { 209 | this.setState((prevState) => { 210 | const newSelectedList = prevState.selectedList.includes(temp.name) 211 | ? prevState.selectedList.filter((item) => item !== temp.name) 212 | : [...prevState.selectedList, temp.name]; 213 | return { selectedList: newSelectedList }; 214 | }); 215 | } 216 | }; 217 | 218 | fetchDirectoryList = () => { 219 | var xhr = new XMLHttpRequest(); 220 | xhr.timeout = 3000; 221 | xhr.addEventListener("readystatechange", () => { 222 | if (xhr.readyState === 4) { 223 | if (xhr.status === 200) { 224 | // request successful 225 | var response = xhr.responseText, 226 | json = JSON.parse(response); 227 | 228 | this.setState({ directoryList: json }); 229 | } else { 230 | // error 231 | this.setState({ 232 | isError: true, 233 | errorRes: JSON.parse(xhr.responseText), 234 | }); 235 | } 236 | } 237 | }); 238 | xhr.open("POST", "/backend/directory", true); 239 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 240 | xhr.send(JSON.stringify({ dir: `${this.state.currentDir}` })); 241 | }; 242 | 243 | handleAdd = (e) => { 244 | e.preventDefault(); 245 | const newDir = (this.state.dirTree.length > 1 ? "/" : "") + this.state.dirTree.slice(1).join("/"); 246 | const newMediaList = this.state.selectedList.map((element) => ({ file: element, dir: newDir })); 247 | this.setState((prevState) => ({ 248 | media: [...prevState.media, ...newMediaList], 249 | })); 250 | }; 251 | 252 | handleRemove = (e) => { 253 | e.preventDefault(); 254 | this.setState((prevState) => { 255 | const newMedia = [...prevState.media]; // Create a copy of the current media state 256 | 257 | prevState.selectedFileList.forEach((file) => { 258 | const index = newMedia.findIndex((item) => item.file === file); // Find the index of the first matching element 259 | if (index !== -1) { 260 | newMedia.splice(index, 1); // Remove the first matching element 261 | } 262 | }); 263 | 264 | return { media: newMedia, selectedFileList: [] }; // Update state with the modified media array and clear selectedFileList 265 | }); 266 | }; 267 | 268 | handleSelectAll = () => { 269 | var newList = []; 270 | this.state.directoryList.forEach((element) => { 271 | if (!element.isDir) { 272 | newList.push(element.name); 273 | } 274 | }); 275 | this.setState({ selectedList: newList }); 276 | }; 277 | 278 | handleSelectNone = () => { 279 | this.setState({ selectedList: [] }); 280 | }; 281 | 282 | handleName = (e) => { 283 | this.setState({ name: e.target.value.toString(), isSaved: false }); 284 | }; 285 | 286 | handleFormSubmit = (e) => { 287 | e.preventDefault(); 288 | 289 | this.setState({ isIncomplete: false }); 290 | 291 | if (this.state.name === "" || (this.state.media.length === 0 && this.state.source === "1")) { 292 | this.setState({ isIncomplete: true }); 293 | return; 294 | } 295 | 296 | var settings = { ...this.props.settings }; 297 | 298 | if (!settings.buckets) settings.buckets = []; 299 | 300 | var temp = {}; 301 | 302 | if (this.props.isEdit) { 303 | temp.id = this.state.id; 304 | } else { 305 | temp.id = uuid().toString(); 306 | } 307 | temp.name = this.state.name; 308 | temp.media = this.state.media; 309 | temp.source = this.state.source; 310 | temp.dir = this.state.currentDir; 311 | 312 | if (this.props.isEdit) { 313 | const index = settings.buckets.findIndex(({ id }) => id === this.state.id); 314 | settings.buckets.splice(index, 1, temp); 315 | } else { 316 | settings.buckets.push(temp); 317 | } 318 | 319 | var xhr = new XMLHttpRequest(); 320 | 321 | xhr.addEventListener("readystatechange", async () => { 322 | if (xhr.readyState === 4) { 323 | if (xhr.status === 200) { 324 | this.setState({ isSaved: true }); 325 | 326 | const response = await fetch("/webhook", { method: "GET" }); 327 | if (!response.ok) { 328 | throw new Error(`Response status: ${response.status}`); 329 | } 330 | 331 | const response2 = await fetch("/backend/monitor", { method: "GET" }); 332 | if (!response2.ok) { 333 | throw new Error(`Response status: ${response.status}`); 334 | } 335 | } else { 336 | // error 337 | this.setState({ 338 | error: xhr.responseText, 339 | }); 340 | } 341 | } 342 | }); 343 | 344 | xhr.open("POST", "/backend/save", true); 345 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 346 | xhr.send(JSON.stringify(settings)); 347 | 348 | this.props.saved(); 349 | }; 350 | 351 | handleStreamer = () => { 352 | this.setState( 353 | { 354 | player: true, 355 | videoIndex: 0, 356 | tempList: this.state.selectedList.sort(([fileA], [fileB]) => fileA.localeCompare(fileB)), 357 | tempLength: this.state.selectedList.length, 358 | }, 359 | () => { 360 | if (this.videoRef.current && this.state.tempList.length > 0) { 361 | // Set the initial video source 362 | this.videoRef.current.src = `/backend/streamer${this.state.currentDir}/${ 363 | this.state.tempList[this.state.videoIndex] 364 | }`; 365 | this.videoRef.current.load(); 366 | this.videoRef.current.play(); // Start playing the first video 367 | } 368 | } 369 | ); 370 | }; 371 | 372 | handleVideoEnded = () => { 373 | this.setState((prevState) => { 374 | const nextIndex = prevState.videoIndex + 1; 375 | 376 | if (nextIndex < prevState.tempLength) { 377 | // Update the video index and set the next video source 378 | this.videoRef.current.src = `/backend/streamer${this.state.currentDir}/${prevState.tempList[nextIndex]}`; 379 | this.videoRef.current.load(); 380 | this.videoRef.current.play(); // Start playing the next video 381 | } else { 382 | // All videos have been played 383 | this.setState({ player: false }); 384 | return { videoIndex: nextIndex }; 385 | } 386 | 387 | return { videoIndex: nextIndex }; 388 | }); 389 | }; 390 | 391 | handleStop = () => { 392 | this.setState({ player: false }); 393 | }; 394 | 395 | truncateString = (str, num) => { 396 | return str.length > num ? str.slice(0, num) + "..." : str; 397 | }; 398 | 399 | handleSource = (e) => { 400 | this.setState({ source: e.target.value.toString() }); 401 | }; 402 | 403 | render() { 404 | return ( 405 |
406 | Name of bucket    407 | 408 |
409 | Source of files    410 | 414 | This setting selects whether you wish to manually add files to the bucket or just use a specific 415 | directory. 416 |
417 |
418 | Manual: You select which files are added to your bucket from this page. This allows you to mix and match 419 | files from different directories. 420 |
421 |
422 | Directory: Select a directory to bind to the bucket and Preroll Plus will randomly select files from that 423 | directory when creating the Preroll entry. 424 |
425 |
426 | Note: Using Directory as an option will not allow you to add weights to each file. To accompish weights 427 | you will need to create duplicates of the file in the file system. 428 | 429 | } 430 | > 431 | Info 432 |
433 |
434 | 445 | 456 |
457 |
458 | 459 | {this.state.source === "1" ? ( 460 | <> 461 | 462 | 469 |
470 | {/* File Listing */} 471 |
472 | 473 | 474 | 475 | Files in bucket 476 | 477 | 478 | 479 | 480 | {this.state.media.length === 0 ? ( 481 | <Add Files Here> 482 | ) : ( 483 | Object.entries( 484 | this.state.media.reduce((acc, item) => { 485 | acc[item.file] = (acc[item.file] || 0) + 1; 486 | return acc; 487 | }, {}) 488 | ) 489 | .sort(([fileA], [fileB]) => fileA.localeCompare(fileB)) 490 | .map(([file, count]) => { 491 | const dir = this.state.media.find((item) => item.file === file)?.dir || ""; 492 | const percentage = ((count / this.state.media.length) * 100).toFixed(1); 493 | const truncatedFile = this.truncateString(file, 45); 494 | return this.state.selectedFileList.includes(file) ? ( 495 | 503 | 504 | {truncatedFile} {count > 1 ? `(${count})` : <>} 505 |
506 |
{dir !== "" ? dir : "/"}
507 |
508 | {percentage}% 509 |
510 | ) : ( 511 | 518 | 519 | {file} {count > 1 ? `(${count})` : <>} 520 |
521 |
{dir !== "" ? dir : "/"}
522 |
523 | 524 | {percentage}% 525 |
526 | ); 527 | }) 528 | )} 529 |
530 |
531 |
532 |
533 |
534 | 535 | 536 | 539 |
540 | 541 | 542 | ) : ( 543 | "" 544 | )} 545 | 546 | {this.state.source === "1" ? ( 547 | <> 548 | 551 |    552 | 555 |    556 | 559 | 560 | ) : ( 561 | "" 562 | )} 563 | 564 |
565 | {/* Directory Listing */} 566 | 567 |
568 | {" "} 569 | {this.props.sockConnected ? ( 570 | "" 571 | ) : this.props.cannotConnect ? ( 572 |
573 | Unable to reconnect. Restart server then refresh page. 574 |
575 | ) : ( 576 |
577 | Lost connection to backend, trying to reconnect... 578 |
579 | )} 580 | 581 | 582 | 583 | {this.state.currentDir} 584 | 585 | 586 | 587 | 588 | {this.state.currentDir !== `${this.state.root}` ? ( 589 | 595 | ../ 596 | 597 | ) : null} 598 | {this.state.directoryList ? ( 599 | this.state.directoryList 600 | .filter((file) => !file.name.startsWith(".") && !file.name.startsWith("@")) // Filter out files starting with . or @ 601 | .map((file) => 602 | file.isDir ? ( 603 | 610 | {file.name}/ 611 | 612 | ) : this.state.source === "1" ? ( 613 | this.state.selectedList.includes(file.name) ? ( 614 | 622 | {file.name} 623 | 624 | ) : ( 625 | 632 | {file.name} 633 | 634 | ) 635 | ) : ( 636 | "" 637 | ) 638 | ) 639 | ) : ( 640 | Directory does not exist // Display this if directoryList is null or empty 641 | )} 642 | 643 | 644 | 645 |
646 |
647 | 648 | {this.state.player ? ( 649 | 650 | 651 | 652 |
653 | 663 |
664 |
665 | 668 | 669 | ) : ( 670 | <> 671 | )} 672 | 673 | 676 |    677 | {this.props.isEdit ? ( 678 | 681 | ) : ( 682 | 685 | )} 686 |    687 | {this.state.isIncomplete ? ( 688 | 689 |   There must be at least one item in the list and/or a bucket name must be filled.{" "} 690 | 691 | ) : ( 692 | <> 693 | )} 694 | {this.state.isSaved ?   Settings saved. : <>} 695 |
696 | 697 | ); 698 | } 699 | } 700 | --------------------------------------------------------------------------------