├── Build
├── app.ico
├── Building.md
└── ReadMeGenerator.cjs
├── Client
├── Script
│ ├── ServerSettingsDialog
│ │ ├── index.js
│ │ ├── ServerSettingsDialogConstants.js
│ │ └── ServerSettingsDialogHelper.js
│ ├── MarkerTable
│ │ ├── index.js
│ │ └── MarkerTableActionsRow.js
│ ├── StickySettings
│ │ ├── index.js
│ │ ├── StickySettingsTypes.js
│ │ ├── BulkDeleteStickySettings.js
│ │ ├── BulkShiftStickySettings.js
│ │ ├── MarkerAddStickySettings.js
│ │ ├── CopyMarkerStickySettings.js
│ │ └── BulkAddStickySettings.js
│ ├── ResultRow
│ │ ├── index.js
│ │ ├── SeasonTitleResultRow.js
│ │ ├── ShowTitleResultRow.js
│ │ ├── SeasonResultRowBase.js
│ │ ├── ShowResultRowBase.js
│ │ ├── BulkActionResultRow.js
│ │ └── SectionOptionsResultRow.js
│ ├── FetchError.js
│ ├── StyleSheets.js
│ ├── CustomEvents.js
│ ├── ServerPausedOverlay.js
│ ├── DataAttributes.js
│ ├── WindowResizeEventHandler.js
│ ├── CommonUI.js
│ ├── HelpOverlay.js
│ ├── Icons.js
│ ├── ThemeColors.js
│ ├── ErrorHandling.js
│ ├── TooltipBuilder.js
│ ├── index.js
│ ├── LongPressHandler.js
│ ├── DateUtil.js
│ ├── ResultSections.js
│ └── ClientDataExtensions.js
└── Style
│ ├── OverlayLight.css
│ ├── OverlayDark.css
│ ├── BulkActionOverlayLight.css
│ ├── BulkActionOverlayDark.css
│ ├── Tooltip.css
│ ├── themeLight.css
│ ├── themeDark.css
│ ├── Overlay.css
│ ├── BulkActionOverlay.css
│ └── MarkerTable.css
├── app.js
├── SVG
├── arrow.svg
├── restart.svg
├── noise.svg
├── table.svg
├── badThumb.svg
├── chapter.svg
├── pause.svg
├── loading.svg
├── confirm.svg
├── imgIcon.svg
├── warn.svg
├── delete.svg
├── filter.svg
├── logout.svg
├── favicon.svg
├── cursor.svg
├── settings.svg
├── edit.svg
├── info.svg
├── cancel.svg
├── help.svg
└── back.svg
├── .dockerignore
├── .gitattributes
├── Shared
├── DocumentProxy.js
├── WindowProxy.js
├── MarkerType.js
└── PostCommands.js
├── jsconfig.json
├── .vscode
├── extensions.json
└── launch.json
├── .gitignore
├── Dockerfile
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── FUNDING.yml
├── Server
├── ServerError.js
├── Commands
│ ├── PostCommand.js
│ ├── ConfigCommands.js
│ └── AuthenticationCommands.js
├── LegacyMarkerBreakdown.js
├── ServerState.js
├── Config
│ ├── AuthenticationConfig.js
│ ├── SslConfig.js
│ └── FeaturesConfig.js
├── TransactionBuilder.js
├── ServerEvents.js
├── Authentication
│ ├── AuthenticationConstants.js
│ └── AuthDatabase.js
├── PostCommands.js
├── MarkerEditCache.js
└── FormDataParse.js
├── package.json
├── LICENSE
├── config.example.json
├── webpack.config.js
├── Test
├── TestClasses
│ ├── ClientTests.js
│ ├── ImageTest.js
│ └── DateUtilTest.js
└── Test.js
├── README.md
└── eslint.config.js
/Build/app.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danrahn/MarkerEditorForPlex/HEAD/Build/app.ico
--------------------------------------------------------------------------------
/Client/Script/ServerSettingsDialog/index.js:
--------------------------------------------------------------------------------
1 | export * from './ServerSettingsDialog.js';
2 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | import { run } from './Server/MarkerEditor.js';
2 | process.title = 'Marker Editor for Plex';
3 | run();
4 |
--------------------------------------------------------------------------------
/Client/Script/MarkerTable/index.js:
--------------------------------------------------------------------------------
1 | export { ThumbnailMarkerEdit } from './MarkerEdit.js';
2 | export * from './MarkerTable.js';
3 | export * from './TableElements.js';
4 |
--------------------------------------------------------------------------------
/SVG/arrow.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/SVG/restart.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/SVG/noise.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "MarkerTable": [ "Client/Script/MarkerTable/index.js" ],
6 | "ResultRow": [ "Client/Script/ResultRow/index.js" ],
7 | "StickySettings": [ "Client/Script/StickySettings/index.js" ],
8 | "ServerSettingsDialog": [ "Client/Script/ServerSettingsDialog/index.js" ],
9 | "/Shared/*": [ "Shared/*" ]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/SVG/table.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/SVG/badThumb.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/SVG/chapter.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 | }
--------------------------------------------------------------------------------
/SVG/pause.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/SVG/loading.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/SVG/confirm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/SVG/imgIcon.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/SVG/warn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/SVG/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/SVG/filter.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/SVG/logout.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/SVG/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/SVG/cursor.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/SVG/settings.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/SVG/edit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/SVG/info.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "marker-editor-for-plex",
3 | "type": "module",
4 | "version": "2.9.0-beta",
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 |
--------------------------------------------------------------------------------
/SVG/cancel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/SVG/help.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | "trustProxy" : false
23 | },
24 | "features" : {
25 | "autoOpen" : true,
26 | "extendedMarkerStats" : true,
27 | "previewThumbnails" : true,
28 | "preciseThumbnails" : false,
29 | "writeExtraData" : false,
30 | "autoSuspend" : false,
31 | "autoSuspendTimeout" : 3600
32 | },
33 | "pathMappings": [
34 | {
35 | "from": "Z:\\",
36 | "to": "/mnt/data"
37 | }
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/SVG/back.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | MarkerTable : resolve(cd, 'Client/Script/MarkerTable/index.js'),
15 | ResultRow : resolve(cd, 'Client/Script/ResultRow/index.js'),
16 | StickySettings : resolve(cd, 'Client/Script/StickySettings/index.js'),
17 | ServerSettingsDialog : resolve(cd, 'Client/Script/ServerSettingsDialog/index.js'),
18 | '/Shared/PlexTypes.js' : resolve(cd, 'Shared/PlexTypes.js'),
19 | '/Shared/MarkerBreakdown.js' : resolve(cd, 'Shared/MarkerBreakdown.js'),
20 | '/Shared/ServerConfig.js' : resolve(cd, 'Shared/ServerConfig.js'),
21 | '/Shared/ConsoleLog.js' : resolve(cd, 'Shared/ConsoleLog.js'),
22 | '/Shared/MarkerType.js' : resolve(cd, 'Shared/MarkerType.js'),
23 | '/Shared/PostCommands.js' : resolve(cd, 'Shared/PostCommands.js'),
24 | }
25 | },
26 | output : {
27 | filename : `${js}.[contenthash].js`,
28 | path : resolve(cd),
29 | }
30 | };
31 | }
32 |
33 | export const IndexJS = webpackConfig('index');
34 | export const LoginJS = webpackConfig('login');
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 Server is suspended due to user inactivity. */
17 | AutoSuspended : 6,
18 | /** @readonly The server is in the process of shutting down. Either permanently or during a restart. */
19 | ShuttingDown : 7,
20 | /** Returns whether the server is currently in a static state (i.e. not booting up or shutting down) */
21 | Stable : () => StableStates.has(CurrentState),
22 | };
23 |
24 | const StableStates = new Set([ServerState.RunningWithoutConfig, ServerState.Running, ServerState.Suspended, ServerState.AutoSuspended]);
25 |
26 | /**
27 | * Indicates whether we're in the middle of shutting down the server, and
28 | * should therefore immediately fail all incoming requests.
29 | * @type {number} */
30 | let CurrentState = ServerState.FirstBoot;
31 |
32 | /**
33 | * Set the current server state.
34 | * @param {number} state */
35 | function SetServerState(state) { CurrentState = state; }
36 |
37 | /**
38 | * Retrieve the current {@linkcode ServerState} */
39 | function GetServerState() { return CurrentState; }
40 |
41 | export { SetServerState, GetServerState, ServerState };
42 |
--------------------------------------------------------------------------------
/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.WriteExtraData] : 'Write Extra Data',
33 | [ServerSettings.AutoSuspend] : 'Auto-Suspend Database Connection',
34 | [ServerSettings.AutoSuspendTimeout] : 'Auto-Suspend Timeout',
35 | [ServerSettings.PathMappings] : 'Path Mappings',
36 | };
37 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | /** @type {Setting} */
28 | trustProxy;
29 |
30 | constructor(json) {
31 | const baseClass = {};
32 | super(json, baseClass);
33 | this.#Base = baseClass;
34 | if (!json) {
35 | Log.warn('Authentication not found in config, setting defaults');
36 | }
37 |
38 | this.enabled = this.#getOrDefault('enabled', false);
39 | this.sessionTimeout = this.#getOrDefault('sessionTimeout', 86_400);
40 | this.trustProxy = this.#getOrDefault('trustProxy', false, 'any');
41 | if (this.sessionTimeout < 300) {
42 | Log.warn(`Session timeout must be at least 300 seconds, found ${this.sessionTimeout}. Setting to 300.`);
43 | this.sessionTimeout = 300;
44 | }
45 | }
46 |
47 | /** Forwards to {@link ConfigBase}s `#getOrDefault`
48 | * @type {GetOrDefault} */
49 | #getOrDefault(key, defaultValue=null, defaultType=null) {
50 | return this.#Base.getOrDefault(key, defaultValue, defaultType);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 the server is suspended due to inactivity. */
9 | AutoSuspend : 'autoSuspend',
10 | /** @readonly Event triggered when the auto-suspend setting is changed. */
11 | AutoSuspendChanged : 'autoSuspendChanged',
12 | /** @readonly Event triggered when we want to clear out our thumbnail cache. */
13 | ReloadThumbnailManager : 'reloadThumbs',
14 | /** @readonly Event triggered when we should reload (or clear) cached marker stats. */
15 | ReloadMarkerStats : 'reloadStats',
16 | /** @readonly Event triggered when we should reload (or clear) the purged marker cache. */
17 | RebuildPurgedCache : 'rebuildPurges',
18 | };
19 |
20 | /**
21 | * EventEmitter responsible for all server-side eventing. */
22 | export const ServerEventHandler = new EventEmitter();
23 |
24 | /**
25 | * Calls all listeners for the given event, returning a promise that resolves when all listeners have completed.
26 | * @param {string} eventName The ServerEvent to trigger.
27 | * @param {...any} [args] Additional arguments to pass to the event listener. */
28 | export function waitForServerEvent(eventName, ...args) {
29 | /** @type {Promise[]} */
30 | const promises = [];
31 | // emit() doesn't work for us, since it masks the listener return value (a promise),
32 | // so we can't wait for it. There are probably approaches that can use emit, but I think
33 | // the current approach does the best job of hiding away the implementation details.
34 | ServerEventHandler.listeners(eventName).forEach(listener => {
35 | promises.push(new Promise(resolve => {
36 | listener(...args, resolve);
37 | }));
38 | });
39 |
40 | return Promise.all(promises);
41 | }
42 |
--------------------------------------------------------------------------------
/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/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/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 | }
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/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/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/StickySettings/CopyMarkerStickySettings.js:
--------------------------------------------------------------------------------
1 | import { StickySettingsBase } from './StickySettingsBase.js';
2 |
3 | import { BulkMarkerResolveType } from '/Shared/PlexTypes.js';
4 |
5 | /** @typedef {!import('./StickySettingsBase').StickySettingsBaseProtected} StickySettingsBaseProtected */
6 |
7 | /**
8 | * Contains marker copy settings that can persist depending on client persistence setting.
9 | */
10 | export class CopyMarkerStickySettings extends StickySettingsBase {
11 |
12 | /** Copy marker settings that persist based on the user's stickiness setting. */
13 | static #keys = {
14 | /** @readonly */
15 | ApplyType : 'applyType',
16 | /** @readonly */
17 | MoveDontCopy : 'moveDontCopy',
18 | };
19 |
20 | /**
21 | * Imitates "protected" methods from the base class.
22 | * @type {StickySettingsBaseProtected} */
23 | #protected = {};
24 |
25 | /** Create bulk add settings. */
26 | constructor() {
27 | const protectedMethods = {};
28 | super('copyMarker', 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 = CopyMarkerStickySettings.#keys;
35 | return {
36 | [keys.ApplyType] : BulkMarkerResolveType.Fail,
37 | [keys.MoveDontCopy] : false, // If true, markers will be moved instead of copied.
38 | };
39 | }
40 |
41 | /** The apply behavior (fail/overwrite/merge/ignore)
42 | * @returns {number} */
43 | applyType() { return this.#protected.get(CopyMarkerStickySettings.#keys.ApplyType); }
44 | /** Set the bulk apply behavior
45 | * @param {number} applyType */
46 | setApplyType(applyType) { this.#protected.set(CopyMarkerStickySettings.#keys.ApplyType, applyType); }
47 |
48 | /** Whether to move markers instead of copying them.
49 | * @returns {boolean} */
50 | moveDontCopy() { return this.#protected.get(CopyMarkerStickySettings.#keys.MoveDontCopy); }
51 | /** Set whether to move markers instead of copying them.
52 | * @param {boolean} moveDontCopy */
53 | setMoveDontCopy(moveDontCopy) { this.#protected.set(CopyMarkerStickySettings.#keys.MoveDontCopy, moveDontCopy); }
54 |
55 | /** Custom validation for a stored key/value pair. */
56 | validateStorageKey(key, value) {
57 | switch (key) {
58 | case CopyMarkerStickySettings.#keys.ApplyType:
59 | // Dry Run not available in copy.
60 | return value > BulkMarkerResolveType.DryRun && value <= BulkMarkerResolveType.Max;
61 | default:
62 | return true; // All other keys are handled by default validation
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 'MarkerTable';
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/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 |
--------------------------------------------------------------------------------
/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 | 
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 |
--------------------------------------------------------------------------------
/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 "