├── .dockerignore ├── .gitattributes ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── Build ├── Building.md ├── ReadMeGenerator.cjs ├── app.ico └── build.js ├── Client ├── Script │ ├── AnimationHelpers.js │ ├── BulkActionCommon.js │ ├── BulkAddOverlay.js │ ├── BulkDeleteOverlay.js │ ├── BulkShiftOverlay.js │ ├── ButtonCreator.js │ ├── Chart.js │ ├── ClientDataExtensions.js │ ├── ClientSettings.js │ ├── Commands.js │ ├── Common.js │ ├── CommonUI.js │ ├── CustomEvents.js │ ├── DataAttributes.js │ ├── DateUtil.js │ ├── ErrorHandling.js │ ├── FetchError.js │ ├── FilterDialog.js │ ├── HelpOverlay.js │ ├── HelpSections.js │ ├── HtmlHelpers.js │ ├── Icons.js │ ├── LongPressHandler.js │ ├── MarkerBreakdownChart.js │ ├── MarkerEdit.js │ ├── MarkerTable.js │ ├── MarkerTableRow.js │ ├── Overlay.js │ ├── PlexClientState.js │ ├── PlexUI.js │ ├── PurgedMarkerCache.js │ ├── PurgedMarkerManager.js │ ├── ResultRow │ │ ├── BaseItemResultRow.js │ │ ├── BulkActionResultRow.js │ │ ├── EpisodeResultRow.js │ │ ├── MovieResultRow.js │ │ ├── ResultRow.js │ │ ├── SeasonResultRow.js │ │ ├── SeasonResultRowBase.js │ │ ├── SeasonTitleResultRow.js │ │ ├── SectionOptionsResultRow.js │ │ ├── ShowResultRow.js │ │ ├── ShowResultRowBase.js │ │ ├── ShowTitleResultRow.js │ │ └── index.js │ ├── ResultSections.js │ ├── SVGHelper.js │ ├── SectionOptionsOverlay.js │ ├── ServerPausedOverlay.js │ ├── ServerSettingsDialog │ │ ├── PathMappingsTable.js │ │ ├── ServerSettingsDialog.js │ │ ├── ServerSettingsDialogConstants.js │ │ ├── ServerSettingsDialogHelper.js │ │ ├── ServerSettingsTooltips.js │ │ └── index.js │ ├── StickySettings │ │ ├── BulkAddStickySettings.js │ │ ├── BulkDeleteStickySettings.js │ │ ├── BulkShiftStickySettings.js │ │ ├── MarkerAddStickySettings.js │ │ ├── StickySettingsBase.js │ │ ├── StickySettingsTypes.js │ │ └── index.js │ ├── StyleSheets.js │ ├── TableElements.js │ ├── ThemeColors.js │ ├── TimeExpression.js │ ├── TimeInput.js │ ├── TimestampThumbnails.js │ ├── Toast.js │ ├── Tooltip.js │ ├── TooltipBuilder.js │ ├── VersionManager.js │ ├── WindowResizeEventHandler.js │ ├── index.js │ └── login.js ├── Style │ ├── BulkActionOverlay.css │ ├── BulkActionOverlayDark.css │ ├── BulkActionOverlayLight.css │ ├── MarkerTable.css │ ├── Overlay.css │ ├── OverlayDark.css │ ├── OverlayLight.css │ ├── Settings.css │ ├── Tooltip.css │ ├── style.css │ ├── themeDark.css │ └── themeLight.css └── login.html ├── Dockerfile ├── LICENSE ├── README.md ├── SVG ├── arrow.svg ├── back.svg ├── badThumb.svg ├── cancel.svg ├── chapter.svg ├── confirm.svg ├── cursor.svg ├── delete.svg ├── edit.svg ├── favicon.svg ├── filter.svg ├── help.svg ├── imgIcon.svg ├── info.svg ├── loading.svg ├── logout.svg ├── noise.svg ├── pause.svg ├── restart.svg ├── settings.svg ├── table.svg └── warn.svg ├── Server ├── Authentication │ ├── AuthDatabase.js │ ├── Authentication.js │ ├── AuthenticationConstants.js │ └── SqliteSessionStore.js ├── Commands │ ├── AuthenticationCommands.js │ ├── ConfigCommands.js │ ├── CoreCommands.js │ ├── PostCommand.js │ ├── PurgeCommands.js │ └── QueryCommands.js ├── Config │ ├── AuthenticationConfig.js │ ├── ConfigBase.js │ ├── ConfigHelpers.js │ ├── FeaturesConfig.js │ ├── FirstRunConfig.js │ ├── MarkerEditorConfig.js │ └── SslConfig.js ├── FormDataParse.js ├── GETHandler.js ├── ImportExport.js ├── LegacyMarkerBreakdown.js ├── MarkerBackupManager.js ├── MarkerCacheManager.js ├── MarkerEditCache.js ├── MarkerEditor.js ├── PlexQueryManager.js ├── PostCommands.js ├── QueryParse.js ├── ServerError.js ├── ServerEvents.js ├── ServerHelpers.js ├── ServerState.js ├── SqliteDatabase.js ├── ThumbnailManager.js └── TransactionBuilder.js ├── Shared ├── ConsoleLog.js ├── DocumentProxy.js ├── MarkerBreakdown.js ├── MarkerType.js ├── PlexTypes.js ├── PostCommands.js ├── ServerConfig.js └── WindowProxy.js ├── Test ├── Test.js ├── TestBase.js ├── TestClasses │ ├── BasicCRUDTest.js │ ├── BulkAddTest.js │ ├── BulkDeleteTest.js │ ├── ChapterTest.js │ ├── ClientTests.js │ ├── ConfigTest.js │ ├── DateUtilTest.js │ ├── DeleteAllTest.js │ ├── ImageTest.js │ ├── ImportExportTest.js │ ├── MultipleMarkersTest.js │ ├── QueryTest.js │ ├── ShiftTest.js │ └── TimeExpressionTest.js ├── TestHelpers.js └── TestRunner.js ├── app.js ├── config.example.json ├── eslint.config.js ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Similar to .gitignore, but ignores broader swaths 2 | # of files not relevant to running the app itself 3 | 4 | # Dockerfile takes care of npm install 5 | node_modules 6 | 7 | # VS Code envrionment 8 | .vscode 9 | .git 10 | 11 | Backup 12 | cache 13 | dist 14 | Logs 15 | Test 16 | cache 17 | SVG/Raw 18 | npm-debug.log 19 | config.json 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.cjs text 4 | *.css text diff=css 5 | *.html text diff=html 6 | *.js text 7 | *.json text 8 | *.md text diff=markdown 9 | *.svg text 10 | *.yml text 11 | *.*rc text 12 | 13 | *.ps1 text eol=crlf 14 | 15 | # Binary files 16 | *.ico binary 17 | *.jpg binary 18 | *.jpeg binary 19 | *.png binary 20 | 21 | dist/* binary -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: danrahn 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: danrahn 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report and bug with Marker Editor 4 | title: '' 5 | labels: bug 6 | assignees: danrahn 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Ubuntu, Windows, macOS, Docker] 28 | - Browser [e.g. firefox, chrome, safari] 29 | - Version [e.g. 2.4.0] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: danrahn 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | 3 | # Ignore everything in .vscode, except launch.json 4 | # for running the program/tests in vscode. 5 | .vscode/settings.json 6 | 7 | # Ignore the backup database 8 | Backup/* 9 | 10 | # Ignore any error logs 11 | Logs/* 12 | 13 | # Ignore test generated files 14 | Test/*.json 15 | Test/**/*.db 16 | Test/**/*.db-journal 17 | 18 | # Ignore the real user config 19 | config.json 20 | 21 | # Ignore exe dist 22 | dist/* 23 | 24 | # Ignore ffmpeg-generated cached thumbnails 25 | cache/* 26 | 27 | # Ignore non-minified/FILL_COLOR-hacked icons 28 | SVG/Raw/* 29 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "dbaeumer.vscode-eslint", 8 | 9 | ], 10 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 11 | "unwantedRecommendations": [ 12 | 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch MarkerEditor", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/app.js" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "First Launch (stdin enabled)", 20 | "skipFiles": [ "/**" ], 21 | "console": "integratedTerminal", 22 | "program": "${workspaceFolder}/app.js" 23 | }, 24 | { 25 | "type" : "node", 26 | "request" : "launch", 27 | "name" : "Run Tests", 28 | "skipFiles": [ "/**" ], 29 | "args": [ "--test" ], 30 | "program": "${workspaceFolder}/Test/Test.js" 31 | }, 32 | { 33 | "type" : "node", 34 | "request" : "launch", 35 | "name" : "Run Custom Test(s)", 36 | "skipFiles": [ "/**" ], 37 | "args": [ "--test", "-tc", "[testClassName]", "-tm", "[testMethodName]" ], 38 | "program": "${workspaceFolder}/Test/Test.js" 39 | }, 40 | { 41 | "type" : "node", 42 | "request" : "launch", 43 | "name" : "Run Specific Test", 44 | "console": "integratedTerminal", 45 | "skipFiles": [ "/**" ], 46 | "args": [ "--test", "--ask-input" ], 47 | "program": "${workspaceFolder}/Test/Test.js" 48 | }, 49 | { 50 | "type" : "node", 51 | "request": "launch", 52 | "name" : "Debug Build Command", 53 | "skipFiles": [ "/**" ], 54 | "runtimeExecutable": "npm", 55 | "runtimeArgs": ["run", "build"] 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /Build/Building.md: -------------------------------------------------------------------------------- 1 | # Building the Marker Editor Package 2 | 3 | > **NOTE**: Building isn't necessary outside of preparing packages for release. Consumers should only need to download the latest pre-built [release](https://github.com/danrahn/MarkerEditorForPlex/releases), and even if a binary isn't available for a specific platform, the ["From Source"](https://github.com/danrahn/MarkerEditorForPlex/releases) instructions should allow this program to run without any manual building. 4 | 5 | The build process uses [nexe](https://github.com/nexe/nexe) to create binaries for various platforms. The simplest way to start a build is to run 6 | 7 | ```bash 8 | npm run build 9 | ``` 10 | 11 | Which will attempt to create a package based on the current system architecture and latest LTS version of Node.js. Other options are outlined below. 12 | 13 | ## Usage 14 | 15 | ``` 16 | npm run build [arch] [pack] [version [VERSION]] [verbose] 17 | 18 | arch The architecture to build for. Defaults to system architecture. 19 | Valid options are 20 | Intel 64-bit: x64, amd64, x86_64 21 | Intel 32-bit: x86, ia32 (only tested on Windows) 22 | ARM 64-bit: arm64, aarch64 23 | 24 | pack Create a zip/tgz of the required files for distribution. 25 | 26 | version [VERSION] Override the baseline version of Node.js to build. Defaults to 27 | latest LTS version. 28 | 29 | verbose Print more verbose output during the build process. 30 | ``` 31 | 32 | ## Cross-Compiling 33 | 34 | Cross-compiling (e.g. building an ARM64 package from an AMD64 machine) is possible, but does require some manual setup. Because this project relies on native modules (sqlite3), those modules must also target the correct architecture, in addition to building the node binary itself that target. To get around this, the compiled `*.node` file should be placed in an `archCache` directory inside of `dist`, following the structure, `dist/archCache/{module}-{version}-{arch}/node_{module}.node`, e.g. `dist/archCache/sqlite3-5.1.7-arm64/node_sqlite3.node`. There are several ways to potentially obtain the right `*.node` files: 35 | 36 | 1. Pre-built binaries from the module maintainer. 37 | 2. Install the module on a system running the target architecture, and copying the binary to your build system. 38 | 3. Explicitly install the "wrong" version on the current system, and copy that output to the `archCache` folder. 39 | 40 | It's likely possible to make things work without this manual setup by reinstalling sqlite3 using different `--target_arch` flags and `--build-from-source`, and copying that build output, but the above process is good enough for me, so probably won't change any time soon. -------------------------------------------------------------------------------- /Build/ReadMeGenerator.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Very simple README generator that takes a given 'recipe' and converts it into 3 | * a fixed-width document surrounded in a box. 4 | * 5 | * Completely unnecessary for its current use. This only exists because I was curious 6 | * about how easy this would be to create. */ 7 | class ReadMeGenerator { 8 | /** The maximum width of the README file (including formatting characters) */ 9 | #width = 80; 10 | /** @type {string} */ 11 | #blankLine; 12 | /** @type {string} */ 13 | #lineBreak; 14 | 15 | /** 16 | * Initialize a new generator with the given maximum width, which defaults to 80 characters. 17 | * @param {number} width */ 18 | constructor(width=80) { 19 | this.#width = width; 20 | this.#blankLine = `|${' '.repeat(this.#width - 2)}|`; 21 | this.#lineBreak = `+${'-'.repeat(this.#width - 2)}+`; 22 | } 23 | 24 | /** 25 | * Converts the given text to the fixed-width README. 26 | * Currently supported markers are: 27 | * * `~`: Insert a line break (`+---...---+`) 28 | * * `!`: Insert a blank line (`| ... |`) 29 | * * `-:-{text}`: Insert centered text (`| text |`) 30 | * * `||`: Continue from the previous line 31 | * * `{text}!!#`: Inserts a normal text line. If there's overflow, indent 32 | * subsequent lines # spaces. E.g. `LongText...!!3` would become: 33 | * ``` 34 | * | LongText... | 35 | * | Continued | 36 | * ``` 37 | * @param {string} recipe The text to convert */ 38 | parse(recipe) { 39 | const lines = []; 40 | const split = recipe.split('\n'); 41 | 42 | const merged = []; 43 | // First pass - merge any continuation lines ('||') 44 | for (const line of split) { 45 | if (line.startsWith('||')) { 46 | if (merged.length === 0) { 47 | merged.push(line.substring(2)); 48 | } else { 49 | merged[merged.length - 1] += ' ' + line.substring(2); 50 | } 51 | 52 | } else { 53 | merged.push(line); 54 | } 55 | } 56 | 57 | for (let line of merged) { 58 | if (line === '') { 59 | continue; 60 | } 61 | 62 | if (line === '~') { 63 | lines.push(this.#lineBreak); 64 | continue; 65 | } 66 | 67 | if (line === '!') { 68 | lines.push(this.#blankLine); 69 | continue; 70 | } 71 | 72 | if (line.startsWith('-:-')) { 73 | lines.push(this.#textLine(line.substring(3), -1)); 74 | continue; 75 | } 76 | 77 | if (line.startsWith('\\')) { 78 | if ((line.length > 1 && ['~', '!'].includes(line[1])) 79 | || (line.length > 2 && ['||'].includes(line.substring(1, 3)))) { 80 | line = line.substring(1); 81 | } 82 | } 83 | 84 | if (line.startsWith('\\') && line.length > 1 && ['~', '!'].includes(line[1])) { 85 | line = line.substring(1); 86 | } 87 | 88 | const match = /(?.*)!!(?\d+)$/.exec(line); 89 | if (match) { 90 | lines.push(this.#textLine(match.groups.content, parseInt(match.groups.indent))); 91 | } else { 92 | lines.push(this.#textLine(line)); 93 | } 94 | 95 | } 96 | 97 | return lines.join('\n') + '\n'; 98 | } 99 | 100 | /** 101 | * Center the given text within the specified width. 102 | * @param {string} text */ 103 | #centeredLine(text) { 104 | // If #textLine's poor splitting algorithm results in a line longer than the width, just return that 105 | if (text.length > this.#width - 4) { 106 | return `| ${text} |`; 107 | } 108 | 109 | const padLeft = ' '.repeat(this.#width / 2 - 1 - (text.length / 2) + (text.length % 2 === 1 ? 1 : 0)); 110 | const padRight = ' '.repeat(this.#width / 2 - 1 - (text.length / 2)); 111 | return `|${padLeft}${text}${padRight}|`; 112 | } 113 | 114 | /** 115 | * Format the given text line, breaking it down into multiple lines if 116 | * it's too long for the specified width. 117 | * @param {string} text 118 | * @param {number} indent The amount of spaces to indent subsequent lines that had to be split. 119 | * -1 indicates the text should be centered. */ 120 | #textLine(text, indent=0) { 121 | const lines = []; 122 | if (text.length > this.#width - 4) { 123 | const words = text.split(' '); 124 | let tmp = ''; 125 | for (const word of words) { 126 | if ((tmp + word + ' ').length > this.#width - 4) { 127 | lines.push(tmp.trimEnd()); 128 | tmp = indent === -1 ? '' : ' '.repeat(indent); 129 | } 130 | 131 | tmp += word + ' ' + (word.length === 0 ? ' ' : ''); 132 | } 133 | 134 | lines.push(tmp.trimEnd()); 135 | } else { 136 | lines.push(text.trimEnd()); 137 | } 138 | 139 | if (indent === -1) { 140 | return lines.map(line => this.#centeredLine(line)).join('\n'); 141 | } 142 | 143 | return lines.map(line => `| ${line}${' '.repeat(this.#width - 3 - line.length)}|`).join('\n'); 144 | } 145 | } 146 | 147 | module.exports = ReadMeGenerator; 148 | -------------------------------------------------------------------------------- /Build/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danrahn/MarkerEditorForPlex/ff46ba3b83b5aca03b468e3b097e6e1a5cafdc7d/Build/app.ico -------------------------------------------------------------------------------- /Client/Script/ClientDataExtensions.js: -------------------------------------------------------------------------------- 1 | import { $$ } from './HtmlHelpers.js'; 2 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 3 | 4 | import { EpisodeData, MarkerData, MovieData } from '/Shared/PlexTypes.js'; 5 | import MarkerBreakdown from '/Shared/MarkerBreakdown.js'; 6 | import MarkerTable from './MarkerTable.js'; 7 | 8 | /** @typedef {!import('/Shared/PlexTypes').PlexData} PlexData */ 9 | /** @typedef {!import('./ResultRow/EpisodeResultRow').EpisodeResultRow} EpisodeResultRow */ 10 | /** @typedef {!import('./ResultRow/MovieResultRow').MovieResultRow} MovieResultRow */ 11 | 12 | const Log = ContextualLog.Create('ClientData'); 13 | 14 | /** 15 | * @typedef {Object} BaseItemCommon 16 | * @property {number} duration The duration of this media item, in milliseconds. 17 | * @property {boolean?} hasThumbnails Whether thumbnails are available for this item. 18 | * @property {() => MarkerTable} markerTable 19 | * 20 | * @typedef {PlexData & BaseItemCommon} MediaItemWithMarkerTable Defines the fields common 21 | * between base Plex data types (Episodes and Movies). 22 | */ 23 | 24 | /** 25 | * An extension of the client/server-agnostic MovieData to include client-specific functionality (connecting with the marker table) 26 | * 27 | * NOTE: This should really be shared with ClientEpisodeData, but to do that correctly, we need multiple inheritance, which isn't 28 | * possible in JS. We want MovieData's base fields, and ClientEpisodeData wants EpisodeData's base fields, but both client 29 | * classes want client-specific methods that can't be added to the distinct base types. There are some hacks that could be 30 | * used, but I've opted to duplicate the code for now. 31 | */ 32 | class ClientMovieData extends MovieData { 33 | 34 | /** 35 | * The UI representation of the markers 36 | * @type {MarkerTable} */ 37 | #markerTable = null; 38 | 39 | /** @param {Object} [movie] */ 40 | constructor(movie) { 41 | super(movie); 42 | } 43 | 44 | /** 45 | * Creates the marker table for this movie. Note that for movies, we don't fully initialize the marker 46 | * table yet for performance reasons, only grabbing the real marker data when the user explicitly 47 | * clicks on a particular movie. 48 | * @param {MovieResultRow} parentRow The UI associated with this movie. */ 49 | createMarkerTable(parentRow) { 50 | if (this.#markerTable !== null) { 51 | // This is expected if the result has appeared in multiple search results. 52 | // Assume we're in a good state and ignore this, but reset the parent and make 53 | // sure the table is in its initial hidden state. 54 | this.#markerTable.setParent(parentRow); 55 | $$('table', this.#markerTable.table())?.classList.add('hidden'); 56 | return; 57 | } 58 | 59 | // Marker breakdown is currently overkill for movies, since it only ever has a single item inside of it. 60 | // If intros/credits are ever separated though, this will do the right thing. 61 | this.#markerTable = MarkerTable.CreateLazyInitMarkerTable(parentRow, parentRow.currentKey()); 62 | } 63 | 64 | /** 65 | * @param {SerializedMarkerData} serializedMarkers 66 | * @param {ChapterData[]} chapters The chapters (if any) associated with this movie. */ 67 | initializeMarkerTable(serializedMarkers, chapters) { 68 | if (this.#markerTable === null) { 69 | Log.error(`Can't initialize marker table if it hasn't been created yet.`); 70 | return; 71 | } 72 | 73 | const markers = []; 74 | for (const marker of serializedMarkers) { 75 | markers.push(new MarkerData().setFromJson(marker)); 76 | } 77 | 78 | this.#markerTable.lazyInit(markers, chapters); 79 | } 80 | 81 | /** @returns {MarkerTable} */ 82 | markerTable() { return this.#markerTable; } 83 | } 84 | 85 | /** 86 | * An extension of the client/server-agnostic EpisodeData to include client-specific functionality 87 | */ 88 | class ClientEpisodeData extends EpisodeData { 89 | 90 | /** 91 | * The UI representation of the markers 92 | * @type {MarkerTable} */ 93 | #markerTable = null; 94 | 95 | /** @param {Object} [episode] */ 96 | constructor(episode) { 97 | super(episode); 98 | } 99 | 100 | /** 101 | * Creates the marker table for this episode. 102 | * @param {EpisodeResultRow} parentRow The UI associated with this episode. 103 | * @param {SerializedMarkerData[]} serializedMarkers Map of episode ids to an array of 104 | * serialized {@linkcode MarkerData} for the episode. 105 | * @param {ChapterData[]} chapters Chapter data for this episode. */ 106 | createMarkerTable(parentRow, serializedMarkers, chapters) { 107 | if (this.#markerTable !== null) { 108 | Log.warn('The marker table already exists, we shouldn\'t be creating a new one!'); 109 | } 110 | 111 | const markers = []; 112 | for (const marker of serializedMarkers) { 113 | markers.push(new MarkerData().setFromJson(marker)); 114 | } 115 | 116 | parentRow.setCurrentKey(markers.reduce((acc, marker) => acc + MarkerBreakdown.deltaFromType(1, marker.markerType), 0)); 117 | this.#markerTable = MarkerTable.CreateMarkerTable(markers, parentRow, chapters); 118 | } 119 | 120 | /** @returns {MarkerTable} */ 121 | markerTable() { return this.#markerTable; } 122 | } 123 | 124 | export { ClientEpisodeData, ClientMovieData }; 125 | -------------------------------------------------------------------------------- /Client/Script/CommonUI.js: -------------------------------------------------------------------------------- 1 | import { $append, $checkbox, $div, $label } from './HtmlHelpers.js'; 2 | import { BaseLog } from '/Shared/ConsoleLog.js'; 3 | 4 | /** 5 | * Creates a themed checkbox 6 | * @param {{[attribute: string]: string}} [attrs] Attributes to apply to the element (e.g. class, id, or custom attributes). 7 | * @param {{[event: string]: EventListener|EventListener[]}} [events] Map of events to attach to the element. 8 | * @param {{[property: string]: any}} [labelProps] Properties to apply to the label masquerading as the checkbox. */ 9 | export function customCheckbox(attrs={}, events={}, labelProps={}, options={}) { 10 | BaseLog.assert(!attrs.type, `customCheckbox attributes shouldn't include "type"`); 11 | const checkedAttr = Object.prototype.hasOwnProperty.call(attrs, 'checked'); 12 | let shouldCheck = false; 13 | if (checkedAttr) { 14 | shouldCheck = attrs.checked; 15 | delete attrs.checked; 16 | } 17 | 18 | const checkbox = $checkbox(attrs, events, options); 19 | if (shouldCheck) { 20 | checkbox.checked = true; 21 | } 22 | 23 | // This is the "real" checkbox that can be styled however we see fit, unlike standard checkboxes. 24 | const label = $label(null, checkbox.getAttribute('id'), { class : 'customCheckbox' }); 25 | for (const [key, value] of Object.entries(labelProps)) { 26 | if (key === 'class') { 27 | value.split(' ').forEach(c => label.classList.add(c)); 28 | } else { 29 | label.setAttribute(key, value); 30 | } 31 | } 32 | 33 | return $div({ class : 'customCheckboxContainer' }, 34 | $append($div({ class : 'customCheckboxInnerContainer noSelect' }), checkbox, label) 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /Client/Script/CustomEvents.js: -------------------------------------------------------------------------------- 1 | /** 2 | * List of Marker Editor's custom events. */ 3 | export const CustomEvents = { 4 | /** @readonly Triggered when the stickiness setting changes. */ 5 | StickySettingsChanged : 'stickySettingsChanged', 6 | /** @readonly Triggered when we attempt to reach out to the server when it's paused. */ 7 | ServerPaused : 'serverPaused', 8 | /** @readonly The user committed changes to client settings. */ 9 | ClientSettingsApplied : 'clientSettingsApplied', 10 | /** @readonly The user applied new filter settings. */ 11 | MarkerFilterApplied : 'markerFilterApplied', 12 | /** @readonly The active UI section changed or was cleared. */ 13 | UISectionChanged : 'uiSectionChanged', 14 | /** @readonly New purged markers were found. */ 15 | PurgedMarkersChanged : 'purgedMarkersChanged', 16 | }; 17 | -------------------------------------------------------------------------------- /Client/Script/DataAttributes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * List of custom data attributes that we can add to element. */ 3 | export const Attributes = { 4 | /** @readonly Associate a metadata id with an element. */ 5 | MetadataId : 'data-metadata-id', 6 | /** @readonly Sets whether this element represents the start or end of a chapter. */ 7 | ChapterFn : 'data-chapterFn', 8 | /** @readonly Indicates that this element can be focused to when navigating result rows. */ 9 | TableNav : 'data-nav-target', 10 | /** @readonly Used to indicate that bulk add is updating internal state. */ 11 | BulkAddUpdating : 'data-switching-episode', 12 | /** @readonly Holds the resolve type message for bulk shift operations. */ 13 | BulkShiftResolveMessage : 'data-shift-resolve-message', 14 | /** @readonly Indicates that a button should use the default tooltip. */ 15 | UseDefaultTooltip : 'data-default-tooltip', 16 | /** @readonly The internal id used to match elements to their tooltip. */ 17 | TooltipId : 'data-tt-id', 18 | /** @readonly Indicates whether the overlay can be dismissed by the user. */ 19 | OverlayDismissible : 'data-dismissible', 20 | /** @readonly Library type of a library in the selection dropdown. */ 21 | LibraryType : `data-lib-type`, 22 | /** @readonly Data attribute for an animation property reset flag. */ 23 | PropReset : prop => `data-${prop}-reset`, 24 | }; 25 | 26 | export const TableNavDelete = 'delete'; 27 | -------------------------------------------------------------------------------- /Client/Script/DateUtil.js: -------------------------------------------------------------------------------- 1 | import { isSmallScreen } from './WindowResizeEventHandler.js'; 2 | import { plural } from './Common.js'; 3 | 4 | /** 5 | * Pretty-print date functions. 6 | * 7 | * Adapted from PlexWeb/script/DateUtil.js 8 | */ 9 | 10 | const Timespans = { 11 | Second : { long : 'second', short : 's' }, 12 | Minute : { long : 'minute', short : 'm' }, 13 | Hour : { long : 'hour', short : 'h' }, 14 | Day : { long : 'day', short : 'd' }, 15 | Week : { long : 'week', short : 'w' }, 16 | Month : { long : 'month', short : 'mo' }, 17 | Year : { long : 'year', short : 'y' }, 18 | Invalid : { long : '???', short : '?' }, 19 | }; 20 | 21 | /** 22 | * Helper that returns "In X" or "X ago" depending on whether the given value 23 | * is in the past or the future. 24 | * @param {string} val The user-friendly timespan 25 | * @param {boolean} isFuture */ 26 | function tense(val, isFuture) { return isFuture ? `In ${val}` : `${val} ago`; } 27 | 28 | /** 29 | * Get the display text for the given count of str. 30 | * For desktop screens, this will be something like '2 weeks'. 31 | * For small screens, this weill be something like '2w'. 32 | * @param {number} count 33 | * @param {keyof Timespans} timespan */ 34 | function getText(count, timespan) { 35 | const ts = Timespans[timespan] || Timespans.Invalid; 36 | return isSmallScreen() ? count + ts.short : plural(count, ts.long); 37 | } 38 | 39 | /** 40 | * Determine if the given value meets our cutoff criteria. 41 | * @param {number} value The value to test. 42 | * @param {number} cutoff The cutoff for the given value. 43 | * @param {string} stringVal The time unit that's being tested (minute, hour, day, etc). 44 | * @returns 'value stringVal(s) ago' if `value` exceeds `cutoff`, otherwise an empty string. */ 45 | function checkDate(value, cutoff, stringVal) { 46 | const abs = Math.abs(value); 47 | if (abs >= cutoff) { 48 | return false; 49 | } 50 | 51 | const count = Math.floor(abs); 52 | // Really shouldn't be happening, but handle future dates 53 | return tense(getText(count, stringVal), value < 0); 54 | } 55 | 56 | 57 | /** 58 | * Get a "pretty-print" string for the given date, e.g. "2 hours ago" or "5 years ago". 59 | * @param {Date|string} date A Date object, or a string that represents a date. 60 | * @returns {string} A string of the form "X [time units] ago" (or "In X [time units]" for future dates) */ 61 | export function getDisplayDate(date) { 62 | if (!(date instanceof Date)) { 63 | date = new Date(date); 64 | } 65 | 66 | const now = new Date(); 67 | let dateDiff = now - date; 68 | if (Math.abs(dateDiff) < 15000) { 69 | return 'Just Now'; 70 | } 71 | 72 | const underTwoWeeks = checkDate(dateDiff /= 1000, 60, 'Second') 73 | || checkDate(dateDiff /= 60, 60, 'Minute') 74 | || checkDate(dateDiff /= 60, 24, 'Hour') 75 | || checkDate(dateDiff /= 24, 14, 'Day'); 76 | 77 | if (underTwoWeeks) { 78 | return underTwoWeeks; 79 | } 80 | 81 | const isFuture = dateDiff < 0; 82 | dateDiff = Math.abs(dateDiff); 83 | 84 | if (dateDiff <= 29) { 85 | const weeks = Math.floor(dateDiff / 7); 86 | return tense(getText(weeks, 'Week'), isFuture); 87 | } 88 | 89 | if (dateDiff < 365) { 90 | const months = Math.round(dateDiff / 30.4); // "X / 30" is a bit more more natural than "May 1 - March 30 = 2 months" 91 | return tense(getText(months || 1, 'Month'), isFuture); 92 | } 93 | 94 | return tense(getText(Math.abs(now.getFullYear() - date.getFullYear()), 'Year'), isFuture); 95 | } 96 | 97 | /** 98 | * Get the long form of the given date. 99 | * @param {Date|string} date A Date object, or a string that represents a date. 100 | * @returns {string} The full date, 'Month d, yyyy, h:.. [AM|PM]' */ 101 | export function getFullDate(date) { 102 | if (!(date instanceof Date)) { 103 | date = new Date(date); 104 | } 105 | 106 | const fullDateOptions = { 107 | month : 'long', 108 | day : 'numeric', 109 | year : 'numeric', 110 | hour : 'numeric', 111 | minute : 'numeric' 112 | }; 113 | 114 | // TODO: Localization? 115 | return date.toLocaleDateString('en-US', fullDateOptions); 116 | } 117 | -------------------------------------------------------------------------------- /Client/Script/ErrorHandling.js: -------------------------------------------------------------------------------- 1 | import { $append, $br, $div } from './HtmlHelpers.js'; 2 | import { Toast, ToastType } from './Toast.js'; 3 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 4 | import FetchError from './FetchError.js'; 5 | import Overlay from './Overlay.js'; 6 | 7 | const Log = ContextualLog.Create('ErrorHandling'); 8 | 9 | /** 10 | * Displays an overlay for the given error 11 | * @param {string} message 12 | * @param {Error|string} err 13 | * @param {() => void} [onDismiss=Overlay.dismiss] */ 14 | export function errorResponseOverlay(message, err, onDismiss = Overlay.dismiss) { 15 | const errType = err instanceof FetchError ? 'Server Message' : 'Error'; 16 | Overlay.show( 17 | $append( 18 | $div(), 19 | message, 20 | $br(), $br(), 21 | errType + ':', 22 | $br(), 23 | errorMessage(err)), 24 | 'OK', 25 | onDismiss); 26 | } 27 | 28 | 29 | /** 30 | * Displays an error message in the top-left of the screen for a couple seconds. 31 | * @param {string|HTMLElement} message 32 | * @param {number} duration The timeout in ms. */ 33 | export function errorToast(message, duration=2500) { 34 | return new Toast(ToastType.Error, message).showSimple(duration); 35 | } 36 | 37 | /** 38 | * Return an error string from the given error. 39 | * In almost all cases, `error` will be either a JSON object with a single `Error` field, 40 | * or an exception of type {@link Error}. Handle both of those cases, otherwise return a 41 | * generic error message. 42 | * 43 | * NOTE: It's expected that all API requests call this on failure, as it's the main console 44 | * logging method. 45 | * @param {string|Error} error 46 | * @returns {string} */ 47 | export function errorMessage(error) { 48 | if (error.Error) { 49 | Log.error(error); 50 | return error.Error; 51 | } 52 | 53 | if (error instanceof Error) { 54 | Log.error(error.message); 55 | Log.error(error.stack ? error.stack : '(Unknown stack)'); 56 | 57 | if (error instanceof TypeError && error.message === 'Failed to fetch') { 58 | // Special handling of what's likely a server-side exit. 59 | return error.toString() + '

The server may have exited unexpectedly, please check the console.'; 60 | } 61 | 62 | return error.toString(); 63 | } 64 | 65 | if (typeof error === 'string') { 66 | return error; 67 | } 68 | 69 | return 'I don\'t know what went wrong, sorry :('; 70 | } 71 | 72 | -------------------------------------------------------------------------------- /Client/Script/FetchError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom error class used to distinguish between errors 3 | * surfaced by an API call and all others. */ 4 | export default class FetchError extends Error { 5 | /** 6 | * @param {string} message 7 | * @param {string} stack */ 8 | constructor(message, stack) { 9 | super(message); 10 | if (stack) { 11 | this.stack = stack; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Client/Script/HelpOverlay.js: -------------------------------------------------------------------------------- 1 | import { $, $a, $append, $div, $h, $hr, $p } from './HtmlHelpers.js'; 2 | import { clickOnEnterCallback } from './Common.js'; 3 | 4 | import Overlay from './Overlay.js'; 5 | 6 | import { HelpSection, HelpSections } from './HelpSections.js'; 7 | import ButtonCreator from './ButtonCreator.js'; 8 | import { ThemeColors } from './ThemeColors.js'; 9 | 10 | 11 | // Only create once we actually need it, but only create it once. 12 | /** @type {HTMLElement} */ 13 | let helpText; 14 | 15 | const getText = () => helpText ??= $append( 16 | $div({ id : 'helpOverlayHolder' }), 17 | $append($div({ id : 'helpMain' }), 18 | $h(1, 'Welcome to Marker Editor for Plex'), 19 | $append($p(), 20 | 'For full configuration and usage instructions, see the ', 21 | $a('wiki on GitHub', 'https://github.com/danrahn/MarkerEditorForPlex/wiki')) 22 | ), 23 | $hr(), 24 | HelpSections.Get(HelpSection.TimeInput), 25 | $hr(), 26 | HelpSections.Get(HelpSection.KeyboardNavigation), 27 | HelpSections.Get(HelpSection.Disclaimer), 28 | ButtonCreator.fullButton('OK', 'confirm', ThemeColors.Green, Overlay.dismiss, { class : 'okButton' }) 29 | ); 30 | 31 | class HelpOverlay { 32 | static #setup = false; 33 | static #btn = $('#helpContainer'); 34 | static ShowHelpOverlay() { 35 | // Note: don't call HelpSections.Reset here, because we want to keep the 36 | // expand/collapsed state for the main help overlay. 37 | Overlay.build( 38 | { closeButton : true, 39 | dismissible : true, 40 | forceFullscreen : true, 41 | focusBack : HelpOverlay.#btn 42 | }, getText()); 43 | } 44 | 45 | static SetupHelperListeners() { 46 | if (HelpOverlay.#setup) { 47 | return; 48 | } 49 | 50 | HelpOverlay.#btn.addEventListener('click', HelpOverlay.ShowHelpOverlay); 51 | HelpOverlay.#btn.addEventListener('keydown', clickOnEnterCallback); 52 | } 53 | } 54 | 55 | export default HelpOverlay; 56 | -------------------------------------------------------------------------------- /Client/Script/Icons.js: -------------------------------------------------------------------------------- 1 | /** All available icons. */ 2 | const Icons = { 3 | /** @readonly A triangle pointing right. */ 4 | Arrow : 'arrow', 5 | /** @readonly A curved arrow pointing left. */ 6 | Back : 'back', 7 | /** @readonly Circle with an X in the middle. */ 8 | Cancel : 'cancel', 9 | /** @readonly Book with a chapter marker. */ 10 | Chapter : 'chapter', 11 | /** @readonly Rounded square with a check mark in the middle. */ 12 | Confirm : 'confirm', 13 | /** @readonly A text cursor. */ 14 | Cursor : 'cursor', 15 | /** @readonly A trash cah. */ 16 | Delete : 'delete', 17 | /** @readonly A pencil. */ 18 | Edit : 'edit', 19 | /** @readonly A funnel with up/down arrows to indicate sorting. */ 20 | Filter : 'filter', 21 | /** @readonly A circle with a question mark in the middle. */ 22 | Help : 'help', 23 | /** @readonly A rectangular picture icon. */ 24 | Img : 'imgIcon', 25 | /** @readonly A circle with an 'i' in the middle. */ 26 | Info : 'info', 27 | /** @readonly A spinning circle. */ 28 | Loading : 'loading', 29 | /** @readonly A circle with a pause button in the middle. */ 30 | Pause : 'pause', 31 | /** @readonly A circular arrow. */ 32 | Restart : 'restart', 33 | /** @readonly A settings cog. */ 34 | Settings : 'settings', 35 | /** @readonly A 2x3 grid with a slightly colored header row. */ 36 | Table : 'table', 37 | /** @readonly A triangle with an exclamation point in the middle. */ 38 | Warn : 'warn', 39 | /** @readonly A logout icon - an arrow peeking out of a rectangle. */ 40 | Logout : 'logout', 41 | }; 42 | 43 | /** 44 | * @typedef {{ 45 | * arrow : 'Arrow', 46 | * back : 'Back', 47 | * cancel : 'Cancel', 48 | * chapter : 'Chapter', 49 | * confirm : 'Confirm', 50 | * cursor : 'Cursor', 51 | * delete : 'Delete', 52 | * edit : 'Edit', 53 | * filter : 'Filter', 54 | * help : 'Help', 55 | * imgIcon : 'Img', 56 | * info : 'Info', 57 | * loading : 'Loading', 58 | * pause : 'Pause', 59 | * restart : 'Restart', 60 | * settings : 'Settings', 61 | * table : 'Table', 62 | * warn : 'Warn', 63 | * logout : 'Logout', 64 | * }} IconKeys 65 | * */ 66 | 67 | export default Icons; 68 | -------------------------------------------------------------------------------- /Client/Script/LongPressHandler.js: -------------------------------------------------------------------------------- 1 | 2 | /** @typedef {!import('./Common').CustomEventCallback} CustomEventCallback */ 3 | 4 | /** 5 | * Data to keep track of touch events. 6 | */ 7 | class TouchData { 8 | /** @type {EventTarget} */ 9 | target = null; 10 | /** The screen x/y during the first touch */ 11 | startCoords = { x : 0, y : 0 }; 12 | /** The current touch coordinates, updated after every touchmove. */ 13 | currentCoords = { x : 0, y : 0 }; 14 | /** Timer set after a touchstart */ 15 | timer = 0; 16 | /** Clear out any existing touch data. */ 17 | clear() { 18 | this.target = null; 19 | this.startCoords = { x : 0, y : 0 }; 20 | this.currentCoords = { x : 0, y : 0 }; 21 | if (this.timer) { 22 | clearTimeout(this.timer); 23 | } 24 | } 25 | } 26 | 27 | /** List of events we want to listen to. */ 28 | const events = [ 'touchstart', 'touchmove', 'touchend' ]; 29 | 30 | /** 31 | * Handles a single element's "longpress" listener, triggering a callback if 32 | * the user has a single press for one second. 33 | */ 34 | class LongPressHandler { 35 | /** @type {TouchData} */ 36 | #touches; 37 | 38 | /** @type {CustomEventCallback} */ 39 | #callback; 40 | 41 | /** 42 | * @param {HTMLElement} element 43 | * @param {CustomEventCallback} callback */ 44 | constructor(element, callback) { 45 | this.#callback = callback; 46 | this.#touches = new TouchData(); 47 | for (const event of events) { 48 | element.addEventListener(event, this.#handleTouch.bind(this), { passive : true }); 49 | } 50 | } 51 | 52 | /** 53 | * @param {TouchEvent} e */ 54 | #handleTouch(e) { 55 | switch (e.type) { 56 | default: 57 | return; 58 | case 'touchstart': 59 | if (e.touches.length !== 1) { 60 | this.#touches.clear(); 61 | return; 62 | } 63 | 64 | this.#touches.target = e.target; 65 | this.#touches.startCoords = { x : e.touches[0].clientX, y : e.touches[0].clientY }; 66 | this.#touches.currentCoords = { x : e.touches[0].clientX, y : e.touches[0].clientY }; 67 | this.#touches.timer = setTimeout(this.#checkCurrentTouch.bind(this), 1000); 68 | break; 69 | case 'touchmove': 70 | if (!this.#touches.timer || e.touches.length !== 1) { 71 | this.#touches.clear(); 72 | return; 73 | } 74 | 75 | this.#touches.currentCoords = { x : e.touches[0].clientX, y : e.touches[0].clientY }; 76 | break; 77 | case 'touchend': 78 | this.#touches.clear(); 79 | break; 80 | } 81 | } 82 | 83 | /** 84 | * Triggered one second after the first touch, if touchend hasn't been fired. 85 | * If our final touch point isn't too far away from the initial point, trigger the callback. */ 86 | #checkCurrentTouch() { 87 | const diffX = Math.abs(this.#touches.currentCoords.x - this.#touches.startCoords.x); 88 | const diffY = Math.abs(this.#touches.currentCoords.y - this.#touches.startCoords.y); 89 | 90 | // Allow a bit more horizontal leeway than vertical. 91 | if (diffX < 20 && diffY < 10) { 92 | this.#callback(this.#touches.target); 93 | } 94 | 95 | this.#touches.clear(); 96 | } 97 | } 98 | 99 | /** 100 | * Add a "longpress" listener to the given element, triggering the callback if a 101 | * single touch lasts for one second and hasn't moved from the original touch point. 102 | * @param {HTMLElement} element 103 | * @param {CustomEventCallback} callback */ 104 | export function addLongPressListener(element, callback) { 105 | new LongPressHandler(element, callback); 106 | } 107 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/BulkActionResultRow.js: -------------------------------------------------------------------------------- 1 | import { ResultRow } from './ResultRow.js'; 2 | 3 | import { $append, $div } from '../HtmlHelpers.js'; 4 | import { Attributes } from '../DataAttributes.js'; 5 | import BulkAddOverlay from '../BulkAddOverlay.js'; 6 | import BulkDeleteOverlay from '../BulkDeleteOverlay.js'; 7 | import BulkShiftOverlay from '../BulkShiftOverlay.js'; 8 | import ButtonCreator from '../ButtonCreator.js'; 9 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 10 | 11 | const Log = ContextualLog.Create('BulkActionRow'); 12 | 13 | /** 14 | * A result row that offers bulk marker actions, like shifting everything X milliseconds. 15 | */ 16 | export class BulkActionResultRow extends ResultRow { 17 | /** @type {HTMLElement} */ 18 | #bulkAddButton; 19 | /** @type {HTMLElement} */ 20 | #bulkShiftButton; 21 | /** @type {HTMLElement} */ 22 | #bulkDeleteButton; 23 | constructor(mediaItem) { 24 | super(mediaItem, 'bulkResultRow'); 25 | } 26 | 27 | /** 28 | * Build the bulk result row, returning the row */ 29 | buildRow() { 30 | if (this.html()) { 31 | Log.warn(`buildRow has already been called for this BulkActionResultRow, that shouldn't happen!`); 32 | return this.html(); 33 | } 34 | 35 | const titleNode = $div({ class : 'bulkActionTitle' }, 'Bulk Actions'); 36 | const row = $div({ class : 'resultRow bulkResultRow' }, 0, { keydown : this.onRowKeydown.bind(this) }); 37 | this.#bulkAddButton = ButtonCreator.textButton( 38 | 'Bulk Add', this.#bulkAdd.bind(this), { style : 'margin-right: 10px', [Attributes.TableNav] : 'bulk-add' }); 39 | this.#bulkShiftButton = ButtonCreator.textButton( 40 | 'Bulk Shift', this.#bulkShift.bind(this), { style : 'margin-right: 10px', [Attributes.TableNav] : 'bulk-shift' }); 41 | this.#bulkDeleteButton = ButtonCreator.textButton( 42 | 'Bulk Delete', this.#bulkDelete.bind(this), { [Attributes.TableNav] : 'bulk-delete' }); 43 | $append(row, 44 | titleNode, 45 | $append(row.appendChild($div({ class : 'goBack' })), 46 | this.#bulkAddButton, 47 | this.#bulkShiftButton, 48 | this.#bulkDeleteButton)); 49 | 50 | this.setHtml(row); 51 | return row; 52 | } 53 | 54 | // Override default behavior and don't show anything here, since we override this with our own actions. 55 | episodeDisplay() { } 56 | 57 | /** 58 | * Launch the bulk add overlay for the current media item (show/season). */ 59 | #bulkAdd() { 60 | new BulkAddOverlay(this.mediaItem()).show(this.#bulkAddButton); 61 | } 62 | 63 | /** 64 | * Launch the bulk shift overlay for the current media item (show/season). */ 65 | #bulkShift() { 66 | new BulkShiftOverlay(this.mediaItem()).show(this.#bulkShiftButton); 67 | } 68 | 69 | /** 70 | * Launch the bulk delete overlay for the current media item (show/season). */ 71 | #bulkDelete() { 72 | new BulkDeleteOverlay(this.mediaItem()).show(this.#bulkDeleteButton); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/SeasonResultRowBase.js: -------------------------------------------------------------------------------- 1 | import { ResultRow } from './ResultRow.js'; 2 | 3 | import { $$, $div, $span } from '../HtmlHelpers.js'; 4 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 5 | import { PurgedMarkers } from '../PurgedMarkerManager.js'; 6 | 7 | /** @typedef {!import('/Shared/PlexTypes').SeasonData} SeasonData */ 8 | 9 | 10 | const Log = ContextualLog.Create('SeasonRowBase'); 11 | 12 | export class SeasonResultRowBase extends ResultRow { 13 | 14 | /** @param {SeasonData} season */ 15 | constructor(season) { 16 | super(season, 'seasonResult'); 17 | } 18 | 19 | /** Whether this row is a placeholder title row, used when a specific season is selected. */ 20 | titleRow() { return false; } 21 | 22 | /** 23 | * Return the underlying season data associated with this result row. 24 | * @returns {SeasonData} */ 25 | season() { return this.mediaItem(); } 26 | 27 | onClick() { return null; } 28 | 29 | /** 30 | * Creates a DOM element for this season. */ 31 | buildRow() { 32 | if (this.html()) { 33 | Log.warn('buildRow has already been called for this SeasonResultRow, that shouldn\'t happen'); 34 | return this.html(); 35 | } 36 | 37 | const season = this.season(); 38 | const title = $div({ class : 'selectedSeasonTitle' }, $span(`Season ${season.index}`)); 39 | if (season.title.length > 0 && season.title.toLowerCase() !== `season ${season.index}`) { 40 | title.appendChild($span(` (${season.title})`, { class : 'resultRowAltTitle' })); 41 | } 42 | 43 | const row = this.buildRowColumns(title, null, this.onClick()); 44 | this.setHtml(row); 45 | return row; 46 | } 47 | 48 | /** 49 | * Returns the callback invoked when clicking on the marker count when purged markers are present. */ 50 | getPurgeEventListener() { 51 | return this.#onSeasonPurgeClick.bind(this); 52 | } 53 | 54 | /** 55 | * Show the purge overlay for this season. 56 | * @param {MouseEvent} e */ 57 | #onSeasonPurgeClick(e) { 58 | if (this.isInfoIcon(e.target)) { 59 | return; 60 | } 61 | 62 | // For dummy rows, set focus back to the first tabbable row, as the purged icon might not exist anymore 63 | const focusBack = this.titleRow() ? $$('.tabbableRow', this.html().parentElement) : this.html(); 64 | PurgedMarkers.showSingleSeason(this.season().metadataId, focusBack); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/SeasonTitleResultRow.js: -------------------------------------------------------------------------------- 1 | import { SeasonResultRowBase } from './SeasonResultRowBase.js'; 2 | 3 | import { UISection, UISections } from '../ResultSections.js'; 4 | 5 | export class SeasonTitleResultRow extends SeasonResultRowBase { 6 | 7 | /** @param {SeasonData} season */ 8 | constructor(season) { 9 | super(season, 'seasonResult'); 10 | } 11 | 12 | titleRow() { return true; } 13 | 14 | /** 15 | * Build this placeholder row. Take the bases row and adds a 'back' button */ 16 | buildRow() { 17 | if (this.html()) { 18 | // Extra data has already been added, and super.buildRow accounts for this, and gives us some warning logging. 19 | return super.buildRow(); 20 | } 21 | 22 | const row = super.buildRow(); 23 | this.addBackButton(row, 'Back to seasons', async () => { 24 | await UISections.hideSections(UISection.Episodes); 25 | UISections.clearSections(UISection.Episodes); 26 | UISections.showSections(UISection.Seasons); 27 | }); 28 | 29 | row.classList.add('dynamicText'); 30 | return row; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/SectionOptionsResultRow.js: -------------------------------------------------------------------------------- 1 | import { ResultRow } from './ResultRow.js'; 2 | 3 | import { $append, $div } from '../HtmlHelpers.js'; 4 | import { FilterDialog, FilterSettings } from '../FilterDialog.js'; 5 | import { Attributes } from '../DataAttributes.js'; 6 | import ButtonCreator from '../ButtonCreator.js'; 7 | import { ClientSettings } from '../ClientSettings.js'; 8 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 9 | import Icons from '../Icons.js'; 10 | import { PlexClientState } from '../PlexClientState.js'; 11 | import { PurgedMarkers } from '../PurgedMarkerManager.js'; 12 | import SectionOptionsOverlay from '../SectionOptionsOverlay.js'; 13 | import { ThemeColors } from '../ThemeColors.js'; 14 | import { toggleVisibility } from '../Common.js'; 15 | import Tooltip from '../Tooltip.js'; 16 | 17 | const Log = ContextualLog.Create('SectionOptionsRow'); 18 | 19 | /** 20 | * A section-wide header that is displayed no matter what the current view state is (beside the blank state). 21 | * Currently only contains the Filter entrypoint. 22 | */ 23 | export class SectionOptionsResultRow extends ResultRow { 24 | /** @type {HTMLElement} */ 25 | #purgeButton; 26 | /** @type {HTMLElement} */ 27 | #filterButton; 28 | /** @type {HTMLElement} */ 29 | #moreOptionsButton; 30 | constructor() { 31 | super(null, 'topLevelResult sectionOptions'); 32 | } 33 | 34 | /** 35 | * Build the section-wide header. */ 36 | buildRow() { 37 | if (this.html()) { 38 | Log.warn(`buildRow has already been called for this SectionOptionsResultRow, that shouldn't happen!`); 39 | return this.html(); 40 | } 41 | 42 | const titleNode = $div({ class : 'bulkActionTitle' }, 'Section Options'); 43 | const row = $div({ class : 'sectionOptionsResultRow' }, 0, { keydown : this.onRowKeydown.bind(this) }); 44 | 45 | this.#purgeButton = ButtonCreator.dynamicButton( 46 | 'Purged Markers Found', Icons.Warn, ThemeColors.Orange, () => PurgedMarkers.showCurrentSection(this.#purgeButton), 47 | { class : 'hidden', style : 'margin-right: 10px', [Attributes.TableNav] : 'section-purges' }); 48 | this.#checkForPurges(); 49 | 50 | this.#filterButton = ButtonCreator.fullButton('Sort/Filter', 51 | Icons.Filter, 52 | ThemeColors.Primary, 53 | function (_e, self) { new FilterDialog(PlexClientState.activeSectionType()).show(self); }, 54 | { class : 'filterBtn', style : 'margin-right: 10px', [Attributes.TableNav] : 'sort-filter' }); 55 | Tooltip.setTooltip(this.#filterButton, 'No Active Filter'); // Need to seed the setTooltip, then use setText for everything else. 56 | this.updateFilterTooltip(); 57 | if (!ClientSettings.showExtendedMarkerInfo()) { 58 | this.#filterButton.classList.add('hidden'); 59 | } 60 | 61 | this.#moreOptionsButton = ButtonCreator.fullButton( 62 | 'More...', 63 | Icons.Settings, 64 | ThemeColors.Primary, 65 | function (_e, self) { new SectionOptionsOverlay().show(self); }, 66 | { class : 'moreSectionOptionsBtn', [Attributes.TableNav] : 'more-options' }); 67 | 68 | $append(row, 69 | titleNode, 70 | $append( 71 | row.appendChild($div({ class : 'goBack' })), 72 | this.#purgeButton, 73 | this.#filterButton, 74 | this.#moreOptionsButton)); 75 | this.setHtml(row); 76 | return row; 77 | } 78 | 79 | /** 80 | * If extended marker statistics are enabled server-side, check if there are any 81 | * purged markers for this section, then update the visibility of the purge button 82 | * based on the result. */ 83 | async #checkForPurges() { 84 | // We only grab section-wide purges when extended marker stats are enabled, 85 | // but we might still have section purges based on the show-level purges 86 | // that we gather during normal app usage. 87 | if (!ClientSettings.extendedMarkerStatsBlocked()) { 88 | await PurgedMarkers.findPurgedMarkers(true /*dryRun*/); 89 | } 90 | 91 | this.updatePurgeDisplay(); 92 | } 93 | 94 | /** 95 | * Show/hide the 'purged markers found' button based on the number of purges found in this section. */ 96 | updatePurgeDisplay() { 97 | toggleVisibility(this.#purgeButton, PurgedMarkers.getSectionPurgeCount() > 0); 98 | } 99 | 100 | /** 101 | * Update the filter button's style and tooltip based on whether a filter is currently active. */ 102 | updateFilterTooltip() { 103 | if (FilterSettings.hasFilter()) { 104 | this.#filterButton.classList.add('filterActive'); 105 | Tooltip.setText(this.#filterButton, FilterSettings.filterTooltipText()); 106 | } else { 107 | this.#filterButton.classList.remove('filterActive'); 108 | Tooltip.setText(this.#filterButton, 'No Active Filter'); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/ShowResultRowBase.js: -------------------------------------------------------------------------------- 1 | import { ResultRow } from './ResultRow.js'; 2 | 3 | import { $$, $div, $span } from '../HtmlHelpers.js'; 4 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 5 | import { plural } from '../Common.js'; 6 | import { PurgedMarkers } from '../PurgedMarkerManager.js'; 7 | 8 | const Log = ContextualLog.Create('ShowRowBase'); 9 | 10 | /** 11 | * Base class for a show result row, either a "real" one or a title placeholder. 12 | */ 13 | export class ShowResultRowBase extends ResultRow { 14 | 15 | /** @param {ShowData} show */ 16 | constructor(show) { 17 | super(show, 'topLevelResult showResult'); 18 | } 19 | 20 | /** Whether this row is a placeholder title row, used when a specific show/season is selected. */ 21 | titleRow() { return false; } 22 | 23 | /** 24 | * Return the underlying show data associated with this result row. 25 | * @returns {ShowData} */ 26 | show() { return this.mediaItem(); } 27 | 28 | /** 29 | * Callback to invoke when the row is clicked. 30 | * @returns {(e: MouseEvent) => any|null} */ 31 | onClick() { return null; } 32 | 33 | /** 34 | * Creates a DOM element for this show. 35 | * Each entry contains three columns - the show name, the number of seasons, and the number of episodes. */ 36 | buildRow() { 37 | if (this.html()) { 38 | Log.warn('buildRow has already been called for this ShowResultRow, that shouldn\'t happen'); 39 | return this.html(); 40 | } 41 | 42 | const show = this.show(); 43 | const titleNode = $div({}, show.title); 44 | if (show.originalTitle) { 45 | titleNode.appendChild($span(` (${show.originalTitle})`, { class : 'resultRowAltTitle' })); 46 | } 47 | 48 | const customColumn = $div({ class : 'showResultSeasons' }, plural(show.seasonCount, 'Season')); 49 | const row = this.buildRowColumns(titleNode, customColumn, this.onClick()); 50 | 51 | this.setHtml(row); 52 | return row; 53 | } 54 | 55 | /** 56 | * Returns the callback invoked when clicking on the marker count when purged markers are present. */ 57 | getPurgeEventListener() { 58 | return this.#onShowPurgeClick.bind(this); 59 | } 60 | 61 | /** 62 | * Launches the purge overlay for this show. 63 | * @param {MouseEvent} e */ 64 | #onShowPurgeClick(e) { 65 | if (this.isInfoIcon(e.target)) { 66 | return; 67 | } 68 | 69 | // For dummy rows, set focus back to the first tabbable row, as the purged icon might not exist anymore 70 | const focusBack = this.titleRow() ? $$('.tabbableRow', this.html().parentElement) : this.html(); 71 | PurgedMarkers.showSingleShow(this.show().metadataId, focusBack); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/ShowTitleResultRow.js: -------------------------------------------------------------------------------- 1 | import { ShowResultRowBase } from './ShowResultRowBase.js'; 2 | 3 | import { UISection, UISections } from '../ResultSections.js'; 4 | import { PlexClientState } from '../PlexClientState.js'; 5 | 6 | /** @typedef {!import('/Shared/PlexTypes').ShowData} ShowData */ 7 | 8 | /** 9 | * A show result row that's used as a placeholder when a specific show/season is active. 10 | */ 11 | export class ShowTitleResultRow extends ShowResultRowBase { 12 | /** 13 | * @param {ShowData} show */ 14 | constructor(show) { 15 | super(show, 'topLevelResult showResult'); 16 | } 17 | 18 | titleRow() { return true; } 19 | 20 | /** 21 | * Build this placeholder row. Takes the base row and adds a 'back' button. */ 22 | buildRow() { 23 | if (this.html()) { 24 | // Extra data has already been added, and super.buildRow accounts for this, and gives us some warning logging. 25 | return super.buildRow(); 26 | } 27 | 28 | const row = super.buildRow(); 29 | this.addBackButton(row, 'Back to results', async () => { 30 | UISections.clearSections(UISection.Seasons | UISection.Episodes); 31 | await UISections.hideSections(UISection.Seasons | UISection.Episodes); 32 | UISections.showSections(UISection.MoviesOrShows); 33 | }); 34 | 35 | row.classList.add('dynamicText'); 36 | return row; 37 | } 38 | 39 | /** 40 | * Updates various UI states after purged markers are restored/ignored. 41 | * @param {PurgedShow} _unpurged */ 42 | notifyPurgeChange(_unpurged) { 43 | /*async*/ PlexClientState.updateNonActiveBreakdown(this, []); 44 | } 45 | 46 | /** 47 | * Update marker breakdown data after a bulk update. 48 | * @param {{[seasonId: number]: MarkerData[]}} _changedMarkers */ 49 | notifyBulkAction(_changedMarkers) { 50 | return PlexClientState.updateNonActiveBreakdown(this, []); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Client/Script/ResultRow/index.js: -------------------------------------------------------------------------------- 1 | export * from './BulkActionResultRow.js'; 2 | export * from './EpisodeResultRow.js'; 3 | export * from './MovieResultRow.js'; 4 | export * from './ResultRow.js'; 5 | export * from './SeasonResultRow.js'; 6 | export * from './SectionOptionsResultRow.js'; 7 | export * from './ShowResultRow.js'; 8 | -------------------------------------------------------------------------------- /Client/Script/ResultSections.js: -------------------------------------------------------------------------------- 1 | import { $, $$, $clear } from './HtmlHelpers.js'; 2 | import { animateOpacity } from './AnimationHelpers.js'; 3 | import { BaseLog } from '/Shared/ConsoleLog.js'; 4 | import { CustomEvents } from './CustomEvents.js'; 5 | import { PlexClientState } from './PlexClientState.js'; 6 | 7 | /** 8 | * The result sections of the application. 9 | * Can be bitwise-or'd and -and'd to pass in multiple 10 | * sections at once to relevant methods. 11 | * @enum {number} */ 12 | export const UISection = { 13 | /** @readonly Top-level section, i.e. movies or shows. */ 14 | MoviesOrShows : 1, 15 | /** @readonly */ 16 | Seasons : 2, 17 | /** @readonly */ 18 | Episodes : 4 19 | }; 20 | 21 | /** 22 | * The singleton result section manager. 23 | * @type {ResultSections} 24 | * @readonly */ // Externally readonly 25 | let Instance; 26 | 27 | class ResultSections { 28 | /** 29 | * Initialize the singleton ResultSections instance. */ 30 | static CreateInstance() { 31 | if (Instance) { 32 | BaseLog.error(`We should only have a single ResultSections instance!`); 33 | return; 34 | } 35 | 36 | Instance = new ResultSections(); 37 | } 38 | 39 | /** 40 | * The three result sections: shows, seasons, and episodes. 41 | * @type {{[group: number]: HTMLElement}} */ 42 | #uiSections = { 43 | [UISection.MoviesOrShows] : $('#toplevellist'), 44 | [UISection.Seasons] : $('#seasonlist'), 45 | [UISection.Episodes] : $('#episodelist') 46 | }; 47 | 48 | constructor() { 49 | // Nothing to do 50 | } 51 | 52 | /** 53 | * Return whether the given UI section is currently visible. 54 | * @param {UISection} section */ 55 | sectionVisible(section) { 56 | return !this.#uiSections[section].classList.contains('hidden'); 57 | } 58 | 59 | /** 60 | * Retrieve the HTML element for the given section. 61 | * @param {UISection} section */ 62 | getSection(section) { 63 | return this.#uiSections[section]; 64 | } 65 | 66 | /** 67 | * Add a row to the given UI section. 68 | * @param {UISection} uiSection 69 | * @param {HTMLElement} row */ 70 | addRow(uiSection, row) { 71 | this.#uiSections[uiSection].appendChild(row); 72 | } 73 | 74 | /** Clears data from the show, season, and episode lists. */ 75 | clearAllSections() { 76 | this.clearAndShowSections(UISection.MoviesOrShows | UISection.Seasons | UISection.Episodes); 77 | PlexClientState.clearActiveShow(); 78 | } 79 | 80 | /** 81 | * Clear out all child elements from the specified UI sections 82 | * @param {UISection} uiSection */ 83 | clearSections(uiSection) { 84 | this.#sectionOperation(uiSection, ele => { 85 | $clear(ele); 86 | }); 87 | } 88 | 89 | /** 90 | * Ensure the given section(s) are visible. 91 | * Despite supporting multiple sections, this should really only ever 92 | * be called with a single section. 93 | * @param {UISection} uiSection */ 94 | showSections(uiSection) { 95 | const promises = []; 96 | this.#sectionOperation(uiSection, ele => { 97 | const isHidden = ele.classList.contains('hidden'); 98 | ele.classList.remove('hidden'); 99 | if (isHidden) { 100 | ele.style.opacity = 0; 101 | ele.style.height = 0; 102 | promises.push(animateOpacity(ele, 0, 1, { noReset : true, duration : 100 }, () => { 103 | if (document.activeElement?.id !== 'search') { 104 | $$('.tabbableRow', ele)?.focus(); 105 | } 106 | })); 107 | } 108 | }); 109 | 110 | return Promise.all(promises); 111 | } 112 | 113 | /** 114 | * Hide all sections indicated by uiSection 115 | * @param {UISection} uiSection */ 116 | hideSections(uiSection) { 117 | /** @type {Promise[]} */ 118 | const promises = []; 119 | this.#sectionOperation(uiSection, ele => { 120 | if (ele.classList.contains('hidden')) { 121 | promises.push(Promise.resolve()); 122 | } else { 123 | promises.push(animateOpacity(ele, 1, 0, 100, () => { ele.classList.add('hidden'); })); 124 | } 125 | }); 126 | 127 | return Promise.all(promises); 128 | } 129 | 130 | /** 131 | * Clear the given result group of any elements and ensure it's not hidden. 132 | * @param {number} uiSections The group(s) to clear and unhide. */ 133 | clearAndShowSections(uiSections) { 134 | this.clearSections(uiSections); 135 | this.showSections(uiSections); 136 | 137 | // Let people know the active UI section has changed/been cleared. 138 | window.dispatchEvent(new Event(CustomEvents.UISectionChanged)); 139 | } 140 | 141 | /** 142 | * Apply the given function to all UI sections specified in uiSections. 143 | * @param {number} uiSections 144 | * @param {(ele: HTMLElement) => void} fn */ 145 | #sectionOperation(uiSections, fn) { 146 | for (const group of Object.values(UISection)) { 147 | if (group & uiSections) { 148 | fn(this.#uiSections[group]); 149 | } 150 | } 151 | } 152 | 153 | 154 | } 155 | 156 | export { Instance as UISections, ResultSections }; 157 | -------------------------------------------------------------------------------- /Client/Script/ServerPausedOverlay.js: -------------------------------------------------------------------------------- 1 | import { CustomEvents } from './CustomEvents.js'; 2 | import { errorResponseOverlay } from './ErrorHandling.js'; 3 | import Overlay from './Overlay.js'; 4 | import { ServerCommands } from './Commands.js'; 5 | 6 | /** 7 | * Static class that is responsible for displaying an undismissible overlay letting the user know 8 | * that the server is suspended, giving them the option to resume it. 9 | */ 10 | class ServerPausedOverlay { 11 | static Setup() { 12 | window.addEventListener(CustomEvents.ServerPaused, ServerPausedOverlay.Show); 13 | } 14 | 15 | static Show() { 16 | Overlay.show( 17 | `Server is Paused. Press 'Resume' to reconnect to the Plex database.`, 18 | 'Resume', 19 | onResume, 20 | false /*dismissible*/); 21 | } 22 | } 23 | 24 | /** 25 | * Attempts to resume the suspended server. 26 | * @param {HTMLElement} button The button that was clicked. */ 27 | async function onResume(_, button) { 28 | button.innerText = 'Resuming...'; 29 | try { 30 | await ServerCommands.resume(); 31 | window.location.reload(); 32 | } catch (err) { 33 | button.innerText = 'Resume'; 34 | errorResponseOverlay('Failed to resume.', err); 35 | } 36 | } 37 | 38 | export default ServerPausedOverlay; 39 | -------------------------------------------------------------------------------- /Client/Script/ServerSettingsDialog/ServerSettingsDialogConstants.js: -------------------------------------------------------------------------------- 1 | import { ServerSettings } from '/Shared/ServerConfig.js'; 2 | 3 | export const ValidationInputDelay = 500; 4 | 5 | /** 6 | * @enum 7 | * @type {{ [setting: string]: string }} */ 8 | export const SettingTitles = { 9 | [ServerSettings.DataPath] : 'Data Path', 10 | [ServerSettings.Database] : 'Database File', 11 | [ServerSettings.Host] : 'Listen Host', 12 | [ServerSettings.Port] : 'Listen Port', 13 | [ServerSettings.BaseUrl] : 'Base URL', 14 | [ServerSettings.UseSsl] : 'Enable HTTPS', 15 | [ServerSettings.SslOnly] : 'Force HTTPS', 16 | [ServerSettings.SslHost] : 'HTTPS Host', 17 | [ServerSettings.SslPort] : 'HTTPS Port', 18 | [ServerSettings.CertType] : 'Certificate Type', 19 | [ServerSettings.PfxPath] : 'PFX Path', 20 | [ServerSettings.PfxPassphrase] : 'PFX Passphrase', 21 | [ServerSettings.PemCert] : 'PEM Certificate', 22 | [ServerSettings.PemKey] : 'PEM Private Key', 23 | [ServerSettings.UseAuthentication] : 'Authentication', 24 | [ServerSettings.Username] : 'Username', 25 | [ServerSettings.Password] : 'Password', 26 | [ServerSettings.SessionTimeout] : 'Session Timeout', 27 | [ServerSettings.LogLevel] : 'Log Level', 28 | [ServerSettings.AutoOpen] : 'Open Browser on Launch', 29 | [ServerSettings.ExtendedStats] : 'Extended Marker Statistics', 30 | [ServerSettings.PreviewThumbnails] : 'Use Preview Thumbnails', 31 | [ServerSettings.FFmpegThumbnails] : 'Use FFmpeg for Thumbnails', 32 | [ServerSettings.PathMappings] : 'Path Mappings', 33 | }; 34 | -------------------------------------------------------------------------------- /Client/Script/ServerSettingsDialog/ServerSettingsDialogHelper.js: -------------------------------------------------------------------------------- 1 | import { $$, $a, $append, $p, $span } from '../HtmlHelpers.js'; 2 | import { BaseLog } from '/Shared/ConsoleLog.js'; 3 | import ButtonCreator from '../ButtonCreator.js'; 4 | import Icons from '../Icons.js'; 5 | import Overlay from '../Overlay.js'; 6 | import { ServerCommands } from '../Commands.js'; 7 | import { ServerConfigState } from '/Shared/ServerConfig.js'; 8 | import { ThemeColors } from '../ThemeColors.js'; 9 | 10 | /** Retrieve the HTML element id associated with the given setting */ 11 | export function settingId(setting, extra=null) { 12 | return `setting_${setting}` + (extra ? `_${extra}` : ''); 13 | } 14 | 15 | /** 16 | * Retrieve the input element for the given setting. 17 | * @param {string} setting The ServerSettings value 18 | * @param {string?} extra Any extra data to append to the settings (e.g. "dark" for the log level dark mode toggle) 19 | * @returns {HTMLInputElement|HTMLSelectElement}*/ 20 | export function settingInput(setting, extra=null) { 21 | return $$(`#${settingId(setting, extra)}`); 22 | } 23 | 24 | /** 25 | * Retrieve the setting's top-level DIV container. 26 | * @param {string|HTMLElement} setting 27 | * @param {string?} extra Any extra data to append to the setting. */ 28 | export function settingHolder(setting, extra=null) { 29 | const input = typeof setting === 'string' ? settingInput(setting, extra) : setting; 30 | if (!input) { return input; } 31 | 32 | let parent = input; 33 | while (parent && !parent.classList.contains('serverSetting')) { 34 | parent = parent.parentElement; 35 | } 36 | 37 | return parent; 38 | } 39 | 40 | /** 41 | * Return the title and description for this dialog, as the intent differs 42 | * depending on the current state of the config file. 43 | * @param {ServerConfigState} state 44 | * @returns {[string, HTMLElement]} */ 45 | export function settingsDialogIntro(state) { 46 | const footer = $append($p(), 47 | `For more details about a setting, hover over the question mark icon, or visit `, 48 | $a('the configuration wiki', 'https://github.com/danrahn/MarkerEditorForPlex/wiki/configuration'), 49 | '.' 50 | ); 51 | 52 | switch (state) { 53 | case ServerConfigState.Valid: 54 | return ['Server Settings', footer]; 55 | case ServerConfigState.DoesNotExist: 56 | return ['Marker Editor Setup', $append($span(), 57 | $p(`Welcome to Marker Editor! It looks like you don't have a configuration file set up yet. ` + 58 | `Please adjust the values below to your liking. If a value isn't provided, the default value listed will be used.`), 59 | footer) 60 | ]; 61 | case ServerConfigState.Invalid: 62 | return ['Marker Editor Setup', $append($span(), 63 | $p('It looks like one or more values in config.json are no longer valid. Please correct them ' + 64 | 'below before continuing.'), 65 | footer) 66 | ]; 67 | default: 68 | throw new Error(`Unexpected config state in ServerSettingsDialog`); 69 | } 70 | } 71 | 72 | /** 73 | * Shut down the server when the user asks to do it. */ 74 | function onShutdown() { 75 | BaseLog.warn('First run setup not completed, user requested shutdown.'); 76 | ServerCommands.shutdown(); 77 | Overlay.show('Setup aborted, application is shutting down', 'Close Window', window.close, false /*dismissible*/); 78 | } 79 | 80 | /** 81 | * Retrieve any extra buttons to add to the bottom of the dialog, based on the current config state. */ 82 | export function buttonsFromConfigState(state) { 83 | const shutdown = ButtonCreator.fullButton('Shut Down', Icons.Cancel, ThemeColors.Red, onShutdown); 84 | switch (state) { 85 | case ServerConfigState.Valid: 86 | return [ButtonCreator.fullButton('Cancel', Icons.Cancel, ThemeColors.Red, Overlay.dismiss)]; 87 | case ServerConfigState.DoesNotExist: 88 | return [shutdown]; 89 | case ServerConfigState.Invalid: 90 | return [shutdown]; 91 | default: 92 | throw new Error(`Unexpected config state in ServerSettingsDialog`); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Client/Script/ServerSettingsDialog/index.js: -------------------------------------------------------------------------------- 1 | export * from './ServerSettingsDialog.js'; 2 | -------------------------------------------------------------------------------- /Client/Script/StickySettings/BulkAddStickySettings.js: -------------------------------------------------------------------------------- 1 | import { StickySettingsBase } from './StickySettingsBase.js'; 2 | 3 | import { BulkMarkerResolveType } from '/Shared/PlexTypes.js'; 4 | import { MarkerType } from '/Shared/MarkerType.js'; 5 | 6 | /** @typedef {!import('./StickySettingsBase').StickySettingsBaseProtected} StickySettingsBaseProtected */ 7 | 8 | /** 9 | * Contains Bulk Add settings that can persist depending on client persistence setting. 10 | */ 11 | export class BulkAddStickySettings extends StickySettingsBase { 12 | 13 | /** Bulk add settings that persist based on the user's stickiness setting. */ 14 | static #keys = { 15 | /** @readonly */ 16 | MarkerType : 'markerType', 17 | /** @readonly */ 18 | ApplyType : 'applyType', 19 | /** @readonly */ 20 | ChapterMode : 'chapterMode', 21 | /** @readonly */ 22 | ChapterIndexMode : 'chapterIndexMode', 23 | }; 24 | 25 | /** 26 | * Imitates "protected" methods from the base class. 27 | * @type {StickySettingsBaseProtected} */ 28 | #protected = {}; 29 | 30 | /** Create bulk add settings. */ 31 | constructor() { 32 | const protectedMethods = {}; 33 | super('bulkAdd', protectedMethods); 34 | this.#protected = protectedMethods; 35 | } 36 | 37 | /** Default values, used when the user doesn't want to persist settings, or they haven't changed the defaults. */ 38 | defaultData() { 39 | const keys = BulkAddStickySettings.#keys; 40 | return { 41 | [keys.MarkerType] : MarkerType.Intro, 42 | [keys.ApplyType] : BulkMarkerResolveType.Fail, 43 | [keys.ChapterMode] : false, 44 | [keys.ChapterIndexMode] : false, 45 | }; 46 | } 47 | 48 | /** The type of marker to add. 49 | * @returns {string} */ 50 | markerType() { return this.#protected.get(BulkAddStickySettings.#keys.MarkerType); } 51 | /** Set the type of marker to add. 52 | * @param {string} markerType */ 53 | setMarkerType(markerType) { this.#protected.set(BulkAddStickySettings.#keys.MarkerType, markerType); } 54 | 55 | /** The apply behavior (fail/overwrite/merge/ignore) 56 | * @returns {number} */ 57 | applyType() { return this.#protected.get(BulkAddStickySettings.#keys.ApplyType); } 58 | /** Set the bulk apply behavior 59 | * @param {number} applyType */ 60 | setApplyType(applyType) { this.#protected.set(BulkAddStickySettings.#keys.ApplyType, applyType); } 61 | 62 | /** Whether to use chapter data to bulk add markers instead of raw timestamp input. 63 | * @returns {boolean} */ 64 | chapterMode() { return this.#protected.get(BulkAddStickySettings.#keys.ChapterMode); } 65 | /** Set whether to use chapter data to bulk add markers instead of raw timestamp input. 66 | * @param {boolean} chapterMode */ 67 | setChapterMode(chapterMode) { this.#protected.set(BulkAddStickySettings.#keys.ChapterMode, chapterMode); } 68 | 69 | /** Returns whether to favor chapter indexes or timestamps for fuzzy matching. 70 | * @returns {boolean} */ 71 | chapterIndexMode() { return this.#protected.get(BulkAddStickySettings.#keys.ChapterIndexMode); } 72 | /** Set whether to favor chapter indexes or timestamps for fuzzy matching. 73 | * @param {boolean} chapterIndexMode */ 74 | setChapterIndexMode(chapterIndexMode) { this.#protected.set(BulkAddStickySettings.#keys.ChapterIndexMode, chapterIndexMode); } 75 | 76 | /** Custom validation for a stored key/value pair. */ 77 | validateStorageKey(key, value) { 78 | switch (key) { 79 | case BulkAddStickySettings.#keys.MarkerType: 80 | return Object.values(MarkerType).includes(value); 81 | case BulkAddStickySettings.#keys.ApplyType: 82 | // Dry Run not available in bulk add. 83 | return value > BulkMarkerResolveType.DryRun && value <= BulkMarkerResolveType.Max; 84 | default: 85 | return true; // All other keys are handled by default validation 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Client/Script/StickySettings/BulkDeleteStickySettings.js: -------------------------------------------------------------------------------- 1 | import { StickySettingsBase } from './StickySettingsBase.js'; 2 | 3 | import { MarkerEnum } from '/Shared/MarkerType.js'; 4 | 5 | /** @typedef {!import('./StickySettingsBase').StickySettingsBaseProtected} StickySettingsBaseProtected */ 6 | 7 | /** 8 | * Contains bulk delete settings that can persist depending on client persistence setting. 9 | */ 10 | export class BulkDeleteStickySettings extends StickySettingsBase { 11 | /** 12 | * Bulk delete settings that persist based on the user's stickiness setting. */ 13 | static #keys = { 14 | /** @readonly */ 15 | ApplyTo : 'applyTo', 16 | }; 17 | 18 | /** 19 | * Imitates "protected" methods from the base class. 20 | * @type {StickySettingsBaseProtected} */ 21 | #protected = {}; 22 | 23 | /** Create bulk shift settings. */ 24 | constructor() { 25 | const protectedMethods = {}; 26 | super('bulkDelete', protectedMethods); 27 | this.#protected = protectedMethods; 28 | } 29 | 30 | /** Default values, used when the user doesn't want to persist settings, or they haven't changed the defaults. */ 31 | defaultData() { 32 | const keys = BulkDeleteStickySettings.#keys; 33 | return { 34 | [keys.ApplyTo] : MarkerEnum.All, 35 | }; 36 | } 37 | 38 | /** The type(s) of markers to delete. 39 | * @returns {number} */ 40 | applyTo() { return this.#protected.get(BulkDeleteStickySettings.#keys.ApplyTo); } 41 | /** Set the type(s) of markers to delete. 42 | * @param {number} applyTo */ 43 | setApplyTo(applyTo) { this.#protected.set(BulkDeleteStickySettings.#keys.ApplyTo, applyTo); } 44 | 45 | /** Custom validation for a stored key/value pair. */ 46 | validateStorageKey(key, value) { 47 | const keys = BulkDeleteStickySettings.#keys; 48 | switch (key) { 49 | case keys.ApplyTo: 50 | return Object.values(MarkerEnum).includes(value); 51 | default: 52 | return true; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Client/Script/StickySettings/BulkShiftStickySettings.js: -------------------------------------------------------------------------------- 1 | import { StickySettingsBase } from './StickySettingsBase.js'; 2 | 3 | import { MarkerEnum } from '/Shared/MarkerType.js'; 4 | 5 | /** @typedef {!import('./StickySettingsBase').StickySettingsBaseProtected} StickySettingsBaseProtected */ 6 | 7 | /** 8 | * Contains bulk shift settings that can persist depending on client persistence setting. 9 | */ 10 | export class BulkShiftStickySettings extends StickySettingsBase { 11 | /** 12 | * Bulk shift settings that persist based on the user's stickiness setting. */ 13 | static #keys = { 14 | /** @readonly */ 15 | SeparateShift : 'separateShift', 16 | /** @readonly */ 17 | ApplyTo : 'applyTo', 18 | }; 19 | 20 | /** 21 | * Imitates "protected" methods from the base class. 22 | * @type {StickySettingsBaseProtected} */ 23 | #protected = {}; 24 | 25 | /** Create bulk shift settings. */ 26 | constructor() { 27 | const protectedMethods = {}; 28 | super('bulkShift', protectedMethods); 29 | this.#protected = protectedMethods; 30 | } 31 | 32 | /** Default values, used when the user doesn't want to persist settings, or they haven't changed the defaults. */ 33 | defaultData() { 34 | const keys = BulkShiftStickySettings.#keys; 35 | return { 36 | [keys.SeparateShift] : false, 37 | [keys.ApplyTo] : MarkerEnum.All, 38 | }; 39 | } 40 | 41 | /** Whether to shift the start and end times separately. 42 | * @returns {boolean} */ 43 | separateShift() { return this.#protected.get(BulkShiftStickySettings.#keys.SeparateShift); } 44 | /** Set whether to shift the start and end times separately. 45 | * @param {boolean} separateShift */ 46 | setSeparateShift(separateShift) { this.#protected.set(BulkShiftStickySettings.#keys.SeparateShift, separateShift); } 47 | 48 | /** The type(s) of markers to shift. 49 | * @returns {number} */ 50 | applyTo() { return this.#protected.get(BulkShiftStickySettings.#keys.ApplyTo); } 51 | /** Set the type(s) of markers to shift. 52 | * @param {number} applyTo */ 53 | setApplyTo(applyTo) { this.#protected.set(BulkShiftStickySettings.#keys.ApplyTo, applyTo); } 54 | 55 | /** Custom validation for a stored key/value pair. */ 56 | validateStorageKey(key, value) { 57 | const keys = BulkShiftStickySettings.#keys; 58 | switch (key) { 59 | case keys.ApplyTo: 60 | return Object.values(MarkerEnum).includes(value); 61 | default: 62 | return true; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Client/Script/StickySettings/MarkerAddStickySettings.js: -------------------------------------------------------------------------------- 1 | import { StickySettingsBase } from './StickySettingsBase.js'; 2 | 3 | import { MarkerType } from '/Shared/MarkerType.js'; 4 | 5 | /** @typedef {!import('./StickySettingsBase').StickySettingsBaseProtected} StickySettingsBaseProtected */ 6 | 7 | /** 8 | * Contains marker add settings that can persist depending on client persistence setting. 9 | */ 10 | export class MarkerAddStickySettings extends StickySettingsBase { 11 | /** Marker add settings that persist based on the user's stickiness setting. */ 12 | static #keys = { 13 | /** @readonly */ 14 | ChapterMode : 'chapterEditMode', 15 | /** @readonly */ 16 | MarkerType : 'markerType', 17 | }; 18 | 19 | /** 20 | * Imitates "protected" methods from the base class. 21 | * @type {StickySettingsBaseProtected} */ 22 | #protected = {}; 23 | 24 | /** Create marker add settings. */ 25 | constructor() { 26 | const protectedMethods = {}; 27 | super('markerAdd', protectedMethods); 28 | this.#protected = protectedMethods; 29 | } 30 | 31 | /** Default values, used when the user doesn't want to persist settings, or they haven't changed the defaults. */ 32 | defaultData() { 33 | const keys = MarkerAddStickySettings.#keys; 34 | return { 35 | [keys.ChapterMode] : false, 36 | [keys.MarkerType] : MarkerType.Intro, 37 | }; 38 | } 39 | 40 | /** Whether to use chapter data to add markers instead of raw timestamp input. 41 | * @returns {boolean} */ 42 | chapterMode() { return this.#protected.get(MarkerAddStickySettings.#keys.ChapterMode); } 43 | /** Set whether to use chapter data to add markers instead of raw timestamp input. 44 | * @param {boolean} chapterMode */ 45 | setChapterMode(chapterMode) { this.#protected.set(MarkerAddStickySettings.#keys.ChapterMode, chapterMode); } 46 | 47 | 48 | /** The type of marker to add. 49 | * @returns {string} */ 50 | markerType() { return this.#protected.get(MarkerAddStickySettings.#keys.MarkerType); } 51 | /** Set the type of marker to add. 52 | * @param {string} markerType */ 53 | setMarkerType(markerType) { this.#protected.set(MarkerAddStickySettings.#keys.MarkerType, markerType); } 54 | 55 | /** Custom validation for a stored key/value pair. */ 56 | validateStorageKey(key, value) { 57 | const keys = MarkerAddStickySettings.#keys; 58 | switch (key) { 59 | case keys.MarkerType: 60 | return Object.values(MarkerType).includes(value); 61 | default: 62 | return true; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Client/Script/StickySettings/StickySettingsTypes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Options for remembering modified settings/toggles. 3 | * @enum */ 4 | export const StickySettingsType = { 5 | /** @readonly Never remember. */ 6 | None : 0, 7 | /** @readonly Remember the current session. */ 8 | Session : 1, 9 | /** @readonly Remember across sessions. */ 10 | Always : 2, 11 | }; 12 | -------------------------------------------------------------------------------- /Client/Script/StickySettings/index.js: -------------------------------------------------------------------------------- 1 | export * from './BulkAddStickySettings.js'; 2 | export * from './BulkDeleteStickySettings.js'; 3 | export * from './BulkShiftStickySettings.js'; 4 | export * from './MarkerAddStickySettings.js'; 5 | export * from './StickySettingsTypes.js'; 6 | export * from './StickySettingsBase.js'; 7 | -------------------------------------------------------------------------------- /Client/Script/StyleSheets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Dictionary of all current style sheets. */ 3 | const StyleSheets = { 4 | Main : 'style', 5 | ThemeDark : 'themeDark', 6 | ThemeLight : 'themeLight', 7 | Tooltip : 'Tooltip', 8 | Overlay : 'Overlay', 9 | OverlayDark : 'OverlayDark', 10 | OverlayLight : 'OverlayLight', 11 | Settings : 'Settings', 12 | MarkerTable : 'MarkerTable', 13 | BulkAction : 'BulkActionOverlay', 14 | BulkActionDark : 'BulkActionOverlayDark', 15 | BulkActionLight : 'BulkActionOverlayLight', 16 | }; 17 | 18 | export default StyleSheets; 19 | -------------------------------------------------------------------------------- /Client/Script/ThemeColors.js: -------------------------------------------------------------------------------- 1 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 2 | 3 | /** @typedef {!import('./Icons').IconKeys} IconKeys */ 4 | 5 | const Log = ContextualLog.Create('ThemeColors'); 6 | 7 | /** 8 | * List of available theme colors. */ 9 | export const ThemeColors = { 10 | /** @readonly */ 11 | Primary : 'Primary', 12 | /** @readonly */ 13 | Green : 'Green', 14 | /** @readonly */ 15 | Red : 'Red', 16 | /** @readonly */ 17 | Orange : 'Orange', 18 | }; 19 | 20 | /** @typedef {ThemeColors} ThemeColorKeys */ 21 | 22 | /** Static class of colors used for icons, which may vary depending on the current theme. */ 23 | export class Theme { 24 | static #dict = { 25 | 0 /*dark*/ : { 26 | [ThemeColors.Primary] : 'c1c1c1', 27 | [ThemeColors.Green] : '4C4', 28 | [ThemeColors.Red] : 'C44', 29 | [ThemeColors.Orange] : 'C94', 30 | }, 31 | 1 /*light*/ : { 32 | [ThemeColors.Primary] : '212121', 33 | [ThemeColors.Green] : '292', 34 | [ThemeColors.Red] : 'A22', 35 | [ThemeColors.Orange] : 'A22', // Just red, looks better than orange/brown 36 | } 37 | }; 38 | 39 | static #isDark = false; 40 | 41 | /** 42 | * Set the current theme. 43 | * @param {boolean} isDark Whether dark theme is enabled. */ 44 | static setDarkTheme(isDark) { this.#isDark = isDark; } 45 | 46 | /** 47 | * Return the hex color for the given color category. 48 | * @param {keyof ThemeColors} themeColor The color category for the button. 49 | * @returns {string} The hex color associated with the given color category. */ 50 | static get(themeColor) { 51 | return Theme.#dict[this.#isDark ? 0 : 1][themeColor]; 52 | } 53 | 54 | /** 55 | * Return the full hex string for the given color category, with an optional 56 | * opacity applied. 57 | * @param {keyof ThemeColors} themeColor The color category 58 | * @param {string} [opacity] The hex opacity (0-F, cannot be two characters) */ 59 | static getHex(themeColor, opacity='F') { 60 | if (!/^[0-9A-Fa-f]$/.test(opacity)) { 61 | Log.warn(`getHex: invalid opacity "${opacity}", defaulting to opaque`); 62 | opacity = 'F'; 63 | } 64 | 65 | const color = Theme.#dict[this.#isDark ? 0 : 1][themeColor]; 66 | if (color.length > 3) { 67 | opacity += String(opacity); 68 | } 69 | 70 | return '#' + color + opacity; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Client/Script/Toast.js: -------------------------------------------------------------------------------- 1 | import { $, $clear, $div } from './HtmlHelpers.js'; 2 | import { animate } from './AnimationHelpers.js'; 3 | 4 | /** @enum */ 5 | export const ToastType = { 6 | Error : 'errorToast', 7 | Warning : 'warnToast', 8 | Info : 'infoToast', 9 | Success : 'successToast', 10 | }; 11 | 12 | /** 13 | * @typedef {Object} ToastTimingOptions 14 | * @property {Promise} promise A promise that dismisses the toast once resolved. 15 | * @property {number} [minDuration=1000] The minimum amount of time the toast should be displayed. 16 | * @property {number} [dismissDelay=0] The amount of time to continue showing the toast after the promise resolves. 17 | * @property {Function} [onResolve] A callback to run when the promise resolves (but potentially before the toast is dismissed). 18 | */ 19 | 20 | function getColor(toastType, colorProperty) { 21 | // TODO: getComputedStyle isn't cheap, so this should be optimized if it becomes a bottleneck. 22 | const fullProperty = `--${toastType.substring(0, toastType.length - 5)}-${colorProperty}`; 23 | return getComputedStyle(document.documentElement).getPropertyValue(fullProperty); 24 | } 25 | 26 | function getBackgroundColor(toastType) { 27 | return getColor(toastType, 'background'); 28 | } 29 | 30 | function getBorderColor(toastType) { 31 | return getColor(toastType, 'border'); 32 | } 33 | 34 | /** 35 | * Encapsulates a toast message that can be shown to the user. 36 | */ 37 | export class Toast { 38 | /** @type {string} */ 39 | #toastType = ToastType.Error; 40 | /** @type {HTMLElement} */ 41 | #toastDiv; 42 | 43 | /** 44 | * @param {string} toastType The ToastType to create 45 | * @param {string|HTMLElement} message The message to display in the toast */ 46 | constructor(toastType, message) { 47 | this.#toastType = toastType; 48 | this.#toastDiv = $div({ class : 'toast' }, message); 49 | this.#toastDiv.classList.add(toastType); 50 | } 51 | 52 | /** 53 | * @param {ToastTimingOptions} options Options specifying how long to show the toast and what to do when it's dismissed. */ 54 | show(options) { 55 | const msg = this.#toastDiv; 56 | $('#toastContainer').appendChild(msg); 57 | 58 | // Hack based on known css padding/border heights to avoid getComputedStyle. 59 | const height = (msg.getBoundingClientRect().height - 32) + 'px'; 60 | msg.style.height = height; 61 | options.minDuration ??= 1000; 62 | options.dismissDelay ??= 0; 63 | const initialSteps = [ 64 | { opacity : 0 }, 65 | { opacity : 1, offset : 1 }, 66 | ]; 67 | 68 | return animate(msg, 69 | initialSteps, 70 | { duration : 250 }, 71 | async () => { 72 | // Opacity is reset after animation finishes, so make sure it stays visible. 73 | msg.style.opacity = 1; 74 | 75 | const start = Date.now(); 76 | await options.promise; 77 | await options.onResolve?.(); 78 | const remaining = Math.max(options.minDuration - (Date.now() - start), options.dismissDelay); 79 | if (remaining > 0) { 80 | await new Promise(r => { setTimeout(r, remaining); }); 81 | } 82 | 83 | const dismissSteps = [ 84 | { opacity : 1, height : height, overflow : 'hidden', padding : '15px' }, 85 | { opacity : 0, height : '0px', overflow : 'hidden', padding : '0px 15px 0px 15px', offset : 1 }, 86 | ]; 87 | 88 | await animate(msg, dismissSteps, { duration : 250 }, () => { 89 | msg.remove(); 90 | }); 91 | } 92 | ); 93 | } 94 | 95 | /** 96 | * @param {number} duration The timeout in ms. */ 97 | showSimple(duration) { 98 | // This repeats some code, but the simple/complex paths are different enough that I think it makes sense. 99 | const msg = this.#toastDiv; 100 | $('#toastContainer').appendChild(msg); 101 | 102 | // Hack based on known css padding/border heights to avoid getComputedStyle. 103 | const height = (msg.getBoundingClientRect().height - 32) + 'px'; 104 | msg.style.height = height; 105 | 106 | const steps = [ 107 | { opacity : 0 }, 108 | { opacity : 1, offset : 0.2 }, 109 | { opacity : 1, offset : 0.8 }, 110 | { height : height, overflow : 'hidden', padding : '15px', offset : 0.95 }, 111 | { opacity : 0, height : '0px', overflow : 'hidden', padding : '0px 15px 0px 15px', offset : 1 }, 112 | ]; 113 | 114 | return animate(msg, 115 | steps, 116 | { duration }, 117 | () => { 118 | msg.remove(); 119 | }); 120 | } 121 | 122 | /** 123 | * @param {string} newType */ 124 | async changeType(newType) { 125 | await animate(this.#toastDiv, [ 126 | { backgroundColor : getBackgroundColor(this.#toastType), borderColor : getBorderColor(this.#toastType) }, 127 | { backgroundColor : getBackgroundColor(newType), borderColor : getBorderColor(newType), offset : 1 }, 128 | ], { duration : 250 }); 129 | 130 | this.#toastDiv.classList.remove(this.#toastType); 131 | this.#toastDiv.classList.add(newType); 132 | this.#toastType = newType; 133 | } 134 | 135 | /** 136 | * @param {string|HTMLElement} newMessage */ 137 | setMessage(newMessage) { 138 | if (newMessage instanceof HTMLElement) { 139 | $clear(this.#toastDiv); 140 | this.#toastDiv.appendChild(newMessage); 141 | } else { 142 | this.#toastDiv.innerText = newMessage; 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Client/Script/TooltipBuilder.js: -------------------------------------------------------------------------------- 1 | import { $append, $br, $span, $text } from './HtmlHelpers.js'; 2 | import { ContextualLog } from '/Shared/ConsoleLog.js'; 3 | 4 | const Log = ContextualLog.Create('TTBuilder'); 5 | 6 | export default class TooltipBuilder { 7 | /** @type {HTMLElement[]} */ 8 | #elements = []; 9 | 10 | /** @type {HTMLElement?} */ 11 | #cached = null; 12 | 13 | /** 14 | * @param {...(string|Element)} lines */ 15 | constructor(...lines) { 16 | this.addLines(...lines); 17 | } 18 | 19 | /** 20 | * @param {(string|Element)[]} lines */ 21 | addLine(line) { 22 | this.addLines(line); 23 | } 24 | 25 | /** 26 | * Adds each item to the tooltip, without inserting breaks between elements. 27 | * @param {...(string|Element)} items */ 28 | addRaw(...items) { 29 | for (const item of items) { 30 | this.#elements.push(item instanceof Element ? item : $text(item)); 31 | } 32 | } 33 | 34 | /** 35 | * Clears out the old content with the new content. 36 | * @param {...(string|HTMLElement)} lines */ 37 | set(...lines) { 38 | this.#elements.length = 0; 39 | this.addLines(...lines); 40 | } 41 | 42 | /** 43 | * Clears out the old content with the new content, without 44 | * automatic line breaks. 45 | * @param {...(string|HTMLElement)} items */ 46 | setRaw(...items) { 47 | this.#elements.length = 0; 48 | this.set(...items); 49 | } 50 | 51 | /** 52 | * @param {(string|Element)[]} lines */ 53 | addLines(...lines) { 54 | if (this.#cached) { 55 | // We already have a cached value. Subsequent get()'s will steal elements from the 56 | // previous get() unless we clone the existing elements. 57 | Log.warn(`Adding content after previously retrieving tooltip. Cloning existing nodes`); 58 | for (let i = 0; i < this.#elements.length; ++i) { 59 | this.#elements[i] = this.#elements[i].cloneNode(true /*deep*/); 60 | } 61 | 62 | this.#cached = null; 63 | } 64 | 65 | for (const line of lines) { 66 | if (this.#elements.length > 0) { 67 | this.#elements.push($br()); 68 | } 69 | 70 | this.#elements.push(line instanceof Element ? line : $text(line)); 71 | } 72 | } 73 | 74 | /** Return whether this tooltip has no content. */ 75 | empty() { return this.#elements.length === 0; } 76 | 77 | /** Retrieve a span containing all tooltip elements. */ 78 | get() { 79 | if (this.#cached) { 80 | return this.#cached; 81 | } 82 | 83 | this.#cached = $append($span(), ...this.#elements); 84 | return this.#cached; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Client/Script/WindowResizeEventHandler.js: -------------------------------------------------------------------------------- 1 | import { BaseLog } from '../../Shared/ConsoleLog.js'; 2 | import DocumentProxy from '../../Shared/DocumentProxy.js'; 3 | 4 | /** 5 | * Set of all registered event listeners. 6 | * @type {Set<(e: UIEvent) => void>}*/ 7 | const smallScreenListeners = new Set(); 8 | 9 | let smallScreenCached = false; 10 | 11 | /** 12 | * Initializes the global window resize event listener, which acts as a wrapper around individually registered listeners. */ 13 | export function SetupWindowResizeEventHandler() { 14 | smallScreenCached = isSmallScreen(); 15 | window.addEventListener('resize', (e) => { 16 | if (smallScreenCached === isSmallScreen()) { 17 | return; 18 | } 19 | 20 | BaseLog.verbose(`Window changed from small=${smallScreenCached} to ${!smallScreenCached}, ` + 21 | `triggering ${smallScreenListeners.size} listeners`); 22 | smallScreenCached = !smallScreenCached; 23 | for (const listener of smallScreenListeners) { 24 | listener(e); 25 | } 26 | }); 27 | } 28 | 29 | 30 | /** @returns Whether the current window size is considered small */ 31 | export function isSmallScreen() { return DocumentProxy.body.clientWidth < 768; } 32 | 33 | /** 34 | * Adds an listener to the window resize event. 35 | * Ensures the event is only triggered when the small/large screen threshold is crossed. 36 | * @param {(e: Event) => void} callback */ 37 | export function addWindowResizedListener(callback) { 38 | smallScreenListeners.add(callback); 39 | } 40 | -------------------------------------------------------------------------------- /Client/Script/index.js: -------------------------------------------------------------------------------- 1 | import { BaseLog } from '/Shared/ConsoleLog.js'; 2 | 3 | import { ClientSettings, SettingsManager } from './ClientSettings.js'; 4 | import { errorMessage, errorResponseOverlay } from './ErrorHandling.js'; 5 | import { PlexUI, PlexUIManager } from './PlexUI.js'; 6 | import ButtonCreator from './ButtonCreator.js'; 7 | import HelpOverlay from './HelpOverlay.js'; 8 | import { PlexClientStateManager } from './PlexClientState.js'; 9 | import { PurgedMarkerManager } from './PurgedMarkerManager.js'; 10 | import { ResultSections } from './ResultSections.js'; 11 | import { ServerCommands } from './Commands.js'; 12 | import ServerPausedOverlay from './ServerPausedOverlay.js'; 13 | import { SetupWindowResizeEventHandler } from './WindowResizeEventHandler.js'; 14 | import { StickySettingsBase } from 'StickySettings'; 15 | import { ThumbnailMarkerEdit } from './MarkerEdit.js'; 16 | import Tooltip from './Tooltip.js'; 17 | import VersionManager from './VersionManager.js'; 18 | 19 | /** @typedef {!import('/Shared/ServerConfig').SerializedConfig} SerializedConfig */ 20 | 21 | window.Log = BaseLog; // Let the user interact with the class to tweak verbosity/other settings. 22 | 23 | window.addEventListener('load', init); 24 | 25 | /** Initial setup on page load. */ 26 | function init() { 27 | HelpOverlay.SetupHelperListeners(); 28 | StickySettingsBase.Setup(); // MUST be before SettingsManager 29 | SettingsManager.CreateInstance(); 30 | PlexUIManager.CreateInstance(); 31 | PlexClientStateManager.CreateInstance(); 32 | ResultSections.CreateInstance(); 33 | Tooltip.Setup(); 34 | ButtonCreator.Setup(); 35 | ThumbnailMarkerEdit.Setup(); 36 | ServerPausedOverlay.Setup(); 37 | SetupWindowResizeEventHandler(); 38 | 39 | mainSetup(); 40 | } 41 | 42 | /** 43 | * Kick off the initial requests necessary for the page to function: 44 | * * Get app config 45 | * * Get local settings 46 | * * Retrieve libraries */ 47 | async function mainSetup() { 48 | /** @type {SerializedConfig} */ 49 | let config = {}; 50 | try { 51 | config = await ServerCommands.getConfig(); 52 | } catch (err) { 53 | BaseLog.warn(errorMessage(err), 'ClientCore: Unable to get app config, assuming everything is disabled. Server responded with'); 54 | } 55 | 56 | if (!ClientSettings.parseServerConfig(config)) { 57 | // Don't continue if we were given an invalid config (or we need to run first-time setup) 58 | return; 59 | } 60 | 61 | // Even if extended marker stats are blocked in the UI, we can still enable "find all purges" 62 | // as long as we have the extended marker data available server-side (i.e. they're not blocked). 63 | PurgedMarkerManager.CreateInstance(!ClientSettings.extendedMarkerStatsBlocked()); 64 | VersionManager.CheckForUpdates(config.version.value); 65 | 66 | try { 67 | PlexUI.init(await ServerCommands.getSections()); 68 | } catch (err) { 69 | errorResponseOverlay('Error getting libraries, please verify you have provided the correct database path and try again.', err); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Client/Style/BulkActionOverlay.css: -------------------------------------------------------------------------------- 1 | #bulkActionContainer { 2 | text-align: center; 3 | 4 | & h1, h2, h3 { 5 | text-align: center; 6 | } 7 | 8 | & hr { 9 | width: 60%; 10 | min-width: calc(min(600px, 90%)); 11 | margin-top: 15px; 12 | } 13 | 14 | & input[type=text].badInput { 15 | background-color: #ff414141; 16 | } 17 | 18 | /** Customization table row/data */ 19 | & tr { 20 | cursor: default; 21 | padding-top: 5px; 22 | padding-bottom: 5px; 23 | } 24 | 25 | & tr.selectedRow { 26 | background-color: var(--bulk-action-tr-selected); 27 | } 28 | 29 | & tr:hover { 30 | background-color: var(--bulk-action-tr-hover); 31 | } 32 | 33 | & td { 34 | padding-top: 3px; 35 | padding-bottom: 3px; 36 | } 37 | 38 | & .bulkActionInactive { 39 | opacity: 0.5; 40 | } 41 | 42 | & .bulkActionOn { 43 | background: var(--bulk-action-table-green); 44 | } 45 | 46 | & .bulkActionOn:hover { 47 | background: var(--bulk-action-table-green-hover); 48 | } 49 | 50 | & .bulkActionOff { 51 | background: var(--bulk-action-table-red); 52 | } 53 | 54 | & .bulkActionOff:hover { 55 | background: var(--bulk-action-table-red-hover); 56 | } 57 | 58 | & .bulkActionSemi { 59 | background: var(--bulk-action-table-yellow); 60 | } 61 | 62 | & .bulkActionSemi:hover { 63 | background: var(--bulk-action-table-yellow-hover); 64 | } 65 | 66 | & .buttonContainer { 67 | text-align: center; 68 | width: auto; 69 | } 70 | 71 | & .multiSelectContainer { 72 | position: absolute; 73 | display: inline-block; 74 | } 75 | 76 | & .multiSelectCheck { 77 | margin: 5px 2px 5px 2px; /* Default input margin is 5px, but that's a bit too much left/right here */ 78 | } 79 | 80 | & .multiSelectLabel { 81 | font-size: 13px; 82 | vertical-align: text-top; 83 | margin-right: 2px; 84 | } 85 | 86 | & #bulkActionButtons > .button { 87 | padding: 3px 5px 3px 5px; 88 | border-radius: 2px; 89 | margin: 5px 10px 5px 10px; 90 | 91 | /** Icon-based buttons don't have a border by default, but we want one in the overlay. */ 92 | border: 1px solid var(--bulk-action-button-border); 93 | } 94 | 95 | & #chapterIndexModeContainer { 96 | font-size: smaller; 97 | display: inline-flex; 98 | 99 | & input[type=checkbox] { 100 | margin: 0; 101 | vertical-align: text-bottom; 102 | } 103 | 104 | & #chapterIndexModeHelp { 105 | margin: 0 0 0 2px; 106 | padding: 0; 107 | vertical-align: text-bottom; 108 | opacity: 0.8; 109 | } 110 | 111 | & #chapterIndexModeHelp:hover { 112 | opacity: 1; 113 | } 114 | 115 | & #chapterIndexModeHelp svg { 116 | vertical-align: text-bottom; 117 | } 118 | 119 | & #timeInputHelpIcon { 120 | cursor: pointer; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Client/Style/BulkActionOverlayDark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bulk-action-table-green: rgba(0,100,0,0.3); 3 | --bulk-action-table-green-hover: rgba(0,100,0,0.4); 4 | --bulk-action-table-yellow: #551a; 5 | --bulk-action-table-yellow-hover: #662a; 6 | --bulk-action-table-red: #612121a1; 7 | --bulk-action-table-red-hover: #812121a1; 8 | --bulk-action-inactive-text: #919191; 9 | --bulk-action-tr-hover: #04C7; 10 | --bulk-action-tr-selected: #0387; 11 | --bulk-action-button-border: rgba(133,133,133,.67); 12 | } 13 | 14 | #bulkActionContainer { 15 | /* Make the permanently disabled checkbox a bit more noticeable, but it's not needed in light mode */ 16 | & .multiSelectContainer input[type=checkbox]:not(:checked) ~ .customCheckbox { 17 | border-color: var(--theme-border-hover); 18 | } 19 | & .multiSelectContainer input[type=checkbox]:not(:checked):hover ~ .customCheckbox { 20 | border-color: var(--theme-primary); 21 | } 22 | } -------------------------------------------------------------------------------- /Client/Style/BulkActionOverlayLight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bulk-action-table-green: rgba(0,100,0,0.2); 3 | --bulk-action-table-green-hover: rgba(0,100,0,0.3); 4 | --bulk-action-table-yellow: #DD8; 5 | --bulk-action-table-yellow-hover: #CC7; 6 | --bulk-action-table-red: #f66a; 7 | --bulk-action-table-red-hover: #d66a; 8 | --bulk-action-inactive-text: #717171; 9 | --bulk-action-tr-hover: #4AF8; 10 | --bulk-action-tr-selected: #48a8; 11 | --bulk-action-button-border: rgba(93,93,93,.67); 12 | } -------------------------------------------------------------------------------- /Client/Style/MarkerTable.css: -------------------------------------------------------------------------------- 1 | /** CSS Specific to (or main for) the marker table */ 2 | 3 | table { 4 | display: table; 5 | border-collapse: collapse; 6 | white-space: normal; 7 | line-height: normal; 8 | font-weight: normal; 9 | border-spacing: 0; 10 | margin: auto; 11 | max-width: 95vw; 12 | font-size: 11pt; 13 | margin-bottom: 10px; 14 | } 15 | 16 | tbody { 17 | vertical-align: middle; 18 | } 19 | 20 | tr { 21 | display: table-row; 22 | padding: 5px; 23 | } 24 | 25 | td { 26 | padding-left: 5px; 27 | padding-right: 5px; 28 | } 29 | 30 | thead tr { 31 | background-color: transparent !important; 32 | } 33 | 34 | td>.timeInput { 35 | margin-left: 0; 36 | } 37 | 38 | table .buttonIconAndText { 39 | margin: 0px 1px 0px 1px; 40 | } 41 | 42 | #episodelist table { 43 | margin-top: 0px; 44 | } 45 | 46 | .tableHolder { 47 | max-width: 1000px; 48 | margin: auto; 49 | justify-content: center; 50 | } 51 | 52 | .spanningTableRow { 53 | text-align: center; 54 | } 55 | 56 | .timeColumn { 57 | min-width: 60px; 58 | } 59 | 60 | .shortTimeColumn { 61 | width: 80px !important; 62 | } 63 | 64 | .centeredColumn { 65 | text-align: center; 66 | } 67 | 68 | .userModifiedMarker { 69 | font-weight: bold; 70 | } 71 | 72 | .topAligned { 73 | vertical-align: top; 74 | } 75 | 76 | .topAlignedPlainText { 77 | vertical-align: top; 78 | padding-top: 5px; 79 | } 80 | 81 | .timeInput { 82 | font-family: monospace; 83 | width: 130px; 84 | } 85 | 86 | .timeInput.invalid { 87 | background-color: var(--error-background); 88 | } 89 | 90 | .thumbnailTimeInput { 91 | text-align: center; 92 | } 93 | 94 | .thumbnailTimeInput>.timeInput { 95 | margin: 5px auto 5px auto; 96 | } 97 | 98 | .thumbnailShowHide:hover { 99 | text-decoration: underline; 100 | } 101 | 102 | .inputThumb { 103 | display: block; 104 | } 105 | 106 | @keyframes initialLoad { 107 | 0% { opacity: 1.0; } 108 | 100% { opacity: 0.5; } 109 | } 110 | 111 | @keyframes loadLoop { 112 | 0% { opacity: 0.5; } 113 | 50% { opacity: 0.25; } 114 | 100% { opacity: 0.5; } 115 | } 116 | 117 | .inputThumb.loading { 118 | opacity: 0.25; 119 | animation: initialLoad 500ms, loadLoop 2s ease-in-out 500ms infinite; 120 | } 121 | 122 | .inputThumb.loaded { 123 | opacity: 1.0; 124 | } 125 | 126 | .thumbnailError { 127 | opacity: 0.7; 128 | } 129 | 130 | .editByChapter { 131 | max-width: 230px; 132 | margin: 5px auto 5px auto; 133 | } 134 | 135 | .chapterSelect { 136 | display: inline-block; 137 | width: 100%; 138 | margin: auto; 139 | text-align: center; 140 | } 141 | 142 | .markerTableSpacer { 143 | height: 10px; 144 | width: 100%; 145 | margin: 0; 146 | padding: 0; 147 | } 148 | 149 | .inlineMarkerType { 150 | margin-top: 0; 151 | } 152 | 153 | @media (min-width: 767px) { /* Probably not a phone */ 154 | .tableHolder::-webkit-scrollbar { 155 | width: 0; 156 | height: 5px; 157 | } 158 | 159 | /* Track */ 160 | .tableHolder::-webkit-scrollbar-track { 161 | background: transparent; 162 | width: 0; 163 | height: 5px; 164 | } 165 | 166 | .thumbnailEnabledTimeColumn { 167 | width: 250px !important; 168 | } 169 | 170 | .optionsColumn:not(.iconOptions) { 171 | min-width: 150px; /* "It works on my machine" */ 172 | } 173 | 174 | .optionsColumn.iconOptions3 { 175 | min-width: 110px; 176 | } 177 | 178 | .optionsColumn.iconOptions2 { 179 | min-width: 67px; 180 | } 181 | } 182 | 183 | @media all and (max-width: 767px) { /* Probably a phone */ 184 | .tableHolder { 185 | max-width: 95vw; 186 | overflow-x: auto; 187 | } 188 | 189 | .tableHolder::-webkit-scrollbar { 190 | width: 2px; 191 | height: 2px; 192 | } 193 | 194 | /* Track */ 195 | .tableHolder::-webkit-scrollbar-track { 196 | background: transparent; 197 | width: 0; 198 | height: 2px; 199 | } 200 | 201 | .tableHolder td { 202 | font-size: smaller; 203 | } 204 | 205 | .thumbnailEnabledTimeColumn { 206 | width: 190px !important; 207 | } 208 | 209 | .optionsColumn.iconOptions2 { 210 | min-width: 60px; /* "It works on my machine" */ 211 | } 212 | 213 | .optionsColumn.iconOptions3 { 214 | min-width: 90px; 215 | } 216 | } -------------------------------------------------------------------------------- /Client/Style/Overlay.css: -------------------------------------------------------------------------------- 1 | /* Taken from PlexWeb/style/overlay.css */ 2 | 3 | #mainOverlay { 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | width: 100vw; 8 | height: 100vh; 9 | overflow: auto; 10 | z-index: 3; /* Show over nav */ 11 | background-color: var(--overlay-background); 12 | scrollbar-width: thin !important; 13 | 14 | & .greenOnHover:hover, .greenOnHover:focus { 15 | background-color: var(--overlay-green-hover); 16 | } 17 | 18 | & .yellowOnHover:hover, .yellowOnHover:focus { 19 | background-color: var(--overlay-yellow-hover); 20 | } 21 | 22 | & .redOnHover:hover, .redOnHover:focus { 23 | background-color: var(--overlay-red-hover); 24 | } 25 | 26 | & .blueOnHover:hover, .blueOnHover:focus { 27 | background-color: var(--overlay-blue-hover); 28 | } 29 | 30 | & hr { 31 | border: 1px solid #53575a; 32 | } 33 | } 34 | 35 | #overlayContainer { 36 | width: auto; 37 | height: auto; 38 | color: var(--theme-primary); 39 | } 40 | 41 | .fullOverlay { 42 | margin-top: 50px; 43 | margin-bottom: 50px; 44 | } 45 | 46 | .overlayDiv { 47 | padding-bottom: 20px; 48 | margin: auto; 49 | display: block; 50 | text-align: center; 51 | overflow: auto; 52 | } 53 | 54 | .overlayTextarea { 55 | display: block; 56 | width: 50%; 57 | min-width: 200px; 58 | margin: auto; 59 | float: none; 60 | height: 50px; 61 | } 62 | 63 | .overlayInput { 64 | display: block; 65 | float: none; 66 | margin: auto; 67 | } 68 | 69 | .overlayButton { 70 | margin-top: 10px; 71 | padding: 10px; 72 | background-color: transparent; 73 | border: 1px solid #53575a; 74 | } 75 | 76 | .overlayButton:hover { 77 | border-color: var(--theme-primary); 78 | } 79 | 80 | .overlayInlineButton { 81 | display : inline; 82 | float : none; 83 | margin: auto; 84 | margin-top: 10px; 85 | padding: 10px; 86 | width: 100px; 87 | } 88 | 89 | .centeredOverlay { 90 | position: relative; 91 | top: 50%; 92 | transform: translateY(-50%); 93 | } 94 | 95 | .overlayCloseButton { 96 | position: fixed; 97 | top: 10px; 98 | right: 20px; 99 | width: 25px; 100 | opacity: 0.6; 101 | } 102 | 103 | .overlayCloseButton:hover { 104 | opacity: 1; 105 | } 106 | 107 | #overlayBtn { 108 | margin: auto; 109 | text-align: center; 110 | display: block; 111 | } 112 | 113 | .darkerOverlay { 114 | background-color: var(--overlay-darker); 115 | } 116 | 117 | /* Desktop/Landscape */ 118 | @media (min-width: 767px) { 119 | .fullOverlay { 120 | margin-left: 5%; 121 | margin-right: 5%; 122 | } 123 | 124 | .defaultOverlay { 125 | margin-top: 25vh; 126 | } 127 | 128 | #overlayContainer { 129 | padding: 20px; 130 | } 131 | } 132 | 133 | /* Phone/Portrait */ 134 | @media all and (max-width: 767px) { 135 | .fullOverlay { 136 | margin-left: 10px; 137 | margin-right: 10px; 138 | } 139 | 140 | .defaultOverlay { 141 | margin-top: 10vh; 142 | } 143 | 144 | #overlayContainer { 145 | padding: 5px; 146 | } 147 | } -------------------------------------------------------------------------------- /Client/Style/OverlayDark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --overlay-background: rgba(0,0,0,0.6); 3 | --overlay-darker: rgba(0,0,0,0.3); 4 | --overlay-modal-background: rgba(0,0,0,0.6); 5 | --overlay-green-hover: #050a; 6 | --overlay-yellow-hover: #a80a !important; 7 | --overlay-red-hover: #700a !important; 8 | --overlay-blue-hover: #46aa !important; 9 | } 10 | -------------------------------------------------------------------------------- /Client/Style/OverlayLight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --overlay-background: rgba(200, 200, 200, 0.7); 3 | --overlay-darker: #fecd; 4 | --overlay-modal-background: #fecd; 5 | --overlay-green-hover: #9E9a; 6 | --overlay-yellow-hover: #fb0a !important; 7 | --overlay-red-hover: #f66a !important; 8 | --overlay-blue-hover: #8afa !important; 9 | } 10 | -------------------------------------------------------------------------------- /Client/Style/Tooltip.css: -------------------------------------------------------------------------------- 1 | /* Taken from PlexWeb/style/tooltip.css */ 2 | 3 | .tooltipSticky { 4 | background-clip: content-box; /* Make sure padding doesn't inherit tooltip background color. */ 5 | } 6 | 7 | #tooltipInner { 8 | border: 1px solid #616161; 9 | padding: 3px; 10 | } 11 | 12 | #tooltip { 13 | font-size: 11pt; 14 | display: none; 15 | border-radius: 3px; 16 | position: absolute; 17 | max-width: calc(min(80%, 350px)); 18 | z-index: 99; /* always on top */ 19 | color: var(--theme-primary); 20 | background-color: var(--tooltip-background); 21 | 22 | & hr { 23 | margin-top: 3px; 24 | margin-bottom: 3px; 25 | opacity: 0.8; 26 | } 27 | 28 | &.largerText { 29 | font-size: 12pt; 30 | } 31 | 32 | &.smallerText { 33 | font-size: 10pt; 34 | } 35 | 36 | &.noBreak { 37 | white-space: nowrap; 38 | } 39 | 40 | &.larger { 41 | max-width: calc(min(90%, 650px)); 42 | } 43 | 44 | &.centered { 45 | text-align: center; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Client/Style/themeDark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Core colors */ 3 | --theme-primary: #c1c1c1; 4 | --theme-secondary: #919191; 5 | --theme-background: #212121; 6 | --theme-border: #616161; 7 | --theme-border-hover: #818181; 8 | --theme-green: #4c4; 9 | --theme-red: #c44; 10 | --theme-orange: #c94; 11 | --theme-input-background: #414141; 12 | --theme-input-background-subtle: #8882; 13 | --theme-hr-color: #999; 14 | --theme-focus-color: #ff7f00; 15 | 16 | /* Errors */ 17 | --error-background: #9337; 18 | --error-background-subtle: #9334; 19 | --error-border: #C44; 20 | 21 | /* Warnings */ 22 | --warn-background: #9937; 23 | --warn-background-subtle: #9934; 24 | --warn-border: #CC4; 25 | 26 | /* Info */ 27 | --info-background: #3397; 28 | --info-border: #44C; 29 | 30 | /* Success */ 31 | --success-background: #3934; 32 | --success-background-subtle: #3932; 33 | --success-border: #4C4; 34 | 35 | /* Main sections */ 36 | --media-item-hover: rgba(0,0,0,0.5); 37 | --section-options-background: rgba(0,0,0,0.4); 38 | --section-options-hover: rgba(0,0,0,0.5); 39 | --section-options-input-hover: rgba(255,255,255,0.1); 40 | 41 | /* Custom checkbox */ 42 | --custom-checkbox-background: #383838AA; 43 | --custom-checkbox-checked: #a09d9a; 44 | --custom-checkbox-hover: transparent; 45 | --custom-checkbox-checked-hover: #908d8a; 46 | --custom-checkbox-checked-check: #1f2225; 47 | --custom-checkbox-checked-hover-check: #5f3235; 48 | 49 | /* Marker Table */ 50 | --table-odd-row: #1116; 51 | --table-odd-hover: #11112166; 52 | --table-even-row: #21212166; 53 | --table-even-row-hover: #21213166; 54 | 55 | /* Update Bar */ 56 | --update-bar-background: #040C; 57 | --update-bar-input-background: #030C; 58 | --update-bar-input-background-hover: #202C; 59 | 60 | /* Misc */ 61 | --background-grad: linear-gradient(#212121CC, #402200CC); 62 | --tooltip-background: rgba(50, 50, 50, .7); 63 | --button-disabled-border: rgba(193,193,193,.67); 64 | --button-disabled-shadow: #919191; 65 | --text-button-border: #666; 66 | --code-background: #282828; 67 | 68 | scrollbar-color: rgba(97, 97, 97, 0.76) #212121 !important; 69 | } 70 | 71 | /* Light theme uses default values */ 72 | a { 73 | color: #81a1e1; 74 | } 75 | 76 | a:visited { 77 | color: #6191c1; 78 | } 79 | 80 | ::-ms-reveal { 81 | /* "Show Password" eye icon is always black, which blends into dark mode backgrounds. Invert its color. */ 82 | filter: invert(80%); 83 | } -------------------------------------------------------------------------------- /Client/Style/themeLight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Core colors */ 3 | --theme-primary: #212121; 4 | --theme-secondary: #414141; 5 | --theme-background: #ffefcc; 6 | --theme-border: #616161; 7 | --theme-border-hover: #212121; 8 | --theme-green: #292; 9 | --theme-red: #A22; 10 | --theme-orange: #A22; /* Just red, looks better than orange/brown */ 11 | --theme-input-background: #f8e8c4; 12 | --theme-input-background-subtle: #fff2; 13 | --theme-hr-color: #444; 14 | --theme-focus-color: #aa4f00; 15 | 16 | /* Errors */ 17 | --error-background: #F777; 18 | --error-background-subtle: #F774; 19 | --error-border: #C44; 20 | 21 | /* Warnings */ 22 | --warn-background: #FF77; 23 | --warn-background-subtle: #FF74; 24 | --warn-border: #CC4; 25 | 26 | /* Info */ 27 | --info-background: #44F7; 28 | --info-border: #22C; 29 | 30 | /* Success */ 31 | --success-background: #7F74; 32 | --success-background-subtle: #7F72; 33 | --success-border: #4C4; 34 | 35 | /* Main sections */ 36 | --media-item-hover: rgba(0,0,0,0.2); 37 | --section-options-background: rgba(30,100,0,0.2); 38 | --section-options-hover: rgba(30,100,0,0.1); 39 | --section-options-input-hover: rgba(255,255,255,0.3); 40 | 41 | /* Custom checkbox */ 42 | --custom-checkbox-background: #fec; 43 | --custom-checkbox-checked: #706d6a; 44 | --custom-checkbox-hover: #edb; 45 | --custom-checkbox-checked-hover: #807d7a; 46 | --custom-checkbox-checked-check: #d0edca; 47 | --custom-checkbox-checked-hover-check: #d0edca; 48 | 49 | /* Marker Table */ 50 | --table-odd-row: #fec6; 51 | --table-odd-hover: #ffeebb66; 52 | --table-even-row: #edb6; 53 | --table-even-row-hover: #eda6; 54 | 55 | /* Update Bar */ 56 | --update-bar-background: #9D9; 57 | --update-bar-input-background: #CFC; 58 | --update-bar-input-background-hover: #DFD; 59 | 60 | /* Misc */ 61 | --background-grad: linear-gradient(#FFEFCCDD, #CCA066DD); 62 | --tooltip-background: rgba(255, 240, 200, 0.8); 63 | --button-disabled-border: rgba(93,93,93,.67); 64 | --button-disabled-shadow: #414141; 65 | --text-button-border: #333; 66 | --code-background: #fff8; 67 | 68 | scrollbar-color: #A19171 #C1B191 !important; 69 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # LTS (as of 2024/03) 2 | FROM node:20 3 | 4 | # Set production env 5 | ARG NODE_ENV=production 6 | ENV NODE_ENV $NODE_ENV 7 | 8 | # Copy package[-lock].json to install dependencies 9 | COPY package*.json ./ 10 | RUN npm ci && npm cache clean --force 11 | 12 | # Copy everything else over 13 | COPY . . 14 | 15 | # Listen on 3232 16 | EXPOSE 3232 17 | 18 | # Let the app know we're in a Docker environment 19 | ENV IS_DOCKER=1 20 | 21 | # Ensure the main /Data directory exists, which is where the 22 | # config and backup database will be stored. 23 | RUN mkdir /Data 24 | 25 | VOLUME [ "/Data" ] 26 | 27 | # Run the app 28 | CMD [ "node", "app.js" ] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Rahn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Marker Editor for Plex 2 | 3 | Plex does not let users modify or manually add markers, relying solely on their own detection processes. This project aims to make it easier to view/edit/add/delete individual markers, as well as apply bulk add/edit/delete operations to a season or an entire show. It can also be used to add multiple markers, for example a "previously on XYZ" section (as seen in the image below). 4 | 5 | ![Application Overview]( 6 | https://github.com/user-attachments/assets/88e6b47e-5ab7-4c11-874b-6e089d163f2f) 7 | 8 | 9 | **NOTE**: While this project has been proven to work for my own individual use cases, it interacts with your Plex database in an unsupported way, and offers no guarantees against breaking your database, neither now or in the future. **_Use at your own risk_**. 10 | 11 | ## Installation 12 | 13 | For detailed instructions, see [Prerequisites and Downloading the Project](https://github.com/danrahn/MarkerEditorForPlex/wiki/installation). 14 | 15 | If available, download the latest [release](https://github.com/danrahn/MarkerEditorForPlex/releases) that matches your system, extract the contents to a new folder, and run MarkerEditorForPlex. 16 | 17 | In Docker: 18 | 19 | ```bash 20 | docker run -p 3233:3232 \ 21 | -v /path/to/config:/Data \ 22 | -v /path/to/PlexData:/PlexDataDirectory \ 23 | -it danrahn/intro-editor-for-plex:latest 24 | ``` 25 | 26 | For platforms that don't have a binary release available (or to run from source): 27 | 28 | 1. Install [Node.js](https://nodejs.org/en/) 29 | 2. [`git clone`](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) this repository or [Download it as a ZIP](https://github.com/danrahn/MarkerEditorForPlex/archive/refs/heads/main.zip) 30 | 3. Install dependencies by running `npm install` from the root of the project 31 | 4. Run `node app.js` from the root of the project 32 | 33 | ## Configuration 34 | 35 | See [Configuring Marker Editor for Plex](https://github.com/danrahn/MarkerEditorForPlex/wiki/configuration) for details on the various settings within `config.json`. 36 | 37 | ## Using Marker Editor 38 | 39 | Before using Marker Editor, it's _strongly_ encouraged to shut down PMS. On some systems, this is required. It's also strongly encouraged to make sure you have a recent database backup available in case something goes wrong. While core functionality been tested fairly extensively, there are no guarantees that something won't go wrong, or that an update to PMS will break this applications. 40 | 41 | For more information on how to use Marker Editor, see [Using Marker Editor for Plex](https://github.com/danrahn/MarkerEditorForPlex/wiki/usage). 42 | 43 | ## Notes 44 | 45 | Due to how Plex generates and stores markers, reanalyzing items in Plex (potentially indirectly by adding a new episode to an existing season) will result in any marker customizations being wiped out and set back to values based on Plex's analyzed data. This application has [a system to detect and restore manual edits](https://github.com/danrahn/MarkerEditorForPlex/wiki/usage#purged-markers), but it's not an automated process. 46 | -------------------------------------------------------------------------------- /SVG/arrow.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SVG/back.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /SVG/badThumb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 🎞 Error 🎞 11 | 12 | 13 | -------------------------------------------------------------------------------- /SVG/cancel.svg: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | -------------------------------------------------------------------------------- /SVG/chapter.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /SVG/confirm.svg: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | -------------------------------------------------------------------------------- /SVG/cursor.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /SVG/delete.svg: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /SVG/edit.svg: -------------------------------------------------------------------------------- 1 | 6 | 12 | 16 | -------------------------------------------------------------------------------- /SVG/favicon.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /SVG/filter.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SVG/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /SVG/imgIcon.svg: -------------------------------------------------------------------------------- 1 | 3 | 7 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /SVG/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /SVG/loading.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 13 | 22 | 23 | -------------------------------------------------------------------------------- /SVG/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /SVG/noise.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /SVG/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /SVG/restart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /SVG/settings.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /SVG/table.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SVG/warn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Server/Authentication/AuthDatabase.js: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync } from 'fs'; 2 | import { join } from 'path'; 3 | 4 | import { authSchemaUpgrades, authSchemaVersion } from './AuthenticationConstants.js'; 5 | import { ContextualLog } from '../../Shared/ConsoleLog.js'; 6 | import SqliteDatabase from '../SqliteDatabase.js'; 7 | 8 | const Log = ContextualLog.Create('AuthDB'); 9 | 10 | /** @readonly @type {AuthDatabase} */ 11 | export let AuthDB; 12 | 13 | /** 14 | * This class owns the creation and lifetime of the authentication database, which holds 15 | * active sessions, user information, and old session secrets. 16 | */ 17 | export class AuthDatabase { 18 | /** 19 | * Create a connection to the authentication database, creating the database if it does not exist. 20 | * @param {string} dataRoot The path to the root of this application. */ 21 | static async Initialize(dataRoot) { 22 | if (AuthDB) { 23 | return AuthDB; 24 | } 25 | 26 | AuthDB = new AuthDatabase(); 27 | await AuthDB.#init(dataRoot); 28 | } 29 | 30 | /** 31 | * Close the connection to the authentication DB. */ 32 | static async Close() { 33 | const auth = AuthDB; 34 | AuthDB = null; 35 | await auth?.db()?.close(); 36 | } 37 | 38 | /** @type {SqliteDatabase} */ 39 | #db; 40 | 41 | /** 42 | * Initialize (and create if necessary) the authentication database. 43 | * @param {string} dataRoot */ 44 | async #init(dataRoot) { 45 | if (this.#db) { 46 | await this.#db.close(); 47 | } 48 | 49 | const dbRoot = join(dataRoot, 'Backup'); 50 | if (!existsSync(dbRoot)) { 51 | mkdirSync(dbRoot); 52 | } 53 | 54 | const dbPath = join(dbRoot, 'auth.db'); 55 | const db = await SqliteDatabase.OpenDatabase(dbPath, true /*allowCreate*/); 56 | this.#db = db; 57 | let version = 0; 58 | try { 59 | version = (await db.get('SELECT version FROM schema_version;'))?.version || 0; 60 | } catch (err) { 61 | Log.info('Version information not found in auth DB, starting from scratch.'); 62 | } 63 | 64 | await this.#upgradeSchema(version); 65 | } 66 | 67 | /** 68 | * Attempts to upgrade the database schema if it's not the latest version. 69 | * @param {number} currentSchema */ 70 | async #upgradeSchema(currentSchema) { 71 | while (currentSchema < authSchemaVersion) { 72 | Log.info(`Upgrade auth db schema from ${currentSchema} to ${currentSchema + 1}`); 73 | await this.#db.exec(authSchemaUpgrades[currentSchema]); 74 | if (currentSchema === 0) { 75 | return; 76 | } 77 | 78 | ++currentSchema; 79 | } 80 | } 81 | 82 | db() { return this.#db; } 83 | } 84 | -------------------------------------------------------------------------------- /Server/Authentication/AuthenticationConstants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} DBUser 3 | * @property {number} id 4 | * @property {string} username 5 | * @property {string} user_norm 6 | * @property {string} password 7 | */ 8 | 9 | export const SessionTableName = 'sessions'; 10 | export const UserTableName = 'users'; 11 | export const SessionSecretTableName = 'secrets'; 12 | 13 | const sessionTable = ` 14 | CREATE TABLE IF NOT EXISTS ${SessionTableName} ( 15 | session_id TEXT NOT NULL PRIMARY KEY, 16 | session JSON NOT NULL, 17 | expire INTEGER NOT NULL 18 | );`.replace(/ +/g, ' '); // Extra spaces are nice for readability, but they're entered as-is 19 | // into the database, which can make changing the schema more difficult. 20 | 21 | const userTable = ` 22 | CREATE TABLE IF NOT EXISTS ${UserTableName} ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, 24 | username TEXT NOT NULL UNIQUE, 25 | user_norm TEXT NOT NULL UNIQUE, 26 | password TEXT NOT NULL 27 | );`.replace(/ +/g, ' '); 28 | 29 | const secretTable = ` 30 | CREATE TABLE IF NOT EXISTS ${SessionSecretTableName} ( 31 | id INTEGER PRIMARY KEY AUTOINCREMENT, 32 | key TEXT NOT NULL, 33 | https INTEGER DEFAULT 0,` /* V2: Differentiate between HTTP and HTTPS secrets. */ + ` 34 | created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 35 | );`.replace(/ +/g, ' '); 36 | 37 | export const authSchemaVersion = 2; 38 | const schemaVersionTable = ` 39 | CREATE TABLE IF NOT EXISTS schema_version ( 40 | version INTEGER 41 | ); 42 | INSERT INTO schema_version (version) SELECT ${authSchemaVersion} WHERE NOT EXISTS (SELECT * FROM schema_version); 43 | `; 44 | 45 | /** @type {(table: string) => string} Create "DROP TABLE IF EXISTS" statement for the given table. */ 46 | const dtii = table => `DROP TABLE IF EXISTS ${table};`; 47 | 48 | export const AuthDatabaseSchema = `${sessionTable} ${userTable} ${secretTable} ${schemaVersionTable}`; 49 | 50 | /** 51 | * Array of database queries to run when upgrading to a particular schema version. */ 52 | export const authSchemaUpgrades = [ 53 | // Version 0 - no existing database, so create everything. 54 | `${dtii(SessionTableName)} ${dtii(UserTableName)} ${dtii(SessionSecretTableName)} ${dtii('schema_version')} 55 | ${AuthDatabaseSchema}`, 56 | 57 | // Version 1 -> 2: Add https column to secrets table. 58 | `ALTER TABLE ${SessionSecretTableName} ADD COLUMN https INTEGER DEFAULT 0; 59 | UPDATE schema_version SET version=2;` 60 | ]; 61 | 62 | -------------------------------------------------------------------------------- /Server/Commands/AuthenticationCommands.js: -------------------------------------------------------------------------------- 1 | import { Config } from '../Config/MarkerEditorConfig.js'; 2 | import { PostCommands } from '../../Shared/PostCommands.js'; 3 | import { registerCommand } from './PostCommand.js'; 4 | import ServerError from '../ServerError.js'; 5 | import { User } from '../Authentication/Authentication.js'; 6 | 7 | /** @typedef {!import('express').Request} ExpressRequest */ 8 | /** @typedef {!import('express').Response} ExpressResponse */ 9 | /** @typedef {!import('express-session')} Session */ 10 | /** @typedef {Session.Session & Partial} ExpressSession */ 11 | 12 | /** 13 | * Attempts to log into marker editor. 14 | * @param {string} password 15 | * @param {ExpressSession} session */ 16 | async function login(username, password, session) { 17 | if (!Config.useAuth() || !User.passwordSet()) { 18 | throw new ServerError('Unexpected call to /login - authentication is disabled or not set up.', 400); 19 | } 20 | 21 | if (!(await User.login(username, password))) { 22 | throw new ServerError('Incorrect username or password', 401); 23 | } 24 | 25 | session.authenticated = true; 26 | } 27 | 28 | /** 29 | * Log out of the current session. 30 | * @param {ExpressRequest} request 31 | * @param {ExpressResponse} response */ 32 | function logout(request, response) { 33 | if (!Config.useAuth() || !User.passwordSet() || !request.session.authenticated) { 34 | throw new ServerError('Unexpected call to /logout - user is not signed in.', 400); 35 | } 36 | 37 | return new Promise(resolve => { 38 | request.session.destroy(err => { 39 | response.clearCookie('markereditor.sid'); 40 | if (err) { 41 | throw new ServerError(`Failed to logout: ${err.message}`, 500); 42 | } 43 | 44 | resolve(); 45 | }); 46 | }); 47 | } 48 | 49 | /** 50 | * Change the current single-user password. 51 | * @param {string} username 52 | * @param {string} oldPassword 53 | * @param {string} newPassword 54 | * @param {ExpressRequest} request */ 55 | async function changePassword(username, oldPassword, newPassword, request) { 56 | // This can also enable auth if no password is set and oldPassword is blank. 57 | if (!Config.useAuth() && oldPassword !== '') { 58 | throw new ServerError('Unexpected call to /change_password, authentication is not enabled.', 400); 59 | } 60 | 61 | const firstSet = !request.session?.authenticated && !User.passwordSet(); 62 | if (!request.session?.authenticated && User.passwordSet()) { 63 | throw new ServerError('Cannot change password when not logged in.', 403); 64 | } 65 | 66 | if (!newPassword) { 67 | throw new ServerError('New password cannot be empty.', 400); 68 | } 69 | 70 | if (oldPassword === newPassword) { 71 | throw new ServerError('New password cannot match old password.', 400); 72 | } 73 | 74 | if (!(await User.changePassword(username, oldPassword, newPassword))) { 75 | throw new ServerError('Old password does not match.', 403); 76 | } 77 | 78 | if (firstSet) { 79 | // eslint-disable-next-line require-atomic-updates 80 | if (request.session) { 81 | request.session.authenticated = true; 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * Check whether user authentication is enabled, but a password is not set. 88 | * @returns {{ value: boolean }} */ 89 | function needsPassword() { 90 | return { 91 | value : Config.useAuth() && !User.passwordSet() 92 | }; 93 | } 94 | 95 | /** Register authentication related commands. */ 96 | export function registerAuthCommands() { 97 | registerCommand(PostCommands.Login, q => login(q.fs('username'), q.fs('password'), q.r().session)); 98 | registerCommand(PostCommands.Logout, q => logout(q.r(), q.response())); 99 | registerCommand(PostCommands.ChangePassword, q => changePassword(q.fs('username'), q.fs('oldPass'), q.fs('newPass'), q.r())); 100 | registerCommand(PostCommands.NeedsPassword, _q => needsPassword()); 101 | } 102 | -------------------------------------------------------------------------------- /Server/Commands/ConfigCommands.js: -------------------------------------------------------------------------------- 1 | import { ServerConfigState, ServerSettings } from '../../Shared/ServerConfig.js'; 2 | 3 | import { ServerEvents, waitForServerEvent } from '../ServerEvents.js'; 4 | import { ServerState, SetServerState } from '../ServerState.js'; 5 | import { Config } from '../Config/MarkerEditorConfig.js'; 6 | import { PostCommands } from '../../Shared/PostCommands.js'; 7 | import { registerCommand } from './PostCommand.js'; 8 | import { sendJsonSuccess } from '../ServerHelpers.js'; 9 | import ServerError from '../ServerError.js'; 10 | 11 | /** @typedef {!import('express').Response} ExpressResponse */ 12 | 13 | /** @typedef {!import('/Shared/ServerConfig').SerializedConfig} SerializedConfig */ 14 | /** @typedef {!import('/Shared/ServerConfig').TypedSetting} TypedSetting */ 15 | 16 | /** 17 | * Retrieve a subset of the app configuration that the frontend needs access to. 18 | * This is only async to conform with the command handler signature. */ 19 | function getConfig() { 20 | return Config.serialize(); 21 | } 22 | 23 | /** 24 | * Validate a serialized config file. 25 | * @param {SerializedConfig} config */ 26 | function validateConfig(config) { 27 | return Config.validateConfig(config); 28 | } 29 | 30 | /** 31 | * Validate a single setting of the server configuration. 32 | * @param {string} setting 33 | * @param {string} value */ 34 | async function validateConfigValue(setting, value) { 35 | let asJson; 36 | try { 37 | asJson = JSON.parse(value); 38 | } catch (_ex) { 39 | throw new ServerError(`Invalid configuration value. Expected JSON object, but couldn't parse it`, 400); 40 | } 41 | 42 | const checkedSetting = await Config.validateField(setting, asJson); 43 | if (setting === ServerSettings.Password) { 44 | // Don't pass the actual password back to the client. 45 | checkedSetting.setValue(''); 46 | } 47 | 48 | return checkedSetting.serialize(); 49 | } 50 | 51 | /** 52 | * Replace the current configuration with the given configuration, if valid. 53 | * @param {SerializedConfig} config 54 | * @param {ExpressResponse} response */ 55 | async function setConfig(config, response) { 56 | const oldConfigState = Config.getValid(); 57 | const newConfig = await Config.trySetConfig(config); 58 | switch (newConfig.config.state) { 59 | case ServerConfigState.FullReloadNeeded: 60 | await waitForServerEvent(ServerEvents.HardRestart, response, newConfig); 61 | break; 62 | case ServerConfigState.ReloadNeeded: 63 | await waitForServerEvent(ServerEvents.SoftRestart, response, newConfig); 64 | break; 65 | default: 66 | // If we were previously in a valid state, we can just mark the server as 67 | // running and return. Otherwise, we need to do a reload, since an invalid 68 | // state implies we haven't set up any of our core classes. 69 | if (oldConfigState === ServerConfigState.Valid) { 70 | SetServerState(ServerState.Running); 71 | sendJsonSuccess(response, newConfig); 72 | } else { 73 | await waitForServerEvent(ServerEvents.SoftRestart, response, newConfig); 74 | } 75 | break; 76 | } 77 | } 78 | 79 | /** 80 | * Register all commands related to server configuration. 81 | * Validation commands use form fields to "hide" values from logging so we 82 | * don't log password validation information. */ 83 | export function registerConfigCommands() { 84 | registerCommand(PostCommands.GetConfig, _ => getConfig()); 85 | registerCommand(PostCommands.ValidateConfig, q => validateConfig(q.fc('config', JSON.parse))); 86 | registerCommand(PostCommands.ValidateConfigValue, q => validateConfigValue(q.fs('setting'), q.fs('value'))); 87 | registerCommand(PostCommands.SetConfig, q => setConfig(q.fc('config', JSON.parse), q.response()), true /*ownsResponse*/); 88 | } 89 | -------------------------------------------------------------------------------- /Server/Commands/PostCommand.js: -------------------------------------------------------------------------------- 1 | /** @typedef {!import('../QueryParse').QueryParser} QueryParser */ 2 | 3 | import ServerError from '../ServerError.js'; 4 | 5 | /** @typedef {(params: QueryParser) => Promise} POSTCallback */ 6 | 7 | class PostCommand { 8 | #ownsResponse = false; 9 | /** @type {(params: QueryParser) => Promise} */ 10 | #handler; 11 | constructor(commandHandler, ownsResponse) { 12 | this.#handler = commandHandler; 13 | this.#ownsResponse = ownsResponse; 14 | } 15 | 16 | handler() { return this.#handler; } 17 | ownsResponse() { return this.#ownsResponse; } 18 | } 19 | 20 | /** @type {Map} */ 21 | const RegisteredCommands = new Map(); 22 | 23 | /** 24 | * Register a new POST endpoint 25 | * @param {string} endpoint The endpoint to register 26 | * @param {POSTCallback} callback 27 | * @param {bool} [ownsResponse=false] */ 28 | export function registerCommand(endpoint, callback, ownsResponse=false) { 29 | RegisteredCommands.set(endpoint, new PostCommand(callback, ownsResponse)); 30 | } 31 | 32 | /** 33 | * Get the PostCommand associated with the given endpoint. 34 | * @param {string} endpoint 35 | * @throws {ServerError} If the endpoint isn't registered. */ 36 | export function getPostCommand(endpoint) { 37 | if (!RegisteredCommands.has(endpoint)) { 38 | throw new ServerError(`Invalid endpoint: ${endpoint}`, 404); 39 | } 40 | 41 | return RegisteredCommands.get(endpoint); 42 | } 43 | -------------------------------------------------------------------------------- /Server/Config/AuthenticationConfig.js: -------------------------------------------------------------------------------- 1 | import ConfigBase from './ConfigBase.js'; 2 | import { ContextualLog } from '../../Shared/ConsoleLog.js'; 3 | 4 | /** @typedef {!import('./ConfigBase').ConfigBaseProtected} ConfigBaseProtected */ 5 | /** @typedef {!import('./ConfigBase').GetOrDefault} GetOrDefault */ 6 | /** @template T @typedef {!import('/Shared/ServerConfig').Setting} Setting */ 7 | 8 | /** 9 | * @typedef {{ 10 | * enabled?: boolean, 11 | * sessionTimeout?: number 12 | * }} RawAuthConfig 13 | */ 14 | 15 | const Log = ContextualLog.Create('EditorConfig'); 16 | 17 | /** 18 | * Captures the 'authentication' portion of the configuration file. 19 | */ 20 | export default class AuthenticationConfig extends ConfigBase { 21 | /** @type {ConfigBaseProtected} */ 22 | #Base = {}; 23 | /** @type {Setting} */ 24 | enabled; 25 | /** @type {Setting} */ 26 | sessionTimeout; 27 | 28 | constructor(json) { 29 | const baseClass = {}; 30 | super(json, baseClass); 31 | this.#Base = baseClass; 32 | if (!json) { 33 | Log.warn('Authentication not found in config, setting defaults'); 34 | } 35 | 36 | this.enabled = this.#getOrDefault('enabled', false); 37 | this.sessionTimeout = this.#getOrDefault('sessionTimeout', 86_400); 38 | if (this.sessionTimeout < 300) { 39 | Log.warn(`Session timeout must be at least 300 seconds, found ${this.sessionTimeout}. Setting to 300.`); 40 | this.sessionTimeout = 300; 41 | } 42 | } 43 | 44 | /** Forwards to {@link ConfigBase}s `#getOrDefault` 45 | * @type {GetOrDefault} */ 46 | #getOrDefault(key, defaultValue=null) { 47 | return this.#Base.getOrDefault(key, defaultValue); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Server/Config/ConfigBase.js: -------------------------------------------------------------------------------- 1 | import { ContextualLog } from '../../Shared/ConsoleLog.js'; 2 | import { Setting } from '../../Shared/ServerConfig.js'; 3 | 4 | const Log = ContextualLog.Create('EditorConfig'); 5 | 6 | /** 7 | * @typedef {(key: string, defaultValue?: T, defaultType?: string) => Setting} GetOrDefault 8 | */ 9 | 10 | /** 11 | * The protected fields of ConfigBase that are available to derived classes, but not available externally. 12 | * @typedef {{json : Object, getOrDefault : (key: string, defaultValue?: T, defaultType?: string) => Setting }} ConfigBaseProtected */ 13 | 14 | /** 15 | * Base class for a piece of a configuration file. 16 | * 17 | * Note that this is also acting as a bit of an experiment with "protected" members, i.e. members 18 | * that are only accessible to the base class and those that derive from it. To accomplish this, 19 | * derived classes pass in an empty object to this base class's constructor, and this class 20 | * populates it with the "protected" members, bound to this base instance. Derived classes then 21 | * set their own private #Base member to that object, and use it as a proxy to this classes 22 | * private members. 23 | * 24 | * It's not super clean, and probably much easier to just make the base members public, or 25 | * duplicate the code between PlexFeatures and MarkerEditorConfig, but where's the fun in that? 26 | */ 27 | export default class ConfigBase { 28 | /** The raw configuration file. 29 | * @type {object} */ 30 | #json; 31 | 32 | /** 33 | * @param {object} json 34 | * @param {ConfigBaseProtected} protectedFields Out parameter - contains private members and methods 35 | * to share with the derived class that called us, making them "protected" */ 36 | constructor(json, protectedFields) { 37 | this.#json = json || {}; 38 | protectedFields.getOrDefault = this.#getOrDefault.bind(this); 39 | protectedFields.json = this.#json; 40 | } 41 | 42 | /** 43 | * @template T 44 | * @param {string} key The config property to retrieve. 45 | * @param {T?} [defaultValue] The default value if the property doesn't exist. 46 | * @param {string?} defaultType If defaultValue is a function, defaultType indicates the return value type. 47 | * @returns {Setting} The retrieved property value. 48 | * @throws if `value` is not in the config and `defaultValue` is not set. */ 49 | #getOrDefault(key, defaultValue=null, defaultType=null) { 50 | if (!Object.prototype.hasOwnProperty.call(this.#json, key)) { 51 | if (defaultValue === null) { 52 | throw new Error(`'${key}' not found in config file, and no default is available.`); 53 | } 54 | 55 | // Some default values are non-trivial to determine, so don't compute it 56 | // until we know we need it. 57 | if (typeof defaultValue === 'function') { 58 | defaultValue = defaultValue(); 59 | } 60 | 61 | Log.info(`'${key}' not found in config file. Defaulting to '${defaultValue}'.`); 62 | return new Setting(null, defaultValue); 63 | } 64 | 65 | // If we have a default value and its type doesn't match what's in the config, reset it to default. 66 | const value = this.#json[key]; 67 | return this.#checkType(key, value, defaultValue, defaultType); 68 | } 69 | 70 | /** 71 | * @param {string} key 72 | * @param {any} value 73 | * @param {any} defaultValue 74 | * @param {string?} defaultType */ 75 | #checkType(key, value, defaultValue, defaultType) { 76 | const vt = typeof value; 77 | let dt = typeof defaultValue; 78 | 79 | if (dt === 'function') { 80 | Log.assert(defaultType !== null, '#checkType - Cant have a null defaultType if defaultValue is a function.'); 81 | dt = defaultType; 82 | } 83 | 84 | if (defaultValue === null || vt === dt) { 85 | Log.verbose(`Setting ${key} to ${dt === 'object' ? JSON.stringify(value) : value}`); 86 | return new Setting(value, defaultValue); 87 | } 88 | 89 | Log.warn(`Type Mismatch: '${key}' should have a type of '${dt}', found '${vt}'. Attempting to coerce...`); 90 | 91 | const space = ' '; 92 | // Allow some simple conversions 93 | switch (dt) { 94 | case 'boolean': 95 | // Intentionally don't allow for things like tRuE, just the standard lower- or title-case. 96 | if (value === 'true' || value === 'True' || value === '1' || value === 1) { 97 | Log.warn(`${space}Coerced to boolean value 'true'`); 98 | return new Setting(true, defaultValue); 99 | } 100 | 101 | if (value === 'false' || value === 'False' || value === '0' || value === 0) { 102 | Log.warn(`${space}Coerced to boolean value 'false'`); 103 | return new Setting(false, defaultValue); 104 | } 105 | 106 | break; 107 | case 'string': 108 | switch (vt) { 109 | case 'boolean': 110 | case 'number': 111 | Log.warn(`${space}Coerced to string value '${value.toString()}'`); 112 | return new Setting(value.toString(), defaultValue); 113 | } 114 | break; 115 | case 'number': { 116 | const asNum = +value; 117 | if (!isNaN(asNum)) { 118 | Log.warn(`${space}Coerced to number value '${asNum}'`); 119 | return new Setting(asNum, defaultValue); 120 | } 121 | break; 122 | } 123 | } 124 | 125 | const ret = (typeof defaultValue === 'function') ? defaultValue() : defaultValue; 126 | Log.error(`${space}Could not coerce. Ignoring value '${value}' and setting to '${ret}'`); 127 | return new Setting(null, ret); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Server/Config/FeaturesConfig.js: -------------------------------------------------------------------------------- 1 | import ConfigBase from './ConfigBase.js'; 2 | import { ContextualLog } from '../../Shared/ConsoleLog.js'; 3 | import { testFfmpeg } from '../ServerHelpers.js'; 4 | 5 | /** @typedef {!import('./ConfigBase').ConfigBaseProtected} ConfigBaseProtected */ 6 | /** @typedef {!import('./ConfigBase').GetOrDefault} GetOrDefault */ 7 | /** @template T @typedef {!import('/Shared/ServerConfig').Setting} Setting */ 8 | 9 | /** 10 | * @typedef {{ 11 | * autoOpen?: boolean, 12 | * extendedMarkerStats?: boolean, 13 | * previewThumbnails?: boolean, 14 | * preciseThumbnails?: boolean 15 | * }} RawConfigFeatures 16 | */ 17 | 18 | const Log = ContextualLog.Create('EditorConfig'); 19 | 20 | /** 21 | * Captures the 'features' portion of the configuration file. 22 | */ 23 | export default class PlexFeatures extends ConfigBase { 24 | /** Protected members of the base class. 25 | * @type {ConfigBaseProtected} */ 26 | #Base = {}; 27 | 28 | /** 29 | * Setting for opening the UI in the browser on launch 30 | * @type {Setting} */ 31 | autoOpen; 32 | 33 | /** 34 | * Setting for gathering all markers before launch to compile additional statistics. 35 | * @type {Setting} */ 36 | extendedMarkerStats; 37 | 38 | /** Setting for displaying timestamped preview thumbnails when editing or adding markers. 39 | * @type {Setting} */ 40 | previewThumbnails; 41 | 42 | /** Setting for displaying precise ffmpeg-based preview thumbnails opposed to the pre-generated Plex BIF files. 43 | * @type {Setting} */ 44 | preciseThumbnails; 45 | 46 | /** Sets the application features based on the given json. 47 | * @param {RawConfigFeatures} json */ 48 | constructor(json) { 49 | const baseClass = {}; 50 | super(json, baseClass); 51 | this.#Base = baseClass; 52 | if (!json) { 53 | Log.warn('Features not found in config, setting defaults'); 54 | } 55 | 56 | this.autoOpen = this.#getOrDefault('autoOpen', true); 57 | this.extendedMarkerStats = this.#getOrDefault('extendedMarkerStats', true); 58 | this.previewThumbnails = this.#getOrDefault('previewThumbnails', true); 59 | this.preciseThumbnails = this.#getOrDefault('preciseThumbnails', false); 60 | 61 | if (this.previewThumbnails.value() && this.preciseThumbnails.value()) { 62 | const canEnable = testFfmpeg(); 63 | if (!canEnable) { 64 | this.preciseThumbnails.setValue(false); 65 | Log.warn(`Precise thumbnails enabled, but ffmpeg wasn't found in your path! Falling back to BIF`); 66 | } 67 | } 68 | } 69 | 70 | /** Forwards to {@link ConfigBase}s `#getOrDefault` 71 | * @type {GetOrDefault} */ 72 | #getOrDefault(key, defaultValue=null) { 73 | return this.#Base.getOrDefault(key, defaultValue); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Server/Config/SslConfig.js: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'fs'; 2 | import { createServer as createHttpsServer } from 'https'; 3 | 4 | import ConfigBase from './ConfigBase.js'; 5 | import { ContextualLog } from '../../Shared/ConsoleLog.js'; 6 | 7 | /** @typedef {!import('./ConfigBase').ConfigBaseProtected} ConfigBaseProtected */ 8 | /** @typedef {!import('./ConfigBase').GetOrDefault} GetOrDefault */ 9 | /** @template T @typedef {!import('/Shared/ServerConfig').Setting} Setting */ 10 | 11 | /** 12 | * @typedef {{ 13 | * enabled?: boolean, 14 | * sslHost?: string, 15 | * sslPort?: number, 16 | * certType?: 'pfx'|'pem', 17 | * pfxPath?: string, 18 | * pfxPassphrase?: string, 19 | * pemCert?: string, 20 | * pemKey?: string, 21 | * sslOnly?: boolean, 22 | * }} RawSslConfig 23 | */ 24 | 25 | const Log = ContextualLog.Create('EditorConfig'); 26 | 27 | export default class SslConfig extends ConfigBase { 28 | /** @type {ConfigBaseProtected} */ 29 | #Base = {}; 30 | /** @type {Setting} */ 31 | enabled; 32 | 33 | /** @type {Setting} */ 34 | sslHost; 35 | /** @type {Setting} */ 36 | sslPort; 37 | 38 | /** @type {Setting} */ 39 | certType; 40 | 41 | /** @type {Setting} */ 42 | pfxPath; 43 | /** @type {Setting} */ 44 | pfxPassphrase; 45 | 46 | /** @type {Setting} */ 47 | pemCert; 48 | /** @type {Setting} */ 49 | pemKey; 50 | 51 | /** @type {Setting} */ 52 | sslOnly; 53 | 54 | constructor(json) { 55 | const baseClass = {}; 56 | super(json, baseClass); 57 | this.#Base = baseClass; 58 | if (!json) { 59 | Log.warn('Authentication not found in config, setting defaults'); 60 | } 61 | 62 | this.enabled = this.#getOrDefault('enabled', false); 63 | this.sslHost = this.#getOrDefault('sslHost', '0.0.0.0'); 64 | this.sslPort = this.#getOrDefault('sslPort', 3233); 65 | this.certType = this.#getOrDefault('certType', 'pfx'); 66 | this.pfxPath = this.#getOrDefault('pfxPath', ''); 67 | this.pfxPassphrase = this.#getOrDefault('pfxPassphrase', ''); 68 | this.pemCert = this.#getOrDefault('pemCert', ''); 69 | this.pemKey = this.#getOrDefault('pemKey', ''); 70 | this.sslOnly = this.#getOrDefault('sslOnly', false); 71 | 72 | if (!this.enabled.value()) { 73 | // Not enabled, we don't care if anything's invalid. 74 | return; 75 | } 76 | 77 | const pfxSet = this.pfxPath.value() && this.pfxPassphrase.value(); 78 | const pemSet = this.pemCert.value() && this.pemKey.value(); 79 | if (!pfxSet && !pemSet) { 80 | Log.warn('SSL enabled, but no valid PFX or PEM certificate. Disabling SSL'); 81 | this.enabled.setValue(false); 82 | return; 83 | } 84 | 85 | const certType = this.certType.value(); 86 | if (!certType) { 87 | // No cert type - prefer PFX, but if not set, try PEM 88 | this.certType.setValue(pfxSet ? 'pfx' : 'pem'); 89 | } else if (certType.toLowerCase() === 'pfx' ? !pfxSet : !pemSet) { 90 | Log.warn(`SSL enabled with ${certType}, but cert/key not set. Disabling SSL`); 91 | this.enabled.setValue(false); 92 | return; 93 | } 94 | 95 | // Make sure we keep the lowercase version to make our lives easier 96 | this.certType.setValue(this.certType.value().toLowerCase()); 97 | 98 | // Ensure files exist 99 | if (this.certType.value() === 'pfx') { 100 | if (!existsSync(this.pfxPath.value())) { 101 | Log.warn(`PFX file "${this.pfxPath.value()}" could not be found. Disabling SSL`); 102 | this.enabled.setValue(false); 103 | return; 104 | } 105 | } else if (!existsSync(this.pemCert.value()) || !existsSync(this.pemKey.value())) { 106 | Log.warn('PEM cert/key not found. Disabling SSL'); 107 | this.enabled.setValue(false); 108 | return; 109 | } 110 | 111 | // Ensure cert/key are valid. 112 | try { 113 | createHttpsServer(this.sslKeys(), () => {}).close(); 114 | } catch (err) { 115 | Log.warn(err.message, `SSL server creation failed`); 116 | Log.warn('Disabling SSL.'); 117 | this.enabled.setValue(false); 118 | } 119 | } 120 | 121 | /** 122 | * Return the certificate options given the selected certificate type. */ 123 | sslKeys() { 124 | const opts = {}; 125 | if (this.certType.value().toLowerCase() === 'pfx') { 126 | opts.pfx = readFileSync(this.pfxPath.value()); 127 | opts.passphrase = this.pfxPassphrase.value(); 128 | } else { 129 | opts.cert = readFileSync(this.pemCert.value()); 130 | opts.key = readFileSync(this.pemKey.value()); 131 | } 132 | 133 | return opts; 134 | } 135 | 136 | /** Forwards to {@link ConfigBase}s `#getOrDefault` 137 | * @type {GetOrDefault} */ 138 | #getOrDefault(key, defaultValue=null) { 139 | return this.#Base.getOrDefault(key, defaultValue); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Server/FormDataParse.js: -------------------------------------------------------------------------------- 1 | import { ContextualLog } from '../Shared/ConsoleLog.js'; 2 | import ServerError from './ServerError.js'; 3 | 4 | /** @typedef {!import('express').Request} ExpressRequest */ 5 | 6 | /** @typedef {{ name : string, data : string, [optionalKeys: string]: string? }} ParsedFormField */ 7 | /** @typedef {{ [name: string]: ParsedFormField }} ParsedFormData */ 8 | 9 | const Log = ContextualLog.Create('FormData'); 10 | 11 | /** Regex that looks for expected 'Content-Disposition: form-data' key/value pairs */ 12 | const headerRegex = /\b(?\w+)="(?[^"]+)"/g; 13 | 14 | /** 15 | * Helper class that parses form-data from HTML request bodies. 16 | */ 17 | class FormDataParse { 18 | 19 | /** 20 | * Retrieves all data from the request body and returns the parsed body as an object of key-value pairs. 21 | * If we ever have reason to parse many megabytes/gigabytes of data, stream the data to a file first and 22 | * then parse. In the meantime, let the caller pass in a reasonable upper limit. 23 | * @param {ExpressRequest} request 24 | * @param {number} maxSize Maximum number of bytes in the body before we bail out. */ 25 | static async parseRequest(request, maxSize) { 26 | /** @type {string} */ 27 | const data = await new Promise((resolve, reject) => { 28 | let body = ''; 29 | request.on('data', chunk => { 30 | if (Buffer.isBuffer(chunk)) { 31 | body += chunk.toString('binary'); 32 | } else { 33 | body += chunk; 34 | } 35 | 36 | if (body.length > maxSize) { 37 | Log.error('Form data parse failed - data too large.'); 38 | reject(new ServerError(`Form data is too large (larger than ${maxSize} bytes).`, 400)); 39 | } 40 | }); 41 | 42 | request.on('end', () => { 43 | if (body.length > 0) { 44 | Log.verbose(`Form data parsed (${body.length} bytes)`); 45 | } 46 | 47 | resolve(body); 48 | }); 49 | }); 50 | 51 | return FormDataParse.rebuildFormData(data); 52 | } 53 | 54 | /** 55 | * Takes raw form input and rebuilds a key-value dictionary. 56 | * Note: I _really_ should use a library. There's probably a built-in one I 57 | * should be using, but a very quick search didn't bring up anything I liked. 58 | * @param {string} raw 59 | * @returns {ParsedFormData} */ 60 | static rebuildFormData(raw) { 61 | /** @type {ParsedFormData} */ 62 | const data = {}; 63 | 64 | if (!raw) { 65 | // No form parameters. Don't try to parse it. 66 | return data; 67 | } 68 | 69 | const sentinelBase = raw.substring(0, raw.indexOf('\r\n')); 70 | if (!sentinelBase) { 71 | throw new ServerError('Malformed response, did not find form data sentinel', 500); 72 | } 73 | 74 | const sentinel = sentinelBase + '\r\n'; 75 | const responseEnd = '\r\n' + sentinelBase + '--\r\n'; 76 | 77 | let index = sentinel.length; 78 | for (;;) { 79 | const headerStart = index; 80 | const headerEnd = raw.indexOf('\r\n\r\n', index) + 4; 81 | index = headerEnd; 82 | if (!sentinel || headerEnd === 3) { 83 | return data; 84 | } 85 | 86 | const rawHeaders = raw.substring(headerStart, headerEnd).split('\r\n').filter(h => !!h); 87 | let name = ''; 88 | // We specifically are looking for form-data 89 | // Also make our lives easier and assume no double quotes in names 90 | for (const header of rawHeaders) { 91 | const headerNorm = header.toLowerCase(); 92 | if (headerNorm.startsWith('content-disposition:') && headerNorm.includes('form-data;')) { 93 | const fields = {}; 94 | for (const match of header.matchAll(headerRegex)) { 95 | fields[match.groups.key] = match.groups.value; 96 | } 97 | 98 | if (!fields.name) { 99 | throw new ServerError('Invalid form data - no name for field', 500); 100 | } 101 | 102 | name = fields.name; 103 | data[name] = fields; 104 | 105 | // Are any other fields relevant? If so, parse those as well instead of breaking 106 | break; 107 | } 108 | } 109 | 110 | const dataStart = index; 111 | const dataEnd = raw.indexOf(sentinelBase, index); 112 | if (dataEnd === -1) { 113 | throw new ServerError('Invalid form input - could not find data sentinel', 500); 114 | } 115 | 116 | data[name].data = raw.substring(dataStart, dataEnd - 2); // Don't include CRLF before sentinel 117 | index = raw.indexOf(sentinel, dataEnd); 118 | if (index === -1) { 119 | // If we don't find the sentinel, we better be at the end 120 | if (raw.indexOf(responseEnd, dataEnd - 2) !== dataEnd - 2) { 121 | Log.warn('Unexpected response end, returning what we have.'); 122 | } 123 | 124 | Log.verbose(`Parsed POST body. Found ${Object.keys(data).length} fields.`); 125 | return data; 126 | } 127 | 128 | index += sentinel.length; 129 | } 130 | } 131 | } 132 | 133 | export default FormDataParse; 134 | -------------------------------------------------------------------------------- /Server/LegacyMarkerBreakdown.js: -------------------------------------------------------------------------------- 1 | import { BaseLog } from '../Shared/ConsoleLog.js'; 2 | 3 | /** 4 | * Manages cached marker breakdown stats, used when extendedMarkerStats are disabled. 5 | */ 6 | class LegacyMarkerBreakdown { 7 | 8 | /** 9 | * Map of section IDs to a map of marker counts X to the number episodes that have X markers. 10 | * @type {Object.} */ 11 | static Cache = {}; 12 | 13 | /** 14 | * Clear out the current cache, e.g. in preparation for a server restart. */ 15 | static Clear() { 16 | LegacyMarkerBreakdown.Cache = {}; 17 | } 18 | 19 | /** 20 | * Ensure our marker bucketing stays up to date after the user adds or deletes markers. 21 | * @param {MarkerData} marker The marker that changed. 22 | * @param {number} oldMarkerCount The old marker count bucket. 23 | * @param {number} delta The change from the old marker count, -1 for marker removals, 1 for additions. */ 24 | static Update(marker, oldMarkerCount, delta) { 25 | const section = marker.sectionId; 26 | const cache = LegacyMarkerBreakdown.Cache[section]; 27 | if (!cache) { 28 | return; 29 | } 30 | 31 | if (!(oldMarkerCount in cache)) { 32 | BaseLog.warn(`LegacyMarkerBreakdown::updateMarkerBreakdownCache: no bucket for oldMarkerCount. That's not right!`); 33 | cache[oldMarkerCount] = 1; // Bring it down to zero I guess. 34 | } 35 | 36 | --cache[oldMarkerCount]; 37 | 38 | const newMarkerCount = oldMarkerCount + delta; 39 | cache[newMarkerCount] ??= 0; 40 | ++cache[newMarkerCount]; 41 | } 42 | } 43 | 44 | export default LegacyMarkerBreakdown; 45 | -------------------------------------------------------------------------------- /Server/MarkerEditCache.js: -------------------------------------------------------------------------------- 1 | import { ContextualLog } from '../Shared/ConsoleLog.js'; 2 | 3 | /** @typedef {!import('../Shared/PlexTypes').MarkerData} MarkerData */ 4 | /** @typedef {!import('./PlexQueryManager').RawMarkerData} RawMarkerData */ 5 | 6 | /** 7 | * @typedef {{ 8 | * userCreated: boolean, 9 | * modifiedAt: number | null, 10 | * }} MarkerEditData 11 | * */ 12 | 13 | 14 | const Log = ContextualLog.Create('MarkerEditCache'); 15 | 16 | /** 17 | * Class that keeps track of marker created/modified dates. 18 | * 19 | * All of this information is stored in the backup database, but as 20 | * a performance optimization is kept in-memory to reduce db calls. 21 | */ 22 | class MarkerTimestamps { 23 | 24 | /** @type {Map} */ 25 | #cache = new Map(); 26 | 27 | /** 28 | * Remove all cached marker data. */ 29 | clear() { 30 | this.#cache.clear(); 31 | } 32 | 33 | /** 34 | * Bulk set marker edit details. 35 | * @param {{[markerId: string]: MarkerEditData}} editData */ 36 | setCache(editData) { 37 | for (const [markerId, data] of Object.entries(editData)) { 38 | this.#cache.set(parseInt(markerId), data); 39 | } 40 | } 41 | 42 | /** 43 | * Return whether the given marker was user created. 44 | * @param {number} markerId */ 45 | getUserCreated(markerId) { 46 | return this.#cache.has(markerId) && this.#cache.get(markerId).userCreated; 47 | } 48 | 49 | /** 50 | * Return the marker modified date, or null if the marker has not been edited. 51 | * @param {number} markerId */ 52 | getModifiedAt(markerId) { 53 | if (!this.#cache.has(markerId)) { 54 | return null; 55 | } 56 | 57 | return this.#cache.get(markerId).modifiedAt; 58 | } 59 | 60 | /** 61 | * Add marker edit data to the cache. 62 | * @param {number} markerId 63 | * @param {MarkerEditData} editData */ 64 | addMarker(markerId, editData) { 65 | if (this.#cache.has(markerId)) { 66 | Log.warn(`addMarker - Cache already has a key for ${markerId}, overwriting with new data.`); 67 | } 68 | 69 | this.#cache.set(markerId, editData); 70 | } 71 | 72 | /** 73 | * Update (or set) the modified date for the given marker. 74 | * @param {number} markerId 75 | * @param {number} modifiedAt */ 76 | updateMarker(markerId, modifiedAt) { 77 | if (!this.#cache.has(markerId)) { 78 | // Expected for the first edit of a Plex-generated marker 79 | Log.verbose(`updateMarker - Cache doesn't have a key for ${markerId}, adding a new entry and assuming it wasn't user created.`); 80 | } 81 | 82 | this.#cache.set(markerId, { userCreated : this.getUserCreated(markerId), modifiedAt : modifiedAt }); 83 | } 84 | 85 | /** 86 | * Delete the given marker from the cache. 87 | * @param {number} markerId */ 88 | deleteMarker(markerId) { 89 | if (!this.#cache.has(markerId)) { 90 | // Expected for the delete of a non-edited Plex-generated marker 91 | Log.verbose(`deleteMarker - Cache doesn't have a key for ${markerId}, nothing to delete`); 92 | return false; 93 | } 94 | 95 | return this.#cache.delete(markerId); 96 | } 97 | 98 | /** 99 | * Takes the raw marker data and updates the user created/modified dates 100 | * based on cached data. Used after adding/editing markers. 101 | * @param {MarkerData[]|MarkerData} markers */ 102 | updateInPlace(markers) { 103 | const markerArr = (markers instanceof Array) ? markers : [markers]; 104 | for (const marker of markerArr) { 105 | marker.createdByUser = this.getUserCreated(marker.id); 106 | marker.modifiedDate = this.getModifiedAt(marker.id); 107 | } 108 | } 109 | 110 | /** 111 | * Takes the raw marker data and updates the user created/modified dates 112 | * based on cached data. Used after adding/editing markers. 113 | * @param {RawMarkerData[]|RawMarkerData} markers */ 114 | updateInPlaceRaw(markers) { 115 | markers = (markers instanceof Array) ? markers : [markers]; 116 | for (const marker of markers) { 117 | marker.user_created = this.getUserCreated(marker.id); 118 | marker.modified_date = this.getModifiedAt(marker.id); 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * @type {MarkerTimestamps} */ 125 | const MarkerEditCache = new MarkerTimestamps(); 126 | 127 | export default MarkerEditCache; 128 | -------------------------------------------------------------------------------- /Server/PostCommands.js: -------------------------------------------------------------------------------- 1 | /** @typedef {!import('express').Request} ExpressRequest */ 2 | /** @typedef {!import('express').Response} ExpressResponse */ 3 | 4 | import { GetServerState, ServerState } from './ServerState.js'; 5 | import { sendJsonError, sendJsonSuccess } from './ServerHelpers.js'; 6 | import { Config } from './Config/MarkerEditorConfig.js'; 7 | import { ContextualLog } from '../Shared/ConsoleLog.js'; 8 | import { getPostCommand } from './Commands/PostCommand.js'; 9 | import { getQueryParser } from './QueryParse.js'; 10 | import { PostCommands } from '../Shared/PostCommands.js'; 11 | import { registerAuthCommands } from './Commands/AuthenticationCommands.js'; 12 | import { registerConfigCommands } from './Commands/ConfigCommands.js'; 13 | import { registerCoreCommands } from './Commands/CoreCommands.js'; 14 | import { registerImportExportCommands } from './ImportExport.js'; 15 | import { registerPurgeCommands } from './Commands/PurgeCommands.js'; 16 | import { registerQueryCommands } from './Commands/QueryCommands.js'; 17 | import ServerError from './ServerError.js'; 18 | import { User } from './Authentication/Authentication.js'; 19 | 20 | const Log = ContextualLog.Create('POSTCommands'); 21 | 22 | /** Subset of server commands that we accept when the config files doesn't exist or is in a bad state. */ 23 | const badStateWhitelist = new Set([ 24 | PostCommands.GetConfig, 25 | PostCommands.ValidateConfig, 26 | PostCommands.ValidateConfigValue, 27 | PostCommands.SetConfig] 28 | ); 29 | 30 | /** Subset of server commands that are accepted when the user is not signed in (and auth is enabled). */ 31 | const noAuthWhitelist = new Set([ 32 | PostCommands.Login, 33 | PostCommands.NeedsPassword, 34 | ]); 35 | 36 | /** 37 | * Verify that the given endpoint is allowed given the current server state. 38 | * @param {string} endpoint 39 | * @param {ExpressRequest} request */ 40 | function throwIfBadEndpoint(endpoint, request) { 41 | if (Config.useAuth() && !User.signedIn(request)) { 42 | // Only exception - change_password if a password isn't set yet. 43 | if (!noAuthWhitelist.has(endpoint) && (endpoint !== PostCommands.ChangePassword || User.passwordSet())) { 44 | throw new ServerError(`${endpoint} is not allowed without authentication`, 401); 45 | } 46 | } 47 | 48 | // Like above, allow change_password if a password isn't set, as that may be part of the initial setup. 49 | if (GetServerState() === ServerState.RunningWithoutConfig 50 | && !badStateWhitelist.has(endpoint) 51 | && (endpoint !== PostCommands.ChangePassword || User.passwordSet())) { 52 | throw new ServerError(`Disallowed request during First Run experience: "${endpoint}"`, 503); 53 | } 54 | } 55 | 56 | /** 57 | * Register all available POST endpoints. */ 58 | export function registerPostCommands() { 59 | Log.assert(GetServerState() === ServerState.FirstBoot, `We should only be calling setupTerminateHandlers on first boot!`); 60 | if (GetServerState() !== ServerState.FirstBoot) { 61 | return; 62 | } 63 | 64 | registerConfigCommands(); 65 | registerCoreCommands(); 66 | registerImportExportCommands(); 67 | registerPurgeCommands(); 68 | registerQueryCommands(); 69 | registerAuthCommands(); 70 | } 71 | 72 | /** 73 | * Run the given command. 74 | * @param {string} endpoint 75 | * @param {ExpressRequest} request 76 | * @param {ExpressResponse} response 77 | * @throws {ServerError} If the endpoint does not exist or the request fails. */ 78 | export async function runPostCommand(endpoint, request, response) { 79 | throwIfBadEndpoint(endpoint, request); 80 | 81 | try { 82 | const command = getPostCommand(endpoint); 83 | const handler = command.handler(); 84 | const params = await getQueryParser(request, response); 85 | const result = await handler(params); 86 | if (!command.ownsResponse()) { 87 | sendJsonSuccess(response, result); 88 | } 89 | } catch (err) { 90 | // Default handler swallows exceptions and adds the endpoint to the json error message. 91 | err.message = `${request.url} failed: ${err.message}`; 92 | sendJsonError(response, err, +err.code || 500); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Server/ServerError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A thin wrapper around an Error that also stores an HTTP status 3 | * code (e.g. to distinguish between server errors and user errors) 4 | */ 5 | class ServerError extends Error { 6 | /** @type {number} HTTP response code. */ 7 | code; 8 | 9 | /** @type {boolean} Whether this is an expected error. */ 10 | expected; 11 | 12 | /** 13 | * Construct a new ServerError 14 | * @param {string} message 15 | * @param {number} code */ 16 | constructor(message, code, expected=false) { 17 | super(message); 18 | this.code = code; 19 | this.expected = expected; 20 | } 21 | 22 | /** 23 | * Return a new server error based on the given database error, 24 | * which will always be a 500 error. 25 | * @param {Error} err */ 26 | static FromDbError(err) { 27 | return new ServerError(err.message, 500); 28 | } 29 | } 30 | 31 | export default ServerError; 32 | -------------------------------------------------------------------------------- /Server/ServerEvents.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | export const ServerEvents = { 4 | /** @readonly Event triggered when a soft restart has been initiated. */ 5 | SoftRestart : 'softRestart', 6 | /** @readonly Event triggered when a full restart has been initiated. */ 7 | HardRestart : 'hardRestart', 8 | /** @readonly Event triggered when we want to clear out our thumbnail cache. */ 9 | ReloadThumbnailManager : 'reloadThumbs', 10 | /** @readonly Event triggered when we should reload (or clear) cached marker stats. */ 11 | ReloadMarkerStats : 'reloadStats', 12 | /** @readonly Event triggered when we should reload (or clear) the purged marker cache. */ 13 | RebuildPurgedCache : 'rebuildPurges', 14 | }; 15 | 16 | /** 17 | * EventEmitter responsible for all server-side eventing. */ 18 | export const ServerEventHandler = new EventEmitter(); 19 | 20 | /** 21 | * Calls all listeners for the given event, returning a promise that resolves when all listeners have completed. 22 | * @param {string} eventName The ServerEvent to trigger. 23 | * @param {...any} [args] Additional arguments to pass to the event listener. */ 24 | export function waitForServerEvent(eventName, ...args) { 25 | /** @type {Promise[]} */ 26 | const promises = []; 27 | // emit() doesn't work for us, since it masks the listener return value (a promise), 28 | // so we can't wait for it. There are probably approaches that can use emit, but I think 29 | // the current approach does the best job of hiding away the implementation details. 30 | ServerEventHandler.listeners(eventName).forEach(listener => { 31 | promises.push(new Promise(resolve => { 32 | listener(...args, resolve); 33 | })); 34 | }); 35 | 36 | return Promise.all(promises); 37 | } 38 | -------------------------------------------------------------------------------- /Server/ServerState.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Set of possible server states. */ 3 | const ServerState = { 4 | /** @readonly Server is booting up. */ 5 | FirstBoot : 0, 6 | /** @readonly Server is booting up after a restart. */ 7 | ReInit : 1, 8 | /** @readonly Server is booting up, but the HTTP server is already running. */ 9 | SoftBoot : 2, 10 | /** @readonly Server is running normally. */ 11 | Running : 3, 12 | /** @readonly Server is running, but settings have not been configured (or are misconfigured). */ 13 | RunningWithoutConfig : 4, 14 | /** @readonly Server is in a suspended state. */ 15 | Suspended : 5, 16 | /** @readonly The server is in the process of shutting down. Either permanently or during a restart. */ 17 | ShuttingDown : 6, 18 | /** Returns whether the server is currently in a static state (i.e. not booting up or shutting down) */ 19 | Stable : () => StableStates.includes(CurrentState), 20 | }; 21 | 22 | const StableStates = [ ServerState.RunningWithoutConfig, ServerState.Running, ServerState.Suspended ]; 23 | 24 | /** 25 | * Indicates whether we're in the middle of shutting down the server, and 26 | * should therefore immediately fail all incoming requests. 27 | * @type {number} */ 28 | let CurrentState = ServerState.FirstBoot; 29 | 30 | /** 31 | * Set the current server state. 32 | * @param {number} state */ 33 | function SetServerState(state) { CurrentState = state; } 34 | 35 | /** 36 | * Retrieve the current {@linkcode ServerState} */ 37 | function GetServerState() { return CurrentState; } 38 | 39 | export { SetServerState, GetServerState, ServerState }; 40 | -------------------------------------------------------------------------------- /Server/TransactionBuilder.js: -------------------------------------------------------------------------------- 1 | import { ContextualLog } from '../Shared/ConsoleLog.js'; 2 | 3 | import SqliteDatabase from './SqliteDatabase.js'; 4 | 5 | /** @typedef {!import('./SqliteDatabase.js').DbQueryParameters} DbQueryParameters */ 6 | 7 | const Log = ContextualLog.Create('SQLiteTxn'); 8 | 9 | class TransactionBuilder { 10 | /** @type {string[]} */ 11 | #commands = []; 12 | /** @type {SqliteDatabase} */ 13 | #db; 14 | /** @type {string|undefined} */ 15 | #cache; 16 | 17 | /** 18 | * @param {SqliteDatabase} database */ 19 | constructor(database) { 20 | this.#db = database; 21 | } 22 | 23 | /** 24 | * Adds the given statement to the current transaction. 25 | * @param {string} statement A single SQL query 26 | * @param {DbQueryParameters} parameters Query parameters */ 27 | addStatement(statement, parameters=[]) { 28 | statement = statement.trim(); 29 | if (statement[statement.length - 1] !== ';') { 30 | statement += ';'; 31 | } 32 | 33 | this.#commands.push(SqliteDatabase.parameterize(statement, parameters)); 34 | this.#cache = null; 35 | } 36 | 37 | empty() { return this.#commands.length === 0; } 38 | reset() { this.#commands = []; this.#cache = null; } 39 | statementCount() { return this.#commands.length; } 40 | toString() { 41 | if (this.#cache) { 42 | return this.#cache; 43 | } 44 | 45 | this.#cache = `BEGIN TRANSACTION;\n`; 46 | for (const statement of this.#commands) { 47 | this.#cache += `${statement}\n`; 48 | } 49 | 50 | this.#cache += `COMMIT TRANSACTION;`; 51 | return this.#cache; 52 | } 53 | 54 | /** 55 | * Executes the current transaction.*/ 56 | exec() { 57 | Log.tmi(this.toString(), `Running transaction`); 58 | return this.#db.exec(this.toString()); 59 | } 60 | } 61 | 62 | export default TransactionBuilder; 63 | -------------------------------------------------------------------------------- /Shared/DocumentProxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Acts as a proxy for test code, where document isn't always defined because my test infra is bad. 3 | */ 4 | 5 | class DocumentMock { 6 | body = { 7 | clientWidth : 10000 8 | }; 9 | } 10 | 11 | /** @type {Document} */ // Tell intellisense to always treat this like a real document 12 | const DocumentProxy = typeof window === 'undefined' ? new DocumentMock() : document; 13 | export default DocumentProxy; 14 | -------------------------------------------------------------------------------- /Shared/MarkerType.js: -------------------------------------------------------------------------------- 1 | 2 | /** Set of known marker types. */ 3 | const _supportedMarkerTypes = new Set(['intro', 'credits', 'commercial']); 4 | 5 | /** 6 | * Return whether the given marker type is a supported type. 7 | * @param {string} markerType */ 8 | const supportedMarkerType = markerType => _supportedMarkerTypes.has(markerType); 9 | 10 | /** 11 | * Possible marker types 12 | * @enum */ 13 | const MarkerType = { 14 | /** @readonly */ 15 | Intro : 'intro', 16 | /** @readonly */ 17 | Credits : 'credits', 18 | /** @readonly */ 19 | Ad : 'commercial', 20 | }; 21 | 22 | /** 23 | * Known marker types, as OR-able values 24 | * @enum */ 25 | const MarkerEnum = { 26 | /**@readonly*/ 27 | Intro : 0x1, 28 | /**@readonly*/ 29 | Credits : 0x2, 30 | /**@readonly*/ 31 | Ad : 0x4, 32 | /**@readonly*/ 33 | All : 0x1 | 0x2 | 0x4, 34 | 35 | /** 36 | * Determine whether the given enum values matches the given marker type string. 37 | * @param {string} markerType 38 | * @param {number} markerEnum */ 39 | typeMatch : (markerType, markerEnum) => { 40 | switch (markerType) { 41 | case MarkerType.Intro: 42 | return (markerEnum & MarkerEnum.Intro) !== 0; 43 | case MarkerType.Credits: 44 | return (markerEnum & MarkerEnum.Credits) !== 0; 45 | case MarkerType.Ad: 46 | return (markerEnum & MarkerEnum.Ad) !== 0; 47 | default: 48 | return false; 49 | } 50 | } 51 | }; 52 | 53 | export { MarkerEnum, MarkerType, supportedMarkerType }; 54 | -------------------------------------------------------------------------------- /Shared/PostCommands.js: -------------------------------------------------------------------------------- 1 | /** 2 | * All available POST commands. */ 3 | export const PostCommands = { 4 | /** @readonly Add a new marker. */ 5 | AddMarker : 'add', 6 | /** @readonly Edit an existing marker. */ 7 | EditMarker : 'edit', 8 | /** @readonly Delete an existing marker. */ 9 | DeleteMarker : 'delete', 10 | /** @readonly Check whether a given shift command is valid. */ 11 | CheckShift : 'check_shift', 12 | /** @readonly Shift multiple markers. */ 13 | ShiftMarkers : 'shift', 14 | 15 | /** @readonly Bulk delete markers for a show/season. */ 16 | BulkDelete : 'bulk_delete', 17 | /** @readonly Bulk add markers to a show/season. */ 18 | BulkAdd : 'bulk_add', 19 | /** @readonly Bulk add markers with customized start/end timestamps. */ 20 | BulkAddCustom : 'add_custom', 21 | 22 | /** @readonly Get marker information for all metadata ids specified. */ 23 | Query : 'query', 24 | /** @readonly Retrieve all libraries on the server. */ 25 | GetLibraries : 'get_sections', 26 | /** @readonly Get details for a specific library. */ 27 | GetLibrary : 'get_section', 28 | /** @readonly Get season information for a single show. */ 29 | GetSeasons : 'get_seasons', 30 | /** @readonly Get episode information for a single season of a show. */ 31 | GetEpisodes : 'get_episodes', 32 | /** @readonly Check whether thumbnails exist for a single movie/episode. */ 33 | CheckThumbs : 'check_thumbs', 34 | /** @readonly Get marker statistics for an entire library. */ 35 | GetStats : 'get_stats', 36 | /** @readonly Get the marker breakdown for a specific metadata id. */ 37 | GetBreakdown : 'get_breakdown', 38 | /** @readonly Get chapters associated with the given metadata id. */ 39 | GetChapters : 'get_chapters', 40 | /** @readonly Get all available information for the given metadata id (markers, thumbnails, chapters) */ 41 | FullQuery : 'query_full', 42 | 43 | /** @readonly Retrieve the current server config. */ 44 | GetConfig : 'get_config', 45 | /** @readonly Validate an entire config file. */ 46 | ValidateConfig : 'validate_config', 47 | /** @readonly Validate a single value in the config file. */ 48 | ValidateConfigValue : 'valid_cfg_v', 49 | /** @readonly Set the server configuration, if possible. */ 50 | SetConfig : 'set_config', 51 | 52 | /** @readonly Checked for purged markers for the given metadata id. */ 53 | PurgeCheck : 'purge_check', 54 | /** @readonly Find all purged markers for the given library section. */ 55 | AllPurges : 'all_purges', 56 | /** @readonly Restore purged markers for the given list of purged marker ids. */ 57 | RestorePurges : 'restore_purge', 58 | /** @readonly Ignore the given purged markers. */ 59 | IgnorePurges : 'ignore_purge', 60 | 61 | /** @readonly Import markers from a previously exported marker database. */ 62 | ImportDb : 'import_db', 63 | /** @readonly Completely wipe out markers for the given library. */ 64 | Nuke : 'nuke_section', 65 | 66 | /** @readonly Shut down Marker Editor */ 67 | ServerShutdown : 'shutdown', 68 | /** @readonly Restart Marker Editor, including the HTTP server */ 69 | ServerRestart : 'restart', 70 | /** @readonly Restart Marker Editor without restarting the HTTP server. */ 71 | ServerReload : 'reload', 72 | /** @readonly Disconnect from the Plex database. */ 73 | ServerSuspend : 'suspend', 74 | /** @readonly Reconnect to the Plex database after suspending the connection. */ 75 | ServerResume : 'resume', 76 | 77 | /** @readonly Log in, if auth is enabled. */ 78 | Login : 'login', 79 | /** @readonly Log out of a session. */ 80 | Logout : 'logout', 81 | /** @readonly Change the single-user password. */ 82 | ChangePassword : 'change_password', 83 | /** @readonly Check whether authentication is enabled, but a user password is not set. */ 84 | NeedsPassword : 'check_password', 85 | }; 86 | 87 | /** 88 | * Set of commands allowed when the server is suspended. */ 89 | export const SuspendedWhitelist = new Set([ 90 | PostCommands.ServerResume, 91 | PostCommands.ServerShutdown, 92 | PostCommands.NeedsPassword, 93 | PostCommands.Login, 94 | PostCommands.Logout 95 | ]); 96 | -------------------------------------------------------------------------------- /Shared/WindowProxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Acts as a proxy for shared (and test) code that is used in backend (Node.js) and frontend (DOM) 3 | * environments, where window isn't always defined. 4 | */ 5 | 6 | 7 | class LocalStorageMock { 8 | constructor() { this._dict = {}; } 9 | getItem(item) { return this._dict[item]; } 10 | setItem(item, value) { this._dict[item] = value; } 11 | } 12 | class WindowMock { 13 | localStorage = new LocalStorageMock(); 14 | matchMedia() { return false; } 15 | addEventListener() { } 16 | } 17 | 18 | /** @type {Window} */ // Tell intellisense to always treat this like a real window 19 | const WindowProxy = typeof window === 'undefined' ? new WindowMock() : window; 20 | export default WindowProxy; 21 | -------------------------------------------------------------------------------- /Test/Test.js: -------------------------------------------------------------------------------- 1 | import { createInterface as createReadlineInterface } from 'readline/promises'; 2 | /** @typedef {!import('readline').Interface} Interface */ 3 | 4 | import { TestLog, TestRunner } from './TestRunner.js'; 5 | 6 | const testRunner = new TestRunner(); 7 | const testClass = getParam('--test_class', '-tc'); 8 | try { 9 | if (testClass) { 10 | await testRunner.runSpecific(testClass, getParam('--test_method', '-tm')); 11 | } else if (~process.argv.indexOf('--ask-input')) { 12 | await askForTests(); 13 | } else { 14 | await testRunner.runAll(); 15 | } 16 | } catch (ex) { 17 | TestLog.error(`Failed to run tests.`); 18 | TestLog.error(ex.message || ex); 19 | TestLog.error(ex.stack ? ex.stack : `[No stack trace available]`); 20 | } 21 | 22 | /** 23 | * Gets user input to determine the test class/method to run. */ 24 | async function askForTests() { 25 | const rl = createReadlineInterface({ 26 | input : process.stdin, 27 | output : process.stdout }); 28 | const tcName = await rl.question('Test Class Name: '); 29 | const testMethod = await rl.question('Test Method (Enter to run all class tests): '); 30 | rl.close(); 31 | return testRunner.runSpecific(tcName, testMethod || null); 32 | } 33 | 34 | /** 35 | * Retrieved a named command line parameter, null if it doesn't exist. 36 | * @param {string} name The full parameter name 37 | * @param {string} alternate An alternative form of the parameter */ 38 | function getParam(name, alternate) { 39 | let paramIndex = process.argv.indexOf(name); 40 | if (paramIndex === -1) { 41 | paramIndex = process.argv.indexOf(alternate); 42 | } 43 | 44 | if (paramIndex === -1 || paramIndex >= process.argv.length - 1) { 45 | return null; 46 | } 47 | 48 | return process.argv[paramIndex + 1]; 49 | } 50 | -------------------------------------------------------------------------------- /Test/TestClasses/ClientTests.js: -------------------------------------------------------------------------------- 1 | import TestBase from '../TestBase.js'; 2 | import TestHelpers from '../TestHelpers.js'; 3 | 4 | import { roundDelta } from '../../Client/Script/TimeInput.js'; 5 | 6 | class ClientTests extends TestBase { 7 | constructor() { 8 | super(); 9 | this.testMethods = [ 10 | this.markerTimestampRoundingTest, 11 | ]; 12 | } 13 | 14 | className() { return 'ClientTests'; } 15 | 16 | markerTimestampRoundingTest() { 17 | /* ms max factor expected*/ 18 | this.#roundTest(1234, 2000, 5000, 0); 19 | this.#roundTest(1234, 2000, 1000, 1000); 20 | this.#roundTest(1234, 2000, 500, 1000); 21 | this.#roundTest(1234, 2000, 100, 1200); 22 | this.#roundTest(1255, 2000, 500, 1500); 23 | this.#roundTest(1555, 1999, 1000, 1000); 24 | this.#roundTest(7600, 15000, 5000, 10000); 25 | this.#roundTest(7600, 15000, 1000, 8000); 26 | this.#roundTest(7600, 15000, 500, 7500); 27 | this.#roundTest(7600, 15000, 100, 7600); 28 | this.#roundTest(5000, 15000, 5000, 5000); 29 | this.#roundTest(5000, 15000, 1000, 5000); 30 | this.#roundTest(5000, 15000, 500, 5000); 31 | this.#roundTest(5000, 15000, 100, 5000); 32 | } 33 | 34 | #roundTest(current, max, factor, expected) { 35 | const delta = roundDelta(current, max, factor); 36 | const result = current + delta; 37 | TestHelpers.verify(result === expected, `Expected roundTo(${current}, ${max}, ${factor}) to return ${expected}, got ${result}`); 38 | } 39 | } 40 | 41 | export default ClientTests; 42 | -------------------------------------------------------------------------------- /Test/TestClasses/DateUtilTest.js: -------------------------------------------------------------------------------- 1 | import TestBase from '../TestBase.js'; 2 | import TestHelpers from '../TestHelpers.js'; 3 | 4 | import { getDisplayDate, getFullDate } from '../../Client/Script/DateUtil.js'; 5 | 6 | /** 7 | * Tests getDisplayDate from DateUtil. There's some fuzziness involved 8 | * due to how dates will be calculated depending on when the test is run, 9 | * but it should cover basic scenarios. 10 | */ 11 | export default class DateUtilTest extends TestBase { 12 | constructor() { 13 | super(); 14 | this.testMethods = [ 15 | this.testNow, 16 | this.testSeconds, 17 | this.testMinutes, 18 | this.testHours, 19 | this.testDays, 20 | this.testWeeks, 21 | this.testMonths, 22 | this.testYears, 23 | this.basicGetFullDate, 24 | ]; 25 | } 26 | 27 | className() { return 'DateUtilTest'; } 28 | 29 | testNow() { 30 | this.#testTime(new Date(), 'Just Now'); 31 | this.#testTime(this.#dateDiff(14000), 'Just Now', '14 seconds'); 32 | this.#testTime(this.#dateDiff(-14000), 'Just Now', '14 seconds'); 33 | } 34 | 35 | testSeconds() { 36 | this.#testPastFuture(16000, '16 seconds', '16 seconds'); 37 | this.#testPastFuture(59000, '59 seconds', '59 seconds'); 38 | } 39 | 40 | testMinutes() { 41 | this.#testPastFuture(60000, '1 minute', '60 seconds'); 42 | this.#testPastFuture(59 * 60000, '59 minutes', '59 minutes'); 43 | } 44 | 45 | testHours() { 46 | this.#testPastFuture(60 * 60 * 1000, '1 hour', '60 minutes'); 47 | this.#testPastFuture(70 * 60 * 1000, '1 hour', '70 minutes'); 48 | this.#testPastFuture(119 * 60 * 1000, '1 hour', '119 minutes'); 49 | this.#testPastFuture(2 * 60 * 60 * 1000, '2 hours', '120 minutes'); 50 | this.#testPastFuture(23 * 60 * 60 * 1000, '23 hours', '23 hours'); 51 | } 52 | 53 | testDays() { 54 | const oneDay = 24 * 60 * 60 * 1000; 55 | this.#testPastFuture(oneDay, '1 day', '24 hours'); 56 | this.#testPastFuture(6 * oneDay, '6 days', '6 days'); 57 | 58 | // Anything under 14 days should be days. 59 | this.#testPastFuture(7 * oneDay, '7 days', '7 days'); 60 | this.#testPastFuture(13 * oneDay, '13 days', '13 days'); 61 | this.#testPastFuture(13.12345 * oneDay, '13 days', '13.12345 days'); 62 | } 63 | 64 | testWeeks() { 65 | const oneDay = 24 * 60 * 60 * 1000; 66 | this.#testPastFuture(14 * oneDay, '2 weeks', '14 days'); 67 | this.#testPastFuture(20 * oneDay, '2 weeks', '20 days'); 68 | this.#testPastFuture(21 * oneDay, '3 weeks', '21 days'); 69 | this.#testPastFuture(22 * oneDay, '3 weeks', '22 days'); 70 | this.#testPastFuture(28 * oneDay, '4 weeks', '28 days'); 71 | } 72 | 73 | testMonths() { 74 | const oneDay = 24 * 60 * 60 * 1000; 75 | this.#testPastFuture(29 * oneDay, '1 month', '29 days'); 76 | this.#testPastFuture(40 * oneDay, '1 month', '40 days'); 77 | this.#testPastFuture(47 * oneDay, '2 months', '47 days'); 78 | this.#testPastFuture(364 * oneDay, '12 months', '364 days'); 79 | } 80 | 81 | testYears() { 82 | const oneDay = 24 * 60 * 60 * 1000; 83 | 84 | // Close enough for what we're testing. 85 | const oneYear = 367 * oneDay; 86 | this.#testPastFuture(oneYear, '1 year', '367 days'); 87 | this.#testPastFuture(2 * oneYear, '2 years', '734 days'); 88 | this.#testPastFuture(10 * oneYear, '10 years', '10 years'); 89 | } 90 | 91 | #testPastFuture(diff, dateString, testDescription) { 92 | // DateUtil should `floor` the date, so add 500 to account for any ms differences 93 | // between the date being set and the call to getDisplayDate 94 | this.#testTime(this.#dateDiff(diff + 500), `${dateString} ago`, testDescription); 95 | this.#testTime(this.#dateDiff(-(diff + 500)), `In ${dateString}`, testDescription); 96 | } 97 | 98 | #dateDiff(msOffset) { 99 | return new Date(Date.now() - msOffset); 100 | } 101 | 102 | #testTime(date, expectedTime, testDescription) { 103 | // Assume that the given date is passed to DateUtil within ms of the initial calculation from now() 104 | const result = getDisplayDate(date); 105 | TestHelpers.verify(result === expectedTime, 106 | `DateUtilTest: "${testDescription}" - Dates do not match. Expected "${expectedTime}", found "${result}"`); 107 | } 108 | 109 | basicGetFullDate() { 110 | // This will need to change if I ever localize this app. 111 | const d = new Date('7/7/1999 13:01:01'); 112 | TestHelpers.verify(getFullDate(d), 'July 7, 1999 at 1:01 PM'); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Test/TestClasses/ImageTest.js: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { readdirSync } from 'fs'; 3 | 4 | import { ProjectRoot } from '../../Server/Config/MarkerEditorConfig.js'; 5 | 6 | import TestBase from '../TestBase.js'; 7 | import TestHelpers from '../TestHelpers.js'; 8 | 9 | class ImageTest extends TestBase { 10 | constructor() { 11 | super(); 12 | this.testMethods = [ 13 | this.test200OnAllSVGs, 14 | this.testMissingSVG, 15 | ]; 16 | } 17 | 18 | // Hacky, but there are some SVGs that don't have a currentColor, so we don't expect to see it in the text. 19 | static #colorExceptions = new Set(['favicon.svg', 'noise.svg', 'badthumb.svg']); 20 | 21 | className() { return 'ImageTest'; } 22 | 23 | /** 24 | * Ensure all SVG icons in the SVG directory are returned successfully. */ 25 | async test200OnAllSVGs() { 26 | const files = readdirSync(join(ProjectRoot(), 'SVG')); 27 | for (const file of files) { 28 | // Shouldn't happen, but maybe non-SVGs snuck in here 29 | if (!file.toLowerCase().endsWith('.svg')) { 30 | continue; 31 | } 32 | 33 | const endpoint = `i/${file}`; 34 | const result = await this.get(endpoint); 35 | await this.#ensureValidSVG(endpoint, result); 36 | } 37 | } 38 | 39 | /** 40 | * Ensure we return a failing status code if a non-existent icon is asked for. */ 41 | async testMissingSVG() { 42 | this.expectFailure(); 43 | const endpoint = `i/_BADIMAGE.svg`; 44 | const result = await this.get(endpoint); 45 | TestHelpers.verify(result.status === 404, `Expected request for "${endpoint}" to return 404, found ${result.status}`); 46 | } 47 | 48 | /** 49 | * Helper that verifies the given image has the right content type, * and the right content. 50 | * @param {string} endpoint 51 | * @param {Response} response */ 52 | async #ensureValidSVG(endpoint, response) { 53 | TestHelpers.verify(response.status === 200, `Expected 200 when retrieving ${endpoint}, got ${response.status}.`); 54 | TestHelpers.verifyHeader(response.headers, 'Content-Type', 'img/svg+xml', endpoint); 55 | 56 | if (ImageTest.#colorExceptions.has(endpoint.substring(endpoint.lastIndexOf('/') + 1).toLowerCase())) { 57 | return; 58 | } 59 | 60 | const text = await response.text(); 61 | 62 | // Should immediately start with "')); 65 | TestHelpers.verify(text.indexOf('currentColor') !== 1, `Expected theme-able icon to have "currentColor"`); 66 | 67 | // Guard against legacy FILL_COLOR 68 | TestHelpers.verify(text.indexOf('FILL_COLOR') === -1, 69 | `SVG icons should no longer use FILL_COLOR for dynamic coloring, but "currentColor" + css variables.`); 70 | } 71 | } 72 | 73 | export default ImageTest; 74 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import { run } from './Server/MarkerEditor.js'; 2 | process.title = 'Marker Editor for Plex'; 3 | run(); 4 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataPath" : "/optional/path/to/data/directory", 3 | "database" : "/optional/path/to/com.plexapp.plugins.library.db", 4 | "host" : "localhost", 5 | "port" : 3232, 6 | "baseUrl" : "/", 7 | "logLevel" : "DarkInfo", 8 | "ssl" : { 9 | "enabled" : false, 10 | "sslOnly" : false, 11 | "sslHost" : "0.0.0.0", 12 | "sslPort" : 3233, 13 | "certType" : "pfx", 14 | "pfxPath" : "/path/to/cert.pfx", 15 | "pfxPassphrase" : "password", 16 | "pemCert" : "/path/to/pem/cert.pem", 17 | "pemKey" : "/path/to/pem/key.pem" 18 | }, 19 | "authentication" : { 20 | "enabled" : false, 21 | "sessionTimeout" : 86400 22 | }, 23 | "features" : { 24 | "autoOpen" : true, 25 | "extendedMarkerStats" : true, 26 | "previewThumbnails" : true, 27 | "preciseThumbnails" : false 28 | }, 29 | "pathMappings": [ 30 | { 31 | "from": "Z:\\", 32 | "to": "/mnt/data" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import { FlatCompat } from '@eslint/eslintrc'; 3 | import globals from 'globals'; 4 | import js from '@eslint/js'; 5 | import path from 'node:path'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory : __dirname, 11 | recommendedConfig : js.configs.recommended, 12 | allConfig : js.configs.all 13 | }); 14 | 15 | export default [{ 16 | ignores : ['dist/*'], 17 | }, ...compat.extends('eslint:recommended'), { 18 | languageOptions : { 19 | globals : { 20 | ...globals.browser, 21 | ...globals.node, 22 | ...globals.es2021 23 | }, 24 | 25 | ecmaVersion : 'latest', 26 | sourceType : 'module' 27 | }, 28 | 29 | rules : { 30 | 'array-callback-return' : 'error', 31 | 'arrow-body-style' : ['error', 'as-needed'], 32 | 'arrow-spacing' : ['error', { before : true, after : true }], 33 | 'block-scoped-var' : 'error', 34 | 'brace-style' : ['error', '1tbs', { allowSingleLine : true }], 35 | 'comma-spacing' : ['error', { before : false, after : true }], 36 | complexity : 'error', 37 | 'consistent-this' : 'error', 38 | 'dot-notation' : 'error', 39 | 'eol-last' : ['error', 'always'], 40 | eqeqeq : ['error', 'smart'], 41 | 'guard-for-in' : 'error', 42 | indent : ['error', 4, { SwitchCase : 1, ObjectExpression : 'first' }], 43 | 'key-spacing' : ['error', { beforeColon : true, mode : 'minimum' }], 44 | 'keyword-spacing' : 'error', 45 | 'linebreak-style' : ['error', 'windows'], 46 | 'logical-assignment-operators' : ['error', 'always'], 47 | 'max-len' : ['error', { code : 140 }], 48 | 'max-nested-callbacks' : ['error', 3], 49 | 'no-constant-binary-expression' : 'error', 50 | 'no-constructor-return' : 'error', 51 | 'no-duplicate-imports' : 'error', 52 | 'no-else-return' : 'error', 53 | 'no-eq-null' : 'error', 54 | 'no-eval' : 'error', 55 | 'no-extra-bind' : 'error', 56 | 'no-implicit-globals' : 'error', 57 | 'no-invalid-this' : 'error', 58 | 'no-label-var' : 'error', 59 | 'no-lone-blocks' : 'error', 60 | 'no-lonely-if' : 'error', 61 | 'no-loop-func' : 'error', 62 | 'no-negated-condition' : 'error', 63 | 'no-new-native-nonconstructor' : 'error', 64 | 'no-promise-executor-return' : 'error', 65 | 'no-self-compare' : 'error', 66 | 'no-shadow' : 'error', 67 | 'no-template-curly-in-string' : 'error', 68 | 'no-throw-literal' : 'error', 69 | 'no-trailing-spaces' : ['error'], 70 | 'no-unmodified-loop-condition' : 'error', 71 | 'no-unneeded-ternary' : 'error', 72 | 'no-unused-expressions' : ['error', { allowTernary : true }], 73 | 'no-unused-private-class-members' : 'error', 74 | 'no-unused-vars' : ['error', { 75 | argsIgnorePattern : '^_', 76 | varsIgnorePattern : '^_', 77 | caughtErrorsIgnorePattern : '^(_|e(rr|x)?)', 78 | }], 79 | 'no-use-before-define' : 'off', 80 | 'no-useless-call' : 'error', 81 | 'no-useless-concat' : 'error', 82 | 'no-useless-rename' : 'error', 83 | 'no-useless-return' : 'error', 84 | 'no-var' : 'error', 85 | 'object-curly-spacing' : ['error', 'always'], 86 | 'object-shorthand' : ['error', 'consistent-as-needed'], 87 | 'operator-assignment' : 'error', 88 | 'operator-linebreak' : ['error', 'after', { 89 | overrides : { 90 | '||' : 'before', 91 | '&&' : 'before' 92 | }, 93 | }], 94 | 'padding-line-between-statements' : ['error', 95 | { blankLine : 'always', prev : 'block-like', next : '*' }, 96 | { blankLine : 'any', prev : '*', next : 'break' }, 97 | { blankLine : 'any', prev : '*', next : 'case' }, 98 | { blankLine : 'any', prev : '*', next : 'default' } 99 | ], 100 | 'prefer-const' : 'error', 101 | 'prefer-named-capture-group' : 'error', 102 | 'prefer-promise-reject-errors' : 'error', 103 | 'prefer-object-spread' : 'error', 104 | 'prefer-regex-literals' : 'error', 105 | 'prefer-rest-params' : 'error', 106 | 'quote-props' : ['error', 'as-needed'], 107 | quotes : ['error', 'single', { 108 | avoidEscape : true, 109 | allowTemplateLiterals : true, 110 | }], 111 | 'require-atomic-updates' : 'error', 112 | 'require-await' : 'error', 113 | 'rest-spread-spacing' : ['error', 'never'], 114 | semi : ['error', 'always'], 115 | 'semi-spacing' : ['error', { before : false, after : true }], 116 | 'semi-style' : ['error', 'last'], 117 | 'sort-imports' : ['error', { ignoreCase : true, allowSeparatedGroups : true }], 118 | 'space-in-parens' : ['error', 'never'], 119 | yoda : ['error', 'never'], 120 | }, 121 | }]; 122 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "ResultRow": [ "Client/Script/ResultRow/index.js" ], 6 | "StickySettings": [ "Client/Script/StickySettings/index.js" ], 7 | "ServerSettingsDialog": [ "Client/Script/ServerSettingsDialog/index.js" ], 8 | "/Shared/*": [ "Shared/*" ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marker-editor-for-plex", 3 | "type": "module", 4 | "version": "2.8.0", 5 | "description": "Add, edit, and delete Plex markers", 6 | "main": "app.js", 7 | "scripts": { 8 | "test": "node Test/Test.js --test", 9 | "build": "node Build/build.js", 10 | "lint": "eslint .", 11 | "lint-fix": "eslint . --fix" 12 | }, 13 | "engines": { 14 | "node": ">=20.0" 15 | }, 16 | "author": "Daniel Rahn", 17 | "license": "MIT", 18 | "dependencies": { 19 | "express": "^5.0.0", 20 | "express-rate-limit": "^7.4.1", 21 | "express-session": "^1.18.0", 22 | "mime-types": "^2.1.35", 23 | "open": "^8.4.0", 24 | "read": "^4.0.0", 25 | "sqlite3": "^5.1.7" 26 | }, 27 | "devDependencies": { 28 | "eslint": "^9.0.0", 29 | "form-data": "^4.0.0", 30 | "fs-extra": "^10.1.0", 31 | "nexe": "^4.0.0-rc.6", 32 | "rcedit": "^3.0.1", 33 | "rollup": "^4.0.0", 34 | "semver": "^7.6.0", 35 | "webpack": "^5.91.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | 3 | const cd = import.meta.dirname; 4 | 5 | /** 6 | * @param {string} js The JS file to pack 7 | * @returns {import('webpack').Configuration} */ 8 | function webpackConfig(js) { 9 | return { 10 | mode : 'production', 11 | entry : resolve(cd, `./Client/Script/${js}.js`), 12 | resolve : { 13 | alias : { 14 | ResultRow : resolve(cd, 'Client/Script/ResultRow/index.js'), 15 | StickySettings : resolve(cd, 'Client/Script/StickySettings/index.js'), 16 | ServerSettingsDialog : resolve(cd, 'Client/Script/ServerSettingsDialog/index.js'), 17 | '/Shared/PlexTypes.js' : resolve(cd, 'Shared/PlexTypes.js'), 18 | '/Shared/MarkerBreakdown.js' : resolve(cd, 'Shared/MarkerBreakdown.js'), 19 | '/Shared/ServerConfig.js' : resolve(cd, 'Shared/ServerConfig.js'), 20 | '/Shared/ConsoleLog.js' : resolve(cd, 'Shared/ConsoleLog.js'), 21 | '/Shared/MarkerType.js' : resolve(cd, 'Shared/MarkerType.js'), 22 | '/Shared/PostCommands.js' : resolve(cd, 'Shared/PostCommands.js'), 23 | } 24 | }, 25 | output : { 26 | filename : `${js}.[contenthash].js`, 27 | path : resolve(cd), 28 | } 29 | }; 30 | } 31 | 32 | export const IndexJS = webpackConfig('index'); 33 | export const LoginJS = webpackConfig('login'); 34 | --------------------------------------------------------------------------------