├── .dockerignore ├── .gitignore ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── LICENSE.md ├── README.md ├── app.js ├── backend ├── directory.js ├── holiday.js ├── load.js ├── logger.js ├── monitor.js ├── save.js ├── settings.js ├── streamer.js ├── thumb.js └── websocket.js ├── bin └── www ├── frontend ├── index.js ├── package-lock.json ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-384x384.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt └── src │ ├── components │ ├── App │ │ ├── Announce.jsx │ │ ├── App.css │ │ └── App.jsx │ ├── Bucket │ │ └── Bucket.jsx │ ├── Buckets │ │ └── Buckets.jsx │ ├── Create │ │ └── Create.jsx │ ├── CreateSeq │ │ ├── CreateSeq.css │ │ └── CreateSeq.jsx │ ├── Login │ │ └── Login.jsx │ ├── Sequence │ │ ├── Sequence.css │ │ └── Sequence.jsx │ ├── Sequences │ │ ├── Sequences.css │ │ └── Sequences.jsx │ └── Settings │ │ ├── Settings.css │ │ └── Settings.jsx │ ├── images │ ├── add-icon.png │ ├── icons8-restart-24.png │ ├── icons8-restart.gif │ └── loading-gif.gif │ ├── index.css │ ├── index.js │ ├── reportWebVitals.js │ └── setupTests.js ├── history.md ├── package-lock.json ├── package.json ├── version.json └── webhook └── index.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | - Can be run natively or in a Docker container. 50 | 51 | ### Support 52 | 53 | - [Discussions](https://github.com/chadwpalm/PrerollPlus/discussions) 54 | - [Issues](https://github.com/chadwpalm/PrerollPlus/issues) 55 | 56 | ### Licenses 57 | 58 | - [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) 59 | - Copyright 2024 60 | 61 | _Preroll Plus is not affiliated or endorsed by Plex Inc._ 62 | -------------------------------------------------------------------------------- /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 | 18 | var app = express(); 19 | 20 | // logger.token("customDate", function () { 21 | // var current_ob = new Date(); 22 | // var date = "[" + current_ob.toLocaleDateString("en-CA") + " " + current_ob.toLocaleTimeString("en-GB") + "]"; 23 | // return date; 24 | // }); 25 | // app.use(logger(":customDate [INFO] :method :url - Status: :status")); 26 | app.use(express.json()); 27 | app.use(express.urlencoded({ extended: true })); 28 | app.use(cookieParser()); 29 | app.use(express.static(path.join(__dirname, "frontend/production"))); 30 | 31 | app.use("/", uiRouter); 32 | app.use("/backend/logger", logger); 33 | app.use("/backend/load", load); 34 | app.use("/backend/save", save); 35 | app.use("/backend/thumb", thumb); 36 | app.use("/backend/settings", settings); 37 | app.use("/backend/directory", directory); 38 | app.use("/backend/streamer", streamer); 39 | app.use("/backend/monitor", monitor); 40 | app.use("/backend/holiday", holiday); 41 | 42 | app.use("/webhook", webhookRouter); 43 | app.use("/*", uiRouter); 44 | 45 | // catch 404 and forward to error handler 46 | app.use(function (req, res, next) { 47 | next(createError(404)); 48 | }); 49 | 50 | // error handler 51 | app.use(function (err, req, res, next) { 52 | console.error(err); 53 | // set locals, only providing error in development 54 | res.locals.message = err.message; 55 | res.locals.error = req.app.get("env") === "development" ? err : {}; 56 | 57 | // render the error page 58 | res.status(err.status || 500); 59 | res.render("error"); 60 | }); 61 | 62 | module.exports = app; 63 | -------------------------------------------------------------------------------- /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/holiday.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var fs = require("fs"); 4 | var axios = require("axios").default; 5 | 6 | router.post("/", async function (req, res, next) { 7 | console.log(req.body.country); 8 | 9 | const today = new Date(); 10 | today.setHours(0, 0, 0, 0); 11 | const currentYear = today.getFullYear(); 12 | 13 | let data; 14 | 15 | var url = `https://date.nager.at/api/v3/publicholidays/${currentYear}/${req.body.country}`; 16 | 17 | await axios 18 | .get(url, { 19 | timeout: 2000, 20 | headers: { 21 | "Content-Type": "application/json;charset=UTF-8", 22 | }, 23 | }) 24 | .then(function (response) { 25 | data = JSON.stringify(response.data); 26 | }) 27 | .catch(function (error) { 28 | console.error("Error while trying to connect to the Public Holiday API: ", error.message); 29 | }); 30 | 31 | res.send(data); 32 | }); 33 | 34 | module.exports = router; 35 | -------------------------------------------------------------------------------- /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 | 7 | var appVersion, branch, UID, GID, build; 8 | 9 | try { 10 | var info = fs.readFileSync("version.json"); 11 | appVersion = JSON.parse(info).version; 12 | branch = JSON.parse(info).branch; 13 | } catch (err) { 14 | console.error("Cannot grab version and branch info", err); 15 | } 16 | 17 | if (process.env.PUID) { 18 | UID = Number(process.env.PUID); 19 | } else { 20 | UID = os.userInfo().uid; 21 | } 22 | 23 | if (process.env.PGID) { 24 | GID = Number(process.env.PGID); 25 | } else { 26 | GID = os.userInfo().gid; 27 | } 28 | 29 | if (process.env.BUILD) { 30 | build = process.env.BUILD; 31 | } else { 32 | build = "Native"; 33 | } 34 | 35 | var fileData = `{"connected": "false","platform":"${ 36 | os.platform 37 | }","uuid":"${uuid()}","version":"${appVersion}","branch":"${branch}","build":"${build}", "sequences": [], "buckets": [],"message":true}`; 38 | 39 | try { 40 | fileData = fs.readFileSync("/config/settings.js"); 41 | var temp = JSON.parse(fileData); 42 | 43 | if (build !== "Native") { 44 | temp.settings.loc = "/prerolls"; 45 | } 46 | 47 | if (temp.version !== appVersion || temp.build !== build || temp.branch !== branch) { 48 | console.info( 49 | "Version updated from", 50 | temp.version, 51 | "build", 52 | temp.build, 53 | "branch", 54 | temp.branch, 55 | "to", 56 | appVersion, 57 | "build", 58 | build, 59 | "branch", 60 | branch 61 | ); 62 | temp.version = appVersion; 63 | temp.build = build; 64 | temp.branch = branch; 65 | temp.message = true; 66 | 67 | delete temp["token"]; 68 | } 69 | 70 | fs.writeFileSync("/config/settings.js", JSON.stringify(temp)); 71 | fs.chownSync("/config/settings.js", UID, GID, (err) => { 72 | if (err) throw err; 73 | }); 74 | console.info(`Config file updated to UID: ${UID} GID: ${GID}`); 75 | console.info("Settings file read"); 76 | } catch (err) { 77 | console.info("Settings file not found, creating"); 78 | try { 79 | if (!fs.existsSync("/config")) { 80 | fs.mkdirSync("/config"); 81 | } 82 | fs.writeFileSync("/config/settings.js", fileData); 83 | console.info("Settings file created"); 84 | fs.chownSync("/config/settings.js", UID, GID, (err) => { 85 | if (err) throw err; 86 | }); 87 | console.info(`Config file set to UID: ${UID} GID: ${GID}`); 88 | } catch (err) { 89 | if (err) throw err; 90 | } 91 | } 92 | 93 | router.get("/", function (req, res, next) { 94 | try { 95 | fileData = fs.readFileSync("/config/settings.js"); 96 | console.info("Settings file read"); 97 | } catch (err) { 98 | console.error("Settings file not found"); 99 | } 100 | 101 | res.send(fileData); 102 | }); 103 | 104 | module.exports = router; 105 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "dependencies": { 5 | "@testing-library/jest-dom": "^5.16.4", 6 | "axios": "^1.7.9", 7 | "bootstrap": "^5.2.3", 8 | "bootstrap-icons": "^1.10.3", 9 | "cross-env": "^7.0.3", 10 | "plex-oauth": "^2.1.0", 11 | "react": "^18.1.0", 12 | "react-bootstrap": "^2.7.0", 13 | "react-dom": "^18.1.0", 14 | "react-router-bootstrap": "^0.26.2", 15 | "react-router-dom": "^6.0.0", 16 | "react-scripts": "^5.0.1", 17 | "uuid": "^9.0.0", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "cross-env BUILD_PATH='./production' react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/public/android-chrome-384x384.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/public/favicon.ico -------------------------------------------------------------------------------- /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/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/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /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 | Holiday Update v1.1.0 22 |
23 |
24 | This version of Preroll Plus now supports adding holidays for sequence scheduling. 25 |
26 |
27 | Any holidays used for a schedule will take precedence over all other schedules. For example, if you have a 28 | schedule between 5/20 and 5/30, and you add the Memorial Day schedule (which falls on 5/26/25) the Memorial Day 29 | schedule will be active on that day overriding the other schedule. 30 |
31 |
32 | Notes: 33 |
34 |
35 | The holiday scheduling uses an online API (https://date.nager.at/) to get its information. This means two 36 | things: 37 |
38 |
39 | 1. The schedule supports 121 countries, but you are limited to which public holidays the API provides. 40 |
41 | 2. Since the information is pulling from an online API, your instance of Preroll Plus must have access to the 42 | internet. This should not be a problem since Preroll Plus requires internet access to connect to your Plex 43 | account for retrieving Plex Servers in the settings. 44 |
45 |
46 | This particular API was chosen due to in not needing an API key and having no limitations on access. Perhaps 47 | some time in the future an API with more holidays can be found and used. 48 |
49 |
50 | 59 |
60 |
61 | 64 |
65 |
66 | ); 67 | }; 68 | 69 | export default Announce; 70 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | 26 | export default class App extends Component { 27 | state = { 28 | isLoaded: false, 29 | isConnected: false, 30 | error: null, 31 | config: {}, 32 | show: false, 33 | fullscreen: true, 34 | fullscreenAnn: true, 35 | isLoggedIn: false, 36 | isUpdate: false, 37 | isOnline: true, 38 | announce: false, 39 | first: false, 40 | dismiss: false, 41 | isDarkMode: false, 42 | announcement: false, //master key to show an announcement after version update 43 | sockConnected: false, 44 | cannotConnect: false, 45 | reconnectAttempts: 0, 46 | }; 47 | 48 | componentDidMount() { 49 | this.connectWebSocket(); 50 | 51 | var xhr = new XMLHttpRequest(); 52 | var state = false; 53 | var online = true; 54 | 55 | xhr.addEventListener("readystatechange", async () => { 56 | if (xhr.readyState === 4) { 57 | if (xhr.status === 200) { 58 | // request successful 59 | var response = xhr.responseText, 60 | json = JSON.parse(response); 61 | 62 | var url = ""; 63 | 64 | if (json.branch === "dev") { 65 | url = `https://raw.githubusercontent.com/chadwpalm/PrerollPlus/develop/version.json?cb=${Date.now()}`; 66 | 67 | await axios 68 | .get(url, { headers: { "Content-Type": "application/json;charset=UTF-8" } }) 69 | .then(function (response) { 70 | var data = response.data; 71 | if (data.version !== json.version) { 72 | state = true; 73 | } 74 | }) 75 | .catch(function (error) { 76 | online = false; 77 | }); 78 | } else { 79 | url = `https://raw.githubusercontent.com/chadwpalm/PrerollPlus/main/version.json`; 80 | 81 | await axios 82 | .get(url, { headers: { "Content-Type": "application/json;charset=UTF-8" } }) 83 | .then(function (response) { 84 | var data = response.data; 85 | 86 | if (data.version !== json.version) { 87 | state = true; 88 | } 89 | }) 90 | .catch(function (error) {}); 91 | } 92 | 93 | if (!online) { 94 | this.setState({ isOnline: false }); 95 | } else { 96 | this.setState({ 97 | isLoaded: true, 98 | config: json, 99 | thumb: json.thumb, 100 | }); 101 | 102 | if (state) this.setState({ isUpdate: true }); 103 | 104 | if (json.token) this.setState({ isLoggedIn: true }); 105 | 106 | if (json.connected === "true") { 107 | this.setState({ isConnected: true }); 108 | } 109 | 110 | if (json.message) this.setState({ first: true }); 111 | 112 | this.setState({ isDarkMode: json.darkMode }, () => { 113 | this.toggleBodyClass(); 114 | }); 115 | } 116 | } else { 117 | // error 118 | this.setState({ 119 | isLoaded: true, 120 | error: xhr.responseText, 121 | }); 122 | } 123 | } 124 | }); 125 | 126 | xhr.open("GET", "/backend/load", true); 127 | xhr.send(); 128 | } 129 | 130 | connectWebSocket() { 131 | const protocol = window.location.protocol === "https:" ? "wss" : "ws"; 132 | this.ws = new WebSocket(`${protocol}://${window.location.host}`); 133 | 134 | this.ws.onopen = () => { 135 | console.log("WebSocket connection opened"); 136 | this.setState({ sockConnected: true, reconnectAttempts: 0 }); 137 | }; 138 | 139 | this.ws.onmessage = (event) => { 140 | if (event.data === "update-config") { 141 | this.refreshConfig(); 142 | } 143 | }; 144 | 145 | this.ws.onclose = () => { 146 | console.log("WebSocket connection closed"); 147 | this.setState({ sockConnected: false }); 148 | 149 | if (this.state.reconnectAttempts < 2) { 150 | console.log("Attempting to reconnect..."); 151 | this.setState( 152 | (prevState) => ({ 153 | reconnectAttempts: prevState.reconnectAttempts + 1, 154 | }), 155 | () => { 156 | setTimeout(() => { 157 | this.connectWebSocket(); // Reconnect 158 | }, 3000); // Delay before reconnecting 159 | } 160 | ); 161 | } else { 162 | console.log("Max reconnect attempts reached."); 163 | this.setState({ cannotConnect: true }); 164 | } 165 | }; 166 | 167 | this.ws.onerror = (error) => { 168 | console.error("WebSocket error:", error); 169 | }; 170 | } 171 | 172 | refreshConfig() { 173 | axios 174 | .get("/backend/load") 175 | .then((response) => { 176 | this.setState({ 177 | config: response.data, 178 | }); 179 | }) 180 | .catch((error) => { 181 | console.error("Error refreshing config:", error); 182 | }); 183 | } 184 | 185 | toggleBodyClass = () => { 186 | if (this.state.isDarkMode) { 187 | document.body.classList.add("dark-mode"); 188 | } else { 189 | document.body.classList.remove("dark-mode"); 190 | } 191 | }; 192 | 193 | handleLogin = () => { 194 | window.location.reload(false); 195 | // this.setState({ isLoggedIn: true }); 196 | }; 197 | 198 | handleConnectionChange = (change) => { 199 | change ? this.setState({ isConnected: true }) : this.setState({ isConnected: false }); 200 | }; 201 | 202 | handleClose = () => this.setState({ show: false }); 203 | 204 | handleOpen = () => this.setState({ show: true, fullscreen: "md-down" }); 205 | 206 | handleCloseAnn = () => { 207 | this.setState({ announce: false }); 208 | if (this.state.dismiss) { 209 | var settings = { ...this.state.config }; 210 | 211 | settings.message = false; 212 | 213 | var xhr = new XMLHttpRequest(); 214 | 215 | xhr.addEventListener("readystatechange", () => { 216 | if (xhr.readyState === 4) { 217 | if (xhr.status === 200) { 218 | } else { 219 | // error 220 | this.setState({ 221 | error: xhr.responseText, 222 | }); 223 | } 224 | } 225 | }); 226 | 227 | xhr.open("POST", "/backend/save", true); 228 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 229 | xhr.send(JSON.stringify(settings)); 230 | } 231 | }; 232 | 233 | handleOpenAnn = () => this.setState({ announce: true, first: false, fullscreenAnn: "md-down" }); 234 | 235 | handleDismiss = () => { 236 | if (this.state.dismiss) { 237 | this.setState({ dismiss: false }); 238 | } else { 239 | this.setState({ dismiss: true }); 240 | } 241 | }; 242 | 243 | handleUpdateThumb = (thumb, token, username, email) => { 244 | var temp = this.state.config; 245 | temp.token = token; 246 | temp.thumb = thumb; 247 | temp.email = email; 248 | temp.username = username; 249 | this.setState({ config: temp }); 250 | }; 251 | 252 | handleLogout = () => { 253 | var settings = { ...this.state.config }; 254 | 255 | delete settings["token"]; 256 | delete settings["thumb"]; 257 | delete settings["email"]; 258 | delete settings["username"]; 259 | 260 | var xhr = new XMLHttpRequest(); 261 | 262 | xhr.addEventListener("readystatechange", () => { 263 | if (xhr.readyState === 4) { 264 | if (xhr.status === 200) { 265 | this.setState({ isLoggedIn: false }); 266 | } else { 267 | // error 268 | this.setState({ 269 | error: xhr.responseText, 270 | }); 271 | } 272 | } 273 | }); 274 | 275 | xhr.open("POST", "/backend/save", true); 276 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 277 | xhr.send(JSON.stringify(settings)); 278 | }; 279 | 280 | updateSettings = (newSettings) => { 281 | this.setState({ config: newSettings }); 282 | }; 283 | 284 | handleDark = () => { 285 | this.setState((prevState) => { 286 | const newMode = !prevState.isDarkMode; 287 | 288 | var settings = { ...prevState.config, darkMode: newMode }; 289 | 290 | var xhr = new XMLHttpRequest(); 291 | 292 | xhr.addEventListener("readystatechange", () => { 293 | if (xhr.readyState === 4) { 294 | if (xhr.status === 200) { 295 | } else { 296 | // error 297 | this.setState({ 298 | error: xhr.responseText, 299 | }); 300 | } 301 | } 302 | }); 303 | 304 | xhr.open("POST", "/backend/save", true); 305 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 306 | xhr.send(JSON.stringify(settings)); 307 | 308 | return { isDarkMode: newMode, config: settings }; 309 | }, this.toggleBodyClass); 310 | }; 311 | 312 | render() { 313 | if (!this.state.isOnline) { 314 | return ( 315 | <> 316 | Preroll Plus requires an internet connection. If you are running Preroll Plus in Docker, check your Docker 317 | network settings. 318 | 319 | ); 320 | } else { 321 | if (!this.state.isLoaded) { 322 | // is loading 323 | return ( 324 |
325 | Loading 326 |
327 | ); 328 | } else if (this.state.error) { 329 | // error 330 | return
Error occured: {this.state.error}
; 331 | } else { 332 | if (this.state.isLoggedIn) { 333 | // success 334 | return ( 335 | 336 | 337 | 338 | 339 | 340 |
341 |

342 | Preroll Plus 343 |

344 |
345 |
346 | 347 | 348 | 375 | 429 | 430 |
431 |
432 | 433 | {this.state.first ? this.handleOpenAnn() : <>} 434 | 442 | 443 | About 444 | 445 | 446 | Version:  {this.state.config.version} 447 |
448 | Branch:  {this.state.config.branch} 449 |
450 | Build:  {this.state.config.build} 451 |
452 | Config Dir:  /config 453 |
454 | App Dir:  /PrerollPlus 455 |
456 | Docker:  457 | 462 | chadwpalm/prerollplus 463 | 464 |
465 | Source:  466 | 467 | github.com/chadwpalm/PrerollPlus 468 | 469 |
470 |
471 | {this.state.announcement ? ( 472 | 480 | ) : ( 481 | <> 482 | )} 483 | 484 | 485 | 486 | 494 | } 495 | /> 496 | 497 | {!this.state.isConnected ? ( 498 | } /> 499 | ) : ( 500 | <> 501 | 509 | } 510 | /> 511 | 521 | } 522 | /> 523 | } /> 524 | 525 | )} 526 | 527 | 528 |
529 |
530 | ); 531 | } else { 532 | return ( 533 | 534 | 535 | } /> 536 | 544 | } 545 | /> 546 | 547 | 548 | ); 549 | } 550 | } 551 | } 552 | } 553 | } 554 | -------------------------------------------------------------------------------- /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 | name: "", 16 | active: true, 17 | }; 18 | 19 | this.state.id = this.props.id; 20 | this.state.name = this.props.bucket; 21 | this.handleClick = this.handleClick.bind(this.props.id); 22 | } 23 | 24 | handleClick = (e) => { 25 | this.props.click(e.currentTarget.value); 26 | }; 27 | 28 | handleDelete = () => { 29 | this.props.delete(this.state.id); 30 | }; 31 | 32 | render() { 33 | return ( 34 | 42 | 43 | {this.props.isEdit || this.props.isCreating ? ( 44 | Close 45 | ) : ( 46 | Close 47 | )} 48 | 49 | 50 | {this.state.name} 51 | 52 | 53 | 54 | 55 | 56 |
57 | {this.props.isEdit || this.props.isCreating ? ( 58 | Edit 59 | ) : ( 60 | 63 | )} 64 |
65 | 66 |
67 |
68 |
69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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 "../Sequences/Sequences.css"; 11 | 12 | export default class Buckets extends Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | buckets: this.props.settings.buckets, 17 | isCreating: false, 18 | id: "-1", 19 | isEdit: false, 20 | show: false, 21 | tempID: "", 22 | }; 23 | } 24 | 25 | handleAddBucket = () => { 26 | this.setState({ 27 | isCreating: true, 28 | isEdit: false, 29 | id: "-1", 30 | }); 31 | }; 32 | 33 | handleEditBucket = (e) => { 34 | this.setState({ 35 | isCreating: true, 36 | isEdit: true, 37 | id: e, 38 | }); 39 | }; 40 | 41 | handleCancelCreate = () => { 42 | this.setState({ isCreating: false, isEdit: false }); 43 | }; 44 | 45 | handleSaveCreate = () => { 46 | this.setState({ isCreating: false, isEdit: false }); 47 | }; 48 | 49 | handleClose = () => this.setState({ show: false }); 50 | 51 | handleOpen = (e) => { 52 | this.setState({ tempID: e }); 53 | this.setState({ show: true, fullscreen: "md-down" }); 54 | }; 55 | 56 | handleDelete = () => { 57 | var settings = { ...this.props.settings }; 58 | 59 | const index = settings.buckets.findIndex(({ id }) => id === this.state.tempID); 60 | 61 | settings.buckets.splice(index, 1); 62 | 63 | settings.sequences = settings.sequences.map((sequence) => ({ 64 | ...sequence, 65 | buckets: sequence.buckets.filter((bucketId) => bucketId.id !== this.state.tempID), 66 | })); 67 | 68 | var xhr = new XMLHttpRequest(); 69 | 70 | xhr.addEventListener("readystatechange", async () => { 71 | if (xhr.readyState === 4) { 72 | if (xhr.status === 200) { 73 | this.setState({ show: false }); 74 | 75 | const response = await fetch("/webhook", { method: "GET" }); 76 | if (!response.ok) { 77 | throw new Error(`Response status: ${response.status}`); 78 | } 79 | } else { 80 | // error 81 | this.setState({ 82 | show: false, 83 | error: xhr.responseText, 84 | }); 85 | } 86 | } 87 | }); 88 | 89 | xhr.open("POST", "/backend/save", true); 90 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 91 | xhr.send(JSON.stringify(settings)); 92 | 93 | this.props.updateSettings(settings); 94 | this.handleSaveCreate(); 95 | }; 96 | 97 | render() { 98 | return ( 99 | <> 100 | 101 |

Buckets

102 |
103 | 104 | {this.state.buckets?.map((bucket) => ( 105 | 106 | 119 |
120 | 121 | ))} 122 | 123 | 124 | {this.state.isEdit || this.state.isCreating ? ( 125 | 130 | 131 | Add 132 | 133 | 134 | ) : ( 135 | 139 | 140 | Add 141 | 142 | 143 | )} 144 | 145 |
146 | 147 | {this.state.isCreating ? ( 148 | 158 | ) : ( 159 |
Click the plus to add a new Bucket.
160 | )} 161 |
162 | 170 | 171 |

Are you sure?

172 | 175 |     176 | 179 |
180 |
181 | 182 | ); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /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 a bucket name must be filled.{" "} 690 | 691 | ) : ( 692 | <> 693 | )} 694 | {this.state.isSaved ?   Settings saved. : <>} 695 |
696 | 697 | ); 698 | } 699 | } 700 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /frontend/src/components/CreateSeq/CreateSeq.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } 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 Row from "react-bootstrap/Row"; 7 | import Col from "react-bootstrap/Col"; 8 | import Card from "react-bootstrap/Card"; 9 | import Stack from "react-bootstrap/Stack"; 10 | import UpArrow from "bootstrap-icons/icons/arrow-up.svg"; 11 | import LeftArrow from "bootstrap-icons/icons/arrow-left.svg"; 12 | import DownArrow from "bootstrap-icons/icons/arrow-down.svg"; 13 | import DownArrowShort from "bootstrap-icons/icons/arrow-down-short.svg"; 14 | import Modal from "react-bootstrap/Modal"; 15 | import Image from "react-bootstrap/Image"; 16 | import "./CreateSeq.css"; 17 | 18 | export default class Create extends Component { 19 | constructor(props) { 20 | super(props); 21 | 22 | if (this.props.isEdit) { 23 | var info = this.props.settings.sequences.find(({ id }) => id === this.props.id.toString()); 24 | 25 | this.state = { 26 | id: info.id, 27 | name: info.name, 28 | startMonth: info.startMonth, 29 | startDay: info.startDay, 30 | endMonth: info.endMonth, 31 | endDay: info.endDay, 32 | schedule: info.schedule, 33 | country: info.country ?? "US", 34 | holiday: info.holiday, 35 | holidayList: [], 36 | buckets: info.buckets.map((bucket) => ({ ...bucket, uid: uuid() })), 37 | selectedBucket: {}, 38 | selectedSequence: {}, 39 | isError: false, 40 | isSaved: false, 41 | isIncomplete: false, 42 | isIncompleteHoliday: false, 43 | show: false, 44 | showOverlapWarning: false, 45 | }; 46 | } else { 47 | this.state = { 48 | name: "", 49 | startMonth: "1", 50 | startDay: "1", 51 | endMonth: "1", 52 | endDay: "1", 53 | schedule: "2", 54 | country: "US", 55 | holiday: "-1", 56 | buckets: [], 57 | holidayList: [], 58 | selectedBucket: {}, 59 | selectedSequence: {}, 60 | isError: false, 61 | isSaved: false, 62 | isIncomplete: false, 63 | isIncompleteHoliday: false, 64 | show: false, 65 | showOverlapWarning: false, 66 | }; 67 | } 68 | 69 | this.handleFormSubmit = this.handleFormSubmit.bind(this); 70 | 71 | this.monthList = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 72 | 73 | this.countryNames = [ 74 | "Albania", 75 | "Andorra", 76 | "Argentina", 77 | "Armenia", 78 | "Australia", 79 | "Austria", 80 | "Bahamas", 81 | "Barbados", 82 | "Belarus", 83 | "Belgium", 84 | "Belize", 85 | "Benin", 86 | "Bolivia", 87 | "Bosnia and Herzegovina", 88 | "Botswana", 89 | "Brazil", 90 | "Bulgaria", 91 | "Canada", 92 | "Chile", 93 | "China", 94 | "Colombia", 95 | "Costa Rica", 96 | "Croatia", 97 | "Cuba", 98 | "Cyprus", 99 | "Czechia", 100 | "Denmark", 101 | "Dominican Republic", 102 | "Ecuador", 103 | "Egypt", 104 | "El Salvador", 105 | "Estonia", 106 | "Faroe Islands", 107 | "Finland", 108 | "France", 109 | "Gabon", 110 | "Gambia", 111 | "Georgia", 112 | "Germany", 113 | "Gibraltar", 114 | "Greece", 115 | "Greenland", 116 | "Grenada", 117 | "Guatemala", 118 | "Guernsey", 119 | "Guyana", 120 | "Haiti", 121 | "Honduras", 122 | "Hong Kong", 123 | "Hungary", 124 | "Iceland", 125 | "Indonesia", 126 | "Ireland", 127 | "Isle of Man", 128 | "Italy", 129 | "Jamaica", 130 | "Japan", 131 | "Jersey", 132 | "Kazakhstan", 133 | "Latvia", 134 | "Lesotho", 135 | "Liechtenstein", 136 | "Lithuania", 137 | "Luxembourg", 138 | "Madagascar", 139 | "Malta", 140 | "Mexico", 141 | "Moldova", 142 | "Monaco", 143 | "Mongolia", 144 | "Montenegro", 145 | "Montserrat", 146 | "Morocco", 147 | "Mozambique", 148 | "Namibia", 149 | "Netherlands", 150 | "New Zealand", 151 | "Nicaragua", 152 | "Niger", 153 | "Nigeria", 154 | "North Macedonia", 155 | "Norway", 156 | "Panama", 157 | "Papua New Guinea", 158 | "Paraguay", 159 | "Peru", 160 | "Poland", 161 | "Portugal", 162 | "Puerto Rico", 163 | "Romania", 164 | "Russia", 165 | "San Marino", 166 | "Serbia", 167 | "Singapore", 168 | "Slovakia", 169 | "Slovenia", 170 | "South Africa", 171 | "South Korea", 172 | "Spain", 173 | "Suriname", 174 | "Svalbard and Jan Mayen", 175 | "Sweden", 176 | "Switzerland", 177 | "Tunisia", 178 | "Turkey", 179 | "Ukraine", 180 | "United Kingdom", 181 | "United States", 182 | "Uruguay", 183 | "Vatican City", 184 | "Venezuela", 185 | "Vietnam", 186 | "Zimbabwe", 187 | ]; 188 | 189 | this.countryCodes = [ 190 | "AL", 191 | "AD", 192 | "AR", 193 | "AM", 194 | "AU", 195 | "AT", 196 | "BS", 197 | "BB", 198 | "BY", 199 | "BE", 200 | "BZ", 201 | "BJ", 202 | "BO", 203 | "BA", 204 | "BW", 205 | "BR", 206 | "BG", 207 | "CA", 208 | "CL", 209 | "CN", 210 | "CO", 211 | "CR", 212 | "HR", 213 | "CU", 214 | "CY", 215 | "CZ", 216 | "DK", 217 | "DO", 218 | "EC", 219 | "EG", 220 | "SV", 221 | "EE", 222 | "FO", 223 | "FI", 224 | "FR", 225 | "GA", 226 | "GM", 227 | "GE", 228 | "DE", 229 | "GI", 230 | "GR", 231 | "GL", 232 | "GD", 233 | "GT", 234 | "GG", 235 | "GY", 236 | "HT", 237 | "HN", 238 | "HK", 239 | "HU", 240 | "IS", 241 | "ID", 242 | "IE", 243 | "IM", 244 | "IT", 245 | "JM", 246 | "JP", 247 | "JE", 248 | "KZ", 249 | "LV", 250 | "LS", 251 | "LI", 252 | "LT", 253 | "LU", 254 | "MG", 255 | "MT", 256 | "MX", 257 | "MD", 258 | "MC", 259 | "MN", 260 | "ME", 261 | "MS", 262 | "MA", 263 | "MZ", 264 | "NA", 265 | "NL", 266 | "NZ", 267 | "NI", 268 | "NE", 269 | "NG", 270 | "MK", 271 | "NO", 272 | "PA", 273 | "PG", 274 | "PY", 275 | "PE", 276 | "PL", 277 | "PT", 278 | "PR", 279 | "RO", 280 | "RU", 281 | "SM", 282 | "RS", 283 | "SG", 284 | "SK", 285 | "SI", 286 | "ZA", 287 | "KR", 288 | "ES", 289 | "SR", 290 | "SJ", 291 | "SE", 292 | "CH", 293 | "TN", 294 | "TR", 295 | "UA", 296 | "GB", 297 | "US", 298 | "UY", 299 | "VA", 300 | "VE", 301 | "VN", 302 | "ZW", 303 | ]; 304 | } 305 | 306 | componentDidMount() { 307 | let country = { country: this.state.country }; 308 | 309 | var xhr = new XMLHttpRequest(); 310 | 311 | xhr.addEventListener("readystatechange", () => { 312 | if (xhr.readyState === 4) { 313 | if (xhr.status === 200) { 314 | var response = xhr.responseText, 315 | json = JSON.parse(response); 316 | this.setState({ holidayList: json }); 317 | } else { 318 | this.setState({ 319 | error: xhr.responseText, 320 | }); 321 | } 322 | } 323 | }); 324 | 325 | xhr.open("POST", "/backend/holiday", true); 326 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 327 | xhr.send(JSON.stringify(country)); 328 | } 329 | 330 | handleDate = (e) => { 331 | this.setState({ [e.target.name]: e.target.value.toString() }); 332 | }; 333 | 334 | handleSchedule = (e) => { 335 | this.setState({ schedule: e.target.value.toString() }); 336 | }; 337 | 338 | handleHoliday = (e) => { 339 | this.setState({ holiday: e.target.value.toString() }); 340 | }; 341 | 342 | handleCountry = (e) => { 343 | this.setState({ country: e.target.value.toString() }); 344 | 345 | let country = { country: e.target.value.toString() }; 346 | 347 | var xhr = new XMLHttpRequest(); 348 | 349 | xhr.addEventListener("readystatechange", () => { 350 | if (xhr.readyState === 4) { 351 | if (xhr.status === 200) { 352 | var response = xhr.responseText, 353 | json = JSON.parse(response); 354 | this.setState({ holidayList: json }); 355 | } else { 356 | this.setState({ 357 | error: xhr.responseText, 358 | }); 359 | } 360 | } 361 | }); 362 | 363 | xhr.open("POST", "/backend/holiday", true); 364 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 365 | xhr.send(JSON.stringify(country)); 366 | }; 367 | 368 | handleClickBuckets = (e) => { 369 | e.preventDefault(); 370 | const target = e.currentTarget; 371 | var temp = JSON.parse(target.value); 372 | 373 | this.setState({ selectedSequence: temp }); 374 | }; 375 | 376 | handleClick = (e) => { 377 | e.preventDefault(); 378 | const target = e.currentTarget; 379 | var temp = JSON.parse(target.value); 380 | 381 | this.setState({ selectedBucket: temp }); 382 | }; 383 | 384 | handleAdd = () => { 385 | // Check if selectedBucket is not undefined and has at least one key 386 | if (this.state.selectedBucket && Object.keys(this.state.selectedBucket).length > 0) { 387 | this.setState((prevState) => ({ 388 | buckets: [ 389 | ...prevState.buckets, 390 | { id: this.state.selectedBucket.id, uid: uuid() }, // Add the object if valid 391 | ], 392 | })); 393 | } 394 | }; 395 | 396 | handleRemove = (e) => { 397 | e.preventDefault(); 398 | this.setState((prevState) => { 399 | const newBuckets = [...prevState.buckets]; // Create a copy of the current media state 400 | 401 | const index = newBuckets.findIndex((item) => item.uid === this.state.selectedSequence.uid); // Find the index of the first matching element 402 | if (index !== -1) { 403 | newBuckets.splice(index, 1); // Remove the first matching element 404 | } 405 | 406 | return { buckets: newBuckets, selectedSequence: {} }; // Update state with the modified media array and clear selectedFileList 407 | }); 408 | }; 409 | 410 | handleMoveUp = () => { 411 | this.setState((prevState) => { 412 | const newBuckets = [...prevState.buckets]; 413 | const index = newBuckets.findIndex((item) => item.uid === this.state.selectedSequence.uid); 414 | 415 | // Check if index is valid and not at the start 416 | if (index <= 0 || index >= newBuckets.length) { 417 | console.error("Invalid index or element is already at the start."); 418 | return newBuckets; 419 | } 420 | 421 | // Swap elements 422 | [newBuckets[index - 1], newBuckets[index]] = [newBuckets[index], newBuckets[index - 1]]; 423 | 424 | return { buckets: newBuckets }; 425 | }); 426 | }; 427 | 428 | handleMoveDown = () => { 429 | this.setState((prevState) => { 430 | const newBuckets = [...prevState.buckets]; 431 | const index = newBuckets.findIndex((item) => item.uid === this.state.selectedSequence.uid); 432 | 433 | // Check if index is valid and not at the start 434 | if (index === -1 || index >= newBuckets.length - 1) { 435 | console.error("Invalid index or element is already at the start."); 436 | return newBuckets; 437 | } 438 | 439 | // Swap elements 440 | [newBuckets[index], newBuckets[index + 1]] = [newBuckets[index + 1], newBuckets[index]]; 441 | 442 | return { buckets: newBuckets }; 443 | }); 444 | }; 445 | 446 | handleName = (e) => { 447 | this.setState({ name: e.target.value.toString(), isSaved: false }); 448 | }; 449 | 450 | handleFormSubmit = async (e) => { 451 | e.preventDefault(); 452 | 453 | this.setState({ isIncomplete: false }); 454 | 455 | if (this.state.name === "" || this.state.buckets.length === 0) { 456 | this.setState({ isIncomplete: true }); 457 | return; 458 | } 459 | 460 | if (this.state.schedule === "3" && this.state.holiday === "-1") { 461 | this.setState({ isIncompleteHoliday: true }); 462 | return; 463 | } 464 | 465 | // Check for a "no schedule" condition (schedule === "2") within other sequences 466 | if ( 467 | this.state.schedule === "2" && 468 | this.props.settings.sequences 469 | .filter((element) => element.id !== this.state.id) 470 | .findIndex(({ schedule }) => schedule === "2") !== -1 471 | ) { 472 | this.setState({ show: true }); 473 | return; 474 | } 475 | 476 | const settings = { ...this.props.settings }; 477 | 478 | if (!settings.sequences) settings.sequences = []; 479 | 480 | const temp = { 481 | id: this.props.isEdit ? this.state.id : uuid().toString(), 482 | name: this.state.name, 483 | schedule: this.state.schedule, 484 | startDay: this.state.startDay, 485 | startMonth: this.state.startMonth, 486 | endDay: this.state.endDay, 487 | endMonth: this.state.endMonth, 488 | country: this.state.country, 489 | holiday: this.state.holiday, 490 | buckets: this.state.buckets.map(({ uid, ...rest }) => rest), 491 | }; 492 | 493 | // Improved overlap detection 494 | const isOverlap = (start1, end1, start2, end2) => { 495 | const dateToNumber = (month, day) => new Date(2024, month - 1, day).getTime(); 496 | 497 | const [start1Num, end1Num] = [dateToNumber(start1.month, start1.day), dateToNumber(end1.month, end1.day)]; 498 | const [start2Num, end2Num] = [dateToNumber(start2.month, start2.day), dateToNumber(end2.month, end2.day)]; 499 | 500 | const isWrapped1 = start1Num > end1Num; 501 | const isWrapped2 = start2Num > end2Num; 502 | 503 | if (isWrapped1 && isWrapped2) { 504 | return true; 505 | } else if (isWrapped1) { 506 | return start1Num <= end2Num || end1Num >= start2Num || (start1Num <= end2Num && end1Num >= start2Num); 507 | } else if (isWrapped2) { 508 | return start2Num <= end1Num || end2Num >= start1Num || (start2Num <= end1Num && end2Num >= start1Num); 509 | } else { 510 | return start1Num <= end2Num && end1Num >= start2Num; 511 | } 512 | }; 513 | 514 | // Check for overlap with existing sequences, excluding itself 515 | const newStart = { month: temp.startMonth, day: temp.startDay }; 516 | const newEnd = { month: temp.endMonth, day: temp.endDay }; 517 | 518 | const overlapFound = settings.sequences 519 | .filter(({ id, schedule }) => id !== temp.id && schedule !== "2") // Exclude the current sequence by ID 520 | .some(({ startMonth, startDay, endMonth, endDay }) => { 521 | if (this.state.schedule === "1") { 522 | const existingStart = { month: startMonth, day: startDay }; 523 | const existingEnd = { month: endMonth, day: endDay }; 524 | return isOverlap(newStart, newEnd, existingStart, existingEnd); 525 | } 526 | return false; 527 | }); 528 | 529 | if (overlapFound) { 530 | this.setState({ showOverlapWarning: true }); 531 | return; 532 | } 533 | 534 | if (this.props.isEdit) { 535 | const index = settings.sequences.findIndex(({ id }) => id === this.state.id); 536 | settings.sequences.splice(index, 1, temp); 537 | } else { 538 | settings.sequences.push(temp); 539 | } 540 | 541 | var xhr = new XMLHttpRequest(); 542 | 543 | xhr.addEventListener("readystatechange", () => { 544 | if (xhr.readyState === 4) { 545 | if (xhr.status === 200) { 546 | this.setState({ isSaved: true }); 547 | var xhr2 = new XMLHttpRequest(); 548 | xhr2.addEventListener("readystatechange", () => { 549 | if (xhr2.readyState === 4) { 550 | if (xhr2.status === 200) { 551 | } else { 552 | this.setState({ 553 | error: xhr2.responseText, 554 | }); 555 | } 556 | } 557 | }); 558 | 559 | xhr2.open("GET", "/webhook", true); 560 | xhr2.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 561 | xhr2.send(); 562 | } else { 563 | this.setState({ 564 | error: xhr.responseText, 565 | }); 566 | } 567 | } 568 | }); 569 | 570 | xhr.open("POST", "/backend/save", true); 571 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 572 | xhr.send(JSON.stringify(settings)); 573 | 574 | this.props.saved(); 575 | }; 576 | 577 | handleClose = () => this.setState({ show: false }); 578 | 579 | handleCloseOverlap = () => this.setState({ showOverlapWarning: false }); 580 | 581 | render() { 582 | const countries = []; 583 | const startMonths = []; 584 | const startDays = []; 585 | const endMonths = []; 586 | const endDays = []; 587 | for (let i = 1; i <= 12; i++) { 588 | startMonths.push( 589 | 592 | ); 593 | endMonths.push( 594 | 597 | ); 598 | } 599 | for (let i = 1; i <= this.monthList[this.state.startMonth]; i++) { 600 | startDays.push( 601 | 604 | ); 605 | } 606 | for (let i = 1; i <= this.monthList[this.state.endMonth]; i++) { 607 | endDays.push( 608 | 611 | ); 612 | } 613 | for (let i = 0; i <= this.countryNames.length; i++) { 614 | countries.push(); 615 | } 616 | 617 | return ( 618 |
619 | Name of sequence    620 | 621 |
622 | Schedule 623 |
624 | 635 | 646 | 657 |
658 |
659 | {this.state.schedule === "1" ? ( 660 | <> 661 | 662 | Start Date:   663 | 670 | {startMonths} 671 | 672 | 679 | {startDays} 680 | 681 | 682 |
683 | 684 | End Date:   685 | 692 | {endMonths} 693 | 694 | 695 | {endDays} 696 | 697 | 698 |
699 | 700 | ) : ( 701 | <> 702 | )} 703 | {this.state.schedule === "3" ? ( 704 | <> 705 | 706 | Country:   707 | 715 | {countries} 716 | 717 | 718 |
719 | 720 | Holiday:   721 | 729 | 730 | {this.state.holidayList 731 | .filter((holiday) => holiday.types.includes("Public")) 732 | .map((holiday) => ( 733 | 736 | ))} 737 | 738 | 739 |
740 | 741 | ) : ( 742 | <> 743 | )} 744 | 745 | 746 | {/* File Listing */} 747 |
748 | 749 | 750 | Bucket Sequence 751 | 752 | 753 | 754 | {this.state.buckets.length === 0 ? ( 755 | <Add Buckets Here> 756 | ) : ( 757 | this.state.buckets.map((bucket, idx) => ( 758 | 759 | {idx !== 0 && ( 760 | 761 |
762 | Down Arrow 763 |
764 |
765 | )} 766 | {this.state.selectedSequence.uid === bucket.uid ? ( 767 | 774 | {this.props.settings.buckets.find(({ id }) => id === bucket.id.toString()).name} 775 | 776 | ) : ( 777 | 783 | {this.props.settings.buckets.find(({ id }) => id === bucket.id.toString()).name} 784 | 785 | )} 786 |
787 | )) 788 | )} 789 |
790 |
791 |
792 |
793 |
794 | 801 |    802 | 805 |    806 | 809 |
810 | 811 | 812 | 815 |
816 | 817 | 818 | {/* Bucket Listing */} 819 |
820 | 821 | 822 | List of Buckets 823 | 824 | 825 | 826 | {this.props.settings.buckets 827 | .slice() 828 | .sort((a, b) => a.name.localeCompare(b.name)) 829 | .map((bucket) => 830 | this.state.selectedBucket.id === bucket.id ? ( 831 | 839 | {bucket.name} 840 | 841 | ) : ( 842 | 849 | {bucket.name} 850 | 851 | ) 852 | )} 853 | 854 | 855 | 856 |
857 |
858 | 859 | 860 | 863 |    864 | {this.props.isEdit ? ( 865 | 868 | ) : ( 869 | 872 | )} 873 |    874 | {this.state.isIncomplete ? ( 875 | 876 |   There must be at least one item in the list and a sequence name must be filled. 877 | 878 | ) : ( 879 | <> 880 | )} 881 | {this.state.isIncompleteHoliday ?   You must select a holiday. : <>} 882 | {this.state.isSaved ?   Settings saved. : <>} 883 |
884 | 885 | 886 |

Error

887 |
888 | 889 | There cannot be more than one sequence that is set to "no schedule". This would cause conflicts in Preroll 890 | Plus choosing which sequence to use. 891 |
892 |
893 | See Preroll Plus documentation for more information. 894 |
895 |
896 | 897 |
898 |
899 | 900 | 901 |

Error

902 |
903 | 904 | To prevent conflicts, two sequences cannot overlap each other. 905 |
906 |
907 | See Preroll Plus documentation for more information. 908 |
909 |
910 | 911 |
912 |
913 | 914 | ); 915 | } 916 | } 917 | -------------------------------------------------------------------------------- /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/Sequence/Sequence.css: -------------------------------------------------------------------------------- 1 | .card.card-default { 2 | border: 1px solid black; 3 | } 4 | 5 | .card.card-error { 6 | border: 2px solid red; 7 | } 8 | 9 | .card.card-unselected { 10 | cursor: pointer; 11 | border: 1px solid black; 12 | } 13 | 14 | .card-header.header-custom { 15 | background-color: #f8f9fa; 16 | padding: 5px; 17 | text-align: right; 18 | } 19 | 20 | .icon-clickable { 21 | cursor: pointer; 22 | } 23 | 24 | .icon-noclick { 25 | cursor: default; 26 | } 27 | 28 | .sub-custom { 29 | height: 4rem; 30 | padding-left: 5px; 31 | padding-right: 5px; 32 | } 33 | 34 | .card-footer.footer-custom { 35 | background-color: #f8f9fa; 36 | padding: 5px; 37 | } 38 | 39 | .div-custom { 40 | display: block; 41 | flex-direction: column; 42 | justify-content: flex-end; 43 | text-align: left; 44 | font-size: 12px; 45 | height: 100%; 46 | padding-left: 5px; 47 | white-space: nowrap; 48 | overflow: hidden; 49 | text-overflow: ellipsis; 50 | max-width: 100%; 51 | width: 100%; 52 | } 53 | 54 | 55 | .edit-button { 56 | margin: 0; 57 | padding: 0; 58 | border-width: 0px; 59 | background-color: inherit; 60 | } 61 | 62 | .card-header.header-custom.dark-mode .icon-clickable, 63 | .card-footer.footer-custom.dark-mode .icon-clickable, 64 | .card-header.header-custom.dark-mode .icon-noclick, 65 | .card-footer.footer-custom.dark-mode .icon-noclick { 66 | filter: invert(100%); 67 | } 68 | 69 | .card-global.dark-mode { 70 | background-color: #2c2c2c; 71 | } 72 | 73 | .card-global.dark-mode .plus-image { 74 | filter: invert(80%); 75 | } 76 | 77 | .card-default.dark-mode { 78 | border: 1px solid #898989; 79 | } 80 | 81 | .card-error.dark-mode { 82 | border: 2px solid red; 83 | } 84 | 85 | .card-unselected.dark-mode { 86 | border: 1px solid #898989; 87 | } 88 | 89 | .header-custom.dark-mode, 90 | .footer-custom.dark-mode { 91 | background-color: #2c2c2c; 92 | } 93 | -------------------------------------------------------------------------------- /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 | name: "", 16 | active: true, 17 | }; 18 | 19 | this.state.id = this.props.id; 20 | this.state.name = this.props.sequence; 21 | this.handleClick = this.handleClick.bind(this.props.id); 22 | } 23 | 24 | handleClick = (e) => { 25 | this.props.click(e.currentTarget.value); 26 | }; 27 | 28 | handleDelete = () => { 29 | this.props.delete(this.state.id); 30 | }; 31 | 32 | render() { 33 | return ( 34 | 42 | 43 | {this.props.isEdit || this.props.isCreating ? ( 44 | Close 45 | ) : ( 46 | Close 47 | )} 48 | 49 | 50 | {this.state.name} 51 | 52 | 53 | 54 | 55 |
56 | {this.props.schedule === "2" ? ( 57 | <>No Schedule 58 | ) : this.props.schedule === "3" ? ( 59 | <>{this.props.holiday} 60 | ) : ( 61 | <> 62 | {this.props.startMonth}/{this.props.startDay} - {this.props.endMonth}/{this.props.endDay} 63 | 64 | )} 65 |
66 | 67 | 68 |
69 | {this.props.isEdit || this.props.isCreating ? ( 70 | Edit 71 | ) : ( 72 | 75 | )} 76 |
77 | 78 |
79 |
80 |
81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 "./Sequences.css"; 12 | 13 | export default class Sequences extends Component { 14 | constructor(props) { 15 | super(props); 16 | this.state = { 17 | sequences: this.props.settings.sequences, 18 | isCreating: false, 19 | id: "-1", 20 | isEdit: false, 21 | show: false, 22 | tempID: "", 23 | }; 24 | } 25 | 26 | handleAddSequence = () => { 27 | this.setState({ 28 | isCreating: true, 29 | isEdit: false, 30 | id: "-1", 31 | }); 32 | }; 33 | 34 | handleEditSequence = (e) => { 35 | this.setState({ 36 | isCreating: true, 37 | isEdit: true, 38 | id: e, 39 | }); 40 | }; 41 | 42 | handleCancelCreate = () => { 43 | this.setState({ isCreating: false, isEdit: false }); 44 | }; 45 | 46 | handleSaveCreate = () => { 47 | this.setState({ isCreating: false, isEdit: false }); 48 | }; 49 | 50 | handleClose = () => this.setState({ show: false }); 51 | 52 | handleOpen = (e) => { 53 | this.setState({ tempID: e }); 54 | this.setState({ show: true, fullscreen: "md-down" }); 55 | }; 56 | 57 | handleDelete = (e) => { 58 | e.preventDefault(); 59 | 60 | var settings = { ...this.props.settings }; 61 | 62 | const index = settings.sequences.findIndex(({ id }) => id === this.state.tempID); 63 | 64 | settings.sequences.splice(index, 1); 65 | 66 | var xhr = new XMLHttpRequest(); 67 | 68 | xhr.addEventListener("readystatechange", () => { 69 | if (xhr.readyState === 4) { 70 | if (xhr.status === 200) { 71 | this.setState({ show: false }); 72 | 73 | var xhr2 = new XMLHttpRequest(); 74 | xhr2.addEventListener("readystatechange", () => { 75 | if (xhr2.readyState === 4) { 76 | if (xhr2.status === 200) { 77 | } else { 78 | this.setState({ 79 | error: xhr2.responseText, 80 | }); 81 | } 82 | } 83 | }); 84 | 85 | xhr2.open("GET", "/webhook", true); 86 | xhr2.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 87 | xhr2.send(); 88 | } else { 89 | // error 90 | this.setState({ 91 | show: false, 92 | error: xhr.responseText, 93 | }); 94 | } 95 | } 96 | }); 97 | 98 | xhr.open("POST", "/backend/save", true); 99 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 100 | xhr.send(JSON.stringify(settings)); 101 | 102 | this.handleSaveCreate(); 103 | }; 104 | 105 | render() { 106 | return ( 107 | <> 108 | 109 |

Sequences

110 |
111 | 112 | {this.state.sequences?.map((sequence) => ( 113 | 114 | 132 |
133 | 134 | ))} 135 | 136 | 137 | {this.state.isEdit || this.state.isCreating ? ( 138 | 143 | 144 | Add 145 | 146 | 147 | ) : ( 148 | 152 | 153 | Add 154 | 155 | 156 | )} 157 | 158 |
159 | 160 | {this.state.isCreating ? ( 161 | 169 | ) : ( 170 |
Click the plus to add a new Sequence.
171 | )} 172 |
173 | 181 | 182 |

Are you sure?

183 | 186 |     187 | 190 |
191 |
192 | 193 | ); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | }; 34 | } else { 35 | this.state = { 36 | ip: "", 37 | port: "", 38 | ssl: false, 39 | loc: "/prerolls", 40 | plexLoc: "", 41 | servers: [], 42 | isGetting: false, 43 | isLoaded: false, 44 | isIncomplete: false, 45 | isSaved: false, 46 | polling: "1", 47 | advanced: this.props.settings.advanced ?? false, 48 | logLevel: "0", 49 | }; 50 | } 51 | } 52 | 53 | handleFormSubmit = (e) => { 54 | e.preventDefault(); 55 | 56 | this.setState({ isIncomplete: false }); 57 | 58 | if (this.state.ip === "" || this.state.port === "") { 59 | this.setState({ isIncomplete: true }); 60 | return; 61 | } 62 | 63 | if (!this.props.settings.settings) this.props.settings.settings = {}; 64 | 65 | this.props.settings.settings.ip = this.state.ip; 66 | this.props.settings.settings.port = this.state.port; 67 | this.props.settings.settings.ssl = this.state.ssl; 68 | this.props.settings.settings.loc = this.state.loc; 69 | this.props.settings.settings.plexLoc = this.state.plexLoc; 70 | this.props.settings.connected = "true"; 71 | this.props.settings.settings.polling = this.state.polling; 72 | this.props.settings.settings.logLevel = this.state.logLevel; 73 | this.props.connection(1); 74 | 75 | var xhr = new XMLHttpRequest(); 76 | 77 | xhr.addEventListener("readystatechange", async () => { 78 | if (xhr.readyState === 4) { 79 | if (xhr.status === 200) { 80 | this.setState({ isSaved: true }); 81 | 82 | var response = await fetch("/backend/monitor", { method: "GET" }); 83 | if (!response.ok) { 84 | throw new Error(`Response status: ${response.status}`); 85 | } 86 | response = await fetch("/backend/logger", { method: "GET" }); 87 | if (!response.ok) { 88 | throw new Error(`Response status: ${response.status}`); 89 | } 90 | response = await fetch("/webhook", { method: "GET" }); 91 | if (!response.ok) { 92 | throw new Error(`Response status: ${response.status}`); 93 | } 94 | } else { 95 | // error 96 | this.setState({ 97 | error: xhr.responseText, 98 | }); 99 | } 100 | } 101 | }); 102 | 103 | xhr.open("POST", "/backend/save", true); 104 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 105 | xhr.send(JSON.stringify(this.props.settings)); 106 | }; 107 | 108 | handleServerGet = () => { 109 | var xhr = new XMLHttpRequest(); 110 | 111 | this.setState({ isGetting: true }); 112 | this.setState({ isLoaded: false }); 113 | this.setState({ isSaved: false }); 114 | 115 | xhr.addEventListener("readystatechange", async () => { 116 | if (xhr.readyState === 4) { 117 | if (xhr.status === 200) { 118 | // request successful 119 | var response = xhr.responseText, 120 | json = JSON.parse(response); 121 | 122 | var tempList = []; 123 | var index = 0; 124 | 125 | const createServerEntry = (element, index, secure, location, socket) => ({ 126 | index: index, 127 | name: element.name, 128 | ip: location === "remote" ? element.remoteIP : element.localIP, 129 | port: element.port, 130 | location, 131 | secure, 132 | cert: element.cert, 133 | certSuccessful: element.certSuccessful, 134 | socket: socket, 135 | }); 136 | 137 | for (const element of json) { 138 | if (element.certSuccessful) { 139 | if (!element.https) { 140 | tempList.push(createServerEntry(element, ++index, false, "local", false)); 141 | tempList.push(createServerEntry(element, ++index, false, "remote", false)); 142 | } 143 | tempList.push(createServerEntry(element, ++index, true, "local", false)); 144 | tempList.push(createServerEntry(element, ++index, true, "remote", false)); 145 | } else { 146 | tempList.push(createServerEntry(element, ++index, false, "local", true)); 147 | tempList.push(createServerEntry(element, ++index, false, "remote", true)); 148 | } 149 | } 150 | this.setState({ servers: tempList }); 151 | this.setState({ isLoaded: true }); 152 | } else { 153 | // error 154 | this.setState({ 155 | isLoaded: true, 156 | error: xhr.responseText, 157 | }); 158 | } 159 | } 160 | }); 161 | 162 | xhr.open("POST", "/backend/settings", true); 163 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 164 | xhr.send(JSON.stringify(this.props.settings)); 165 | }; 166 | 167 | handleServerChange = (e) => { 168 | if (e.target.value !== 0) { 169 | if (this.state.servers[e.target.value - 1].secure) { 170 | this.setState({ 171 | ip: `${this.state.servers[e.target.value - 1].ip.replace(/\./g, "-")}.${ 172 | this.state.servers[e.target.value - 1].cert 173 | }.plex.direct`, 174 | ssl: true, 175 | }); 176 | } else { 177 | this.setState({ ip: this.state.servers[e.target.value - 1].ip, ssl: false }); 178 | } 179 | if (this.state.servers[e.target.value - 1].location === "remote") { 180 | this.setState({ port: this.state.servers[e.target.value - 1].port }); 181 | } else { 182 | this.setState({ port: "32400" }); 183 | } 184 | } 185 | this.setState({ isSaved: false }); 186 | }; 187 | 188 | handleIp = (e) => { 189 | this.setState({ ip: e.target.value.toString(), isSaved: false }); 190 | }; 191 | 192 | handlePort = (e) => { 193 | this.setState({ port: e.target.value.toString(), isSaved: false }); 194 | }; 195 | 196 | handleSSL = (e) => { 197 | // console.log(e.target.checked); 198 | this.setState({ ssl: e.target.checked, isSaved: false }); 199 | }; 200 | 201 | handleLoc = (e) => { 202 | this.setState({ loc: e.target.value.toString(), isSaved: false }); 203 | }; 204 | 205 | handlePlexLoc = (e) => { 206 | this.setState({ plexLoc: e.target.value.toString(), isSaved: false }); 207 | }; 208 | 209 | handlePolling = (e) => { 210 | this.setState({ polling: e.target.value.toString() }); 211 | }; 212 | 213 | handleLogLevel = (e) => { 214 | this.setState({ logLevel: e.target.value.toString(), isSaved: false }); 215 | }; 216 | 217 | handleAdvanced = () => { 218 | this.setState((prevState) => { 219 | const newMode = !prevState.advanced; 220 | 221 | var settings = { ...this.props.settings, advanced: newMode }; 222 | 223 | var xhr = new XMLHttpRequest(); 224 | 225 | xhr.addEventListener("readystatechange", () => { 226 | if (xhr.readyState === 4) { 227 | if (xhr.status === 200) { 228 | } else { 229 | // error 230 | this.setState({ 231 | error: xhr.responseText, 232 | }); 233 | } 234 | } 235 | }); 236 | 237 | xhr.open("POST", "/backend/save", true); 238 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 239 | xhr.send(JSON.stringify(settings)); 240 | 241 | return { advanced: newMode }; 242 | }); 243 | }; 244 | 245 | render() { 246 | return ( 247 | <> 248 | 249 | 250 |

Settings

251 | 252 | 253 | {this.state.advanced ? ( 254 | 257 | ) : ( 258 | 261 | )} 262 | 263 |
264 |
265 | 266 |
267 |
268 | Plex Server    269 | 273 | Enter the Plex server's IP address and port, or use the search function to list servers associated 274 | with your account. 275 |
276 |
277 | For remote servers, it will be the user's responsibility to set up the remote connection path. 278 | 279 | } 280 | > 281 | info 282 |
283 |
284 |
285 | {/* Server */} 286 | Server    287 | 288 | {this.state.isLoaded ? ( 289 | 297 | 298 | {this.state.servers.map((server) => { 299 | const certInfo = server.secure ? `${server.cert}.plex.direct` : ""; 300 | const ip = server.secure ? server.ip.replace(/\./g, "-") : server.ip; 301 | const location = `[${server.location}]`; 302 | const socket = server.socket ? `(socket hang up)` : ""; 303 | const secure = server.secure ? `[secure]` : ""; 304 | 305 | return ( 306 | 313 | ); 314 | })} 315 | 316 | ) : ( 317 | <> 318 | {this.state.isGetting ? ( 319 | 320 | 321 | 322 | ) : ( 323 | 331 | 332 | 333 | )} 334 | 335 | )} 336 | 339 | 340 |
341 | Hostname or IP Address    342 | 343 |
344 | Port    345 | 346 |
347 | Use SSL    348 | 349 |
350 |
Preroll Media   
351 |
352 | Location of preroll media    353 | 357 | This is the root location of your Plex preroll media files. 358 |
359 |
360 | This option is only available when running the application natively. If running from Docker, it will 361 | be grayed out and you can set your root location through mounting the internal /prerolls directory to 362 | the directory of your choosing on your host system. 363 |
364 |
365 | When creating buckets, this is the directory that Preroll Plus will look for preroll media, so make 366 | sure the root location of your media matches this location. 367 | 368 | } 369 | > 370 | Info 371 |
372 | {this.props.settings.build === "Native" ? ( 373 | 374 | ) : ( 375 | 376 | )} 377 |
378 | Plex location of preroll media    379 | 383 | This is the location of your Plex prerolls as Plex sees them. 384 |
385 |
386 | This path should corrospond to root location of your preroll files based on the location of your Plex 387 | server. If you are running Preroll Plus and Plex on the same device, this should match the above path. 388 | If you are running Plex on a different machine than Preroll Plus, this path will most likely be 389 | different than the one above. 390 | 391 | } 392 | > 393 | Info 394 |
395 | 402 |
403 | {this.state.advanced ? ( 404 | <> 405 |
406 | 407 | File Monitor Polling    408 | 412 | This setting changes backend file monitoring from using "inotify" to a polling method. 413 |
414 |
415 | If you are connecting to your prerolls directory using an SMB (or similar) share, it is more 416 | than likely that the file system's ability to be notified of file changes will not work. 417 |
418 |
419 | If you are finding that renaming, moving, or removing files in your preroll directory isn't 420 | automatically working, set this to on and Preroll Plus will monitor file changes using a 421 | constant polling of the file system. 422 |
423 |
424 | If everything is working correctly, it is recommended to keep this setting off. 425 | 426 | } 427 | > 428 | Info 429 |
430 |
431 |
432 | 443 | 454 |
455 |
456 | 457 | Log Level:   458 | 466 | 467 | 468 | 469 | 470 | 471 | ) : ( 472 | <> 473 | )} 474 |
475 | {/* Cancel/Save */} 476 | 479 |    480 | {this.state.isIncomplete ?   IP and Port must be filled. : <>} 481 | {this.state.isSaved ?   Settings saved. : <>} 482 | 483 | 484 | 485 | ); 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /frontend/src/images/add-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/src/images/add-icon.png -------------------------------------------------------------------------------- /frontend/src/images/icons8-restart-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/src/images/icons8-restart-24.png -------------------------------------------------------------------------------- /frontend/src/images/icons8-restart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/src/images/icons8-restart.gif -------------------------------------------------------------------------------- /frontend/src/images/loading-gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadwpalm/PrerollPlus/976f871b46407e57e03721aad3b84f601c717091/frontend/src/images/loading-gif.gif -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | # Preroll Plus Version History 2 | 3 | ### 1.1.2 4 | 5 | ### Fixes 6 | 7 | 1. Sequence cards were not showing the scheduled holiday if "Holiday" is chosen as the schedule. 8 | 9 | ### 1.1.1 10 | 11 | ### Fixes 12 | 13 | 1. Brought interal packages up to date. 14 | 2. Log file names were using the wrong application. 15 | 3. Minor code fixes. 16 | 17 | ### 1.1.0 18 | 19 | ### New Feature 20 | 21 | 1. Added ability to select a holiday for the schedule. 22 | 23 | ## 1.0.0 24 | 25 | ### Changes 26 | 27 | 1. Bring the app out of beta testing. 28 | 2. Logging to file. Logs will be found in /config for native installs and the mounted config folder for Docker installs. 29 | 3. Removed logging of frontend to backend web router calls. 30 | 4. Added "Show/Hide Advanced" options in Settings page and included a logging level toggle. 31 | 32 | ### Fixes 33 | 34 | 1. Server list wasn't showing secure connection options when "Secure connections" in the Plex Media Server settings was set to "Required". 35 | 2. Dark mode setting was not being preserved in the save file when logging out. 36 | 3. Minor fix to file monitoring. 37 | 38 | ## 0.1.5 39 | 40 | ### New Feature 41 | 42 | 1. Added a dark mode which can be toggled on and off [[#5](https://github.com/chadwpalm/PrerollPlus/discussions/5)] 43 | 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)] 44 | 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)] 45 | 46 | ### Minor Fix 47 | 48 | 1. Moved all inline CSS to external files to make style changes easier 49 | 2. Basic code cleanup to remove compiler warnings 50 | 51 | ## 0.1.4 52 | 53 | ### Minor Fix 54 | 55 | 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)] 56 | 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)] 57 | 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. 58 | 4. Added option for file monitoring through polling for when preroll directory is mounted over an SMB (or similar) share. 59 | 60 | ## 0.1.3 61 | 62 | ### Minor Features 63 | 64 | 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)] 65 | 2. Deleting a Bucket will update any Sequences it is in and also update the Plex string. 66 | 3. Added directory location under the file names in the "Files in buckets" list. 67 | 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)] 68 | 69 | ### Bug Fixes 70 | 71 | 1. Fixed issue where opening a Sequence that contains a recently deleted bucket was generating an error. 72 | 73 | ## 0.1.2 74 | 75 | ### Minor Features 76 | 77 | 1. Make header bar "sticky" to the top of the window. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 78 | 2. Make list of files in bucket alphabetical. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 79 | 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)] 80 | 4. Sequences and Buckets are now highlighted when editing them. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 81 | 5. Added a video preview player in the Bucket creation/edit page. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 82 | 83 | ### Bug Fixes 84 | 85 | 1. Brought back build number which wasn't brought back in previous version after local testing. 86 | 2. The file list in Buckets was not indicating if the directory location set in Settings does not exist. 87 | 88 | ## 0.1.1 89 | 90 | ### Bug Fixes 91 | 92 | 1. Fix "Update Available" URL. 93 | 2. Fixed output string in Sequences when trying to save without having a sequence name or buckets. 94 | 3. Plex server preroll sequence string is updated when a Sequence is deleted to prevent playing an unwanted sequence. 95 | 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)] 96 | 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. 97 | 6. Fixed double slashes in pre-roll string on some file system. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)] 98 | 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)] 99 | 100 | ## 0.1.0 101 | 102 | Initial beta release 103 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "branch": "dev", 3 | "version": "1.1.2" 4 | } 5 | -------------------------------------------------------------------------------- /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 axios = require("axios").default; 6 | 7 | // Global Variables 8 | 9 | var flag = false; 10 | 11 | var upload = multer({ dest: "/tmp/" }); 12 | 13 | const filePath = "/config/settings.js"; 14 | 15 | var settings; 16 | 17 | // General Functions 18 | 19 | async function isHolidayDay(country, holiday) { 20 | const today = new Date(); 21 | const currentYear = today.getFullYear(); 22 | 23 | // Set today to midnight of the current day (local time) 24 | today.setHours(0, 0, 0, 0); // Normalize today's date to midnight 25 | 26 | var url = `https://date.nager.at/api/v3/publicholidays/${currentYear}/${country}`; 27 | 28 | try { 29 | const response = await axios.get(url, { 30 | timeout: 2000, 31 | headers: { 32 | "Content-Type": "application/json;charset=UTF-8", 33 | }, 34 | }); 35 | 36 | const data = response.data.find((item) => item.name === holiday); 37 | 38 | if (!data) { 39 | console.log("Holiday not found:", holiday); 40 | return false; // Holiday not found, return false 41 | } 42 | 43 | // Use the UTC format to ensure the date is interpreted in UTC 44 | const holidayDate = new Date(`${data.date}T00:00:00Z`); 45 | 46 | // Set holidayDate to midnight (UTC) for comparison 47 | holidayDate.setUTCHours(0, 0, 0, 0); 48 | 49 | // Now compare only the year, month, and day of both dates 50 | const isHolidayToday = 51 | holidayDate.getUTCFullYear() === today.getUTCFullYear() && 52 | holidayDate.getUTCMonth() === today.getUTCMonth() && 53 | holidayDate.getUTCDate() === today.getUTCDate(); 54 | 55 | return isHolidayToday; // Return the comparison result (true or false) 56 | } catch (error) { 57 | console.error("Error while trying to connect to the Public Holiday API: ", error.message); 58 | return false; // Return false on error 59 | } 60 | } 61 | 62 | async function checkSchedule() { 63 | let index = -1; 64 | let foundDateMatch = false; 65 | const today = new Date(); 66 | today.setHours(0, 0, 0, 0); // Set today to midnight (local time) 67 | const currentYear = today.getFullYear(); 68 | 69 | // Convert today's date to a number that can be compared to other dates 70 | const todayNumber = new Date(Date.UTC(currentYear, today.getMonth(), today.getDate())).getTime(); 71 | 72 | for (let idx = 0; idx < settings.sequences.length; idx++) { 73 | const element = settings.sequences[idx]; 74 | 75 | if (element.schedule === "3") { 76 | const isHoliday = await isHolidayDay(element.country, element.holiday); 77 | 78 | if (isHoliday) { 79 | index = idx; 80 | foundDateMatch = true; 81 | break; 82 | } 83 | continue; 84 | } 85 | 86 | if (element.schedule === "2") { 87 | if (!foundDateMatch) { 88 | index = idx; 89 | } 90 | continue; // Skip to the next element 91 | } 92 | 93 | // Convert the sequence start and end dates to timestamps for comparison (UTC) 94 | if (!foundDateMatch) { 95 | const startNumber = new Date(Date.UTC(currentYear, element.startMonth - 1, element.startDay)).getTime(); 96 | const endNumber = new Date(Date.UTC(currentYear, element.endMonth - 1, element.endDay)).getTime(); 97 | 98 | // Handle ranges that do not wrap and those that do wrap around the end of the year 99 | const isWrapped = startNumber > endNumber; 100 | 101 | if ( 102 | (isWrapped && (todayNumber >= startNumber || todayNumber <= endNumber)) || 103 | (!isWrapped && todayNumber >= startNumber && todayNumber <= endNumber) 104 | ) { 105 | index = idx; 106 | foundDateMatch = true; 107 | break; // Break early if a match is found 108 | } 109 | } 110 | } 111 | 112 | return index; 113 | } 114 | 115 | async function createList(index) { 116 | let plexString = ""; 117 | if (index !== -1) { 118 | const bucketIds = settings.sequences[index].buckets; 119 | let usedFiles = new Set(); // Set to keep track of used files 120 | 121 | // Using `for...of` loop to await `axios` inside the loop 122 | for (const [idx, bucketId] of bucketIds.entries()) { 123 | let files = []; 124 | const info = settings.buckets.find(({ id }) => id === bucketId.id.toString()); 125 | 126 | if (info.source === "2") { 127 | try { 128 | const response = await axios.post( 129 | "http://localhost:4949/backend/directory", 130 | { dir: `${info.dir}` }, 131 | { 132 | headers: { 133 | "Content-Type": "application/json;charset=UTF-8", 134 | }, 135 | } 136 | ); 137 | console.log(response.data); 138 | response.data.forEach((media) => { 139 | if (!media.isDir) 140 | files.push(`${settings.settings.plexLoc}${info.dir.replace(settings.settings.loc, "")}/${media.name}`); 141 | }); 142 | } catch (error) { 143 | if (error.response) { 144 | console.error("Server responded with error:", error.response.data); 145 | } else if (error.request) { 146 | console.error("No response received:", error.request); 147 | } else { 148 | console.error("Error setting up request:", error.message); 149 | } 150 | } 151 | } else { 152 | info.media.forEach((media) => { 153 | files.push(`${settings.settings.plexLoc}${media.dir}/${media.file}`); 154 | }); 155 | } 156 | 157 | if (files.length !== 0) { 158 | let randomFile; 159 | do { 160 | randomFile = files[Math.floor(Math.random() * files.length)]; // Fix: `info.media.length` -> `files.length` 161 | } while (usedFiles.has(randomFile)); // Keep picking until an unused file is found 162 | 163 | usedFiles.add(randomFile); // Mark the selected file as used 164 | 165 | if (idx === bucketIds.length - 1) { 166 | plexString += randomFile; 167 | } else { 168 | plexString += randomFile + ","; 169 | } 170 | } 171 | } 172 | } 173 | return plexString; 174 | } 175 | 176 | async function sendList(string) { 177 | const url = `http${settings.settings.ssl ? "s" : ""}://${settings.settings.ip}:${settings.settings.port}/:/prefs`; 178 | 179 | await axios 180 | .put(url, null, { 181 | headers: { 182 | "X-Plex-Token": `${settings.token}`, 183 | }, 184 | params: { 185 | CinemaTrailersPrerollID: string, 186 | }, 187 | }) 188 | .then((response) => { 189 | console.log("Preroll updated successfully: ", string); 190 | }) 191 | .catch((error) => { 192 | console.error("Error updating preroll:", error); 193 | }); 194 | } 195 | 196 | async function doTask() { 197 | const index = await checkSchedule(); 198 | const string = await createList(index); 199 | sendList(string); 200 | } 201 | 202 | // Function to calculate delay until the desired time (3:00 PM) 203 | function getDelayUntilTargetTime(hour, minute) { 204 | const now = new Date(); 205 | const targetTime = new Date(); 206 | 207 | targetTime.setHours(hour, minute, 0, 0); // Set target time to 3:00 PM today 208 | 209 | if (targetTime <= now) { 210 | // If the target time has already passed today, schedule for tomorrow 211 | targetTime.setDate(targetTime.getDate() + 1); 212 | } 213 | 214 | // Calculate the delay in milliseconds 215 | return targetTime - now; 216 | } 217 | 218 | // Schedule the initial run 219 | const delay = getDelayUntilTargetTime(0, 0); // 3:00 PM 220 | 221 | // Periodic Task to Check Schedules 222 | function myAsyncTask() { 223 | try { 224 | settings = JSON.parse(fs.readFileSync(filePath)); 225 | // Your async code here 226 | console.log("Task running..."); 227 | doTask(); 228 | } catch (error) { 229 | console.error("Error in async task:", error); 230 | } 231 | } 232 | 233 | // Set the task to run every day 234 | setTimeout(() => { 235 | myAsyncTask(); 236 | 237 | setInterval(myAsyncTask, 24 * 60 * 60 * 1000); 238 | }, delay); 239 | 240 | router.post("/", upload.single("thumb"), async function (req, res, next) { 241 | var payload = JSON.parse(req.body.payload); 242 | settings = JSON.parse(fs.readFileSync(filePath)); 243 | 244 | try { 245 | if (payload.event === "media.play" && payload.Metadata.type === "movie") { 246 | console.info("Movie has started. Updating prerolls"); 247 | 248 | doTask(); 249 | } 250 | res.sendStatus(200); 251 | } catch (e) { 252 | console.log("There was an error", e); 253 | res.sendStatus(200); 254 | } 255 | }); 256 | 257 | router.get("/", function (req, res, next) { 258 | settings = JSON.parse(fs.readFileSync(filePath)); 259 | doTask(); 260 | res.sendStatus(200); 261 | }); 262 | 263 | module.exports = router; 264 | --------------------------------------------------------------------------------