├── .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 | You need to enable JavaScript to run this app.
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 |
62 | Dismiss
63 |
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 |
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 |
349 | {!this.state.isConnected ? (
350 | <>
351 |
352 | Sequences
353 |
354 |
355 | Buckets
356 |
357 | >
358 | ) : (
359 | <>
360 |
361 | Sequences
362 |
363 |
364 | Buckets
365 |
366 | >
367 | )}
368 |
369 | <>
370 |
371 | Settings
372 |
373 | >
374 |
375 |
376 |
381 |
382 |
428 |
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 |
45 | ) : (
46 |
47 | )}
48 |
49 |
50 | {this.state.name}
51 |
52 |
53 |
54 |
55 |
56 |
57 | {this.props.isEdit || this.props.isCreating ? (
58 |
59 | ) : (
60 |
61 |
62 |
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 |
132 |
133 |
134 | ) : (
135 |
139 |
140 |
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 |
173 | Yes
174 |
175 |
176 |
177 | Cancel
178 |
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 | 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 |
432 |
433 |
434 |
445 |
456 |
457 |
458 |
459 | {this.state.source === "1" ? (
460 | <>
461 |
462 |
467 | Remove
468 |
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 |
537 |
538 |
539 |
540 |
541 | >
542 | ) : (
543 | ""
544 | )}
545 |
546 | {this.state.source === "1" ? (
547 | <>
548 |
549 | Select All
550 |
551 |
552 |
553 | Select None
554 |
555 |
556 |
557 | Preview
558 |
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 |
661 | Your browser does not support the video tag.
662 |
663 |
664 |
665 |
666 | Close
667 |
668 |
669 | ) : (
670 | <>>
671 | )}
672 |
673 |
674 | Cancel
675 |
676 |
677 | {this.props.isEdit ? (
678 |
679 | Update
680 |
681 | ) : (
682 |
683 | Save
684 |
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 |
590 | {i.toLocaleString("en-US", { minimumIntegerDigits: 2, useGrouping: false })}
591 |
592 | );
593 | endMonths.push(
594 |
595 | {i.toLocaleString("en-US", { minimumIntegerDigits: 2, useGrouping: false })}
596 |
597 | );
598 | }
599 | for (let i = 1; i <= this.monthList[this.state.startMonth]; i++) {
600 | startDays.push(
601 |
602 | {i.toLocaleString("en-US", { minimumIntegerDigits: 2, useGrouping: false })}
603 |
604 | );
605 | }
606 | for (let i = 1; i <= this.monthList[this.state.endMonth]; i++) {
607 | endDays.push(
608 |
609 | {i.toLocaleString("en-US", { minimumIntegerDigits: 2, useGrouping: false })}
610 |
611 | );
612 | }
613 | for (let i = 0; i <= this.countryNames.length; i++) {
614 | countries.push({this.countryNames[i]} );
615 | }
616 |
617 | return (
618 | 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 | Select a Holiday
730 | {this.state.holidayList
731 | .filter((holiday) => holiday.types.includes("Public"))
732 | .map((holiday) => (
733 |
734 | {holiday.name} ({holiday.localName})
735 |
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 |
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 |
799 | Remove
800 |
801 |
802 |
803 |
804 |
805 |
806 |
807 |
808 |
809 |
810 |
811 |
812 |
813 |
814 |
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 |
861 | Cancel
862 |
863 |
864 | {this.props.isEdit ? (
865 |
866 | Update
867 |
868 | ) : (
869 |
870 | Save
871 |
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 | Acknowledge
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 | Acknowledge
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 |
200 |
201 |
202 | Loading...
203 |
204 |
205 | ) : this.state.noInternet ? (
206 |
207 |
208 |
209 | Sign in
210 |
211 |
212 | ) : (
213 |
214 |
215 |
216 | Sign in
217 |
218 |
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 |
45 | ) : (
46 |
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 |
71 | ) : (
72 |
73 |
74 |
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 |
145 |
146 |
147 | ) : (
148 |
152 |
153 |
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 |
184 | Yes
185 |
186 |
187 |
188 | Cancel
189 |
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 |
255 | Hide Advanced
256 |
257 | ) : (
258 |
259 | Show Advanced
260 |
261 | )}
262 |
263 |
264 |
265 |
266 | Server
287 |
288 | {this.state.isLoaded ? (
289 |
297 | Manual configuration
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 |
311 | {`${server.name} (${ip}${certInfo ? `.${certInfo}` : ""}) ${location} ${secure} ${socket}`}
312 |
313 | );
314 | })}
315 |
316 | ) : (
317 | <>
318 | {this.state.isGetting ? (
319 |
320 | Retrieving servers...
321 |
322 | ) : (
323 |
331 | Press the button to load available servers
332 |
333 | )}
334 | >
335 | )}
336 |
337 |
338 |
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 |
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 |
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 |
429 |
430 |
431 |
432 |
443 |
454 |
455 |
456 |
457 | Log Level:
458 |
466 | Info
467 | Debug
468 |
469 |
470 | >
471 | ) : (
472 | <>>
473 | )}
474 |
475 | {/* Cancel/Save */}
476 |
477 | Save
478 |
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 |
--------------------------------------------------------------------------------