├── .dockerignore
├── version.json
├── frontend
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-384x384.png
│ ├── manifest.json
│ └── index.html
├── src
│ ├── images
│ │ ├── add-icon.png
│ │ ├── loading-gif.gif
│ │ ├── icons8-restart.gif
│ │ └── icons8-restart-24.png
│ ├── index.css
│ ├── setupTests.js
│ ├── reportWebVitals.js
│ ├── index.js
│ └── components
│ │ ├── Calendar
│ │ ├── Calendar.css
│ │ └── Calendar.jsx
│ │ ├── Buckets
│ │ ├── Buckets.css
│ │ └── Buckets.jsx
│ │ ├── Settings
│ │ ├── Settings.css
│ │ └── Settings.jsx
│ │ ├── Sequences
│ │ ├── Sequences.css
│ │ └── Sequences.jsx
│ │ ├── App
│ │ ├── App.css
│ │ ├── Announce.jsx
│ │ └── App.jsx
│ │ ├── Sequence
│ │ ├── Sequence.css
│ │ └── Sequence.jsx
│ │ ├── CreateSeq
│ │ ├── CreateSeq.css
│ │ └── countries.js
│ │ ├── Bucket
│ │ └── Bucket.jsx
│ │ ├── Login
│ │ └── Login.jsx
│ │ └── Create
│ │ └── Create.jsx
├── index.js
└── package.json
├── Dockerfile
├── .gitignore
├── backend
├── save.js
├── websocket.js
├── thumb.js
├── directory.js
├── clearcache.js
├── streamer.js
├── logger.js
├── migrate.js
├── load.js
├── settings.js
├── holiday.js
└── monitor.js
├── package.json
├── bin
└── www
├── app.js
├── README.md
├── Jenkinsfile
├── history.md
└── webhook
└── index.js
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/version.json:
--------------------------------------------------------------------------------
1 | {
2 | "branch": "dev",
3 | "version": "1.3.2"
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/favicon-16x16.png
--------------------------------------------------------------------------------
/frontend/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/favicon-32x32.png
--------------------------------------------------------------------------------
/frontend/src/images/add-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/add-icon.png
--------------------------------------------------------------------------------
/frontend/src/images/loading-gif.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/loading-gif.gif
--------------------------------------------------------------------------------
/frontend/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/frontend/src/images/icons8-restart.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/icons8-restart.gif
--------------------------------------------------------------------------------
/frontend/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/frontend/public/android-chrome-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/public/android-chrome-384x384.png
--------------------------------------------------------------------------------
/frontend/src/images/icons8-restart-24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chadwpalm/PrerollPlus/HEAD/frontend/src/images/icons8-restart-24.png
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | html, body, #root, #root>div {
2 | height: 100%
3 | }
4 |
5 | code {
6 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
7 | monospace;
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/frontend/index.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | const path = require("path");
4 |
5 | /* GET users listing. */
6 | router.get("/", function (req, res, next) {
7 | res.sendFile(path.join(__dirname, "/production/index.html"));
8 | });
9 |
10 | module.exports = router;
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18.15.0-slim
2 |
3 | ARG BUILD
4 |
5 | ARG FILEDIR
6 |
7 | ENV BUILD=${BUILD}
8 |
9 | ENV FILEDIR=${FILEDIR}
10 |
11 | ENV GENERATE_SOURCEMAP=false
12 |
13 | COPY . /PrerollPlus
14 |
15 | WORKDIR /PrerollPlus/frontend
16 |
17 | RUN npm ci && npm run build
18 |
19 | WORKDIR /PrerollPlus
20 |
21 | RUN npm ci
22 |
23 | ENTRYPOINT ["npm", "start"]
24 |
--------------------------------------------------------------------------------
/frontend/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /frontend/node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # production
13 | /frontend/production
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/backend/save.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 |
5 | router.post("/", function (req, res, next) {
6 | var fileData = JSON.stringify(req.body);
7 |
8 | try {
9 | fs.writeFileSync("/config/settings.js", fileData);
10 | console.info("Settings file saved");
11 | } catch (err) {
12 | if (err) throw err;
13 | }
14 |
15 | res.send();
16 | });
17 |
18 | module.exports = router;
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prerollplus",
3 | "private": true,
4 | "scripts": {
5 | "start": "nodemon ./bin/www"
6 | },
7 | "dependencies": {
8 | "axios": "^1.5.1",
9 | "chokidar": "^4.0.0",
10 | "cookie-parser": "~1.4.4",
11 | "express": "^4.16.1",
12 | "http-errors": "~1.6.3",
13 | "mime-types": "^2.1.35",
14 | "multer": "^1.4.5-lts.1",
15 | "nodemon": "^3.1.9",
16 | "uuid": "^8.3.2",
17 | "winston": "^3.16.0",
18 | "winston-daily-rotate-file": "^5.0.0",
19 | "ws": "^8.18.0",
20 | "xml-js": "^1.6.11"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import App from "./components/App/App";
5 |
6 | const root = ReactDOM.createRoot(document.getElementById("root"));
7 | root.render(
8 |
9 |
10 |
11 | );
12 |
13 | // If you want to start measuring performance in your app, pass a function
14 | // to log results (for example: reportWebVitals(console.log))
15 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
16 | // reportWebVitals(console.log);
17 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Preroll+",
3 | "name": "Preroll Plus - Plex Preroll Manager",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "android-chrome-192x192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "android-chrome-384x384.png",
17 | "type": "image/png",
18 | "sizes": "384x384"
19 | },
20 | {
21 | "src": "apple-touch-icon.png",
22 | "type": "image/png",
23 | "sizes": "180x180"
24 | }
25 | ],
26 | "start_url": ".",
27 | "display": "standalone",
28 | "theme_color": "#000000",
29 | "background_color": "#ffffff"
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/components/Calendar/Calendar.css:
--------------------------------------------------------------------------------
1 | /* Kill all default FullCalendar underlines and blue links */
2 | .fc a {
3 | text-decoration: none !important;
4 | color: inherit !important;
5 | }
6 |
7 | /* Day-of-the-week header cells (Sun, Mon, Tue, etc.) */
8 | .fc .fc-col-header-cell {
9 | background-color: #e2e2e2 !important; /* Light mode: medium-light gray */
10 | color: #000000 !important; /* Black text for readability */
11 | }
12 |
13 | .dark-mode .fc .fc-col-header-cell {
14 | background-color: #2c2c2c !important; /* Dark mode: medium-dark gray */
15 | color: #e0e0e0 !important; /* Light gray text */
16 | }
17 |
18 | /* Optional: Improve text padding/alignment in headers */
19 | .fc .fc-col-header-cell-cushion {
20 | padding: 8px 4px !important;
21 | }
--------------------------------------------------------------------------------
/backend/websocket.js:
--------------------------------------------------------------------------------
1 | const WebSocket = require("ws");
2 |
3 | let wss;
4 |
5 | function initializeWebSocket(server) {
6 | wss = new WebSocket.Server({ server });
7 |
8 | wss.on("connection", (ws) => {
9 | console.log("Client connected");
10 | ws.on("message", (message) => {
11 | console.log("Received message from client:", message);
12 | });
13 | });
14 |
15 | return wss;
16 | }
17 |
18 | function broadcastUpdate() {
19 | console.info("Sending update");
20 | if (wss && wss.clients) {
21 | wss.clients.forEach((client) => {
22 | if (client.readyState === WebSocket.OPEN) {
23 | client.send("update-config");
24 | }
25 | });
26 | } else {
27 | console.error("Could not send update");
28 | }
29 | }
30 |
31 | module.exports = { initializeWebSocket, broadcastUpdate };
32 |
--------------------------------------------------------------------------------
/frontend/src/components/Buckets/Buckets.css:
--------------------------------------------------------------------------------
1 | .sort-buttons-group {
2 | display: inline-flex;
3 | gap: 10px;
4 | align-items: center;
5 | padding-left: 10px;
6 | padding-top: 20px;
7 | }
8 |
9 | .sort-button {
10 | background-color: #e0e0e0 !important;
11 | border: 1px solid #cccccc !important;
12 | border-radius: 8px !important;
13 | padding: 6px 12px !important;
14 | box-shadow: none !important;
15 | min-height: 40px;
16 | }
17 |
18 | .dark-mode .sort-button {
19 | background-color: #000000 !important;
20 | border: 1px solid #666666 !important;
21 | }
22 |
23 | .dark-mode .arrow-icon {
24 | filter: invert(100%) brightness(1.2) !important;
25 | }
26 |
27 | .sort-button.active {
28 | background-color: #c0c0c0 !important;
29 | }
30 | .dark-mode .sort-button.active {
31 | background-color: #606060 !important;
32 | }
33 |
34 | .sort-button:hover {
35 | background-color: #b0b0b0 !important;
36 | }
37 | .form-content.dark-mode .sort-button:hover {
38 | background-color: #707070 !important;
39 | }
--------------------------------------------------------------------------------
/frontend/src/components/Settings/Settings.css:
--------------------------------------------------------------------------------
1 | .div-seperator {
2 | padding-bottom: 0.75rem;
3 | }
4 |
5 | .server-list {
6 | width: 100% !important;
7 | }
8 |
9 | .sched-style {
10 | width: 100px !important;
11 | }
12 |
13 | .form-content.dark-mode .repeat-icon {
14 | filter: invert(100%);
15 | }
16 |
17 | .form-content.dark-mode .btn-outline-light {
18 | --bs-btn-hover-bg: rgb(54, 54, 54);
19 | --bs-btn-active-bg: black;
20 | --bs-btn-border-color: #9f9f9f;
21 | }
22 |
23 | .form-content.dark-mode .form-control {
24 | color: white;
25 | background-color: rgb(46, 46, 46);
26 | border-color: #898989;
27 | }
28 |
29 | .form-content.dark-mode .form-select {
30 | color: white;
31 | background-color: rgb(46, 46, 46);
32 | border-color: #898989;
33 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3E%3C/svg%3E");
34 | }
35 |
36 | .form-content.dark-mode .image-info {
37 | filter: invert(100%);
38 | }
--------------------------------------------------------------------------------
/backend/thumb.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var axios = require("axios").default;
4 | var parser = require("xml-js");
5 |
6 | router.post("/", async function (req, res, next) {
7 | var url = "https://plex.tv/users/account";
8 |
9 | await axios
10 | .get(url, { params: { "X-Plex-Token": req.body.token } })
11 |
12 | .then(function (response) {
13 | console.info("Retrieving thumbnail from Plex account");
14 |
15 | let thumb = parser.xml2js(response.data, { compact: true, spaces: 4 }).user._attributes.thumb;
16 | let username = parser.xml2js(response.data, { compact: true, spaces: 4 }).user._attributes.username;
17 | let email = parser.xml2js(response.data, { compact: true, spaces: 4 }).user._attributes.email;
18 |
19 | let data = { thumb, username, email };
20 |
21 | res.send(JSON.stringify(data));
22 | })
23 | .catch(function (error) {
24 | if (error.request) {
25 | console.error("Could not connect to the Plex sewrver");
26 | res.status(403).send("Could not connect to the Plex server");
27 | }
28 | });
29 | });
30 |
31 | module.exports = router;
32 |
--------------------------------------------------------------------------------
/backend/directory.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 |
5 | router.post("/", function (req, res, next) {
6 | try {
7 | console.info("Entering Directory: ", req.body.dir);
8 | dirData = fs.readdirSync(req.body.dir, { withFileTypes: true });
9 | dirRet = [];
10 | console.info("Directory read");
11 |
12 | dirData.sort((a, b) => {
13 | const aIsDir = a.isDirectory();
14 | const bIsDir = b.isDirectory();
15 |
16 | if (aIsDir && !bIsDir) {
17 | return -1; // a comes before b
18 | } else if (!aIsDir && bIsDir) {
19 | return 1; // b comes before a
20 | } else {
21 | const aStr = String(a.name).toLowerCase();
22 | const bStr = String(b.name).toLowerCase();
23 | return aStr.localeCompare(bStr); // regular alphabetical sort
24 | }
25 | });
26 |
27 | dirData.forEach((file) => {
28 | dirRet.push({ name: file.name, isDir: file.isDirectory() });
29 | });
30 |
31 | res.send(JSON.stringify(dirRet));
32 | console.debug(`Directory info: ${JSON.stringify(dirRet)}`);
33 | } catch (err) {
34 | console.error("Directory not found", err.message.split("\n")[0]); // Send only the first line
35 | res.status(200).send(JSON.stringify(null));
36 | }
37 | });
38 |
39 | module.exports = router;
40 |
--------------------------------------------------------------------------------
/backend/clearcache.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 | var path = require("path");
5 |
6 | const cacheDir = path.join("/", "config", "cache");
7 |
8 | function clearCache() {
9 | if (!fs.existsSync(cacheDir)) {
10 | console.info(`Cache directory does not exist: ${cacheDir}`);
11 | return { success: false, code: 404, message: "Cache directory does not exist" };
12 | }
13 |
14 | try {
15 | const files = fs.readdirSync(cacheDir);
16 | files.forEach((file) => {
17 | const filePath = path.join(cacheDir, file);
18 | try {
19 | fs.unlinkSync(filePath);
20 | console.info(`Deleted: ${filePath}`);
21 | } catch (err) {
22 | console.error(`Failed to delete ${filePath}:`, err.message);
23 | throw err; // bubble up to outer catch
24 | }
25 | });
26 | console.info("Cache directory cleared.");
27 | return { success: true, code: 200, message: "Cache directory cleared" };
28 | } catch (err) {
29 | console.error("Error reading/deleting cache directory:", err.message);
30 | return { success: false, code: 500, message: err.message };
31 | }
32 | }
33 |
34 | router.get("/", function (req, res, next) {
35 | const result = clearCache();
36 | res.status(result.code).json(result);
37 | });
38 |
39 | module.exports = router;
40 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "dependencies": {
5 | "@fullcalendar/core": "^6.1.19",
6 | "@fullcalendar/daygrid": "^6.1.19",
7 | "@fullcalendar/react": "^6.1.19",
8 | "@testing-library/jest-dom": "^5.16.4",
9 | "axios": "^1.7.9",
10 | "bootstrap": "^5.2.3",
11 | "bootstrap-icons": "^1.10.3",
12 | "cross-env": "^7.0.3",
13 | "date-fns": "^4.1.0",
14 | "plex-oauth": "^2.1.0",
15 | "react": "^18.1.0",
16 | "react-bootstrap": "^2.7.0",
17 | "react-dom": "^18.1.0",
18 | "react-router-bootstrap": "^0.26.2",
19 | "react-router-dom": "^6.0.0",
20 | "react-scripts": "^5.0.1",
21 | "uuid": "^9.0.0",
22 | "web-vitals": "^2.1.4"
23 | },
24 | "scripts": {
25 | "start": "react-scripts start",
26 | "build": "cross-env BUILD_PATH='./production' react-scripts build",
27 | "test": "react-scripts test",
28 | "eject": "react-scripts eject"
29 | },
30 | "eslintConfig": {
31 | "extends": [
32 | "react-app",
33 | "react-app/jest"
34 | ]
35 | },
36 | "browserslist": {
37 | "production": [
38 | ">0.2%",
39 | "not dead",
40 | "not op_mini all"
41 | ],
42 | "development": [
43 | "last 1 chrome version",
44 | "last 1 firefox version",
45 | "last 1 safari version"
46 | ]
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/backend/streamer.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const path = require("path");
3 | const fs = require("fs");
4 | const mime = require("mime-types");
5 | const router = express.Router();
6 |
7 | router.get("/*", (req, res) => {
8 | const filename = req.params[0];
9 | const filepath = path.join("/", filename);
10 |
11 | // Check if file exists
12 | if (!fs.existsSync(filepath)) {
13 | return res.status(404).send("File not found");
14 | }
15 |
16 | const stat = fs.statSync(filepath);
17 | const fileSize = stat.size;
18 | const range = req.headers.range;
19 |
20 | const contentType = mime.lookup(filepath) || "application/octet-stream"; // Default to generic binary stream
21 |
22 | if (range) {
23 | const parts = range.replace(/bytes=/, "").split("-");
24 | const start = parseInt(parts[0], 10);
25 | const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
26 | const chunkSize = end - start + 1;
27 | const file = fs.createReadStream(filepath, { start, end });
28 | const head = {
29 | "Content-Range": `bytes ${start}-${end}/${fileSize}`,
30 | "Accept-Ranges": "bytes",
31 | "Content-Length": chunkSize,
32 | "Content-Type": contentType,
33 | };
34 |
35 | res.writeHead(206, head);
36 | file.pipe(res);
37 | } else {
38 | const head = {
39 | "Content-Length": fileSize,
40 | "Content-Type": contentType,
41 | };
42 |
43 | res.writeHead(200, head);
44 | fs.createReadStream(filepath).pipe(res);
45 | }
46 | });
47 |
48 | module.exports = router;
49 |
--------------------------------------------------------------------------------
/backend/logger.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 | const { createLogger, format, transports } = require("winston");
5 | const DailyRotateFile = require("winston-daily-rotate-file");
6 |
7 | function setLogLevel() {
8 | let level = "info";
9 | try {
10 | if (fs.existsSync("/config/settings.js")) {
11 | const settings = JSON.parse(fs.readFileSync("/config/settings.js"));
12 | level = settings.settings?.logLevel === "1" ? "debug" : "info";
13 | }
14 | } catch (err) {
15 | console.error("Error reading or parsing settings.js:", err);
16 | }
17 |
18 | const logger = createLogger({
19 | level: level,
20 | format: format.combine(
21 | format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
22 | format.printf((info) => `[${info.timestamp}] [${info.level}] ${info.message}`)
23 | ),
24 | transports: [
25 | new transports.Console(),
26 | new DailyRotateFile({
27 | filename: "/config/logs/prerollplus-%DATE%.log",
28 | datePattern: "YYYY-MM-DD",
29 | maxSize: "20m",
30 | maxFiles: "5",
31 | }),
32 | ],
33 | });
34 |
35 | // Override default console methods
36 | console.log = (...args) => logger.log("info", args.join(" "));
37 | console.info = (...args) => logger.log("info", args.join(" "));
38 | console.error = (...args) => logger.log("error", args.join(" "));
39 | console.warn = (...args) => logger.log("warn", args.join(" "));
40 | console.debug = (...args) => logger.log("debug", args.join(" "));
41 |
42 | console.info(`Log level set to "${level}"`);
43 | }
44 |
45 | setLogLevel();
46 |
47 | router.get("/", function (req, res, next) {
48 | setLogLevel();
49 | res.status(200).send();
50 | });
51 |
52 | module.exports = router;
53 |
--------------------------------------------------------------------------------
/backend/migrate.js:
--------------------------------------------------------------------------------
1 | var axios = require("axios").default;
2 |
3 | async function updateSequences(temp) {
4 | if (!temp || !Array.isArray(temp.sequences)) {
5 | console.warn("No sequences found to migrate.");
6 | return temp;
7 | }
8 |
9 | const today = new Date();
10 | today.setHours(0, 0, 0, 0);
11 | const currentYear = today.getFullYear();
12 |
13 | // helper delay
14 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15 |
16 | for (let i = 0; i < temp.sequences.length; i++) {
17 | const sequence = temp.sequences[i];
18 | const url = `https://date.nager.at/api/v3/publicholidays/${currentYear}/${sequence.country}`;
19 |
20 | try {
21 | const response = await axios.get(url, { timeout: 10000 });
22 |
23 | const publicHolidays = (response.data || []).filter((h) => Array.isArray(h.types) && h.types.includes("Public"));
24 |
25 | const match = publicHolidays.find((h) => h.name === sequence.holiday);
26 |
27 | if (match) {
28 | sequence.states = match.counties === null ? "All" : match.counties.join(", ");
29 | sequence.type = sequence.type ?? "1";
30 | sequence.holidayDate = match.date;
31 | sequence.holidaySource = sequence.holidaySource ?? "1";
32 |
33 | console.info(`Migrated sequence "${sequence.name}" (${sequence.id}) -> holiday ${match.date}`);
34 | } else {
35 | console.info(`No holiday match for sequence "${sequence.name}" in country ${sequence.country}`);
36 | }
37 | } catch (error) {
38 | console.error(
39 | `Error fetching holidays for country=${sequence.country} (sequence="${sequence.name}"):`,
40 | error.message
41 | );
42 | }
43 |
44 | // Add a 100ms delay before the next iteration to avoid hammering the API
45 | if (i < temp.sequences.length - 1) {
46 | await delay(100);
47 | }
48 | }
49 |
50 | return temp;
51 | }
52 |
53 | exports.updateSequences = updateSequences;
54 |
--------------------------------------------------------------------------------
/frontend/src/components/Sequences/Sequences.css:
--------------------------------------------------------------------------------
1 | .row-custom {
2 | padding-left: 10px;
3 | padding-top: 20px;
4 | }
5 |
6 | .card.card-global {
7 | width: 10rem;
8 | height: 8rem;
9 | background-color: #f8f9fa;
10 | }
11 |
12 | .card.card-default {
13 | border: 1px solid black;
14 | }
15 |
16 | .card.card-error {
17 | border: 2px solid red;
18 | }
19 |
20 | .card.card-unselected {
21 | cursor: pointer;
22 | border: 1px solid black;
23 | }
24 |
25 | .card-global.dark-mode {
26 | background-color: #2c2c2c;
27 | }
28 |
29 | .card-global.dark-mode .plus-image {
30 | filter: invert(80%);
31 | }
32 |
33 | .card-default.dark-mode {
34 | border: 1px solid #898989 !important;
35 | }
36 |
37 | .card-error.dark-mode {
38 | border: 2px solid red;
39 | }
40 |
41 | .card-unselected.dark-mode {
42 | border: 1px solid #898989;
43 | }
44 |
45 | .modal.dark-mode .modal-content {
46 | background-color: #363636;
47 | }
48 |
49 | .sort-buttons-group {
50 | display: inline-flex;
51 | gap: 10px;
52 | align-items: center;
53 | padding-left: 10px;
54 | padding-top: 20px;
55 | }
56 |
57 | .sort-button {
58 | background-color: #e0e0e0 !important;
59 | border: 1px solid #cccccc !important;
60 | border-radius: 8px !important;
61 | padding: 6px 12px !important;
62 | box-shadow: none !important;
63 | min-height: 40px;
64 | }
65 |
66 | .dark-mode .sort-button {
67 | background-color: #000000 !important;
68 | border: 1px solid #666666 !important;
69 | }
70 |
71 | .dark-mode .arrow-icon {
72 | filter: invert(100%) brightness(1.2) !important;
73 | }
74 |
75 | .sort-button.active {
76 | background-color: #c0c0c0 !important;
77 | }
78 | .dark-mode .sort-button.active {
79 | background-color: #606060 !important;
80 | }
81 |
82 | .sort-button:hover {
83 | background-color: #b0b0b0 !important;
84 | }
85 | .form-content.dark-mode .sort-button:hover {
86 | background-color: #707070 !important;
87 | }
--------------------------------------------------------------------------------
/frontend/src/components/App/App.css:
--------------------------------------------------------------------------------
1 | .navbar-row {
2 | border-bottom: solid;
3 | border-bottom-color: black;
4 | position: sticky;
5 | top: 0;
6 | z-index: 1000;
7 | box-shadow: 0 3px 0px 0px #e93663;
8 | }
9 |
10 | .navbar-content {
11 | background-color: #ddd;
12 | padding-left: 15px !important;
13 | padding-right: 15px !important;
14 | }
15 |
16 | .img-thumbnail {
17 | height: 40px;
18 | width: 40px;
19 | padding: 0 !important;
20 | }
21 |
22 | .nav-dropdown-update {
23 | color: red;
24 | }
25 |
26 | .main-row {
27 | padding-left: 30px;
28 | padding-top: 30px;
29 | padding-right: 30px;
30 | }
31 |
32 | .moon-icon {
33 | height: 20px;
34 | width: 20px;
35 | cursor: pointer;
36 | }
37 |
38 | /* Dark Mode Styles */
39 |
40 | body.dark-mode {
41 | background-color: #121212; /* Dark background */
42 | color: white; /* Light text */
43 | }
44 |
45 | body.dark-mode a {
46 | color: rgb(185, 185, 185);
47 | }
48 |
49 | .navbar-row.dark-mode {
50 | border-bottom-color: rgb(161, 161, 161);
51 | }
52 |
53 | .navbar-content.dark-mode {
54 | background-color: #333; /* Darker navbar */
55 | color: white; /* Light text */
56 | }
57 |
58 | /* Ensure links are styled correctly in dark mode */
59 | .navbar-content.dark-mode .nav-link {
60 | color: #b9b9b9; /* White text for navbar links and brand */
61 | }
62 |
63 | .navbar-content.dark-mode .navbar-brand {
64 | color: white; /* White text for navbar links and brand */
65 | }
66 |
67 | .navbar-content.dark-mode .nav-link:hover {
68 | color: #5c5c5c; /* Change hover color if desired */
69 | }
70 |
71 | .navbar-content.dark-mode .nav-link.active {
72 | color: white; /* Change hover color if desired */
73 | }
74 |
75 | .navbar-content.dark-mode .moon-icon {
76 | filter: invert(100%);
77 | }
78 |
79 | .navbar-content.dark-mode .logout-icon {
80 | filter: invert(100%);
81 | }
82 |
83 | .modal.dark-mode .modal-content {
84 | background-color: #363636;
85 | }
--------------------------------------------------------------------------------
/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Module dependencies.
5 | */
6 |
7 | var app = require("../app");
8 | var debug = require("debug")("prerollplus:server");
9 | var http = require("http");
10 | const { initializeWebSocket } = require("../backend/websocket");
11 |
12 | /**
13 | * Get port from environment and store in Express.
14 | */
15 |
16 | var port = normalizePort(process.env.PORT || "4949");
17 | app.set("port", port);
18 |
19 | /**
20 | * Create HTTP server.
21 | */
22 |
23 | var server = http.createServer(app);
24 |
25 | initializeWebSocket(server);
26 |
27 | /**
28 | * Listen on provided port, on all network interfaces.
29 | */
30 |
31 | server.listen(port);
32 | server.on("error", onError);
33 | server.on("listening", onListening);
34 |
35 | /**
36 | * Normalize a port into a number, string, or false.
37 | */
38 |
39 | function normalizePort(val) {
40 | var port = parseInt(val, 10);
41 |
42 | if (isNaN(port)) {
43 | // named pipe
44 | return val;
45 | }
46 |
47 | if (port >= 0) {
48 | // port number
49 | return port;
50 | }
51 |
52 | return false;
53 | }
54 |
55 | /**
56 | * Event listener for HTTP server "error" event.
57 | */
58 |
59 | function onError(error) {
60 | if (error.syscall !== "listen") {
61 | throw error;
62 | }
63 |
64 | var bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
65 |
66 | // handle specific listen errors with friendly messages
67 | switch (error.code) {
68 | case "EACCES":
69 | console.error(bind + " requires elevated privileges");
70 | process.exit(1);
71 | break;
72 | case "EADDRINUSE":
73 | console.error(bind + " is already in use");
74 | process.exit(1);
75 | break;
76 | default:
77 | throw error;
78 | }
79 | }
80 |
81 | /**
82 | * Event listener for HTTP server "listening" event.
83 | */
84 |
85 | function onListening() {
86 | var addr = server.address();
87 | var bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
88 | debug("Listening on " + bind);
89 | }
90 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
30 | Preroll+
31 |
32 |
33 | You need to enable JavaScript to run this app.
34 |
35 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/frontend/src/components/Sequence/Sequence.css:
--------------------------------------------------------------------------------
1 | .card.card-default {
2 | border: 1px solid black;
3 | }
4 |
5 | .card.card-error {
6 | border: 2px solid red !important;
7 | }
8 |
9 | .card.card-current {
10 | border: 2px solid #3A7BD5 !important;
11 | }
12 |
13 | .card.card-unselected {
14 | cursor: pointer;
15 | border: 1px solid black;
16 | }
17 |
18 | .card-header.header-custom {
19 | background-color: #f8f9fa;
20 | padding: 5px;
21 | }
22 |
23 | .icon-clickable {
24 | cursor: pointer;
25 | }
26 |
27 | .icon-noclick {
28 | cursor: default;
29 | }
30 |
31 | .sub-custom {
32 | height: 4rem;
33 | padding-left: 5px;
34 | padding-right: 5px;
35 | }
36 |
37 | .card-footer.footer-custom {
38 | background-color: #f8f9fa;
39 | padding: 5px;
40 | }
41 |
42 | .div-custom {
43 | display: block;
44 | flex-direction: column;
45 | justify-content: flex-end;
46 | text-align: left;
47 | font-size: 12px;
48 | height: 100%;
49 | padding-left: 5px;
50 | white-space: nowrap;
51 | overflow: hidden;
52 | text-overflow: ellipsis;
53 | max-width: 100%;
54 | width: 100%;
55 | }
56 |
57 |
58 | .edit-button {
59 | margin: 0;
60 | padding: 0;
61 | border-width: 0px;
62 | background-color: inherit;
63 | }
64 |
65 | .card-header.header-custom.dark-mode .icon-clickable,
66 | .card-footer.footer-custom.dark-mode .icon-clickable,
67 | .card-header.header-custom.dark-mode .icon-noclick,
68 | .card-footer.footer-custom.dark-mode .icon-noclick {
69 | filter: invert(100%);
70 | }
71 |
72 | .card-global.dark-mode {
73 | background-color: #2c2c2c;
74 | }
75 |
76 | .card-global.dark-mode .plus-image {
77 | filter: invert(80%);
78 | }
79 |
80 | .card-default.dark-mode {
81 | border: 1px solid #898989;
82 | }
83 |
84 | .card-error.dark-mode {
85 | border: 2px solid red !important;
86 | }
87 |
88 | .card-current.dark-mode {
89 | border: 2px solid #3A7BD5 !important;
90 | }
91 |
92 | .card-unselected.dark-mode {
93 | border: 1px solid #898989;
94 | }
95 |
96 | .header-custom.dark-mode,
97 | .footer-custom.dark-mode {
98 | background-color: #2c2c2c;
99 | }
100 |
--------------------------------------------------------------------------------
/frontend/src/components/CreateSeq/CreateSeq.css:
--------------------------------------------------------------------------------
1 | .div-seperator {
2 | padding-bottom: 0.75rem;
3 | }
4 |
5 | .listgroup-custom-s {
6 | border: none !important;
7 | }
8 |
9 | .div-font {
10 | font-size: 12px;
11 | }
12 |
13 | .form-content .form-select {
14 | width: 65px;
15 | }
16 |
17 | .card-custom {
18 | width: 22rem;
19 | background-color: white;
20 | border-radius: 0;
21 | }
22 |
23 | .card-body-custom {
24 | height: 500px;
25 | overflow-y: auto;
26 | margin: 0;
27 | }
28 |
29 | .listgroup-header {
30 | color: rgb(90, 90, 90) !important;
31 | background-color: white !important;
32 | font-size: 14px !important;
33 | }
34 |
35 | .listgroup-arrow {
36 | height: 25%;
37 | padding: 2px !important;
38 | }
39 |
40 | .listgroup-arrow-div {
41 | display: flex;
42 | justify-content: center;
43 | align-items: center;
44 | height: 100%;
45 | }
46 |
47 | .directory-loc {
48 | font-size: 11px;
49 | color: gray;
50 | }
51 |
52 | .form-content.dark-mode .listgroup-custom-b,
53 | .form-content.dark-mode .listgroup-custom-s {
54 | color: white;
55 | background-color: rgb(46, 46, 46);
56 | }
57 |
58 | .form-content.dark-mode .listgroup-custom-active {
59 | color: white;
60 | background-color: rgb(94, 94, 94);
61 | border: 0;
62 | }
63 |
64 | .form-content.dark-mode .listgroup-header {
65 | color: rgb(172, 172, 172) !important;
66 | background-color: rgb(46, 46, 46) !important;
67 | font-size: 14px !important;
68 | }
69 |
70 | .form-content.dark-mode .card-custom {
71 | background-color: rgb(46, 46, 46);
72 | }
73 |
74 | .form-content.dark-mode .form-control {
75 | color: white;
76 | background-color: rgb(46, 46, 46);
77 | border-color: #898989;
78 | }
79 |
80 | .form-content.dark-mode .arrow-icon {
81 | filter: invert(100%);
82 | }
83 |
84 | .form-content.dark-mode .listgroup-arrow {
85 | background-color: rgb(46, 46, 46);
86 | }
87 |
88 | .form-content.dark-mode .directory-loc {
89 | font-size: 11px;
90 | color: rgb(206, 206, 206);
91 | }
92 |
93 | .form-content.dark-mode .image-info {
94 | filter: invert(100%);
95 | }
96 |
97 | .modal.dark-mode .modal-content {
98 | background-color: #363636;
99 | }
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | var createError = require("http-errors");
2 | var express = require("express");
3 | var path = require("path");
4 | var cookieParser = require("cookie-parser");
5 |
6 | var logger = require("./backend/logger");
7 | var webhookRouter = require("./webhook/index");
8 | var uiRouter = require("./frontend/index");
9 | var load = require("./backend/load");
10 | var save = require("./backend/save");
11 | var thumb = require("./backend/thumb");
12 | var settings = require("./backend/settings");
13 | var directory = require("./backend/directory");
14 | var streamer = require("./backend/streamer");
15 | var monitor = require("./backend/monitor");
16 | var holiday = require("./backend/holiday");
17 | var clearCache = require("./backend/clearcache");
18 |
19 | var app = express();
20 |
21 | // logger.token("customDate", function () {
22 | // var current_ob = new Date();
23 | // var date = "[" + current_ob.toLocaleDateString("en-CA") + " " + current_ob.toLocaleTimeString("en-GB") + "]";
24 | // return date;
25 | // });
26 | // app.use(logger(":customDate [INFO] :method :url - Status: :status"));
27 | app.use(express.json());
28 | app.use(express.urlencoded({ extended: true }));
29 | app.use(cookieParser());
30 | app.use(express.static(path.join(__dirname, "frontend/production")));
31 |
32 | app.use("/", uiRouter);
33 | app.use("/backend/logger", logger);
34 | app.use("/backend/load", load);
35 | app.use("/backend/save", save);
36 | app.use("/backend/thumb", thumb);
37 | app.use("/backend/settings", settings);
38 | app.use("/backend/directory", directory);
39 | app.use("/backend/streamer", streamer);
40 | app.use("/backend/monitor", monitor);
41 | app.use("/backend/holiday", holiday);
42 | app.use("/backend/clearcache", clearCache);
43 |
44 | app.use("/webhook", webhookRouter);
45 | app.use("/*", uiRouter);
46 |
47 | // catch 404 and forward to error handler
48 | app.use(function (req, res, next) {
49 | next(createError(404));
50 | });
51 |
52 | // error handler
53 | app.use(function (err, req, res, next) {
54 | console.error(err);
55 | // set locals, only providing error in development
56 | res.locals.message = err.message;
57 | res.locals.error = req.app.get("env") === "development" ? err : {};
58 |
59 | // render the error page
60 | res.status(err.status || 500);
61 | res.render("error");
62 | });
63 |
64 | module.exports = app;
65 |
--------------------------------------------------------------------------------
/frontend/src/components/Bucket/Bucket.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Card from "react-bootstrap/Card";
3 | import Xclose from "bootstrap-icons/icons/x-square.svg";
4 | import Edit from "bootstrap-icons/icons/pencil-square.svg";
5 | import Row from "react-bootstrap/Row";
6 | import Col from "react-bootstrap/Col";
7 | import Image from "react-bootstrap/Image";
8 | import "../Sequence/Sequence.css";
9 |
10 | export default class Bucket extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.state = {
14 | id: "",
15 | active: true,
16 | };
17 |
18 | this.state.id = this.props.id;
19 | this.handleClick = this.handleClick.bind(this.props.id);
20 | }
21 |
22 | handleClick = (e) => {
23 | this.props.click(e.currentTarget.value);
24 | };
25 |
26 | handleDelete = () => {
27 | this.props.delete(this.state.id);
28 | };
29 |
30 | render() {
31 | return (
32 |
40 |
41 | {this.props.isEdit || this.props.isCreating ? (
42 |
43 | ) : (
44 |
45 | )}
46 |
47 |
48 | {this.props.bucket}
49 |
50 |
51 |
52 |
53 |
54 |
55 | {this.props.isEdit || this.props.isCreating ? (
56 |
57 | ) : (
58 |
59 |
60 |
61 | )}
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Preroll Plus
2 |
3 | Preroll Plus is a dynamic preroll updater and scheduler. This app bypasses the limitations Plex has for combining random and sequential prerolls (using the "," and ";" delimiters). It works by using Plex webhooks to update the preroll string in Plex whenever a movie is started, allowing file sets that require randomization to remain randomized while maintaining a broader sequence.
4 |
5 | For example:
6 |
7 | Plex allows files delimited by commas to play in sequence:
8 |
9 | "preroll1,preroll2,preroll3,preroll4"
10 |
11 | This will play preroll1, followed by preroll2, followed by preroll3, etc. Four prerolls are played.
12 |
13 | Plex also allows files delimited by semi-colons to play randomly:
14 |
15 | "preroll1;preroll2;preroll3;preroll4"
16 |
17 | In this instance Plex will randomly choose **one** of the four prerolls and play it only.
18 |
19 | What you **cannot** do with Plex is create a list of prerolls that combine commas and semi-colons.
20 |
21 | For example:
22 |
23 | "preroll1;preroll2,preroll3;preroll4"
24 |
25 | The intention would be to randomly play either preroll1 or preroll2, and then randomly play preroll3 or preroll4 thus playing two prerolls total.
26 |
27 | #### Solution
28 |
29 | Preroll Plus replaces semi-colon lists with "buckets" and comma lists with "sequences".
30 |
31 | You then create sequences (with or without a schedule) that contain a sequence of buckets. A file in each bucket will play randomly and then move on to the next bucket in a sequence.
32 |
33 | Since you can create as many buckets as you'd like, this can generate an infinite amount of combinations as you desire.
34 |
35 | Scheduled sequences will automatically assert on the first day the sequence starts. Fall back to a default unscheduled sequence when no scheduled sequenced are in their timeframe.
36 |
37 | ## Getting Started
38 |
39 | ### Information
40 |
41 | - [Documentation](https://github.com/chadwpalm/PrerollPlus/wiki)
42 | - [Donate](https://www.buymeacoffee.com/lumunarr)
43 |
44 | ### Features
45 |
46 | - Easy to use web-based graphical interface that is also mobile friendly.
47 | - Combine "buckets" and "sequences" to generate your desired preroll functionality.
48 | - Sequences can be scheduled to change throughout the year.
49 | - Run a schedule based on a holiday leveraging one of the largest holiday calendar APIs.
50 | - Prioritize schedules so that you can layer schedules adding more power to what sequence plays for that day.
51 | - Can be run natively or in a Docker container.
52 |
53 | ### Support
54 |
55 | - [Discussions](https://github.com/chadwpalm/PrerollPlus/discussions)
56 | - [Issues](https://github.com/chadwpalm/PrerollPlus/issues)
57 | - [Discord](https://discord.gg/sXse6H8fMF)
58 |
59 | ### Licenses
60 |
61 | - [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
62 | - Copyright 2025
63 |
64 | _Preroll Plus is not affiliated or endorsed by Plex Inc._
65 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | pipeline {
2 |
3 | agent any
4 |
5 | environment {
6 | REPO="chadwpalm"
7 | IMAGE_NAME="prerollplus"
8 | BUILD_CRED=credentials('c8678c85-1f8d-4dc0-b9b0-e7fe12d6a24a')
9 | }
10 |
11 | options {
12 | timeout (time: 10, unit: 'MINUTES')
13 | buildDiscarder (logRotator (numToKeepStr: '3'))
14 | }
15 |
16 | stages {
17 | stage('Login') {
18 | steps {
19 | withCredentials([usernamePassword(credentialsId: '71aeb696-0670-4267-8db4-8ee74774e051', usernameVariable: 'DOCKER_USERNAME', passwordVariable: 'DOCKER_PASSWORD')]) {
20 | sh ('echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin')
21 | }
22 | }
23 | }
24 | stage('Build Dev') {
25 | when {
26 | branch "develop"
27 | }
28 | steps {
29 | script {
30 | def JSONVersion = readJSON file: "version.json"
31 | def PulledVersion = JSONVersion.version
32 | def BuildNumber = sh (
33 | script: 'curl https://increment.build/${BUILD_CRED}',
34 | returnStdout: true
35 | ).trim()
36 | def APPVersion = "${PulledVersion}.${BuildNumber}"
37 | sh "docker build --force-rm --pull --build-arg BUILD='${BuildNumber}' -t ${REPO}/${IMAGE_NAME}:develop-${APPVersion} ."
38 | sh "docker tag ${REPO}/${IMAGE_NAME}:develop-${APPVersion} ${REPO}/${IMAGE_NAME}:develop"
39 | sh "docker push ${REPO}/${IMAGE_NAME}:develop-${APPVersion}"
40 | sh "docker push ${REPO}/${IMAGE_NAME}:develop"
41 | sh "docker rmi ${REPO}/${IMAGE_NAME}:develop-${APPVersion}"
42 | }
43 |
44 | }
45 | }
46 | stage('Build Prod') {
47 | when {
48 | branch "main"
49 | }
50 | steps {
51 | script {
52 | def JSONVersion = readJSON file: "version.json"
53 | def PulledVersion = JSONVersion.version
54 | def BuildNumber = sh (
55 | script: 'curl https://increment.build/${BUILD_CRED}/get',
56 | returnStdout: true
57 | ).trim()
58 | def APPVersion = "${PulledVersion}.${BuildNumber}"
59 | sh "docker build --force-rm --pull --build-arg BUILD='${BuildNumber}' -t ${REPO}/${IMAGE_NAME}:${APPVersion} ."
60 | sh "docker tag ${REPO}/${IMAGE_NAME}:${APPVersion} ${REPO}/${IMAGE_NAME}:latest"
61 | sh "docker push ${REPO}/${IMAGE_NAME}:${APPVersion}"
62 | sh "docker push ${REPO}/${IMAGE_NAME}:latest"
63 | sh "docker rmi ${REPO}/${IMAGE_NAME}:${APPVersion}"
64 | }
65 | }
66 | }
67 | stage('Build Features/Fixes') {
68 | when {
69 | not {
70 | anyOf {
71 | branch "main";
72 | branch "develop"
73 | }
74 | }
75 | }
76 | steps {
77 | script {
78 | def JSONVersion = readJSON file: "version.json"
79 | def PulledVersion = JSONVersion.version
80 | def BuildNumber = sh (
81 | script: 'curl https://increment.build/${BUILD_CRED}',
82 | returnStdout: true
83 | ).trim()
84 | def APPVersion = "${PulledVersion}.${BuildNumber}"
85 | sh "docker build --force-rm --pull --build-arg BUILD=${BuildNumber} -t ${REPO}/${IMAGE_NAME}:${BRANCH_NAME}-${APPVersion} ."
86 | sh "docker push ${REPO}/${IMAGE_NAME}:${BRANCH_NAME}-${APPVersion}"
87 | }
88 | }
89 | }
90 | }
91 | }
92 |
93 |
--------------------------------------------------------------------------------
/frontend/src/components/App/Announce.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Modal from "react-bootstrap/Modal";
3 | import Form from "react-bootstrap/Form";
4 | import Button from "react-bootstrap/Button";
5 | import "./App.css";
6 |
7 | const Announce = ({ announce, fullscreenAnn, handleCloseAnn, handleDismiss, dismiss, isDarkMode }) => {
8 | return (
9 |
17 |
18 | Announcement
19 |
20 |
21 | Major Update v1.3.0 - What are my Sequences?
22 |
23 |
24 | One thought occurred to me recently when a Preroll Plus user submitted a request: they wanted to see exactly
25 | which files were being sent to Plex for the current sequence. That thought was, “Why haven’t I thought of this
26 | sooner?”
27 |
28 |
29 | It wasn’t so much about knowing the exact files being played, but about being able to see at a glance which
30 | sequence was active. So I added two new elements to Preroll Plus that do just that!
31 |
32 |
33 | The first is simple: a blue border now appears around the sequence that is being used for that day. Now, with
34 | one quick look, you can instantly see which sequence is active at any given moment.
35 |
36 |
37 | I decided to take it a step further and add a calendar page that shows which sequence will play on each day of
38 | the month. Just open the Calendar page and you’ll see a calendar for the current month, with each day clearly
39 | labeled with the sequence that will be used — based on your schedules and priorities. This lets you verify in
40 | advance whether your schedules and priorities are working the way you intend, eliminating guesswork or having to
41 | wait for the day to arrive. You can also step through the months for the current year and the upcoming year.
42 |
43 |
44 | Major Bug Fix
45 |
46 |
47 | It’s actually a good thing I went through the exercise of adding the calendar, because it revealed a bug in the
48 | code that would have prevented the correct holiday dates for 2026 from working — the app would have kept using
49 | 2025’s dates instead. This is now fixed.
50 |
51 |
52 | We’ve come a long way with Preroll Plus, and I’m excited for whatever features come next!
53 |
54 |
55 | As always, if you have any issues please{" "}
56 |
57 | create an issue on GitHub
58 |
59 | , or if you simply have a question or want to discuss any features you and use the{" "}
60 |
61 | discussion board
62 |
63 | .
64 |
65 |
66 |
75 |
76 |
77 |
78 | Dismiss
79 |
80 |
81 |
82 | );
83 | };
84 |
85 | export default Announce;
86 |
--------------------------------------------------------------------------------
/frontend/src/components/Sequence/Sequence.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Card from "react-bootstrap/Card";
3 | import Xclose from "bootstrap-icons/icons/x-square.svg";
4 | import Edit from "bootstrap-icons/icons/pencil-square.svg";
5 | import Row from "react-bootstrap/Row";
6 | import Col from "react-bootstrap/Col";
7 | import Image from "react-bootstrap/Image";
8 | import "./Sequence.css";
9 |
10 | export default class Sequence extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.state = {
14 | id: "",
15 | active: true,
16 | };
17 |
18 | this.state.id = this.props.id;
19 | this.handleClick = this.handleClick.bind(this.props.id);
20 | }
21 |
22 | handleClick = (e) => {
23 | this.props.click(e.currentTarget.value);
24 | };
25 |
26 | handleDelete = () => {
27 | this.props.delete(this.state.id);
28 | };
29 |
30 | render() {
31 | return (
32 |
44 |
45 |
46 |
47 | Priority: {this.props.priority || "N/A"}
48 |
49 |
50 | {" "}
51 | {this.props.isEdit || this.props.isCreating ? (
52 |
53 | ) : (
54 |
55 | )}
56 |
57 |
58 |
59 |
60 | {this.props.sequence}
61 |
62 |
63 |
64 |
65 |
66 | {this.props.schedule === "2" ? (
67 | <>No Schedule>
68 | ) : this.props.schedule === "3" ? (
69 | <>{this.props.holiday}>
70 | ) : (
71 | <>
72 | {new Intl.DateTimeFormat(undefined, {
73 | day: "2-digit",
74 | month: "2-digit",
75 | }).format(new Date(2023, this.props.startMonth - 1, this.props.startDay))}
76 | {" - "}
77 | {new Intl.DateTimeFormat(undefined, {
78 | day: "2-digit",
79 | month: "2-digit",
80 | }).format(new Date(2023, this.props.endMonth - 1, this.props.endDay))}
81 | >
82 | )}
83 |
84 |
85 |
86 |
87 | {this.props.isEdit || this.props.isCreating ? (
88 |
89 | ) : (
90 |
91 |
92 |
93 | )}
94 |
95 |
96 |
97 |
98 |
99 | );
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/backend/load.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 | var os = require("os");
5 | var uuid = require("uuid").v4;
6 | var updates = require("./migrate.js");
7 | var axios = require("axios").default;
8 |
9 | var appVersion, branch, UID, GID, build;
10 |
11 | try {
12 | var info = fs.readFileSync("version.json");
13 | appVersion = JSON.parse(info).version;
14 | branch = JSON.parse(info).branch;
15 | } catch (err) {
16 | console.error("Cannot grab version and branch info", err);
17 | }
18 |
19 | if (process.env.PUID) {
20 | UID = Number(process.env.PUID);
21 | } else {
22 | UID = os.userInfo().uid;
23 | }
24 |
25 | if (process.env.PGID) {
26 | GID = Number(process.env.PGID);
27 | } else {
28 | GID = os.userInfo().gid;
29 | }
30 |
31 | if (process.env.BUILD) {
32 | build = process.env.BUILD;
33 | } else {
34 | build = "Native";
35 | }
36 |
37 | var fileData = `{"connected": "false","platform":"${
38 | os.platform
39 | }","uuid":"${uuid()}","version":"${appVersion}","branch":"${branch}","build":"${build}", "sequences": [], "buckets": [],"message":true}`;
40 |
41 | try {
42 | fileData = fs.readFileSync("/config/settings.js");
43 | var temp = JSON.parse(fileData);
44 |
45 | if (build !== "Native") {
46 | temp.settings.loc = "/prerolls";
47 | }
48 |
49 | if (temp.api !== "v2") {
50 | console.info('Backing up old settings file to "settings_v1.bak"');
51 | fs.writeFileSync("/config/settings_v1.bak", JSON.stringify(temp));
52 |
53 | updates.updateSequences(temp).then((newTemp) => {
54 | newTemp.api = "v2";
55 | if (newTemp.version !== appVersion || newTemp.build !== build || newTemp.branch !== branch) {
56 | console.info(
57 | "Version updated from",
58 | newTemp.version,
59 | "build",
60 | newTemp.build,
61 | "branch",
62 | newTemp.branch,
63 | "to",
64 | appVersion,
65 | "build",
66 | build,
67 | "branch",
68 | branch
69 | );
70 | newTemp.version = appVersion;
71 | newTemp.build = build;
72 | newTemp.branch = branch;
73 | newTemp.message = true;
74 |
75 | delete newTemp["token"];
76 | }
77 |
78 | fs.writeFileSync("/config/settings.js", JSON.stringify(newTemp));
79 | fs.chownSync("/config/settings.js", UID, GID, (err) => {
80 | if (err) throw err;
81 | });
82 | console.info(`Config file updated to UID: ${UID} GID: ${GID}`);
83 | console.info("Settings file read");
84 | });
85 | } else {
86 | if (temp.version !== appVersion || temp.build !== build || temp.branch !== branch) {
87 | console.info(
88 | "Version updated from",
89 | temp.version,
90 | "build",
91 | temp.build,
92 | "branch",
93 | temp.branch,
94 | "to",
95 | appVersion,
96 | "build",
97 | build,
98 | "branch",
99 | branch
100 | );
101 | temp.version = appVersion;
102 | temp.build = build;
103 | temp.branch = branch;
104 | temp.message = true;
105 |
106 | delete temp["token"];
107 | }
108 |
109 | fs.writeFileSync("/config/settings.js", JSON.stringify(temp));
110 | fs.chownSync("/config/settings.js", UID, GID, (err) => {
111 | if (err) throw err;
112 | });
113 | console.info(`Config file updated to UID: ${UID} GID: ${GID}`);
114 | console.info("Settings file read");
115 | }
116 |
117 | if (temp.settings) {
118 | try {
119 | axios
120 | .get("http://localhost:4949/webhook") // Make sure the path is correct
121 | .then((response) => {})
122 | .catch((error) => {});
123 | } catch {
124 | console.error("Could not create initial sequence");
125 | }
126 | }
127 | } catch (err) {
128 | console.info("Settings file not found, creating");
129 | try {
130 | if (!fs.existsSync("/config")) {
131 | fs.mkdirSync("/config");
132 | }
133 | fs.writeFileSync("/config/settings.js", fileData);
134 | console.info("Settings file created");
135 | fs.chownSync("/config/settings.js", UID, GID, (err) => {
136 | if (err) throw err;
137 | });
138 | console.info(`Config file set to UID: ${UID} GID: ${GID}`);
139 | } catch (err) {
140 | if (err) throw err;
141 | }
142 | }
143 |
144 | router.get("/", function (req, res, next) {
145 | try {
146 | fileData = fs.readFileSync("/config/settings.js");
147 | console.info("Settings file read");
148 | } catch (err) {
149 | console.error("Settings file not found");
150 | }
151 |
152 | res.send(fileData);
153 | });
154 |
155 | module.exports = router;
156 |
--------------------------------------------------------------------------------
/backend/settings.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var axios = require("axios").default;
4 | const https = require("https");
5 | var parser = require("xml-js");
6 |
7 | router.post("/", async function (req, res, next) {
8 | var serverList = [];
9 | var finalList = [];
10 | var unauth = false;
11 |
12 | const token = req.body.token;
13 | const plexApiUrl = "https://plex.tv/api/servers";
14 |
15 | try {
16 | console.info("Retrieving Plex Servers");
17 | const response = await axios.get(plexApiUrl, { timeout: 10000, params: { "X-Plex-Token": token } });
18 | const servers = parser.xml2js(response.data, { compact: true, spaces: 4 }).MediaContainer.Server;
19 |
20 | if (Array.isArray(servers)) {
21 | serverList = servers;
22 | } else if (!servers) {
23 | serverList = [];
24 | } else {
25 | serverList.push(servers);
26 | }
27 |
28 | console.debug("Found", serverList.length, "Plex servers");
29 |
30 | for (let i = 0; i < serverList.length; i++) {
31 | console.debug(`Server ${i + 1} info:`);
32 | console.debug("Name:", serverList[i]._attributes.name);
33 | console.debug("Owned:", serverList[i]._attributes.owned);
34 | console.debug("External Address:", serverList[i]._attributes.address);
35 | console.debug("External Port:", serverList[i]._attributes.port);
36 | console.debug("Local Address:", serverList[i]._attributes.localAddresses);
37 | console.debug("Local Port: 32400");
38 | }
39 | } catch (error) {
40 | if (error.response && error.response.status === 401) {
41 | unauth = true;
42 | }
43 | console.error("Issue with connection to online Plex account while requesting servers:", error.message);
44 | }
45 |
46 | for (const element of serverList) {
47 | if (element._attributes.owned === "1") {
48 | const localAddresses = element._attributes.localAddresses.split(","); // Split multiple local addresses by commas
49 |
50 | for (const localIP of localAddresses) {
51 | const localUrl = `http://${localIP.trim()}:32400/:/prefs`; // Trim whitespace and form the URL
52 | console.debug("Retrieving Cert from URL:", localUrl);
53 |
54 | try {
55 | const response = await axios.get(localUrl, { timeout: 3000, params: { "X-Plex-Token": token } });
56 |
57 | let certId = response.data.MediaContainer.Setting.find((id) => id.id === "CertificateUUID");
58 |
59 | // Push the server info, marking certSuccessful as true if the cert is found
60 | finalList.push({
61 | name: element._attributes.name,
62 | localIP: localIP.trim(),
63 | remoteIP: element._attributes.address,
64 | port: element._attributes.port,
65 | cert: certId ? certId.value : null, // Only include cert if it exists
66 | certSuccessful: certId !== undefined, // Flag indicating whether cert was found or not
67 | https: false,
68 | });
69 | } catch (error) {
70 | console.info(`Could not make insecure connection to Plex Server at ${localIP}`);
71 | console.info("Attempting secure connection");
72 | // Check with secure connection
73 | const localUrl = `https://${localIP.trim()}:32400/:/prefs`;
74 | try {
75 | const agent = new https.Agent({
76 | rejectUnauthorized: false, // Disable certificate verification
77 | });
78 |
79 | const response = await axios.get(localUrl, {
80 | timeout: 3000,
81 | params: { "X-Plex-Token": token },
82 | httpsAgent: agent,
83 | });
84 |
85 | let certId = response.data.MediaContainer.Setting.find((id) => id.id === "CertificateUUID");
86 |
87 | // Push the server info, marking certSuccessful as true if the cert is found
88 | finalList.push({
89 | name: element._attributes.name,
90 | localIP: localIP.trim(),
91 | remoteIP: element._attributes.address,
92 | port: element._attributes.port,
93 | cert: certId ? certId.value : null, // Only include cert if it exists
94 | certSuccessful: certId !== undefined, // Flag indicating whether cert was found or not
95 | https: true,
96 | });
97 | } catch (error) {
98 | console.error(`Issue with secure connection to Plex Server at ${localIP}:`, error.message);
99 | }
100 | // On failure, still push the server info but set certSuccessful to false
101 | finalList.push({
102 | name: element._attributes.name,
103 | localIP: localIP.trim(),
104 | remoteIP: element._attributes.address,
105 | port: element._attributes.port,
106 | cert: null, // No cert available on failure
107 | certSuccessful: false, // Mark the cert as unsuccessful
108 | https: false,
109 | });
110 | }
111 | }
112 | }
113 | }
114 | console.debug("Final List: ", JSON.stringify(finalList));
115 | res.send(JSON.stringify(finalList));
116 | });
117 |
118 | module.exports = router;
119 |
--------------------------------------------------------------------------------
/frontend/src/components/Calendar/Calendar.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from "react";
2 | import FullCalendar from "@fullcalendar/react";
3 | import dayGridPlugin from "@fullcalendar/daygrid";
4 | import { addMonths, startOfMonth, format, startOfYear } from "date-fns";
5 | import "./Calendar.css";
6 |
7 | const today = new Date();
8 | const start = format(startOfYear(today), "yyyy-MM-dd");
9 | const end = format(addMonths(startOfYear(today), 24), "yyyy-MM-dd");
10 |
11 | export default function SequenceCalendar({ events, isDarkMode = false, settings }) {
12 | const [monthEvents, setMonthEvents] = useState([]);
13 | const currentMonthRef = useRef("");
14 |
15 | const loadMonth = (year, month) => {
16 | const key = `${year}-${month}`;
17 | if (currentMonthRef.current === key) return;
18 | currentMonthRef.current = key;
19 |
20 | fetch(`/webhook/calendar?year=${year}&month=${month}&_=${Date.now()}`)
21 | .then((r) => r.json())
22 | .then((data) => {
23 | console.log("Fresh data loaded:", year, month, data);
24 | setMonthEvents(data);
25 | });
26 | };
27 |
28 | useEffect(() => {
29 | const now = new Date();
30 | loadMonth(now.getFullYear(), now.getMonth() + 1);
31 | }, []);
32 |
33 | return (
34 | <>
35 |
44 | Hover over a Sequence name to view its buckets
45 |
46 | {
59 | const currentDate = info.view.calendar.getDate();
60 | const year = currentDate.getFullYear();
61 | const month = currentDate.getMonth() + 1;
62 | loadMonth(year, month);
63 | }}
64 | dayCellContent={(arg) => {
65 | const dateStr = arg.date.toISOString().split("T")[0];
66 | const ev = monthEvents.find((e) => e.date === dateStr);
67 |
68 | const dayNumberColor = isDarkMode ? "#ffffff" : "#000000";
69 | const textColor = isDarkMode ? "#e0e0e0" : "#000000";
70 | const bgColor = isDarkMode ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.06)";
71 |
72 | return (
73 |
74 | {/* Day number — top right */}
75 |
86 | {arg.dayNumberText.replace(/\D/g, "")}
87 |
88 |
89 | {/* Sequence title */}
90 | {ev && (
91 |
92 |
{
104 | // Remove any existing tooltip
105 | document.querySelectorAll(".bucket-tooltip").forEach((t) => t.remove());
106 |
107 | const rect = e.currentTarget.getBoundingClientRect();
108 | const tooltip = document.createElement("div");
109 | tooltip.className = "bucket-tooltip";
110 | tooltip.innerHTML = ev.buckets.map((name, i) => `${i + 1}. ${name}`).join(" ");
111 |
112 | Object.assign(tooltip.style, {
113 | position: "fixed",
114 | top: rect.bottom + 8 + "px",
115 | left: rect.left + rect.width / 2 + "px",
116 | transform: "translateX(-50%)",
117 | background: isDarkMode ? "#1a1a1a" : "#ffffff",
118 | color: isDarkMode ? "#eee" : "#222",
119 | border: `1px solid ${isDarkMode ? "#444" : "#ccc"}`,
120 | borderRadius: "8px",
121 | padding: "10px 14px",
122 | fontSize: "13px",
123 | fontWeight: "500",
124 | zIndex: "999999",
125 | boxShadow: "0 10px 30px rgba(0,0,0,0.5)",
126 | pointerEvents: "none",
127 | whiteSpace: "nowrap",
128 | });
129 |
130 | document.body.appendChild(tooltip);
131 | }}
132 | onMouseLeave={() => {
133 | document.querySelectorAll(".bucket-tooltip").forEach((t) => t.remove());
134 | }}
135 | >
136 | {ev.title}
137 |
138 |
139 | )}
140 |
141 | );
142 | }}
143 | />
144 | >
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/Login.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Container from "react-bootstrap/Container";
3 | import Row from "react-bootstrap/Row";
4 | import Button from "react-bootstrap/Button";
5 | import LoginIcon from "bootstrap-icons/icons/box-arrow-in-left.svg";
6 | // import Logo from "../../images/Logo2.png";
7 | import Card from "react-bootstrap/Card";
8 | import { PlexOauth } from "plex-oauth";
9 |
10 | export default class Login extends Component {
11 | constructor(props) {
12 | super(props);
13 |
14 | this.state = {
15 | gotURL: false,
16 | gotToken: false,
17 | url: "",
18 | pin: null,
19 | ip: "",
20 | port: "",
21 | ssl: false,
22 | saved: false,
23 | openWindow: false,
24 | gettingToken: false,
25 | gettingIPs: false,
26 | isLoading: false,
27 | noInternet: false,
28 | error: null,
29 | };
30 |
31 | let version;
32 |
33 | if (this.props.settings.build !== "Native") {
34 | if (this.props.settings.branch === "dev") {
35 | version = `${this.props.settings.version}.${this.props.settings.build}-dev`;
36 | } else {
37 | version = `${this.props.settings.version}.${this.props.settings.build}`;
38 | }
39 | } else {
40 | if (this.props.settings.branch === "dev") {
41 | version = `${this.props.settings.version}-dev`;
42 | } else {
43 | version = `${this.props.settings.version}`;
44 | }
45 | }
46 |
47 | let clientInformation = {
48 | clientIdentifier: `${this.props.settings.uuid}`, // This is a unique identifier used to identify your app with Plex.
49 | product: "Preroll Plus", // Name of your application
50 | device: `${this.props.settings.platform}`, // The type of device your application is running on
51 | version: `${version}`, // Version of your application
52 | forwardUrl: "", // Url to forward back to after signing in.
53 | platform: "Web", // Optional - Platform your application runs on - Defaults to 'Web'
54 | };
55 |
56 | this.externalWindow = null;
57 | this.plexOauth = new PlexOauth(clientInformation);
58 | }
59 |
60 | componentDidMount() {
61 | if (!this.state.gotURL) {
62 | this.plexOauth
63 | .requestHostedLoginURL()
64 | .then((data) => {
65 | let [hostedUILink, pinId] = data;
66 |
67 | this.setState({ url: `${hostedUILink}`, pin: `${pinId}` });
68 | })
69 | .catch((err) => {
70 | this.setState({ noInternet: true });
71 | throw err;
72 | });
73 | this.setState({
74 | gotURL: true,
75 | });
76 | }
77 | }
78 |
79 | executePoll = async () => {
80 | try {
81 | var token;
82 | if (!this.state.pin) {
83 | throw new Error("Unable to poll when pin is not initialized.");
84 | }
85 | await this.plexOauth
86 | .checkForAuthToken(this.state.pin)
87 | .then((authToken) => {
88 | token = authToken;
89 | })
90 | .catch((err) => {
91 | throw err;
92 | });
93 |
94 | if (token) {
95 | this.setState({ gotToken: true });
96 | this.handleGetThumb(token);
97 | this.handleSaveToken(token);
98 | this.externalWindow.close();
99 | } else if (token === null && !this.externalWindow.closed) {
100 | setTimeout(this.executePoll, 1000);
101 | } else {
102 | this.setState({ gettingToken: false });
103 | throw new Error("Window closed without completing login");
104 | }
105 | } catch (e) {
106 | this.externalWindow.close();
107 | }
108 | };
109 |
110 | handlePlexAuth = async () => {
111 | this.setState({ gettingToken: true });
112 | const y = window.top.outerHeight / 2 + window.top.screenY - 300;
113 | const x = window.top.outerWidth / 2 + window.top.screenX - 300;
114 | this.externalWindow = window.open(
115 | `${this.state.url}`,
116 | "",
117 | `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=600, height=600, top=${y}, left=${x}`
118 | );
119 |
120 | await this.executePoll();
121 | };
122 |
123 | handleSaveToken = (token) => {
124 | var settings = { ...this.props.settings };
125 |
126 | settings.token = token;
127 | settings.thumb = this.thumb;
128 | settings.email = this.email;
129 | settings.username = this.username;
130 |
131 | var xhr = new XMLHttpRequest();
132 |
133 | xhr.addEventListener("readystatechange", () => {
134 | if (xhr.readyState === 4) {
135 | if (xhr.status === 200) {
136 | } else {
137 | // error
138 | this.setState({
139 | error: xhr.responseText,
140 | });
141 | }
142 | }
143 | });
144 |
145 | xhr.open("POST", "/backend/save", false);
146 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
147 | xhr.send(JSON.stringify(settings));
148 |
149 | this.props.handleLogin();
150 | this.props.handleUpdateThumb(this.thumb, token, this.username, this.email);
151 | };
152 |
153 | handleGetThumb = (token) => {
154 | var data = {};
155 | data.token = token;
156 | var xhr = new XMLHttpRequest();
157 | xhr.addEventListener("readystatechange", () => {
158 | if (xhr.readyState === 4) {
159 | if (xhr.status === 200) {
160 | // request successful
161 | var response = xhr.responseText,
162 | json = JSON.parse(response);
163 |
164 | this.thumb = json.thumb;
165 | this.email = json.email;
166 | this.username = json.username;
167 | } else {
168 | // error
169 | }
170 | }
171 | });
172 | xhr.open("POST", "/backend/thumb", false);
173 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
174 | xhr.send(JSON.stringify(data));
175 | };
176 |
177 | render() {
178 | if (this.state.gotURL) {
179 | return (
180 |
184 |
188 |
189 |
190 | Preroll Plus
191 |
192 |
193 |
194 | Sign in to Plex account to continue
195 |
196 |
197 |
198 | {this.state.gettingToken ? (
199 |
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/Buckets/Buckets.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Bucket from "../Bucket/Bucket";
3 | import AddIcon from "../../images/add-icon.png";
4 | import Create from "../Create/Create";
5 | import Row from "react-bootstrap/Row";
6 | import Col from "react-bootstrap/Col";
7 | import Card from "react-bootstrap/Card";
8 | import Modal from "react-bootstrap/Modal";
9 | import Button from "react-bootstrap/Button";
10 | import SortName from "bootstrap-icons/icons/sort-alpha-up.svg";
11 | import Image from "react-bootstrap/Image";
12 | import OverlayTrigger from "react-bootstrap/OverlayTrigger";
13 | import Tooltip from "react-bootstrap/Tooltip";
14 | import "../Sequences/Sequences.css";
15 |
16 | export default class Buckets extends Component {
17 | constructor(props) {
18 | super(props);
19 | this.state = {
20 | // buckets: this.props.settings.buckets,
21 | isCreating: false,
22 | id: "-1",
23 | isEdit: false,
24 | show: false,
25 | tempID: "",
26 | };
27 | }
28 |
29 | refreshSettings = () => {
30 | this.props.onSettingsChanged?.(); // calls refreshConfig() in App.jsx
31 | };
32 |
33 | handleAddBucket = () => {
34 | this.setState({
35 | isCreating: true,
36 | isEdit: false,
37 | id: "-1",
38 | });
39 | };
40 |
41 | handleEditBucket = (e) => {
42 | this.setState({
43 | isCreating: true,
44 | isEdit: true,
45 | id: e,
46 | });
47 | };
48 |
49 | handleCancelCreate = () => {
50 | this.setState({ isCreating: false, isEdit: false });
51 | };
52 |
53 | handleSaveCreate = () => {
54 | this.props.saved();
55 | this.setState({ isCreating: false, isEdit: false });
56 | };
57 |
58 | handleClose = () => this.setState({ show: false });
59 |
60 | handleOpen = (e) => {
61 | this.setState({ tempID: e, show: true, fullscreen: "md-down" });
62 | };
63 |
64 | handleDelete = () => {
65 | var settings = { ...this.props.settings };
66 |
67 | const index = settings.buckets.findIndex(({ id }) => id === this.state.tempID);
68 |
69 | settings.buckets.splice(index, 1);
70 |
71 | settings.sequences = settings.sequences.map((sequence) => ({
72 | ...sequence,
73 | buckets: sequence.buckets.filter((bucketId) => bucketId.id !== this.state.tempID),
74 | }));
75 |
76 | var xhr = new XMLHttpRequest();
77 |
78 | xhr.addEventListener("readystatechange", async () => {
79 | if (xhr.readyState === 4) {
80 | if (xhr.status === 200) {
81 | this.setState({ show: false });
82 |
83 | const response = await fetch("/webhook", { method: "GET" });
84 | if (!response.ok) {
85 | throw new Error(`Response status: ${response.status}`);
86 | }
87 | } else {
88 | // error
89 | this.setState({
90 | show: false,
91 | error: xhr.responseText,
92 | });
93 | }
94 | }
95 | });
96 |
97 | xhr.open("POST", "/backend/save", true);
98 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
99 | xhr.send(JSON.stringify(settings));
100 |
101 | this.props.updateSettings(settings);
102 | this.handleSaveCreate();
103 | };
104 |
105 | handleSortOrder = () => {
106 | const settings = { ...this.props.settings };
107 | let buckets = [...settings.buckets]; // Always work on a copy
108 |
109 | buckets.sort((a, b) => a.name.localeCompare(b.name));
110 |
111 | settings.buckets = buckets;
112 |
113 | // Save to backend
114 | const xhr = new XMLHttpRequest();
115 | xhr.addEventListener("readystatechange", () => {
116 | if (xhr.readyState === 4) {
117 | if (xhr.status === 200) {
118 | this.refreshSettings();
119 | } else {
120 | this.setState({
121 | error: xhr.responseText,
122 | });
123 | }
124 | }
125 | });
126 | xhr.open("POST", "/backend/save", true);
127 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
128 | xhr.send(JSON.stringify(settings));
129 |
130 | // Instant UI update
131 | this.props.updateSettings(settings);
132 | this.handleSaveCreate();
133 | };
134 |
135 | render() {
136 | return (
137 | <>
138 |
139 | Buckets
140 |
141 |
142 |
143 | Sort by Name}>
144 | this.handleSortOrder()}
146 | variant={this.props.isDarkMode ? "outline-light" : "light"}
147 | className="sort-button"
148 | >
149 |
150 |
151 |
152 |
153 |
154 |
155 | {this.props.settings.buckets?.map((bucket) => (
156 |
157 |
170 |
171 |
172 | ))}
173 |
174 |
175 | {this.state.isEdit || this.state.isCreating ? (
176 |
181 |
182 |
183 |
184 |
185 | ) : (
186 |
190 |
191 |
192 |
193 |
194 | )}
195 |
196 |
197 |
198 | {this.state.isCreating ? (
199 |
209 | ) : (
210 | <>
211 |
212 | Red Border: Currently Editing Bucket
213 |
214 | Click the plus to add a new Bucket.
215 | >
216 | )}
217 |
218 |
226 |
227 | Are you sure?
228 |
229 | Yes
230 |
231 |
232 |
233 | Cancel
234 |
235 |
236 |
237 | >
238 | );
239 | }
240 | }
241 |
--------------------------------------------------------------------------------
/backend/holiday.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 | var path = require("path");
5 | var axios = require("axios").default;
6 |
7 | const cacheDir = path.join("/", "config", "cache");
8 |
9 | // Make sure the directory exists
10 | if (!fs.existsSync(cacheDir)) {
11 | console.info("Cache directory doesn't exist....creating....");
12 | fs.mkdirSync(cacheDir, { recursive: true });
13 | }
14 |
15 | router.post("/", async function (req, res, next) {
16 | const HolidayType = {
17 | 1: "national",
18 | 2: "local",
19 | 3: "religious",
20 | 4: "observance",
21 | };
22 |
23 | const typeName = HolidayType[parseInt(req.body.type, 10)];
24 |
25 | console.debug(
26 | `Country: ${req.body.country}, Source: ${req.body.source === "1" ? "Legacy" : "Premier"}, Type: ${typeName}`
27 | );
28 |
29 | const today = new Date();
30 | today.setHours(0, 0, 0, 0);
31 | const currentYear = today.getFullYear();
32 | const userLocale = req.headers["accept-language"]?.split(",")[0] || "en-US"; // So dates are formatted based on location
33 |
34 | // Helper function to safely parse and format dates
35 | function formatHolidayDate(dateString, locale) {
36 | let year, month, day;
37 |
38 | // If it's just YYYY-MM-DD
39 | const simpleDateMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
40 | if (simpleDateMatch) {
41 | [, year, month, day] = simpleDateMatch;
42 | return new Date(year, month - 1, day).toLocaleDateString(locale, {
43 | year: "numeric",
44 | month: "2-digit",
45 | day: "2-digit",
46 | });
47 | }
48 |
49 | // If it's a full ISO string with time or timezone, extract YYYY-MM-DD only
50 | const isoDateMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})/);
51 | if (isoDateMatch) {
52 | [, year, month, day] = isoDateMatch;
53 | return new Date(year, month - 1, day).toLocaleDateString(locale, {
54 | year: "numeric",
55 | month: "2-digit",
56 | day: "2-digit",
57 | });
58 | }
59 |
60 | console.warn(`Unrecognized date format: ${dateString}`);
61 | return dateString; // fallback
62 | }
63 |
64 | const source = req.body.source;
65 | let rawData;
66 |
67 | let cacheFile;
68 | if (source === "2") {
69 | cacheFile = path.join(cacheDir, `${req.body.country}-calendarific-${typeName}-${currentYear}.json`);
70 | }
71 |
72 | if (source === "2" && fs.existsSync(cacheFile)) {
73 | console.log(`Reading Calendarific holidays from cache: ${cacheFile}`);
74 | rawData = fs.readFileSync(cacheFile, "utf-8");
75 | } else {
76 | const url =
77 | source === "1"
78 | ? `https://date.nager.at/api/v3/publicholidays/${currentYear}/${req.body.country}`
79 | : `https://calendarific.com/api/v2/holidays?api_key=${req.body.apiKey}&country=${req.body.country}&year=${currentYear}&type=${typeName}`;
80 |
81 | if (source === "2" && !req.body.apiKey) {
82 | return res.status(400).send(
83 | JSON.stringify({
84 | success: false,
85 | message: "Calendarific API key is not set.",
86 | apiKeyMissing: true, // frontend can use this flag to show a notice
87 | })
88 | );
89 | }
90 |
91 | try {
92 | const response = await axios.get(url, { timeout: 10000 });
93 | rawData = JSON.stringify(response.data);
94 |
95 | // Only cache Calendarific data
96 | if (source === "2") {
97 | fs.writeFileSync(cacheFile, rawData, "utf-8");
98 | }
99 | } catch (error) {
100 | let source = req.body.source;
101 | let type = HolidayType[parseInt(req.body.type, 10)];
102 |
103 | if (error.response) {
104 | const status = error.response.status;
105 | const data = error.response.data;
106 |
107 | let message = `Error while trying to connect to the ${type} Holiday API. `;
108 |
109 | if (source === "2") {
110 | // 📌 Calendarific API
111 | switch (status) {
112 | case 401:
113 | message += "Unauthorized: Missing or incorrect API token.";
114 | break;
115 | case 422:
116 | if (data && data.meta && data.meta.error_code) {
117 | switch (data.meta.error_code) {
118 | case 600:
119 | message += "API is offline for maintenance.";
120 | break;
121 | case 601:
122 | message += "Unauthorized: Missing or incorrect API token.";
123 | break;
124 | case 602:
125 | message += "Invalid query parameters.";
126 | break;
127 | case 603:
128 | message += "Subscription level required.";
129 | break;
130 | default:
131 | message += `Unprocessable Entity: ${data.meta.error_detail || "Unknown error"}`;
132 | }
133 | } else {
134 | message += "Unprocessable Entity: Request was malformed.";
135 | }
136 | break;
137 | case 500:
138 | message += "Internal server error at Calendarific.";
139 | break;
140 | case 503:
141 | message += "Service unavailable (planned outage).";
142 | break;
143 | case 429:
144 | message += "Too many requests: API rate limit reached.";
145 | break;
146 | default:
147 | message += `Unexpected HTTP status: ${status}`;
148 | }
149 | } else if (source === "1") {
150 | // 📌 date.nager.at API (they only use standard HTTP statuses)
151 | switch (status) {
152 | case 400:
153 | message += "Bad request: invalid parameters.";
154 | break;
155 | case 401:
156 | message += "Unauthorized: Invalid API key or missing auth.";
157 | break;
158 | case 404:
159 | message += "Not found: Invalid endpoint or country code.";
160 | break;
161 | case 429:
162 | message += "Too many requests: API rate limit reached.";
163 | break;
164 | case 500:
165 | message += "Internal server error at Nager.Date.";
166 | break;
167 | default:
168 | message += `Unexpected HTTP status: ${status}`;
169 | }
170 | } else {
171 | message += "Unknown source specified.";
172 | }
173 |
174 | console.error(message);
175 | } else if (error.request) {
176 | console.error(`No response received from API (source=${source}).`);
177 | } else {
178 | console.error(`Error setting up request (source=${source}): ${error.message}`);
179 | }
180 | }
181 | }
182 |
183 | let countries = [];
184 | if (source === "2") {
185 | try {
186 | const parsed = JSON.parse(rawData);
187 | parsed.response.holidays.forEach((country) => {
188 | countries.push({
189 | name: country.name,
190 | date: formatHolidayDate(country.date.iso, userLocale),
191 | rawDate: country.date.iso,
192 | states: country.locations,
193 | });
194 | });
195 | } catch {
196 | console.error("There was not valid data returned from the Holiday API");
197 | }
198 | } else if (source === "1") {
199 | try {
200 | const parsed = JSON.parse(rawData);
201 | parsed
202 | .filter((holiday) => holiday.types.includes("Public"))
203 | .forEach((country) => {
204 | countries.push({
205 | name: country.name,
206 | date: formatHolidayDate(country.date, userLocale),
207 | rawDate: country.date,
208 | states: country.counties === null ? "All" : country.counties.join(", "),
209 | });
210 | });
211 | } catch {
212 | console.error("There was not valid data returned from the Holiday API");
213 | }
214 | }
215 |
216 | function dedupeHolidays(holidays) {
217 | const seen = new Set();
218 | return holidays.filter((holiday) => {
219 | const key = `${holiday.name}||${holiday.date}||${holiday.states}`;
220 | if (seen.has(key)) {
221 | console.log(`Duplicate removed: ${key}`); // optional logging
222 | return false;
223 | }
224 | seen.add(key);
225 | return true;
226 | });
227 | }
228 |
229 | res.send(JSON.stringify(dedupeHolidays(countries)));
230 | });
231 |
232 | module.exports = router;
233 |
--------------------------------------------------------------------------------
/backend/monitor.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var fs = require("fs");
4 | var path = require("path");
5 | var chokidar = require("chokidar");
6 | var axios = require("axios");
7 | const { broadcastUpdate } = require("./websocket");
8 |
9 | let pendingAdds = new Map(); // Store added files with relevant information
10 | let pendingRemovals = new Map(); // Track removed files
11 | let renameDelay; // Delay for rename detection
12 | let pathToWatch = "";
13 | let isRemoved = true;
14 | let isAdded = true;
15 | let watcher = null;
16 | let isInit = true;
17 | let initTime = 2000;
18 |
19 | function initializeWatcher() {
20 | if (watcher) {
21 | console.info("Closing existing watcher before reinitializing...");
22 | watcher.close();
23 | watcher = null;
24 | isInit = true;
25 | }
26 |
27 | try {
28 | settings = JSON.parse(fs.readFileSync("/config/settings.js"));
29 |
30 | if (settings.settings) {
31 | pathToWatch = settings.settings.loc;
32 | renameDelay = settings.settings.polling === "1" ? 500 : 1000;
33 | }
34 | } catch (err) {
35 | console.error("Cannot grab dir location", err);
36 | return;
37 | }
38 |
39 | if (fs.existsSync(pathToWatch)) {
40 | watcher = chokidar.watch(pathToWatch, {
41 | ignored: /(^|[\/\\])\../, // Ignore dotfiles
42 | persistent: true,
43 | usePolling: settings.settings.polling === "2",
44 | interval: 100,
45 | });
46 |
47 | watcher.on("addDir", (filePath) => {
48 | if (!isInit) {
49 | console.info(`Directory ${filePath} has been added`);
50 | broadcastUpdate();
51 | }
52 | });
53 |
54 | watcher.on("unlinkDir", (filePath) => {
55 | if (!isInit) {
56 | console.info(`Directory ${filePath} has been removed`);
57 | broadcastUpdate();
58 | }
59 | });
60 |
61 | // When a file is added
62 | watcher.on("add", (filePath) => {
63 | filePath = filePath
64 | .replace(/@SynoEAStream/i, "")
65 | .replace(/@SynoResource/i, "")
66 | .replace(/@eaDir\//i, ""); // Fix for Synology issue
67 | const baseName = path.basename(filePath);
68 | const dirName = path.dirname(filePath);
69 |
70 | // Store the added file with its path
71 | pendingAdds.set(filePath, { baseName, dirName });
72 | // console.log("PA", pendingAdds);
73 |
74 | // Check for any corresponding removals
75 | for (const [removedPath, removedFile] of pendingRemovals) {
76 | if (removedFile.dirName === dirName && removedFile.baseName !== baseName) {
77 | console.info(`File ${removedPath} was renamed to ${filePath}`);
78 | // Execute your specific rename/move handling code here
79 | handleRenameOrMove(removedPath, filePath);
80 |
81 | // Clean up both the added and removed paths
82 | pendingAdds.delete(filePath);
83 | isRemoved = false;
84 | return;
85 | }
86 | if (removedFile.baseName === baseName && removedFile.dirName !== dirName) {
87 | console.info(`File ${removedPath} was moved to ${filePath}`);
88 | // Execute your specific rename/move handling code here
89 | handleRenameOrMove(removedPath, filePath);
90 |
91 | // Clean up both the added and removed paths
92 | pendingAdds.delete(filePath);
93 | isRemoved = false;
94 | return;
95 | }
96 | }
97 |
98 | // Delay to confirm if it's a rename
99 | setTimeout(() => {
100 | if (!isInit && isAdded) {
101 | console.info(`File ${filePath} has been added`);
102 | broadcastUpdate();
103 | }
104 | if (pendingAdds.has(filePath)) {
105 | // Confirm it's still an add
106 |
107 | pendingAdds.delete(filePath);
108 | isAdded = true;
109 | }
110 | }, renameDelay);
111 | });
112 |
113 | // When a file is removed
114 | watcher.on("unlink", (filePath) => {
115 | filePath = filePath
116 | .replace(/@SynoEAStream/i, "")
117 | .replace(/@SynoResource/i, "")
118 | .replace(/@eaDir\//i, ""); // Fix for Synology issue
119 | const baseName = path.basename(filePath);
120 | const dirName = path.dirname(filePath);
121 |
122 | // Store the removed file for potential rename detection
123 | pendingRemovals.set(filePath, { baseName, dirName });
124 |
125 | // Check for any corresponding adds
126 |
127 | for (const [addedPath, addedFile] of pendingAdds) {
128 | if (addedFile.dirName === dirName && addedFile.baseName !== baseName) {
129 | console.info(`File ${filePath} was renamed to ${addedPath}`);
130 | // Execute your specific rename/move handling code here
131 | handleRenameOrMove(filePath, addedPath);
132 |
133 | // Clean up both the added and removed paths
134 | pendingRemovals.delete(filePath);
135 | isAdded = false;
136 | return;
137 | }
138 | // Handle files across different directories
139 | if (addedFile.baseName === baseName && addedFile.dirName !== dirName) {
140 | console.info(`File ${filePath} was moved to ${addedPath}`);
141 | handleRenameOrMove(filePath, addedPath);
142 |
143 | pendingRemovals.delete(filePath);
144 | isAdded = false;
145 | return;
146 | }
147 | }
148 |
149 | // Delay to confirm if it's a rename
150 | setTimeout(() => {
151 | if (isRemoved) {
152 | // If no corresponding add was found, treat it as a normal removal
153 | console.info(`File ${filePath} has been removed`);
154 |
155 | // Execute your specific removal handling code here
156 | handleRemove(filePath);
157 | }
158 | pendingRemovals.delete(filePath);
159 | isRemoved = true;
160 | }, renameDelay);
161 | });
162 |
163 | // Handle errors
164 | watcher.on("error", (error) => console.error(`Watcher error: ${error}`));
165 | } else {
166 | console.warn(`Watcher not started. Directory ${pathToWatch} not found`);
167 | return;
168 | }
169 |
170 | setTimeout(() => {
171 | isInit = false;
172 | console.info("Ready to start monitoring directories");
173 | }, initTime);
174 | }
175 |
176 | function handleRenameOrMove(oldPath, newPath) {
177 | settings = JSON.parse(fs.readFileSync("/config/settings.js"));
178 | console.info(`Handling rename from ${oldPath} to ${newPath} in buckets`);
179 | let settingsUpdated = false;
180 | settings.buckets.forEach((bucket) => {
181 | bucket.media.forEach((file) => {
182 | if (
183 | file.file === path.basename(oldPath) &&
184 | file.dir === path.dirname(oldPath).replace(settings.settings.loc, "")
185 | ) {
186 | file.file = path.basename(newPath);
187 | file.dir = path.dirname(newPath).replace(settings.settings.loc, "");
188 | console.info(`Updated settings for renamed file: ${oldPath} to ${newPath} in bucket "${bucket.name}"`);
189 | settingsUpdated = true;
190 | }
191 | });
192 | });
193 | if (settingsUpdated) {
194 | try {
195 | fs.writeFileSync("/config/settings.js", JSON.stringify(settings));
196 | console.info("Settings file saved");
197 |
198 | axios
199 | .get("http://localhost:4949/webhook") // Make sure the path is correct
200 | .then((response) => {})
201 | .catch((error) => {});
202 | } catch (err) {
203 | console.error("Error saving settings file", err);
204 | }
205 | }
206 | broadcastUpdate();
207 | }
208 |
209 | function handleRemove(oldPath) {
210 | settings = JSON.parse(fs.readFileSync("/config/settings.js"));
211 | console.info(`Handling removal of ${oldPath} in buckets...`);
212 |
213 | let settingsUpdated = false;
214 |
215 | settings.buckets.forEach((bucket) => {
216 | const initialMediaLength = bucket.media.length;
217 |
218 | bucket.media = bucket.media.filter(
219 | (file) =>
220 | !(file.file === path.basename(oldPath) && file.dir === path.dirname(oldPath).replace(settings.settings.loc, ""))
221 | );
222 |
223 | if (bucket.media.length !== initialMediaLength) {
224 | console.info(`Removed all occurrences of ${oldPath} from bucket "${bucket.name}"`);
225 | settingsUpdated = true;
226 | }
227 | });
228 |
229 | if (settingsUpdated) {
230 | try {
231 | fs.writeFileSync("/config/settings.js", JSON.stringify(settings));
232 | console.info("Settings file saved");
233 | axios
234 | .get("http://localhost:4949/webhook") // Make sure the path is correct
235 | .then((response) => {})
236 | .catch((error) => {});
237 | } catch (err) {
238 | console.error("Error saving settings file", err);
239 | }
240 | } else {
241 | console.info("No changes made to settings.");
242 | }
243 | broadcastUpdate();
244 | }
245 |
246 | initializeWatcher();
247 |
248 | router.get("/", function (req, res, next) {
249 | initializeWatcher();
250 | res.status(200).send();
251 | });
252 |
253 | module.exports = router;
254 |
--------------------------------------------------------------------------------
/history.md:
--------------------------------------------------------------------------------
1 | # Preroll Plus Version History
2 |
3 | ## 1.3.2
4 |
5 | ### New Features
6 |
7 | 1. Added an option in the Settings page to choose the starting day of the week for the Calendar page. Can be set to Sunday, Monday, or Saturday. [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)]
8 | 2. Dates shown in the Sequence cards on the Sequences page will be formatted per user locale. (eg. 12/30 for U.S. and 30/12 for U.K.) [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)]
9 | 3. Sequences on the Sequences page can now be sorted by priority or name. [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)]
10 | 4. Buckets on the Buckets page can now be sorted by name. [[#25](https://github.com/chadwpalm/PrerollPlus/discussions/25)]
11 |
12 | ### Fix
13 |
14 | 1. Added "Month" and "Day" labels when editing schedules to remove ambiguity and make it more international friendly.
15 |
16 | ## 1.3.1
17 |
18 | ### New Feature
19 |
20 | 1. Ability to extend a holiday schedule out any number of days before or after the actually holiday to create a "holiday window" rather than using a schedule.
21 |
22 | ## 1.3.0
23 |
24 | ### New Features
25 |
26 | 1. New calendar page. You can now view and cycle through calendar months to see which Sequence will play on a particular day. You can view all current and upcoming year's months.
27 | 2. Added a blue box around the currently used Sequence for that day in the Sequences page. [[#22](https://github.com/chadwpalm/PrerollPlus/discussions/22)]
28 |
29 | ### Fixes
30 |
31 | 1. Fixed a critical issue where holidays would not have worked correctly for years past 2025.
32 | 2. Fixed issue where deleting a newly made Sequence wasn't removing it from the list of cards.
33 |
34 | ## 1.2.1
35 |
36 | ### Hotfix
37 |
38 | 1. Fixed code to prevent app crash when can't connect to calendar API servers. [[#19](https://github.com/chadwpalm/PrerollPlus/issues/19)]
39 | 2. Increased timeout for calendar API retrieval. [[#19](https://github.com/chadwpalm/PrerollPlus/issues/19)]
40 | 3. Prevent the querying of the calendar APIs when creating a new Sequence and when toggling the schedule and it is not set to Holiday. [[#19](https://github.com/chadwpalm/PrerollPlus/issues/19)]
41 |
42 | ## 1.2.0
43 |
44 | ### New Features
45 |
46 | 1. Addition of a second calendar source (Calendarific) that includes hundreds more holidays to choose from. [[#15](https://github.com/chadwpalm/PrerollPlus/issues/15)]
47 | a. Calendar APIs have been broken out to two sources you can choose from: Legacy (Nager.Date) which is the original API used, and Premier (Calendarific).
48 | b. The use of the Premier (Calendarific) calendar API requires signing up to a free account with them which include up to 500 API calls per month.
49 | c. Calendarific calendars are cached locally to reduce the number of API calls to eliminate the need to pay for a higher account tier.
50 | d. Calendars are based on year/country/type with type being National, Local, Religious, and Observance.
51 | e. Caches can be cleared at any time if you wish to pull a fresh copy of the calendars (Settings/Advanced), otherwise new versions will be automatically pulled at the beginning of the calendar year when needed.
52 | f. The listings for the holidays in the UI now include dates (current year) and the dates should be formatted for your language/country.
53 | g. In areas where there are states, provinces, or other local regions, holidays may be broken up by those local regions.
54 | h. Holiday lists in the UI can now be sorted by holiday name or by date.
55 | 2. Scheduling conflicts have been removed from the UI and schedule priorities have been put in their place. [[#16](https://github.com/chadwpalm/PrerollPlus/issues/16)]
56 | a. There can still only be one Sequence that does not have a schedule.
57 |
58 | ### Changes
59 |
60 | 1. Changed menu behavior so if there are no buckets you cannot enter the Sequences page and user will be routed to Buckets to start creating one since Sequences are dependent on Buckets.
61 | 2. Configuration file scheme has been updated to accommodate changes in Sequences such as priorities and information for Calendarific.
62 | a. Existing configs will be backed up and migrated over to the new schema. Behavior should work as previously intended, but all priorities will be set to N/A until changed.
63 |
64 | ### Fixes
65 |
66 | 1. Fixed issue where not having a Bucket name was allowed when source was set to Directory.
67 | 2. Fixed issue where information that is saved in a Sequence or Bucket were not updating immediately on cards when saved.
68 | 3. Fixed issue where pop-ups were not showing properly in dark mode. [[#14](https://github.com/chadwpalm/PrerollPlus/issues/14)]
69 |
70 | ## 1.1.2
71 |
72 | ### Fixes
73 |
74 | 1. Sequence cards were not showing the scheduled holiday if "Holiday" is chosen as the schedule.
75 |
76 | ## 1.1.1
77 |
78 | ### Fixes
79 |
80 | 1. Brought internal packages up to date.
81 | 2. Log file names were using the wrong application.
82 | 3. Minor code fixes.
83 |
84 | ## 1.1.0
85 |
86 | ### New Feature
87 |
88 | 1. Added ability to select a holiday for the schedule.
89 |
90 | ## 1.0.0
91 |
92 | ### Changes
93 |
94 | 1. Bring the app out of beta testing.
95 | 2. Logging to file. Logs will be found in /config for native installs and the mounted config folder for Docker installs.
96 | 3. Removed logging of frontend to backend web router calls.
97 | 4. Added "Show/Hide Advanced" options in Settings page and included a logging level toggle.
98 |
99 | ### Fixes
100 |
101 | 1. Server list wasn't showing secure connection options when "Secure connections" in the Plex Media Server settings was set to "Required".
102 | 2. Dark mode setting was not being preserved in the save file when logging out.
103 | 3. Minor fix to file monitoring.
104 |
105 | ## 0.1.5
106 |
107 | ### New Feature
108 |
109 | 1. Added a dark mode which can be toggled on and off [[#5](https://github.com/chadwpalm/PrerollPlus/discussions/5)]
110 | 2. You can now choose if you want Buckets to be tied directly to folders, or stay with the current approach of manually selecting files for the Bucket in the UI. [[#2](https://github.com/chadwpalm/PrerollPlus/discussions/2)]
111 | 3. Bucket and file listings when editing a Bucket are updated in realtime when files/folders in the filesystem are added/deleted/renamed. [[#2](https://github.com/chadwpalm/PrerollPlus/discussions/2)]
112 |
113 | ### Minor Fix
114 |
115 | 1. Moved all inline CSS to external files to make style changes easier
116 | 2. Basic code cleanup to remove compiler warnings
117 |
118 | ## 0.1.4
119 |
120 | ### Minor Fix
121 |
122 | 1. Fixed issue where Synology is adding additional info to the file paths causing the monitoring to not work correctly. [[#4](https://github.com/chadwpalm/PrerollPlus/issues/4)]
123 | 2. Fixed bug where files added to Buckets had an extra slash if file came from root directory. This also affected the file's ability to be monitored for changes. [[#4](https://github.com/chadwpalm/PrerollPlus/issues/4)]
124 | 3. If Bucket is empty due to a backend file removal (since the UI does not allow for saving empty buckets), the Plex string no longer displays "undefined" for that Bucket.
125 | 4. Added option for file monitoring through polling for when preroll directory is mounted over an SMB (or similar) share.
126 |
127 | ## 0.1.3
128 |
129 | ### Minor Features
130 |
131 | 1. Update Buckets in settings and Plex string when files in the file system are deleted, renamed, or moved. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
132 | 2. Deleting a Bucket will update any Sequences it is in and also update the Plex string.
133 | 3. Added directory location under the file names in the "Files in buckets" list.
134 | 4. "Plex location or preroll media" text box in the Settings tab is not grayed out for Native installs anymore. This is to accommodate users that run their Plex server on a different machine than Preroll Plus. [[#3](https://github.com/chadwpalm/PrerollPlus/issues/3)]
135 |
136 | ### Bug Fixes
137 |
138 | 1. Fixed issue where opening a Sequence that contains a recently deleted bucket was generating an error.
139 |
140 | ## 0.1.2
141 |
142 | ### Minor Features
143 |
144 | 1. Make header bar "sticky" to the top of the window. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
145 | 2. Make list of files in bucket alphabetical. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
146 | 3. Change "Add" button in buckets to a "left arrow" to match the asthetic of the Sequences page. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
147 | 4. Sequences and Buckets are now highlighted when editing them. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
148 | 5. Added a video preview player in the Bucket creation/edit page. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
149 |
150 | ### Bug Fixes
151 |
152 | 1. Brought back build number which wasn't brought back in previous version after local testing.
153 | 2. The file list in Buckets was not indicating if the directory location set in Settings does not exist.
154 |
155 | ## 0.1.1
156 |
157 | ### Bug Fixes
158 |
159 | 1. Fix "Update Available" URL.
160 | 2. Fixed output string in Sequences when trying to save without having a sequence name or buckets.
161 | 3. Plex server preroll sequence string is updated when a Sequence is deleted to prevent playing an unwanted sequence.
162 | 4. Fix error when clicking the button to add a bucket to a sequence when a bucket is not selected. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
163 | 5. If two identical buckets are used in a sequence, or the same pre-roll is used in two different buckets in the same sequence, Plex will not play the same pre-roll twice.
164 | 6. Fixed double slashes in pre-roll string on some file system. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
165 | 7. Updated server retrieval logic to parse multiple IPs returned from account server list and mark the unreachable IP's as unreachable. [[#1](https://github.com/chadwpalm/PrerollPlus/discussions/1)]
166 |
167 | ## 0.1.0
168 |
169 | Initial beta release
170 |
--------------------------------------------------------------------------------
/frontend/src/components/Sequences/Sequences.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Sequence from "../Sequence/Sequence";
3 | import AddIcon from "../../images/add-icon.png";
4 | import CreateSeq from "../CreateSeq/CreateSeq";
5 | import Row from "react-bootstrap/Row";
6 | import Col from "react-bootstrap/Col";
7 | import Card from "react-bootstrap/Card";
8 | import Modal from "react-bootstrap/Modal";
9 | import Button from "react-bootstrap/Button";
10 | import Image from "react-bootstrap/Image";
11 | import SortName from "bootstrap-icons/icons/sort-alpha-up.svg";
12 | import SortPriority from "bootstrap-icons/icons/sort-numeric-up.svg";
13 | import OverlayTrigger from "react-bootstrap/OverlayTrigger";
14 | import Tooltip from "react-bootstrap/Tooltip";
15 | import "./Sequences.css";
16 |
17 | export default class Sequences extends Component {
18 | constructor(props) {
19 | super(props);
20 | this.state = {
21 | // sequences: this.props.settings.sequences,
22 | isCreating: false,
23 | id: "-1",
24 | isEdit: false,
25 | show: false,
26 | tempID: "",
27 | };
28 | }
29 |
30 | refreshSettings = () => {
31 | this.props.onSettingsChanged?.(); // calls refreshConfig() in App.jsx
32 | };
33 |
34 | handleAddSequence = () => {
35 | this.setState({
36 | isCreating: true,
37 | isEdit: false,
38 | id: "-1",
39 | });
40 | };
41 |
42 | handleEditSequence = (e) => {
43 | this.setState({
44 | isCreating: true,
45 | isEdit: true,
46 | id: e,
47 | });
48 | };
49 |
50 | handleCancelCreate = () => {
51 | this.setState({ isCreating: false, isEdit: false });
52 | };
53 |
54 | handleSaveCreate = () => {
55 | this.setState({ isCreating: false, isEdit: false });
56 | };
57 |
58 | handleClose = () => this.setState({ show: false });
59 |
60 | handleOpen = (e) => {
61 | this.setState({ tempID: e, show: true, fullscreen: "md-down" });
62 | };
63 |
64 | handleSortOrder = (order) => {
65 | const settings = { ...this.props.settings };
66 | let sequences = [...settings.sequences]; // Always work on a copy
67 |
68 | if (order === "1") {
69 | sequences.sort((a, b) => a.priority - b.priority);
70 | }
71 |
72 | if (order === "2") {
73 | sequences.sort((a, b) => a.name.localeCompare(b.name));
74 | }
75 |
76 | settings.sequences = sequences;
77 |
78 | // Save to backend
79 | const xhr = new XMLHttpRequest();
80 | xhr.addEventListener("readystatechange", () => {
81 | if (xhr.readyState === 4) {
82 | if (xhr.status === 200) {
83 | this.refreshSettings();
84 | } else {
85 | this.setState({
86 | error: xhr.responseText,
87 | });
88 | }
89 | }
90 | });
91 | xhr.open("POST", "/backend/save", true);
92 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
93 | xhr.send(JSON.stringify(settings));
94 |
95 | // Instant UI update
96 | this.props.updateSettings(settings);
97 | this.handleSaveCreate();
98 | };
99 |
100 | sortHolidayList = (list, order) => {
101 | if (!list) return [];
102 | if (order === "1") {
103 | return [...list].sort((a, b) => new Date(a.date) - new Date(b.date));
104 | }
105 | if (order === "2") {
106 | return [...list].sort((a, b) => a.name.localeCompare(b.name));
107 | }
108 | return list;
109 | };
110 |
111 | handleDelete = (e) => {
112 | e.preventDefault();
113 |
114 | var settings = { ...this.props.settings };
115 |
116 | const index = settings.sequences.findIndex(({ id }) => id === this.state.tempID);
117 |
118 | settings.sequences.splice(index, 1);
119 |
120 | var xhr = new XMLHttpRequest();
121 |
122 | xhr.addEventListener("readystatechange", () => {
123 | if (xhr.readyState === 4) {
124 | if (xhr.status === 200) {
125 | this.setState({ show: false });
126 |
127 | var xhr2 = new XMLHttpRequest();
128 | xhr2.addEventListener("readystatechange", () => {
129 | if (xhr2.readyState === 4) {
130 | if (xhr2.status === 200) {
131 | this.refreshSettings();
132 | } else {
133 | this.setState({
134 | error: xhr2.responseText,
135 | });
136 | }
137 | }
138 | });
139 |
140 | xhr2.open("GET", "/webhook", true);
141 | xhr2.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
142 | xhr2.send();
143 | } else {
144 | // error
145 | this.setState({
146 | show: false,
147 | error: xhr.responseText,
148 | });
149 | }
150 | }
151 | });
152 |
153 | xhr.open("POST", "/backend/save", true);
154 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
155 | xhr.send(JSON.stringify(settings));
156 |
157 | this.props.updateSettings(settings);
158 | this.handleSaveCreate();
159 | };
160 |
161 | render() {
162 | return (
163 | <>
164 |
165 | Sequences
166 |
167 |
168 |
169 | Sort by Name}>
170 | this.handleSortOrder("2")}
172 | variant={this.props.isDarkMode ? "outline-light" : "light"}
173 | className="sort-button"
174 | >
175 |
176 |
177 |
178 |
179 | Sort by Priority}>
180 | this.handleSortOrder("1")}
182 | variant={this.props.isDarkMode ? "outline-light" : "light"}
183 | className="sort-button"
184 | >
185 |
186 |
187 |
188 |
189 |
190 |
191 | {this.props.settings.sequences?.map((sequence) => (
192 |
193 |
213 |
214 |
215 | ))}
216 |
217 |
218 | {this.state.isEdit || this.state.isCreating ? (
219 |
224 |
225 |
226 |
227 |
228 | ) : (
229 |
233 |
234 |
235 |
236 |
237 | )}
238 |
239 |
240 |
241 | {this.state.isCreating ? (
242 |
251 | ) : (
252 | <>
253 |
254 | Blue Border: Current Sequence Red Border: Currently Editing Sequence
255 |
256 | Click the plus to add a new Sequence.
257 | >
258 | )}
259 |
260 |
268 |
269 | Are you sure?
270 |
271 | Yes
272 |
273 |
274 |
275 | Cancel
276 |
277 |
278 |
279 | >
280 | );
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/frontend/src/components/CreateSeq/countries.js:
--------------------------------------------------------------------------------
1 | export const countryNames = [
2 | "Albania",
3 | "Andorra",
4 | "Argentina",
5 | "Armenia",
6 | "Australia",
7 | "Austria",
8 | "Bahamas",
9 | "Barbados",
10 | "Belarus",
11 | "Belgium",
12 | "Belize",
13 | "Benin",
14 | "Bolivia",
15 | "Bosnia and Herzegovina",
16 | "Botswana",
17 | "Brazil",
18 | "Bulgaria",
19 | "Canada",
20 | "Chile",
21 | "China",
22 | "Colombia",
23 | "Costa Rica",
24 | "Croatia",
25 | "Cuba",
26 | "Cyprus",
27 | "Czechia",
28 | "Denmark",
29 | "Dominican Republic",
30 | "Ecuador",
31 | "Egypt",
32 | "El Salvador",
33 | "Estonia",
34 | "Faroe Islands",
35 | "Finland",
36 | "France",
37 | "Gabon",
38 | "Gambia",
39 | "Georgia",
40 | "Germany",
41 | "Gibraltar",
42 | "Greece",
43 | "Greenland",
44 | "Grenada",
45 | "Guatemala",
46 | "Guernsey",
47 | "Guyana",
48 | "Haiti",
49 | "Honduras",
50 | "Hong Kong",
51 | "Hungary",
52 | "Iceland",
53 | "Indonesia",
54 | "Ireland",
55 | "Isle of Man",
56 | "Italy",
57 | "Jamaica",
58 | "Japan",
59 | "Jersey",
60 | "Kazakhstan",
61 | "Latvia",
62 | "Lesotho",
63 | "Liechtenstein",
64 | "Lithuania",
65 | "Luxembourg",
66 | "Madagascar",
67 | "Malta",
68 | "Mexico",
69 | "Moldova",
70 | "Monaco",
71 | "Mongolia",
72 | "Montenegro",
73 | "Montserrat",
74 | "Morocco",
75 | "Mozambique",
76 | "Namibia",
77 | "Netherlands",
78 | "New Zealand",
79 | "Nicaragua",
80 | "Niger",
81 | "Nigeria",
82 | "North Macedonia",
83 | "Norway",
84 | "Panama",
85 | "Papua New Guinea",
86 | "Paraguay",
87 | "Peru",
88 | "Poland",
89 | "Portugal",
90 | "Puerto Rico",
91 | "Romania",
92 | "Russia",
93 | "San Marino",
94 | "Serbia",
95 | "Singapore",
96 | "Slovakia",
97 | "Slovenia",
98 | "South Africa",
99 | "South Korea",
100 | "Spain",
101 | "Suriname",
102 | "Svalbard and Jan Mayen",
103 | "Sweden",
104 | "Switzerland",
105 | "Tunisia",
106 | "Turkey",
107 | "Ukraine",
108 | "United Kingdom",
109 | "United States",
110 | "Uruguay",
111 | "Vatican City",
112 | "Venezuela",
113 | "Vietnam",
114 | "Zimbabwe",
115 | ];
116 |
117 | export const countryCodes = [
118 | "AL",
119 | "AD",
120 | "AR",
121 | "AM",
122 | "AU",
123 | "AT",
124 | "BS",
125 | "BB",
126 | "BY",
127 | "BE",
128 | "BZ",
129 | "BJ",
130 | "BO",
131 | "BA",
132 | "BW",
133 | "BR",
134 | "BG",
135 | "CA",
136 | "CL",
137 | "CN",
138 | "CO",
139 | "CR",
140 | "HR",
141 | "CU",
142 | "CY",
143 | "CZ",
144 | "DK",
145 | "DO",
146 | "EC",
147 | "EG",
148 | "SV",
149 | "EE",
150 | "FO",
151 | "FI",
152 | "FR",
153 | "GA",
154 | "GM",
155 | "GE",
156 | "DE",
157 | "GI",
158 | "GR",
159 | "GL",
160 | "GD",
161 | "GT",
162 | "GG",
163 | "GY",
164 | "HT",
165 | "HN",
166 | "HK",
167 | "HU",
168 | "IS",
169 | "ID",
170 | "IE",
171 | "IM",
172 | "IT",
173 | "JM",
174 | "JP",
175 | "JE",
176 | "KZ",
177 | "LV",
178 | "LS",
179 | "LI",
180 | "LT",
181 | "LU",
182 | "MG",
183 | "MT",
184 | "MX",
185 | "MD",
186 | "MC",
187 | "MN",
188 | "ME",
189 | "MS",
190 | "MA",
191 | "MZ",
192 | "NA",
193 | "NL",
194 | "NZ",
195 | "NI",
196 | "NE",
197 | "NG",
198 | "MK",
199 | "NO",
200 | "PA",
201 | "PG",
202 | "PY",
203 | "PE",
204 | "PL",
205 | "PT",
206 | "PR",
207 | "RO",
208 | "RU",
209 | "SM",
210 | "RS",
211 | "SG",
212 | "SK",
213 | "SI",
214 | "ZA",
215 | "KR",
216 | "ES",
217 | "SR",
218 | "SJ",
219 | "SE",
220 | "CH",
221 | "TN",
222 | "TR",
223 | "UA",
224 | "GB",
225 | "US",
226 | "UY",
227 | "VA",
228 | "VE",
229 | "VN",
230 | "ZW",
231 | ];
232 |
233 | export const countryNamesCalrific = [
234 | "Afghanistan",
235 | "Albania",
236 | "Algeria",
237 | "American Samoa",
238 | "Andorra",
239 | "Angola",
240 | "Anguilla",
241 | "Antigua and Barbuda",
242 | "Argentina",
243 | "Armenia",
244 | "Aruba",
245 | "Australia",
246 | "Austria",
247 | "Azerbaijan",
248 | "Bahrain",
249 | "Bangladesh",
250 | "Barbados",
251 | "Belarus",
252 | "Belgium",
253 | "Belize",
254 | "Benin",
255 | "Bermuda",
256 | "Bhutan",
257 | "Bolivia",
258 | "Bosnia and Herzegovina",
259 | "Botswana",
260 | "Brazil",
261 | "British Indian Ocean Territory",
262 | "British Virgin Islands",
263 | "Brunei",
264 | "Bulgaria",
265 | "Burkina Faso",
266 | "Burundi",
267 | "Cambodia",
268 | "Cameroon",
269 | "Canada",
270 | "Cape Verde",
271 | "Cayman Islands",
272 | "Central African Republic",
273 | "Chad",
274 | "Chile",
275 | "China",
276 | "Christmas Island",
277 | "Cocos Islands",
278 | "Colombia",
279 | "Comoros",
280 | "Congo",
281 | "Congo (DRC)",
282 | "Cook Islands",
283 | "Costa Rica",
284 | "Croatia",
285 | "Cuba",
286 | "Curacao",
287 | "Cyprus",
288 | "Czechia",
289 | "Ivory Coast",
290 | "Denmark",
291 | "Djibouti",
292 | "Dominica",
293 | "Dominican Republic",
294 | "Ecuador",
295 | "Egypt",
296 | "El Salvador",
297 | "Equatorial Guinea",
298 | "Eritrea",
299 | "Estonia",
300 | "Eswatini",
301 | "Ethiopia",
302 | "Falkland Islands",
303 | "Faroe Islands",
304 | "Fiji",
305 | "Finland",
306 | "France",
307 | "French Guiana",
308 | "French Polynesia",
309 | "French Southern Territories",
310 | "Gabon",
311 | "Gambia",
312 | "Georgia",
313 | "Germany",
314 | "Ghana",
315 | "Gibraltar",
316 | "Greece",
317 | "Greenland",
318 | "Grenada",
319 | "Guam",
320 | "Guatemala",
321 | "Guernsey",
322 | "Guinea",
323 | "Guinea-Bissau",
324 | "Guyana",
325 | "Haiti",
326 | "Heard Island and McDonald Islands",
327 | "Honduras",
328 | "Hong Kong",
329 | "Hungary",
330 | "Iceland",
331 | "India",
332 | "Indonesia",
333 | "Iran",
334 | "Iraq",
335 | "Ireland",
336 | "Israel",
337 | "Italy",
338 | "Jamaica",
339 | "Japan",
340 | "Jersey",
341 | "Jordan",
342 | "Kazakhstan",
343 | "Kenya",
344 | "Kiribati",
345 | "South Korea",
346 | "Kuwait",
347 | "Kyrgyzstan",
348 | "Laos",
349 | "Latvia",
350 | "Lebanon",
351 | "Lesotho",
352 | "Liberia",
353 | "Libya",
354 | "Liechtenstein",
355 | "Lithuania",
356 | "Luxembourg",
357 | "Macau",
358 | "North Macedonia",
359 | "Madagascar",
360 | "Malawi",
361 | "Malaysia",
362 | "Maldives",
363 | "Mali",
364 | "Malta",
365 | "Marshall Islands",
366 | "Martinique",
367 | "Mauritania",
368 | "Mauritius",
369 | "Mayotte",
370 | "Mexico",
371 | "Micronesia",
372 | "Moldova",
373 | "Monaco",
374 | "Mongolia",
375 | "Montenegro",
376 | "Montserrat",
377 | "Morocco",
378 | "Mozambique",
379 | "Myanmar",
380 | "Namibia",
381 | "Nauru",
382 | "Nepal",
383 | "Netherlands",
384 | "New Caledonia",
385 | "New Zealand",
386 | "Nicaragua",
387 | "Niger",
388 | "Nigeria",
389 | "Niue",
390 | "Norfolk Island",
391 | "Northern Mariana Islands",
392 | "Norway",
393 | "Oman",
394 | "Pakistan",
395 | "Palau",
396 | "Palestine",
397 | "Panama",
398 | "Papua New Guinea",
399 | "Paraguay",
400 | "Peru",
401 | "Philippines",
402 | "Pitcairn Islands",
403 | "Poland",
404 | "Portugal",
405 | "Puerto Rico",
406 | "Qatar",
407 | "Reunion",
408 | "Romania",
409 | "Russia",
410 | "Rwanda",
411 | "Saint Barthelemy",
412 | "Saint Helena",
413 | "Saint Kitts and Nevis",
414 | "Saint Lucia",
415 | "Saint Martin",
416 | "Saint Pierre and Miquelon",
417 | "Saint Vincent and the Grenadines",
418 | "Samoa",
419 | "San Marino",
420 | "Sao Tome and Principe",
421 | "Saudi Arabia",
422 | "Senegal",
423 | "Serbia",
424 | "Seychelles",
425 | "Sierra Leone",
426 | "Singapore",
427 | "Sint Maarten",
428 | "Slovakia",
429 | "Slovenia",
430 | "Solomon Islands",
431 | "Somalia",
432 | "South Africa",
433 | "South Georgia and the South Sandwich Islands",
434 | "South Sudan",
435 | "Spain",
436 | "Sri Lanka",
437 | "Sudan",
438 | "Suriname",
439 | "Svalbard and Jan Mayen",
440 | "Sweden",
441 | "Switzerland",
442 | "Syria",
443 | "Taiwan",
444 | "Tajikistan",
445 | "Tanzania",
446 | "Thailand",
447 | "Timor-Leste",
448 | "Togo",
449 | "Tokelau",
450 | "Tonga",
451 | "Trinidad and Tobago",
452 | "Tunisia",
453 | "Turkey",
454 | "Turkmenistan",
455 | "Turks and Caicos Islands",
456 | "Tuvalu",
457 | "Uganda",
458 | "Ukraine",
459 | "United Arab Emirates",
460 | "United Kingdom",
461 | "United States",
462 | "Uruguay",
463 | "Uzbekistan",
464 | "Vanuatu",
465 | "Vatican City",
466 | "Venezuela",
467 | "Vietnam",
468 | "Wallis and Futuna",
469 | "Western Sahara",
470 | "Yemen",
471 | "Zambia",
472 | "Zimbabwe",
473 | ];
474 |
475 | export const countryCodesCalrific = [
476 | "AF",
477 | "AL",
478 | "DZ",
479 | "AS",
480 | "AD",
481 | "AO",
482 | "AI",
483 | "AG",
484 | "AR",
485 | "AM",
486 | "AW",
487 | "AU",
488 | "AT",
489 | "AZ",
490 | "BH",
491 | "BD",
492 | "BB",
493 | "BY",
494 | "BE",
495 | "BZ",
496 | "BJ",
497 | "BM",
498 | "BT",
499 | "BO",
500 | "BA",
501 | "BW",
502 | "BR",
503 | "IO",
504 | "VG",
505 | "BN",
506 | "BG",
507 | "BF",
508 | "BI",
509 | "KH",
510 | "CM",
511 | "CA",
512 | "CV",
513 | "KY",
514 | "CF",
515 | "TD",
516 | "CL",
517 | "CN",
518 | "CX",
519 | "CC",
520 | "CO",
521 | "KM",
522 | "CG",
523 | "CD",
524 | "CK",
525 | "CR",
526 | "HR",
527 | "CU",
528 | "CW",
529 | "CY",
530 | "CZ",
531 | "CI",
532 | "DK",
533 | "DJ",
534 | "DM",
535 | "DO",
536 | "EC",
537 | "EG",
538 | "SV",
539 | "GQ",
540 | "ER",
541 | "EE",
542 | "SZ",
543 | "ET",
544 | "FK",
545 | "FO",
546 | "FJ",
547 | "FI",
548 | "FR",
549 | "GF",
550 | "PF",
551 | "TF",
552 | "GA",
553 | "GM",
554 | "GE",
555 | "DE",
556 | "GH",
557 | "GI",
558 | "GR",
559 | "GL",
560 | "GD",
561 | "GU",
562 | "GT",
563 | "GG",
564 | "GN",
565 | "GW",
566 | "GY",
567 | "HT",
568 | "HM",
569 | "HN",
570 | "HK",
571 | "HU",
572 | "IS",
573 | "IN",
574 | "ID",
575 | "IR",
576 | "IQ",
577 | "IE",
578 | "IL",
579 | "IT",
580 | "JM",
581 | "JP",
582 | "JE",
583 | "JO",
584 | "KZ",
585 | "KE",
586 | "KI",
587 | "KR",
588 | "KW",
589 | "KG",
590 | "LA",
591 | "LV",
592 | "LB",
593 | "LS",
594 | "LR",
595 | "LY",
596 | "LI",
597 | "LT",
598 | "LU",
599 | "MO",
600 | "MK",
601 | "MG",
602 | "MW",
603 | "MY",
604 | "MV",
605 | "ML",
606 | "MT",
607 | "MH",
608 | "MQ",
609 | "MR",
610 | "MU",
611 | "YT",
612 | "MX",
613 | "FM",
614 | "MD",
615 | "MC",
616 | "MN",
617 | "ME",
618 | "MS",
619 | "MA",
620 | "MZ",
621 | "MM",
622 | "NA",
623 | "NR",
624 | "NP",
625 | "NL",
626 | "NC",
627 | "NZ",
628 | "NI",
629 | "NE",
630 | "NG",
631 | "NU",
632 | "NF",
633 | "MP",
634 | "NO",
635 | "OM",
636 | "PK",
637 | "PW",
638 | "PS",
639 | "PA",
640 | "PG",
641 | "PY",
642 | "PE",
643 | "PH",
644 | "PN",
645 | "PL",
646 | "PT",
647 | "PR",
648 | "QA",
649 | "RE",
650 | "RO",
651 | "RU",
652 | "RW",
653 | "BL",
654 | "SH",
655 | "KN",
656 | "LC",
657 | "MF",
658 | "PM",
659 | "VC",
660 | "WS",
661 | "SM",
662 | "ST",
663 | "SA",
664 | "SN",
665 | "RS",
666 | "SC",
667 | "SL",
668 | "SG",
669 | "SX",
670 | "SK",
671 | "SI",
672 | "SB",
673 | "SO",
674 | "ZA",
675 | "GS",
676 | "SS",
677 | "ES",
678 | "LK",
679 | "SD",
680 | "SR",
681 | "SJ",
682 | "SE",
683 | "CH",
684 | "SY",
685 | "TW",
686 | "TJ",
687 | "TZ",
688 | "TH",
689 | "TL",
690 | "TG",
691 | "TK",
692 | "TO",
693 | "TT",
694 | "TN",
695 | "TR",
696 | "TM",
697 | "TC",
698 | "TV",
699 | "UG",
700 | "UA",
701 | "AE",
702 | "GB",
703 | "US",
704 | "UY",
705 | "UZ",
706 | "VU",
707 | "VA",
708 | "VE",
709 | "VN",
710 | "WF",
711 | "EH",
712 | "YE",
713 | "ZM",
714 | "ZW",
715 | ];
716 |
--------------------------------------------------------------------------------
/webhook/index.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var router = express.Router();
3 | var multer = require("multer");
4 | var fs = require("fs");
5 | var path = require("path");
6 | var axios = require("axios").default;
7 |
8 | // Global Variables
9 |
10 | var flag = false;
11 |
12 | var upload = multer({ dest: "/tmp/" });
13 |
14 | const filePath = "/config/settings.js";
15 |
16 | var settings;
17 |
18 | // General Functions
19 |
20 | async function isHolidayDay(country, holiday, states, type, source, apiKey, checkDate = null, pre = "0", post = "0") {
21 | const cacheDir = path.join("/", "config", "cache");
22 | const HolidayType = {
23 | 1: "national",
24 | 2: "local",
25 | 3: "religious",
26 | 4: "observance",
27 | };
28 | const typeName = HolidayType[parseInt(type, 10)];
29 |
30 | const today = checkDate
31 | ? new Date(
32 | Date.UTC(
33 | Number(checkDate.split("-")[0]), // year
34 | Number(checkDate.split("-")[1]) - 1, // month (0-based)
35 | Number(checkDate.split("-")[2]) // day
36 | )
37 | )
38 | : new Date();
39 | today.setUTCHours(0, 0, 0, 0);
40 | const currentYear = today.getUTCFullYear();
41 |
42 | let rawData, cacheFile;
43 | if (source === "2") {
44 | cacheFile = path.join(cacheDir, `${country}-calendarific-${typeName}-${currentYear}.json`);
45 | }
46 |
47 | if (source === "2" && fs.existsSync(cacheFile)) {
48 | rawData = fs.readFileSync(cacheFile, "utf-8");
49 | } else {
50 | const url =
51 | source === "1"
52 | ? `https://date.nager.at/api/v3/publicholidays/${currentYear}/${country}`
53 | : `https://calendarific.com/api/v2/holidays?api_key=${apiKey}&country=${country}&year=${currentYear}&type=${typeName}`;
54 |
55 | try {
56 | const response = await axios.get(url, { timeout: 2000 });
57 | rawData = JSON.stringify(response.data);
58 | if (source === "2") {
59 | fs.writeFileSync(cacheFile, rawData, "utf-8");
60 | }
61 | } catch (error) {
62 | if (error.response) {
63 | const status = error.response.status;
64 | const data = error.response.data;
65 | let message = `Error while trying to connect to the ${typeName} Holiday API. `;
66 | if (source === "2") {
67 | switch (status) {
68 | case 401:
69 | message += "Unauthorized: Missing or incorrect API token.";
70 | break;
71 | case 422:
72 | if (data && data.meta && data.meta.error_code) {
73 | switch (data.meta.error_code) {
74 | case 600:
75 | message += "API is offline for maintenance.";
76 | break;
77 | case 601:
78 | message += "Unauthorized: Missing or incorrect API token.";
79 | break;
80 | case 602:
81 | message += "Invalid query parameters.";
82 | break;
83 | case 603:
84 | message += "Subscription level required.";
85 | break;
86 | default:
87 | message += `Unprocessable Entity: ${data.meta.error_detail || "Unknown error"}`;
88 | }
89 | } else {
90 | message += "Unprocessable Entity: Request was malformed.";
91 | }
92 | break;
93 | case 500:
94 | message += "Internal server error at Calendarific.";
95 | break;
96 | case 503:
97 | message += "Service unavailable (planned outage).";
98 | break;
99 | case 429:
100 | message += "Too many requests: API rate limit reached.";
101 | break;
102 | default:
103 | message += `Unexpected HTTP status: ${status}`;
104 | }
105 | } else if (source === "1") {
106 | switch (status) {
107 | case 400:
108 | message += "Bad request: invalid parameters.";
109 | break;
110 | case 401:
111 | message += "Unauthorized: Invalid API key or missing auth.";
112 | break;
113 | case 404:
114 | message += "Not found: Invalid endpoint or country code.";
115 | break;
116 | case 429:
117 | message += "Too many requests: API rate limit reached.";
118 | break;
119 | case 500:
120 | message += "Internal server error at Nager.Date.";
121 | break;
122 | default:
123 | message += `Unexpected HTTP status: ${status}`;
124 | }
125 | } else {
126 | message += "Unknown source specified.";
127 | }
128 | console.error(message);
129 | } else if (error.request) {
130 | console.error(`No response received from API (source=${source}).`);
131 | } else {
132 | console.error(`Error setting up request (source=${source}): ${error.message}`);
133 | }
134 | return false;
135 | }
136 | }
137 |
138 | let data;
139 | if (source === "2") {
140 | const parsed = JSON.parse(rawData);
141 | data = parsed.response.holidays.find((item) => item.name === holiday && item.locations === states);
142 | } else if (source === "1") {
143 | const parsed = JSON.parse(rawData);
144 | data = parsed.find((item) => {
145 | const stateString = item.counties === null ? "All" : item.counties.join(", ");
146 | return item.name === holiday && stateString === states;
147 | });
148 | }
149 |
150 | if (!data) {
151 | console.log("Holiday not found:", holiday);
152 | return false;
153 | }
154 |
155 | const holidayDateStr = source === "2" ? data.date.iso : data.date;
156 | const [datePart] = holidayDateStr.split("T");
157 | const [year, month, day] = datePart.split("-").map(Number);
158 |
159 | const holidayDate = new Date(Date.UTC(year, month - 1, day));
160 | holidayDate.setUTCHours(0, 0, 0, 0);
161 |
162 | const preDays = parseInt(pre, 10) || 0;
163 | const postDays = parseInt(post, 10) || 0;
164 |
165 | var windowStart, windowEnd;
166 |
167 | windowStart = new Date(holidayDate);
168 | windowStart.setUTCDate(holidayDate.getUTCDate() - preDays);
169 |
170 | windowEnd = new Date(holidayDate);
171 | windowEnd.setUTCDate(holidayDate.getUTCDate() + postDays);
172 | windowEnd.setUTCHours(23, 59, 59, 999);
173 |
174 | if (today < windowStart) {
175 | windowStart.setFullYear(windowStart.getFullYear() - 1);
176 | windowEnd.setFullYear(windowEnd.getFullYear() - 1);
177 | }
178 | if (today > windowEnd) {
179 | windowStart.setFullYear(windowStart.getFullYear() + 1);
180 | windowEnd.setFullYear(windowEnd.getFullYear() + 1);
181 | }
182 |
183 | return today >= windowStart && today <= windowEnd;
184 | }
185 |
186 | async function saveId(id) {
187 | var settingsCopy = { ...settings };
188 |
189 | settingsCopy.currentSeq = id;
190 |
191 | fs.writeFileSync("/config/settings.js", JSON.stringify(settingsCopy));
192 | }
193 |
194 | async function checkSchedule(forceDate = null) {
195 | let bestIndex = -1;
196 | let bestPriority = Infinity; // smaller number is higher priority
197 | const today = forceDate ? new Date(forceDate + "T00:00:00") : new Date();
198 | today.setHours(0, 0, 0, 0);
199 | const currentYear = today.getFullYear();
200 |
201 | const todayNumber = new Date(Date.UTC(currentYear, today.getMonth(), today.getDate())).getTime();
202 |
203 | for (let idx = 0; idx < settings.sequences.length; idx++) {
204 | const element = settings.sequences[idx];
205 | const priority = element.priority ? parseInt(element.priority, 10) : Infinity;
206 |
207 | let isMatch = false;
208 |
209 | if (element.schedule === "3") {
210 | // Holiday
211 | const isHoliday = await isHolidayDay(
212 | element.country,
213 | element.holiday,
214 | element.states,
215 | element.type,
216 | element.holidaySource,
217 | settings.settings.apiKey,
218 | forceDate,
219 | element.preHoliday || "0",
220 | element.postHoliday || "0"
221 | );
222 | if (isHoliday) isMatch = true;
223 | } else if (element.schedule === "2") {
224 | // Fallback
225 | isMatch = true;
226 | } else {
227 | // Date range
228 | const startNumber = new Date(Date.UTC(currentYear, element.startMonth - 1, element.startDay)).getTime();
229 | const endNumber = new Date(Date.UTC(currentYear, element.endMonth - 1, element.endDay)).getTime();
230 | const isWrapped = startNumber > endNumber;
231 |
232 | if (
233 | (isWrapped && (todayNumber >= startNumber || todayNumber <= endNumber)) ||
234 | (!isWrapped && todayNumber >= startNumber && todayNumber <= endNumber)
235 | ) {
236 | isMatch = true;
237 | }
238 | }
239 |
240 | // If this element matches and has a better (smaller) priority
241 | if (isMatch) {
242 | if (priority < bestPriority) {
243 | // Pick higher-priority sequence
244 | bestPriority = priority;
245 | bestIndex = idx;
246 | } else if (priority === Infinity && bestPriority === Infinity && bestIndex === -1) {
247 | // No priorities set anywhere, fall back to first match
248 | bestIndex = idx;
249 | }
250 | }
251 | }
252 | if (!forceDate) await saveId(bestIndex !== -1 ? settings.sequences[bestIndex].id : "");
253 | return bestIndex;
254 | }
255 |
256 | async function createList(index) {
257 | let plexString = "";
258 | if (index !== -1) {
259 | const bucketIds = settings.sequences[index].buckets;
260 | let usedFiles = new Set(); // Set to keep track of used files
261 |
262 | // Using `for...of` loop to await `axios` inside the loop
263 | for (const [idx, bucketId] of bucketIds.entries()) {
264 | let files = [];
265 | const info = settings.buckets.find(({ id }) => id === bucketId.id.toString());
266 |
267 | if (info.source === "2") {
268 | try {
269 | const response = await axios.post(
270 | "http://localhost:4949/backend/directory",
271 | { dir: `${info.dir}` },
272 | {
273 | headers: {
274 | "Content-Type": "application/json;charset=UTF-8",
275 | },
276 | }
277 | );
278 | response.data.forEach((media) => {
279 | if (!media.isDir)
280 | files.push(`${settings.settings.plexLoc}${info.dir.replace(settings.settings.loc, "")}/${media.name}`);
281 | });
282 | } catch (error) {
283 | if (error.response) {
284 | console.error("Server responded with error:", error.response.data);
285 | } else if (error.request) {
286 | console.error("No response received:", error.request);
287 | } else {
288 | console.error("Error setting up request:", error.message);
289 | }
290 | }
291 | } else {
292 | info.media.forEach((media) => {
293 | files.push(`${settings.settings.plexLoc}${media.dir}/${media.file}`);
294 | });
295 | }
296 |
297 | if (files.length !== 0) {
298 | let randomFile;
299 | do {
300 | randomFile = files[Math.floor(Math.random() * files.length)]; // Fix: `info.media.length` -> `files.length`
301 | } while (usedFiles.has(randomFile)); // Keep picking until an unused file is found
302 |
303 | usedFiles.add(randomFile); // Mark the selected file as used
304 |
305 | if (idx === bucketIds.length - 1) {
306 | plexString += randomFile;
307 | } else {
308 | plexString += randomFile + ",";
309 | }
310 | }
311 | }
312 | }
313 | console.log("Updating using Sequence: ", settings.sequences[index].name);
314 | return plexString;
315 | }
316 |
317 | async function sendList(string) {
318 | const url = `http${settings.settings.ssl ? "s" : ""}://${settings.settings.ip}:${settings.settings.port}/:/prefs`;
319 |
320 | await axios
321 | .put(url, null, {
322 | headers: {
323 | "X-Plex-Token": `${settings.token}`,
324 | },
325 | params: {
326 | CinemaTrailersPrerollID: string,
327 | },
328 | })
329 | .then((response) => {
330 | string === ""
331 | ? console.log("No string to update in Plex")
332 | : console.log("Preroll updated successfully: ", string);
333 | })
334 | .catch((error) => {
335 | console.error("Error updating preroll:", error);
336 | });
337 | }
338 |
339 | async function doTask() {
340 | const index = await checkSchedule();
341 | const string = await createList(index);
342 | sendList(string);
343 | }
344 |
345 | // Function to calculate delay until the desired time (3:00 PM)
346 | function getDelayUntilTargetTime(hour, minute) {
347 | const now = new Date();
348 | const targetTime = new Date();
349 |
350 | targetTime.setHours(hour, minute, 0, 0); // Set target time to 3:00 PM today
351 |
352 | if (targetTime <= now) {
353 | // If the target time has already passed today, schedule for tomorrow
354 | targetTime.setDate(targetTime.getDate() + 1);
355 | }
356 |
357 | // Calculate the delay in milliseconds
358 | return targetTime - now;
359 | }
360 |
361 | // Periodic Task to Check Schedules
362 | function myAsyncTask() {
363 | try {
364 | settings = JSON.parse(fs.readFileSync(filePath));
365 | // Your async code here
366 | console.log("Task running...");
367 | doTask();
368 | } catch (error) {
369 | console.error("Error in async task:", error);
370 | }
371 | }
372 |
373 | router.post("/", upload.single("thumb"), async function (req, res, next) {
374 | var payload = JSON.parse(req.body.payload);
375 | settings = JSON.parse(fs.readFileSync(filePath));
376 |
377 | try {
378 | if (payload.event === "media.play" && payload.Metadata.type === "movie") {
379 | console.info("Movie has started. Updating prerolls");
380 |
381 | doTask();
382 | }
383 | res.sendStatus(200);
384 | } catch (e) {
385 | console.log("There was an error", e);
386 | res.sendStatus(200);
387 | }
388 | });
389 |
390 | router.get("/", function (req, res, next) {
391 | settings = JSON.parse(fs.readFileSync(filePath));
392 |
393 | doTask();
394 |
395 | res.sendStatus(200);
396 | });
397 |
398 | router.get("/calendar", async (req, res) => {
399 | const { year, month } = req.query;
400 | if (!year || !month) {
401 | return res.status(400).json({ error: "year and month required" });
402 | }
403 |
404 | const y = parseInt(year, 10);
405 | const m = parseInt(month, 10) - 1; // JS months are 0-based
406 |
407 | const events = [];
408 |
409 | // Build bucket ID → name lookup map (once per request)
410 | const bucketMap = {};
411 | settings.buckets.forEach((bucket) => {
412 | bucketMap[bucket.id] = bucket.name;
413 | });
414 |
415 | // Start and end of the requested month (UTC)
416 | let currentDate = new Date(Date.UTC(y, m, 1));
417 | const endDate = new Date(Date.UTC(y, m + 1, 0)); // Last day of month
418 |
419 | console.log(
420 | `Fetching calendar: ${year}-${month} (${currentDate.toISOString().split("T")[0]} to ${
421 | endDate.toISOString().split("T")[0]
422 | })`
423 | );
424 |
425 | while (currentDate <= endDate) {
426 | const dateStr = currentDate.toISOString().split("T")[0];
427 |
428 | // DEBUG: See every date being processed
429 | // console.log("Processing:", dateStr);
430 |
431 | const index = await checkSchedule(dateStr);
432 | const seq = index !== -1 ? settings.sequences[index] : null;
433 |
434 | if (seq && Array.isArray(seq.buckets) && seq.buckets.length > 0) {
435 | const bucketNames = seq.buckets
436 | .map((b) => (typeof b === "object" && b.id ? bucketMap[b.id] : null))
437 | .filter(Boolean);
438 |
439 | events.push({
440 | title: seq.name,
441 | date: dateStr,
442 | buckets: bucketNames,
443 | });
444 | }
445 |
446 | // Move to next day — safely, without mutation bugs
447 | currentDate = new Date(
448 | Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth(), currentDate.getUTCDate() + 1)
449 | );
450 | }
451 |
452 | console.log(`Returning ${events.length} events for ${year}-${month}`);
453 | // console.log(JSON.stringify(events, null, 2)); // Pretty print for debugging
454 |
455 | res.json(events);
456 | });
457 |
458 | // Schedule the initial run
459 | const delay = getDelayUntilTargetTime(0, 0);
460 | // Set the task to run every day
461 | setTimeout(() => {
462 | myAsyncTask();
463 |
464 | setInterval(myAsyncTask, 24 * 60 * 60 * 1000);
465 | }, delay);
466 |
467 | module.exports = router;
468 |
--------------------------------------------------------------------------------
/frontend/src/components/App/App.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Component } from "react";
3 | import Loading from "../../images/loading-gif.gif";
4 | import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
5 | import Login from "../Login/Login";
6 | import Sequences from "../Sequences/Sequences";
7 | import Buckets from "../Buckets/Buckets";
8 | import Settings from "../Settings/Settings";
9 | import Announce from "./Announce";
10 | import Container from "react-bootstrap/Container";
11 | import Row from "react-bootstrap/Row";
12 | import Nav from "react-bootstrap/Nav";
13 | import Navbar from "react-bootstrap/Navbar";
14 | import "bootstrap/dist/css/bootstrap.min.css";
15 | import { LinkContainer } from "react-router-bootstrap";
16 | import Logout from "bootstrap-icons/icons/box-arrow-right.svg";
17 | import Moon from "bootstrap-icons/icons/moon-stars.svg";
18 | import Sun from "bootstrap-icons/icons/sun.svg";
19 | import Modal from "react-bootstrap/Modal";
20 | import NavDropdown from "react-bootstrap/NavDropdown";
21 | import Image from "react-bootstrap/Image";
22 | import Badge from "react-bootstrap/Badge";
23 | import { default as axios } from "axios";
24 | import "./App.css";
25 | // import FullCalendar from "@fullcalendar/react";
26 | import SequenceCalendar from "../Calendar/Calendar";
27 |
28 | export default class App extends Component {
29 | state = {
30 | isLoaded: false,
31 | isConnected: false,
32 | error: null,
33 | config: {},
34 | show: false,
35 | fullscreen: true,
36 | fullscreenAnn: true,
37 | isLoggedIn: false,
38 | isUpdate: false,
39 | isOnline: true,
40 | announce: false,
41 | first: false,
42 | dismiss: false,
43 | isDarkMode: false,
44 | announcement: false, //master key to show an announcement after version update
45 | sockConnected: false,
46 | cannotConnect: false,
47 | reconnectAttempts: 0,
48 | isBucket: false,
49 | };
50 |
51 | componentDidMount() {
52 | this.connectWebSocket();
53 |
54 | var xhr = new XMLHttpRequest();
55 | var state = false;
56 | var online = true;
57 |
58 | xhr.addEventListener("readystatechange", async () => {
59 | if (xhr.readyState === 4) {
60 | if (xhr.status === 200) {
61 | // request successful
62 | var response = xhr.responseText,
63 | json = JSON.parse(response);
64 |
65 | var url = "";
66 |
67 | if (json.branch === "dev") {
68 | url = `https://raw.githubusercontent.com/chadwpalm/PrerollPlus/develop/version.json?cb=${Date.now()}`;
69 |
70 | await axios
71 | .get(url, { headers: { "Content-Type": "application/json;charset=UTF-8" } })
72 | .then(function (response) {
73 | var data = response.data;
74 | if (data.version !== json.version) {
75 | state = true;
76 | }
77 | })
78 | .catch(function (error) {
79 | online = false;
80 | });
81 | } else {
82 | url = `https://raw.githubusercontent.com/chadwpalm/PrerollPlus/main/version.json`;
83 |
84 | await axios
85 | .get(url, { headers: { "Content-Type": "application/json;charset=UTF-8" } })
86 | .then(function (response) {
87 | var data = response.data;
88 |
89 | if (data.version !== json.version) {
90 | state = true;
91 | }
92 | })
93 | .catch(function (error) {});
94 | }
95 |
96 | if (!online) {
97 | this.setState({ isOnline: false });
98 | } else {
99 | this.setState({
100 | isLoaded: true,
101 | config: json,
102 | thumb: json.thumb,
103 | });
104 |
105 | if (json.buckets.length !== 0) this.setState({ isBucket: true });
106 |
107 | if (state) this.setState({ isUpdate: true });
108 |
109 | if (json.token) this.setState({ isLoggedIn: true });
110 |
111 | if (json.connected === "true") {
112 | this.setState({ isConnected: true });
113 | }
114 |
115 | if (json.message) this.setState({ first: true });
116 |
117 | this.setState({ isDarkMode: json.darkMode }, () => {
118 | this.toggleBodyClass();
119 | });
120 | }
121 | } else {
122 | // error
123 | this.setState({
124 | isLoaded: true,
125 | error: xhr.responseText,
126 | });
127 | }
128 | }
129 | });
130 |
131 | xhr.open("GET", "/backend/load", true);
132 | xhr.send();
133 | }
134 |
135 | connectWebSocket() {
136 | const protocol = window.location.protocol === "https:" ? "wss" : "ws";
137 | this.ws = new WebSocket(`${protocol}://${window.location.host}`);
138 |
139 | this.ws.onopen = () => {
140 | console.log("WebSocket connection opened");
141 | this.setState({ sockConnected: true, reconnectAttempts: 0 });
142 | };
143 |
144 | this.ws.onmessage = (event) => {
145 | if (event.data === "update-config") {
146 | this.refreshConfig();
147 | }
148 | };
149 |
150 | this.ws.onclose = () => {
151 | console.log("WebSocket connection closed");
152 | this.setState({ sockConnected: false });
153 |
154 | if (this.state.reconnectAttempts < 2) {
155 | console.log("Attempting to reconnect...");
156 | this.setState(
157 | (prevState) => ({
158 | reconnectAttempts: prevState.reconnectAttempts + 1,
159 | }),
160 | () => {
161 | setTimeout(() => {
162 | this.connectWebSocket(); // Reconnect
163 | }, 3000); // Delay before reconnecting
164 | }
165 | );
166 | } else {
167 | console.log("Max reconnect attempts reached.");
168 | this.setState({ cannotConnect: true });
169 | }
170 | };
171 |
172 | this.ws.onerror = (error) => {
173 | console.error("WebSocket error:", error);
174 | };
175 | }
176 |
177 | refreshConfig() {
178 | axios
179 | .get("/backend/load")
180 | .then((response) => {
181 | this.setState({
182 | config: response.data,
183 | });
184 | })
185 | .catch((error) => {
186 | console.error("Error refreshing config:", error);
187 | });
188 | }
189 |
190 | toggleBodyClass = () => {
191 | if (this.state.isDarkMode) {
192 | document.body.classList.add("dark-mode");
193 | } else {
194 | document.body.classList.remove("dark-mode");
195 | }
196 | };
197 |
198 | handleLogin = () => {
199 | window.location.reload(false);
200 | // this.setState({ isLoggedIn: true });
201 | };
202 |
203 | handleConnectionChange = (change) => {
204 | change ? this.setState({ isConnected: true }) : this.setState({ isConnected: false });
205 | };
206 |
207 | handleClose = () => this.setState({ show: false });
208 |
209 | handleOpen = () => this.setState({ show: true, fullscreen: "md-down" });
210 |
211 | handleCloseAnn = () => {
212 | this.setState({ announce: false });
213 | if (this.state.dismiss) {
214 | var settings = { ...this.state.config };
215 |
216 | settings.message = false;
217 |
218 | var xhr = new XMLHttpRequest();
219 |
220 | xhr.addEventListener("readystatechange", () => {
221 | if (xhr.readyState === 4) {
222 | if (xhr.status === 200) {
223 | } else {
224 | // error
225 | this.setState({
226 | error: xhr.responseText,
227 | });
228 | }
229 | }
230 | });
231 |
232 | xhr.open("POST", "/backend/save", true);
233 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
234 | xhr.send(JSON.stringify(settings));
235 | }
236 | };
237 |
238 | handleOpenAnn = () => this.setState({ announce: true, first: false, fullscreenAnn: "md-down" });
239 |
240 | handleDismiss = () => {
241 | if (this.state.dismiss) {
242 | this.setState({ dismiss: false });
243 | } else {
244 | this.setState({ dismiss: true });
245 | }
246 | };
247 |
248 | handleUpdateThumb = (thumb, token, username, email) => {
249 | var temp = this.state.config;
250 | temp.token = token;
251 | temp.thumb = thumb;
252 | temp.email = email;
253 | temp.username = username;
254 | this.setState({ config: temp });
255 | };
256 |
257 | handleLogout = () => {
258 | var settings = { ...this.state.config };
259 |
260 | delete settings["token"];
261 | delete settings["thumb"];
262 | delete settings["email"];
263 | delete settings["username"];
264 |
265 | var xhr = new XMLHttpRequest();
266 |
267 | xhr.addEventListener("readystatechange", () => {
268 | if (xhr.readyState === 4) {
269 | if (xhr.status === 200) {
270 | this.setState({ isLoggedIn: false });
271 | } else {
272 | // error
273 | this.setState({
274 | error: xhr.responseText,
275 | });
276 | }
277 | }
278 | });
279 |
280 | xhr.open("POST", "/backend/save", true);
281 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
282 | xhr.send(JSON.stringify(settings));
283 | };
284 |
285 | updateSettings = (newSettings) => {
286 | this.setState({ config: newSettings });
287 | };
288 |
289 | handleBuckets = () => {
290 | this.setState({ isBucket: true });
291 | };
292 |
293 | handleDark = () => {
294 | this.setState((prevState) => {
295 | const newMode = !prevState.isDarkMode;
296 |
297 | var settings = { ...prevState.config, darkMode: newMode };
298 |
299 | var xhr = new XMLHttpRequest();
300 |
301 | xhr.addEventListener("readystatechange", () => {
302 | if (xhr.readyState === 4) {
303 | if (xhr.status === 200) {
304 | } else {
305 | // error
306 | this.setState({
307 | error: xhr.responseText,
308 | });
309 | }
310 | }
311 | });
312 |
313 | xhr.open("POST", "/backend/save", true);
314 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
315 | xhr.send(JSON.stringify(settings));
316 |
317 | return { isDarkMode: newMode, config: settings };
318 | }, this.toggleBodyClass);
319 | };
320 |
321 | render() {
322 | if (!this.state.isOnline) {
323 | return (
324 | <>
325 | Preroll Plus requires an internet connection. If you are running Preroll Plus in Docker, check your Docker
326 | network settings.
327 | >
328 | );
329 | } else {
330 | if (!this.state.isLoaded) {
331 | // is loading
332 | return (
333 |
334 |
335 |
336 | );
337 | } else if (this.state.error) {
338 | // error
339 | return Error occured: {this.state.error}
;
340 | } else {
341 | if (this.state.isLoggedIn) {
342 | // success
343 | return (
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 | Preroll Plus
352 |
353 |
354 |
355 |
356 |
357 |
358 | {!this.state.isConnected ? (
359 | <>
360 |
361 | Sequences
362 |
363 |
364 | Calendar
365 |
366 |
367 | Buckets
368 |
369 | >
370 | ) : (
371 | <>
372 | {this.state.config.buckets.length === 0 ? (
373 | <>
374 |
375 | Sequences
376 |
377 |
378 | Calendar
379 |
380 | >
381 | ) : (
382 | <>
383 |
384 | Sequences
385 |
386 |
387 | Calendar
388 |
389 | >
390 | )}
391 |
392 |
393 | Buckets
394 |
395 | >
396 | )}
397 |
398 |
399 | Settings
400 |
401 |
402 |
403 |
404 |
409 |
410 |
456 |
457 |
458 |
459 |
460 |
461 | {this.state.first ? this.handleOpenAnn() : <>>}
462 |
470 |
471 | About
472 |
473 |
474 | Version: {this.state.config.version}
475 |
476 | Branch: {this.state.config.branch}
477 |
478 | Build: {this.state.config.build}
479 |
480 | Config Dir: /config
481 |
482 | App Dir: /PrerollPlus
483 |
484 | Docker:
485 |
490 | chadwpalm/prerollplus
491 |
492 |
493 | Source:
494 |
495 | github.com/chadwpalm/PrerollPlus
496 |
497 |
498 |
499 | {this.state.announcement ? (
500 |
508 | ) : (
509 | <>>
510 | )}
511 |
512 |
513 |
514 |
522 | }
523 | />
524 |
525 | {!this.state.isConnected ? (
526 | } />
527 | ) : (
528 | <>
529 | {!this.state.isBucket ? (
530 |
542 | }
543 | />
544 | ) : (
545 |
555 | }
556 | />
557 | )}
558 |
559 |
571 | }
572 | />
573 | }
576 | />
577 | } />
578 | >
579 | )}
580 |
581 |
582 |
583 |
584 | );
585 | } else {
586 | return (
587 |
588 |
589 | } />
590 |
598 | }
599 | />
600 |
601 |
602 | );
603 | }
604 | }
605 | }
606 | }
607 | }
608 |
--------------------------------------------------------------------------------
/frontend/src/components/Settings/Settings.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Row from "react-bootstrap/Row";
3 | import Col from "react-bootstrap/Col";
4 | import Form from "react-bootstrap/Form";
5 | import OverlayTrigger from "react-bootstrap/OverlayTrigger";
6 | import Tooltip from "react-bootstrap/Tooltip";
7 | import Info from "bootstrap-icons/icons/info-circle.svg";
8 | import Repeat from "bootstrap-icons/icons/arrow-repeat.svg";
9 | import Button from "react-bootstrap/Button";
10 | import Stack from "react-bootstrap/Stack";
11 | import Image from "react-bootstrap/Image";
12 | import "./Settings.css";
13 |
14 | export default class Settings extends Component {
15 | constructor(props) {
16 | super(props);
17 | if (this.props.settings.settings) {
18 | this.state = {
19 | ip: this.props.settings.settings.ip,
20 | port: this.props.settings.settings.port,
21 | ssl: this.props.settings.settings.ssl,
22 | loc: this.props.settings.settings.loc ?? "/prerolls",
23 | plexLoc: this.props.settings.settings.plexLoc,
24 | servers: [],
25 | isGetting: false,
26 | isLoaded: false,
27 | isError: false,
28 | isIncomplete: false,
29 | isSaved: false,
30 | polling: this.props.settings.settings.polling ?? "1",
31 | advanced: this.props.settings.advanced ?? false,
32 | logLevel: this.props.settings.settings.logLevel ?? "0",
33 | holidaySource: this.props.settings.settings.holidaySource ?? "1",
34 | apiKey: this.props.settings.settings.apiKey ?? "",
35 | isCacheSuccess: false,
36 | isCacheFailed: false,
37 | dayOfWeek: this.props.settings.settings.dayOfWeek ?? "0",
38 | };
39 | } else {
40 | this.state = {
41 | ip: "",
42 | port: "",
43 | ssl: false,
44 | loc: "/prerolls",
45 | plexLoc: "",
46 | servers: [],
47 | isGetting: false,
48 | isLoaded: false,
49 | isIncomplete: false,
50 | isSaved: false,
51 | polling: "1",
52 | advanced: this.props.settings.advanced ?? false,
53 | logLevel: "0",
54 | holidaySource: "1",
55 | apiKey: "",
56 | isCacheSuccess: false,
57 | isCacheFailed: false,
58 | dayOfWeek: "0",
59 | };
60 | }
61 | }
62 |
63 | handleFormSubmit = async (e) => {
64 | e.preventDefault();
65 |
66 | this.setState({ isIncomplete: false });
67 |
68 | if (this.state.ip === "" || this.state.port === "") {
69 | this.setState({ isIncomplete: true });
70 | return;
71 | }
72 |
73 | if (!this.props.settings.settings) this.props.settings.settings = {};
74 |
75 | this.props.settings.settings.ip = this.state.ip;
76 | this.props.settings.settings.port = this.state.port;
77 | this.props.settings.settings.ssl = this.state.ssl;
78 | this.props.settings.settings.loc = this.state.loc;
79 | this.props.settings.settings.plexLoc = this.state.plexLoc;
80 | this.props.settings.connected = "true";
81 | this.props.settings.settings.polling = this.state.polling;
82 | this.props.settings.settings.logLevel = this.state.logLevel;
83 | this.props.settings.settings.apiKey = this.state.apiKey;
84 | this.props.settings.settings.dayOfWeek = this.state.dayOfWeek;
85 | this.props.connection(1);
86 |
87 | try {
88 | const saveResponse = await fetch("/backend/save", {
89 | method: "POST",
90 | headers: { "Content-Type": "application/json;charset=UTF-8" },
91 | body: JSON.stringify(this.props.settings),
92 | });
93 |
94 | if (!saveResponse.ok) throw new Error(`Save failed: ${saveResponse.status}`);
95 |
96 | this.setState({ isSaved: true });
97 |
98 | const monitorResp = await fetch("/backend/monitor");
99 | if (!monitorResp.ok) throw new Error(`Monitor failed: ${monitorResp.status}`);
100 |
101 | const loggerResp = await fetch("/backend/logger");
102 | if (!loggerResp.ok) throw new Error(`Logger failed: ${loggerResp.status}`);
103 |
104 | const webhookResp = await fetch("/webhook");
105 | if (!webhookResp.ok) throw new Error(`Webhook failed: ${webhookResp.status}`);
106 | } catch (err) {
107 | console.error(err);
108 | this.setState({ error: err.message });
109 | }
110 | };
111 |
112 | handleServerGet = () => {
113 | var xhr = new XMLHttpRequest();
114 |
115 | this.setState({ isGetting: true });
116 | this.setState({ isLoaded: false });
117 | this.setState({ isSaved: false });
118 |
119 | xhr.addEventListener("readystatechange", async () => {
120 | if (xhr.readyState === 4) {
121 | if (xhr.status === 200) {
122 | // request successful
123 | var response = xhr.responseText,
124 | json = JSON.parse(response);
125 |
126 | var tempList = [];
127 | var index = 0;
128 |
129 | const createServerEntry = (element, index, secure, location, socket) => ({
130 | index: index,
131 | name: element.name,
132 | ip: location === "remote" ? element.remoteIP : element.localIP,
133 | port: element.port,
134 | location,
135 | secure,
136 | cert: element.cert,
137 | certSuccessful: element.certSuccessful,
138 | socket: socket,
139 | });
140 |
141 | for (const element of json) {
142 | if (element.certSuccessful) {
143 | if (!element.https) {
144 | tempList.push(createServerEntry(element, ++index, false, "local", false));
145 | tempList.push(createServerEntry(element, ++index, false, "remote", false));
146 | }
147 | tempList.push(createServerEntry(element, ++index, true, "local", false));
148 | tempList.push(createServerEntry(element, ++index, true, "remote", false));
149 | } else {
150 | tempList.push(createServerEntry(element, ++index, false, "local", true));
151 | tempList.push(createServerEntry(element, ++index, false, "remote", true));
152 | }
153 | }
154 | this.setState({ servers: tempList });
155 | this.setState({ isLoaded: true });
156 | } else {
157 | // error
158 | this.setState({
159 | isLoaded: true,
160 | error: xhr.responseText,
161 | });
162 | }
163 | }
164 | });
165 |
166 | xhr.open("POST", "/backend/settings", true);
167 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
168 | xhr.send(JSON.stringify(this.props.settings));
169 | };
170 |
171 | handleServerChange = (e) => {
172 | if (e.target.value !== 0) {
173 | if (this.state.servers[e.target.value - 1].secure) {
174 | this.setState({
175 | ip: `${this.state.servers[e.target.value - 1].ip.replace(/\./g, "-")}.${
176 | this.state.servers[e.target.value - 1].cert
177 | }.plex.direct`,
178 | ssl: true,
179 | });
180 | } else {
181 | this.setState({ ip: this.state.servers[e.target.value - 1].ip, ssl: false });
182 | }
183 | if (this.state.servers[e.target.value - 1].location === "remote") {
184 | this.setState({ port: this.state.servers[e.target.value - 1].port });
185 | } else {
186 | this.setState({ port: "32400" });
187 | }
188 | }
189 | this.setState({ isSaved: false });
190 | };
191 |
192 | handleIp = (e) => {
193 | this.setState({ ip: e.target.value.toString(), isSaved: false });
194 | };
195 |
196 | handlePort = (e) => {
197 | this.setState({ port: e.target.value.toString(), isSaved: false });
198 | };
199 |
200 | handleSSL = (e) => {
201 | // console.log(e.target.checked);
202 | this.setState({ ssl: e.target.checked, isSaved: false });
203 | };
204 |
205 | handleLoc = (e) => {
206 | this.setState({ loc: e.target.value.toString(), isSaved: false });
207 | };
208 |
209 | handlePlexLoc = (e) => {
210 | this.setState({ plexLoc: e.target.value.toString(), isSaved: false });
211 | };
212 |
213 | handlePolling = (e) => {
214 | this.setState({ polling: e.target.value.toString() });
215 | };
216 |
217 | handleLogLevel = (e) => {
218 | this.setState({ logLevel: e.target.value.toString(), isSaved: false });
219 | };
220 |
221 | handleAPIKey = (e) => {
222 | this.setState({ apiKey: e.target.value.toString() });
223 | };
224 |
225 | handleDayOfWeek = (e) => {
226 | this.setState({ dayOfWeek: e.target.value.toString() });
227 | };
228 |
229 | handleAdvanced = () => {
230 | this.setState((prevState) => {
231 | const newMode = !prevState.advanced;
232 |
233 | var settings = { ...this.props.settings, advanced: newMode };
234 |
235 | var xhr = new XMLHttpRequest();
236 |
237 | xhr.addEventListener("readystatechange", () => {
238 | if (xhr.readyState === 4) {
239 | if (xhr.status === 200) {
240 | } else {
241 | // error
242 | this.setState({
243 | error: xhr.responseText,
244 | });
245 | }
246 | }
247 | });
248 |
249 | xhr.open("POST", "/backend/save", true);
250 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
251 | xhr.send(JSON.stringify(settings));
252 |
253 | return { advanced: newMode };
254 | });
255 | };
256 |
257 | handleCache = () => {
258 | var xhr = new XMLHttpRequest();
259 |
260 | xhr.addEventListener("readystatechange", () => {
261 | if (xhr.readyState === 4) {
262 | if (xhr.status === 200) {
263 | this.setState({ isCacheSuccess: true });
264 | } else if (xhr.status === 500) {
265 | this.setState({ isCacheFailed: true });
266 | } else {
267 | this.setState({
268 | error: xhr.responseText,
269 | });
270 | }
271 | }
272 | });
273 |
274 | xhr.open("GET", "/backend/clearcache", true);
275 | xhr.send();
276 | };
277 |
278 | render() {
279 | return (
280 | <>
281 |
282 |
283 | Settings
284 |
285 |
286 | {this.state.advanced ? (
287 |
288 | Hide Advanced
289 |
290 | ) : (
291 |
292 | Show Advanced
293 |
294 | )}
295 |
296 |
297 |
298 |
299 | Server
320 |
321 | {this.state.isLoaded ? (
322 |
330 | Manual configuration
331 | {this.state.servers.map((server) => {
332 | const certInfo = server.secure ? `${server.cert}.plex.direct` : "";
333 | const ip = server.secure ? server.ip.replace(/\./g, "-") : server.ip;
334 | const location = `[${server.location}]`;
335 | const socket = server.socket ? `(socket hang up)` : "";
336 | const secure = server.secure ? `[secure]` : "";
337 |
338 | return (
339 |
344 | {`${server.name} (${ip}${certInfo ? `.${certInfo}` : ""}) ${location} ${secure} ${socket}`}
345 |
346 | );
347 | })}
348 |
349 | ) : (
350 | <>
351 | {this.state.isGetting ? (
352 |
353 | Retrieving servers...
354 |
355 | ) : (
356 |
364 | Press the button to load available servers
365 |
366 | )}
367 | >
368 | )}
369 |
370 |
371 |
372 |
373 |
374 | Hostname or IP Address
375 |
376 |
377 | Port
378 |
379 |
380 | Use SSL
381 |
382 |
383 | Preroll Media
384 |
385 | Location of preroll media
386 |
390 | This is the root location of your Plex preroll media files.
391 |
392 |
393 | This option is only available when running the application natively. If running from Docker, it will
394 | be grayed out and you can set your root location through mounting the internal /prerolls directory to
395 | the directory of your choosing on your host system.
396 |
397 |
398 | When creating buckets, this is the directory that Preroll Plus will look for preroll media, so make
399 | sure the root location of your media matches this location.
400 |
401 | }
402 | >
403 |
404 |
405 | {this.props.settings.build === "Native" ? (
406 |
407 | ) : (
408 |
409 | )}
410 |
411 | Plex location of preroll media
412 |
416 | This is the location of your Plex prerolls as Plex sees them.
417 |
418 |
419 | This path should corrospond to root location of your preroll files based on the location of your Plex
420 | server. If you are running Preroll Plus and Plex on the same device, this should match the above path.
421 | If you are running Plex on a different machine than Preroll Plus, this path will most likely be
422 | different than the one above.
423 |
424 | }
425 | >
426 |
427 |
428 |
435 |
436 | {this.state.advanced ? (
437 | <>
438 |
439 |
440 | File Monitor Polling
441 |
445 | This setting changes backend file monitoring from using "inotify" to a polling method.
446 |
447 |
448 | If you are connecting to your prerolls directory using an SMB (or similar) share, it is more
449 | than likely that the file system's ability to be notified of file changes will not work.
450 |
451 |
452 | If you are finding that renaming, moving, or removing files in your preroll directory isn't
453 | automatically working, set this to on and Preroll Plus will monitor file changes using a
454 | constant polling of the file system.
455 |
456 |
457 | If everything is working correctly, it is recommended to keep this setting off.
458 |
459 | }
460 | >
461 |
462 |
463 |
464 |
465 |
476 |
487 |
488 |
489 |
490 | Log Level:
491 |
499 | Info
500 | Debug
501 |
502 |
503 | >
504 | ) : (
505 | <>>
506 | )}
507 |
508 |
509 | Holidays
510 |
511 |
512 | Calendarific API Key
513 |
517 | Enter Calendarific API key here.
518 |
519 |
520 | You can find your API key on your account dashboard toward the bottom of the page.
521 |
522 | }
523 | >
524 |
525 |
526 | Get account and API key at{" "}
527 |
528 | calendarific.com
529 |
530 |
538 |
539 |
540 | {this.state.advanced ? (
541 | <>
542 |
543 | Clear Calendarific Cache
544 |
545 |
546 | >
547 | ) : (
548 | <>>
549 | )}
550 | {this.state.isCacheSuccess ? (
551 | Cache cleared successfully.
552 | ) : (
553 | <>>
554 | )}
555 | {this.state.isCacheFailed ? (
556 | Error clearing cache. Check logs.
557 | ) : (
558 | <>>
559 | )}
560 |
561 |
562 | Calendar Day of Week Start
563 |
564 |
575 |
586 |
597 |
598 |
599 | {/* Cancel/Save */}
600 |
601 | Save
602 |
603 |
604 | {this.state.isIncomplete ? IP and Port must be filled. : <>>}
605 | {this.state.isSaved ? Settings saved. : <>>}
606 |
607 |
608 |
609 |
610 | >
611 | );
612 | }
613 | }
614 |
--------------------------------------------------------------------------------
/frontend/src/components/Create/Create.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, createRef } from "react";
2 | import { v4 as uuid } from "uuid";
3 | import Form from "react-bootstrap/Form";
4 | import Button from "react-bootstrap/Button";
5 | import ListGroup from "react-bootstrap/ListGroup";
6 | import OverlayTrigger from "react-bootstrap/OverlayTrigger";
7 | import Tooltip from "react-bootstrap/Tooltip";
8 | import Info from "bootstrap-icons/icons/info-circle.svg";
9 | import Row from "react-bootstrap/Row";
10 | import Col from "react-bootstrap/Col";
11 | import Card from "react-bootstrap/Card";
12 | import Badge from "react-bootstrap/Badge";
13 | import LeftArrow from "bootstrap-icons/icons/arrow-left.svg";
14 | import Image from "react-bootstrap/Image";
15 | import "../CreateSeq/CreateSeq.css";
16 |
17 | export default class Create extends Component {
18 | constructor(props) {
19 | super(props);
20 |
21 | if (this.props.isEdit) {
22 | var info = this.props.settings.buckets.find(({ id }) => id === this.props.id.toString());
23 |
24 | this.state = {
25 | id: info.id,
26 | media: info.media,
27 | name: info.name,
28 | directoryList: [],
29 | selectedList: [],
30 | selectedFileList: [],
31 | root: this.props.settings.settings.loc,
32 | currentDir: info.source === "2" ? info.dir : this.props.settings.settings.loc,
33 | dirTree: [],
34 | isError: false,
35 | isSaved: false,
36 | isIncomplete: false,
37 | player: false,
38 | videoIndex: 0,
39 | tempList: [],
40 | tempLength: 0,
41 | source: info.source ?? "1",
42 | sourceDir: info.dir ?? "",
43 | };
44 | } else {
45 | this.state = {
46 | media: [],
47 | name: "",
48 | directoryList: [],
49 | selectedList: [],
50 | selectedFileList: [],
51 | root: this.props.settings.settings.loc,
52 | currentDir: this.props.settings.settings.loc,
53 | dirTree: [],
54 | isError: false,
55 | isSaved: false,
56 | isIncomplete: false,
57 | player: false,
58 | videoIndex: 0,
59 | tempList: [],
60 | tempLength: 0,
61 | source: "1",
62 | sourceDir: "",
63 | };
64 | }
65 |
66 | this.videoRef = createRef();
67 | this.handleFormSubmit = this.handleFormSubmit.bind(this);
68 | this.handleStreamer = this.handleStreamer.bind(this);
69 | this.fetchDirectoryList = this.fetchDirectoryList.bind(this);
70 | }
71 |
72 | componentDidMount() {
73 | var xhr = new XMLHttpRequest();
74 | xhr.timeout = 3000;
75 | xhr.addEventListener("readystatechange", () => {
76 | if (xhr.readyState === 4) {
77 | if (xhr.status === 200) {
78 | // request successful
79 | var response = xhr.responseText,
80 | json = JSON.parse(response);
81 |
82 | this.setState((prevState) => {
83 | const updatedDirTree = [
84 | ...prevState.dirTree,
85 | this.state.root,
86 | ...(this.state.source === "2"
87 | ? this.state.currentDir
88 | .replace(this.state.root, "")
89 | .split("/")
90 | .filter((dir) => dir !== "")
91 | : []),
92 | ];
93 | return {
94 | dirTree: updatedDirTree,
95 | directoryList: json,
96 | };
97 | });
98 | } else {
99 | // error
100 | this.setState({
101 | isError: true,
102 | errorRes: JSON.parse(xhr.responseText),
103 | });
104 | }
105 | }
106 | });
107 | xhr.open("POST", "/backend/directory", true);
108 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
109 | xhr.send(
110 | JSON.stringify({
111 | dir: `${this.state.source === "2" ? this.state.currentDir : this.state.root}`,
112 | })
113 | );
114 | }
115 |
116 | componentDidUpdate(prevProps) {
117 | // Check if the settings prop has changed
118 | if (this.props.settings !== prevProps.settings) {
119 | // Find the relevant bucket if in edit mode
120 | if (this.props.isEdit) {
121 | const info = this.props.settings.buckets.find(({ id }) => id === this.props.id.toString());
122 | if (info) {
123 | this.setState(
124 | {
125 | media: info.media,
126 | },
127 | () => {
128 | try {
129 | this.fetchDirectoryList();
130 | } catch (err) {
131 | console.error("Error calling fetchDirectoryList:", err);
132 | }
133 | }
134 | );
135 | }
136 | } else {
137 | // Handle the default state for new items
138 | this.setState(
139 | {
140 | media: [],
141 | },
142 | () => {
143 | try {
144 | this.fetchDirectoryList();
145 | } catch (err) {
146 | console.error("Error calling fetchDirectoryList:", err);
147 | }
148 | }
149 | );
150 | }
151 | }
152 | }
153 |
154 | handleClickFiles = (e) => {
155 | e.preventDefault();
156 | const target = e.currentTarget;
157 | const temp = JSON.parse(target.value);
158 |
159 | this.setState((prevState) => {
160 | const newSelectedList = prevState.selectedFileList.includes(temp)
161 | ? prevState.selectedFileList.filter((item) => item !== temp)
162 | : [...prevState.selectedFileList, temp];
163 | return { selectedFileList: newSelectedList };
164 | });
165 | };
166 |
167 | handleClick = (e) => {
168 | e.preventDefault();
169 | const target = e.currentTarget;
170 | var temp = JSON.parse(target.value);
171 |
172 | if (temp.isDir) {
173 | if (temp.name === "..") {
174 | this.setState(
175 | (prevState) => {
176 | // Remove the last directory from the path
177 | const updatedDirTree = prevState.dirTree.slice(0, -1);
178 | const tempDir = updatedDirTree.join("/"); // Construct the new directory path
179 | return {
180 | dirTree: updatedDirTree,
181 | currentDir: tempDir,
182 | selectedList: [], // Clear selected list when navigating directories
183 | };
184 | },
185 | () => {
186 | // Callback to handle state changes and perform subsequent actions
187 | this.fetchDirectoryList();
188 | }
189 | );
190 | } else {
191 | this.setState(
192 | (prevState) => {
193 | // Add the new directory to the path
194 | const updatedDirTree = [...prevState.dirTree, temp.name];
195 | const tempDir = updatedDirTree.join("/"); // Construct the new directory path
196 | return {
197 | dirTree: updatedDirTree,
198 | currentDir: tempDir,
199 | selectedList: [], // Clear selected list when navigating directories
200 | };
201 | },
202 | () => {
203 | // Callback to handle state changes and perform subsequent actions
204 | this.fetchDirectoryList();
205 | }
206 | );
207 | }
208 | } else {
209 | this.setState((prevState) => {
210 | const newSelectedList = prevState.selectedList.includes(temp.name)
211 | ? prevState.selectedList.filter((item) => item !== temp.name)
212 | : [...prevState.selectedList, temp.name];
213 | return { selectedList: newSelectedList };
214 | });
215 | }
216 | };
217 |
218 | fetchDirectoryList = () => {
219 | var xhr = new XMLHttpRequest();
220 | xhr.timeout = 3000;
221 | xhr.addEventListener("readystatechange", () => {
222 | if (xhr.readyState === 4) {
223 | if (xhr.status === 200) {
224 | // request successful
225 | var response = xhr.responseText,
226 | json = JSON.parse(response);
227 |
228 | this.setState({ directoryList: json });
229 | } else {
230 | // error
231 | this.setState({
232 | isError: true,
233 | errorRes: JSON.parse(xhr.responseText),
234 | });
235 | }
236 | }
237 | });
238 | xhr.open("POST", "/backend/directory", true);
239 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
240 | xhr.send(JSON.stringify({ dir: `${this.state.currentDir}` }));
241 | };
242 |
243 | handleAdd = (e) => {
244 | e.preventDefault();
245 | const newDir = (this.state.dirTree.length > 1 ? "/" : "") + this.state.dirTree.slice(1).join("/");
246 | const newMediaList = this.state.selectedList.map((element) => ({ file: element, dir: newDir }));
247 | this.setState((prevState) => ({
248 | media: [...prevState.media, ...newMediaList],
249 | }));
250 | };
251 |
252 | handleRemove = (e) => {
253 | e.preventDefault();
254 | this.setState((prevState) => {
255 | const newMedia = [...prevState.media]; // Create a copy of the current media state
256 |
257 | prevState.selectedFileList.forEach((file) => {
258 | const index = newMedia.findIndex((item) => item.file === file); // Find the index of the first matching element
259 | if (index !== -1) {
260 | newMedia.splice(index, 1); // Remove the first matching element
261 | }
262 | });
263 |
264 | return { media: newMedia, selectedFileList: [] }; // Update state with the modified media array and clear selectedFileList
265 | });
266 | };
267 |
268 | handleSelectAll = () => {
269 | var newList = [];
270 | this.state.directoryList.forEach((element) => {
271 | if (!element.isDir) {
272 | newList.push(element.name);
273 | }
274 | });
275 | this.setState({ selectedList: newList });
276 | };
277 |
278 | handleSelectNone = () => {
279 | this.setState({ selectedList: [] });
280 | };
281 |
282 | handleName = (e) => {
283 | this.setState({ name: e.target.value.toString(), isSaved: false });
284 | };
285 |
286 | handleFormSubmit = (e) => {
287 | e.preventDefault();
288 |
289 | this.setState({ isIncomplete: false });
290 |
291 | if (this.state.name === "" || (this.state.media.length === 0 && this.state.source === "1")) {
292 | this.setState({ isIncomplete: true });
293 | return;
294 | }
295 |
296 | var settings = { ...this.props.settings };
297 |
298 | if (!settings.buckets) settings.buckets = [];
299 |
300 | var temp = {};
301 |
302 | if (this.props.isEdit) {
303 | temp.id = this.state.id;
304 | } else {
305 | temp.id = uuid().toString();
306 | }
307 | temp.name = this.state.name;
308 | temp.media = this.state.media;
309 | temp.source = this.state.source;
310 | temp.dir = this.state.currentDir;
311 |
312 | if (this.props.isEdit) {
313 | const index = settings.buckets.findIndex(({ id }) => id === this.state.id);
314 | settings.buckets.splice(index, 1, temp);
315 | } else {
316 | settings.buckets.push(temp);
317 | }
318 |
319 | var xhr = new XMLHttpRequest();
320 |
321 | xhr.addEventListener("readystatechange", async () => {
322 | if (xhr.readyState === 4) {
323 | if (xhr.status === 200) {
324 | this.setState({ isSaved: true });
325 |
326 | const response = await fetch("/webhook", { method: "GET" });
327 | if (!response.ok) {
328 | throw new Error(`Response status: ${response.status}`);
329 | }
330 |
331 | const response2 = await fetch("/backend/monitor", { method: "GET" });
332 | if (!response2.ok) {
333 | throw new Error(`Response status: ${response.status}`);
334 | }
335 | } else {
336 | // error
337 | this.setState({
338 | error: xhr.responseText,
339 | });
340 | }
341 | }
342 | });
343 |
344 | xhr.open("POST", "/backend/save", true);
345 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
346 | xhr.send(JSON.stringify(settings));
347 |
348 | this.props.saved();
349 | };
350 |
351 | handleStreamer = () => {
352 | this.setState(
353 | {
354 | player: true,
355 | videoIndex: 0,
356 | tempList: this.state.selectedList.sort(([fileA], [fileB]) => fileA.localeCompare(fileB)),
357 | tempLength: this.state.selectedList.length,
358 | },
359 | () => {
360 | if (this.videoRef.current && this.state.tempList.length > 0) {
361 | // Set the initial video source
362 | this.videoRef.current.src = `/backend/streamer${this.state.currentDir}/${
363 | this.state.tempList[this.state.videoIndex]
364 | }`;
365 | this.videoRef.current.load();
366 | this.videoRef.current.play(); // Start playing the first video
367 | }
368 | }
369 | );
370 | };
371 |
372 | handleVideoEnded = () => {
373 | this.setState((prevState) => {
374 | const nextIndex = prevState.videoIndex + 1;
375 |
376 | if (nextIndex < prevState.tempLength) {
377 | // Update the video index and set the next video source
378 | this.videoRef.current.src = `/backend/streamer${this.state.currentDir}/${prevState.tempList[nextIndex]}`;
379 | this.videoRef.current.load();
380 | this.videoRef.current.play(); // Start playing the next video
381 | } else {
382 | // All videos have been played
383 | this.setState({ player: false });
384 | return { videoIndex: nextIndex };
385 | }
386 |
387 | return { videoIndex: nextIndex };
388 | });
389 | };
390 |
391 | handleStop = () => {
392 | this.setState({ player: false });
393 | };
394 |
395 | truncateString = (str, num) => {
396 | return str.length > num ? str.slice(0, num) + "..." : str;
397 | };
398 |
399 | handleSource = (e) => {
400 | this.setState({ source: e.target.value.toString() });
401 | };
402 |
403 | render() {
404 | return (
405 | 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/or a bucket name must be filled.{" "}
690 |
691 | ) : (
692 | <>>
693 | )}
694 | {this.state.isSaved ? Settings saved. : <>>}
695 |
696 |
697 | );
698 | }
699 | }
700 |
--------------------------------------------------------------------------------