diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c08108e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist +build + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE files +.vscode +.idea +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Documentation +README.md +docs + +# Scripts +run.sh +run.bat +scripts + +# GitHub Actions +.github \ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..20a56fd --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,54 @@ +# Adapted from https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions +name: Build and Push Docker Image + +on: + push: + branches: + - main + - dev + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} # i.e. / + +jobs: + build-and-push: + runs-on: ubuntu-latest + # Only run on the original repository, not on forks + if: github.event_name == 'push' && github.repository_owner == 'lufinkey' + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set image tag + id: tag + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "version=latest" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "version=dev" >> $GITHUB_OUTPUT + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Build image + run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" + + - name: Log in to registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push image + run: | + IMAGE_ID=ghcr.io/$IMAGE_NAME + + # This changes all uppercase characters to lowercase. + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + VERSION=${{ steps.tag.outputs.version }} + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/.gitignore b/.gitignore index 8280646..84b010e 100644 --- a/.gitignore +++ b/.gitignore @@ -133,7 +133,12 @@ dist .DS_Store # private data -/plex_docker/docker-compose.yml +docker-compose.yml +/plex-config +/plex-cache +/plugindeps +/external /keys /config/config.json /config/csr.conf +/config/authCache.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6ccc05 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ + +# Install all dependencies (including dev dependencies) for building +RUN npm ci && npm cache clean --force + +COPY . . + +RUN npm run build + + +# Production stage: copies dist from builder stage, then only installs dependencies needed for production (non-dev) +FROM node:22-alpine AS production + +WORKDIR /app + +# Run as non-root user `node`, which is a default user provided by the node image +RUN chown node:node ./ +USER node + +COPY --from=builder --chown=node:node /app/dist ./dist + +COPY package*.json ./ + +RUN npm ci --omit=dev && npm cache clean --force + +# Entrypoint is used here to allow signals (e.g. SIGTERM) to properly pass through to node process +ENTRYPOINT [ "node", "--enable-source-maps", "dist/main.js", "--config=/config/config.json" ] diff --git a/README.md b/README.md index e0138e4..1d47a99 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A middleware proxy for the plex server API. This sits in between the plex client Inspired by [Replex](https://github.com/lostb1t/replex) -This project is still very much a WIP. While I've tried to do my due diligence in terms of security ([middleware](src/plex/requesthandling.ts#L73) prevents unauthorized requests from tokens not listed in the shared account list), I'm really the only contributor right now. Use at your own risk. +This project is still very much a WIP. While I've tried to do my due diligence in terms of security ([middleware](src/plex/requesthandling.ts#L124) prevents unauthorized requests from tokens not listed in the shared account list), I'm really the only contributor right now. Use at your own risk. This is an unofficial project that is **NOT** endorsed by or associated with Plexinc. @@ -42,6 +42,12 @@ This is an unofficial project that is **NOT** endorsed by or associated with Ple ![Letterboxd Friends Reviews](docs/images/letterboxd_friends_reviews.png) +- ### Password Locking + + Password-protect your server to easily whitelist IPs per user! To log into your server, add the instructions item to a new playlist, and input the password as the playlist title. If successful, then once you refresh the page (or restart the app), you will be "logged in" for the IP you're connecting from. + + ![Password Locking](docs/images/passwordlock.png) + ## Contributing This app is structured to have different ["plugins"](src/plugins) to provide different functionality. The [example plugin](pluginexample) and the [plugin template](src/plugins/template) are provided to give a starting point for anyone implementing a new plugin. If you would like to add your own set of functionality unrelated to letterboxd or any existing functionality, you should create your own plugin. @@ -54,7 +60,7 @@ Feel free to ask me if you're unsure of where or how to implement something! You will need to use your own SSL certificate for your plex server in order for this proxy to modify requests over HTTPS. Otherwise, it will only work over HTTP, or it will fallback to the plex server's true address instead of the proxy address. -The configuration option `autoP12Password` is provided to automatically decrypt and use the built-in plex direct SSL certificate, so that you don't need to set up your own SSL certificate. If you're running any service in front of this proxy (ie, another reverse proxy or anything using its own custom domain name) then it is recommended to **not** use the built-in plex certificate, and instead use your own certificate for your custom domain. +The configuration option `autoP12Password` is provided to automatically decrypt and use the built-in plex direct SSL certificate, so that you don't need to set up your own SSL certificate. ### Configuration @@ -64,8 +70,8 @@ Create a `config.json` file with the following structure, and fill in the config { "port": 32397, "plex": { - "host": "http://127.0.0.1:32400", - "token": "" + "host": "http://192.168.1.123:32400", + "token": "" }, "ssl": { "keyPath": "/etc/pseudo_plex_proxy/ssl_cert.key", @@ -73,6 +79,7 @@ Create a `config.json` file with the following structure, and fill in the config }, "dashboard": { "enabled": true, + "uuid": "" }, "perUser": { "yourplexuseremail@example.com": { @@ -98,12 +105,14 @@ Create a `config.json` file with the following structure, and fill in the config - **httpPort**: Manually specify the port that the http proxy will run on, if you want http and https traffic on separate ports. - **httpsPort**: Manually specify the port that the https proxy will run on, if you want http and https traffic on separate ports. - **redirectPlexStreams**: Optionally redirect video streams to go directly to plex, rather than through the proxy. The `plex.redirectHost` option must be set in order for streams to be redirected. +- **sendMetadataUnavailability**: By default, the proxy will send the "unavailable" status for any "pseudo" metadata item (ie from letterboxd) that doesn't match up to an item in your library. If you for whatever reason don't want this behaviour, you can optionally set this to `false` to disable it. +- **trustProxy**: Set this to `true` only if you have another proxy in front of this proxy - **plex** - - **host**: The url of your plex server. + - **host**: The url of your plex server. You probably don't want to set this to use `http://localhost:32400` or `http://127.0.0.1:32400`, even if you're on the same machine. It will work, but plex will also classify the traffic as localhost and it won't show up in bandwidth statistics. Instead, use the local ip (for example `http://192.168.1.123:32400`) - **secureHost**: The "secure" url of your plex server, if you want https traffic to use a different url. - **redirectHost**: The external url of your plex server, to use when redirecting streams. - **secureRedirectHost**: The "secure" external url of your plex server, to use when redirecting streams for https traffic. - - **token**: The plex API token of the server owner. + - **token**: The plex API token of the server owner. This *must* be the token used by the actual server itself. All other tokens will expire. See [here](https://www.plexopedia.com/plex-media-server/general/plex-token/#plexservertoken) for how to get the server token. - **appDataPath**: (*optional*) Manually specify the path of your plex server's appdata folder if it's in an unconventional place. On Linux, this is typically `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server` unless you're running via docker. This will be used to determine the path of the SSL certificate if `ssl.autoP12Path` is `true`. This will also be used to determine the path of `Preferences.xml` if `ssl.autoP12Password` is `true`. - **assumedTopSectionId**: (*optional*) Because of a bug in Plex for Mobile, it isn't possible to determine which section is the first "pinned" section. To fix this, you can manually specify the top pinned section ID here. - **ssl** @@ -122,12 +131,20 @@ Create a `config.json` file with the following structure, and fill in the config - **friendsReviewsEnabled**: Display letterboxd friends reviews for all users with a letterboxd username configured - **dashboard**: - **enabled**: Controls whether to show a pseudo "Dashboard" section for all users, which will show custom hubs + - **id**: The section id for the dashboard section. This must be a number not already in use by another section or metadata. - **uuid**: The unique uuid for the dashboard section. If enabling the dashboard, you should specify your own [randomly generated uuid](https://www.uuidgenerator.net), to ensure it's unique to your server. - **title**: The title to display for the section - **hubs**: An array of hubs to show on the dashboard section for all users. For a list of built-in hubs that can be configured, see [here](docs/Dashboard.md#hubs). - **plugin**: The name of the plugin that this hub comes from (for example, `letterboxd` for letterboxd hubs) - **hub**: The name of the hub within the plugin (for example, `userFollowingActivity` the activity feed of users that a given user is following) - **arg**: The argument to pass to the hub provider for this hub. (for `letterboxd`.`userFollowingActivity` hub, this would be a letterboxd username slug, for example `crew`) +- **passwordLock**: + - **enabled**: Controls whether to password protect this server. + - **sectionID**: The section id for the initial section when the library is locked. This must be a number not already in use by another section or metadata. + - **sectionUUID**: A unique uuid for the initial section when the library is locked. You should specify your own [randomly generated uuid](https://www.uuidgenerator.net), to ensure it's unique to your server. + - **password**: The custom password of your server. + - **authCachePath**: The file path to store the auth cache json file. This stores the mapping of tokens to their whitelisted IPs. + - **autoWhitelistNetmask**: The ip netmask to whitelist automatically. Typically this would be a local netmask, like `"192.168.0.0/16"`. - **perUser**: A map of settings to configure for each user on your server. The map keys are the plex email for each user. - **letterboxd**: - **username**: The letterboxd username for this user @@ -141,6 +158,10 @@ Create a `config.json` file with the following structure, and fill in the config - **plugin**: The name of the plugin that this hub comes from (for example, `letterboxd` for letterboxd hubs) - **hub**: The name of the hub within the plugin - **arg**: The argument to pass to the hub provider + - **passwordLock**: + - **password**: The custom password to require from this specific user. + - **autoWhitelistNetmask**: The ip netmask to whitelist automatically for this user. + - **overrideAutoWhitelistNetmask**: Set to `true` if the `autoWhitelistNetmask` for this user should override the global `autoWhitelistNetmask` config. ### Network Settings diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..83efed0 --- /dev/null +++ b/bun.lock @@ -0,0 +1,357 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "pseuplex", + "dependencies": { + "@httptoolkit/httpolyglot": "^3.0.0", + "express": "^5.1.0", + "express-http-proxy": "git+https://github.com/lufinkey/express-http-proxy.git", + "http-proxy": "git+https://github.com/Jimbly/http-proxy-node16.git", + "ip-cidr": "^4.0.2", + "letterboxd-retriever": "git+https://github.com/lufinkey/letterboxd-retriever.git", + "node-forge": "^1.3.3", + "sharp": "^0.34.3", + "winreg": "^1.2.5", + "ws": "^8.18.3", + "xml2js": "^0.6.2", + }, + "devDependencies": { + "@types/bun": "^1.2.20", + "@types/express": "^5.0.3", + "@types/express-http-proxy": "1.6.6", + "@types/http-proxy": "^1.17.15", + "@types/node": "^22.16.5", + "@types/node-forge": "^1.3.14", + "@types/winreg": "^1.2.36", + "@types/ws": "^8.18.1", + "@types/xml2js": "^0.4.14", + "typescript": "^5.5.3", + }, + }, + }, + "trustedDependencies": [ + "express-http-proxy", + "letterboxd-retriever", + "http-proxy", + ], + "packages": { + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + + "@httptoolkit/httpolyglot": ["@httptoolkit/httpolyglot@3.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-fU9psZBRc49PfdrxFZ8W19SwVQ4+rrc0EYVxmy7H41y6/elaIu5p68wly4uLVt1DPD0K92MdN65GqP3N1ofZKg=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-http-proxy": ["@types/express-http-proxy@1.6.6", "", { "dependencies": { "@types/express": "*" } }, "sha512-J8ZqHG76rq1UB716IZ3RCmUhg406pbWxsM3oFCFccl5xlWUPzoR4if6Og/cE4juK8emH0H9quZa5ltn6ZdmQJg=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/http-proxy": ["@types/http-proxy@1.17.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw=="], + + "@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + + "@types/winreg": ["@types/winreg@1.2.36", "", {}, "sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-http-proxy": ["express-http-proxy@git+ssh://git@github.com/lufinkey/express-http-proxy.git#bcda5c8fd1ad7667c91e395de589f8bea18c8ab2", { "dependencies": { "debug": "^3.0.1", "es6-promise": "^4.1.1", "raw-body": "^2.3.0" } }, "bcda5c8fd1ad7667c91e395de589f8bea18c8ab2"], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy": ["http-proxy-node16@git+ssh://git@github.com/Jimbly/http-proxy-node16.git#23aa916239ae9224df2f372e6e278ddbdd284c3f", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "23aa916239ae9224df2f372e6e278ddbdd284c3f"], + + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="], + + "ip-cidr": ["ip-cidr@4.0.2", "", { "dependencies": { "ip-address": "^9.0.5" } }, "sha512-KifhLKBjdS/hB3TD4UUOalVp1BpzPFvRpgJvXcP0Ya98tuSQTUQ71iI7EW7CKddkBJTYB3GfTWl5eJwpLOXj2A=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], + + "letterboxd-retriever": ["letterboxd-retriever@git+ssh://git@github.com/lufinkey/letterboxd-retriever.git#348df5c4dde10a8a3b12f783f2860b31fdff3479", { "dependencies": { "cheerio": "^1.1.0" } }, "348df5c4dde10a8a3b12f783f2860b31fdff3479"], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + + "semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "winreg": ["winreg@1.2.5", "", {}, "sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "express-http-proxy/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + } +} diff --git a/config/config.docker.example.json b/config/config.docker.example.json new file mode 100644 index 0000000..aaf43e0 --- /dev/null +++ b/config/config.docker.example.json @@ -0,0 +1,19 @@ +{ + "port": 32397, + "plex": { + "host": "http://192.168.1.123:32421", + "token": "", + "appDataPath": "/plex-config", + "appCachePath": "/plex-cache" + }, + "ssl": { + "autoP12Path": true, + "autoP12Password": true, + "watchCertChanges": true + }, + "perUser": { + "exampleuser@example.com": { + + } + } +} \ No newline at end of file diff --git a/config/config.example.json b/config/config.example.json index a39afc4..fa6e2a7 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -1,8 +1,8 @@ { "port": 32397, "plex": { - "host": "http://127.0.0.1:32400", - "token": "bxViLIUTBlbdow-iuBVT", + "host": "http://192.168.1.123:32400", + "token": "", "appDataPath": "/var/lib/plexmediaserver/Library/Application Support/Plex Media Server" }, "ssl": { diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..39e1d98 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,47 @@ +services: + pseuplex: + image: ghcr.io/lufinkey/pseuplex:latest + restart: unless-stopped + ports: + # Setting the external port to 32400 will override the traffic that would normally auto resolve to plex + # NOTE: You must also redirect your plex container to a non-32400 external port + - "32400:32397" + volumes: + # Mount your config.json file here. + # You should create a 'config' directory in the same directory as this docker-compose.yml + # and place your config.json inside it. + - ./config:/config:rw + + # When using `ssl.autoP12Password`, mount your plex config folder here + # NOTE: Update `plex.appDataPath` in the `config/config.json` file to reflect the mount path within this app's container (ie: "/plex-config") + - ./plex-config:/plex-config:ro + + # If you're using the built-in plex p12 certificate, mount your plex cache folder here + # NOTE: Update `plex.appCachePath` in the `config/config.json` file to reflect mount path within this app's container (ie: "/plex-cache") + - ./plex-cache:/plex-cache:ro + + # (Optional: when not using `ssl.autoP12Path`) Mount ssl certs directory for manual configuration + # NOTE: Update ssl options in the `config/config.json` file to correspond to the mounted `/ssl` path within the docker container + # - ./ssl:/ssl:ro + + # NOTE: Below is a simplified example of a plex container setup + # Please refer to the pms-docker docs: https://github.com/plexinc/pms-docker + plex: + image: plexinc/pms-docker + restart: unless-stopped + network_mode: host + environment: + - TZ=America/New_York + # The hosts that the plex server should advertise + - ADVERTISE_IP=https://mydomain.com:32400,http://mydomain.com:32400,http://192.168.1.123:32400/ + ports: + # Send the traffic to a port other than 32400, so that requests to your plex server dont bypass the proxy + - 32421:32400 + hostname: mydomain.com + volumes: + # Plex configuration directory ( on linux without docker, this is sometimes /var/lib/plexmediaserver ) + - ./plex-config:/config + # Plex caches directory ( on linux without docker, this is sometimes (no joke) /var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Cache ) + - ./plex-cache:/transcode + # Your data directory (with movies, shows, etc) + - /srv/media:/data:ro diff --git a/docs/images/passwordlock.png b/docs/images/passwordlock.png new file mode 100644 index 0000000..5c2bc5a Binary files /dev/null and b/docs/images/passwordlock.png differ diff --git a/docs/images/plex_server_urls.png b/docs/images/plex_server_urls.png index e14bf01..3c072bd 100644 Binary files a/docs/images/plex_server_urls.png and b/docs/images/plex_server_urls.png differ diff --git a/docs/images/plex_server_urls_default.png b/docs/images/plex_server_urls_default.png index f3f6871..cb3e245 100644 Binary files a/docs/images/plex_server_urls_default.png and b/docs/images/plex_server_urls_default.png differ diff --git a/docs/images/plex_ssl_prefs.png b/docs/images/plex_ssl_prefs.png index 6e970e2..e8445cb 100644 Binary files a/docs/images/plex_ssl_prefs.png and b/docs/images/plex_ssl_prefs.png differ diff --git a/images/icons/lock.png b/images/icons/lock.png new file mode 100644 index 0000000..d5c0233 Binary files /dev/null and b/images/icons/lock.png differ diff --git a/images/lockedSectionInstructions.png b/images/lockedSectionInstructions.png new file mode 100644 index 0000000..edb7353 Binary files /dev/null and b/images/lockedSectionInstructions.png differ diff --git a/images/overlays/partiallyAvailable.png b/images/overlays/partiallyAvailable.png new file mode 100644 index 0000000..834e086 Binary files /dev/null and b/images/overlays/partiallyAvailable.png differ diff --git a/package-lock.json b/package-lock.json index 3b797dc..d5a2a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,44 +9,69 @@ "version": "0.2.2", "license": "Zlib", "dependencies": { + "@httptoolkit/httpolyglot": "^3.0.0", "express": "^5.1.0", - "express-http-proxy": "https://github.com/lufinkey/express-http-proxy.git", - "http-proxy": "https://github.com/Jimbly/http-proxy-node16.git", - "httpolyglot": "^0.1.2", - "letterboxd-retriever": "https://github.com/lufinkey/letterboxd-retriever.git", - "node-forge": "^1.3.1", + "express-http-proxy": "git+https://github.com/lufinkey/express-http-proxy.git", + "http-proxy": "git+https://github.com/Jimbly/http-proxy-node16.git", + "ip-cidr": "^4.0.2", + "letterboxd-retriever": "git+https://github.com/lufinkey/letterboxd-retriever.git", + "node-forge": "^1.3.3", "sharp": "^0.34.3", "winreg": "^1.2.5", + "ws": "^8.18.3", "xml2js": "^0.6.2" }, "bin": { "pseuplex": "dist/main.js" }, "devDependencies": { + "@types/bun": "^1.2.20", "@types/express": "^5.0.3", "@types/express-http-proxy": "1.6.6", "@types/http-proxy": "^1.17.15", "@types/node": "^22.16.5", - "@types/node-forge": "^1.3.11", + "@types/node-forge": "^1.3.14", "@types/winreg": "^1.2.36", + "@types/ws": "^8.18.1", "@types/xml2js": "^0.4.14", "typescript": "^5.5.3" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@httptoolkit/httpolyglot": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.0.1.tgz", + "integrity": "sha512-fU9psZBRc49PfdrxFZ8W19SwVQ4+rrc0EYVxmy7H41y6/elaIu5p68wly4uLVt1DPD0K92MdN65GqP3N1ofZKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", - "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -62,13 +87,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.0" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", - "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -84,13 +109,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.0" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -104,9 +129,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", - "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -120,9 +145,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", - "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -136,9 +161,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", - "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -152,9 +177,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", - "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -167,10 +192,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", - "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -184,9 +225,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -200,9 +241,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -216,9 +257,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -232,9 +273,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", - "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -250,13 +291,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.0" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", - "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -272,13 +313,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.0" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", - "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -294,13 +335,35 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.0" + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", - "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -316,13 +379,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.0" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -338,13 +401,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -360,13 +423,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -382,20 +445,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", - "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.4" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -405,9 +468,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -424,9 +487,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", - "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -443,9 +506,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", - "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -472,6 +535,16 @@ "@types/node": "*" } }, + "node_modules/@types/bun": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz", + "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.3" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -483,15 +556,15 @@ } }, "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" + "@types/serve-static": "^2" } }, "node_modules/@types/express-http-proxy": { @@ -505,9 +578,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", "dev": true, "license": "MIT", "dependencies": { @@ -525,36 +598,28 @@ "license": "MIT" }, "node_modules/@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { - "version": "22.17.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", - "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", - "dev": true, + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, "node_modules/@types/node-forge": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.13.tgz", - "integrity": "sha512-zePQJSW5QkwSHKRApqWCVKeKoSOt4xvEnLENZPjyvm9Ezdf/EyDeJM7jqLzOwjVICQQzvLZ63T55MKdJB5H6ww==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", "dev": true, "license": "MIT", "dependencies": { @@ -576,26 +641,24 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" + "@types/node": "*" } }, "node_modules/@types/winreg": { @@ -605,6 +668,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/xml2js": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", @@ -629,23 +702,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -654,6 +731,16 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/bun-types": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz", + "integrity": "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -734,57 +821,17 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -843,9 +890,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -869,9 +916,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -974,6 +1021,18 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1044,18 +1103,19 @@ "license": "MIT" }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -1120,24 +1180,24 @@ } }, "node_modules/express-http-proxy/node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1148,7 +1208,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/follow-redirects": { @@ -1303,28 +1367,23 @@ } }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy": { @@ -1341,24 +1400,20 @@ "node": ">=8.0.0" } }, - "node_modules/httpolyglot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/httpolyglot/-/httpolyglot-0.1.2.tgz", - "integrity": "sha512-ouHI1AaQMLgn4L224527S5+vq6hgvqPteurVfbm7ChViM3He2Wa8KP1Ny7pTYd7QKnDSPKcN8JYfC8r/lmsE3A==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/inherits": { @@ -1367,6 +1422,31 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-cidr": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/ip-cidr/-/ip-cidr-4.0.2.tgz", + "integrity": "sha512-KifhLKBjdS/hB3TD4UUOalVp1BpzPFvRpgJvXcP0Ya98tuSQTUQ71iI7EW7CKddkBJTYB3GfTWl5eJwpLOXj2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5" + }, + "engines": { + "node": ">=16.14.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1376,21 +1456,21 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT" - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, "node_modules/letterboxd-retriever": { "version": "1.1.0", - "resolved": "git+ssh://git@github.com/lufinkey/letterboxd-retriever.git#f7464b12b9d6d739dff67e27a8bf978226976086", + "resolved": "git+ssh://git@github.com/lufinkey/letterboxd-retriever.git#348df5c4dde10a8a3b12f783f2860b31fdff3479", "license": "ISC", "dependencies": { "cheerio": "^1.1.0" @@ -1436,15 +1516,19 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ms": { @@ -1463,9 +1547,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -1575,12 +1659,13 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-addr": { @@ -1621,18 +1706,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/requires-port": { @@ -1657,26 +1742,6 @@ "node": ">= 18" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1684,15 +1749,15 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1745,15 +1810,15 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.4", - "semver": "^7.7.2" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1762,28 +1827,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/side-channel": { @@ -1858,14 +1925,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" }, "node_modules/statuses": { "version": "2.0.2", @@ -1907,9 +1971,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1921,9 +1985,9 @@ } }, "node_modules/undici": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.13.0.tgz", - "integrity": "sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -1933,7 +1997,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -1966,6 +2029,18 @@ "node": ">=18" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -1987,6 +2062,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 000793a..6e09a94 100644 --- a/package.json +++ b/package.json @@ -13,34 +13,51 @@ "url": "https://github.com/lufinkey/pseuplex.git" }, "scripts": { - "build": "tsc && npm link pseuplex@file:./", - "clean": "rm -rf dist node_modules plugindeps pluginexample/node_modules pluginexample/dist pluginexample/package-lock.json", - "prepare": "tsc", - "start": "tsc && node --enable-source-maps dist/main.js", - "start_test": "tsc && node --enable-source-maps --trace-deprecation dist/main.js --config=config/config.json --verbose --verbose-traffic", - "debug": "tsc && node --enable-source-maps --inspect dist/main.js --config=config/config.json --verbose" + "build": "tsc && npm run linkself:windows && npm run linkself:notwindows", + "linkself:notwindows": "npm run if:windows || npm link pseuplex@file:./", + "linkself:windows": "npm run if:notwindows || npm link --prefix %cd% pseuplex@file:./", + "prepublishOnly": "tsc", + "clean": "npm run clean:notwindows && npm run clean:windows", + "clean:notwindows": "npm run if:windows || rm -rf dist node_modules plugindeps pluginexample/node_modules pluginexample/dist pluginexample/package-lock.json", + "clean:windows": "npm run if:notwindows || rmdir /s /q dist node_modules plugindeps pluginexample\\node_modules pluginexample\\dist pluginexample\\package-lock.json || echo \"fuck you windows i hate you\"", + "start": "npm run node:start --", + "node:start": "node --enable-source-maps dist/main.js", + "node:start_test": "tsc && node --enable-source-maps --trace-deprecation dist/main.js --config=config/config.json --verbose --verbose-traffic", + "debug": "tsc && node --enable-source-maps --inspect dist/main.js --config=config/config.json --verbose", + "bun:start": "bun run ./src/main.ts", + "if:windows": "node -e \"if (process.platform !== 'win32') process.exit(1)\"", + "if:notwindows": "node -e \"if (process.platform === 'win32') process.exit(1)\"" }, "author": "Luis Finke (luisfinke@gmail.com)", "license": "Zlib", "dependencies": { + "@httptoolkit/httpolyglot": "^3.0.0", "express": "^5.1.0", - "express-http-proxy": "https://github.com/lufinkey/express-http-proxy.git", - "http-proxy": "https://github.com/Jimbly/http-proxy-node16.git", - "httpolyglot": "^0.1.2", - "letterboxd-retriever": "https://github.com/lufinkey/letterboxd-retriever.git", - "node-forge": "^1.3.1", + "express-http-proxy": "git+https://github.com/lufinkey/express-http-proxy.git", + "http-proxy": "git+https://github.com/Jimbly/http-proxy-node16.git", + "ip-cidr": "^4.0.2", + "letterboxd-retriever": "git+https://github.com/lufinkey/letterboxd-retriever.git", + "node-forge": "^1.3.3", "sharp": "^0.34.3", "winreg": "^1.2.5", + "ws": "^8.18.3", "xml2js": "^0.6.2" }, "devDependencies": { + "@types/bun": "^1.2.20", "@types/express": "^5.0.3", "@types/express-http-proxy": "1.6.6", "@types/http-proxy": "^1.17.15", "@types/node": "^22.16.5", - "@types/node-forge": "^1.3.11", + "@types/node-forge": "^1.3.14", "@types/winreg": "^1.2.36", + "@types/ws": "^8.18.1", "@types/xml2js": "^0.4.14", "typescript": "^5.5.3" - } -} \ No newline at end of file + }, + "trustedDependencies": [ + "express-http-proxy", + "http-proxy", + "letterboxd-retriever" + ] +} diff --git a/plex_docker/docker-compose.example.yml b/plex_docker/docker-compose.example.yml deleted file mode 100644 index ef7ff21..0000000 --- a/plex_docker/docker-compose.example.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: '2' -services: - plex: - container_name: plex - image: plexinc/pms-docker - restart: unless-stopped - ports: - - 32401:32400 - environment: - - TZ=America/New_York - - ADVERTISE_IP=https://mydomain.com:32397,http://mydomain.com:32397,http://192.168.1.148:32397/ - hostname: mydomain.com - volumes: - - /var/lib/plexmediaserver:/config - - /var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Cache:/transcode - - /srv/media:/data:ro diff --git a/pluginexample/README.md b/pluginexample/README.md index e7843fb..d71a14a 100644 --- a/pluginexample/README.md +++ b/pluginexample/README.md @@ -14,7 +14,7 @@ git clone https://github.com/lufinkey/pseuplex --branch v0.2.2 # enter the proxy repo folder cd pseuplex # install dependencies -npm install +npm install && npm run build ``` Then you'll need to link your plugin repo to the proxy repo: @@ -22,7 +22,7 @@ Then you'll need to link your plugin repo to the proxy repo: ```shell # enter your plugin repo cd ../pseuplex-plugin-helloworld -# link the proxy's package to your plugin +# link the proxy's package to your plugin (this is only for development purposes) npm link pseuplex@file:../pseuplex ``` diff --git a/pluginexample/src/index.ts b/pluginexample/src/index.ts index 47464aa..c046d1d 100644 --- a/pluginexample/src/index.ts +++ b/pluginexample/src/index.ts @@ -3,8 +3,8 @@ import type { PseuplexPlugin, PseuplexPluginClass, PseuplexReadOnlyResponseFilters, + PseuplexRouterApp, } from 'pseuplex'; -import express from 'express'; export default (class ExamplePlugin implements PseuplexPlugin { static slug = 'example'; @@ -29,7 +29,7 @@ export default (class ExamplePlugin implements PseuplexPlugin { } } - defineRoutes(router: express.Express) { + defineRoutes(router: PseuplexRouterApp) { // define any custom routes here } diff --git a/run.bat b/run.bat index 8cb67b3..42e1f4a 100644 --- a/run.bat +++ b/run.bat @@ -1,7 +1,14 @@ @echo off -cd %~dp0% || exit /b -call npm install || exit /b -call npm run build || exit /b -set NODE_ENV=production -call npm start -- --config=config/config.json +setlocal +( + cd "%~dp0" || goto :exit + call npm install || goto :exit + call npm run build || goto :exit + set NODE_ENV=production + call npm start -- --config=config/config.json --log-timestamps --log-loglevel --log-watched-paths || goto :exit +) +endlocal + +:exit pause +exit /b diff --git a/run.sh b/run.sh index 845f40c..25fdebb 100755 --- a/run.sh +++ b/run.sh @@ -1,6 +1,12 @@ -#!/bin/sh -cd "$(dirname "$(realpath "$0")")" || exit $? +#!/bin/bash + +# enter base directory +cd "${BASH_SOURCE%/*}" || exit $? + +# install dependencies and build npm install || exit $? npm run build || exit $? + +# run the app export NODE_ENV=production -npm start -- --config=config/config.json || exit $? +npm start -- --config=config/config.json --log-timestamps --log-loglevel --log-watched-paths || exit $? diff --git a/src/cmdargs.ts b/src/cmdargs.ts index 81a630e..8617238 100644 --- a/src/cmdargs.ts +++ b/src/cmdargs.ts @@ -5,11 +5,18 @@ export type CommandArguments = { verbose?: boolean, verboseHttpTraffic?: boolean, verboseWsTraffic?: boolean, + noInstallPlugins?: boolean, + installPluginsAndExit?: boolean, } & LoggingOptions; enum CmdFlag { configPath = '--config', + installPluginsAndExit = '--install-plugins-and-exit', + noInstallPlugins = '--no-install-plugins', + logTimestamps = '--log-timestamps', + logLogLevel = '--log-loglevel', logPlexTokenInfo = '--log-plex-tokens', + logWatchedPaths = '--log-watched-paths', logOutgoingRequests = '--log-outgoing-requests', logUserRequests = '--log-user-requests', logUserRequestHeaders = '--log-user-request-headers', @@ -73,9 +80,29 @@ export const parseCmdArgs = (args: string[]): CommandArguments => { parsedArgs.configPath = flagVal; break; + case CmdFlag.noInstallPlugins: + parsedArgs.noInstallPlugins = true; + break; + + case CmdFlag.installPluginsAndExit: + parsedArgs.installPluginsAndExit = true; + break; + + case CmdFlag.logTimestamps: + parsedArgs.logTimestamps = true; + break; + + case CmdFlag.logLogLevel: + parsedArgs.logLogLevel = true; + break; + case CmdFlag.logPlexTokenInfo: parsedArgs.logPlexTokenInfo = true; break; + + case CmdFlag.logWatchedPaths: + parsedArgs.logWatchedPaths = true; + break; case CmdFlag.logOutgoingRequests: parsedArgs.logOutgoingRequests = true; diff --git a/src/config.ts b/src/config.ts index 108802c..8c05587 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,15 +1,14 @@ import fs from 'fs'; -import { SSLConfig } from './utils/ssl'; -import { IPv4NormalizeModeKey } from './utils/ip'; -import { - PseuplexConfigBase, - PseuplexServerProtocol, -} from './pseuplex'; -import { LetterboxdPluginConfig } from './plugins/letterboxd/config'; -import { RequestsPluginConfig } from './plugins/requests/config'; -import { DashboardPluginConfig } from './plugins/dashboard/config'; -import { OverseerrRequestsPluginConfig } from './plugins/requests/providers/overseerr/config'; -import { LoggingOptions } from './logging'; +import type { SSLConfig } from './utils/ssl'; +import type { IPv4NormalizeModeKey } from './utils/ip'; +import type { PseuplexConfigBase } from './pseuplex/configbase'; +import type { PseuplexServerProtocol } from './pseuplex/types/server'; +import type { PasswordLockPluginConfig } from './plugins/passwordlock/config'; +import type { LetterboxdPluginConfig } from './plugins/letterboxd/config'; +import type { RequestsPluginConfig } from './plugins/requests/config'; +import type { DashboardPluginConfig } from './plugins/dashboard/config'; +import type { OverseerrRequestsPluginConfig } from './plugins/requests/providers/overseerr/config'; +import type { LoggingOptions } from './logging'; export type Config = { protocol?: PseuplexServerProtocol, @@ -18,6 +17,7 @@ export type Config = { httpPort?: number; httpsPort?: number; ipv4ForwardingMode?: IPv4NormalizeModeKey; + trustProxy?: boolean; sendMetadataUnavailability?: boolean; forwardMetadataRefreshToPluginMetadata?: boolean; redirectPlexStreams?: boolean; @@ -34,6 +34,7 @@ export type Config = { token: string; processedMachineIdentifier?: string; appDataPath?: string; + appCachePath?: string; metadataHost?: string; notificationSocketRetryInterval?: number; overwritePrivatePort?: number | boolean; @@ -48,7 +49,8 @@ export type Config = { plugins?: { [id: string]: string } -} & PseuplexConfigBase<{}> +} & PseuplexConfigBase<{[key: string]: any}> + & PasswordLockPluginConfig & LetterboxdPluginConfig & RequestsPluginConfig & DashboardPluginConfig diff --git a/src/fetching/CachedFetcher.ts b/src/fetching/CachedFetcher.ts index edb72a5..ce49c0f 100644 --- a/src/fetching/CachedFetcher.ts +++ b/src/fetching/CachedFetcher.ts @@ -1,3 +1,4 @@ +import { OutRef, Ref, setRefIfNone } from '../utils/ref'; export type Fetcher = (id: string | number) => Promise; @@ -10,6 +11,8 @@ export type CacheItemNode = { export type CachedFetcherOptions = { /// How long an item can exist in the cache, in seconds itemLifetime?: number | null; + // How long a null item can exist in the cache, in seconds + nullItemLifetime?: number | null; /// Controls whether accessing an item resets its lifetime accessResetsLifetime?: boolean; /// Determines the maximum number of items that can be cleaned from the cache in one synchronous go (if limit is reached, timer will be rescheduled) @@ -20,55 +23,75 @@ type CachedFetcherCache = { [key: string | number]: CacheItemNode | Promise }; -export class CachedFetcher { +type CacheID = string | number; + +export class CachedFetcher { options: CachedFetcherOptions; - private _fetcher: Fetcher; - private _cache: CachedFetcherCache = {}; + private _fetcher: Fetcher; + private _cache: CachedFetcherCache = {}; private _autoclean: boolean; private _cleanTimer?: NodeJS.Timeout | null; + private _nullEntries: Set = new Set(); - constructor(fetcher: Fetcher, options?: CachedFetcherOptions) { + constructor(fetcher: Fetcher, options?: CachedFetcherOptions) { this.options = options || {}; this._fetcher = fetcher; } - private _itemNodeAccessed(id: string | number, itemNode: CacheItemNode) { - if(this.options.itemLifetime && this.options.accessResetsLifetime) { + private _put(id: CacheID, itemNode: CacheItemNode) { + this.delete(id); // ensure new ID is added to the end + this._cache[id] = itemNode; + if(itemNode.item == null) { + this._nullEntries.add(id.toString()); + } + } + + private _itemNodeAccessed(id: CacheID, itemNode: CacheItemNode, nowRef?: OutRef) { + if(this.options.accessResetsLifetime && (this.options.itemLifetime != null || this.options.nullItemLifetime != null)) { // move this item to the end, since it was just accessed - delete this._cache[id]; - this._cache[id] = itemNode; + this._put(id, itemNode); } - itemNode.accessedAt = process.uptime(); + nowRef = setRefIfNone(nowRef, () => process.uptime()); + itemNode.accessedAt = nowRef.val!; } - async fetch(id: string | number): Promise { - const itemTask = this._fetcher(id); - this._cache[id] = itemTask; + async fetch(id: string | number): Promise { + let itemTask: Promise; try { - const item = await itemTask; - if(item === undefined) { - // if the fetcher returns undefined, this means it shouldn't get cached - delete this._cache[id]; - return item; - } - const now = process.uptime(); - delete this._cache[id]; // ensure new ID is added to the end - this._cache[id] = { - item: item, - updatedAt: now, - accessedAt: now - }; - if(this._autoclean) { - this._scheduleAutoCleanIfUnscheduled(); - } - return item; + itemTask = this._fetcher(id); } catch(error) { - delete this._cache[id]; + this.delete(id); throw error; } + return await this.set(id, itemTask); + } + + delete(id: string | number) { + delete this._cache[id]; + this._nullEntries.delete(id.toString()); + } + + private _expireItemIfNeeded(id: string | number, itemNode: CacheItemNode, now?: OutRef, elapsedTimeRef?: OutRef): boolean { + const { nullItemLifetime, itemLifetime, accessResetsLifetime } = this.options; + const lifetimeForItem = itemNode.item == null ? (nullItemLifetime ?? itemLifetime) : itemLifetime; + if(lifetimeForItem != null) { + now = setRefIfNone(now, () => process.uptime()); + elapsedTimeRef ??= {}; + if(accessResetsLifetime) { + elapsedTimeRef.val = now.val! - itemNode.accessedAt; + } else { + elapsedTimeRef.val = now.val! - itemNode.updatedAt; + } + if(elapsedTimeRef.val! >= lifetimeForItem) { + // item is expired, so remove + this.delete(id); + return true; + } + } + return false; } - async getOrFetch(id: string | number): Promise { + async getOrFetch(id: string | number): Promise { let itemNode = this._cache[id]; if(itemNode == null) { return await this.fetch(id); @@ -76,11 +99,17 @@ export class CachedFetcher { if(itemNode instanceof Promise) { return await itemNode; } - this._itemNodeAccessed(id, itemNode); + let nowRef: OutRef = {}; + // check if the item is expired + if(this._expireItemIfNeeded(id, itemNode, nowRef)) { + return await this.fetch(id); + } + // mark item as accessed + this._itemNodeAccessed(id, itemNode, nowRef); return itemNode.item; } - get(id: string | number, access: boolean = true): (ItemType | Promise | undefined) { + get(id: string | number, access: boolean = true): (TItem | Promise | undefined) { const itemNode = this._cache[id]; if(itemNode) { if(itemNode instanceof Promise) { @@ -95,35 +124,39 @@ export class CachedFetcher { return undefined; } - async set(id: string | number, value: ItemType | Promise): Promise { - let result: ItemType | undefined; + async set(id: string | number, value: TItem | Promise): Promise { + let result: TItem | undefined; if(value instanceof Promise) { this._cache[id] = value; try { result = await value; } catch(error) { - delete this._cache[id]; + this.delete(id); throw error; } } else { result = value; } if(result === undefined) { - delete this._cache[id]; + // if the fetcher returns undefined, this means it shouldn't get cached + this.delete(id); return result; } const now = process.uptime(); - delete this._cache[id]; // ensure new ID is added to the end - this._cache[id] = { + this._put(id, { item: result, updatedAt: now, accessedAt: now - }; + }); + if(this._autoclean) { + this._scheduleAutoCleanIfUnscheduled(); + } return result; } - setSync(id: string | number, value: ItemType | Promise, logError?: boolean) { + setSync(id: string | number, value: TItem | Promise, logError?: boolean) { let caughtError: Error | undefined = undefined; + logError ??= !(value instanceof Promise); this.set(id, value).catch((error) => { caughtError = error; if(logError) { @@ -132,40 +165,65 @@ export class CachedFetcher { }); } + private get minItemLifetime(): (number | null) { + const { itemLifetime, nullItemLifetime } = this.options; + let minItemLifetime: number = itemLifetime!; + if(minItemLifetime == null || (nullItemLifetime != null && nullItemLifetime < minItemLifetime)) { + minItemLifetime = nullItemLifetime!; + } + return minItemLifetime; + } + /// Cleans any expired entries, and returns the amount of time to wait until the next cleaning cleanExpiredEntries(opts?: {limit?: number}): (number | null) { - const { itemLifetime, accessResetsLifetime } = this.options; - if(!itemLifetime) { + const { itemLifetime, nullItemLifetime } = this.options; + let nowRef: OutRef = {}; + let count = 0; + // clean null entries + if(nullItemLifetime != null) { + for(const id of this._nullEntries) { + const itemNode = this._cache[id]; + if(itemNode && !(itemNode instanceof Promise)) { + const elapsedTimeRef: OutRef = {}; + if(this._expireItemIfNeeded(id, itemNode, nowRef, elapsedTimeRef)) { + // expired + } + } + count++; + // check if we should stop here + if(opts?.limit && count >= opts.limit) { + // return seconds until we should clean again + return 0; + } + } + } + // clean old entries + if(itemLifetime == null) { // items have no lifetime return null; } - let count = 0; - const now = process.uptime(); for(const id of Object.keys(this._cache)) { const itemNode = this._cache[id]; if(itemNode && !(itemNode instanceof Promise)) { - // get elapsed time - let elapsedTime; - if(accessResetsLifetime) { - elapsedTime = now - itemNode.accessedAt; - } else { - elapsedTime = now - itemNode.updatedAt; - } - // return next expiration if done - const remainingTime = itemLifetime - elapsedTime; - if(opts?.limit && count >= opts.limit) { - return remainingTime; - } - // check if item is expired - if(remainingTime <= 0) { - // item has expired, so delete it from the cache - delete this._cache[id]; + const elapsedTimeRef: OutRef = {}; + if(this._expireItemIfNeeded(id, itemNode, nowRef, elapsedTimeRef)) { + // expired } else { - // item is not expired, so we can stop here, since all items after will be newer - return remainingTime; + // item is not expired, so check if we can stop here, since all items after will be newer + const remainingTime = (itemLifetime - elapsedTimeRef.val!); + if(remainingTime > 0) { + // item would not be expired with regular item lifetime, so stop here + // return seconds until we should clean again + return remainingTime; + } } } count++; + // check if we should stop here + if(opts?.limit && count >= opts.limit) { + // return seconds until we should clean again + return 0; + } } return null; } diff --git a/src/logging.ts b/src/logging.ts index b07e583..b6884f0 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,15 +1,23 @@ import http from 'http'; +import stream from 'stream'; import express from 'express'; import type { PlexServerAccountInfo } from './plex/accounts'; import { PlexNotificationSender, PlexNotificationSenderTypeToName } from './plex/notifications'; +import type { PossiblySilentError } from './utils/error'; import { urlFromClientRequest } from './utils/requests'; -import { requestIsEncrypted } from './utils/requesthandling'; +import { + expressRequestDebugString, + requestIsEncrypted +} from './utils/requesthandling'; import type { WebSocketEventMap } from './utils/websocket'; -import * as overseerrTypes from './plugins/requests/providers/overseerr/apitypes'; +import type * as overseerrTypes from './plugins/requests/providers/overseerr/apitypes'; export type GeneralLoggingOptions = { + logTimestamps?: boolean; + logLogLevel?: boolean; logDebug?: boolean; logFullURLs?: boolean; + logWatchedPaths?: boolean; }; export type PlexLoggingOptions = { @@ -56,7 +64,11 @@ export type OverseerrLoggingOptions = { logOverseerrUsers?: boolean; logOverseerrUserMatches?: boolean; logOverseerrUserMatchFailures?: boolean; -} +}; + +export type PasswordLockLoggingOptions = { + logLibraryIsLocked?: boolean; +}; export type LoggingOptions = GeneralLoggingOptions @@ -66,7 +78,8 @@ export type LoggingOptions = & ProxyRequestsLoggingOptions & WebsocketLoggingOptions & NotificationLoggingOptions - & OverseerrLoggingOptions; + & OverseerrLoggingOptions + & PasswordLockLoggingOptions; export class Logger { options: LoggingOptions; @@ -83,6 +96,33 @@ export class Logger { return true; } + fullUrlStringOfRequest(req: express.Request | http.IncomingMessage) { + const exReq = req as express.Request; + if(exReq.baseUrl) { + return exReq.baseUrl + req.url!; + } else { + return req.url!; + } + } + + urlStringOfRequest(req: express.Request | http.IncomingMessage) { + // return full url if enabled + if(this.options.logFullURLs) { + return this.fullUrlStringOfRequest(req); + } + // just return the path + const exReq = req as express.Request; + if(exReq.path) { + return exReq.path; + } + const reqUrlString = this.fullUrlStringOfRequest(req); + const queryIndex = reqUrlString.indexOf('?'); + if(queryIndex != -1) { + return reqUrlString.substring(0, queryIndex); + } + return reqUrlString; + }; + urlString(urlString: string) { if(this.options.logFullURLs) { return urlString; @@ -94,6 +134,48 @@ export class Logger { return urlString; }; + logWatchingDirectory(directoryPath) { + if(!this.options.logWatchedPaths) { + return; + } + console.log(`Watching directory ${directoryPath}`); + } + + logStoppedWatchingDirectory(directoryPath) { + if(!this.options.logWatchedPaths) { + return; + } + console.log(`Stopped watching directory ${directoryPath}`); + } + + logWatchingFile(filePath) { + if(!this.options.logWatchedPaths) { + return; + } + console.log(`Watching file ${filePath}`); + } + + logStoppedWatchingFile(filePath) { + if(!this.options.logWatchedPaths) { + return; + } + console.log(`Stopped watching file ${filePath}`); + } + + logWatchedFileChanged(eventType, filePath, filename) { + if(!this.options.logWatchedPaths) { + return; + } + console.log(`\nFile ${eventType} ${filename} detected: ${filePath}`); + } + + logWatchedDirectoryFileChanged(eventType, directoryPath, filename) { + if(!this.options.logWatchedPaths) { + return; + } + console.log(`\nDirectory file ${eventType} detected: ${directoryPath}/${filename}`); + } + logOutgoingRequest(url: string, options: RequestInit) { if(!this.options.logOutgoingRequests) { return; @@ -227,7 +309,7 @@ export class Logger { return true; } - logProxyAndUserResponse(userReq: express.Request, userRes: express.Response, proxyRes: http.IncomingMessage, headers: http.IncomingHttpHeaders | undefined, resDataString: string | undefined): boolean { + logProxyAndUserResponse(userReq: express.Request, userRes: express.Response, proxyRes: http.IncomingMessage, headers: http.IncomingHttpHeaders | http.OutgoingHttpHeaders | undefined, resDataString: string | undefined): boolean { const isErrorResponse = !proxyRes.statusCode || proxyRes.statusCode < 200 || proxyRes.statusCode >= 300; if(!(this.options.logUserResponses || this.options.logProxyResponses || (this.options.logProxyErrorResponseBody && isErrorResponse)) @@ -256,13 +338,14 @@ export class Logger { return true; } - logIncomingUserUpgradeRequest(userReq: http.IncomingMessage): boolean { + logIncomingUserUpgradeRequest(req: http.IncomingMessage, socket: stream, head: Buffer): boolean { if(!(this.options.logUserRequests || this.options.logWebsocketConnections)) { return false; } - console.log(`\n\x1b[104mupgrade ws ${userReq.url}\x1b[0m`); + const reqUrlString = this.fullUrlStringOfRequest(req); + console.log(`\n\x1b[104mupgrade ${req.headers['upgrade'] ?? ''} ${req.method ?? ''} ${reqUrlString}\x1b[0m`); if(this.options.logUserRequestHeaders) { - const reqHeaderList = userReq.rawHeaders; + const reqHeaderList = req.rawHeaders; for(let i=0; i { + this.logIncomingWebsocketClosed(req); + }) + } return true; } @@ -277,7 +365,8 @@ export class Logger { if(!(this.options.logUserRequests || this.options.logWebsocketConnections)) { return false; } - console.log(`\nclosed socket ${req.url}`); + const reqUrlString = this.fullUrlStringOfRequest(req); + console.log(`\nclosed socket ${reqUrlString}`); return true; } @@ -317,8 +406,10 @@ export class Logger { } logPlexRequestHandlerFailed(userReq: express.Request, userRes: express.Response, error: Error): boolean { - const logsAnyUrls = this.options.logUserRequests || this.options.logProxyRequests || this.options.logProxyResponses; - console.error(`Plex request handler failed${!logsAnyUrls ? ` for ${userReq.originalUrl} :` : ':'}`); + if((error as PossiblySilentError).silent && !this.options.logDebug && !this.options.logUserResponses) { + return false; + } + console.error(`Plex request handler failed\n${expressRequestDebugString(userReq)}`); console.error(error); return true; } diff --git a/src/main.ts b/src/main.ts index cf1fae8..8f3b366 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node --enable-source-maps +import tls from 'tls'; import sharp from 'sharp'; +import fs from 'fs'; import * as constants from './constants'; import { Config, @@ -16,25 +18,29 @@ import { } from './utils/ssl'; import { IPv4NormalizeMode } from './utils/ip'; import { + includeLogLevelForAllLogs, + includeTimestampsForAllLogs, includeTracesForConsoleWarnAndError, modConsoleColors, } from './utils/console'; +import { addProtocolToUrlIfMissing } from './utils/url'; import { getAppVersionString } from './utils/version'; import { RequestExecutor } from './fetching/RequestExecutor'; import { PseuplexApp } from './pseuplex'; +import PasswordLockPlugin from './plugins/passwordlock'; +import HideSectionsPlugin from './plugins/hidesections'; import LetterboxdPlugin from './plugins/letterboxd'; import RequestsPlugin from './plugins/requests'; import DashboardPlugin from './plugins/dashboard'; import { calculatePlexP12Password, - getPlexP12Path, + findPlexP12Path, readPlexPreferences } from './plex/config'; import { PlexPreferences } from './plex/types'; import { PlexClient } from './plex/client'; import { Logger, LoggingOptions } from './logging'; import { importPlugins, installPlugins } from './pluginload'; -import { addProtocolToUrlIfMissing } from './utils/url'; if(process.env.NODE_ENV !== 'production') { includeTracesForConsoleWarnAndError(); @@ -46,15 +52,11 @@ sharp.cache(false); let plexPrefs: PlexPreferences | undefined = undefined; let cfg: Config; let args: CommandArguments; -const readPlexPrefsIfNeeded = async () => { - if(!plexPrefs) { - plexPrefs = await readPlexPreferences({appDataPath:cfg.plex?.appDataPath}); - } -}; -(async () => { +(async () => { try { const appVersionString = await getAppVersionString(); console.log(`${constants.APP_NAME} ${appVersionString}\n`); + console.log(`${(new Date()).toISOString()}`); // parse command line arguments args = parseCmdArgs(process.argv.slice(2)); @@ -66,6 +68,12 @@ const readPlexPrefsIfNeeded = async () => { console.log(`parsed arguments:\n${JSON.stringify(args, null, '\t')}\n`); process.env.DEBUG = '*'; } + if(args.logLogLevel) { + includeLogLevelForAllLogs(); + } + if(args.logTimestamps) { + includeTimestampsForAllLogs(); + } // load config cfg = await readConfigFile(args.configPath); @@ -73,6 +81,39 @@ const readPlexPrefsIfNeeded = async () => { console.log(`parsed config:\n${JSON.stringify(cfg, null, '\t')}\n`); } + // create logger + const loggingOptions: LoggingOptions = {...cfg.logging}; + for(const key of Object.keys(args)) { + if(key.startsWith('log')) { + const val = args[key]; + if(val != null) { + loggingOptions[key] = val; + } + } + } + if(args.verbose) { + console.log(`Logging options: ${JSON.stringify(loggingOptions, null, '\t')}`); + } + const logger = new Logger(loggingOptions); + + // only install plugins and exit if needed + if(args.installPluginsAndExit) { + if(!args.noInstallPlugins) { + await installPlugins(cfg); + } + process.exit(0); + return; + } + + // define function to read plex prefs + const readPlexPrefsIfNeeded = async () => { + if(!plexPrefs) { + plexPrefs = await readPlexPreferences({ + appDataPath: cfg.plex?.appDataPath + }); + } + }; + // get plex server urls let plexServerHost = cfg.plex.host; if(!plexServerHost) { @@ -94,32 +135,20 @@ const readPlexPrefsIfNeeded = async () => { if(plexServerRedirectHostSecure) { plexServerRedirectHostSecure = addProtocolToUrlIfMissing(plexServerRedirectHostSecure, 'https'); } - - // create logger - const loggingOptions: LoggingOptions = {...cfg.logging}; - for(const key of Object.keys(args)) { - if(key.startsWith('log')) { - const val = args[key]; - if(val != null) { - loggingOptions[key] = val; - } - } - } - if(args.verbose) { - console.log(`Logging options: ${JSON.stringify(loggingOptions, null, '\t')}`); - } - const logger = new Logger(loggingOptions); // initialize server SSL const sslConfig: SSLConfig = { p12Path: cfg.ssl?.p12Path, p12Password: cfg.ssl?.p12Password, certPath: cfg.ssl?.certPath, - keyPath: cfg.ssl?.keyPath + keyPath: cfg.ssl?.keyPath, }; // auto-determine p12 path if needed - if(!sslConfig.p12Path && cfg.ssl?.autoP12Path) { - let appDataPath = cfg.plex?.appDataPath; + if(cfg.ssl?.autoP12Path && (!sslConfig.p12Path || !fs.existsSync(sslConfig.p12Path))) { + if(sslConfig.p12Path) { + console.error(`Failed to find plex p12 certificate at ${sslConfig.p12Path}. Other paths will be searched.`); + } + let { appDataPath, appCachePath } = cfg.plex; if(!appDataPath) { // determine the path of plex's app data if(process.platform == 'win32') { @@ -130,7 +159,8 @@ const readPlexPrefsIfNeeded = async () => { } } } - sslConfig.p12Path = await getPlexP12Path({appDataPath}); + sslConfig.p12Path = await findPlexP12Path({appDataPath,appCachePath}); + console.log(`Using plex p12 certificate path ${sslConfig.p12Path}`); } // calculate p12 password if needed if(sslConfig.p12Path && !sslConfig.p12Password && cfg.ssl?.autoP12Password) { @@ -144,7 +174,9 @@ const readPlexPrefsIfNeeded = async () => { } // install and import plugins - await installPlugins(cfg); + if(!args.noInstallPlugins) { + await installPlugins(cfg); + } const plugins = await importPlugins(cfg); // read SSL certificates, if any @@ -156,11 +188,12 @@ const readPlexPrefsIfNeeded = async () => { httpPort: cfg.httpPort ?? cfg.port, httpsPort: cfg.httpsPort ?? cfg.port, ipv4ForwardingMode: cfg.ipv4ForwardingMode ? IPv4NormalizeMode[cfg.ipv4ForwardingMode] : undefined, + trustProxy: cfg.trustProxy, forwardMetadataRefreshToPluginMetadata: cfg.forwardMetadataRefreshToPluginMetadata, sendMetadataUnavailability: cfg.sendMetadataUnavailability, overwritePlexPrivatePort: cfg.plex.overwritePrivatePort, mapPseuplexMetadataIds: cfg.remapMetadataIds, - serverOptions: { + tlsCertOptions: { ...sslCertData }, plexServerHost, @@ -191,6 +224,8 @@ const readPlexPrefsIfNeeded = async () => { overlayImageOverrides: cfg.imageOverlays?.overrides, logger, plugins: [ + PasswordLockPlugin, + HideSectionsPlugin, LetterboxdPlugin, RequestsPlugin, DashboardPlugin, @@ -213,10 +248,11 @@ const readPlexPrefsIfNeeded = async () => { }); // watch for certificate changes if this is an SSL server - const secureServer = pseuplex.httpsServer || pseuplex.httpolyglotServer; + const secureServer = pseuplex.httpsServer || ((pseuplex.httpolyglotServer as any)?._tlsServer as tls.Server); if(cfg.ssl?.watchCertChanges && secureServer?.setSecureContext) { const watcher = watchSSLCertAndKeyChanges(sslConfig, { - debounceDelay: (cfg.ssl?.certReloadDelay ?? 1000) + debounceDelay: (cfg.ssl?.certReloadDelay ?? 1000), + logger, }, (sslCertData) => { try { console.log("\nUpdating SSL certificate"); @@ -228,7 +264,7 @@ const readPlexPrefsIfNeeded = async () => { }); } -})().catch((error) => { +} catch(error) { console.error(error); process.exit(2); -}); +} })(); diff --git a/src/plex/accounts.ts b/src/plex/accounts.ts index 8c156d8..fa3a625 100644 --- a/src/plex/accounts.ts +++ b/src/plex/accounts.ts @@ -6,15 +6,23 @@ import * as plexServerAPI from './api'; import * as plexTVAPI from '../plextv/api'; import { PlexTVCurrentUserInfo } from '../plextv/types'; import { Logger } from '../logging'; -import { HttpResponseError } from '../utils/error'; +import { CachedFetcher } from '../fetching/CachedFetcher'; +import { httpError, HttpResponseError } from '../utils/error'; import { PlexServerPropertiesStore } from './serverproperties'; +export type PlexTransientTokenInfo = { + creatorToken: string; + type: string; + scope: string; +}; + export type PlexServerAccountInfo = { email: string; plexUsername: string; plexUserID: number | string; serverUserID: number | string; isServerOwner: boolean; + transient?: PlexTransientTokenInfo; }; export type PlexServerAccountsStoreOptions = { @@ -23,115 +31,105 @@ export type PlexServerAccountsStoreOptions = { logger?: Logger; }; +const TransientTokenPrefix = 'transient-'; + export class PlexServerAccountsStore { readonly plexServerProperties: PlexServerPropertiesStore; readonly sharedServersMinLifetime: number; - _tokensToPlexOwnersMap: {[token: string]: PlexServerAccountInfo} = {}; - _tokensToPlexUsersMap: {[token: string]: PlexServerAccountInfo} = {}; - - _serverOwnerTokenCheckTasks: {[key: string]: Promise} = {}; _sharedServersTask: Promise | null = null; _lastSharedServersFetchTime: number | null = null; + _ownerTokens: CachedFetcher; + _sharedTokens: {[token: string]: PlexServerAccountInfo} = {}; + _transientTokens: CachedFetcher; + _logger?: Logger; constructor(options: PlexServerAccountsStoreOptions) { this.plexServerProperties = options.plexServerProperties; this.sharedServersMinLifetime = options.sharedServersMinLifetime ?? 60; this._logger = options.logger; + this._ownerTokens = new CachedFetcher((token: string) => { + return this._fetchTokenServerOwnerAccount(token); + }, { + itemLifetime: (60 * 60 * 24), // 24 hour lifetime + nullItemLifetime: 120 // 120 seconds + }); + this._transientTokens = new CachedFetcher((token) => { + return undefined!; + }, { + itemLifetime: (60 * 60 * 48), // 48 hour lifetime + }); } get lastSharedServersFetchTime() { return this._lastSharedServersFetchTime; } - isTokenMapped(token: string): boolean { - if(this._tokensToPlexOwnersMap[token] || this._tokensToPlexUsersMap[token]) { - return true; - } - return false; - } - /// Returns the account info if the token belongs to the server owner, otherwise returns null private async _fetchTokenServerOwnerAccount(token: string): Promise { - let task = this._serverOwnerTokenCheckTasks[token]; - if(task) { - // wait for existing task - return await task; - } + // send request for myplex account + let myPlexAccountPage: PlexMyPlexAccountPage | null; try { - task = (async () => { - // send request for myplex account - let myPlexAccountPage: PlexMyPlexAccountPage | null; - try { - myPlexAccountPage = await plexServerAPI.getMyPlexAccount({ - ...this.plexServerProperties.requestOptions, - authContext: { - 'X-Plex-Token': token - } - }); - } catch(error) { - // 401 or 403 means the token isn't authorized as the server owner - // (this changed from 401 to 403 in a version update) - const httpResponse = (error as HttpResponseError).httpResponse; - if(httpResponse?.status == 401 || httpResponse?.status == 403) { - return null; - } - // all non 401/403 errors should still get thrown - throw error; - } - // check that required data exists - if(!myPlexAccountPage?.MyPlex?.username) { - console.error(`Missing plex account username in MyPlex account response`); - return null; + myPlexAccountPage = await plexServerAPI.getMyPlexAccount({ + ...this.plexServerProperties.requestOptions, + authContext: { + 'X-Plex-Token': token } - // fetch the rest of the user data from plex - const plexTvOptions: plexTVAPI.PlexTVAPIRequestOptions = {...this.plexServerProperties.requestOptions}; - delete (plexTvOptions as {serverURL?: string}).serverURL; - let plexUserInfo: PlexTVCurrentUserInfo; - try { - plexUserInfo = await plexTVAPI.getCurrentUser({ - ...plexTvOptions, - authContext: { - 'X-Plex-Token': token - }, - }); - } catch (error) { - const httpResponse = (error as HttpResponseError).httpResponse; - if(httpResponse?.status == 401 || httpResponse?.status == 403) { - // this shouldn't hit, but since there was a past version of plex where it did (as a bug), we should log here and handle gracefully - console.error("The plex server owner wasn't able to fetch account info:"); - console.error(error); - return null; - } - throw error; - } - // ensure the account info matches the owner info - if (plexUserInfo.email != myPlexAccountPage.MyPlex.username - && plexUserInfo.username != myPlexAccountPage.MyPlex.username) { - console.error(`User info ${plexUserInfo.email ?? plexUserInfo.username} doesnt match plex server owner ${myPlexAccountPage.MyPlex.username}`); - return null; - } - // add user info for owner - const userInfo: PlexServerAccountInfo = { - email: myPlexAccountPage.MyPlex.username, - serverUserID: 1, // user 1 is the server owner - plexUsername: plexUserInfo.username, - plexUserID: plexUserInfo.id, - isServerOwner: true - }; - this._tokensToPlexOwnersMap[token] = userInfo; - this._logger?.logPlexTokenRegistered(token, userInfo); - return userInfo; - })(); - // store pending task and wait - this._serverOwnerTokenCheckTasks[token] = task; - return await task; - } finally { - // delete pending task - delete this._serverOwnerTokenCheckTasks[token]; + }); + } catch(error) { + // 401 or 403 means the token isn't authorized as the server owner + // (this changed from 401 to 403 in a version update) + const httpResponse = (error as HttpResponseError).httpResponse; + if(httpResponse?.status == 401 || httpResponse?.status == 403) { + return null; + } + // all non 401/403 errors should still get thrown + throw error; + } + // check that required data exists + if(!myPlexAccountPage?.MyPlex?.username) { + console.error(`Missing plex account username in MyPlex account response`); + return null; } + // fetch the rest of the user data from plex + const plexTvOptions: plexTVAPI.PlexTVAPIRequestOptions = {...this.plexServerProperties.requestOptions}; + delete (plexTvOptions as {serverURL?: string}).serverURL; + let plexUserInfo: PlexTVCurrentUserInfo; + try { + plexUserInfo = await plexTVAPI.getCurrentUser({ + ...plexTvOptions, + authContext: { + 'X-Plex-Token': token + }, + }); + } catch (error) { + const httpResponse = (error as HttpResponseError).httpResponse; + if(httpResponse?.status == 401 || httpResponse?.status == 403) { + // this shouldn't hit, but since there was a past version of plex where it did (as a bug), we should log here and handle gracefully + console.error("The plex server owner wasn't able to fetch account info:"); + console.error(error); + return null; + } + throw error; + } + // ensure the account info matches the owner info + if (plexUserInfo.email != myPlexAccountPage.MyPlex.username + && plexUserInfo.username != myPlexAccountPage.MyPlex.username) { + console.error(`User info ${plexUserInfo.email ?? plexUserInfo.username} doesnt match plex server owner ${myPlexAccountPage.MyPlex.username}`); + return null; + } + // return user info for owner + const userInfo: PlexServerAccountInfo = { + email: myPlexAccountPage.MyPlex.username, + serverUserID: 1, // user 1 is the server owner + plexUsername: plexUserInfo.username, + plexUserID: plexUserInfo.id, + isServerOwner: true + }; + this._logger?.logPlexTokenRegistered(token, userInfo); + return userInfo; } /// Refetches the list of shared servers if needed @@ -169,16 +167,16 @@ export class PlexServerAccountsStore { plexUserID: sharedServer.id, isServerOwner: false }; - this._tokensToPlexUsersMap[sharedServer.accessToken] = userInfo; + this._sharedTokens[sharedServer.accessToken] = userInfo; this._logger?.logPlexTokenRegistered(sharedServer.accessToken, userInfo); } } } // delete old server tokens - for(const token in this._tokensToPlexUsersMap) { + for(const token in this._sharedTokens) { if(!newServerTokens.has(token)) { - const userInfo = this._tokensToPlexUsersMap[token]; - delete this._tokensToPlexUsersMap[token]; + const userInfo = this._sharedTokens[token]; + delete this._sharedTokens[token]; this._logger?.logPlexTokenUnregistered(token, userInfo); } } @@ -195,29 +193,59 @@ export class PlexServerAccountsStore { } } - async getUserInfo(authContext: PlexAuthContext): Promise { - const token = authContext['X-Plex-Token']; - if(!token) { - return null; + async getNonTransientUserInfo(token: string): Promise { + if(token.startsWith(TransientTokenPrefix)) { + throw httpError(403, "Transient token is not allowed in this context"); } - // get user info for token - let userInfo: (PlexServerAccountInfo | null) = this._tokensToPlexOwnersMap[token] ?? this._tokensToPlexUsersMap[token]; + // get owner user info if any + let userInfo = await this._ownerTokens.getOrFetch(token); if(userInfo) { return userInfo; } - // check if the token belongs to the server owner - userInfo = await this._fetchTokenServerOwnerAccount(token); + // get shared user info if any + userInfo = this._sharedTokens[token]; if(userInfo) { return userInfo; } // refetch shared users for server if needed if(await this._refetchSharedServersIfAble()) { // get the token user info (if any) - return this._tokensToPlexUsersMap[token] ?? null; + return this._sharedTokens[token] ?? null; } return null; } + async getUserInfo(authContext: PlexAuthContext): Promise { + let token = authContext['X-Plex-Token']; + if(!token) { + return null; + } + // check if token is a transient token + let transientToken: string | undefined; + let transientInfo = await this._transientTokens.get(token); + if(transientInfo) { + transientToken = token; + token = transientInfo.creatorToken; + } else if(token.startsWith(TransientTokenPrefix)) { + throw httpError(401, "Invalid token"); + } + // get user info from non-transient token + let userInfo = await this.getNonTransientUserInfo(token); + if(!userInfo) { + return null; + } + // attach transient info if needed + if(transientInfo) { + userInfo = { + ...userInfo, + transient: { + ...transientInfo, + }, + }; + } + return userInfo; + } + async getUserInfoOrNull(authContext: PlexAuthContext): Promise { try { return await this.getUserInfo(authContext); @@ -227,4 +255,22 @@ export class PlexServerAccountsStore { return null; } } + + + startAutoCleaningTokens() { + this._transientTokens.startAutoClean(); + this._ownerTokens.startAutoClean(); + } + + stopAutoCleaningTokens() { + this._transientTokens.stopAutoClean(); + this._ownerTokens.stopAutoClean(); + } + + registerTransientToken(transientToken: string, tokenInfo: PlexTransientTokenInfo) { + if(tokenInfo.creatorToken.startsWith(TransientTokenPrefix)) { + throw httpError(403, "Transient tokens cannot create other transient tokens"); + } + this._transientTokens.setSync(transientToken, tokenInfo); + } } diff --git a/src/plex/api/library.ts b/src/plex/api/library.ts index 344b492..cd09e20 100644 --- a/src/plex/api/library.ts +++ b/src/plex/api/library.ts @@ -5,14 +5,14 @@ import { plexServerFetch } from './core'; -export const getLibraryMetadata = async (id: string | string[], options: (PlexAPIRequestOptions & { +export const getLibraryMetadata = async (id: string | number | (string | number)[], options: (PlexAPIRequestOptions & { params?: plexTypes.PlexMetadataPageParams, })): Promise => { - const idString = (id instanceof Array) ? id.map((idVal) => qs.escape(idVal)).join(',') : qs.escape(id); + const idsString = (id instanceof Array) ? id.map((idVal) => qs.escape(idVal?.toString())).join(',') : qs.escape(id?.toString()); return await plexServerFetch({ ...options, method: 'GET', - endpoint: `library/metadata/${idString}`, + endpoint: `library/metadata/${idsString}`, }); }; diff --git a/src/plex/config.ts b/src/plex/config.ts index cdd69bb..9a3fdc5 100644 --- a/src/plex/config.ts +++ b/src/plex/config.ts @@ -31,8 +31,9 @@ export const readPlexPreferences = async (opts?: {appDataPath?: string, prefFile case 'darwin': return await readPrefsFromMacOSDefaults(); - case 'linux': default: + console.warn(`Unknown platform ${process.platform}. Linux will be assumed`); + case 'linux': return await readPrefsFromXML(`${opts?.appDataPath ?? PlexAppDataDir_Linux}/Preferences.xml`); } }; @@ -84,16 +85,49 @@ export const calculatePlexP12Password = (prefs: {ProcessedMachineIdentifier}): s return crypto.createHash('sha512').update(`plex${prefs.ProcessedMachineIdentifier}`).digest('hex'); }; -export const getPlexP12Path = (opts: {appDataPath?: string}) => { +export const getPlexP12BasePath = (opts: {appDataPath?: string, appCachePath?: string}) => { switch(process.platform) { case 'win32': - return `${opts?.appDataPath || `${os.homedir()}/AppData/Local/Plex Media Server`}/Cache/cert-v2.p12`; + return `${opts?.appCachePath || `${opts?.appDataPath || `${os.homedir()}/AppData/Local/Plex Media Server`}/Cache`}`; case 'darwin': - return `${os.homedir()}/Library/Caches/PlexMediaServer/cert-v2.p12`; + return `${opts?.appCachePath || `${os.homedir()}/Library/Caches/PlexMediaServer`}`; - case 'linux': default: - return `${opts?.appDataPath || PlexAppDataDir_Linux}/Cache/cert-v2.p12`; + console.warn(`Unknown platform ${process.platform}. Linux will be assumed`); + case 'linux': + return `${opts?.appCachePath || `${opts?.appDataPath || PlexAppDataDir_Linux}/Cache`}`; + } +}; + +export const PossiblePlexP12FileNames = ['cert-v2.p12', 'certificate.p12']; + +export const findPlexP12Path = async (opts: {appDataPath?: string, appCachePath?: string}): Promise => { + const p12BasePath = getPlexP12BasePath(opts); + // attempt to access all the possible p12 paths + for(const fileName of PossiblePlexP12FileNames) { + const fullP12Path = `${p12BasePath}/${fileName}`; + try { + await fs.promises.access(fullP12Path, fs.constants.R_OK); + return fullP12Path; + } catch(error) { + console.error(`Error while accessing plex p12 file at ${fullP12Path}`); + console.error(error); + } + } + // look for any file with the p12 extension + const filesInPath = await fs.promises.readdir(p12BasePath, { + encoding: 'utf8' + }); + for(const fileName of filesInPath.filter((p) => (p.endsWith('.p12') || p.endsWith('.P12')))) { + const fullP12Path = `${p12BasePath}/${fileName}`; + try { + await fs.promises.access(fullP12Path, fs.constants.R_OK); + return fullP12Path; + } catch(error) { + console.error(`Error while accessing plex p12 file at ${fullP12Path}`); + console.error(error); + } } + return `${p12BasePath}/cert-v2.p12`; }; diff --git a/src/plex/metadata.ts b/src/plex/metadata.ts index c742a47..8cde114 100644 --- a/src/plex/metadata.ts +++ b/src/plex/metadata.ts @@ -31,6 +31,8 @@ export type PlexIdCachedInfo = { parentRatingKey?: string; grandparentSlug?: string; grandparentRatingKey?: string; + thumb?: string; + year?: number; Guid?: plexTypes.PlexGuid[]; }; @@ -39,8 +41,10 @@ export class PlexIdToInfoCache extends CachedFetcher { static fields: (keyof PlexIdCachedInfo)[] = [ 'index', 'slug', + 'thumb', + 'year', 'parentIndex','parentSlug','parentRatingKey', - 'grandparentSlug','grandparentRatingKey' + 'grandparentSlug','grandparentRatingKey', ]; static elements: (keyof PlexIdCachedInfo)[] = ['Guid']; plexMetadataClient: PlexClient; @@ -68,6 +72,8 @@ export class PlexIdToInfoCache extends CachedFetcher { return { index: metadataItem.index, slug: metadataItem.slug, + year: metadataItem.year, + thumb: metadataItem.thumb, parentIndex: metadataItem.parentIndex, parentSlug: metadataItem.parentSlug, parentRatingKey: metadataItem.parentRatingKey, diff --git a/src/plex/metadataidentifier.ts b/src/plex/metadataidentifier.ts index bda2a68..b8b0320 100644 --- a/src/plex/metadataidentifier.ts +++ b/src/plex/metadataidentifier.ts @@ -1,14 +1,23 @@ - +import qs from 'querystring'; import * as plexTypes from './types'; import { httpError } from '../utils/error'; -export type PlexMetadataKeyParts = { +export type PlexSingularMetadataKeyParts = { basePath: string; id: string; relativePath?: string; }; -export const parseMetadataIDFromKeyOrThrow = (metadataKey: string, basePath: string): PlexMetadataKeyParts | null => { +export type PlexPluralMetadataKeyParts = { + basePath: string; + ids: string[]; + relativePath?: string; +}; + +export const PlexLibraryMetadataBasePath = '/library/metadata'; + + +const parseRawPlexMetadataKeyOrThrow = (metadataKey: string, basePath: string): PlexSingularMetadataKeyParts => { if(!metadataKey) { throw httpError(400, `Invalid empty metadata key`); } @@ -28,21 +37,47 @@ export const parseMetadataIDFromKeyOrThrow = (metadataKey: string, basePath: str const parsedBasePath = metadataKey.slice(0, idStartIndex); const slashIndex = metadataKey.indexOf('/', idStartIndex); if(slashIndex == -1) { + const idString = metadataKey.substring(idStartIndex); return { basePath: parsedBasePath, - id: metadataKey.substring(idStartIndex) + id: idString }; } + const idString = metadataKey.substring(idStartIndex, slashIndex); return { basePath: parsedBasePath, - id: metadataKey.substring(idStartIndex, slashIndex), + id: idString, relativePath: metadataKey.substring(slashIndex) }; }; -export const parseMetadataIDFromKey = (metadataKey: string, basePath: string, warnOnFailure: boolean = true): PlexMetadataKeyParts | null => { +export const parsePlexMetadataKeyOrThrow = (metadataKey: string): PlexSingularMetadataKeyParts => { + return parseRawPlexMetadataKeyOrThrow(metadataKey, PlexLibraryMetadataBasePath); +}; + +export const parsePlexMetadataKey = (metadataKey: string, warnOnFailure: boolean = true): PlexSingularMetadataKeyParts | null => { try { - return parseMetadataIDFromKeyOrThrow(metadataKey, basePath); + return parsePlexMetadataKeyOrThrow(metadataKey); + } catch(error) { + if(warnOnFailure) { + console.warn((error as Error).message); + } + return null; + } +}; + +export const parsePlexPluralMetadataKeyOrThrow = (metadataKey: string): PlexPluralMetadataKeyParts => { + const rawMetadataKeyParts = parseRawPlexMetadataKeyOrThrow(metadataKey, PlexLibraryMetadataBasePath); + const ids = rawMetadataKeyParts.id.split(','); + const pluralKeyParts = (rawMetadataKeyParts as Partial); + delete (rawMetadataKeyParts as Partial).id; + pluralKeyParts.ids = ids; + return pluralKeyParts as PlexPluralMetadataKeyParts; +}; + +export const parsePlexPluralMetadataKey = (metadataKey: string, warnOnFailure: boolean = true): PlexPluralMetadataKeyParts | null => { + try { + return parsePlexPluralMetadataKeyOrThrow(metadataKey); } catch(error) { if(warnOnFailure) { console.warn((error as Error).message); @@ -54,44 +89,62 @@ export const parseMetadataIDFromKey = (metadataKey: string, basePath: string, wa export type PlexMetadataGuidParts = { protocol: plexTypes.PlexMetadataGuidProtocol | string; type?: plexTypes.PlexMediaItemType | string; - id: string + id: string; + relativePath?: string; }; export const parsePlexMetadataGuidOrThrow = (guid: string): PlexMetadataGuidParts => { if(!guid) { throw httpError(400, "Invalid empty guid"); } - // trim trailing slash - if(guid.endsWith('/')) { - guid = guid.substring(0, guid.length-1); - } // parse protocol const protocolEndIndex = guid.indexOf('://'); if(protocolEndIndex == -1) { - throw httpError(400, `Invalid guid ${guid}`); + throw httpError(400, `Invalid guid ${guid} has no protocol`); } const protocol = guid.slice(0, protocolEndIndex); - // split remaining path - const remainingPath = guid.slice(protocolEndIndex+3); - if(!remainingPath) { - throw httpError(400, `Invalid guid ${guid}`); - } - const pathParts = remainingPath.split('/'); - if(pathParts.length > 2) { - throw httpError(400, `Invalid guid ${guid}`); - } - // parse ID portion - const id = pathParts[pathParts.length-1]; - if(!id) { - throw httpError(400, `Invalid guid ${guid}`); - } - // parse type portion - const type = pathParts.length > 1 ? pathParts[0] : undefined; - // parse protocol + const pathStartIndex = protocolEndIndex+3; + // try to find a slash that divides the type and ID + const typeEndIndex = guid.indexOf('/', pathStartIndex); + if(typeEndIndex == -1) { + // there is no slash, so remaining path is just the ID + // protocol://id + return { + protocol, + id: guid.slice(pathStartIndex) + }; + } + else if(typeEndIndex == guid.length-1) { + // ends in a slash, so just set a relative path and no "type" + // protocol://id/ + return { + protocol, + id: guid.slice(pathStartIndex, typeEndIndex), + relativePath: guid.slice(typeEndIndex), + }; + } + // got type + const type = guid.slice(pathStartIndex, typeEndIndex); + // find any other slashes in the remaining path + const idStartIndex = typeEndIndex+1; + const idEndIndex = guid.indexOf('/', idStartIndex); + if(idEndIndex == -1) { + // protocol://type/id + return { + protocol, + type, + id: guid.slice(idStartIndex) + }; + } + // split relative path + const id = guid.slice(idStartIndex, idEndIndex); + const relativePath = guid.slice(idEndIndex); + // protocol://type/id/relativepath return { protocol, type, - id + id, + relativePath }; }; diff --git a/src/plex/proxy.ts b/src/plex/proxy.ts index a000332..70640d0 100644 --- a/src/plex/proxy.ts +++ b/src/plex/proxy.ts @@ -17,10 +17,15 @@ import { } from '../utils/ip'; import { getPortFromRequest, - requestIsEncrypted + expressRequestDebugString, + remoteAddressOfRequest, + requestIsEncrypted, + urlFromServerRequest, } from '../utils/requesthandling'; +import { httpError } from '../utils/error'; export type PlexProxyOptions = { + trustProxy: boolean; logger?: Logger; ipv4Mode?: (IPv4NormalizeMode | (() => IPv4NormalizeMode)); }; @@ -37,6 +42,65 @@ type ProxyingUserResponse = express.Response & { ___proxyReq: http.ClientRequest; } +type XForwardedHeaders = { + 'X-Forwarded-For': string, + 'X-Forwarded-Port': string, + 'X-Forwarded-Proto': string, + 'X-Forwarded-Host': string | undefined, + 'X-Real-IP': string | undefined, + 'Forwarded': undefined, +}; + +const xForwardedHeaders = (req: http.IncomingMessage, options: {ipv4Mode: IPv4NormalizeMode, trustProxy: boolean}): XForwardedHeaders => { + const headers: Partial = {}; + const encrypted = requestIsEncrypted(req); + const remoteAddress = remoteAddressOfRequest(req); + const fwdHeaders = { + For: remoteAddress ? normalizeIPAddress(remoteAddress, options.ipv4Mode) : remoteAddress, + Port: getPortFromRequest(req), + Proto: encrypted ? 'https' : 'http', + }; + for(const headerSuffix of Object.keys(fwdHeaders)) { + const headerName = `X-Forwarded-${headerSuffix}`; + const lowercaseHeaderName = headerName.toLowerCase(); + let prevHeaderItems = req.headers[headerName] || req.headers[lowercaseHeaderName]; + prevHeaderItems = (prevHeaderItems instanceof Array) ? prevHeaderItems.flat(Infinity)[0] : prevHeaderItems; + const newHeaderItem = fwdHeaders[headerSuffix]; + let headerValue: string | undefined; + if(newHeaderItem) { + // if the proxy is trusted, we can append the value. otherwise just set it. + if(options.trustProxy) { + headerValue = (prevHeaderItems ? `${prevHeaderItems}, ` : '') + newHeaderItem; + } else { + headerValue = newHeaderItem; + } + } else { + let missingThing = headerSuffix.toLowerCase(); + if(missingThing == 'for') { + missingThing = 'remote address'; + } + throw httpError(400, `Missing ${missingThing} in request`); + } + headers[headerName] = headerValue; + } + // overwrite x-forwarded-host header + let fwdHost; + if(options.trustProxy) { + fwdHost = (req.headers['x-forwarded-host'] || req.headers['host']); + } else { + fwdHost = req.headers['host']; + } + fwdHost = (fwdHost instanceof Array) ? fwdHost.flat(Infinity)[0] : fwdHost; + headers['X-Forwarded-Host'] = fwdHost || undefined; + // set x-real-ip header + const incomingRealIPHeader = req.headers['x-real-ip']; + let realIP = (options.trustProxy && incomingRealIPHeader) ? incomingRealIPHeader : fwdHeaders.For; + realIP = (realIP instanceof Array) ? realIP.flat(Infinity)[0] : realIP; + headers['X-Real-IP'] = realIP || undefined; + headers['Forwarded'] = undefined; // just delete this header always for now + return headers as XForwardedHeaders; +}; + type HostOrHostGetter = (string | ((req: express.Request) => string)); export const plexThinProxy = (host: HostOrHostGetter, options: PlexProxyOptions, proxyFilters: expressHttpProxy.ProxyOptions = {}) => { @@ -51,37 +115,19 @@ export const plexThinProxy = (host: HostOrHostGetter, options: PlexProxyOptions, ?? IPv4NormalizeMode.DontChange; reqOpts.headers ??= {}; // add x-forwarded headers - const encrypted = requestIsEncrypted(userReq); - const remoteAddress = userReq.connection?.remoteAddress || userReq.socket?.remoteAddress; - const fwdHeaders = { - For: remoteAddress ? normalizeIPAddress(remoteAddress, ipv4Mode) : remoteAddress, - Port: getPortFromRequest(userReq), - Proto: encrypted ? 'https' : 'http', - }; - for(const headerSuffix in fwdHeaders) { - if(headerSuffix == null) { - continue; - } - const headerName = 'X-Forwarded-' + headerSuffix; - const lowercaseHeaderName = headerName.toLowerCase(); - const prevHeaderVal = userReq.headers[headerName] || userReq.headers[lowercaseHeaderName]; - const newHeaderVal = fwdHeaders[headerSuffix]; - if(newHeaderVal) { - const headerVal = (prevHeaderVal ? `${prevHeaderVal},` : '') + newHeaderVal; - delete reqOpts.headers[lowercaseHeaderName]; + const xFwdHeaders = xForwardedHeaders(userReq, { + ipv4Mode, + trustProxy:options.trustProxy + }); + for(const headerName of Object.keys(xFwdHeaders)) { + delete reqOpts.headers[headerName]; + delete reqOpts.headers[headerName.toLowerCase()]; + const headerVal = xFwdHeaders[headerName]; + if(headerVal) { reqOpts.headers[headerName] = headerVal; } } - const fwdHost = userReq.headers['x-forwarded-host'] || userReq.headers['host']; - if(fwdHost) { - delete reqOpts.headers['x-forwarded-host']; - reqOpts.headers['X-Forwarded-Host'] = fwdHost; - } - const realIP = userReq.headers['x-real-ip'] || fwdHeaders.For; - if(realIP) { - delete reqOpts.headers['x-real-ip']; - reqOpts.headers['X-Real-IP'] = realIP; - } + // call passed-in modifier if(innerProxyReqOptDecorator) { reqOpts = await innerProxyReqOptDecorator(reqOpts, userReq); } @@ -94,7 +140,7 @@ export const plexThinProxy = (host: HostOrHostGetter, options: PlexProxyOptions, if(innerProxyReqPathResolver) { url = await innerProxyReqPathResolver(userReq); } else { - url = userReq.url; + url = urlFromServerRequest(userReq); } // log proxy request const proxyReqOpts = (userReq as ProxiedUserReq).___proxyReqOpts; @@ -125,6 +171,13 @@ export type PlexAPIProxyFilters = { requestOptionsModifier?: (proxyReqOpts: http.RequestOptions, userReq: express.Request) => http.RequestOptions, requestPathModifier?: (req: express.Request) => string | Promise, requestBodyModifier?: (bodyContent: string, userReq: express.Request) => string | Promise, + responseHeadersModifier?: ( + headers: http.OutgoingHttpHeaders, + userReq: express.Request, + userRes: express.Response, + proxyReq: http.ClientRequest, + proxyRes: http.IncomingMessage + ) => http.OutgoingHttpHeaders; responseModifier?: (proxyRes: http.IncomingMessage, proxyResData: any, userReq: express.Request, userRes: express.Response) => any, }; @@ -151,8 +204,8 @@ export const plexApiProxy = (host: HostOrHostGetter, options: PlexProxyOptions, proxyReqOpts.headers['accept'] = 'application/json'; } isApiRequest = true; - } else { - console.warn(`Unknown content type for Accept header: ${userReq.headers['accept']}`); + } else if(userReq.headers['accept'] != '*/*') { + console.warn(`Unknown content type for Accept header: ${userReq.headers['accept']}\n${expressRequestDebugString(userReq)}`); } // modify request destination /*if(userReq.protocol) { @@ -172,7 +225,10 @@ export const plexApiProxy = (host: HostOrHostGetter, options: PlexProxyOptions, }, proxyReqPathResolver: proxyFilters.requestPathModifier, proxyReqBodyDecorator: proxyFilters.requestBodyModifier, - userResHeaderDecorator: (headers, userReq, userRes, proxyReq, proxyRes) => { + userResHeaderDecorator: (headers: http.OutgoingHttpHeaders, userReq, userRes, proxyReq, proxyRes) => { + if(proxyFilters.responseHeadersModifier) { + headers = proxyFilters.responseHeadersModifier(headers, userReq, userRes, proxyReq, proxyRes); + } if(proxyFilters.responseModifier) { // set the accepted content type if we're going to change back from json to xml const acceptTypes = parseHttpContentTypeFromHeader(userReq, 'accept').contentTypes; @@ -235,6 +291,7 @@ export const plexApiProxy = (host: HostOrHostGetter, options: PlexProxyOptions, if(userRes.headersSent) { console.error("Too late to remove headers"); } else { + userRes.removeHeader('content-encoding'); userRes.removeHeader('x-plex-content-original-length'); userRes.removeHeader('x-plex-content-compressed-length'); userRes.removeHeader('content-length'); @@ -248,7 +305,9 @@ export const plexApiProxy = (host: HostOrHostGetter, options: PlexProxyOptions, let resData; if(isXml) { // parse xml - console.warn(`Expected json response, but got xml`); + if(proxyReq.getHeader('accept') == 'application/json') { + console.warn(`Expected json response, but got xml`); + } resData = await plexXMLToJS(proxyResString); } else { // parse json @@ -262,18 +321,17 @@ export const plexApiProxy = (host: HostOrHostGetter, options: PlexProxyOptions, } } // serialize response - const resDataString = (await serializeResponseContent(userReq, userRes, resData)).data; - let encodedResData: (Buffer | string) = resDataString; + const serializedRes = await serializeResponseContent(userReq, userRes, resData); + let encodedResData = serializedRes.data; // encode user response if(proxyRes.headers['content-encoding']) { const encoding = proxyRes.headers['content-encoding']; // need to do this so this proxy library doesn't encode the content later delete proxyRes.headers['content-encoding']; - userRes.removeHeader('content-encoding'); // encode if(encoding == 'gzip') { encodedResData = await new Promise((resolve, reject) => { - zlib.gzip(resDataString, (error, result) => { + zlib.gzip(serializedRes.data, (error, result) => { if(error) { reject(error); } else { @@ -282,13 +340,13 @@ export const plexApiProxy = (host: HostOrHostGetter, options: PlexProxyOptions, }); }); userRes.setHeader('Content-Encoding', encoding); - userRes.setHeader('X-Plex-Content-Original-Length', resDataString.length); + userRes.setHeader('X-Plex-Content-Original-Length', serializedRes.data.length); userRes.setHeader('X-Plex-Content-Compressed-Length', encodedResData.length); - userRes.setHeader('Content-Length', encodedResData.length); } } + userRes.setHeader('Content-Length', encodedResData.length); // log user response if needed - options.logger?.logIncomingUserRequestResponse(userReq, userRes, resDataString); + options.logger?.logIncomingUserRequestResponse(userReq, userRes, serializedRes.dataString); return encodedResData; } : undefined }); @@ -304,32 +362,27 @@ export const plexHttpProxy = (serverURL: string, options: PlexProxyOptions, even const plexGeneralProxy = httpProxy.createProxyServer({ target: serverURL, ws: true, - xfwd: true, + xfwd: false, // we'll set this later preserveHeaderKeyCase: true, //changeOrigin: false, //autoRewrite: true, }); const shouldHandleProxyResponse = (events?.onProxyResponse || options.logger?.options.logProxyResponses || options.logger?.options.logUserResponses || options.logger?.options.logProxyErrorResponseBody); + // handle proxy request plexGeneralProxy.on('proxyReq', (proxyReq, userReq: express.Request, userRes: express.Response) => { const ipv4Mode = ((options.ipv4Mode instanceof Function) ? options.ipv4Mode() : options.ipv4Mode) ?? IPv4NormalizeMode.DontChange; - // add x-real-ip to proxy headers - if (!userReq.headers['x-real-ip']) { - const realIP = userReq.connection?.remoteAddress || userReq.socket?.remoteAddress; - const normalizedIP = realIP ? normalizeIPAddress(realIP, ipv4Mode) : realIP; - if(normalizedIP) { - proxyReq.setHeader('X-Real-IP', normalizedIP); - } - // fix forwarded header if needed - if(normalizedIP != realIP) { - const forwardedFor = proxyReq.getHeader('X-Forwarded-For'); - if(forwardedFor && typeof forwardedFor === 'string') { - const newForwardedFor = forwardedFor.split(',').map((part) => { - const trimmedPart = part.trim(); - return normalizeIPAddress(trimmedPart, ipv4Mode); - }).join(','); - proxyReq.setHeader('X-Forwarded-For', newForwardedFor); - } + // add x-forwarded headers + const xFwdHeaders = xForwardedHeaders(userReq, { + ipv4Mode, + trustProxy:options.trustProxy + }); + for(const headerName of Object.keys(xFwdHeaders)) { + proxyReq.removeHeader(headerName); + proxyReq.removeHeader(headerName.toLowerCase()); + const headerVal = xFwdHeaders[headerName]; + if(headerVal) { + proxyReq.setHeader(headerName, headerVal); } } // log proxy request if needed @@ -338,6 +391,26 @@ export const plexHttpProxy = (serverURL: string, options: PlexProxyOptions, even (userRes as ProxyingUserResponse).___proxyReq = proxyReq; } }); + // handle websocket proxy request + plexGeneralProxy.on('proxyReqWs', (proxyReq, userReq, socket, reqOpts, head) => { + const ipv4Mode = ((options.ipv4Mode instanceof Function) ? options.ipv4Mode() : options.ipv4Mode) + ?? IPv4NormalizeMode.DontChange; + // add x-forwarded headers + const xFwdHeaders = xForwardedHeaders(userReq, { + ipv4Mode, + trustProxy:options.trustProxy + }); + for(const headerName of Object.keys(xFwdHeaders)) { + proxyReq.removeHeader(headerName); + proxyReq.removeHeader(headerName.toLowerCase()); + const headerVal = xFwdHeaders[headerName]; + if(headerVal) { + proxyReq.setHeader(headerName, headerVal); + } + } + // TODO log proxied websocket request if needed? + }); + // handle proxy response if needed if(shouldHandleProxyResponse) { plexGeneralProxy.on('proxyRes', (proxyRes, userReq: express.Request, userRes: express.Response) => { const encoding = proxyRes.headers['content-encoding']; diff --git a/src/plex/requesthandling.ts b/src/plex/requesthandling.ts index 4540bc3..b876f55 100644 --- a/src/plex/requesthandling.ts +++ b/src/plex/requesthandling.ts @@ -1,7 +1,11 @@ - +import http from 'http'; import express from 'express'; import * as plexTypes from './types'; -import { serializeResponseContent } from './serialization'; +import { + encodeResponseContentIfAble, + SerializedPlexAPIResponse, + serializeResponseContent +} from './serialization'; import { PlexServerAccountInfo, PlexServerAccountsStore @@ -13,6 +17,7 @@ import { HttpResponseError, } from '../utils/error'; import { parseQueryParams } from '../utils/queryparams'; +import { asyncRequestHandler } from '../utils/requesthandling'; export type PlexAPIRequestHandler = (req: express.Request, res: express.Response) => Promise; export type PlexAPIRequestHandlerOptions = { @@ -22,76 +27,147 @@ export type PlexAPIRequestHandlerOptions = { export type PlexAPIRequestHandlerMiddleware = (handler: PlexAPIRequestHandler, options?: PlexAPIRequestHandlerOptions) => ((req: express.Request, res: express.Response) => Promise); export const handlePlexAPIRequest = async (req: express.Request, res: express.Response, handler: PlexAPIRequestHandler, options: PlexAPIRequestHandlerOptions): Promise => { - let serializedRes: {contentType:string, data:string}; + let serializedRes: SerializedPlexAPIResponse; try { const result = await handler(req,res); serializedRes = serializeResponseContent(req, res, result); } catch(error) { - if(!options?.logger?.logPlexRequestHandlerFailed(req, res, error)) { - console.error("Plex request handler failed:"); - console.error(error); + if(options?.logger) { + options.logger.logPlexRequestHandlerFailed(req, res, error) + } else { + if(!error.silent) { + console.error("Plex request handler failed:"); + console.error(error); + } } let statusCode = (error as HttpError).statusCode ?? (error as HttpResponseError).httpResponse?.status; - if(!statusCode) { + if(!statusCode || (statusCode >= 200 && statusCode < 300)) { statusCode = 500; } // send response - res.status(statusCode); - if(req.headers.origin) { - res.header('access-control-allow-origin', req.headers.origin); + if(!res.hasHeader('x-plex-protocol')) { + res.setHeader('x-plex-protocol', '1.0'); + } + if(req.headers.origin && !res.hasHeader('Access-Control-Allow-Origin')) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin); } + res.status(statusCode); res.send(); // TODO use error message format // log response options?.logger?.logIncomingUserRequestResponse(req, res, undefined); return; } // send response - res.status(200); + if(!res.hasHeader('X-Plex-Protocol')) { + res.setHeader('X-Plex-Protocol', '1.0'); + } + if(!res.hasHeader('Vary')) { + res.setHeader('Vary', 'Origin, X-Plex-Token'); + } + if(!res.hasHeader('Cache-Control')) { + res.setHeader('Cache-Control', 'no-cache'); + } + if(!res.hasHeader('Date')) { + res.setHeader('Date', (new Date()).toUTCString()); + } + if(req.headers.origin && !res.hasHeader('Access-Control-Allow-Origin')) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin); + } if(req.headers.origin) { - res.header('access-control-allow-origin', req.headers.origin); + if(!res.hasHeader('Access-Control-Expose-Headers')) { + res.setHeader('Access-Control-Expose-Headers', 'Location, Date'); + } + } + let encodedResData: Buffer | null | undefined; + try { + encodedResData = await encodeResponseContentIfAble(req, res, serializedRes.data); + } catch(error) { + console.error('Error encoding response data:'); + console.error(error); + } + if(!encodedResData) { + encodedResData = serializedRes.data; } - res.contentType(serializedRes.contentType) - res.send(serializedRes.data); + res.setHeader('Content-Length', encodedResData.length); + res.contentType(serializedRes.contentType); + res.status(200); + res.send(encodedResData); // log response - options?.logger?.logIncomingUserRequestResponse(req, res, serializedRes.data); + options?.logger?.logIncomingUserRequestResponse(req, res, serializedRes.dataString); +}; + +export type PlexRequestInfo = { + authContext: plexTypes.PlexAuthContext; + userInfo: PlexServerAccountInfo; + requestParams: {[key: string]: any} +}; + +export type IncomingPlexAPIRequestMixin = { + plex: PlexRequestInfo; }; -export type IncomingPlexAPIRequest = express.Request & { - plex: { - authContext: plexTypes.PlexAuthContext; - userInfo: PlexServerAccountInfo; - requestParams: {[key: string]: any} +export type IncomingPlexAPIRequest = express.Request & IncomingPlexAPIRequestMixin; +export type IncomingPlexHttpRequest = http.IncomingMessage & IncomingPlexAPIRequestMixin; + +export const authenticatePlexRequest = async (req: TRequest, accountsStore: PlexServerAccountsStore) => { + const authContext = plexTypes.parseAuthContextFromRequest(req); + const userInfo = await accountsStore.getUserInfoOrNull(authContext); + if(!userInfo) { + throw httpError(401, "Not Authorized"); } + const plexReq = req as any as IncomingPlexAPIRequestMixin; + plexReq.plex = { + authContext, + userInfo, + requestParams: parseQueryParams(req, (key) => !(key in authContext)) + }; }; -export const createPlexAuthenticationMiddleware = (accountsStore: PlexServerAccountsStore) => { - return async (req: express.Request, res: express.Response, next: (error?: Error) => void) => { - try { - const authContext = plexTypes.parseAuthContextFromRequest(req); - const userInfo = await accountsStore.getUserInfoOrNull(authContext); - if(!userInfo) { - throw httpError(401, "Not Authorized"); - } - const plexReq = req as IncomingPlexAPIRequest; - plexReq.plex = { - authContext, - userInfo, - requestParams: parseQueryParams(req, (key) => !(key in authContext)) - }; - } catch(error) { - next(error); - return; +export const createPlexAuthenticationMiddleware = (accountsStore: PlexServerAccountsStore) => { + return asyncRequestHandler(async (req: TRequest, res: TResponse) => { + const plexReq = (req as any as IncomingPlexAPIRequestMixin); + if(plexReq.plex && plexReq.plex.authContext['X-Plex-Token'] == plexTypes.parsePlexTokenFromRequest(req)) { + return false; } - next(); - }; + await authenticatePlexRequest(req, accountsStore); + return false; + }); }; export type PlexAuthedRequestHandler = ((req: IncomingPlexAPIRequest, res: express.Response) => (void | Promise)) | ((req: IncomingPlexAPIRequest, res: express.Response, next: (error?: Error) => void) => (void | Promise)); +export const createPlexServerOwnerOnlyMiddleware = () => { + return (req: IncomingPlexAPIRequest, res: http.ServerResponse, next) => { + if(!req.plex) { + next(httpError(500, "Cannot access endpoint without plex authentication")); + return; + } + if (!req.plex.userInfo.isServerOwner) { + next(httpError(403, "Get out of here you sussy baka")); + return; + } + next(); + }; +}; + +export const createNoPlexTransientTokensMiddleware = () => { + return (req: IncomingPlexAPIRequest, res: http.ServerResponse, next) => { + if(!req.plex) { + next(httpError(500, "Cannot access endpoint without plex authentication")); + return; + } + if (req.plex.userInfo.transient) { + next(httpError(403, "Get out of here you sussy baka")); + return; + } + next(); + }; +}; + export const doesRequestIncludeFirstPinnedContentDirectory = (params: { diff --git a/src/plex/serialization.ts b/src/plex/serialization.ts index 6a3a05b..6a84f20 100644 --- a/src/plex/serialization.ts +++ b/src/plex/serialization.ts @@ -1,4 +1,4 @@ - +import zlib from 'zlib'; import xml2js from 'xml2js'; import express from 'express'; @@ -140,23 +140,50 @@ export const plexJSToXML = (json: any): string => { return xmlBuilder.buildObject(json); }; - -export const serializeResponseContent = (userReq: express.Request, userRes: express.Response, data: any): { +export type SerializedPlexAPIResponse = { contentType: string; - data: string; - } => { + data: Buffer; + dataString: string; +}; + +export const serializeResponseContent = (userReq: express.Request, userRes: express.Response, data: any): SerializedPlexAPIResponse => { const acceptTypes = parseHttpContentTypeFromHeader(userReq, 'accept').contentTypes; if(acceptTypes.indexOf('application/json') != -1) { + const dataString = JSON.stringify(data); return { - contentType: 'application/json', - data: JSON.stringify(data) + contentType: 'application/json; charset=utf8', + dataString, + data: Buffer.from(dataString, 'utf8'), } } else { const xmlContentType = acceptTypes.find((item) => (item.endsWith('/xml'))); // convert to xml + const dataString = plexJSToXML(data); return { contentType: xmlContentType || 'application/xml', - data: plexJSToXML(data) + dataString, + data: Buffer.from(dataString, 'utf8'), }; } }; + +export const encodeResponseContentIfAble = async (userReq: express.Request, userRes: express.Response, data: Buffer): Promise => { + const acceptedEncodings = userReq.header('Accept-Encoding')?.split(',').map((e) => e.trim().toLowerCase()); + const encoding = 'gzip'; + if(!acceptedEncodings || acceptedEncodings.indexOf(encoding) == -1) { + return null; + } + const encodedResData = await new Promise((resolve, reject) => { + zlib.gzip(data, (error, result) => { + if(error) { + reject(error); + } else { + resolve(result); + } + }); + }); + userRes.setHeader('Content-Encoding', encoding); + userRes.setHeader('X-Plex-Content-Original-Length', data.length); + userRes.setHeader('X-Plex-Content-Compressed-Length', encodedResData.length); + return encodedResData; +} diff --git a/src/plex/serverproperties.ts b/src/plex/serverproperties.ts index 8268439..8597d67 100644 --- a/src/plex/serverproperties.ts +++ b/src/plex/serverproperties.ts @@ -55,7 +55,7 @@ export class PlexServerPropertiesStore { const key = `/library/sections/${id}`; const sections = (await this.getLibrarySections()).MediaContainer.Directory; for(const section of sections) { - if(section.key == key || section.key == id || (section as any as plexTypes.PlexContentDirectory).id == id) { + if(section.key == key || section.key == id || (section as Partial).id == id) { return section; } } diff --git a/src/plex/types/Collection.ts b/src/plex/types/Collection.ts new file mode 100644 index 0000000..d1864c8 --- /dev/null +++ b/src/plex/types/Collection.ts @@ -0,0 +1,93 @@ +import express from 'express'; +import { + PlexMediaItemType, + PlexMediaItemTypeNumeric, + PlexPluginIdentifier, + PlexSortParam +} from './common'; +import { + PlexLibrarySectionContentType, + PlexLibrarySectionViewGroup +} from './Library'; +import { PlexMeta } from './Meta'; +import { + PlexMetadataImage, + PlexUltraBlurColors +} from './Metadata'; +import { parseBooleanQueryParam, parseIntQueryParam, parseStringQueryParam } from '../../utils/queryparams'; + +export type PlexCollection = { + ratingKey: string; + key: string; + guid: string; + type: PlexMediaItemType.Collection; + title: string; + contentRating: string; + subtype: PlexMediaItemType; + summary: string; + index: number; + ratingCount: number; + thumb?: string; + art?: string; + addedAt: number; + updatedAt: number; + childCount: number; + minYear?: string; + maxYear?: string; + Image: PlexMetadataImage[]; + UltraBlurColors: PlexUltraBlurColors; +}; + + + +export enum PlexCollectionsSortField { + Random = 'random', + UpdatedAt = 'updatedAt', + // TODO add other fields +}; + +export type PlexCollectionsSortParam = PlexSortParam; + +export type PlexCollectionsPageParams = { + 'X-Plex-Container-Start'?: number; + 'X-Plex-Container-Size'?: number; + subtype?: PlexMediaItemTypeNumeric; + smart?: boolean; + sort?: PlexCollectionsSortParam; + limit?: number; +}; + +export const parsePlexCollectionsPageParams = (req: express.Request): PlexCollectionsPageParams => { + const query = req.query ?? {}; + return { + 'X-Plex-Container-Start': parseIntQueryParam(query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), + 'X-Plex-Container-Size': parseIntQueryParam(query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')), + subtype: parseIntQueryParam(query['subtype']), + smart: parseBooleanQueryParam(query['smart']), + sort: parseStringQueryParam(query['sort']) as PlexCollectionsSortParam, + limit: parseIntQueryParam(query['limit']), + }; +}; + +export type PlexCollectionsPage = { + MediaContainer: { + size: number; + totalSize: number; + offset: number; + allowSync: boolean; + art?: string; // "/:/resources/movie-fanart.jpg" + content: PlexLibrarySectionContentType; + identifier: PlexPluginIdentifier; + librarySectionID?: number | string; + librarySectionTitle?: string; + librarySectionUUID?: string; + mediaTagPrefix?: string; // "/system/bundle/media/flags/" + mediaTagVersion?: number; // 1754916256 + thumb?: string; // "/:/resources/movie.png" + title1: string; // "Movies" + title2?: string; // "All Movies" + viewGroup: PlexMediaItemType; + Meta?: PlexMeta; + Metadata: PlexCollection[]; + } +}; diff --git a/src/plex/types/Hub.ts b/src/plex/types/Hub.ts index b551178..f281f7e 100644 --- a/src/plex/types/Hub.ts +++ b/src/plex/types/Hub.ts @@ -50,27 +50,40 @@ export type PlexHubWithItems = PlexHub & { export type PlexHubPageParams = { + 'X-Plex-Container-Start'?: number; + 'X-Plex-Container-Size'?: number; contentDirectoryID?: string[]; pinnedContentDirectoryID?: string[]; includeMeta?: boolean; excludeFields?: string[]; // "summary" - start?: number; - count?: number; }; -export const parsePlexHubPageParams = (req: express.Request, options: {fromListPage: boolean}): PlexHubPageParams => { - const query = req.query; - if(!query) { - return {}; +export type ParsePlexHubPageParamsOptions = { + fromListPage: boolean +}; + +export const parsePlexHubPageParams = (req: express.Request, options: ParsePlexHubPageParamsOptions): PlexHubPageParams => { + if(options.fromListPage) { + const hubListParams = parsePlexHubListPageParams(req); + return plexHubPageParamsFromHubListParams(hubListParams); } + const query = req.query ?? {}; return { - start: options.fromListPage ? undefined : parseIntQueryParam(query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), - count: options.fromListPage ? parseIntQueryParam(query['count']) : parseIntQueryParam(query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')), + 'X-Plex-Container-Start': parseIntQueryParam(query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), + 'X-Plex-Container-Size': parseIntQueryParam(query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')), contentDirectoryID: parseStringArrayQueryParam(query['contentDirectoryID']), pinnedContentDirectoryID: parseStringArrayQueryParam(query['pinnedContentDirectoryID']), excludeFields: parseStringArrayQueryParam(query['excludeFields']), - includeMeta: parseBooleanQueryParam(query['includeMeta']) - }; + includeMeta: parseBooleanQueryParam(query['includeMeta']), + } satisfies (PlexHubPageParams & Partial); +}; + +export const plexHubPageParamsFromHubListParams = (hubListParams: PlexHubListPageParams): PlexHubPageParams => { + const params: Partial = {...hubListParams}; + params['X-Plex-Container-Size'] = params.count; + delete params.count; + delete params['X-Plex-Container-Start']; + return params; }; export type PlexHubPage = { @@ -81,7 +94,10 @@ export type PlexHubPage = { }; + export type PlexHubListPageParams = { + contentDirectoryID?: string[]; + pinnedContentDirectoryID?: string[]; count?: number; includeLibraryPlaylists?: boolean; includeStations?: boolean; @@ -97,6 +113,8 @@ export const parsePlexHubListPageParams = (req: express.Request): PlexHubListPag return {}; } return { + contentDirectoryID: parseStringArrayQueryParam(query['contentDirectoryID']), + pinnedContentDirectoryID: parseStringArrayQueryParam(query['pinnedContentDirectoryID']), count: parseIntQueryParam(query['count']), includeLibraryPlaylists: parseBooleanQueryParam(query['includeLibraryPlaylists']), includeStations: parseBooleanQueryParam(query['includeStations']), diff --git a/src/plex/types/Library.ts b/src/plex/types/Library.ts index f8c6f56..4805b67 100644 --- a/src/plex/types/Library.ts +++ b/src/plex/types/Library.ts @@ -1,3 +1,4 @@ +import express from 'express'; import { PlexLanguage, PlexLibraryAgent, @@ -5,9 +6,20 @@ import { PlexMediaItemType, PlexMediaItemTypeNumeric, PlexPluginIdentifier, + PlexSortParam, } from './common'; import { PlexMediaContainer } from './MediaContainer'; -import { BooleanQueryParam } from '../../utils/queryparams'; +import { PlexSetting } from './Prefs'; +import { + BooleanQueryParam, + parseBooleanQueryParam, + parseIntArrayQueryParam, + parseIntQueryParam, + parseStringQueryParam +} from '../../utils/queryparams'; + + + export type PlexGetLibraryMatchesParams = { guid?: string, @@ -23,7 +35,7 @@ export type PlexGetLibraryMatchesParams = { export type PlexLibrarySectionsPageParams = { includePreferences?: BooleanQueryParam; -} +}; export type PlexLibrarySection = { allowSync: boolean; @@ -48,32 +60,19 @@ export type PlexLibrarySection = { hidden?: number; Location?: PlexSectionLocation[]; Preferences?: PlexSectionPreferences; -} +}; -export interface PlexSectionLocation { +export type PlexSectionLocation = { id: number; path: string; -} - -export interface PlexSectionPreferences { - Setting: PlexSectionSetting[]; -} +}; -export interface PlexSectionSetting { - id: string; - label: string; - summary: string; - type: 'bool' | 'int' | 'text'; - default: string; - value: string; - hidden: boolean; - advanced: boolean; - group: string; - enumValues?: string; // "0:Disabled|1:For recorded items|2:For all items" -} +export type PlexSectionPreferences = { + Setting: PlexSetting[]; +}; -export type PlexLibrarySectionsPage = PlexMediaContainer & { - MediaContainer: { +export type PlexLibrarySectionsPage = { + MediaContainer: PlexMediaContainer & { size: number; title1: string; Directory: PlexLibrarySection[]; @@ -116,3 +115,60 @@ export type PlexLibrarySectionPage = { Directory?: PlexLibrarySectionDirectory[]; } }; + +export type PlexSectionAllItemsParams = { + 'X-Plex-Container-Start'?: number; + 'X-Plex-Container-Size'?: number; + type?: PlexMediaItemTypeNumeric, +}; + +export const parsePlexSectionAllItemsPageParams = (req: express.Request): PlexSectionAllItemsParams => { + const query = req.query ?? {}; + // TODO some of these may be arrays sometimes + return { + 'X-Plex-Container-Start': parseIntQueryParam(query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), + 'X-Plex-Container-Size': parseIntQueryParam(query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')), + type: parseIntQueryParam(query['type']), + }; +}; + + + +export enum PlexLibrarySortField { + Random = 'random', + // TODO add other fields +}; + +export type PlexLibrarySortParam = PlexSortParam; + +export type PlexLibraryAllItemsParams = { + 'X-Plex-Container-Start'?: number; + 'X-Plex-Container-Size'?: number; + type?: PlexMediaItemTypeNumeric; + guid?: string; + 'show.guid'?: string; + season?: number; + sort?: PlexLibrarySortParam | string; + includeCollections?: boolean; + includeExternalMedia?: boolean; + includeAdvanced?: boolean; + includeMeta?: boolean; +}; + +export const parsePlexLibraryAllItemsPageParams = (req: express.Request): PlexLibraryAllItemsParams => { + const query = req.query ?? {}; + // TODO some of these may be arrays sometimes + return { + 'X-Plex-Container-Start': parseIntQueryParam(query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), + 'X-Plex-Container-Size': parseIntQueryParam(query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')), + type: parseIntQueryParam(query['type']), + guid: parseStringQueryParam(query['guid']), + 'show.guid': parseStringQueryParam(query['guid']), + season: parseIntQueryParam(query['season']), + sort: parseStringQueryParam(query['sort']), + includeCollections: parseBooleanQueryParam(query['includeCollections']), + includeExternalMedia: parseBooleanQueryParam(query['includeExternalMedia']), + includeAdvanced: parseBooleanQueryParam(query['includeAdvanced']), + includeMeta: parseBooleanQueryParam(query['includeMeta']), + }; +}; diff --git a/src/plex/types/Metadata.ts b/src/plex/types/Metadata.ts index a4684e3..0d2a7fc 100644 --- a/src/plex/types/Metadata.ts +++ b/src/plex/types/Metadata.ts @@ -1,4 +1,4 @@ - +import express from 'express'; import { PlexContentRating, PlexMediaItemType, @@ -10,7 +10,11 @@ import { import { PlexMediaContainer } from './MediaContainer'; -import { BooleanQueryParam } from '../../utils/queryparams'; +import { + BooleanQueryParam, + parseBooleanQueryParam, + parseIntQueryParam +} from '../../utils/queryparams'; export type PlexMetadataPageParams = { includeConcerts?: BooleanQueryParam; @@ -34,9 +38,18 @@ export type PlexMetadataPageParams = { }; export type PlexMetadataChildrenPageParams = { - excludeAllLeaves?: boolean; 'X-Plex-Container-Start'?: number; 'X-Plex-Container-Size'?: number; + excludeAllLeaves?: boolean; +}; + +export const parsePlexMetadataChildrenPageParams = (req: express.Request): PlexMetadataChildrenPageParams => { + const query = req.query; + return { + 'X-Plex-Container-Start': parseIntQueryParam(query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), + 'X-Plex-Container-Size': parseIntQueryParam(query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')), + excludeAllLeaves: parseBooleanQueryParam(query['excludeAllLeaves']), + } }; export type PlexMetadataCollection = { @@ -87,7 +100,8 @@ export type PlexMetadataItem = { availabilityId?: string; streamingMediaId?: string; userState?: boolean; - childCount?: number; + childCount?: number; // I think this might be only be for /library/all + leafCount?: number; Guid?: PlexGuid[]; Genre?: PlexGenre[]; diff --git a/src/plex/types/PlayQueue.ts b/src/plex/types/PlayQueue.ts index f1bd9f5..edef09c 100644 --- a/src/plex/types/PlayQueue.ts +++ b/src/plex/types/PlayQueue.ts @@ -1,12 +1,40 @@ +import { + PlexMetadataItem, + PlexMetadataPage, +} from './Metadata'; -export type PlexPlayQueueURIParts = { +export type PlayQueueItem = PlexMetadataItem & { + playQueueItemID: number; +}; +export type PlayQueueItemsPage = PlexMetadataPage & { + MediaContainer: { + // The ID of the queue + playQueueID: number; + // The ID of the current item in the queue + playQueueSelectedItemID: number; + // the offset of the current item in the queue + playQueueSelectedItemOffset: number; + // the metadata id of the current item in the queue + playQueueSelectedMetadataItemID: number, + // whether the queue is shuffled + playQueueShuffled: boolean, + // the plex server uri of the metadata item + playQueueSourceURI: `${string}://${string}/${string}/${string}`, // "library://x/item/%2Flibrary%2Fmetadata%2F63272" + // the total number of items in the queue + playQueueTotalCount: number, + // the playqueue version + playQueueVersion: number, // 1 + } +}; + +export type PlexServerItemURIParts = { protocol?: string | undefined; // "server", machineIdentifier?: string | undefined; sourceIdentifier?: string | undefined; // "com.plexapp.plugins.library" path?: string | undefined; }; -export const parsePlayQueueURI = (uri: string): PlexPlayQueueURIParts => { +export const parsePlexServerItemURI = (uri: string): PlexServerItemURIParts => { // parse protocol const protocolIndex = uri.indexOf('://'); let protocol: string | undefined; @@ -51,7 +79,7 @@ export const parsePlayQueueURI = (uri: string): PlexPlayQueueURIParts => { }; }; -export const stringifyPlayQueueURIParts = (uriParts: PlexPlayQueueURIParts): string => { +export const stringifyPlexServerItemURI = (uriParts: PlexServerItemURIParts): string => { let uri: string; if(uriParts.protocol != null) { uri = uriParts.protocol + '://'; diff --git a/src/plex/types/Playlist.ts b/src/plex/types/Playlist.ts index 4ec7f28..f5aa440 100644 --- a/src/plex/types/Playlist.ts +++ b/src/plex/types/Playlist.ts @@ -1,6 +1,10 @@ import { PlexMediaItemType } from './common'; import { PlexMetadataItem } from './Metadata'; +export enum PlexPlaylistType { + Video = 'video', +} + export type PlexPlaylist = { guid: string; // "com.plexapp.agents.none://d8895e9a-06d9-4549-a4d5-6e4d74a19bb9" ratingKey: string; // "42548" @@ -9,18 +13,18 @@ export type PlexPlaylist = { title: string; summary: string; smart: boolean; - playlistType: PlexMediaItemType; + playlistType: PlexPlaylistType; composite: string; // "/playlists/42548/composite/1726155341" - viewCount: number; - lastViewedAt: number; // 1720153977 - thumb: string; // "/library/metadata/42548/thumb/1726155341" + viewCount?: number; + lastViewedAt?: number; // 1720153977 + thumb?: string; // "/library/metadata/42548/thumb/1726155341" duration: number; // 350663000 leafCount: number; // number of items in the playlist addedAt: number; // 1720153977 updatedAt: number; // 1726155341 }; -export type PlexPlaylistPage = { +export type PlexPlaylistsPage = { MediaContainer: { size: number; Metadata: PlexPlaylist[]; @@ -41,7 +45,7 @@ export type PlexPlaylistItemsPage = { composite: string; // "/playlists/42548/composite/1726155341" duration: number; // 350663 (seems to be playlist.duration / 1000) leafCount: number; // number of items in the playlist - playlistType: PlexMediaItemType; + playlistType: PlexPlaylistType; ratingKey: string; // "42548" smart: boolean; title: string; diff --git a/src/plex/types/Prefs.ts b/src/plex/types/Prefs.ts new file mode 100644 index 0000000..64f1b6c --- /dev/null +++ b/src/plex/types/Prefs.ts @@ -0,0 +1,21 @@ +import { PlexMediaContainer } from './MediaContainer'; + +export type PlexSetting = { + id: string; + label: string; + summary: string; + type: 'bool' | 'int' | 'text'; + default: string; + value: string; + hidden: boolean; + advanced: boolean; + group: string; + enumValues?: string; // "0:Disabled|1:For recorded items|2:For all items" +}; + +export type PlexPrefsPage = { + MediaContainer: { + size: number; + Setting: PlexSetting[]; + } +}; diff --git a/src/plex/types/Server.ts b/src/plex/types/Server.ts index 2a6a254..c82cb27 100644 --- a/src/plex/types/Server.ts +++ b/src/plex/types/Server.ts @@ -82,3 +82,10 @@ export type PlexServerMediaProvidersPage = { MediaProvider: PlexMediaProvider[]; }; }; + +export type PlexTransientTokenResponse = { + MediaContainer: { + size?: number; + token: string; + } +}; diff --git a/src/plex/types/Update.ts b/src/plex/types/Update.ts new file mode 100644 index 0000000..8b1539f --- /dev/null +++ b/src/plex/types/Update.ts @@ -0,0 +1,10 @@ + +export type PlexUpdaterStatusPage = { + MediaContainer: { + size: number, + autoUpdateVersion: boolean, + canInstall: boolean, + checkedAt: number, + status: boolean, + } +} diff --git a/src/plex/types/auth.ts b/src/plex/types/auth.ts index 42a89c8..15a81dd 100644 --- a/src/plex/types/auth.ts +++ b/src/plex/types/auth.ts @@ -1,7 +1,8 @@ import http from 'http'; import express from 'express'; +import { urlFromServerRequest } from '../../utils/requesthandling'; import { parseStringQueryParam } from '../../utils/queryparams'; -import { parseURLPath } from '../../utils/url'; +import { parseURLPath, parseURLQueryItems } from '../../utils/url'; export type PlexAuthContext = { 'X-Plex-Product'?: string; @@ -40,9 +41,13 @@ const PlexAuthContextKeys: (keyof PlexAuthContext)[] = [ export const parseAuthContextFromRequest = (req: express.Request | http.IncomingMessage): PlexAuthContext => { // get query if needed let query: {[key: string]: any} = (req as express.Request).query; - if(!query) { - const urlParts = parseURLPath(req.url!); - query = urlParts.queryItems ?? {}; + if(query) { + // copy query contents (in express 5, the object does not persist changes) + query = {...query}; + } else { + // parse query items + const reqUrl = urlFromServerRequest(req); + query = parseURLQueryItems(reqUrl) ?? {}; } // parse each key const authContext: PlexAuthContext = {}; @@ -78,8 +83,7 @@ export const parseAuthContextFromRequest = (req: express.Request | http.Incoming export const parsePlexTokenFromRequest = (req: (http.IncomingMessage | express.Request)): string | undefined => { let query: {[key: string]: any} = (req as express.Request).query; if(!query) { - const urlParts = parseURLPath(req.url!); - query = urlParts.queryItems ?? {}; + query = parseURLQueryItems(req.url!) ?? {}; } let plexToken = query ? parseStringQueryParam(query['X-Plex-Token']) : undefined; if(!plexToken) { diff --git a/src/plex/types/common.ts b/src/plex/types/common.ts index 5d3d4f5..252742a 100644 --- a/src/plex/types/common.ts +++ b/src/plex/types/common.ts @@ -52,6 +52,7 @@ export enum PlexMediaItemType { Clip = 'clip', Photos = 'photos', Playlist = 'playlist', + Collection = 'collection', Mixed = 'mixed', } @@ -88,6 +89,7 @@ export const PlexMediaItemTypeToNumeric = { [PlexMediaItemType.Clip]: PlexMediaItemTypeNumeric.Clip, [PlexMediaItemType.Photos]: PlexMediaItemTypeNumeric.PhotoAlbum, [PlexMediaItemType.Playlist]: PlexMediaItemTypeNumeric.Playlist, + [PlexMediaItemType.Collection]: PlexMediaItemTypeNumeric.Collection, }; export const PlexMediaItemNumericToType = { @@ -101,4 +103,12 @@ export const PlexMediaItemNumericToType = { [PlexMediaItemTypeNumeric.Clip]: PlexMediaItemType.Clip, [PlexMediaItemTypeNumeric.PhotoAlbum]: PlexMediaItemType.Photos, [PlexMediaItemTypeNumeric.Playlist]: PlexMediaItemType.Playlist, + [PlexMediaItemTypeNumeric.Collection]: PlexMediaItemType.Collection, }; + +export enum PlexSortOrder { + Ascending = 'asc', + Descending = 'desc', +}; + +export type PlexSortParam = `${TField}:${PlexSortOrder}` | TField | PlexSortOrder; diff --git a/src/plex/types/index.ts b/src/plex/types/index.ts index 271f9e6..00a59e7 100644 --- a/src/plex/types/index.ts +++ b/src/plex/types/index.ts @@ -1,6 +1,7 @@ export * from './common'; export * from './auth'; +export * from './Collection'; export * from './HubContext'; export * from './Hub'; export * from './Library'; @@ -13,7 +14,9 @@ export * from './MyPlex'; export * from './Notifications'; export * from './Playlist'; export * from './PlayQueue'; +export * from './Prefs'; export * from './preferences'; export * from './Search'; export * from './SearchProvider'; export * from './Server'; +export * from './Update'; diff --git a/src/plexdiscover/library.ts b/src/plexdiscover/library.ts index 3be1171..fa11466 100644 --- a/src/plexdiscover/library.ts +++ b/src/plexdiscover/library.ts @@ -8,11 +8,11 @@ import { export const getLibraryMetadata = async (id: string | string[], options: (PlexDiscoverAPIRequestOptions & { params?: plexTypes.PlexMetadataPageParams, })): Promise => { - const idString = (id instanceof Array) ? id.map((idVal) => qs.escape(idVal)).join(',') : qs.escape(id); + const idsString = (id instanceof Array) ? id.map((idVal) => qs.escape(idVal)).join(',') : qs.escape(id); return await plexDiscoverFetch({ ...options, method: 'GET', - endpoint: `library/metadata/${idString}`, + endpoint: `library/metadata/${idsString}`, }); }; diff --git a/src/plextv/api/servers.ts b/src/plextv/api/servers.ts index 131c4a3..60273b1 100644 --- a/src/plextv/api/servers.ts +++ b/src/plextv/api/servers.ts @@ -1,6 +1,6 @@ import { PlexAuthContext } from '../../plex/types'; import { PlexTVAPIRequestOptions, plexTVFetch } from './core'; -import { PlexTVSharedServersPage } from '../types'; +import { PlexTVAccessTokensPage, PlexTVSharedServersPage } from '../types'; export const getSharedServers = async (args: { clientIdentifier: string, @@ -11,3 +11,14 @@ export const getSharedServers = async (args: { endpoint: `api/servers/${args.clientIdentifier}/shared_servers`, }); }; + +export const getAccessTokens = async (options: PlexTVAPIRequestOptions): Promise => { + return await plexTVFetch({ + ...options, + method: 'GET', + endpoint: `api/v2/server/access_tokens`, + headers: { + 'accept': 'application/json' + } + }); +} diff --git a/src/plextv/types/Servers.ts b/src/plextv/types/Servers.ts index cb2a435..e046767 100644 --- a/src/plextv/types/Servers.ts +++ b/src/plextv/types/Servers.ts @@ -45,3 +45,22 @@ export type PlexTVSharedServersPage = { SharedServer?: PlexTVSharedServer[]; } }; + +export type PlexTVAccessTokensPage = PlexTVAccessTokenInfo[]; + +export type PlexTVAccessTokenInfo = { + type: PlexTVAccessTokenType; + token: string; + owned: boolean + device?: string; + title?: string; + createdAt: string; // "2025-11-01T19:48:28Z" + // invited: PlexTVAccessTokenInvite + // settings: PlexTVAccessTokenSettings + // sections: PlexTVAccessTokenSection[] +}; + +export enum PlexTVAccessTokenType { + Device = 'device', + Server = 'server' +} diff --git a/src/pluginload.ts b/src/pluginload.ts index bac8432..b8fa944 100644 --- a/src/pluginload.ts +++ b/src/pluginload.ts @@ -39,18 +39,24 @@ export const installPlugins = async (cfg: Config) => { }; export const importPlugins = async (cfg: Config): Promise => { + if(!cfg?.plugins) { + return []; + } + const pluginMapKeys = Object.keys(cfg.plugins); + if(pluginMapKeys.length == 0) { + return []; + } // ensure the plugins search path exists if(!prependedPluginsPath) { module.paths.splice(0, 0, installedPluginsPath); prependedPluginsPath = true; } - if(!cfg?.plugins) { - return []; - } - const pluginIds = Object.keys(cfg.plugins).map((id) => getPluginModuleName(id)); + // get list of modules to import + const pluginIds = pluginMapKeys.map((id) => getPluginModuleName(id)); if(pluginIds.length == 0) { return []; } + // import the modules return await Promise.all(pluginIds.map(async (pluginId) => { return (await import(pluginId)).default; })); diff --git a/src/plugins/dashboard/config.ts b/src/plugins/dashboard/config.ts index ae2d945..80a9516 100644 --- a/src/plugins/dashboard/config.ts +++ b/src/plugins/dashboard/config.ts @@ -18,6 +18,7 @@ type DashboardPerUserPluginConfig = { } & DashboardFlags; export type DashboardPluginConfig = (PseuplexConfigBase & DashboardFlags & { dashboard?: { + id?: number; uuid?: string; } }); diff --git a/src/plugins/dashboard/index.ts b/src/plugins/dashboard/index.ts index 66c3a42..18fff07 100644 --- a/src/plugins/dashboard/index.ts +++ b/src/plugins/dashboard/index.ts @@ -1,5 +1,4 @@ - -import express from 'express'; +import crypto from 'crypto'; import * as plexTypes from '../../plex/types'; import { IncomingPlexAPIRequest } from '../../plex/requesthandling'; import { @@ -8,6 +7,7 @@ import { PseuplexPluginClass, PseuplexReadOnlyResponseFilters, PseuplexRequestContext, + PseuplexRouterApp, PseuplexSection } from '../../pseuplex'; import { DashboardHubConfig, DashboardPluginConfig } from './config'; @@ -23,8 +23,8 @@ export default (class DashboardPlugin implements DashboardPluginDef, PseuplexPlu constructor(app: PseuplexApp) { this.app = app; this.section = new DashboardSection(this, { - id: 'dashboard', - uuid: this.config.dashboard?.uuid ?? '81596aaa-14b1-4b74-8433-ff564d3020ff', + id: this.config.dashboard?.id ?? -23, + uuid: this.config.dashboard?.uuid ?? crypto.randomUUID(), type: plexTypes.PlexMediaItemType.Mixed, title: "Dashboard", path: `${this.basePath}`, @@ -44,9 +44,9 @@ export default (class DashboardPlugin implements DashboardPluginDef, PseuplexPlu // } - defineRoutes(router: express.Express) { + defineRoutes(router: PseuplexRouterApp) { router.get(this.section.path, [ - this.app.middlewares.plexAuthentication, + this.app.middlewares.plexAuthentication(), this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { const context = this.app.contextForRequest(req); return await this.section.getSectionPage(context); @@ -54,10 +54,10 @@ export default (class DashboardPlugin implements DashboardPluginDef, PseuplexPlu ]); router.get(this.section.hubsPath, [ - this.app.middlewares.plexAuthentication, + this.app.middlewares.plexAuthentication(), this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { const context = this.app.contextForRequest(req); - const reqParams = req.plex.requestParams; + const reqParams = plexTypes.parsePlexHubListPageParams(req); return await this.section.getHubsPage(reqParams,context); }), ]); diff --git a/src/plugins/hidesections/config.ts b/src/plugins/hidesections/config.ts new file mode 100644 index 0000000..507388d --- /dev/null +++ b/src/plugins/hidesections/config.ts @@ -0,0 +1,15 @@ +import { PseuplexConfigBase } from '../../pseuplex'; + +type HideSectionsFlags = { + hideSections?: { + ids: (number | string)[]; + } +}; +type HideSectionsPerUserPluginConfig = { + hideSections?: { + override: boolean; + } +} & HideSectionsFlags; +export type HideSectionsPluginConfig = PseuplexConfigBase & HideSectionsFlags & { + // +}; diff --git a/src/plugins/hidesections/index.ts b/src/plugins/hidesections/index.ts new file mode 100644 index 0000000..b891e29 --- /dev/null +++ b/src/plugins/hidesections/index.ts @@ -0,0 +1,78 @@ +import * as plexTypes from '../../plex/types'; +import { IncomingPlexAPIRequest } from '../../plex/requesthandling'; +import { + PseuplexApp, + PseuplexMetadataProvider, + PseuplexPlugin, + PseuplexPluginClass, + PseuplexReadOnlyResponseFilters, + PseuplexRouterApp, +} from '../../pseuplex'; +import { HideSectionsPluginConfig } from './config'; +import { HideSectionsPluginDef } from './plugindef'; + +export default (class HideSectionsPlugin implements HideSectionsPluginDef, PseuplexPlugin { + static slug = 'hidesections'; + readonly slug = HideSectionsPlugin.slug; + readonly app: PseuplexApp; + + constructor(app: PseuplexApp) { + this.app = app; + } + + get metadataProviders(): PseuplexMetadataProvider[] { + return [ + //this.metadata // if you want the plugin to define a custom metadata provider + ]; + } + + get config(): HideSectionsPluginConfig { + return this.app.config as HideSectionsPluginConfig; + } + + responseFilters?: PseuplexReadOnlyResponseFilters = { + mediaProviders: (resData, context) => { + const sectionsFeature = resData.MediaContainer?.MediaProvider?.[0]?.Feature?.find((f) => f.type == plexTypes.PlexFeatureType.Content) as plexTypes.PlexContentFeature; + if(sectionsFeature) { + const hiddenSections = this.getHiddenSectionsForRequest(context.userReq); + if(hiddenSections && hiddenSections.length > 0) { + sectionsFeature.Directory = sectionsFeature.Directory?.filter((d) => (d.id == null || hiddenSections.findIndex((s) => (d.id == s)) == -1)); + } + } + }, + sections: (resData, context) => { + if(resData.MediaContainer.Directory) { + const hiddenSections = this.getHiddenSectionsForRequest(context.userReq); + if(hiddenSections && hiddenSections.length > 0) { + const originalCount = resData.MediaContainer.Directory.length; + resData.MediaContainer.Directory = resData.MediaContainer.Directory.filter((d) => (d.key == null || hiddenSections.findIndex((s) => (d.key == s)) == -1)); + const newCount = resData.MediaContainer.Directory.length; + const removedCount = originalCount - newCount; + if(resData.MediaContainer.size) { + resData.MediaContainer.size -= removedCount; + } + if(resData.MediaContainer.totalSize) { + resData.MediaContainer.totalSize -= removedCount; + } + } + } + } + } + + + + getHiddenSectionsForRequest(userReq: IncomingPlexAPIRequest) { + const userHideSections = this.config.perUser?.[userReq.plex.userInfo.email]?.hideSections; + const genHideSections = this.config.hideSections; + let hiddenSections: (string | number)[] | undefined = genHideSections?.ids; + if(userHideSections) { + if(userHideSections.override) { + hiddenSections = userHideSections.ids; + } else if(userHideSections.ids) { + hiddenSections = (hiddenSections ?? []).concat(userHideSections.ids); + } + } + return hiddenSections; + } + +} satisfies PseuplexPluginClass); diff --git a/src/plugins/hidesections/plugindef.ts b/src/plugins/hidesections/plugindef.ts new file mode 100644 index 0000000..0a1d00b --- /dev/null +++ b/src/plugins/hidesections/plugindef.ts @@ -0,0 +1,13 @@ +import { + PseuplexApp, + PseuplexPlugin, + PseuplexRequestContext +} from '../../pseuplex'; +import { + HideSectionsPluginConfig, +} from './config'; + +export interface HideSectionsPluginDef extends PseuplexPlugin { + app: PseuplexApp; + config: HideSectionsPluginConfig; +} diff --git a/src/plugins/letterboxd/hubs.ts b/src/plugins/letterboxd/hubs.ts index eedcc42..fc098cf 100644 --- a/src/plugins/letterboxd/hubs.ts +++ b/src/plugins/letterboxd/hubs.ts @@ -1,14 +1,12 @@ - +import qs from 'querystring'; import * as letterboxd from 'letterboxd-retriever'; import * as plexTypes from '../../plex/types'; import { PseuplexMetadataTransformOptions, PseuplexPartialMetadataIDString, - qualifyPartialMetadataID, PseuplexHubSectionInfo, - PseuplexMetadataPathTransformOptions, - PseuplexHubMetadataTransformOptions, - getMetadataTransformOptionsForHub, + qualifyPartialPseuplexMetadataID, + stringifyPseuplexMetadataKeyFromIDString, } from '../../pseuplex'; import { ListFetchInterval } from '../../fetching/LoadableList'; import { RequestExecutor } from '../../fetching/RequestExecutor'; @@ -20,19 +18,19 @@ import { LetterboxdFilmListHub } from './filmlisthub'; import { Logger } from '../../logging'; -export const createUserFollowingFeedHub = (letterboxdUsername: string, options: (PseuplexHubMetadataTransformOptions & { +export const createUserFollowingFeedHub = (letterboxdUsername: string, options: { hubPath: string, style: plexTypes.PlexHubStyle, promoted?: boolean, uniqueItemsOnly: boolean, letterboxdMetadataProvider: LetterboxdMetadataProvider, + metadataTransformOptions: PseuplexMetadataTransformOptions, section?: PseuplexHubSectionInfo, matchToPlexServerMetadata?: boolean, logger?: Logger, requestExecutor?: RequestExecutor, -})): LetterboxdActivityFeedHub => { +}): LetterboxdActivityFeedHub => { const { requestExecutor } = options; - const metadataTransformOptions = getMetadataTransformOptionsForHub(options.letterboxdMetadataProvider.basePath, options); return new LetterboxdActivityFeedHub({ hubPath: options.hubPath, title: `Friends Activity on Letterboxd (${letterboxdUsername})`, @@ -43,7 +41,7 @@ export const createUserFollowingFeedHub = (letterboxdUsername: string, options: style: options.style, promoted: options.promoted, uniqueItemsOnly: options.uniqueItemsOnly, - metadataTransformOptions, + metadataTransformOptions: options.metadataTransformOptions, letterboxdMetadataProvider: options.letterboxdMetadataProvider, section: options.section, matchToPlexServerMetadata: options.matchToPlexServerMetadata, @@ -65,27 +63,23 @@ export const createUserFollowingFeedHub = (letterboxdUsername: string, options: }; -export const createSimilarItemsHub = async (metadataId: PseuplexPartialMetadataIDString, options: (PseuplexHubMetadataTransformOptions & { - relativePath: string, +export const createSimilarItemsHub = async (metadataId: PseuplexPartialMetadataIDString, options: { + hubPath: string, title: string, style: plexTypes.PlexHubStyle, promoted?: boolean, letterboxdMetadataProvider: LetterboxdMetadataProvider, + metadataTransformOptions: PseuplexMetadataTransformOptions, defaultCount?: number, section?: PseuplexHubSectionInfo, matchToPlexServerMetadata?: boolean, logger?: Logger, requestExecutor?: RequestExecutor, -})) => { +}) => { const { requestExecutor } = options; - const metadataTransformOptions = getMetadataTransformOptionsForHub(options.letterboxdMetadataProvider.basePath, options); const filmOpts = lbtransform.getFilmOptsFromPartialMetadataId(metadataId); - const metadataIdInPath = metadataTransformOptions.qualifiedMetadataIds - ? qualifyPartialMetadataID(metadataId, options.letterboxdMetadataProvider.sourceSlug) - : metadataId; - const hubPath = `${metadataTransformOptions.metadataBasePath}/${metadataIdInPath}/${options.relativePath}`; return new LetterboxdFilmsHub({ - hubPath: hubPath, + hubPath: options.hubPath, title: options.title, type: plexTypes.PlexMediaItemType.Movie, style: options.style, @@ -96,7 +90,7 @@ export const createSimilarItemsHub = async (metadataId: PseuplexPartialMetadataI uniqueItemsOnly: true, listStartFetchInterval: 'never', letterboxdMetadataProvider: options.letterboxdMetadataProvider, - metadataTransformOptions, + metadataTransformOptions: options.metadataTransformOptions, section: options.section, matchToPlexServerMetadata: options.matchToPlexServerMetadata, logger: options.logger, @@ -119,23 +113,23 @@ export const createSimilarItemsHub = async (metadataId: PseuplexPartialMetadataI }; -export const createListHub = async (listId: lbtransform.PseuplexLetterboxdListID, options: (PseuplexHubMetadataTransformOptions & { - path: string, +export const createListHub = async (listId: lbtransform.PseuplexLetterboxdListID, options: { + hubPath: string, style: plexTypes.PlexHubStyle, promoted?: boolean, letterboxdMetadataProvider: LetterboxdMetadataProvider, + metadataTransformOptions: PseuplexMetadataTransformOptions, defaultCount?: number, listStartFetchInterval?: ListFetchInterval, section?: PseuplexHubSectionInfo, matchToPlexServerMetadata?: boolean, logger?: Logger, requestExecutor?: RequestExecutor, -})) => { +}) => { const { requestExecutor } = options; - const metadataTransformOptions = getMetadataTransformOptionsForHub(options.letterboxdMetadataProvider.basePath, options); const listOpts = lbtransform.getFilmListOptsFromPartialListId(listId); return new LetterboxdFilmListHub({ - hubPath: options.path, + hubPath: options.hubPath, title: `${listOpts.userSlug}'s ${listOpts.listSlug} list`, type: plexTypes.PlexMediaItemType.Movie, style: options.style, @@ -146,7 +140,7 @@ export const createListHub = async (listId: lbtransform.PseuplexLetterboxdListID uniqueItemsOnly: false, listStartFetchInterval: options.listStartFetchInterval, letterboxdMetadataProvider: options.letterboxdMetadataProvider, - metadataTransformOptions, + metadataTransformOptions: options.metadataTransformOptions, section: options.section, matchToPlexServerMetadata: options.matchToPlexServerMetadata, logger: options.logger, diff --git a/src/plugins/letterboxd/index.ts b/src/plugins/letterboxd/index.ts index cbca5bc..ec5da86 100644 --- a/src/plugins/letterboxd/index.ts +++ b/src/plugins/letterboxd/index.ts @@ -1,5 +1,4 @@ import qs from 'querystring'; -import express from 'express'; import * as letterboxd from 'letterboxd-retriever'; import * as plexTypes from '../../plex/types'; import { @@ -18,31 +17,22 @@ import { PseuplexReadOnlyResponseFilters, PseuplexMetadataIDParts, PseuplexMetadataSource, - PseuplexSimilarItemsHubProvider, - stringifyMetadataID, - parsePartialMetadataID, - stringifyPartialMetadataID, + stringifyPseuplexMetadataID, PseuplexMetadataProvider, PseuplexSection, - PseuplexRelatedHubsSource, - getPlexRelatedHubsEndpoints, PseuplexMetadataRelatedHubsResponseFilterContext, PseuplexMetadataItem, + PseuplexRouterApp, + stringifyPartialPseuplexMetadataID, } from '../../pseuplex'; import { LetterboxdPluginConfig } from './config'; import { LetterboxdMetadataProvider } from './metadata'; -import { - createUserFollowingFeedHub, - createSimilarItemsHub, - createListHub, -} from './hubs' +import * as lbHubs from './hubs' import * as lbTransform from './transform'; import { LetterboxdPluginDef } from './plugindef'; import { RequestExecutor } from '../../fetching/RequestExecutor'; -import { httpError } from '../../utils/error'; -import { parseStringQueryParam } from '../../utils/queryparams'; import { forArrayOrSingleAsyncParallel, pushToArray, @@ -55,7 +45,7 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP readonly metadata: LetterboxdMetadataProvider; readonly hubs: { readonly userFollowingActivity: PseuplexHubProvider & {readonly basePath: string}; - readonly similar: PseuplexSimilarItemsHubProvider; + readonly similar: PseuplexHubProvider & {readonly basePath: string}; readonly list: PseuplexHubProvider & {readonly basePath: string}; }; //readonly section?: PseuplexSection; @@ -82,18 +72,22 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP this.section = section;*/ // create hub providers + const hubsBasePath = `${this.basePath}/hubs`; this.hubs = { userFollowingActivity: new class extends PseuplexHubProvider { - readonly basePath = `${self.basePath}/hubs/following`; + readonly basePath = `${hubsBasePath}/following`; + override path(id: string) { + return `${this.basePath}/${id}`; + } override fetch(letterboxdUsername: string): PseuplexHub | Promise { // TODO validate that the profile exists - return createUserFollowingFeedHub(letterboxdUsername, { - ...app.requiredHubMetadataTransformOptions(), - hubPath: `${this.basePath}/${letterboxdUsername}`, + return lbHubs.createUserFollowingFeedHub(letterboxdUsername, { + hubPath: this.path(letterboxdUsername), style: plexTypes.PlexHubStyle.Shelf, promoted: true, uniqueItemsOnly: true, letterboxdMetadataProvider: self.metadata, + metadataTransformOptions: app.metadataTransformOptions(), //section: section, //matchToPlexServerMetadata: true logger: app.logger, @@ -103,23 +97,24 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP }(), similar: new class extends PseuplexHubProvider { - readonly relativePath = 'similar'; - + readonly basePath = `${hubsBasePath}/similar`; + override path(id: string) { + return `${this.basePath}/${qs.escape(id)}`; + } override transformHubID(id: string): (string | Promise) { if(id.indexOf(':') != -1) { return id; } return `film:${id}`; } - override fetch(metadataId: PseuplexPartialMetadataIDString): PseuplexHub | Promise { - return createSimilarItemsHub(metadataId, { - ...app.requiredHubMetadataTransformOptions(), - relativePath: this.relativePath, + return lbHubs.createSimilarItemsHub(metadataId, { + hubPath: this.path(metadataId), title: "Similar Films on Letterboxd", style: plexTypes.PlexHubStyle.Shelf, //promoted: true, letterboxdMetadataProvider: self.metadata, + metadataTransformOptions: app.metadataTransformOptions(), defaultCount: 12, logger: app.logger, requestExecutor, @@ -129,7 +124,9 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP list: new class extends PseuplexHubProvider { readonly basePath = `${self.basePath}/list`; - + override path(id: string) { + return `${this.basePath}/${id}`; + } override transformHubID(id: string): string { if(!id.startsWith('/') && id.indexOf('://') == -1) { return id; @@ -171,11 +168,10 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP return `${userSlug}:${listSlug}`; } } - override fetch(listId: lbTransform.PseuplexLetterboxdListID): PseuplexHub | Promise { - return createListHub(listId, { - ...app.requiredHubMetadataTransformOptions(), - path: `${this.basePath}/${listId}`, + return lbHubs.createListHub(listId, { + hubPath: this.path(listId), + metadataTransformOptions: app.metadataTransformOptions(), style: plexTypes.PlexHubStyle.Shelf, promoted: true, letterboxdMetadataProvider: self.metadata, @@ -189,7 +185,6 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP // create metadata provider this.metadata = new LetterboxdMetadataProvider({ - basePath: `${this.basePath}/metadata`, //section: this.section, plexMetadataClient: this.app.plexMetadataClient, relatedHubsProviders: [ @@ -232,165 +227,31 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP }, metadataRelatedHubs: async (resData, context) => { + // similar items hub will already be included with the metadata provider if(context.metadataId.source != this.metadata.sourceSlug) { await this._addSimilarItemsHubIfNeeded(resData, context); } }, - - metadataFromProvider: async (resData, context) => { - await this._addFriendReviewsIfNeeded(resData, context); - }, } - defineRoutes(router: express.Express) { - // get metadata item(s) - router.get(`${this.metadata.basePath}/:id`, [ - this.app.middlewares.plexAuthentication, - this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { - console.log(`Got request for letterboxd item ${req.params.id}`); - const context = this.app.contextForRequest(req); - const params: plexTypes.PlexMetadataPageParams = req.plex.requestParams; - const itemIdsStr = req.params.id?.trim(); - if(!itemIdsStr) { - throw httpError(400, "No slug was provided"); - } - const metadataIds = itemIdsStr.split(','); - // get metadatas from letterboxd - const metadataProvider = this.metadata; - const resData = await metadataProvider.get(metadataIds, { - context: context, - includePlexDiscoverMatches: true, - includeUnmatched: true, - transformMatchKeys: true, - metadataBasePath: metadataProvider.basePath, - includeMetadataUnavailability: this.app.sendsMetadataUnavailability, - qualifiedMetadataIds: false, - plexParams: params, - }); - // cache metadata access if needed - if(metadataIds.length == 1) { - this.app.pluginMetadataAccessCache?.cachePluginMetadataAccessIfNeeded(metadataProvider, metadataIds[0], req.path, resData.MediaContainer.Metadata, context); - } - // add related hubs if included - if(params.includeRelated == 1) { - // filter related hubs - await forArrayOrSingleAsyncParallel(resData.MediaContainer.Metadata, async (metadataItem) => { - const metadataId = metadataItem.Pseuplex.metadataIds[this.metadata.sourceSlug]; - if(!metadataId) { - return; - } - // add letterboxd hubs - const metadataProvider = this.metadata; - const relHubsData = await metadataProvider.getRelatedHubs(metadataId, { - context, - from: PseuplexRelatedHubsSource.Library, - }); - const existingRelatedItemCount = (metadataItem.Related?.Hub?.length ?? 0); - if(existingRelatedItemCount > 0) { - const relatedItemsOgCount = relHubsData.MediaContainer.size ?? relHubsData.MediaContainer.Hub?.length ?? 0; - if(relatedItemsOgCount > 0) { - relHubsData.MediaContainer.Hub = metadataItem.Related!.Hub!.concat(relHubsData.MediaContainer.Hub!); - relHubsData.MediaContainer.size = relatedItemsOgCount + existingRelatedItemCount; - } - } - // filter response - await this.app.filterResponse('metadataRelatedHubsFromProvider', relHubsData, { - userReq:req, - userRes:res, - metadataId, - metadataProvider, - from: PseuplexRelatedHubsSource.Library, - }); - // apply items hub - metadataItem.Related = relHubsData.MediaContainer; - }); - } - // filter page - await this.app.filterResponse('metadataFromProvider', resData, { - userReq:req, - userRes:res, - metadataProvider, - metadataIds, - }); - // send unavailable notification(s) if needed - this.app.sendMetadataUnavailableNotificationsIfNeeded(resData, params, context); - return resData; - }) - ]); - - // get hubs related to metadata item - for(const {endpoint, hubsSource} of getPlexRelatedHubsEndpoints(`${this.metadata.basePath}/:id`)) { - router.get(endpoint, [ - this.app.middlewares.plexAuthentication, - this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { - const metadataId = req.params.id; - const context = this.app.contextForRequest(req); - const params = plexTypes.parsePlexHubPageParams(req, {fromListPage:true}); - // add similar items hub - const metadataProvider = this.metadata; - const resData = await metadataProvider.getRelatedHubs(metadataId, { - plexParams: params, - context, - from: hubsSource, - }); - // filter response - await this.app.filterResponse('metadataRelatedHubsFromProvider', resData, { - userReq:req, - userRes:res, - metadataId, - metadataProvider, - from: hubsSource, - }); - // return response - return resData; - }) - ]); - } - + defineRoutes(router: PseuplexRouterApp) { // get similar films on letterboxd as a hub - router.get(`${this.metadata.basePath}/:id/${this.hubs.similar.relativePath}`, [ - this.app.middlewares.plexAuthentication, - this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { - const id = req.params.id; - const context = this.app.contextForRequest(req); - const params = plexTypes.parsePlexHubPageParams(req, {fromListPage:false}); - const hub = await this.hubs.similar.get(id); - return await hub.getHubPage(params, context); - }) - ]); + router.provideHub(`${this.hubs.similar.basePath}/:filmId`, this.hubs.similar, { + auth: true, + hubArgParam: 'filmId', + }); // get letterboxd friend activity as a hub - router.get(`${this.hubs.userFollowingActivity.basePath}/:letterboxdUsername`, [ - this.app.middlewares.plexAuthentication, - this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { - const context = this.app.contextForRequest(req); - const letterboxdUsername = req.params['letterboxdUsername']; - if(!letterboxdUsername) { - throw httpError(400, "No user provided"); - } - const params = plexTypes.parsePlexHubPageParams(req, {fromListPage:false}); - const hub = await this.hubs.userFollowingActivity.get(letterboxdUsername); - return await hub.getHubPage({ - ...params, - listStartToken: parseStringQueryParam(req.query['listStartToken']) - }, context); - }) - ]); + router.provideHub(`${this.hubs.userFollowingActivity.basePath}/:letterboxdUsername`, this.hubs.userFollowingActivity, { + auth: true, + hubArgParam: 'letterboxdUsername', + }); // get letterboxd list as a hub - router.get(`${this.hubs.list.basePath}/:listId`, [ - this.app.middlewares.plexAuthentication, - this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { - const listId = req.params['listId']; - if(!listId) { - throw httpError(400, "No list ID provided"); - } - const context = this.app.contextForRequest(req); - const params = plexTypes.parsePlexHubPageParams(req, {fromListPage:false}); - const hub = await this.hubs.list.get(listId); - return await hub.getHubPage(params, context); - }) - ]); + router.provideHub(`${this.hubs.list.basePath}/:listId`, this.hubs.list, { + auth: true, + hubArgParam: 'listId', + }); } @@ -402,9 +263,9 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP const friendsActvityHubEnabled = userPrefs?.letterboxd?.friendsActivityHubEnabled ?? config.letterboxd?.friendsActivityHubEnabled ?? false; // add friends activity feed hub if enabled if(friendsActvityHubEnabled && userPrefs?.letterboxd?.username) { - const params = plexTypes.parsePlexHubPageParams(context.userReq, {fromListPage:true}); + const plexParams = plexTypes.parsePlexHubPageParams(context.userReq, {fromListPage:true}); const hub = await this.hubs.userFollowingActivity.get(userPrefs.letterboxd.username); - const page = await hub.getHubListEntry(params, this.app.contextForRequest(context.userReq)); + const page = await hub.getHubListEntry(plexParams, this.app.contextForRequest(context.userReq)); if(!resData.MediaContainer.Hub) { resData.MediaContainer.Hub = []; } else if(!(resData.MediaContainer.Hub instanceof Array)) { @@ -428,12 +289,12 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP // get plex guid from metadata id if(metadataId.source == this.metadata.sourceSlug) { // id is already a letterboxd id - letterboxdId = stringifyPartialMetadataID(metadataId); + letterboxdId = stringifyPartialPseuplexMetadataID(metadataId); } else { // get plex guid let plexGuid: string | null | undefined = null; if(metadataId.source == PseuplexMetadataSource.Plex) { - plexGuid = stringifyMetadataID({ + plexGuid = stringifyPseuplexMetadataID({ ...metadataId, isURL:true }); @@ -473,7 +334,7 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP async _addFriendReviewsIfNeeded(resData: PseuplexMetadataPage, context: PseuplexResponseFilterContext) { const userInfo = context.userReq.plex.userInfo; const plexAuthContext = context.userReq.plex.authContext; - const reqParams = context.userReq.plex.requestParams; + const reqParams: plexTypes.PlexMetadataPageParams = context.userReq.plex.requestParams; // get prefs const config = this.config; const userPrefs = config.perUser[userInfo.email]; @@ -499,6 +360,7 @@ export default (class LetterboxdPlugin implements LetterboxdPluginDef, PseuplexP } // attach letterboxd friends reviews const getFilmOpts = lbTransform.getFilmOptsFromPartialMetadataId(letterboxdMetadataId); + console.log(`Fetching letterboxd friend reviews for film ${JSON.stringify(getFilmOpts)} and user ${letterboxdUsername}`); const friendViewings = await letterboxd.getReviews({ ...getFilmOpts, userSlug: letterboxdUsername, diff --git a/src/plugins/letterboxd/metadata.ts b/src/plugins/letterboxd/metadata.ts index 5c11578..046fb05 100644 --- a/src/plugins/letterboxd/metadata.ts +++ b/src/plugins/letterboxd/metadata.ts @@ -72,6 +72,14 @@ export class LetterboxdMetadataProvider extends PseuplexMetadataProviderBase { - return stringifyPartialMetadataID({ + if(!filmInfo.pageData.slug) { + throw httpError(500, "Missing film slug in letterboxd film info"); + } + return stringifyPartialPseuplexMetadataID({ directory: filmInfo.pageData.type, id: filmInfo.pageData.slug }); }; export const getFilmOptsFromPartialMetadataId = (metadataId: PseuplexPartialMetadataIDString): letterboxd.FilmURLOptions => { - const idParts = parsePartialMetadataID(metadataId); + const idParts = parsePartialPseuplexMetadataID(metadataId); if(!idParts.directory) { if(idParts.id.indexOf('/') != -1) { return {href:idParts.id}; @@ -43,7 +48,10 @@ export const getFilmOptsFromPartialMetadataId = (metadataId: PseuplexPartialMeta }; export const fullMetadataIdFromFilmInfo = (filmInfo: letterboxd.FilmPage, opts?: {asUrl?: boolean}): PseuplexMetadataIDString => { - return stringifyMetadataID({ + if(!filmInfo.pageData.slug) { + throw httpError(500, "Missing film slug in letterboxd film info"); + } + return stringifyPseuplexMetadataID({ isURL: opts?.asUrl, source: PseuplexMetadataSource.Letterboxd, directory: filmInfo.pageData.type ?? 'film', @@ -57,7 +65,7 @@ export const filmInfoToPlexMetadata = (filmInfo: letterboxd.FilmPage, context: P const fullMetadataId = fullMetadataIdFromFilmInfo(filmInfo,{asUrl:false}); return { // guid: fullMetadataIdFromFilmInfo(filmInfo, {asUrl:true}), - key: combinePathSegments(options.metadataBasePath, options.qualifiedMetadataIds ? fullMetadataId : partialMetadataId), + key: stringifyPseuplexMetadataKeyFromIDString(fullMetadataId), ratingKey: fullMetadataId, type: plexTypes.PlexMediaItemType.Movie, title: filmInfo.ldJson.name, @@ -120,14 +128,20 @@ export const filmInfoGuids = (filmInfo: letterboxd.FilmPage) => { }; export const partialMetadataIdFromFilm = (film: letterboxd.Film): PseuplexPartialMetadataIDString => { - return stringifyPartialMetadataID({ + if(!film.slug) { + throw httpError(500, "Missing film slug in letterboxd film"); + } + return stringifyPartialPseuplexMetadataID({ directory: film.type, id: film.slug }); }; export const fullMetadataIdFromFilm = (film: letterboxd.Film, opts:{asUrl:boolean}): PseuplexMetadataIDString => { - return stringifyMetadataID({ + if(!film.slug) { + throw httpError(500, "Missing film slug in letterboxd film"); + } + return stringifyPseuplexMetadataID({ isURL: opts.asUrl, source: PseuplexMetadataSource.Letterboxd, directory: film.type, @@ -137,10 +151,9 @@ export const fullMetadataIdFromFilm = (film: letterboxd.Film, opts:{asUrl:boolea export const filmToPlexMetadata = (film: letterboxd.Film, options: PseuplexMetadataTransformOptions): plexTypes.PlexMetadataItem => { const fullMetadataId = fullMetadataIdFromFilm(film, {asUrl:false}); - const metadataId = options.qualifiedMetadataIds ? fullMetadataId : partialMetadataIdFromFilm(film); return { // guid: fullMetadataIdFromFilm(film, {asUrl:true}), - key: combinePathSegments(options.metadataBasePath, metadataId), + key: stringifyPseuplexMetadataKeyFromIDString(fullMetadataId), ratingKey: fullMetadataId, type: plexTypes.PlexMediaItemType.Movie, title: film.name, diff --git a/src/plugins/passwordlock/authcache.ts b/src/plugins/passwordlock/authcache.ts new file mode 100644 index 0000000..04b4ce7 --- /dev/null +++ b/src/plugins/passwordlock/authcache.ts @@ -0,0 +1,275 @@ +import fs from 'fs'; +import * as plexTypes from '../../plex/types'; +import { + PlexServerAccountsStore +} from '../../plex/accounts'; +import { + IPv4NormalizeMode, + normalizeIPAddress +} from '../../utils/ip'; +import { IncomingPlexAPIRequest, IncomingPlexHttpRequest } from '../../plex/requesthandling'; + +export type PasswordLockWhitelistedIPInfo = { + addedAt: number; + // TODO lastAccessedAt: number; + // TODO add recent tokens and client IDs +}; + +export type PasswordLockWhitelistedIPMap = { + [ip: string]: PasswordLockWhitelistedIPInfo +}; + +export type PasswordLockAuthCacheUser = { + whitelistedIPs: PasswordLockWhitelistedIPMap; +}; + +export type PasswordLockAuthCacheUsers = { + [email: string]: PasswordLockAuthCacheUser; +}; + +export type PasswordLockAuthCacheData = { + tokensToWhitelistedIPs?: { + [plexToken: string]: string[] + }, + emailsToWhitelistedIPs?: { + [email: string]: string[] + }, + users: PasswordLockAuthCacheUsers +}; + +export type EmailsToIPsMap = { + [email: string]: Set; +}; + +export class PasswordLockAuthenticationCache { + readonly filePath: string | null; + readonly plexServerAccountsStore: PlexServerAccountsStore; + saveReadableJson: boolean; + + private _users: PasswordLockAuthCacheUsers = {}; + private _fileTaskPromise: Promise | null = null; + private _loadPromise: Promise | null = null; + private _nextSavePromise: Promise | null = null; + private _pendingUnsavedChanges: boolean = false; + private _savingUnsavedChanges: boolean = false; + + constructor(filePath: string | null | undefined, options: { + plexAccountsStore: PlexServerAccountsStore, + saveReadableJson?: boolean, + }) { + this.filePath = filePath ?? null; + this.plexServerAccountsStore = options.plexAccountsStore; + this.saveReadableJson = options.saveReadableJson ?? false; + } + + private async _doFileTask(task: () => Promise): Promise { + while(this._fileTaskPromise) { + await this._fileTaskPromise; + } + let resolveFunc!: () => void; + this._fileTaskPromise = new Promise((resolve, reject) => { + resolveFunc = resolve; + }); + try { + return await task(); + } finally { + this._fileTaskPromise = null; + resolveFunc(); + } + } + + waitForLoad(): (Promise | void) { + if(!this._loadPromise) { + return; + } + return this._loadPromise?.then(() => {}, (_) => {}); + } + + async load(): Promise { + if(!this.filePath) { + return false; + } + let done = false; + const loadPromise = this._doFileTask(async () => { + try { + if(!(await new Promise((r) => fs.exists(this.filePath!, r)))) { + return false; + } + const data = await fs.promises.readFile(this.filePath!, {encoding: 'utf8'}); + const cacheObj: PasswordLockAuthCacheData = JSON.parse(data); + if(!cacheObj || typeof cacheObj !== 'object') { + throw new Error(`Invalid auth cache data`); + } + let users: PasswordLockAuthCacheUsers = cacheObj.users || {}; + const now = (new Date()).getTime() / 1000; + let unsavedChanges = false; + // auto-convert emails to IPs + if(cacheObj.emailsToWhitelistedIPs) { + for(const email of Object.keys(cacheObj.emailsToWhitelistedIPs)) { + const ipList = cacheObj.emailsToWhitelistedIPs[email]; + if(!(ipList instanceof Array) || ipList.length == 0) { + continue; + } + // get auth cache entry for user + let userAuthCache = users[email]; + if(!userAuthCache) { + userAuthCache = {whitelistedIPs: {}}; + users[email] = userAuthCache; + } + unsavedChanges = true; + // add ip entries for user + for(const ip of ipList) { + let ipInfo = userAuthCache.whitelistedIPs[ip]; + if(!ipInfo) { + ipInfo = { + addedAt: now, + // lastAccessedAt: now, + }; + userAuthCache.whitelistedIPs[ip] = ipInfo; + } + } + } + } + // auto-convert tokens to IPs + if(cacheObj.tokensToWhitelistedIPs) { + const tokensList = Object.keys(cacheObj.tokensToWhitelistedIPs); + for(const plexToken of tokensList) { + const ipList = cacheObj.tokensToWhitelistedIPs[plexToken]; + if(!(ipList instanceof Array) || ipList.length == 0) { + continue; + } + // get the associated user for the token + const userForToken = await this.plexServerAccountsStore.getUserInfoOrNull({'X-Plex-Token':plexToken}); + const email = userForToken?.email; + if(!email) { + continue; + } + // get auth cache entry for user + let userAuthCache = users[email]; + if(!userAuthCache) { + userAuthCache = {whitelistedIPs: {}}; + users[email] = userAuthCache; + } + unsavedChanges = true; + // add ip entries for user + for(const ip of ipList) { + let ipInfo = userAuthCache.whitelistedIPs[ip]; + if(!ipInfo) { + ipInfo = { + addedAt: now, + // lastAccessedAt: now, + }; + userAuthCache.whitelistedIPs[ip] = ipInfo; + } + } + } + } + // set new auth data + this._users = users; + this._pendingUnsavedChanges = unsavedChanges; + this._savingUnsavedChanges = false; + return true; + } finally { + this._loadPromise = null; + done = true; + } + }); + if(!done) { + this._loadPromise = loadPromise; + } + return await loadPromise; + } + + get hasPendingUnsavedChanges(): boolean { + return this._pendingUnsavedChanges; + } + + get hasUnsavedChanges(): boolean { + return this._pendingUnsavedChanges || this._savingUnsavedChanges; + } + + get isSaveQueued(): boolean { + return this._nextSavePromise != null; + } + + async save(): Promise { + if(!this.filePath) { + return false; + } + if(this._nextSavePromise) { + return await this._nextSavePromise; + } + // to prevent multiple subsequent saves, we only queue one save until the save actually executes + let nextSaveStarted = false; + const nextSavePromise = this._doFileTask(async () => { + this._nextSavePromise = null; + nextSaveStarted = true; + // create cache object + const cacheObj: PasswordLockAuthCacheData = { + users: this._users, + }; + // serialize to json + let cacheData: string; + if(this.saveReadableJson) { + cacheData = JSON.stringify(cacheObj, null, '\t'); + } else { + cacheData = JSON.stringify(cacheObj); + } + // write to file + this._savingUnsavedChanges = this._pendingUnsavedChanges; + this._pendingUnsavedChanges = false; + try { + await fs.promises.writeFile(this.filePath!, cacheData); + } catch(error) { + // didn't save successfully, + // so re-apply unsaved changes if needed + if(this._savingUnsavedChanges) { + this._pendingUnsavedChanges = this._savingUnsavedChanges; + this._savingUnsavedChanges = false; + } + throw error; + } + return true; + }); + // if we haven't already started the next save, we should cache the promise to ensure multiple save calls only save once + if(!nextSaveStarted) { + this._nextSavePromise = nextSavePromise; + } + return await nextSavePromise; + } + + isIPWhitelistedForUser(ipAddress: string, req: IncomingPlexAPIRequest | IncomingPlexHttpRequest): boolean { + const userEmail = req.plex.userInfo.email; + // ipv4 addresses are stored as ipv4 + ipAddress = normalizeIPAddress(ipAddress, IPv4NormalizeMode.ToIPv4); + // get info for ip address + const ipInfo = this._users[userEmail]?.whitelistedIPs[ipAddress]; + return ipInfo ? true : false; + } + + whitelistIPForUser(ipAddress: string, req: IncomingPlexAPIRequest | IncomingPlexHttpRequest) { + const userEmail = req.plex.userInfo.email; + // get user auth cache info + let userAuthCache = this._users[userEmail]; + if(!userAuthCache) { + userAuthCache = {whitelistedIPs:{}}; + this._users[userEmail] = userAuthCache; + } + // ensure ipv4 addresses are stored as ipv4 + ipAddress = normalizeIPAddress(ipAddress, IPv4NormalizeMode.ToIPv4); + // update whitelisted ip info + let ipInfo = userAuthCache.whitelistedIPs[ipAddress]; + const now = (new Date()).getTime() / 1000; + if(ipInfo) { + // ipInfo.lastAccessedAt = now; + } else { + ipInfo = { + addedAt: now, + // lastAccessedAt: now, + }; + userAuthCache.whitelistedIPs[ipAddress] = ipInfo; + } + // mark unsaved changes + this._pendingUnsavedChanges = true; + } +} diff --git a/src/plugins/passwordlock/config.ts b/src/plugins/passwordlock/config.ts new file mode 100644 index 0000000..84ff7ed --- /dev/null +++ b/src/plugins/passwordlock/config.ts @@ -0,0 +1,34 @@ +import { PseuplexConfigBase } from '../../pseuplex'; + +type PasswordLockFlags = { + passwordLock?: { + password?: string; + autoWhitelistNetmask?: string | string[]; + } +}; +type PasswordLockPerUserPluginConfig = { + passwordLock?: { + overrideAutoWhitelistNetmask?: boolean; + } +} & PasswordLockFlags; +export type PasswordLockPluginConfig = PseuplexConfigBase & PasswordLockFlags & { + plex: { + assumedTopSectionId?: string | number; + } + passwordLock?: { + enabled?: boolean; + authCachePath?: string; + readableAuthCacheJson?: boolean; + sectionID?: number; + sectionUUID?: string; + sectionTitle?: string; + hubsPivotTitle?: string; + introHubTitle?: string; + instructionsItemUUID?: string; + instructionsItemTitle?: string; + instructionsItemSummary?: string; + instructionsItemVideoId?: number; + loginSuccessItemUUID?: string; + loginFailureDelay?: number; + } +}; diff --git a/src/plugins/passwordlock/errors.ts b/src/plugins/passwordlock/errors.ts new file mode 100644 index 0000000..5d249df --- /dev/null +++ b/src/plugins/passwordlock/errors.ts @@ -0,0 +1,13 @@ + +import { LoggingOptions } from '../../logging'; +import { HttpError } from '../../utils/error'; + +export class LibraryIsLockedError extends Error implements HttpError { + statusCode: number = 403; + silent: boolean; + + constructor(loggingOptions: LoggingOptions | null | undefined) { + super("Library is Locked"); + this.silent = !(loggingOptions?.logLibraryIsLocked ?? false); + } +} diff --git a/src/plugins/passwordlock/index.ts b/src/plugins/passwordlock/index.ts new file mode 100644 index 0000000..4630463 --- /dev/null +++ b/src/plugins/passwordlock/index.ts @@ -0,0 +1,1232 @@ +import qs from 'querystring'; +import http from 'http'; +import crypto from 'crypto'; +import express from 'express'; +import IPCIDR from 'ip-cidr'; +import ws from 'ws'; +import * as plexServerAPI from '../../plex/api'; +import * as plexTypes from '../../plex/types'; +import { + authenticatePlexRequest, + IncomingPlexAPIRequest, + IncomingPlexHttpRequest, + PlexRequestInfo, +} from '../../plex/requesthandling'; +import { + PseuplexAllSectionsSource, + PseuplexApp, + PseuplexMetadataIDParts, + PseuplexPlugin, + PseuplexPluginClass, + PseuplexReadOnlyResponseFilters, + PseuplexRelatedHubsSource, + PseuplexRequestContext, + PseuplexRouterApp, + UpgradeRequest, + UpgradeResponse, + createUpgradeRouter, + endpointForPseuplexSectionsSource, + parsePseuplexMetadataID, + stringifyPartialPseuplexMetadataID, + parsePseuplexMetadataKeyAndID, + parsePseuplexMetadataKey, + parsePseuplexMetadataIDsFromPathParam, + parsePseuplexMetadataIDFromPathParam, + stringifyPseuplexMetadataKeyFromIDString, + PseuplexMetadataIDString +} from '../../pseuplex'; +import { PasswordLockMetadataID, PasswordLockMetadataProvider } from './metadata'; +import { PasswordLockPluginConfig } from './config'; +import { PasswordLockPluginDef } from './plugindef'; +import { PasswordLockAuthenticationCache } from './authcache'; +import { PasswordLockSection } from './lockedSection'; +import { asyncRequestHandler, remoteAddressOfRequest } from '../../utils/requesthandling'; +import { httpError, HttpResponseError } from '../../utils/error'; +import { getModuleRootPath } from '../../utils/compat'; +import { parseIntQueryParam } from '../../utils/queryparams'; +import { parseURLPath, stringifyURLPath } from '../../utils/url'; +import { delay } from '../../utils/timing'; +import { arrayFromArrayOrSingle, firstOrSingle, pushToArray } from '../../utils/misc'; +import { IPv4NormalizeMode, normalizeIPAddress } from '../../utils/ip'; +import { LibraryIsLockedError } from './errors'; + +const transcodeSessionsPrefix = '/transcode/sessions/'; +const videoTranscodePathPrefix = '/video/:/transcode/universal/session/'; +const musicTranscodePathPrefix = '/music/:/transcode/universal/session/'; +const subtitlesTranscodePathPrefix = '/subtitles/:/transcode/universal/session'; +const passthroughTranscodeMethods = ['GET','OPTIONS','HEAD']; + +const protectedOptionsEndpoints = ['/security']; + +const lockInstructionsThumbFilepath = `${getModuleRootPath()}/images/lockedSectionInstructions.png`; +const lockIconFilepath = `${getModuleRootPath()}/images/icons/lock.png`; + +const plexTVAvatarPathRegex = /\/users\/([a-zA-Z0-9]+)\/avatar(?:\/|$)/; + +const SectionTitle = "Login"; + +type PlexClientWebsocketMixin = { + remoteAddress: string; + identityIP: string; + plex: PlexRequestInfo; +}; + +export default (class PasswordLockPlugin implements PasswordLockPluginDef, PseuplexPlugin { + static slug = 'passwordlock'; + readonly slug = PasswordLockPlugin.slug; + readonly app: PseuplexApp; + readonly metadata: PasswordLockMetadataProvider; + readonly section: PasswordLockSection; + readonly authCache: PasswordLockAuthenticationCache; + readonly autoWhitelistNetmasks?: IPCIDR[]; + readonly userAutoWhitelistNetmasks?: { + [email: string]: { + override: boolean; + netmasks?: IPCIDR[]; + } + }; + + readonly notificationWebsocketServer: ws.Server<(typeof ws.WebSocket) & PlexClientWebsocketMixin>; + readonly notificationEventsourceSubscribers: Set<{req: IncomingPlexAPIRequest, res: express.Response}> = new Set(); + + readonly loginFailureDelayPromises: { + [ipAddress: string]: (Promise | undefined) + } = {}; + + readonly cachedVideoMedia: { + [id: string | number]: {Media: (plexTypes.PlexMedia[] | undefined)} | Promise<{Media: (plexTypes.PlexMedia[] | undefined)}> | undefined + } = {}; + + constructor(app: PseuplexApp) { + this.app = app; + + const authCachePath = this.config.passwordLock?.authCachePath; + this.authCache = new PasswordLockAuthenticationCache(authCachePath, { + plexAccountsStore: this.app.plexServerAccounts, + saveReadableJson: this.config.passwordLock?.readableAuthCacheJson, + }); + if(authCachePath) { + this.authCache.load().then((loaded) => { + if(loaded) { + console.log(`Loaded ${this.slug} auth cache from ${authCachePath}`); + } else { + console.log(`No auth cache at ${authCachePath} to load`); + } + if(this.authCache.hasPendingUnsavedChanges && !this.authCache.isSaveQueued) { + this.saveAuthCache(); + } + }, (error) => { + console.error(`Error loading auth cache for ${this.slug} plugin:`); + console.error(error); + }); + } + + this.autoWhitelistNetmasks = parseAutoWhitelistedNetmasks(this.config.passwordLock?.autoWhitelistNetmask); + this.userAutoWhitelistNetmasks = {}; + const perUserConfigs = this.config.perUser; + if(perUserConfigs) { + for(const email of Object.keys(perUserConfigs)) { + const userConfig = perUserConfigs[email]; + const userPwLockCfg = userConfig.passwordLock; + if(userPwLockCfg?.autoWhitelistNetmask || userPwLockCfg?.overrideAutoWhitelistNetmask) { + const whitelistedNetmasks = parseAutoWhitelistedNetmasks(userPwLockCfg.autoWhitelistNetmask); + this.userAutoWhitelistNetmasks[email] = { + override: userPwLockCfg.overrideAutoWhitelistNetmask ?? false, + netmasks: whitelistedNetmasks, + }; + } + } + } + + this.notificationWebsocketServer = new ws.Server({ + noServer: true, + }); + this.notificationWebsocketServer.on('connection', (client, req) => { + client.on('error', (error) => { + console.error(`Websocket client error:`); + console.error(error); + }); + }); + + this.metadata = new PasswordLockMetadataProvider({ + lockInstructionsThumbEndpoint: `${this.basePath}/images/thumb/instructions`, + lockInstructionsItemUUID: this.config.passwordLock?.instructionsItemUUID ?? crypto.randomUUID(), + lockInstructionsTitle: this.config.passwordLock?.instructionsItemTitle, + lockInstructionsSummary: this.config.passwordLock?.instructionsItemSummary, + getLockInstructionsItemMedia: async (context) => { + return await this.getInstructionsItemMedia(context); + }, + loginSuccessEndpoint: `${this.basePath}/${PasswordLockMetadataID.LoginSuccess}`, + loginSuccessItemUUID: this.config.passwordLock?.loginSuccessItemUUID ?? crypto.randomUUID(), + }); + + this.section = new PasswordLockSection(this, { + id: this.config.passwordLock?.sectionID ?? -24, + uuid: this.config.passwordLock?.sectionUUID ?? crypto.randomUUID(), + path: `${this.basePath}`, + hubsPath: `${this.basePath}/hubs`, + title: this.config.passwordLock?.sectionTitle ?? SectionTitle, + type: plexTypes.PlexMediaItemType.Movie, + allowSync: false, + hubsPivotTitle: this.config.passwordLock?.hubsPivotTitle, + introHubTitle: this.config.passwordLock?.introHubTitle, + }); + } + + get config(): PasswordLockPluginConfig { + return this.app.config as PasswordLockPluginConfig; + } + + get basePath() { + return `/${this.app.slug}/${this.slug}`; + } + + responseFilters?: PseuplexReadOnlyResponseFilters = { + // TODO define any functions to modify plex server responses + } + + defineRoutes(router: PseuplexRouterApp) { + + // define unauthenticated router + const unauthRouterOptions: express.RouterOptions = { + caseSensitive: router.enabled('case sensitive routing'), + strict: router.enabled('strict routing'), + }; + const unauthRouter = express.Router(unauthRouterOptions); + const unauthUpgradeRouter = createUpgradeRouter(unauthRouterOptions); + const plexProxyMiddleware = this.app.middlewares.plexProxy(); + + unauthRouter.get('/', [ + plexProxyMiddleware, + ]); + + unauthRouter.get('/media/providers', [ + this.app.middlewares.plexAPIProxy({ + responseModifier: async (proxyRes, resData: plexTypes.PlexServerMediaProvidersPage, userReq: IncomingPlexAPIRequest, userRes) => { + const context = this.app.contextForRequest(userReq); + // remove all non-home sections + for(const mediaProvider of resData.MediaContainer.MediaProvider) { + for(const feature of mediaProvider.Feature) { + if(feature.type == plexTypes.PlexFeatureType.Content) { + const contentFeature = feature as plexTypes.PlexContentFeature; + contentFeature.Directory = contentFeature.Directory.filter((dir) => { + return dir.hubKey === '/hubs'; + }); + } + } + } + // add passwordlock section + const contentFeature = resData.MediaContainer.MediaProvider[0] + ?.Feature.find((f) => f.type == plexTypes.PlexFeatureType.Content) as plexTypes.PlexContentFeature; + if(contentFeature) { + contentFeature.Directory.push(await this.section.getMediaProviderDirectory(context)); + } + return resData; + } + }), + ]); + + for(const sectionsSource of Object.values(PseuplexAllSectionsSource)) { + unauthRouter.get(endpointForPseuplexSectionsSource(sectionsSource), [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const context = { + ...this.app.contextForRequest(req), + from: sectionsSource, + }; + const reqParams: plexTypes.PlexLibrarySectionsPageParams = req.plex.requestParams; + // return singular section + return { + MediaContainer: { + title1: "Plex Library", + size: 1, + Directory: [ + await this.section.getLibrarySectionsEntry(reqParams, context) + ] + } + }; + }), + ]); + } + + unauthRouter.get([ this.section.path, `/library/sections/${this.section.id}` ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + return await this.section.getSectionPage(context); + }), + ]); + + unauthRouter.get([ `${this.section.path}/prefs`, `/library/sections/${this.section.id}/prefs` ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + return await this.section.getPrefsPage(context); + }), + ]); + + unauthRouter.get([ `${this.section.path}/collections`, `/library/sections/${this.section.id}/collections` ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + const plexParams = plexTypes.parsePlexCollectionsPageParams(req); + return await this.section.getCollectionsPage(plexParams, context); + }), + ]); + + unauthRouter.get([ `${this.section.path}/all`, `/library/sections/${this.section.id}/all` ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + const plexParams = plexTypes.parsePlexSectionAllItemsPageParams(req); + return await this.section.getAllItemsPage(plexParams, context); + }), + ]); + + unauthRouter.get([ this.section.hubsPath, `/hubs/section/${this.section.id}` ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + const reqParams = plexTypes.parsePlexHubListPageParams(req); + return await this.section.getHubsPage(reqParams,context); + }), + ]); + + unauthRouter.get(this.section.introHub.path, [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + const reqParams = plexTypes.parsePlexHubPageParams(req, {fromListPage:false}); + return await this.section.introHub.getHubPage(reqParams,context); + }), + ]); + + unauthRouter.get('/hubs', [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const context = this.app.contextForRequest(req); + const reqParams = plexTypes.parsePlexHubListPageParams(req); + // ensure the section is included + if(reqParams.contentDirectoryID && reqParams.contentDirectoryID.length > 0) { + if(reqParams.contentDirectoryID.findIndex(id => (id == this.section.id)) == -1) { + return { + MediaContainer: { + size: 0, + allowSync: false, + } + }; + } + } + // get hubs for each section + const hubsPage: plexTypes.PlexHubsPage = await this.section.getHubsPage(reqParams, context); + delete hubsPage.MediaContainer.librarySectionID; + delete hubsPage.MediaContainer.librarySectionTitle; + delete hubsPage.MediaContainer.librarySectionUUID; + delete (hubsPage.MediaContainer as any).librarySectionKey; + return hubsPage; + }), + ]); + + unauthRouter.get('/hubs/promoted', [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const context = this.app.contextForRequest(req); + const reqParams = plexTypes.parsePlexHubListPageParams(req); + // ensure the section is included + if(reqParams.contentDirectoryID && reqParams.contentDirectoryID.length > 0) { + if(reqParams.contentDirectoryID.findIndex(id => (id == this.section.id)) == -1) { + return { + MediaContainer: { + size: 0, + allowSync: false, + } + }; + } + } + // get hubs for each section + const hubsPage: plexTypes.PlexHubsPage = await this.section.getPromotedHubsPage(reqParams, context); + delete hubsPage.MediaContainer.librarySectionID; + delete hubsPage.MediaContainer.librarySectionTitle; + delete hubsPage.MediaContainer.librarySectionUUID; + delete (hubsPage.MediaContainer as any).librarySectionKey; + return hubsPage; + }), + ]); + + // proxy if whitelisted metadata is being fetched + unauthRouter.get('/library/metadata/:metadataId', [ + asyncRequestHandler((req: IncomingPlexAPIRequest, res, next) => { + if(req.method === 'GET' || req.method === 'OPTIONS') { + const context = this.app.contextForRequest(req); + if(this.isMetadataIdWhitelisted(req.params.metadataId, context)) { + // delete any included hubs + const urlParts = parseURLPath(req.url); + if(urlParts.queryItems?.['includeRelated']) { + delete urlParts.queryItems['includeRelated']; + req.url = stringifyURLPath(urlParts); + } + // proxy request + plexProxyMiddleware(req,res,next); + return true; + } + } + return false; + }) + ]); + // handle metadata endpoint + unauthRouter.get('/library/metadata/:metadataId', [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const context = this.app.contextForRequest(req); + const reqParams: plexTypes.PlexMetadataPageParams = req.plex.requestParams; + // get metadata ids + const metadataIds = parsePseuplexMetadataIDsFromPathParam(req.params.metadataId); + for(const metadataIdParts of metadataIds) { + // ensure metadata is a "passwordlock" metadata + if(metadataIdParts.source != this.metadata.sourceSlug) { + throw httpError(403, `Metadata is locked`); + } + // validate disallowed passwordlock items + if(!metadataIdParts.directory) { + if(metadataIdParts.id == PasswordLockMetadataID.LoginSuccess) { + throw httpError(403, "Success metadata is locked (nice try)"); + } + } + } + // fetch metadatas + const partialMetadataIds = metadataIds.map((idParts) => stringifyPartialPseuplexMetadataID(idParts)); + return await this.metadata.get(partialMetadataIds, { + context, + metadataTransformOptions: { + ...this.app.metadataTransformOptions(), + includeMetadataUnavailability: true, + }, + plexParams: reqParams, + includeUnmatched: true, + }); + }), + ]); + + for(const hubsSource of Object.values(PseuplexRelatedHubsSource)) { + unauthRouter.get(`/${hubsSource}/metadata/:metadataId/related`, [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const context = this.app.contextForRequest(req); + const reqParams = plexTypes.parsePlexHubListPageParams(req); + // get metadata id + const metadataIdParts = parsePseuplexMetadataIDFromPathParam(req.params.metadataId); + // ensure that only "passwordlock" metadata can be fetched + if(metadataIdParts.source != this.metadata.sourceSlug) { + throw httpError(403, `Metadata is locked`); + } + // get related hubs for metadata id + const partialMetadataId = stringifyPartialPseuplexMetadataID(metadataIdParts); + return await this.metadata.getRelatedHubs(partialMetadataId, { + context, + plexParams: reqParams, + from: hubsSource, + }); + }), + ]); + } + + unauthRouter.get(`/library/all`, [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + const context = this.app.contextForRequest(req); + const plexParams = plexTypes.parsePlexLibraryAllItemsPageParams(req); + if(plexParams.guid || plexParams['show.guid']) { + // show "unlock server" item + const resData: plexTypes.PlexMetadataPage = { + MediaContainer: { + size: 0, + Metadata: [] + } + }; + const unlockMetadata = firstOrSingle((await this.metadata.get([PasswordLockMetadataID.Instructions], { + context, + includeUnmatched: true, + metadataTransformOptions: { + ...this.app.metadataTransformOptions(), + includeMetadataUnavailability: true, + } + })).MediaContainer.Metadata); + if(unlockMetadata) { + // add extra fields to metadata + const actionTitle = "Unlock Server :"; + unlockMetadata.title = actionTitle; + unlockMetadata.librarySectionTitle = actionTitle; + unlockMetadata.librarySectionID = this.section.id; + unlockMetadata.librarySectionKey = this.section.path; + unlockMetadata.Media = [{ + id: 99999999999, + videoResolution: actionTitle, + Part: [ + { + id: 99999999998, + } + ] + } as plexTypes.PlexMedia]; + // add password metadata to response + resData.MediaContainer.Metadata = pushToArray(resData.MediaContainer.Metadata, unlockMetadata); + resData.MediaContainer.size += 1; + if(resData.MediaContainer.totalSize != null) { + resData.MediaContainer.totalSize += 1; + } + } + return resData; + } + // get all items + const libraryPage = await this.section.getAllItemsPage(plexParams, context); + delete libraryPage.MediaContainer.librarySectionID; + delete libraryPage.MediaContainer.librarySectionTitle; + delete libraryPage.MediaContainer.librarySectionUUID; + delete (libraryPage.MediaContainer as any).librarySectionKey; + return libraryPage; + }), + ]); + + unauthRouter.get([ + '/hubs/continueWatching', '/hubs/continueWatching/items', + '/hubs/home/continueWatching', '/hubs/home/continueWatching/items', + ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + return { + MediaContainer: { + size: 0, + allowSync: false, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, + } + }; + }), + ]); + + unauthRouter.get([ + '/hubs/home/recentlyAdded', + ], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + return { + MediaContainer: { + size: 0, + allowSync: false, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, + } + }; + }), + ]); + + unauthRouter.get('/status/sessions', [ + this.app.middlewares.plexServerOwnerOnly(), + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise<{MediaContainer:plexTypes.PlexMediaContainer}> => { + return { + MediaContainer: { + size: 0, + } + }; + }), + ]); + + unauthRouter.get('/activities', [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise<{MediaContainer:plexTypes.PlexMediaContainer}> => { + return { + MediaContainer: { + size: 0, + } + }; + }), + ]); + + unauthRouter.get(['/playlists', '/playlists/all'], [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise<{MediaContainer:plexTypes.PlexMediaContainer}> => { + return { + MediaContainer: { + size: 0, + totalSize: 0, + offset: 0, + } + }; + }), + ]); + + unauthRouter.post('/playlists', [ + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const context = this.app.contextForRequest(req); + // get the uri of the metadata being added + const metadataItemURIString = req.query['uri']; + if(metadataItemURIString && (typeof metadataItemURIString === 'string')) { + // split the item into parts + const metadataItemURIParts = plexTypes.parsePlexServerItemURI(metadataItemURIString); + // validate that the item is for this server + const plexServerIdentifier = await this.app.plexServerProperties.getMachineIdentifier(); + if(metadataItemURIParts.path && (metadataItemURIParts.machineIdentifier == plexServerIdentifier || metadataItemURIParts.machineIdentifier == "x")) { + // get the key of the item + const metadataKeyParts = parsePseuplexMetadataKeyAndID(metadataItemURIParts.path); + if(metadataKeyParts) { + const metadataIdParts = metadataKeyParts.idParts; + if(metadataIdParts.source == this.metadata.sourceSlug) { + // check the type of item + if(!metadataIdParts.directory && metadataIdParts.id == PasswordLockMetadataID.Instructions) { + // item is the instructions item, so treat this as password input + let inputPassword = req.query['title'] || ""; + if(typeof inputPassword !== 'string') { + throw httpError(400, "Invalid input password"); + } + await this.login(req, inputPassword); + // return successfully + const successItem = firstOrSingle((await this.metadata.get([PasswordLockMetadataID.LoginSuccess], { + context, + includeUnmatched: true, + metadataTransformOptions: { + ...this.app.metadataTransformOptions(), + includeMetadataUnavailability: true, + } + })).MediaContainer.Metadata); + return { + MediaContainer: { + size: 1, + Metadata: [ + successItem as any as plexTypes.PlexPlaylist + ] + } + }; + } + } + } + } + } + throw new LibraryIsLockedError(this.app.logger?.options); + }), + ]); + + // reroute instructions video + unauthRouter.post('/playQueues', [ + async (req: IncomingPlexAPIRequest, res, next) => { + try { + const context = this.app.contextForRequest(req); + const urlParts = parseURLPath(req.url); + const uriString = urlParts.queryItems?.['uri']; + if(!uriString || typeof uriString !== 'string') { + next(); + return; + } + const uriParts = plexTypes.parsePlexServerItemURI(uriString); + const plexMachineId = await this.app.plexServerProperties.getMachineIdentifier(); + if(!uriParts.path || (uriParts.machineIdentifier != plexMachineId && uriParts.machineIdentifier != "x")) { + next(); + return; + } + const metadataKeyParts = parsePseuplexMetadataKeyAndID(uriParts.path); + if(!metadataKeyParts) { + next(); + return; + } + const metadataId = metadataKeyParts.idParts; + // check if any of the video ids match + let matchedVideoId = false; + if(!metadataId.source) { + if(this.isMetadataIdWhitelisted(metadataId.id, context)) { + matchedVideoId = true; + } + } else { + const newMetadataId = this.rewriteAliasedMetadataId(metadataId, context); + if(newMetadataId) { + // replace id with the video ID + matchedVideoId = true; + uriParts.path = stringifyPseuplexMetadataKeyFromIDString(newMetadataId); + urlParts.queryItems!['uri'] = plexTypes.stringifyPlexServerItemURI(uriParts); + if(urlParts.queryItems!['key']) { + urlParts.queryItems!['key'] = uriParts.path; + } + req.url = stringifyURLPath(urlParts); + } + } + if(!matchedVideoId) { + next(); + return; + } + // video ID matches, so rewrite this request and proxy it + plexProxyMiddleware(req, res, next); + } catch(error) { + console.error(`Error handling password locked playQueues POST`); + next(error); + } + } + ]); + // proxy and validate that whitelisted metadata is included + unauthRouter.get('/playQueues/:playQueueId', [ + this.app.middlewares.plexAPIProxy({ + responseModifier: (proxyRes, resData: plexTypes.PlayQueueItemsPage, userReq: IncomingPlexAPIRequest, userRes) => { + const context = this.app.contextForRequest(userReq); + const metadatas = arrayFromArrayOrSingle(resData.MediaContainer.Metadata); + if(metadatas) { + // throw an error if any metadata item is disallowed + for(const metadata of metadatas) { + if(metadata.ratingKey) { + if(!this.isMetadataIdWhitelisted(metadata.ratingKey, context)) { + throw httpError(403, "PlayQueue contains unavailable items"); + } + } + else if(metadata.key) { + if(!this.isMetadataKeyWhitelisted(metadata.key, context)) { + throw httpError(403, "PlayQueue contains unavailable items"); + } + } + else { + throw httpError(403, "PlayQueue contains unknown items"); + } + } + } + return resData; + } + }) + ]); + // proxy if whitelisted metadata is being played + for(const endpoint of [ + '/video/\\:/transcode/universal/decision', + '/video/\\:/transcode/universal/start.m3u8', + '/video/\\:/transcode/universal/stop', + '/music/\\:/transcode/universal/decision', + '/music/\\:/transcode/universal/start.m3u8', + '/subtitles/\\:/transcode/universal/start', + ]) { + unauthRouter.get(endpoint, [ + asyncRequestHandler((req: IncomingPlexAPIRequest, res, next) => { + const context = this.app.contextForRequest(req); + // rewrite path if needed + let path = req.query['path']; + if(typeof path === 'string' && path) { + // rewrite metadata key if needed + const metadataKeyParts = parsePseuplexMetadataKeyAndID(path); + if(metadataKeyParts) { + const metadataIdParts = metadataKeyParts.idParts; + const newMetadataId = this.rewriteAliasedMetadataId(metadataIdParts, context); + if(newMetadataId) { + path = stringifyPseuplexMetadataKeyFromIDString(newMetadataId, metadataKeyParts.relativePath); + const reqPathParts = parseURLPath(req.url); + reqPathParts.queryItems!['path'] = path; + req.url = stringifyURLPath(reqPathParts); + } + } + // ignore if whitelisted metadata is being played + if(this.isMetadataKeyWhitelisted(path, context)) { + plexProxyMiddleware(req,res,next); + return true; + } + } + return false; + }) + ]); + } + // proxy if whitelisted part is being played + unauthRouter.use([ + asyncRequestHandler((req: IncomingPlexAPIRequest, res: express.Response, next) => { + const path = req.path; + if(!path.startsWith('/library/parts/')) { + return false; + } + if(req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') { + const context = this.app.contextForRequest(req); + // ignore if whitelisted metadata is being played + if(this.isMetadataMediaPartKeyWhitelisted(path, context)) { + plexProxyMiddleware(req,res,next); + return true; + } + } + return false; + }) + ]); + // proxy if whitelisted metadata is being used + unauthRouter.get('/\\:/timeline', [ + asyncRequestHandler((req: IncomingPlexAPIRequest, res, next) => { + const context = this.app.contextForRequest(req); + // ignore if whitelisted metadata is being played + const urlPathParts = parseURLPath(req.url); + let ratingKey = urlPathParts.queryItems?.['ratingKey']; + let key = urlPathParts.queryItems?.['key']; + // rewrite metadata id if needed + if(ratingKey && typeof ratingKey === 'string') { + // rewrite metadata id + const newMetadataId = this.rewriteAliasedMetadataId(parsePseuplexMetadataID(ratingKey), context); + if(newMetadataId) { + ratingKey = newMetadataId.toString(); + urlPathParts.queryItems!['ratingKey'] = ratingKey; + } + } + if(key && typeof key === 'string') { + const metadataKeyParts = parsePseuplexMetadataKeyAndID(key); + if(metadataKeyParts) { + // rewrite metadata id + const newMetadataId = this.rewriteAliasedMetadataId(metadataKeyParts.idParts, context); + if(newMetadataId) { + ratingKey = newMetadataId.toString(); + key = `/library/` + urlPathParts.queryItems!['key'] = key; + } + } + } + // check if metadata is whitelisted + if(ratingKey && typeof ratingKey === 'string') { + if(this.isMetadataIdWhitelisted(ratingKey, context)) { + plexProxyMiddleware(req,res,next); + return true; + } + } + else if(key && typeof key === 'string') { + if(this.isMetadataKeyWhitelisted(key, context)) { + plexProxyMiddleware(req,res,next); + return true; + } + } + return false; + }) + ]); + + unauthRouter.get('/\\:/prefs', [ + this.app.middlewares.plexServerOwnerOnly(), + this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + return { + MediaContainer: { + size: 0, + Setting: [], + } + }; + }), + ]); + + unauthRouter.get('/updater/status', [ + plexProxyMiddleware, + ]); + + unauthRouter.put('/updater/check', [ + this.app.middlewares.plexServerOwnerOnly(), + asyncRequestHandler(async (req: IncomingPlexAPIRequest, res: express.Response): Promise => { + res.setHeader('Access-Control-Allow-Origin', 'https://app.plex.tv'); + res.setHeader('Vary', 'Origin, X-Plex-Token'); + res.setHeader('X-Plex-Protocol', '1.0'); + res.status(200).send(); + this.app.logger?.logIncomingUserRequestResponse(req, res, undefined); + return true; + }), + ]); + + unauthRouter.get(this.metadata.options.lockInstructionsThumbEndpoint, [ + asyncRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + // parse width and height + const width = parseIntQueryParam(req.query.width); + const height = parseIntQueryParam(req.query.height); + // send image response + await this.app.sendImageResponse({ + origin: req.headers['origin'], + filepath: lockInstructionsThumbFilepath, + width, + height, + }, res); + return true; + }), + ]); + + unauthRouter.get('/photo/\\:/transcode', [ + asyncRequestHandler(async (req: IncomingPlexAPIRequest, res, next) => { + try { + const context = this.app.contextForRequest(req); + const urlParts = parseURLPath(req.url); + let photoUrl = urlParts.queryItems?.['url']; + if(!photoUrl || typeof photoUrl !== 'string') { + // continue + return false; + } + // check if plex.tv avatar url + const rewrittenPhotoUrl = this.app.rewritePhotoEndpointLocalhostURL(photoUrl); + photoUrl = rewrittenPhotoUrl.url; + if(!photoUrl.startsWith('/')) { + const photoUrlParts = new URL(photoUrl); + if(photoUrlParts.host == 'plex.tv') { + if(plexTVAvatarPathRegex.test(photoUrlParts.pathname)) { + // parse width and height + const width = parseIntQueryParam(req.query.width); + const height = parseIntQueryParam(req.query.height); + // send image response + await this.app.sendImageResponse({ + origin: req.headers['origin'], + filepath: lockIconFilepath, + width, + height, + }, res); + return true; + } + } + // continue + return false; + } + // check if passwordlock metadata thumb + const photoUrlParts = parseURLPath(photoUrl); + switch(photoUrlParts.path) { + case this.metadata.options.lockInstructionsThumbEndpoint: { + // parse width and height + const width = parseIntQueryParam(req.query.width); + const height = parseIntQueryParam(req.query.height); + // send image response + await this.app.sendImageResponse({ + origin: req.headers['origin'], + filepath: lockInstructionsThumbFilepath, + width, + height, + }, res); + return true; + } + } + // check if instructions video thumb + const instructionsVideoId = this.getInstructionsItemVideoId(context); + if(instructionsVideoId) { + const photoKeyParts = parsePseuplexMetadataKey(photoUrlParts.path); + if(photoKeyParts && photoKeyParts.id == instructionsVideoId) { + // proxy to plex + plexProxyMiddleware(req,res,next); + return true; + } + } + } catch(error) { + console.error(`Error rewriting plex photo url:`); + console.error(error); + } + // continue + return false; + }), + ]); + + unauthRouter.get('/\\:/eventsource/notifications', [ + asyncRequestHandler(async (req, res) => { + // add subscriber to set + const subscriber = {req,res}; + this.notificationEventsourceSubscribers.add(subscriber); + let done = false; + const onDone = () => { + if(done) { + return; + } + done = true; + this.notificationEventsourceSubscribers.delete(subscriber); + }; + req.once('close', onDone); + res.once('finish', onDone); + res.once('close', onDone); + // set response headers + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + res.flushHeaders(); + return true; + }), + ]); + + unauthRouter.use((req, res, next) => { + // all other requests should return a 403 + next(new LibraryIsLockedError(this.app.logger?.options)); + }); + + unauthUpgradeRouter.get('/\\:/websockets/notifications', [ + asyncRequestHandler(async (req: IncomingPlexHttpRequest, res: UpgradeResponse) => { + if(req.headers['upgrade']?.toLowerCase().trim() != 'websocket') { + // continue + return false; + } + const { socket, head } = res; + this.notificationWebsocketServer.handleUpgrade(req, socket, head, (client: (ws & PlexClientWebsocketMixin), req: IncomingPlexHttpRequest) => { + try { + client.remoteAddress = remoteAddressOfRequest(req); + client.identityIP = this.identityIPOfRequest(req); + client.plex = req.plex; + } catch(error) { + console.error(`Error after connecting websocket:`); + console.error(error); + client.close(); + req.destroy(); + return; + } + this.notificationWebsocketServer.emit('connection', client, req); + }); + // handled + return true; + }), + ]); + + unauthUpgradeRouter.use((req: UpgradeRequest, res: UpgradeResponse, next) => { + req.destroy(); + res.socket.destroy(); + }); + + // catch and authenticate all upgrade requests + router.upgradeRouter.use([ + async (req: UpgradeRequest, res: UpgradeResponse, next) => { + // check if password lock is enabled + if(!this.config?.passwordLock?.enabled) { + // continue + next(); + return; + } + // authenticate the request + let allowedAccess: boolean; + try { + // authenticate request as plex user + await authenticatePlexRequest(req, this.app.plexServerAccounts); + // validate that we're allowed to continue + allowedAccess = await this.isUserAllowedAccess(req as IncomingPlexHttpRequest); + } catch(error) { + next(error); + return; + } + // continue if allowed access + if(allowedAccess) { + next(); + return; + } + // forward to unauthed router + // unauthUpgradeRouter has a catch-all that throws an error, so any non-matching routes will fail + unauthUpgradeRouter(req, res, next); + }, + ]); + + // catch and authenticate all api requests + router.use([ + async (req: express.Request, res: express.Response, next) => { + try { + // check if password lock is enabled + if(!this.config?.passwordLock?.enabled) { + next(); + return; + } + // get normalized path + let reqPath = req.path; + let oldReqPath: string; + do { + oldReqPath = reqPath; + reqPath = reqPath.replaceAll('//', '/'); + } while(oldReqPath.length != reqPath.length); + // ignore paths that don't need a plex token + if((req.method === 'OPTIONS' && protectedOptionsEndpoints.findIndex((e) => reqPath.startsWith(e)) == -1) + || reqPath == '/identity' || reqPath.startsWith('/web/') || reqPath == '/web' + || (passthroughTranscodeMethods.indexOf(req.method) != -1 && ( + (reqPath.startsWith(videoTranscodePathPrefix) && reqPath.length > videoTranscodePathPrefix.length) + || (reqPath.startsWith(musicTranscodePathPrefix) && reqPath.length > musicTranscodePathPrefix.length) + || (reqPath.startsWith(subtitlesTranscodePathPrefix) && reqPath.length > subtitlesTranscodePathPrefix.length) + || (reqPath.startsWith(transcodeSessionsPrefix) && reqPath.length > transcodeSessionsPrefix.length) + )) + || ((reqPath.endsWith('.png') || reqPath.endsWith('.ico')) && reqPath.indexOf('/', 1) == -1) + ) { + next(); + return; + } + // authenticate the request + let allowedAccess: boolean; + try { + // authenticate request as plex user + await authenticatePlexRequest(req, this.app.plexServerAccounts); + // validate that we're allowed to continue + allowedAccess = await this.isUserAllowedAccess(req as IncomingPlexAPIRequest); + } catch(error) { + next(error); + return; + } + // continue if allowed access + if(allowedAccess) { + next(); + return; + } + // IP is not allowed access, so redirect to subrouter + // unauthRouter has a catch-all that throws an error, so any non-matching routes will fail + unauthRouter(req, res, next); + } catch(error) { + console.error(`Exception while handling route ${req.path}`); + console.error(error); + next(error); + } + } + ]); + } + + isMetadataKeyWhitelisted(key: string, context: PseuplexRequestContext) { + if(!key) { + return false; + } + const metadataKeyParts = parsePseuplexMetadataKey(key); + if(!metadataKeyParts) { + return false; + } + return this.isMetadataIdWhitelisted(metadataKeyParts.id, context); + } + + isMetadataIdWhitelisted(id: string, context: PseuplexRequestContext) { + const instructionsVideoId = this.getInstructionsItemVideoId(context); + if(instructionsVideoId) { + if(id == instructionsVideoId) { + return true; + } + } + return false; + } + + isMetadataMediaPartKeyWhitelisted(key: string, context: PseuplexRequestContext) { + const instructionsVideoId = this.getInstructionsItemVideoId(context); + if(instructionsVideoId) { + const instructionsMedia = this.cachedVideoMedia[instructionsVideoId]; + if(!(instructionsMedia instanceof Promise) && instructionsMedia?.Media) { + for(const media of instructionsMedia.Media) { + if(media.Part) { + for(const part of media.Part) { + if(part.key == key) { + return true; + } + } + } + } + } + } + return false; + } + + rewriteAliasedMetadataId(metadataId: PseuplexMetadataIDParts, context: PseuplexRequestContext): PseuplexMetadataIDString | number | null { + if(metadataId.source == this.metadata.sourceSlug && !metadataId.directory) { + if(metadataId.id == PasswordLockMetadataID.Instructions) { + const instructionsVideoId = this.getInstructionsItemVideoId(context); + if(instructionsVideoId) { + return instructionsVideoId; + } + } + } + return null; + } + + getInstructionsItemVideoId(context: PseuplexRequestContext): PseuplexMetadataIDString | number | undefined { + // TODO get per user + return this.config.passwordLock?.instructionsItemVideoId; + } + + async getInstructionsItemMedia(context: PseuplexRequestContext): Promise { + const videoId = this.getInstructionsItemVideoId(context); + if(!videoId) { + return undefined; + } + let videoData = this.cachedVideoMedia[videoId]; + if(!videoData) { + let done = false; + videoData = plexServerAPI.getLibraryMetadata(videoId, { + serverURL: context.plexServerURL, + authContext: context.plexAuthContext, + logger: this.app.logger, + }).then((r) => { + done = true; + const result = {Media: firstOrSingle(r.MediaContainer.Metadata)?.Media}; + this.cachedVideoMedia[videoId] = result; + return result; + }, (e) => { + done = true; + delete this.cachedVideoMedia[videoId]; + if((e as HttpResponseError).httpResponse?.status == 404) { + return {Media:undefined}; + } + throw e; + }); + if(!done) { + this.cachedVideoMedia[videoId] = videoData; + } + } + return (await videoData).Media; + } + + get loginFailureDelay(): number { + return this.config.passwordLock?.loginFailureDelay ?? 6000; + } + + identityIPOfRequest(req: http.IncomingMessage) { + const realIP = this.app.realIPOfRequest(req); + return normalizeIPAddress(realIP, IPv4NormalizeMode.ToIPv4); + } + + async isUserAllowedAccess(req: IncomingPlexHttpRequest): Promise { + const userEmail = req.plex.userInfo.email; + // check if source IP is confirmed + await this.authCache.waitForLoad(); + const identityIP = this.identityIPOfRequest(req); + // check if we're on an auto-whitelisted network + const userNetmasks = this.userAutoWhitelistNetmasks?.[userEmail]; + if(userNetmasks?.netmasks && userNetmasks.netmasks.findIndex((n: IPCIDR) => n.contains(identityIP)) != -1) { + return true; + } + if(!userNetmasks?.override) { + if(this.autoWhitelistNetmasks && this.autoWhitelistNetmasks.findIndex((n: IPCIDR) => n.contains(identityIP)) != -1) { + return true; + } + } + // validate the IP + return this.authCache.isIPWhitelistedForUser(identityIP, req); + } + + async login(req: IncomingPlexAPIRequest, inputPassword: string) { + const identityIP = this.identityIPOfRequest(req); + // if user has a pending login failure, throw a 429 to prevent spam + if(this.loginFailureDelayPromises[identityIP]) { + throw httpError(429, "Slow down there jibro"); + } + // validate password + const password = this.config.perUser?.[req.plex.userInfo.email]?.passwordLock?.password + ?? this.config.passwordLock?.password + ?? ""; + if(password != inputPassword) { + // failure, delay some time to prevent brute force + console.error(`Failed login from ip ${identityIP} with context ${JSON.stringify(req.plex)}`); + const failureDelay = delay(this.loginFailureDelay); + this.loginFailureDelayPromises[identityIP] = failureDelay; + try { + await failureDelay; + } finally { + delete this.loginFailureDelayPromises[identityIP]; + } + throw httpError(401, "Wrong password"); + } + // success, so whitelist the IP + console.log(`Successful login from ip ${identityIP} with context ${JSON.stringify(req.plex)}`); + const plexToken = req.plex.authContext['X-Plex-Token']!; + this.authCache.whitelistIPForUser(identityIP, req); + if(!this.authCache.isSaveQueued) { + this.saveAuthCache(); + } + // TODO send section change notifications to add library sections and remove login section, so user doesn't have to restart the app + // disconnect any unauthed websockets + for(const client of this.notificationWebsocketServer.clients as Set) { + const cmpPlexToken = client.plex.authContext['X-Plex-Token']; + if(plexToken == cmpPlexToken && identityIP == client.identityIP) { + console.log(`Disconnecting unauthenticated plex websocket for ${req.plex.userInfo.email} on ip ${identityIP}`); + client.close(); + } + } + // disconnect any unauthed eventsource subscribers + for(const subscriber of this.notificationEventsourceSubscribers) { + const cmpPlexToken = subscriber.req.plex.authContext['X-Plex-Token']; + const cmpIdentityIP = this.identityIPOfRequest(subscriber.req); + if(plexToken == cmpPlexToken && identityIP == cmpIdentityIP) { + console.log(`Disconnecting unauthenticated plex eventsource subscriber for ${req.plex.userInfo.email} on ip ${identityIP}`); + subscriber.res.end(); + } + } + } + + saveAuthCache() { + this.authCache.save().catch((error) => { + console.error("Error saving auth cache:"); + console.error(error); + }); + } + +} satisfies PseuplexPluginClass); + + +function parseAutoWhitelistedNetmasks(netmaskStrings: string | string[] | undefined) { + if(typeof netmaskStrings === 'string') { + netmaskStrings = netmaskStrings.trim(); + if(netmaskStrings) { + netmaskStrings = netmaskStrings.split(','); + } else { + netmaskStrings = []; + } + } else if(netmaskStrings) { + netmaskStrings = netmaskStrings.flatMap((netmask) => { + netmask = netmask.trim(); + if(netmask) { + return netmask.split(','); + } else { + return []; + } + }); + } + return netmaskStrings?.map((maskString) => new IPCIDR(maskString)) ?? []; +} diff --git a/src/plugins/passwordlock/lockedSection/index.ts b/src/plugins/passwordlock/lockedSection/index.ts new file mode 100644 index 0000000..f338df9 --- /dev/null +++ b/src/plugins/passwordlock/lockedSection/index.ts @@ -0,0 +1,94 @@ +import * as plexTypes from '../../../plex/types'; +import { + PseuplexHub, + PseuplexHubPage, + PseuplexHubPageParams, + PseuplexHubSectionInfo, + PseuplexMetadataTransformOptions, + PseuplexRequestContext, + PseuplexSectionBase, + PseuplexSectionItemsPage, + PseuplexSectionOptions +} from '../../../pseuplex'; +import { PasswordLockMetadataID } from '../metadata'; +import { PasswordLockPluginDef } from '../plugindef'; +import { PasswordLockedSectionIntroHub } from './introHub'; +import { arrayFromArrayOrSingle } from '../../../utils/misc'; + +export type PasswordLockSectionOptions = PseuplexSectionOptions & { + hubsPivotTitle?: string, + introHubTitle?: string, +}; + +const SectionHubsPivotTitle = "Library Locked"; +const SectionIntroHubTitle = "Sorry! Please Log in"; + +export class PasswordLockSection extends PseuplexSectionBase { + readonly plugin: PasswordLockPluginDef; + readonly hubsPivotTitle: string; + readonly introHub: PasswordLockedSectionIntroHub; + readonly metadataTransformOptions: PseuplexMetadataTransformOptions; + + constructor(plugin: PasswordLockPluginDef, options: PasswordLockSectionOptions) { + super(options); + this.plugin = plugin; + + this.metadataTransformOptions = { + includeMetadataUnavailability: true, + }; + + this.hubsPivotTitle = options.hubsPivotTitle ?? SectionHubsPivotTitle; + this.introHub = new PasswordLockedSectionIntroHub({ + path: `${this.hubsPath}/intro`, + title: options.introHubTitle ?? SectionIntroHubTitle, + metadataProvider: plugin.metadata, + metadataTransformOptions: this.metadataTransformOptions, + section: { + id: `${this.id}`, + uuid: this.uuid, + title: this.title, + }, + }); + } + + async getPivots(): Promise { + return [ + { + id: plexTypes.PlexPivotID.Recommended, + key: this.hubsPath, + type: plexTypes.PlexPivotType.Hub, + title: this.hubsPivotTitle, + context: plexTypes.PlexPivotContext.Discover, + symbol: plexTypes.PlexSymbol.Star, + } + ]; + } + + async getHubs?(plexParams: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise { + return [ + this.introHub, + ]; + } + + async getPromotedHubs?(plexParams: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise { + return [ + this.introHub, + ]; + } + + async getAllItems(plexParams: plexTypes.PlexSectionAllItemsParams, context: PseuplexRequestContext): Promise { + const items = arrayFromArrayOrSingle((await this.plugin.metadata.get([ + PasswordLockMetadataID.Instructions + ], { + context, + includeUnmatched: true, + metadataTransformOptions: this.metadataTransformOptions, + })).MediaContainer.Metadata); + return { + items, + offset: 0, + more: false, + totalItemCount: items.length, + }; + } +} diff --git a/src/plugins/passwordlock/lockedSection/introHub.ts b/src/plugins/passwordlock/lockedSection/introHub.ts new file mode 100644 index 0000000..e7c2580 --- /dev/null +++ b/src/plugins/passwordlock/lockedSection/introHub.ts @@ -0,0 +1,59 @@ +import * as plexTypes from '../../../plex/types'; +import { + PseuplexHub, + PseuplexHubPage, + PseuplexHubPageParams, + PseuplexHubSectionInfo, + PseuplexMetadataProvider, + PseuplexMetadataTransformOptions, + PseuplexRequestContext +} from '../../../pseuplex'; +import { PasswordLockMetadataID, PasswordLockMetadataProvider } from '../metadata'; +import { arrayFromArrayOrSingle } from '../../../utils/misc'; + +export class PasswordLockedSectionIntroHub extends PseuplexHub { + readonly path: string; + readonly title: string; + readonly metadataProvider: PasswordLockMetadataProvider; + readonly metadataTransformOptions: PseuplexMetadataTransformOptions; + section?: PseuplexHubSectionInfo | undefined; + + constructor(options: { + path: string, + title: string, + metadataProvider: PasswordLockMetadataProvider, + metadataTransformOptions: PseuplexMetadataTransformOptions, + section?: PseuplexHubSectionInfo, + }) { + super(); + this.path = options.path; + this.title = options.title; + this.metadataProvider = options.metadataProvider; + this.metadataTransformOptions = options.metadataTransformOptions; + this.section = options.section; + } + + async get(params: PseuplexHubPageParams, context: PseuplexRequestContext): Promise { + return { + hub: { + key: this.path, + title: this.title, + type: plexTypes.PlexMediaItemType.Movie, + hubIdentifier: `hub.custom.lockedpasswordsection.intro${this.section?.id != null ? `.${this.section.id}` : ''}`, + context: `hub.custom.lockedpasswordsection.intro`, + style: plexTypes.PlexHubStyle.Shelf, + promoted: true, + }, + items: arrayFromArrayOrSingle((await this.metadataProvider.get([ + PasswordLockMetadataID.Instructions + ], { + context, + includeUnmatched: true, + metadataTransformOptions: this.metadataTransformOptions, + })).MediaContainer.Metadata), + offset: 0, + more: false, + totalItemCount: 1 + }; + } +} diff --git a/src/plugins/passwordlock/metadata.ts b/src/plugins/passwordlock/metadata.ts new file mode 100644 index 0000000..15de2cc --- /dev/null +++ b/src/plugins/passwordlock/metadata.ts @@ -0,0 +1,144 @@ +import * as plexTypes from '../../plex/types'; +import { + parsePartialPseuplexMetadataID, + PseuplexMetadataChildrenPage, + PseuplexMetadataChildrenProviderParams, + PseuplexMetadataItem, + PseuplexMetadataPage, + PseuplexMetadataProvider, + PseuplexMetadataProviderParams, + PseuplexRelatedHubsParams, + qualifyPartialPseuplexMetadataID, + PseuplexRequestContext, + parsePseuplexMetadataKeyAndIDs, + PseuplexPartialMetadataIDString, + stringifyPseuplexMetadataKeyFromIDString, +} from '../../pseuplex'; +import { httpError } from '../../utils/error'; + +export enum PasswordLockMetadataID { + Instructions = 'instructions', + LoginSuccess = 'loginsuccess', +} + +const LockInstructionsItemTitle = "Enter password"; +const LockInstructionsItemSummary = +`This client has not yet been authorized for this IP address. +To log in, add this item to a new playlist, and enter the password for the server as the playlist name. +After this is done, restart the app (or refresh the browser page) and you should have access. +NOTE: Adding things to a playlist isn't possible on the new mobile app, so you may need to do this from a browser.`; + +export type PasswordLockMetadataProviderOptions = { + lockInstructionsThumbEndpoint: string, + loginSuccessEndpoint: string, + lockInstructionsItemUUID: string, + lockInstructionsTitle?: string, + lockInstructionsSummary?: string, + getLockInstructionsItemMedia?: (context: PseuplexRequestContext) => (plexTypes.PlexMedia[] | Promise | undefined); + loginSuccessItemUUID: string, + loginSuccessTitle?: string, + loginSuccessSummary?: string, +}; + +export class PasswordLockMetadataProvider implements PseuplexMetadataProvider { + readonly sourceDisplayName = "Password Lock"; + readonly sourceSlug = 'passwordlock'; + readonly options: PasswordLockMetadataProviderOptions; + + constructor(options: PasswordLockMetadataProviderOptions) { + this.options = options; + } + + async get(ids: PseuplexPartialMetadataIDString[], options: PseuplexMetadataProviderParams): Promise { + const metadatas = await Promise.all(ids.map(async (idString): Promise => { + const idParts = parsePartialPseuplexMetadataID(idString); + if(idParts.directory) { + throw httpError(400, "Invalid metadata"); + } + switch(idParts.id) { + case PasswordLockMetadataID.Instructions: { + // return password instructions metadata + const fullMetadataId = qualifyPartialPseuplexMetadataID(idString, this.sourceSlug); + const metadataKey = stringifyPseuplexMetadataKeyFromIDString(fullMetadataId); + const metadataItem = ({ + type: plexTypes.PlexMediaItemType.Movie, + key: metadataKey, + guid: `com.plexapp.agents.none://${this.options.lockInstructionsItemUUID}`, + ratingKey: fullMetadataId, + title: this.options.lockInstructionsTitle ?? LockInstructionsItemTitle, + thumb: this.options.lockInstructionsThumbEndpoint, + summary: this.options.lockInstructionsSummary ?? LockInstructionsItemSummary, + userState: false, + Pseuplex: { + isOnServer: false, + unavailable: true, + metadataIds: { + [this.sourceSlug]: idString, + }, + } + } satisfies Partial) as PseuplexMetadataItem; + // get media for instructions item + try { + metadataItem.Media = await this.options.getLockInstructionsItemMedia?.(options.context); + } catch(error) { + console.error(`Error fetching instructions item media:`); + console.error(error); + } + return metadataItem; + } + + case PasswordLockMetadataID.LoginSuccess: { + const fullMetadataId = qualifyPartialPseuplexMetadataID(idString, this.sourceSlug); + const playlist = ({ + ratingKey: fullMetadataId, + key: this.options.loginSuccessEndpoint, + guid: `com.plexapp.agents.none://${this.options.loginSuccessItemUUID}`, + type: plexTypes.PlexMediaItemType.Playlist, + title: this.options.loginSuccessTitle ?? "Success!", + summary: this.options.loginSuccessSummary ?? "You have successfully logged in", + smart: false, + playlistType: plexTypes.PlexPlaylistType.Video, + composite: undefined!, // TODO add success image + duration: 7762000, + leafCount: 1, + addedAt: 1755571432, + updatedAt: 1755571432, + } satisfies plexTypes.PlexPlaylist) as any as PseuplexMetadataItem; + playlist.Pseuplex = { + isOnServer: false, + unavailable: true, + metadataIds: { + [this.sourceSlug]: idString, + } + }; + return playlist; + } + } + throw httpError(404, `No matching metadata`); + })); + return { + MediaContainer: { + offset: 0, + size: metadatas.length, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, + Metadata: metadatas, + } + }; + } + + async getChildren(id: PseuplexPartialMetadataIDString, options: PseuplexMetadataChildrenProviderParams): Promise { + throw httpError(500, "No children can be fetched from this provider"); + } + + async getRelatedHubs(id: PseuplexPartialMetadataIDString, options: PseuplexRelatedHubsParams): Promise { + return { + MediaContainer: { + offset: 0, + size: 0, + totalSize: 0, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, + Hub: [] + } + }; + } +} diff --git a/src/plugins/passwordlock/plugindef.ts b/src/plugins/passwordlock/plugindef.ts new file mode 100644 index 0000000..72d00e3 --- /dev/null +++ b/src/plugins/passwordlock/plugindef.ts @@ -0,0 +1,18 @@ +import { + PseuplexApp, + PseuplexMetadataProvider, + PseuplexPlugin, + PseuplexRequestContext +} from '../../pseuplex'; +import { + PasswordLockPluginConfig, +} from './config'; +import { PasswordLockMetadataProvider } from './metadata'; + +export interface PasswordLockPluginDef extends PseuplexPlugin { + readonly app: PseuplexApp; + readonly metadata: PasswordLockMetadataProvider; + + get config(): PasswordLockPluginConfig; + get basePath(): string; +} diff --git a/src/plugins/requests/config.ts b/src/plugins/requests/config.ts index cf99a64..c76592d 100644 --- a/src/plugins/requests/config.ts +++ b/src/plugins/requests/config.ts @@ -4,6 +4,7 @@ type RequestsFlags = { requests?: { enabled?: boolean; requestableSeasons?: boolean; + partiallyAvailableOverlay?: boolean; }, }; type RequestsPerUserPluginConfig = { diff --git a/src/plugins/requests/handler.ts b/src/plugins/requests/handler.ts index 6134dcb..002ef7c 100644 --- a/src/plugins/requests/handler.ts +++ b/src/plugins/requests/handler.ts @@ -18,6 +18,8 @@ import { PseuplexRequestContext, PseuplexPartialMetadataIDsFromKey, PseuplexMetadataChildrenPage, + stringifyPseuplexMetadataKeyFromIDString, + PseuplexPartialMetadataIDString, } from '../../pseuplex'; import * as extPlexTransform from '../../pseuplex/externalplex/transform'; import { @@ -39,6 +41,7 @@ import { Logger } from '../../logging'; import { RequestsPluginDef } from './plugindef'; import { httpError } from '../../utils/error'; import { + arrayFromArrayOrSingle, findInArrayOrSingle, firstOrSingle, forArrayOrSingle, @@ -102,10 +105,10 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { season?: number, requestProvider: RequestsProvider, plexMetadataClient: PlexClient, - authContext?: plexTypes.PlexAuthContext, moviesLibraryId?: string | number, tvShowsLibraryId?: string | number, useLibraryMetadataPath?: boolean, + context: PseuplexRequestContext, }): Promise { // determine properties and get metadata let requestActionTitle: string; @@ -177,23 +180,18 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { return null; } // create hook metadata + const children = (options.mediaType == plexTypes.PlexMediaItemTypeNumeric.Show); + const relativePath = children ? '/children' : undefined; + const metadataId = reqsTransform.createRequestMetadataId({ + requestProviderSlug: options.requestProvider.slug, + mediaType: guidParts.type as plexTypes.PlexMediaItemType, + plexId: guidParts.id, + season: options.season + }); const requestMetadataItem: WithOptionalPropsRecursive = { guid: options.guid, - key: reqsTransform.createRequestItemMetadataKey({ - metadataBasePath: options.useLibraryMetadataPath ? '/library/metadata' : this.basePath, - qualifiedMetadataId: options.useLibraryMetadataPath ?? false, - requestProviderSlug: options.requestProvider.slug, - mediaType: guidParts.type as plexTypes.PlexMediaItemType, - plexId: guidParts.id, - season: options.season, - children: (options.mediaType == plexTypes.PlexMediaItemTypeNumeric.Show) - }), - ratingKey: reqsTransform.createRequestFullMetadataId({ - requestProviderSlug: options.requestProvider.slug, - mediaType: guidParts.type as plexTypes.PlexMediaItemType, - plexId: guidParts.id, - season: options.season - }), + key: stringifyPseuplexMetadataKeyFromIDString(metadataId, relativePath), + ratingKey: metadataId, type: options.mediaType == plexTypes.PlexMediaItemTypeNumeric.Show ? // TV shows should display as movies so that the "request" text shows up plexTypes.PlexMediaItemType.Movie : plexTypes.PlexMediaItemNumericToType[options.mediaType], @@ -206,11 +204,11 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { librarySectionKey: `/library/sections/${librarySectionID}`, childCount: (options.mediaType == plexTypes.PlexMediaItemTypeNumeric.Show) ? 0 : undefined, //metadataItem.childCount, Media: [{ - id: 1, + id: 99999999999, videoResolution: requestActionTitle, Part: [ { - id: 1 + id: 99999999998, } ] }] @@ -227,10 +225,6 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { includeUnmatched?: boolean; // Indicates whether to transform the keys of items matched to plex server items back to their plugin custom keys transformMatchKeys?: boolean; - // The base path to use when transforming metadata keys - metadataBasePath?: string; - // Whether to use full metadata IDs in the transformed metadata keys - qualifiedMetadataIds?: boolean; // Whether to throw a 404 error if includeUnmatched is false and no matches were found throw404OnNoMatches?: boolean; }): Promise { @@ -254,14 +248,10 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { throw httpError(400, `Unknown media type ${id.mediaType}`); } // create options for transforming metadata - const fullIdString = reqsTransform.createRequestFullMetadataId(id); - const metadataBasePath = options.metadataBasePath || this.basePath; - const qualifiedMetadataIds = options.qualifiedMetadataIds ?? false; + const fullIdString = reqsTransform.createRequestMetadataId(id); const childrenTransformOpts = options.children ? { parentRatingKey: fullIdString, - parentKey: (qualifiedMetadataIds ? - `${metadataBasePath}/${fullIdString}` - : `${metadataBasePath}/${reqsTransform.createRequestPartialMetadataId(id)}`), + parentKey: stringifyPseuplexMetadataKeyFromIDString(fullIdString) } : undefined; // check if item already exists on the plex server const guid = `plex://${id.mediaType}/${id.plexId}`; @@ -327,21 +317,19 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { } await this.addRequestableSeasons(plexDisplayedPage, { ...childrenTransformOpts!, - metadataBasePath, - qualifiedMetadataIds, requestsProvider: reqProvider, plexId: id.plexId, plexType: id.mediaType, plexParams: options.plexParams as (plexTypes.PlexMetadataChildrenPageParams | undefined), transformMatchKeys: options.transformMatchKeys, + partiallyAvailableOverlay: this.plugin.partiallyAvailableOverlayEnabledForContext(context), + overlayedImageEndpoint: this.plugin.app.overlayedImageEndpoint, }, context); } else { // transform metadata item key since not getting children if(options.transformMatchKeys) { forArrayOrSingle(plexDisplayedPage.MediaContainer.Metadata, (metadataItem: PseuplexMetadataItem) => { reqsTransform.setMetadataItemKeyToRequestKey(metadataItem, { - metadataBasePath, - qualifiedMetadataIds, requestProviderSlug: reqProvider.slug, // since the item is on the server, we want to leave the original ratingKey, // so that the plex server items will be fetched directly if any additional request is made @@ -432,8 +420,6 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { resData.MediaContainer.identifier = plexTypes.PlexPluginIdentifier.PlexAppLibrary; resData.MediaContainer.Metadata = transformArrayOrSingle(resData.MediaContainer.Metadata, (metadataItem) => { return extPlexTransform.transformExternalPlexMetadata(metadataItem, this.plexMetadataClient.serverURL, context, { - metadataBasePath: `/library/metadata`, - qualifiedMetadataIds: true, includeMetadataUnavailability: this.plugin.app.sendsMetadataUnavailability, }); }); @@ -452,14 +438,12 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { try { requests = requestingPlexItem ? (await reqProvider.getRequestsForPlexItem(requestingPlexItem, context)) : []; } catch(error) { - console.error(`Error fetching requests for ${reqsTransform.createRequestFullMetadataId(id)} :`); + console.error(`Error fetching requests for ${reqsTransform.createRequestMetadataId(id)} :`); console.error(error); } forArrayOrSingle(childrenContainer.Metadata, (metadataItem) => { reqsTransform.transformRequestableChildMetadata(metadataItem, { ...childrenTransformOpts!, - metadataBasePath, - qualifiedMetadataIds, requestProviderSlug: reqProvider.slug, transformRatingKey: true, overlayedImageEndpoint: this.plugin.app.overlayedImageEndpoint, @@ -483,8 +467,6 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { metadataItem.title = `Request • ${metadataItem.title}`; } reqsTransform.setMetadataItemKeyToRequestKey(metadataItem, { - metadataBasePath, - qualifiedMetadataIds, requestProviderSlug: reqProvider.slug, children: (itemType == plexTypes.PlexMediaItemType.TVShow), transformRatingKey: true, @@ -496,7 +478,7 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { - async get(ids: string[], options: PseuplexMetadataProviderParams): Promise { + async get(ids: PseuplexPartialMetadataIDString[], options: PseuplexMetadataProviderParams): Promise { const metadataPages = await Promise.all(ids.map(async (id) => { const idParts = reqsTransform.parsePartialRequestMetadataId(id); const metadataPage = await this.handlePlexRequest(idParts, { @@ -505,8 +487,6 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { plexParams: options.plexParams, includeUnmatched: options.includeUnmatched, transformMatchKeys: options.transformMatchKeys, - metadataBasePath: options.metadataBasePath, - qualifiedMetadataIds: options.qualifiedMetadataIds, }); return metadataPage; })); @@ -542,8 +522,6 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { plexParams: options.plexParams, context: options.context, transformMatchKeys: false, - metadataBasePath: options.metadataBasePath, - qualifiedMetadataIds: options.qualifiedMetadataIds, }); } @@ -566,10 +544,27 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { parentKey: string, parentRatingKey: string, requestsProvider: RequestsProvider, - metadataBasePath: string, - qualifiedMetadataIds: boolean, + partiallyAvailableOverlay: boolean | undefined, + overlayedImageEndpoint?: string, }, context: PseuplexRequestContext) { + const metadatas: PseuplexMetadataItem[] = arrayFromArrayOrSingle(resData.MediaContainer.Metadata); + resData.MediaContainer.Metadata = metadatas; + // transform server item keys if needed + if(options.transformMatchKeys) { + for(const metadataItem of metadatas) { + // child exists on the server, so return that item + reqsTransform.setMetadataItemKeyToRequestKey(metadataItem, { + requestProviderSlug: options.requestsProvider.slug, + // don't show children of children + children: false, + // since the item is on the server, we want to leave the original ratingKey, + // so that the plex server items will be fetched directly if any additional request is made + transformRatingKey: false, + }); + } + } // fetch other children (seasons) from plex metadata provider + // TODO cache this data const discoverMetadataPageTask = this.plexMetadataClient.getMetadataChildren(options.plexId, options.plexParams as plexTypes.PlexMetadataChildrenPageParams); // fetch requests let requests: RequestInfo[] | undefined; @@ -582,49 +577,46 @@ export class PlexRequestsHandler implements PseuplexMetadataProvider { } // wait for plex metadata const discoverMetadataPage = await discoverMetadataPageTask; - this.plexIdToInfoCache?.cacheMetadataItems(discoverMetadataPage.MediaContainer.Metadata); + const discoverMetadatas = arrayFromArrayOrSingle(discoverMetadataPage.MediaContainer.Metadata); + this.plexIdToInfoCache?.cacheMetadataItems(discoverMetadatas); + // if there are more server items than discover items, this server is likely using a different tv show layout and we shouldn't apply this + if(metadatas.length > discoverMetadatas.length) { + return; + } // transform requestable children - resData.MediaContainer.Metadata = transformArrayOrSingle(discoverMetadataPage.MediaContainer.Metadata, (metadataItem: PseuplexMetadataItem): PseuplexMetadataItem => { + const partiallyAvailableOverlayEnabled = options.partiallyAvailableOverlay ?? true; + forArrayOrSingle(discoverMetadataPage.MediaContainer.Metadata, (discoverItem: PseuplexMetadataItem, index: number) => { // find matching child from plex server - const matchingItem = metadataItem.index != null ? + const serverItem = discoverItem.index != null ? findInArrayOrSingle(resData.MediaContainer.Metadata, (cmpMetadataItem) => { - return (cmpMetadataItem.index == metadataItem.index); + return (cmpMetadataItem.index == discoverItem.index); }) : undefined; - if(matchingItem) { - // child exists on the server, so return that item - if(options.transformMatchKeys) { - reqsTransform.setMetadataItemKeyToRequestKey(matchingItem, { - metadataBasePath: options.metadataBasePath, - qualifiedMetadataIds: options.qualifiedMetadataIds, - requestProviderSlug: options.requestsProvider.slug, - // don't show children of children - children: false, - // since the item is on the server, we want to leave the original ratingKey, - // so that the plex server items will be fetched directly if any additional request is made - transformRatingKey: false, + if(serverItem) { + // add partially available overlay if needed + if(partiallyAvailableOverlayEnabled && options.overlayedImageEndpoint) { + reqsTransform.addPartiallyAvailableBannerIfNeeded(serverItem, discoverItem, { + overlayedImageEndpoint: options.overlayedImageEndpoint, }); } - return matchingItem; } else { // child doesn't exist on the server - metadataItem.Pseuplex = { + discoverItem.Pseuplex = { isOnServer: false, unavailable: true, metadataIds: {}, }; - reqsTransform.transformRequestableChildMetadata(metadataItem, { - metadataBasePath: options.metadataBasePath, - qualifiedMetadataIds: options.qualifiedMetadataIds, + reqsTransform.transformRequestableChildMetadata(discoverItem, { requestProviderSlug: options.requestsProvider.slug, // don't show children of children children: false, // item isn't on the server, so use the "request" ratingKey transformRatingKey: true, overlayedImageEndpoint: this.plugin.app.overlayedImageEndpoint, - requested: requests?.find((r) => (r.seasons?.find((s) => s == metadataItem.index) != null)) != null, + requested: requests?.find((r) => (r.seasons?.find((s) => s == discoverItem.index) != null)) != null, }); - return metadataItem; + // insert the item + metadatas.splice((discoverItem.index ?? index), 0, discoverItem); } }); resData.MediaContainer.size = discoverMetadataPage.MediaContainer.size; diff --git a/src/plugins/requests/index.ts b/src/plugins/requests/index.ts index 7ea05b5..bd0af09 100644 --- a/src/plugins/requests/index.ts +++ b/src/plugins/requests/index.ts @@ -1,25 +1,18 @@ import express from 'express'; import * as plexTypes from '../../plex/types'; -import * as plexServerAPI from '../../plex/api'; import { parsePlexMetadataGuid } from '../../plex/metadataidentifier'; -import { - IncomingPlexAPIRequest, -} from '../../plex/requesthandling'; -import { PlexServerAccountInfo } from '../../plex/accounts'; import { PseuplexApp, - PseuplexConfigBase, - PseuplexMetadataChildrenPage, PseuplexMetadataProvider, PseuplexMetadataSource, PseuplexPlugin, PseuplexPluginClass, PseuplexReadOnlyResponseFilters, PseuplexRequestContext, - PseuplexResponseFilterContext + PseuplexRouterApp, + stringifyPseuplexMetadataKeyFromIDString } from '../../pseuplex'; -import * as extPlexTransform from '../../pseuplex/externalplex/transform'; import { parseStringQueryParam, parseIntQueryParam, @@ -32,10 +25,6 @@ import { firstOrSingle, transformArrayOrSingle } from '../../utils/misc'; -import { - RequestsProvider, - RequestsProviders, -} from './provider'; import OverseerrRequestsProvider from './providers/overseerr'; import { PlexRequestsHandler } from './handler'; import * as reqsTransform from './transform'; @@ -77,14 +66,14 @@ export default (class RequestsPlugin implements RequestsPluginDef, PseuplexPlugi responseFilters?: PseuplexReadOnlyResponseFilters = { findGuidInLibrary: async (resData, filterContext) => { - const plexAuthContext = filterContext.userReq.plex.authContext; - const plexUserToken = plexAuthContext?.['X-Plex-Token']; + const reqContext = this.app.contextForRequest(filterContext.userReq); + const plexUserToken = reqContext.plexAuthContext?.['X-Plex-Token']; if(!plexUserToken) { return; } const plexUserInfo = filterContext.userReq.plex.userInfo; // check if requests are enabled - const requestsEnabled = this.config.perUser[plexUserInfo.email]?.requests?.enabled ?? this.config.requests?.enabled; + const requestsEnabled = this.requestsEnabledForContext(reqContext); if(!requestsEnabled) { return; } @@ -115,7 +104,7 @@ export default (class RequestsPlugin implements RequestsPluginDef, PseuplexPlugi if(guidParts?.protocol == plexTypes.PlexMetadataGuidProtocol.Plex && guidParts.type) { mediaType = plexTypes.PlexMediaItemTypeToNumeric[guidParts.type]; } else { - console.error(`No media type specified in request`); + console.error(`Invalid plex guid ${guid}`); return; } } @@ -126,7 +115,7 @@ export default (class RequestsPlugin implements RequestsPluginDef, PseuplexPlugi season, requestProvider, plexMetadataClient: this.app.plexMetadataClient, - authContext: plexAuthContext, + context: reqContext, moviesLibraryId: this.config.plex.requestedMoviesLibraryId, tvShowsLibraryId: this.config.plex.requestedTVShowsLibraryId, useLibraryMetadataPath: this.app.alwaysUseLibraryMetadataPath, @@ -136,116 +125,108 @@ export default (class RequestsPlugin implements RequestsPluginDef, PseuplexPlugi } resData.MediaContainer.Metadata = pushToArray(resData.MediaContainer.Metadata, metadataItem); resData.MediaContainer.size += 1; + if(resData.MediaContainer.totalSize != null) { + resData.MediaContainer.totalSize += 1; + } }, metadataChildren: async (resData, filterContext) => { const reqContext = this.app.contextForRequest(filterContext.userReq); + const plexParams = plexTypes.parsePlexMetadataChildrenPageParams(filterContext.userReq); const plexUserToken = filterContext.userReq.plex.authContext?.['X-Plex-Token']; if(!plexUserToken) { return; } const plexUserInfo = filterContext.userReq.plex.userInfo; // get prefs - const config = this.config; - const userPrefs = config.perUser[plexUserInfo.email]; - const requestsEnabled = userPrefs?.requests?.enabled ?? config.requests?.enabled; + const requestsEnabled = this.requestsEnabledForContext(reqContext); if(!requestsEnabled) { return; } - const showRequestableSeasons = userPrefs?.requests?.requestableSeasons ?? config.requests?.requestableSeasons; + const showRequestableSeasons = this.requestableSeasonsEnabledForContext(reqContext); + const partiallyAvailableOverlay = this.partiallyAvailableOverlayEnabledForContext(reqContext); const requestsProvider = await this.requestsHandler.getRequestsProviderForPlexUser(plexUserToken, plexUserInfo); // add requestable seasons if able - if(showRequestableSeasons && !filterContext.metadataId.source && requestsProvider) { + if((showRequestableSeasons || partiallyAvailableOverlay) && !filterContext.metadataId.source && requestsProvider) { await Promise.all(filterContext.previousFilterPromises ?? []); // get guid for id const plexGuid = await this.app.plexServerIdToGuidCache.getOrFetch(filterContext.metadataId.id); const plexGuidParts = plexGuid ? parsePlexMetadataGuid(plexGuid) : null; - if(plexGuidParts + if(plexGuidParts?.protocol == plexTypes.PlexMetadataGuidProtocol.Plex && plexGuidParts.type == plexTypes.PlexMediaItemType.TVShow - && plexGuidParts.protocol == plexTypes.PlexMetadataGuidProtocol.Plex + && plexGuidParts.id ) { - const fullIdString = reqsTransform.createRequestFullMetadataId({ - mediaType: plexGuidParts.type as plexTypes.PlexMediaItemType, - plexId: plexGuidParts.id, - requestProviderSlug: requestsProvider.slug, - }); - await this.requestsHandler.addRequestableSeasons(resData, { - plexId: plexGuidParts.id, - plexType: plexGuidParts.type, - plexParams: filterContext.userReq.plex.requestParams, - transformMatchKeys: false, - metadataBasePath: '/library/metadata', - qualifiedMetadataIds: true, - requestsProvider, - parentKey: `/library/metadata/${fullIdString}`, - parentRatingKey: fullIdString, - }, reqContext); + // add requestable seasons if needed + if(showRequestableSeasons) { + const fullIdString = reqsTransform.createRequestMetadataId({ + mediaType: plexGuidParts.type as plexTypes.PlexMediaItemType, + plexId: plexGuidParts.id, + requestProviderSlug: requestsProvider.slug, + }); + const parentMetadataKey = stringifyPseuplexMetadataKeyFromIDString(fullIdString); + await this.requestsHandler.addRequestableSeasons(resData, { + plexId: plexGuidParts.id, + plexType: plexGuidParts.type, + plexParams, + transformMatchKeys: false, + requestsProvider, + parentKey: parentMetadataKey, + parentRatingKey: fullIdString, + partiallyAvailableOverlay: partiallyAvailableOverlay, + overlayedImageEndpoint: this.app.overlayedImageEndpoint, + }, reqContext); + } + else if(partiallyAvailableOverlay && this.app.overlayedImageEndpoint) { + // fetch other children (seasons) from plex metadata provider + // TODO cache this data + const discoverMetadataPage= await this.app.plexMetadataClient.getMetadataChildren(plexGuidParts.id, plexParams); + // add partially available overlays if needed + console.log(`adding overlays for ${(resData.MediaContainer.Metadata as any).length} seasons`); + forArrayOrSingle(resData.MediaContainer.Metadata, (metadataItem) => { + // find matching child from plex server + const discoverItem = metadataItem.index != null ? + findInArrayOrSingle(discoverMetadataPage.MediaContainer.Metadata, (cmpMetadataItem) => { + return (cmpMetadataItem.index == metadataItem.index); + }) + : undefined; + if(!discoverItem) { + console.log(`skipped`); + return; + } + console.log("adding overlay"); + // add partially available overlay if needed + reqsTransform.addPartiallyAvailableBannerIfNeeded(metadataItem, discoverItem, { + overlayedImageEndpoint: this.app.overlayedImageEndpoint! + }); + }); + } } } }, } - defineRoutes(router: express.Express) { - // handle different paths for a plex request - for(const endpoint of [ - `${this.requestsHandler.basePath}/:providerSlug/:mediaType/:plexId`, - `${this.requestsHandler.basePath}/:providerSlug/:mediaType/:plexId/children`, - `${this.requestsHandler.basePath}/:providerSlug/:mediaType/:plexId/season/:season`, - `${this.requestsHandler.basePath}/:providerSlug/:mediaType/:plexId/season/:season/children` - ]) { - const children = endpoint.endsWith(reqsTransform.ChildrenRelativePath); - - // get metadata for requested item - router.get(endpoint, [ - this.app.middlewares.plexAuthentication, - this.app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res) => { - // get request properties - const { providerSlug, mediaType, plexId } = req.params; - const season = parseIntQueryParam(req.params.season); - const plexParams = req.plex.requestParams; - const context = this.app.contextForRequest(req); - // handle request - const resData = await this.requestsHandler.handlePlexRequest({ - requestProviderSlug: providerSlug, - mediaType: mediaType as plexTypes.PlexMediaItemType, - plexId, - season - }, { - children, - plexParams, - context, - throw404OnNoMatches: true, - transformMatchKeys: !children, - }); - // cache metadata access if needed - if(this.app.pluginMetadataAccessCache) { - const metadataId = reqsTransform.createRequestPartialMetadataId({ - requestProviderSlug: providerSlug, - mediaType: mediaType as plexTypes.PlexMediaItemType, - plexId, - season, - }); - let metadataKey = req.path; - if(children) { - if(metadataKey.endsWith('/')) { - metadataKey = metadataKey.slice(0, metadataKey.length-1); - } - if(metadataKey.endsWith(reqsTransform.ChildrenRelativePath)) { - metadataKey = metadataKey.slice(0, metadataKey.length - reqsTransform.ChildrenRelativePath.length); - } - } - this.app.pluginMetadataAccessCache.cachePluginMetadataAccessIfNeeded(this.requestsHandler, metadataId, metadataKey, resData.MediaContainer.Metadata, context); - } - // send unavailable notification(s) if needed - this.app.sendMetadataUnavailableNotificationsIfNeeded(resData, plexParams, context); - return resData; - }) - ]); - - if(!children) { - // TODO handle /related routes - } - } + defineRoutes(router: PseuplexRouterApp) { + // + } + + + + requestsEnabledForContext(context: PseuplexRequestContext) { + const cfg = this.config; + const userPrefs = cfg.perUser[context.plexUserInfo.email]; + return userPrefs?.requests?.enabled ?? cfg.requests?.enabled; + } + + requestableSeasonsEnabledForContext(context: PseuplexRequestContext) { + const cfg = this.config; + const userPrefs = cfg.perUser[context.plexUserInfo.email]; + return userPrefs?.requests?.requestableSeasons ?? cfg.requests?.requestableSeasons; + } + + partiallyAvailableOverlayEnabledForContext(context: PseuplexRequestContext) { + const cfg = this.config; + const userPrefs = cfg.perUser[context.plexUserInfo.email]; + return userPrefs?.requests?.partiallyAvailableOverlay ?? cfg.requests?.partiallyAvailableOverlay; } } satisfies PseuplexPluginClass); diff --git a/src/plugins/requests/plugindef.ts b/src/plugins/requests/plugindef.ts index 228118e..239b385 100644 --- a/src/plugins/requests/plugindef.ts +++ b/src/plugins/requests/plugindef.ts @@ -1,6 +1,7 @@ import { PseuplexApp, PseuplexPlugin, + PseuplexRequestContext, } from '../../pseuplex'; import { RequestsPluginConfig, @@ -9,4 +10,8 @@ import { export interface RequestsPluginDef extends PseuplexPlugin { app: PseuplexApp; config: RequestsPluginConfig; + + requestsEnabledForContext(context: PseuplexRequestContext): boolean | undefined; + requestableSeasonsEnabledForContext(context: PseuplexRequestContext): boolean | undefined; + partiallyAvailableOverlayEnabledForContext(context: PseuplexRequestContext): boolean | undefined; } diff --git a/src/plugins/requests/transform.ts b/src/plugins/requests/transform.ts index e56b5f1..61cfc93 100644 --- a/src/plugins/requests/transform.ts +++ b/src/plugins/requests/transform.ts @@ -1,11 +1,13 @@ import * as plexTypes from '../../plex/types'; -import { parsePlexMetadataGuidOrThrow } from '../../plex/metadataidentifier'; +import { parsePlexMetadataGuid } from '../../plex/metadataidentifier'; import { PseuplexMetadataSource, PseuplexPartialMetadataIDString, - stringifyMetadataID, - stringifyPartialMetadataID, - parsePartialMetadataID + stringifyPseuplexMetadataID, + stringifyPartialPseuplexMetadataID, + parsePartialPseuplexMetadataID, + parsePseuplexMetadataKey, + stringifyPseuplexMetadataKeyFromIDString } from '../../pseuplex'; export const ChildrenRelativePath = '/children'; @@ -64,8 +66,8 @@ const parseRequestMetadataItemIdComponent = (idString: string): RequestMetadataI }; }; -export const createRequestFullMetadataId = (idParts: RequestPartialMetadataIDParts) => { - return stringifyMetadataID({ +export const createRequestMetadataId = (idParts: RequestPartialMetadataIDParts) => { + return stringifyPseuplexMetadataID({ source: PseuplexMetadataSource.Request, directory: idParts.requestProviderSlug, id: createRequestMetadataItemIdComponent(idParts), @@ -73,32 +75,12 @@ export const createRequestFullMetadataId = (idParts: RequestPartialMetadataIDPar }; export const createRequestPartialMetadataId = (idParts: RequestPartialMetadataIDParts) => { - return stringifyPartialMetadataID({ + return stringifyPartialPseuplexMetadataID({ directory: idParts.requestProviderSlug, id: createRequestMetadataItemIdComponent(idParts) }); }; -export const createRequestItemMetadataKey = (options: { - metadataBasePath: string, - qualifiedMetadataId: boolean, - requestProviderSlug: string, - mediaType: plexTypes.PlexMediaItemType, - plexId: string, - season?: number, - children?: boolean, -}): string => { - if(options.qualifiedMetadataId) { - const metadataId = createRequestFullMetadataId(options); - return `${options.metadataBasePath}/${metadataId}` - + (options.children ? ChildrenRelativePath : ''); - } else { - return `${options.metadataBasePath}/${options.requestProviderSlug}/${options.mediaType}/${options.plexId}` - + (options.season != null ? `${SeasonRelativePath}${options.season}` : '') - + (options.children ? ChildrenRelativePath : ''); - } -} - export const parseUnqualifiedRequestItemMetadataKey = (metadataKey: string, basePath: string, warnOnFailure: boolean = true): RequestMetadataKeyParts | null => { if(!metadataKey) { if(warnOnFailure) { @@ -188,7 +170,7 @@ export const parseUnqualifiedRequestItemMetadataKey = (metadataKey: string, base }; export const parsePartialRequestMetadataId = (metadataId: PseuplexPartialMetadataIDString): RequestPartialMetadataIDParts => { - const metadataIdParts = parsePartialMetadataID(metadataId); + const metadataIdParts = parsePartialPseuplexMetadataID(metadataId); if(!metadataIdParts.directory) { throw new Error(`Missing request provider slug on metadata id ${metadataId}`); } @@ -200,12 +182,10 @@ export const parsePartialRequestMetadataId = (metadataId: PseuplexPartialMetadat }; export type TransformRequestMetadataOptions = { - metadataBasePath: string, parentKey?: string, parentRatingKey?: string, requestProviderSlug: string, children?: boolean, - qualifiedMetadataIds: boolean; transformRatingKey: boolean; }; @@ -216,24 +196,29 @@ export const setMetadataItemKeyToRequestKey = (metadataItem: plexTypes.PlexMetad itemGuid = metadataItem.parentGuid; season = metadataItem.index; } - const guidParts = parsePlexMetadataGuidOrThrow(itemGuid!); - const children = opts?.children ?? metadataItem.key.endsWith(ChildrenRelativePath); - metadataItem.key = createRequestItemMetadataKey({ - metadataBasePath: opts.metadataBasePath, - qualifiedMetadataId: opts.qualifiedMetadataIds, + const guidParts = parsePlexMetadataGuid(itemGuid!); + if(!guidParts) { + console.error("Unable to set metadata item key to request key"); + return; + } + const metadataItemKey = parsePseuplexMetadataKey(metadataItem.key); + let relativePath = metadataItemKey?.relativePath; + if(opts?.children != null) { + if(opts.children) { + relativePath = '/children'; + } else if(relativePath == '/children') { + relativePath = undefined; + } + } + const metadataId = createRequestMetadataId({ requestProviderSlug: opts.requestProviderSlug, mediaType: guidParts.type as plexTypes.PlexMediaItemType, plexId: guidParts.id, season, - children }); + metadataItem.key = stringifyPseuplexMetadataKeyFromIDString(metadataId, relativePath); if(opts.transformRatingKey) { - metadataItem.ratingKey = createRequestFullMetadataId({ - requestProviderSlug: opts.requestProviderSlug, - mediaType: guidParts.type as plexTypes.PlexMediaItemType, - plexId: guidParts.id, - season, - }); + metadataItem.ratingKey = metadataId; } if(opts.parentKey) { metadataItem.parentKey = opts.parentKey; @@ -259,3 +244,18 @@ export const transformRequestableChildMetadata = (metadataItem: plexTypes.PlexMe } } }; + +export const addPartiallyAvailableBannerIfNeeded = (serverMetadataItem: plexTypes.PlexMetadataItem, discoverMetadataItem: plexTypes.PlexMetadataItem, opts: { + overlayedImageEndpoint: string, +}): boolean => { + const serverChildCount = serverMetadataItem.childCount ?? serverMetadataItem.leafCount; + const discoverChildCount = discoverMetadataItem.childCount ?? discoverMetadataItem.leafCount; + if(serverChildCount && discoverChildCount && serverChildCount < discoverChildCount) { + const thumb = serverMetadataItem.thumb || discoverMetadataItem.thumb; + if(thumb) { + serverMetadataItem.thumb = `${opts.overlayedImageEndpoint}?overlay=partiallyAvailable&url=${encodeURIComponent(thumb)}`; + } + return true; + } + return false; +}; diff --git a/src/plugins/template/index.ts b/src/plugins/template/index.ts index 269248c..83727c4 100644 --- a/src/plugins/template/index.ts +++ b/src/plugins/template/index.ts @@ -1,5 +1,3 @@ - -import express from 'express'; import * as plexTypes from '../../plex/types'; import { IncomingPlexAPIRequest } from '../../plex/requesthandling'; import { @@ -7,7 +5,8 @@ import { PseuplexMetadataProvider, PseuplexPlugin, PseuplexPluginClass, - PseuplexReadOnlyResponseFilters + PseuplexReadOnlyResponseFilters, + PseuplexRouterApp, } from '../../pseuplex'; //import { TemplateMetadataProvider } from './metadata'; // uncomment if defining a custom metadata provider import { TemplatePluginConfig } from './config'; @@ -52,7 +51,7 @@ export default (class TemplatePlugin implements TemplatePluginDef, PseuplexPlugi // TODO define any functions to modify plex server responses } - defineRoutes(router: express.Express) { + defineRoutes(router: PseuplexRouterApp) { // TODO define any custom routes } diff --git a/src/plugins/template/transform.ts b/src/plugins/template/transform.ts index dbbc872..4fc4708 100644 --- a/src/plugins/template/transform.ts +++ b/src/plugins/template/transform.ts @@ -6,14 +6,15 @@ import { PseuplexMetadataTransformOptions, PseuplexPartialMetadataIDString, PseuplexRequestContext, - stringifyMetadataID, - stringifyPartialMetadataID + stringifyPseuplexMetadataID, + stringifyPartialPseuplexMetadataID, + stringifyPseuplexMetadataKeyFromIDString } from '../../pseuplex'; import { combinePathSegments } from '../../utils/misc'; export const partialMetadataIdFromTemplateItem = (item: any): PseuplexPartialMetadataIDString => { // TODO create a partial metadata ID from an item from your source - return stringifyPartialMetadataID({ + return stringifyPartialPseuplexMetadataID({ directory: item.type, id: item.id, }); @@ -21,7 +22,7 @@ export const partialMetadataIdFromTemplateItem = (item: any): PseuplexPartialMet export const fullMetadataIdFromTemplateItem = (item: any, opts?: {asUrl?: boolean}): PseuplexMetadataIDString => { // TODO create a full metadata ID from an item from your source - return stringifyMetadataID({ + return stringifyPseuplexMetadataID({ isURL: opts?.asUrl, source: 'template', //PseuplexMetadataSource.Template, directory: item.type, @@ -35,7 +36,7 @@ export const templateItemToPlexMetadata = (item: any, context: PseuplexRequestCo const fullMetadataId = fullMetadataIdFromTemplateItem(item, {asUrl:false}); return { // guid: fullMetadataIdFromTemplateItem(item, {asUrl:true}), - key: combinePathSegments(options.metadataBasePath, options.qualifiedMetadataIds ? fullMetadataId : partialMetadataId), + key: stringifyPseuplexMetadataKeyFromIDString(fullMetadataId), ratingKey: fullMetadataId, type: plexTypes.PlexMediaItemType.Movie, //slug: item.slug, diff --git a/src/pseuplex/app.ts b/src/pseuplex/app.ts index 0b4cc82..513c6af 100644 --- a/src/pseuplex/app.ts +++ b/src/pseuplex/app.ts @@ -3,7 +3,7 @@ import https from 'https'; import stream from 'stream'; import qs from 'querystring'; import express from 'express'; -import httpolyglot from 'httpolyglot'; +import * as httpolyglot from '@httptoolkit/httpolyglot'; import sharp from 'sharp'; import HttpProxyServer from 'http-proxy'; import * as plexTypes from '../plex/types'; @@ -18,13 +18,8 @@ import { createPlexServerIdToGuidCache, } from '../plex/metadata'; import { - parseMetadataIDFromKey, parsePlexMetadataGuid, } from '../plex/metadataidentifier'; -import { - PseuplexMetadataAccessCache, - PseuplexMetadataAccessCacheOptions -} from './metadataAccessCache'; import { plexApiProxy, PlexAPIProxyFilters, @@ -32,9 +27,13 @@ import { PlexProxyOptions, } from '../plex/proxy'; import { + createNoPlexTransientTokensMiddleware, createPlexAuthenticationMiddleware, + createPlexServerOwnerOnlyMiddleware, handlePlexAPIRequest, IncomingPlexAPIRequest, + IncomingPlexAPIRequestMixin, + IncomingPlexHttpRequest, PlexAPIRequestHandler, PlexAPIRequestHandlerOptions, PlexAuthedRequestHandler @@ -59,15 +58,28 @@ import { PseuplexPossiblyConfirmedClientWebSocketInfo, PseuplexEventSourceSubscriber, } from './types'; -import { PseuplexConfigBase } from './configbase'; +import type { PseuplexConfigBase } from './configbase'; +import { + PseuplexMetadataAccessCache, + PseuplexMetadataAccessCacheOptions +} from './metadataAccessCache'; import { - stringifyPartialMetadataID, - stringifyMetadataID, + stringifyPartialPseuplexMetadataID, + stringifyPseuplexMetadataID, PseuplexMetadataIDParts, - parseMetadataID, + parsePseuplexMetadataID, + parsePseuplexMetadataKeyAndID, + parsePseuplexMetadataIDFromItem, + parsePseuplexMetadataIDStringFromItem, + parsePseuplexMetadataKeyAndIDs, + parsePseuplexPluralMetadataKey, + parsePseuplexMetadataKey, + unescapeMetadataIdStringIfNeeded, + stringifyPseuplexMetadataKeyFromIDString, + stringifyPseuplexPluralMetadataKey, + stringifyPseuplexMetadataKeyFromIDStrings, } from './metadataidentifier'; import { - PseuplexMetadataPathTransformOptions, PseuplexMetadataProvider, PseuplexMetadataProviderParams, PseuplexMetadataTransformOptions, @@ -80,35 +92,48 @@ import { PseuplexResponseFilters, } from './plugin'; import { - parseMetadataIdFromPathParam, - parseMetadataIdsFromPathParam, + parsePseuplexMetadataIDFromPathParam, + parsePseuplexMetadataIDsFromPathParam, + parsePseuplexMetadataIDStringsFromPathParam, pseuplexMetadataIdRequestMiddleware, pseuplexMetadataIdsRequestMiddleware, PseuplexRemappedMetadataIdsRequest, remapPublicToPrivateMetadataIdMiddleware, - remapPublicToPrivateMetadataIdsMiddleware + remapPublicToPrivateMetadataIdsMiddleware, } from './requesthandling'; -import { PseuplexHubMetadataTransformOptions } from './hub'; import { PseuplexIDRemappings, PseuplexPrivateToPublicIDsMap, } from './idmappings'; -import { PseuplexSection } from './section'; +import { + endpointForPseuplexSectionsSource, + PseuplexAllSectionsSource, + PseuplexSection, +} from './section'; import { sendMediaUnavailableNotifications, sendMetadataRefreshTimelineNotifications, } from './notifications'; +import { + pseuplexRouterApp, + UpgradeRequest, + UpgradeResponse, +} from './router'; import * as constants from '../constants'; import { Logger } from '../logging'; import { CachedFetcher } from '../fetching/CachedFetcher'; import { httpError, HttpResponseError } from '../utils/error'; import { + addOriginalRemoteAddressToRequest, asyncRequestHandler, expressErrorHandler, - requestIsEncrypted + remoteAddressOfRequest, + requestIsEncrypted, + urlFromServerRequest, } from '../utils/requesthandling'; import { - parseIntQueryParam + parseIntQueryParam, + parseStringQueryParam } from '../utils/queryparams'; import { parseURLPath, @@ -124,7 +149,9 @@ import { } from '../utils/misc'; import { IPv4NormalizeMode } from '../utils/ip'; import type { WebSocketEventMap } from '../utils/websocket'; -import { applyOverlayToImage } from '../utils/images'; +import { applyOverlayToImage, getResizedImageFromFile } from '../utils/images'; +import { getModuleRootPath } from '../utils/compat'; +import { TLSCertificateOptions } from '../utils/ssl'; // plugins @@ -172,11 +199,12 @@ export type PseuplexAppOptions = { httpPort?: number; httpsPort?: number; ipv4ForwardingMode?: IPv4NormalizeMode; + trustProxy?: boolean; forwardMetadataRefreshToPluginMetadata?: boolean; sendMetadataUnavailability?: boolean; overwritePlexPrivatePort?: number | boolean; alwaysUseLibraryMetadataPath?: boolean; - serverOptions: https.ServerOptions; + tlsCertOptions: TLSCertificateOptions; plexServerHost: string; plexServerHostSecure?: string; plexServerRedirectHost?: string; @@ -204,8 +232,9 @@ export class PseuplexApp { readonly config: PseuplexAppConfig; readonly httpPort?: number; readonly httpsPort?: number; + readonly trustProxy: boolean; readonly forwardsMetadataRefreshToPluginMetadata: boolean; - readonly sendsMetadataUnavailability: boolean; + sendsMetadataUnavailability: boolean; readonly overwritePlexPrivatePort: number | boolean; readonly logger?: Logger; readonly plexServerNotificationsOptions: PseuplexPlexServerNotificationsOptions; @@ -250,10 +279,12 @@ export class PseuplexApp { private _plexServerNotificationsSocketRetryTimeout?: NodeJS.Timeout | undefined; readonly middlewares: { - plexAuthentication: express.RequestHandler; - plexServerOwnerOnly: PlexAuthedRequestHandler; + plexAuthentication: (alwaysCheck?: boolean) => ((req: TRequest, res: TResponse, next: (error?: Error) => void) => void); + plexServerOwnerOnly: () => PlexAuthedRequestHandler; + noPlexTransientTokens: () => PlexAuthedRequestHandler; plexAPIRequestHandler: (handler: PlexAPIRequestHandler) => express.RequestHandler; plexAPIProxy: (filters: PlexAPIProxyFilters) => express.RequestHandler; + plexProxy: () => express.RequestHandler; }; constructor(options: PseuplexAppOptions) { @@ -270,6 +301,7 @@ export class PseuplexApp { this.config = options.config; this.httpPort = httpPort; this.httpsPort = httpsPort; + this.trustProxy = options.trustProxy ?? false; this.forwardsMetadataRefreshToPluginMetadata = options.forwardMetadataRefreshToPluginMetadata ?? true; this.sendsMetadataUnavailability = options.sendMetadataUnavailability ?? true; this.overwritePlexPrivatePort = options.overwritePlexPrivatePort ?? true; @@ -317,33 +349,67 @@ export class PseuplexApp { logger: this.logger, }; const plexProxyOpts: PlexProxyOptions = { + trustProxy: this.trustProxy, logger: this.logger, ipv4Mode: options.ipv4ForwardingMode }; const plexServerHostGetter = (req: express.Request) => { return this.plexServerHostForRequest(req); }; + const plexGeneralProxy = plexHttpProxy(this.plexServerHost, plexProxyOpts); + plexGeneralProxy.on('error', (error) => { + console.error(); + console.error(`Got proxy error:`); + console.error(error); + }); + let plexGeneralProxySecure: HttpProxyServer; + if(plexServerHostSecureIsDifferent) { + plexGeneralProxySecure = plexHttpProxy(this.plexServerHostSecure, plexProxyOpts); + plexGeneralProxySecure.on('error', (error) => { + console.error(); + console.error(`Got proxy error:`); + console.error(error); + }); + } else { + plexGeneralProxySecure = plexGeneralProxy; + } + const plexAuthMiddleware = createPlexAuthenticationMiddleware(this.plexServerAccounts); + const plexServerOwnerOnlyMiddleware = createPlexServerOwnerOnlyMiddleware(); + const noPlexTransientsMiddleware = createNoPlexTransientTokensMiddleware(); + this.middlewares = { - plexAuthentication: createPlexAuthenticationMiddleware(this.plexServerAccounts), - plexServerOwnerOnly: (req: IncomingPlexAPIRequest, res, next) => { - if(!req.plex) { - next(httpError(500, "Cannot access endpoint without plex authentication")); - return; - } - if (!req.plex.userInfo.isServerOwner) { - next(httpError(403, "Get out of here you sussy baka")); - return; - } - next(); + plexAuthentication: (alwaysCheck?: boolean) => { + return (req, res, next) => { + if((req as any as IncomingPlexAPIRequestMixin).plex) { + if(!alwaysCheck) { + // already authenticated + next(); + return; + } + } + plexAuthMiddleware(req, res, next); + }; }, + plexServerOwnerOnly: () => plexServerOwnerOnlyMiddleware, + noPlexTransientTokens: () => noPlexTransientsMiddleware, plexAPIRequestHandler: (handler: PlexAPIRequestHandler) => { return async (req: IncomingPlexAPIRequest, res: express.Response) => { + res.header(constants.APP_CUSTOM_HEADER, 'yes'); await handlePlexAPIRequest(req, res, handler, options); }; }, plexAPIProxy: (proxyFilters: PlexAPIProxyFilters) => { return plexApiProxy(plexServerHostGetter, plexProxyOpts, proxyFilters); }, + plexProxy: () => { + return (req, res) => { + if(requestIsEncrypted(req)) { + plexGeneralProxySecure.web(req,res); + } else { + plexGeneralProxy.web(req,res); + } + }; + }, }; // loop through and instantiate plugins @@ -435,11 +501,20 @@ export class PseuplexApp { } // create router and define routes - const router = express(); + const router = pseuplexRouterApp(express(), this); + router.set('trust proxy', this.trustProxy); + router.set('etag', false); + // apply original remote address // log request if needed router.use((req, res, next) => { - this.logger?.logIncomingUserRequest(req); + try { + addOriginalRemoteAddressToRequest(req); + this.logger?.logIncomingUserRequest(req); + } catch(error) { + next(error); + return; + } next(); }); @@ -458,12 +533,12 @@ export class PseuplexApp { }; }; + const libraryMetadataIdReplacer = getIdReplacer('/library/metadata/'); router.get('/library/metadata/:metadataId', [ - remapPublicToPrivateMetadataIdsMiddleware(this.metadataIdMappings!, plexReqHandlerOpts, getIdReplacer('/library/metadata/')) + remapPublicToPrivateMetadataIdsMiddleware(this.metadataIdMappings!, plexReqHandlerOpts, libraryMetadataIdReplacer) ]); - router.get('/library/metadata/:metadataId/children', [ - remapPublicToPrivateMetadataIdMiddleware(this.metadataIdMappings!, plexReqHandlerOpts, getIdReplacer('/library/metadata/')) + remapPublicToPrivateMetadataIdMiddleware(this.metadataIdMappings!, plexReqHandlerOpts, libraryMetadataIdReplacer) ]); for(const hubsSource of Object.values(PseuplexRelatedHubsSource)) { @@ -490,29 +565,25 @@ export class PseuplexApp { // resolve play queue uri const plexMachineId = await this.plexServerProperties.getMachineIdentifier(); let urisChanged = false; - const libraryMetadataPrefix = '/library/metadata/'; uriProp = transformArrayOrSingle(uriProp, (uri) => { const originalURI = uri; - const uriParts = plexTypes.parsePlayQueueURI(uri); + const uriParts = plexTypes.parsePlexServerItemURI(uri); if(!uriParts.path || (uriParts.machineIdentifier != plexMachineId && uriParts.machineIdentifier != "x")) { return uri; } - const metadataKeyParts = parseMetadataIDFromKey(uriParts.path, libraryMetadataPrefix); + const metadataKeyParts = parsePseuplexPluralMetadataKey(uriParts.path); if(!metadataKeyParts) { return uri; } let idsChanged = false; - // path is using /library/metadata - let metadataIdStrings = metadataKeyParts.id.split(','); // remap if the path is using a mapped id - for(let i=0; i { const context = this.contextForRequest(req); @@ -565,36 +636,48 @@ export class PseuplexApp { }) ]); - router.get(['/library/sections', '/library/sections/all'], [ - this.middlewares.plexAuthentication, - this.middlewares.plexAPIProxy({ - filter: async (req: IncomingPlexAPIRequest, res) => { - const context = this.contextForRequest(req); - return await this.hasPluginSections(context); - }, - responseModifier: async (proxyRes, resData: plexTypes.PlexLibrarySectionsPage, userReq: IncomingPlexAPIRequest, userRes) => { - const context = this.contextForRequest(userReq); - const reqParams = userReq.plex.requestParams; - // add sections - const allSections = await this.getPluginSections(context); - const existingSections = resData.MediaContainer.Directory ?? []; - const newSections = await Promise.all(Array.from(allSections).map(async (section) => { - return await section.getLibrarySectionsEntry(reqParams,context); - })); - existingSections.push(...newSections); - resData.MediaContainer.Directory = existingSections; - resData.MediaContainer.size = (resData.MediaContainer.size ?? 0) + newSections.length; - return resData; - } - }) - ]); + for(const sectionsSource of Object.values(PseuplexAllSectionsSource)) { + router.get(endpointForPseuplexSectionsSource(sectionsSource), [ + this.middlewares.plexAuthentication(), + this.middlewares.plexAPIProxy({ + filter: async (req: IncomingPlexAPIRequest, res) => { + const context = this.contextForRequest(req); + return await this.hasPluginSections(context); + }, + responseModifier: async (proxyRes, resData: plexTypes.PlexLibrarySectionsPage, userReq: IncomingPlexAPIRequest, userRes) => { + const context = { + ...this.contextForRequest(userReq), + from: sectionsSource, + }; + const reqParams: plexTypes.PlexLibrarySectionsPageParams = userReq.plex.requestParams; + // add sections + const allSections = await this.getPluginSections(context); + const existingSections = resData.MediaContainer.Directory ?? []; + const newSections = await Promise.all(Array.from(allSections).map(async (section) => { + return await section.getLibrarySectionsEntry(reqParams,context); + })); + existingSections.push(...newSections); + resData.MediaContainer.Directory = existingSections; + resData.MediaContainer.size = (resData.MediaContainer.size ?? 0) + newSections.length; + // filter response + await this.filterResponse('sections', resData, { + proxyRes, + userReq, + userRes, + from: sectionsSource, + }); + return resData; + } + }) + ]); + } router.get('/hubs', [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexLibraryHubsPage, userReq: IncomingPlexAPIRequest, userRes) => { const context = this.contextForRequest(userReq); - const reqParams = userReq.plex.requestParams; + const reqParams = plexTypes.parsePlexHubListPageParams(userReq); // get hubs for each section // TODO maybe add some sort of sorting? const hubsPromisesForSections = (await this.getPluginSections(context)).map((section) => { @@ -629,23 +712,20 @@ export class PseuplexApp { ]); router.get('/hubs/promoted', [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexLibraryHubsPage, userReq: IncomingPlexAPIRequest, userRes) => { const context = this.contextForRequest(userReq); - const reqParams = userReq.plex.requestParams; - // get section IDs to include - const contentDirectoryID = userReq.query?.['contentDirectoryID']; - const contentDirIds = ((typeof contentDirectoryID == 'string') ? contentDirectoryID.split(',') : contentDirectoryID) as (string[] | undefined); + const plexParams = plexTypes.parsePlexHubListPageParams(userReq); // get promoted hubs for included sections // TODO maybe add some sort of sorting? const hubsPromisesForSections = (await this.getPluginSections(context)).map((section) => { // ensure we're including this section - if(!contentDirIds || contentDirIds.findIndex((id) => (id == section.id)) == -1) { + if(!plexParams.contentDirectoryID || plexParams.contentDirectoryID.findIndex((id) => (id == section.id)) == -1) { return null; } // get promoted hubs for this section - return section.getPromotedHubsPage(reqParams, context); + return section.getPromotedHubsPage(plexParams, context); }); // add hubs from sections const allSectionHubs: plexTypes.PlexHubWithItems[] = []; @@ -676,7 +756,7 @@ export class PseuplexApp { ]); router.get('/hubs/sections/:sectionId', [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), // TODO handle custom sections this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexSectionHubsPage, userReq: IncomingPlexAPIRequest, userRes) => { @@ -695,14 +775,14 @@ export class PseuplexApp { ]); router.get(`/library/metadata/:metadataId`, [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), pseuplexMetadataIdsRequestMiddleware(plexReqHandlerOpts, async (req: PseuplexRemappedMetadataIdsRequest, res, metadataIds): Promise => { const privateToPublicIds = req.remappedPlexMetadataIds; const context = this.contextForRequest(req); - const params: plexTypes.PlexMetadataPageParams = req.plex.requestParams; + const plexParams: plexTypes.PlexMetadataPageParams = req.plex.requestParams; // get metadatas const resData = await this.getMetadata(metadataIds, { - plexParams: req.plex.requestParams, + plexParams, context, cachePluginMetadataAccess: true, }); @@ -716,15 +796,11 @@ export class PseuplexApp { } } // filter related hubs if included - if(params.includeRelated == 1) { + if(plexParams.includeRelated == 1) { // get metadata id - let metadataIdString = parseMetadataIDFromKey(metadataItem.key, '/library/metadata/')?.id; - if(!metadataIdString) { - metadataIdString = metadataItem.ratingKey; - } - if(metadataIdString) { + const metadataId = parsePseuplexMetadataIDFromItem(metadataItem); + if(metadataId) { // filter related hubs - const metadataId = parseMetadataID(metadataIdString); const relatedHubsResponse: plexTypes.PlexHubsPage = { MediaContainer: { ...metadataItem.Related, @@ -756,31 +832,31 @@ export class PseuplexApp { }); } // send unavailable notifications if needed - this.sendMetadataUnavailableNotificationsIfNeeded(resData, params, context); + this.sendMetadataUnavailableNotificationsIfNeeded(resData, plexParams, context); return resData; }), this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexMetadataPage, userReq: IncomingPlexAPIRequest, userRes) => { const context = this.contextForRequest(userReq); const plexParams: plexTypes.PlexMetadataPageParams = userReq.plex.requestParams; - const metadataIds = parseMetadataIdsFromPathParam(userReq.params.metadataId); + const metadataIds = parsePseuplexMetadataIDsFromPathParam(userReq.params.metadataId); // process metadata items await forArrayOrSingleAsyncParallel(resData.MediaContainer.Metadata, async (metadataItem: PseuplexMetadataItem) => { - const metadataId = parseMetadataIDFromKey(metadataItem.key, '/library/metadata/')?.id; + const metadataIdString = parsePseuplexMetadataIDStringFromItem(metadataItem); metadataItem.Pseuplex = { isOnServer: true, unavailable: false, metadataIds: {}, - plexServerMetadataId: metadataId, + plexServerMetadataId: metadataIdString ?? undefined, }; // cache id => guid mapping - if(metadataItem.guid && metadataId) { - this.plexServerIdToGuidCache.setSync(metadataId, metadataItem.guid); + if(metadataItem.guid && metadataIdString) { + this.plexServerIdToGuidCache.setSync(metadataIdString, metadataItem.guid); } // filter related hubs if included - if(metadataId && plexParams.includeRelated == 1) { + if(metadataIdString && plexParams.includeRelated == 1) { // filter related hubs - const metadataIdParts = parseMetadataID(metadataId); + const metadataIdParts = parsePseuplexMetadataID(metadataIdString); const relatedHubsResponse: plexTypes.PlexHubsPage = { MediaContainer: { ...metadataItem.Related, @@ -813,18 +889,14 @@ export class PseuplexApp { ]); router.get(`/library/metadata/:metadataId/children`, [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), pseuplexMetadataIdRequestMiddleware(plexReqHandlerOpts, async (req: PseuplexRemappedMetadataIdsRequest, res, metadataId): Promise => { const privateToPublicIds = req.remappedPlexMetadataIds; const context = this.contextForRequest(req); - const plexParams: plexTypes.PlexMetadataChildrenPageParams = { - ...req.plex.requestParams, - 'X-Plex-Container-Start': parseIntQueryParam(req.query['X-Plex-Container-Start'] ?? req.header('x-plex-container-start')), - 'X-Plex-Container-Size': parseIntQueryParam(req.query['X-Plex-Container-Size'] ?? req.header('x-plex-container-size')) - }; + const plexParams = plexTypes.parsePlexMetadataChildrenPageParams(req); // get metadatas const resData = await this.getMetadataChildren(metadataId, { - plexParams: plexParams, + plexParams, context, cachePluginMetadataAccess: true, }); @@ -847,20 +919,16 @@ export class PseuplexApp { this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexMetadataChildrenPage, userReq: IncomingPlexAPIRequest, userRes) => { const context = this.contextForRequest(userReq); - const metadataId = parseMetadataIdFromPathParam(userReq.params.metadataId); - const plexParams: plexTypes.PlexMetadataChildrenPageParams = { - ...userReq.plex.requestParams, - 'X-Plex-Container-Start': parseIntQueryParam(userReq.query['X-Plex-Container-Start'] ?? userReq.header('x-plex-container-start')), - 'X-Plex-Container-Size': parseIntQueryParam(userReq.query['X-Plex-Container-Size'] ?? userReq.header('x-plex-container-size')) - }; + const metadataId = parsePseuplexMetadataIDFromPathParam(userReq.params.metadataId); + const plexParams = plexTypes.parsePlexMetadataChildrenPageParams(userReq); // process metadata items await forArrayOrSingleAsyncParallel(resData.MediaContainer.Metadata, async (metadataItem: PseuplexMetadataItem) => { - const metadataId = parseMetadataIDFromKey(metadataItem.key, '/library/metadata/')?.id; + const metadataIdString = parsePseuplexMetadataIDStringFromItem(metadataItem); metadataItem.Pseuplex = { isOnServer: true, unavailable: false, metadataIds: {}, - plexServerMetadataId: metadataId, + plexServerMetadataId: metadataIdString ?? undefined, }; }); // filter metadata page @@ -879,13 +947,14 @@ export class PseuplexApp { for(const hubsSource of Object.values(PseuplexRelatedHubsSource)) { router.get(`/${hubsSource}/metadata/:metadataId/related`, [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), pseuplexMetadataIdRequestMiddleware(plexReqHandlerOpts, async (req: PseuplexRemappedMetadataIdsRequest, res, metadataId): Promise => { const privateToPublicIds = req.remappedPlexMetadataIds; const context = this.contextForRequest(req); + const plexParams = plexTypes.parsePlexHubListPageParams(req); // get metadata const resData = await this.getMetadataRelatedHubs(metadataId, { - plexParams: req.plex.requestParams, + plexParams, context, from: hubsSource, }); @@ -907,7 +976,7 @@ export class PseuplexApp { this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexHubsPage, userReq: IncomingPlexAPIRequest, userRes) => { // get request info - const metadataId = parseMetadataIdFromPathParam(userReq.params.metadataId); + const metadataId = parsePseuplexMetadataIDFromPathParam(userReq.params.metadataId); // filter hub list page await this.filterResponse('metadataRelatedHubs', resData, { proxyRes, @@ -929,7 +998,8 @@ export class PseuplexApp { } router.get(`/library/all`, [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), + // filter requests that are asking for a specific guid this.middlewares.plexAPIProxy({ filter: (req, res) => { // only filter if guid is included @@ -953,9 +1023,9 @@ export class PseuplexApp { ]); router.get('/myplex/account', [ - this.middlewares.plexAuthentication, - // ensure that this endpoint NEVER gives data to non-owners - this.middlewares.plexServerOwnerOnly, + this.middlewares.plexAuthentication(), + this.middlewares.plexServerOwnerOnly(), + this.middlewares.noPlexTransientTokens(), this.middlewares.plexAPIProxy({ responseModifier: async (proxyRes, resData: plexTypes.PlexMyPlexAccountPage, userReq: IncomingPlexAPIRequest, userRes) => { // overwrite privatePort if needed @@ -981,7 +1051,7 @@ export class PseuplexApp { ]); router.post('/playQueues', [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), asyncRequestHandler(async (req, res) => { const context = this.contextForRequest(req); // parse url path @@ -1001,7 +1071,7 @@ export class PseuplexApp { context, }; uriProp = await transformArrayOrSingleAsyncParallel(uriProp, async (uri) => { - const uriParts = plexTypes.parsePlayQueueURI(uri); + const uriParts = plexTypes.parsePlexServerItemURI(uri); if(!uriParts.path) { return uri; } @@ -1009,7 +1079,7 @@ export class PseuplexApp { if(!uriChanged) { return uri; } - const newUri = plexTypes.stringifyPlayQueueURIParts(uriParts); + const newUri = plexTypes.stringifyPlexServerItemURI(uriParts); console.log(`Remapped play queue uri ${uri} to ${newUri}`); return newUri; }); @@ -1024,60 +1094,49 @@ export class PseuplexApp { // redirect streams if needed if(this.redirectPlexStreams && (this.plexServerRedirectHost || this.plexServerRedirectHostSecure)) { - router.use([ + router.get([ '/video/\\:/transcode/universal/session', + '/music/\\:/transcode/universal/session', '/library/parts', ], [ - (req: express.Request, res: express.Response, next) => { + asyncRequestHandler(async (req: IncomingPlexAPIRequest, res: express.Response) => { + // check if we should redirect this request + const redirectPlexStreams = this.redirectPlexStreams; + if(!redirectPlexStreams) { + return false; + } // get redirect url, if any - let redirectUrl: (string | undefined); + let redirectHost: (string | undefined); try { - const redirectHost = this.plexServerRedirectHostForRequest(req); - if(redirectHost) { - redirectUrl = redirectHost + req.url; + redirectHost = this.plexServerRedirectHostForRequest(req); + if(!redirectHost) { + return false; } } catch(error) { console.error(`Error handling stream redirect:`); console.error(error); + return false; } - // redirect or continue - if(redirectUrl) { - res.redirect(307, redirectUrl); - return; - } - next(); - } + // redirect + // TODO if the auth context was passed via header, we may need to include it in the query params + const reqUrl = urlFromServerRequest(req); + const redirectUrl = redirectHost + reqUrl; + res.redirect(307, redirectUrl); + return true; + }) ]); } router.get('/photo/\\:/transcode', [ - this.middlewares.plexAuthentication, - asyncRequestHandler(async (req: IncomingPlexAPIRequest, res) => { + this.middlewares.plexAuthentication(), + asyncRequestHandler(async (req: IncomingPlexAPIRequest, res: express.Response) => { try { const urlParts = parseURLPath(req.url); let photoUrl = urlParts.queryItems?.['url']; if(photoUrl && typeof photoUrl === 'string') { - let changedUrl = false; - const urlsToRewrite = [ - 'http://127.0.0.1:32400', - 'https://127.0.0.1:32400', - ]; - if(this.httpsPort) { - urlsToRewrite.push(`https://127.0.0.1:${this.httpsPort}`); - } - if(this.httpPort) { - urlsToRewrite.push(`http://127.0.0.1:${this.httpPort}`); - } - // rewrite 127.0.0.1 urls query params to absolute paths - for(const urlToRewrite of urlsToRewrite) { - if(photoUrl.startsWith(urlToRewrite) && photoUrl[urlToRewrite.length] == '/') { - const ogPhotoUrl = photoUrl; - photoUrl = photoUrl.substring(urlToRewrite.length); - // TODO log photo url rewrite - changedUrl = true; - break; - } - } + const rewrittenPhotoUrl = this.rewritePhotoEndpointLocalhostURL(photoUrl); + let changedUrl = rewrittenPhotoUrl.changed; + photoUrl = rewrittenPhotoUrl.url; // replace photo url if it matches the overlay url if(this.overlayedImageEndpoint && photoUrl.startsWith(this.overlayedImageEndpoint) @@ -1114,10 +1173,10 @@ export class PseuplexApp { let imagePath = this.overlayImageOverrides?.[imageName]; if(imagePath) { if(!imagePath.startsWith('/') && !imagePath.startsWith('./') && !imagePath.startsWith('../')) { - imagePath = `${require.main!.path}/../${imagePath}`; + imagePath = `${getModuleRootPath()}/${imagePath}`; } } else { - imagePath = `${require.main!.path}/../images/overlays/${imageName}.png`; + imagePath = `${getModuleRootPath()}/images/overlays/${imageName}.png`; } const image = sharp(imagePath); try { @@ -1134,14 +1193,37 @@ export class PseuplexApp { this.overlayedImageEndpoint = `/${this.slug}/image/withoverlay`; router.get(this.overlayedImageEndpoint, [ - this.middlewares.plexAuthentication, - asyncRequestHandler(async (req, res) => { + this.middlewares.plexAuthentication(), + asyncRequestHandler(async (req: express.Request, res: express.Response) => { await this._handleOverlayedImageRequest(req, res); this.logger?.logIncomingUserRequestResponse(req, res, undefined); return true; }) ]); } + + // handle transient token requests + router.all('/security/token', [ + this.middlewares.plexAuthentication(), + this.middlewares.plexAPIProxy({ + responseModifier: (proxyRes, resData: plexTypes.PlexTransientTokenResponse, userReq: IncomingPlexAPIRequest, userRes) => { + const transientToken = resData.MediaContainer.token; + if(!transientToken) { + console.error(`Unexpected transient token response: ${JSON.stringify(resData)}`); + return resData; + } + const creatorToken = userReq.plex.authContext['X-Plex-Token']!; + const type = parseStringQueryParam(userReq.query['type'])!; + const scope = parseStringQueryParam(userReq.query['scope'])!; + this.plexServerAccounts.registerTransientToken(transientToken, { + creatorToken, + type, + scope, + }); + return resData; + } + }) + ]); // handle eventsource requests const onPlexSSEProxyResponse = (proxyReq: http.ClientRequest, proxyRes: http.IncomingMessage, userReq: IncomingPlexAPIRequest, userRes: express.Response) => { @@ -1158,9 +1240,9 @@ export class PseuplexApp { subscribers = [subscriberInfo]; this.eventSourceSubscribers[plexToken] = subscribers; } - // remove subscriber when response ends + // remove subscriber when request or response ends let done = false; - const onResponseDone = () => { + const onDone = () => { if(done) { return; } @@ -1176,23 +1258,34 @@ export class PseuplexApp { console.error(`Couldn't find notification eventsource subscriber to remove`); } }; - userRes.once('finish', onResponseDone); - userRes.once('close', onResponseDone); + userReq.once('close', onDone); + userRes.once('finish', onDone); + userRes.once('close', onDone); }; // proxy SSE events const plexSSEProxy = plexHttpProxy(this.plexServerHost, plexProxyOpts, { onProxyResponse: onPlexSSEProxyResponse, }); + plexSSEProxy.on('error', (error) => { + console.error(); + console.error(`Got proxy error:`); + console.error(error); + }); let plexSSEProxySecure: HttpProxyServer; if(plexServerHostSecureIsDifferent) { plexSSEProxySecure = plexHttpProxy(this.plexServerHostSecure, plexProxyOpts, { onProxyResponse: onPlexSSEProxyResponse, }); + plexSSEProxySecure.on('error', (error) => { + console.error(); + console.error(`Got proxy error:`); + console.error(error); + }); } else { plexSSEProxySecure = plexSSEProxy; } router.get('/\\:/eventsource/notifications', [ - this.middlewares.plexAuthentication, + this.middlewares.plexAuthentication(), (req, res) => { if(requestIsEncrypted(req)) { plexSSEProxySecure.web(req,res); @@ -1209,30 +1302,7 @@ export class PseuplexApp { } // proxy requests to plex - const plexGeneralProxy = plexHttpProxy(this.plexServerHost, plexProxyOpts); - plexGeneralProxy.on('error', (error) => { - console.error(); - console.error(`Got proxy error:`); - console.error(error); - }); - let plexGeneralProxySecure: HttpProxyServer; - if(plexServerHostSecureIsDifferent) { - plexGeneralProxySecure = plexHttpProxy(this.plexServerHostSecure, plexProxyOpts); - plexGeneralProxySecure.on('error', (error) => { - console.error(); - console.error(`Got proxy error:`); - console.error(error); - }); - } else { - plexGeneralProxySecure = plexGeneralProxy; - } - router.use((req, res) => { - if(requestIsEncrypted(req)) { - plexGeneralProxySecure.web(req,res); - } else { - plexGeneralProxy.web(req,res); - } - }); + router.use(this.middlewares.plexProxy()); // handle any errors router.use(expressErrorHandler); @@ -1243,37 +1313,46 @@ export class PseuplexApp { let httpolyglotServer: httpolyglot.Server | undefined; const servers: (http.Server | https.Server | httpolyglot.Server)[] = []; if(httpPort == httpsPort) { - httpolyglotServer = httpolyglot.createServer(options.serverOptions, router); + httpolyglotServer = httpolyglot.createServer({ + tls: options.tlsCertOptions, + }, router); servers.push(httpolyglotServer); } else { if(httpPort) { - httpServer = http.createServer(options.serverOptions, router); + httpServer = http.createServer({}, router); servers.push(httpServer); } if(httpsPort) { - httpsServer = https.createServer(options.serverOptions, router); + httpsServer = https.createServer({ + ...options.tlsCertOptions + }, router); servers.push(httpsServer); } } console.assert(servers.length > 0, "No servers were created"); - - for(const server of servers) { - // handle upgrade to socket - server.on('upgrade', (req, socket, head) => { - this.logger?.logIncomingUserUpgradeRequest(req); + + router.upgradeRouter.use([ + // add websocket to list + asyncRequestHandler((req: UpgradeRequest, res: UpgradeResponse) => { + // only handle if upgrading to websocket + if(req.headers['upgrade']?.toLowerCase().trim() != 'websocket') { + return false; + } + const { socket } = res; // socket endpoints seem to only get passed the token const plexToken = plexTypes.parsePlexTokenFromRequest(req); if(plexToken) { // save socket info per plex token let sockets = this.clientWebSockets[plexToken]; - let endpoint = (req as express.Request).path || parseURLPathParts(req.url!).path; + const reqUrl = urlFromServerRequest(req); + let endpoint = (req as express.Request).path || parseURLPathParts(reqUrl).path; // trim trailing endpoint slash if needed if(endpoint && endpoint.length > 1 && endpoint.endsWith('/') && endpoint.startsWith('/')) { endpoint = endpoint.slice(0, endpoint.length-1); } const socketInfo: PseuplexPossiblyConfirmedClientWebSocketInfo = { endpoint, - socket, + socket: res.socket, proxySocket: undefined, }; if(sockets) { @@ -1305,15 +1384,44 @@ export class PseuplexApp { delete this.clientWebSockets[plexToken]; } } else { - console.error(`Couldn't find socket to remove for ${req.url}`); + console.error(`Couldn't find socket to remove for ${reqUrl}`); } - this.logger?.logIncomingWebsocketClosed(req); }); } - plexGeneralProxy.ws(req, socket, head); + return false; + }), + ]); + + for(const server of servers) { + // handle upgrade to socket + server.on('upgrade', (req, socket, head) => { + // add original request information if needed + addOriginalRemoteAddressToRequest(req); + // log request + this.logger?.logIncomingUserUpgradeRequest(req, socket, head); + // send to upgrade middleware + router.upgradeRouter(req, {socket, head, locals:Object.create(null)}, (error) => { + // handle error if any + if(error != null) { + console.error(`Error while handling upgrade request:`); + console.error(error); + req.destroy(); + socket.destroy(); + return; + } + // handle type of upgrade + if(req.headers['upgrade']?.toLowerCase().trim() == 'websocket') { + // proxy websocket + plexGeneralProxy.ws(req, socket, head); + } else { + // destroy other type of socket + req.destroy(); + socket.destroy(); + } + }); }); } - + // set servers this.httpServer = httpServer; this.httpsServer = httpsServer; @@ -1341,6 +1449,7 @@ export class PseuplexApp { onHttpsListening?: (port: number) => void, onHttpolyglotListening?: (port: number) => void, }) { + this.plexServerAccounts.startAutoCleaningTokens(); if(this.httpsServer) { const port = this.httpsPort!; this.httpsServer!.listen(port, () => { @@ -1379,6 +1488,7 @@ export class PseuplexApp { this.httpolyglotServer?.close((error) => { evts?.onHttpolyglotClosed?.(error); }); + this.plexServerAccounts.stopAutoCleaningTokens(); } @@ -1450,7 +1560,7 @@ export class PseuplexApp { let opened = false; let closed = false; // listen for errors - socket.addEventListener('error', (error) => { + socket.addEventListener('error', (error: WebSocketEventMap['error']) => { if(!opened) { this.logger?.logServerWebsocketFailedToOpen(error, firstAttempt); } else { @@ -1503,7 +1613,7 @@ export class PseuplexApp { // TODO log possibly }); // listen for message - socket.addEventListener('message', (evt) => { + socket.addEventListener('message', (evt: WebSocketEventMap['message']) => { // TODO log possibly this._handlePlexServerNotification(evt); }); @@ -1593,19 +1703,8 @@ export class PseuplexApp { }; } - requiredMetadataPathTransformOptions(): (PseuplexMetadataPathTransformOptions | undefined) { - if(this.alwaysUseLibraryMetadataPath) { - return { - metadataBasePath: '/library/metadata', - qualifiedMetadataIds: true, - }; - } - return undefined; - } - - requiredHubMetadataTransformOptions(): PseuplexHubMetadataTransformOptions { + metadataTransformOptions(): PseuplexMetadataTransformOptions { return { - metadataTransformOptions: this.requiredMetadataPathTransformOptions(), includeMetadataUnavailability: this.sendsMetadataUnavailability, }; } @@ -1616,25 +1715,31 @@ export class PseuplexApp { return this.plexServerHostSecure ?? this.plexServerHost; } - plexServerHostForRequest(req: express.Request): string { + plexServerHostForRequest(req: http.IncomingMessage): string { return requestIsEncrypted(req) ? (this.plexServerHostSecure ?? this.plexServerHost) : this.plexServerHost; } - plexServerRedirectHostForRequest(req: express.Request): string | undefined { + plexServerRedirectHostForRequest(req: http.IncomingMessage): string | undefined { return requestIsEncrypted(req) ? (this.plexServerRedirectHostSecure ?? this.plexServerRedirectHost) : this.plexServerRedirectHost; } - contextForRequest(req: IncomingPlexAPIRequest): PseuplexRequestContext { + contextForRequest(req: IncomingPlexAPIRequest | IncomingPlexHttpRequest): PseuplexRequestContext { return { plexServerURL: this.plexServerHostForRequest(req), plexAuthContext: req.plex.authContext, plexUserInfo: req.plex.userInfo, }; } + + realIPOfRequest(req: http.IncomingMessage): string { + let realIPHeaderVal = req.headers['X-Real-IP']; + realIPHeaderVal = (realIPHeaderVal instanceof Array) ? realIPHeaderVal.flat(Infinity)[0] : realIPHeaderVal; + return (this.trustProxy && realIPHeaderVal) ? realIPHeaderVal : remoteAddressOfRequest(req); + } @@ -1648,19 +1753,13 @@ export class PseuplexApp { let caughtError: Error | undefined = undefined; let caughtNon404Error: Error | undefined = undefined; // create provider params - const transformOpts: PseuplexMetadataTransformOptions = { - metadataBasePath: '/library/metadata', - qualifiedMetadataIds: true, - includeMetadataUnavailability: this.sendsMetadataUnavailability, - }; + const transformOpts: PseuplexMetadataTransformOptions = this.metadataTransformOptions(); const providerParams: PseuplexMetadataProviderParams = { ...options, includePlexDiscoverMatches: true, includeUnmatched: true, transformMatchKeys: true, - metadataBasePath: transformOpts.metadataBasePath, - qualifiedMetadataIds: transformOpts.qualifiedMetadataIds, - includeMetadataUnavailability: transformOpts.includeMetadataUnavailability, + metadataTransformOptions: transformOpts, }; // get metadata for each id const metadataPages = (await Promise.all(metadataIds.map(async (metadataId) => { @@ -1669,7 +1768,7 @@ export class PseuplexApp { // if the metadataId doesn't have a source, assume plex if (source == null || source == PseuplexMetadataSource.Plex) { // fetch from plex - const fullMetadataId = stringifyMetadataID(metadataId); + const fullMetadataId = stringifyPseuplexMetadataID(metadataId); const metadataPage = await plexServerAPI.getLibraryMetadata(fullMetadataId, { params: options.plexParams, serverURL: context.plexServerURL, @@ -1711,7 +1810,7 @@ export class PseuplexApp { throw httpError(400, `Unknown metadata source ${source}`); } // fetch from provider - const partialId = stringifyPartialMetadataID(metadataId); + const partialId = stringifyPartialPseuplexMetadataID(metadataId); const metadataPage = await metadataProvider.get([partialId], providerParams); const metadatas = metadataPage.MediaContainer.Metadata; // cache plugin metadata access if needed @@ -1727,8 +1826,8 @@ export class PseuplexApp { } const plexGuid = metadataItem?.guid; if(plexGuid) { - const fullMetadataId = stringifyMetadataID(metadataId); - const metadataKey = `${transformOpts.metadataBasePath}/${fullMetadataId}`; + const fullMetadataId = stringifyPseuplexMetadataID(metadataId); // qualifiedMetadataIds is always true in this method + const metadataKey = stringifyPseuplexMetadataKeyFromIDString(fullMetadataId); this.pluginMetadataAccessCache.addMetadataAccessEntry(plexGuid, fullMetadataId, metadataKey, context); } } @@ -1736,7 +1835,7 @@ export class PseuplexApp { } } catch(error) { if((error as HttpResponseError)?.httpResponse?.status != 404) { - console.error(`Error fetching metadata for metadata id ${stringifyMetadataID(metadataId)} :`); + console.error(`Error fetching metadata for metadata id ${stringifyPseuplexMetadataID(metadataId)} :`); console.error(error); if(!caughtNon404Error) { caughtNon404Error = error; @@ -1783,8 +1882,6 @@ export class PseuplexApp { const { context } = options; // create provider params const transformOpts: PseuplexMetadataTransformOptions = { - metadataBasePath: '/library/metadata', - qualifiedMetadataIds: true, includeMetadataUnavailability: this.sendsMetadataUnavailability, }; // get metadata for each id @@ -1792,7 +1889,7 @@ export class PseuplexApp { // if the metadataId doesn't have a source, assume plex if (source == null || source == PseuplexMetadataSource.Plex) { // fetch from plex server - const fullMetadataId = stringifyMetadataID(metadataId); + const fullMetadataId = stringifyPseuplexMetadataID(metadataId); const metadataPage = await plexServerAPI.getLibraryMetadataChildren(fullMetadataId, { params: options.plexParams, serverURL: context.plexServerURL, @@ -1833,11 +1930,9 @@ export class PseuplexApp { throw httpError(404, `Unknown metadata source ${source}`); } // fetch from provider - const partialId = stringifyPartialMetadataID(metadataId); + const partialId = stringifyPartialPseuplexMetadataID(metadataId); const page = await metadataProvider.getChildren(partialId, { ...options, - metadataBasePath: transformOpts.metadataBasePath, - qualifiedMetadataIds: transformOpts.qualifiedMetadataIds, includeMetadataUnavailability: transformOpts.includeMetadataUnavailability, }); // cache metadata access if needed @@ -1849,8 +1944,8 @@ export class PseuplexApp { } const plexGuid = metadatas[0]?.parentGuid; if(plexGuid) { - const fullMetadataId = stringifyMetadataID(metadataId); - const metadataKey = `${transformOpts.metadataBasePath}/${fullMetadataId}`; + const fullMetadataId = stringifyPseuplexMetadataID(metadataId); + const metadataKey = stringifyPseuplexMetadataKeyFromIDString(fullMetadataId); this.pluginMetadataAccessCache.addMetadataAccessEntry(plexGuid, fullMetadataId, metadataKey, context); } } @@ -1863,7 +1958,7 @@ export class PseuplexApp { // determine where each ID comes from if(metadataId.source == null || metadataId.source == PseuplexMetadataSource.Plex) { // get related hubs from pms - const metadataIdString = stringifyMetadataID(metadataId); + const metadataIdString = stringifyPseuplexMetadataID(metadataId); const relatedHubsOpts: plexServerAPI.GetRelatedHubsOptions = { params: options.plexParams, // TODO include forwarded request headers @@ -1905,187 +2000,143 @@ export class PseuplexApp { if(!metadataProvider) { throw httpError(404, `Unknown metadata source ${metadataId.source}`); } - const providerMetadataId = stringifyPartialMetadataID(metadataId); + const providerMetadataId = stringifyPartialPseuplexMetadataID(metadataId); return await metadataProvider.getRelatedHubs(providerMetadataId, options); } - async resolvePlayQueueURI(uriParts: plexTypes.PlexPlayQueueURIParts, options: PseuplexPlayQueueURIResolverOptions): Promise { + async resolvePlayQueueURI(uriParts: plexTypes.PlexServerItemURIParts, options: PseuplexPlayQueueURIResolverOptions): Promise { if(!uriParts.path) { return false; } if(uriParts.machineIdentifier != options.plexMachineIdentifier && uriParts.machineIdentifier != "x") { return false; } - const libraryMetadataPath = '/library/metadata'; - const metadataKeyParts = parseMetadataIDFromKey(uriParts.path, libraryMetadataPath); + const metadataKeyParts = parsePseuplexPluralMetadataKey(uriParts.path); let uriChanged = false; - if(metadataKeyParts) { - // path is using /library/metadata - let metadataIdStrings = metadataKeyParts.id.split(','); - // remap metadata ids for custom providers to plex server items - const mappingTasks: {[index: number]: Promise} = {}; - for(let i=0; i} = {}; + for(let i=0; i 0) { - // wait for all metadata tasks and return the resolved IDs - let caughtError; - metadataIdStrings = (await Promise.all(metadataIdStrings.map(async (id, index): Promise => { - try { - const mappingTask = mappingTasks[index]; - if(!mappingTask) { - return [id]; - } - let metadatas = (await mappingTask).MediaContainer.Metadata; - if(!metadatas) { - return []; - } - if(!(metadatas instanceof Array)) { - metadatas = []; + } + const remappedIds = Object.keys(mappingTasks); + if(remappedIds.length > 0) { + // wait for all metadata tasks and return the resolved IDs + let caughtError; + metadataIdStrings = (await Promise.all(metadataIdStrings.map(async (id, index): Promise => { + try { + const mappingTask = mappingTasks[index]; + if(!mappingTask) { + return [id]; + } + let metadatas = (await mappingTask).MediaContainer.Metadata; + if(!metadatas) { + return []; + } + if(!(metadatas instanceof Array)) { + metadatas = []; + } + let foundNull = false; + let ratingKeys = metadatas.map((m) => { + if(m.ratingKey) { + return m.ratingKey; } - let foundNull = false; - let ratingKeys = metadatas.map((m) => { - if(m.ratingKey) { - return m.ratingKey; - } - const parsedKey = parseMetadataIDFromKey(m.key, libraryMetadataPath); - if(parsedKey) { - return parsedKey.id; - } - foundNull = true; - console.error(`No metadata ratingKey or key for item with title ${m.title}`); - return null!; - }); - if(foundNull) { - ratingKeys = ratingKeys.filter((rk) => rk); + const parsedKey = parsePseuplexMetadataKey(m.key); + if(parsedKey) { + return parsedKey.id; } - console.log(`Remapped metadata id ${id} to ${ratingKeys.join(",")}`); - return ratingKeys; - } catch(error) { - console.error(`Failed to remap metadata id ${id} :`); - console.error(error); - if(!caughtError) { - caughtError = error; - } - return []; + foundNull = true; + console.error(`No metadata ratingKey or key for item with title ${m.title}`); + return null!; + }); + if(foundNull) { + ratingKeys = ratingKeys.filter((rk) => rk); } - }))).flat(); - if(metadataIdStrings.length == 0) { - if(caughtError) { - throw caughtError; + console.log(`Remapped metadata id ${id} to ${ratingKeys.join(",")}`); + return ratingKeys; + } catch(error) { + console.error(`Failed to remap metadata id ${id} :`); + console.error(error); + if(!caughtError) { + caughtError = error; } - throw httpError(500, "Failed to resolve custom metadata ids for play queue"); + return []; } - // rebuild path and uri from metadata ids - uriParts.path = `${libraryMetadataPath}/${metadataIdStrings.join(',')}${metadataKeyParts.relativePath ?? ''}`; - uriChanged = true; - } - } else { - // using an unknown metadata base path - // check all metadata providers to see if one matches - for(const metadataProvider of Object.values(this.metadataProviders)) { - const metadataIds = metadataProvider.metadataIdsFromKey(uriParts.path); - if(!metadataIds) { - continue; + }))).flat(); + if(metadataIdStrings.length == 0) { + if(caughtError) { + throw caughtError; } - // resolve items to plex server items - let metadatas = (await metadataProvider.get(metadataIds.ids, { - context: options.context, - includePlexDiscoverMatches: false, - includeUnmatched: false, - transformMatchKeys: false, // keep the key from the plex server - qualifiedMetadataIds: true, - includeMetadataUnavailability: this.sendsMetadataUnavailability, - metadataBasePath: libraryMetadataPath, - })).MediaContainer.Metadata || []; - if(!(metadatas instanceof Array)) { - metadatas = [metadatas]; - } - if(metadatas.length <= 0) { - throw httpError(404, "A matching plex server item was not found for this item"); - } - let foundNull = false; - let newMetadataIds = metadatas.map((m) => { - if(m.ratingKey) { - return m.ratingKey; - } - const parsedKey = parseMetadataIDFromKey(m.key, libraryMetadataPath); - if(parsedKey) { - return parsedKey.id; - } - foundNull = true; - console.error(`No metadata ratingKey or key for item with title ${m.title}`); - return null; - }); - if(foundNull) { - newMetadataIds = newMetadataIds.filter((rk) => rk); - } - // rebuild path from metadata ids - const newMetadataKey = `${libraryMetadataPath}/${newMetadataIds.join(',')}${metadataIds.relativePath ?? ''}`; - console.log(`Remapped metadata key ${uriParts.path} to ${newMetadataKey}`); - uriParts.path = newMetadataKey; - uriChanged = true; - break; + throw httpError(500, "Failed to resolve custom metadata ids for play queue"); } + // rebuild path and uri from metadata ids + metadataKeyParts.ids = metadataIdStrings; + uriParts.path = stringifyPseuplexPluralMetadataKey(metadataKeyParts); + uriChanged = true; } return uriChanged; } + rewritePhotoEndpointLocalhostURL(photoUrl: string): { + url: string, + changed: boolean, + } { + const urlsToRewrite = [ + 'http://127.0.0.1:32400', + 'https://127.0.0.1:32400', + ]; + if(this.httpsPort) { + urlsToRewrite.push(`https://127.0.0.1:${this.httpsPort}`); + } + if(this.httpPort) { + urlsToRewrite.push(`http://127.0.0.1:${this.httpPort}`); + } + // rewrite 127.0.0.1 urls query params to absolute paths + for(const urlToRewrite of urlsToRewrite) { + if(photoUrl.startsWith(urlToRewrite) && photoUrl[urlToRewrite.length] == '/') { + const ogPhotoUrl = photoUrl; + return { + url: photoUrl.substring(urlToRewrite.length), + changed: true, + }; + } + } + return { + url: photoUrl, + changed: false + }; + } private async _handleOverlayedImageRequest(req: express.Request, res: express.Response) { if(!this.overlayImageCache) { throw httpError(500, "Overlays are disabled"); } - // parse width - let width: any = req.query['width']; - if(typeof width === 'string') { - if(width) { - width = Number.parseInt(width); - if(Number.isNaN(width)) { - throw httpError(500, "Invalid width"); - } - } else { - width = null; - } - } - if(width != null && typeof width !== 'number') { - throw httpError(400, "Invalid width"); - } - // parse height - let height: any = req.query['height']; - if(typeof height === 'string') { - if(height) { - height = Number.parseInt(height); - if(Number.isNaN(height)) { - throw httpError(500, "Invalid height"); - } - } else { - height = null; - } - } - if(height != null && typeof height !== 'number') { - throw httpError(400, "Invalid height"); - } + // parse width and height + const width = parseIntQueryParam(req.query.width); + const height = parseIntQueryParam(req.query.height); // parse url let url = req.query['url']; if(url instanceof Array) { @@ -2106,13 +2157,31 @@ export class PseuplexApp { if(overlayName instanceof Array) { overlayName = overlayName[0] as string; } + if(!overlayName) { + throw httpError(400, "Missing overlay parameter"); + } if(typeof overlayName !== 'string') { throw httpError(400, `Invalid overlay ${overlayName}`); } - if(!overlayName) { - throw httpError(400, "Missing overlay parameter"); + await this.sendOverlayedImageResponse({ + origin: req.headers['origin'], + url, + width, height, + overlayName + }, res); + } + + async sendOverlayedImageResponse({origin, url, width, height, overlayName}: { + origin?: string, + url: string, + width?: number, + height?: number, + overlayName: string, + }, res: express.Response) { + if(!this.overlayImageCache) { + throw httpError(500, "Overlays are disabled"); } - if(!overlayName || !overlayImageNameRegex.test(overlayName)) { + if(!overlayName || !overlayImageNameRegex.test(overlayName) || overlayName == '..' || overlayName == '.') { throw httpError(400, "Invalid overlay"); } // get overlay image @@ -2124,14 +2193,13 @@ export class PseuplexApp { resize: (width != null && height != null) ? {width,height} : undefined, keepAspectRatio: true, }); + if(origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + } const contentType = baseImageRes.headers.get('Content-Type'); if(contentType) { res.setHeader('Content-Type', contentType); } - const origin = req.headers['origin']; - if(origin) { - res.setHeader('Access-Control-Allow-Origin', origin); - } const cacheControl = baseImageRes.headers.get('Cache-Control'); if(cacheControl) { res.setHeader('Cache-Control', cacheControl); @@ -2141,6 +2209,42 @@ export class PseuplexApp { res.end(outputImageBuffer); } + async sendImageResponse({origin, filepath, width, height}: { + origin?: string, + filepath: string, + width?: number, + height?: number, + }, res: express.Response) { + // if not resizing, just serve image directly + if(!width && !height) { + await new Promise((resolve, reject) => { + res.sendFile(filepath, (error) => { + if(error) { + if(!res.headersSent) { + reject(error); + return; + } + console.error(`Error sending ${filepath} response:`); + console.error(error); + } + resolve(); + }); + }); + return; + } + // load image from file and resize + const {image,meta} = await getResizedImageFromFile(filepath, { + width, + height, + keepAspectRatio: true + }); + if(origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.set('Content-Type', `image/${meta.format}`); + image.pipe(res); + } + async filterResponse(filterName: TFilterName, resData: Parameters>[0], context: Parameters>[1]) { const filtersList = this.responseFilters[filterName]; @@ -2153,7 +2257,7 @@ export class PseuplexApp { } as any); if(result) { promises.push(result.catch((error) => { - const urlToLog = this.logger?.urlString(context.userReq.url) ?? context.userReq.url; + const urlToLog = this.logger?.urlStringOfRequest(context.userReq) ?? urlFromServerRequest(context.userReq); console.error(`Filter for ${urlToLog} response failed:`); console.error(error); })); @@ -2171,21 +2275,22 @@ export class PseuplexApp { } // check if hub key needs to be mapped if(hub.hubKey) { - let metadataKeyParts = parseMetadataIDFromKey(hub.hubKey, '/library/metadata/'); - let metadataIds: (string | number)[] | undefined = metadataKeyParts?.id.split(','); - if(metadataIds) { + let metadataKeyParts = parsePseuplexPluralMetadataKey(hub.hubKey); + if(metadataKeyParts) { + const metadataIds = metadataKeyParts.ids; for(let i=0; i { try { - const idsToNotifications: {[id: string]: plexTypes.PlexActivityNotification} = {}; + const idsToNotifications: {[id: string]: plexTypes.PlexActivityNotification[]} = {}; const idsToGuids: {[id: string]: string | null | undefined | Promise} = {}; // find item ids with existing guid map const remainingIdsToMatch = new Set(); @@ -2423,7 +2528,7 @@ export class PseuplexApp { continue; } // parse metadata id - const keyParts = parseMetadataIDFromKey(metadataKey, '/library/metadata/'); + const keyParts = parsePseuplexMetadataKey(metadataKey); if(!keyParts) { continue; } @@ -2433,7 +2538,13 @@ export class PseuplexApp { } // map id to notification const { id } = keyParts; - idsToNotifications[id] = notification; + let idNotifsList = idsToNotifications[id]; + if(idNotifsList) { + idNotifsList.push(notification); + } else { + idNotifsList = [notification]; + } + idsToNotifications[id] = idNotifsList; // get cached guid task if any let guidTask = idsToGuids[id]; if(guidTask) { @@ -2496,17 +2607,17 @@ export class PseuplexApp { } } // create map of guids back to the notification - const guidsToNotifications: {[guid: string]: plexTypes.PlexActivityNotification} = {}; + const guidsToNotifications: {[guid: string]: plexTypes.PlexActivityNotification[]} = {}; for(const id of Object.keys(idsToGuids)) { const guid = await idsToGuids[id]; if(!guid) { continue; } - const notif = idsToNotifications[id]; - if(!notif) { + const notifs = idsToNotifications[id]; + if(!notifs) { continue; } - guidsToNotifications[guid] = notif; + guidsToNotifications[guid] = notifs; } // get guids to send notifications const guids = Object.keys(guidsToNotifications); @@ -2515,7 +2626,7 @@ export class PseuplexApp { } // send notifications for guids after delay for(const guid of guids) { - const notification = guidsToNotifications[guid]; + const notifsForGuid = guidsToNotifications[guid]; const sendNotifOptions = this.plexSendNotificationOptions(); this.pluginMetadataAccessCache!.forEachAccessorForGuid(guid, ({token,clientId,metadataIds,metadataIdsMap}) => { setTimeout(() => { @@ -2534,7 +2645,7 @@ export class PseuplexApp { sendPlexNotifications(notifSenders, { type: plexTypes.PlexNotificationType.Activity, size: 1, - ActivityNotification: [ + ActivityNotification: notifsForGuid.map((notification) => ( { ...notification, uuid, @@ -2548,7 +2659,7 @@ export class PseuplexApp { } } } - ] + )) }, sendNotifOptions); } catch(error) { console.error(`Error sending notification to socket:`); diff --git a/src/pseuplex/externalplex/transform.ts b/src/pseuplex/externalplex/transform.ts index 4151ee3..a49f9d2 100644 --- a/src/pseuplex/externalplex/transform.ts +++ b/src/pseuplex/externalplex/transform.ts @@ -1,6 +1,5 @@ import qs from 'querystring'; import * as plexTypes from '../../plex/types'; -import { parseMetadataIDFromKey } from '../../plex/metadataidentifier'; import { PseuplexMetadataItem, PseuplexMetadataSource, @@ -9,8 +8,10 @@ import { import { PseuplexMetadataTransformOptions } from '../metadata'; import { PseuplexPartialMetadataIDParts, - stringifyMetadataID, - stringifyPartialMetadataID + stringifyPseuplexMetadataID, + stringifyPartialPseuplexMetadataID, + parsePseuplexMetadataIDStringFromItem, + stringifyPseuplexMetadataKeyFromIDString } from '../metadataidentifier'; import { nonexistantMediaItems } from '../media'; @@ -22,11 +23,11 @@ export const createPartialExternalPlexMetadataIdParts = (opts: {serverURL: strin }; export const createPartialExternalPlexMetadataId = (opts: {serverURL: string, metadataId: string}): string => { - return stringifyPartialMetadataID(createPartialExternalPlexMetadataIdParts(opts)); + return stringifyPartialPseuplexMetadataID(createPartialExternalPlexMetadataIdParts(opts)); }; export const createFullExternalPlexMetadataId = (opts:{serverURL: string, metadataId: string, asUrl: boolean}): string => { - return stringifyMetadataID({ + return stringifyPseuplexMetadataID({ isURL: opts.asUrl, source: PseuplexMetadataSource.PlexServer, directory: opts.serverURL, @@ -42,13 +43,6 @@ export const transformExternalPlexMetadata = (metadataItem: plexTypes.PlexMetada delete pseuMetadataItem.primaryExtraKey; delete pseuMetadataItem.availabilityId; delete pseuMetadataItem.streamingMediaId; - let metadataId = pseuMetadataItem.ratingKey; - if(!metadataId) { - metadataId = parseMetadataIDFromKey(pseuMetadataItem.key, '/library/metadata/')?.id; - if(metadataId) { - metadataId = qs.unescape(metadataId); - } - } for(const person of [ ...(pseuMetadataItem.Writer ?? []), ...(pseuMetadataItem.Role ?? []), @@ -61,24 +55,21 @@ export const transformExternalPlexMetadata = (metadataItem: plexTypes.PlexMetada } } } - if(metadataId) { - const partialMetadataId = createPartialExternalPlexMetadataId({ - serverURL, - metadataId, - }); + const extMetadataId = parsePseuplexMetadataIDStringFromItem(pseuMetadataItem); + if(extMetadataId) { const fullMetadataId = createFullExternalPlexMetadataId({ serverURL, - metadataId, + metadataId: extMetadataId, asUrl: false }); pseuMetadataItem.ratingKey = fullMetadataId; - pseuMetadataItem.key = `${transformOpts.metadataBasePath}/${transformOpts.qualifiedMetadataIds ? fullMetadataId : partialMetadataId}`; + pseuMetadataItem.key = stringifyPseuplexMetadataKeyFromIDString(fullMetadataId); pseuMetadataItem.Pseuplex = { isOnServer: false, unavailable: true, metadataIds: {}, externalPlexMetadataIds: { - [serverURL]: metadataId + [serverURL]: extMetadataId }, }; } else { diff --git a/src/pseuplex/feedhub.ts b/src/pseuplex/feedhub.ts index a8cd065..6cea63c 100644 --- a/src/pseuplex/feedhub.ts +++ b/src/pseuplex/feedhub.ts @@ -20,6 +20,7 @@ import { PseuplexRequestContext } from './types'; import { + HubStartTokenQueryParam, PseuplexHub, PseuplexHubPage, PseuplexHubPageParams, @@ -82,26 +83,28 @@ export abstract class PseuplexFeedHub< abstract compareItemTokens(itemToken1: TItemToken, itemToken2: TItemToken): number; abstract transformItem(item: TItem, context: PseuplexRequestContext): (plexTypes.PlexMetadataItem | Promise); - override async get(params: PseuplexHubPageParams, context: PseuplexRequestContext): Promise { + override async get(plexParams: PseuplexHubPageParams, context: PseuplexRequestContext): Promise { const opts = this._options; const loadAheadCount = opts.loadAheadCount ?? DEFAULT_LOAD_AHEAD_COUNT; let chunk: LoadableListChunk; let start: number; - let { listStartToken } = params; + let { hubStartToken } = plexParams; let listStartItemToken: TItemToken | null | undefined = undefined; - if(listStartToken != null || (params.start != null && params.start > 0)) { - if(listStartToken != null) { - listStartItemToken = this.parseItemTokenParam(listStartToken); + const startParam = plexParams['X-Plex-Container-Start']; + const countParam = plexParams['X-Plex-Container-Size']; + if(hubStartToken != null || (startParam != null && startParam > 0)) { + if(hubStartToken != null) { + listStartItemToken = this.parseItemTokenParam(hubStartToken); } - start = params.start ?? 0; - const itemCount = params.count ?? opts.defaultItemCount; + start = startParam ?? 0; + const itemCount = countParam ?? opts.defaultItemCount; chunk = await this._itemList.getOrFetchItems(listStartItemToken ?? null, start, itemCount, { unique: opts.uniqueItemsOnly, loadAheadCount }); } else { start = 0; - const itemCount = params.count ?? opts.defaultItemCount; + const itemCount = countParam ?? opts.defaultItemCount; chunk = await this._itemList.getOrFetchStartItems(itemCount, { unique: opts.uniqueItemsOnly, loadAheadCount @@ -110,7 +113,7 @@ export abstract class PseuplexFeedHub< } let key = opts.hubPath; if(listStartItemToken != null) { - key = addQueryArgumentToURLPath(opts.hubPath, `listStartToken=${listStartItemToken}`); + key = addQueryArgumentToURLPath(opts.hubPath, `${HubStartTokenQueryParam}=${listStartItemToken}`); } // transform items let items = await Promise.all(chunk.items.map(async (itemNode) => { @@ -183,7 +186,7 @@ export abstract class PseuplexFeedHub< key: key, title: opts.title, type: opts.type, - hubIdentifier: `${opts.hubIdentifier}${(params.contentDirectoryID != null && !(params.contentDirectoryID instanceof Array)) ? `.${params.contentDirectoryID}` : ''}`, + hubIdentifier: `${opts.hubIdentifier}${(plexParams.contentDirectoryID != null && plexParams.contentDirectoryID.length == 1) ? `.${plexParams.contentDirectoryID[0]}` : ''}`, context: opts.context, style: opts.style, promoted: opts.promoted diff --git a/src/pseuplex/hub.ts b/src/pseuplex/hub.ts index 5f46bfb..6e0616d 100644 --- a/src/pseuplex/hub.ts +++ b/src/pseuplex/hub.ts @@ -1,13 +1,13 @@ +import express from 'express'; import * as plexTypes from '../plex/types'; -import { parseMetadataIDFromKey } from '../plex/metadataidentifier'; import { CachedFetcher } from '../fetching/CachedFetcher'; -import type { - PseuplexMetadataPathTransformOptions, - PseuplexMetadataTransformOptions, - PseuplexMetadataProvider, -} from './metadata'; import type { PseuplexRequestContext } from './types'; -import { parseMetadataID, stringifyPartialMetadataID } from './metadataidentifier'; +import { + parsePseuplexMetadataKey, + stringifyPseuplexMetadataKeyFromIDStrings, +} from './metadataidentifier'; +import { PseuplexMetadataTransformOptions } from './metadata'; +import { parseStringQueryParam } from '../utils/queryparams'; export type PseuplexHubPage = { hub: plexTypes.PlexHub; @@ -17,30 +17,27 @@ export type PseuplexHubPage = { more: boolean; } +export const HubStartTokenQueryParam = 'hubStartToken'; + export type PseuplexHubPageParams = plexTypes.PlexHubPageParams & { - listStartToken?: string | null | undefined; + hubStartToken?: string | null | undefined; +}; + +export const parsePseuplexHubPageParams = (req: express.Request, options: plexTypes.ParsePlexHubPageParamsOptions): PseuplexHubPageParams => { + const hubPageParams: PseuplexHubPageParams = plexTypes.parsePlexHubPageParams(req, options); + if(!options.fromListPage) { + const hubStartToken = parseStringQueryParam(req.query[HubStartTokenQueryParam]); + if(hubStartToken !== undefined) { + hubPageParams.hubStartToken = hubStartToken; + } + } + return hubPageParams; }; export type PseuplexHubSectionInfo = { id: string; title: string; - uuid: string; -}; - -export type PseuplexHubMetadataTransformOptions = { - metadataTransformOptions?: PseuplexMetadataPathTransformOptions; - includeMetadataUnavailability: boolean; -}; - -export const getMetadataTransformOptionsForHub = (metadataProviderBasePath: string, options: PseuplexHubMetadataTransformOptions): PseuplexMetadataTransformOptions => { - return options.metadataTransformOptions ? { - ...options.metadataTransformOptions, - includeMetadataUnavailability: options.includeMetadataUnavailability, - } : { - metadataBasePath: metadataProviderBasePath, - qualifiedMetadataIds: false, - includeMetadataUnavailability: options.includeMetadataUnavailability, - }; + uuid?: string; }; export abstract class PseuplexHub { @@ -81,28 +78,18 @@ export abstract class PseuplexHub { async getHubListEntry(params: PseuplexHubPageParams, context: PseuplexRequestContext): Promise { const page = await this.get(params, context); - let transformOpts = this.metadataTransformOptions; - let metadataBasePath = transformOpts.metadataBasePath; - if(metadataBasePath && !metadataBasePath.endsWith('/')) { - metadataBasePath += '/'; - } - const metadataIds = page.items + const metadataIds: string[] = page.items .map((item) => { - let metadataId = parseMetadataIDFromKey(item.key, metadataBasePath)?.id; + let metadataId = parsePseuplexMetadataKey(item.key)?.id; if (!metadataId) { metadataId = item.ratingKey; - if(metadataId && !transformOpts.qualifiedMetadataIds) { - // unqualify metadata id - const fullMetadataIdParts = parseMetadataID(metadataId); - metadataId = stringifyPartialMetadataID(fullMetadataIdParts); - } } - return metadataId; + return metadataId!; }) .filter((metadataId) => metadataId); return { ...page.hub, - hubKey: (metadataIds.length > 0 ? `${metadataBasePath}${metadataIds.join(',')}` : undefined) as string, + hubKey: stringifyPseuplexMetadataKeyFromIDStrings(metadataIds), size: (page.items?.length ?? 0), more: page.more, Metadata: page.items @@ -112,6 +99,14 @@ export abstract class PseuplexHub { +export const pseuplexHubPageParamsFromHubListParams = (hubListParams: plexTypes.PlexHubPageParams) => { + const hubPageParams: PseuplexHubPageParams = plexTypes.plexHubPageParamsFromHubListParams(hubListParams); + delete hubPageParams.hubStartToken; + return hubPageParams; +}; + + + export abstract class PseuplexHubProvider { readonly cache: CachedFetcher; @@ -123,6 +118,7 @@ export abstract class PseuplexHubProvider); abstract fetch(id: string): (THub | Promise); + abstract path(id: string): string; async get(id: string): Promise { if(id == null) { @@ -134,13 +130,3 @@ export abstract class PseuplexHubProvider { - const params: plexTypes.PlexHubListPageParams = {...hubListParams}; - delete params.count; - delete (params as PseuplexHubPageParams).start; - delete (params as PseuplexHubPageParams).listStartToken; - return params; -}; diff --git a/src/pseuplex/idmappings.ts b/src/pseuplex/idmappings.ts index 1fefbbd..25a3f5c 100644 --- a/src/pseuplex/idmappings.ts +++ b/src/pseuplex/idmappings.ts @@ -1,6 +1,8 @@ -import { parseMetadataIDFromKey } from '../plex/metadataidentifier'; import { PseuplexMetadataSource } from './types'; -import { parseMetadataID } from './metadataidentifier'; +import { + parsePseuplexMetadataID, + parsePseuplexMetadataKey, +} from './metadataidentifier'; export type PseuplexPrivateToPublicIDsMap = { [privateId: string]: (number | string) @@ -50,16 +52,13 @@ export class PseuplexIDRemappings { getPublicSanitizedMetadataKey(metadataKey: string, metadataRatingKey: (string | undefined), privateToPublicIds?: PseuplexPrivateToPublicIDsMap | undefined): string { // check if ID needs to be mapped - let metadataKeyParts = parseMetadataIDFromKey(metadataKey, '/library/metadata/'); - let metadataIdString = metadataKeyParts?.id; + let metadataKeyParts = parsePseuplexMetadataKey(metadataKey); + const metadataIdString = metadataKeyParts?.id || metadataRatingKey; if(!metadataIdString) { - metadataIdString = metadataRatingKey; - if(!metadataIdString) { - // failed to find the ID of the item - return metadataKey; - } + // failed to find the ID of the item + return metadataKey; } - const metadataId = parseMetadataID(metadataIdString); + const metadataId = parsePseuplexMetadataID(metadataIdString); if(!metadataId.source || metadataId.source == PseuplexMetadataSource.Plex) { // don't map plex IDs return metadataKey; @@ -71,7 +70,7 @@ export class PseuplexIDRemappings { } getPublicSanitizedMetadataRatingKey(metadataRatingKey: string, privateToPublicIds?: PseuplexPrivateToPublicIDsMap | undefined) : string { - const metadataId = parseMetadataID(metadataRatingKey); + const metadataId = parsePseuplexMetadataID(metadataRatingKey); if(!metadataId.source || metadataId.source == PseuplexMetadataSource.Plex) { // don't map plex IDs return metadataRatingKey; diff --git a/src/pseuplex/index.ts b/src/pseuplex/index.ts index 1702745..d9c05c5 100644 --- a/src/pseuplex/index.ts +++ b/src/pseuplex/index.ts @@ -10,4 +10,6 @@ export * from './matching'; export * from './media'; export * from './notifications'; export * from './plugin'; +export * from './requesthandling'; +export * from './router'; export * from './section'; diff --git a/src/pseuplex/metadata.ts b/src/pseuplex/metadata.ts index 377080d..d678705 100644 --- a/src/pseuplex/metadata.ts +++ b/src/pseuplex/metadata.ts @@ -5,7 +5,6 @@ import * as plexTypes from '../plex/types'; import * as plexServerAPI from '../plex/api'; import { removeFileParamsFromMetadataParams } from '../plex/api/serialization'; import { - parseMetadataIDFromKey, parsePlexMetadataGuid, parsePlexMetadataGuidOrThrow, } from '../plex/metadataidentifier'; @@ -25,9 +24,10 @@ import { findMatchingPlexMetadataItem, } from './matching'; import { - parsePartialMetadataID, + parsePartialPseuplexMetadataID, PseuplexPartialMetadataIDString, - stringifyMetadataID + stringifyPseuplexMetadataID, + stringifyPseuplexMetadataKeyFromIDString } from './metadataidentifier'; import { PseuplexHubProvider @@ -54,12 +54,8 @@ export type PseuplexMetadataProviderParams = { includeUnmatched?: boolean; // Indicates whether to transform the keys of items matched to plex server items transformMatchKeys?: boolean; - // The base path to use when transforming metadata keys - metadataBasePath?: string; - // Whether to use full metadata IDs in the transformed metadata keys - qualifiedMetadataIds?: boolean; - // Whether to include the "unavailable" status on the metadata if it's not available - includeMetadataUnavailability: boolean; + // Options when transforming metadata into plex metadata + metadataTransformOptions: PseuplexMetadataTransformOptions; // Parameters to use when sending plex metadata requests plexParams?: plexTypes.PlexMetadataPageParams; }; @@ -91,7 +87,7 @@ export type PseuplexRelatedHubsParams = { }; export type PseuplexPartialMetadataIDsFromKey = { - ids: string[]; + ids: PseuplexPartialMetadataIDString[]; relativePath?: string; }; @@ -101,30 +97,19 @@ export interface PseuplexMetadataProvider { get(ids: string[], options: PseuplexMetadataProviderParams): Promise; getChildren(id: string, options: PseuplexMetadataChildrenProviderParams): Promise; getRelatedHubs(id: string, options: PseuplexRelatedHubsParams): Promise; - - metadataIdsFromKey(metadataKey: string): PseuplexPartialMetadataIDsFromKey | null; } -export type PseuplexSimilarItemsHubProvider = PseuplexHubProvider & { - relativePath: string -}; - export type PseuplexMetadataProviderOptions = { - basePath: string; section?: PseuplexSection; plexMetadataClient: PlexClient; - relatedHubsProviders?: PseuplexSimilarItemsHubProvider[]; + relatedHubsProviders?: PseuplexHubProvider[]; plexIdToInfoCache?: PlexIdToInfoCache; logger?: Logger; requestExecutor?: RequestExecutor; }; -export type PseuplexMetadataPathTransformOptions = { - metadataBasePath: string; - qualifiedMetadataIds: boolean; -} - -export type PseuplexMetadataTransformOptions = PseuplexMetadataPathTransformOptions & { +export type PseuplexMetadataTransformOptions = { + // Whether to include the "unavailable" status on the metadata if it's not available includeMetadataUnavailability: boolean; }; @@ -145,19 +130,17 @@ export abstract class PseuplexMetadataProviderBase implements Pse abstract readonly sourceDisplayName: string; abstract readonly sourceSlug: string; - readonly basePath: string; readonly section?: PseuplexSection; readonly plexMetadataClient: PlexClient; readonly logger?: Logger; readonly requestExecutor?: RequestExecutor; - readonly relatedHubsProviders?: PseuplexSimilarItemsHubProvider[]; + readonly relatedHubsProviders?: PseuplexHubProvider[]; readonly idToPlexGuidCache: CachedFetcher; readonly plexGuidToIDCache: CachedFetcher; readonly plexIdToInfoCache?: PlexIdToInfoCache; constructor(options: PseuplexMetadataProviderOptions) { - this.basePath = options.basePath; this.section = options.section; this.plexMetadataClient = options.plexMetadataClient; this.logger = options.logger; @@ -261,6 +244,12 @@ export abstract class PseuplexMetadataProviderBase implements Pse if(plexInfo.Guid && plexInfo.Guid.length > 0) { metadataItem.Guid = plexInfo.Guid; } + if(plexInfo.thumb) { + metadataItem.thumb = plexInfo.thumb; + } + if(plexInfo.year) { + metadataItem.year = plexInfo.year; + } } catch(error) { console.error(`Failed to attach plex data to metadata ${metadataId} :`); console.error(error); @@ -328,16 +317,7 @@ export abstract class PseuplexMetadataProviderBase implements Pse const plexGuids: {[id: PseuplexPartialMetadataIDString]: Promise | string | null | undefined} = {}; const plexMatches: {[id: PseuplexPartialMetadataIDString]: (Promise | plexTypes.PlexMetadataItem | null)} = {}; const providerItems: {[id: PseuplexPartialMetadataIDString]: TMetadataItem | Promise} = {}; - const transformOpts: PseuplexMetadataTransformOptions = { - qualifiedMetadataIds: options.qualifiedMetadataIds ?? false, - metadataBasePath: options.metadataBasePath ?? this.basePath, - includeMetadataUnavailability: options.includeMetadataUnavailability, - }; - const externalPlexTransformOpts: PseuplexMetadataTransformOptions = { - qualifiedMetadataIds: true, - metadataBasePath: '/library/metadata', - includeMetadataUnavailability: options.includeMetadataUnavailability, - }; + const transformOpts: PseuplexMetadataTransformOptions = options.metadataTransformOptions; const plextvMetadataParams = removeFileParamsFromMetadataParams(plexParams ?? {}); // process each id for(const id of ids) { @@ -434,7 +414,7 @@ export abstract class PseuplexMetadataProviderBase implements Pse // (otherwise, we will try to make a plex discover query later in this function) const matchMetadata = await plexMatches[id]; if(matchMetadata?.guid && !plexMetadataMap[matchMetadata.guid]) { - plexMetadataMap[matchMetadata.guid] = extPlexTransform.transformExternalPlexMetadata(matchMetadata, this.plexMetadataClient.serverURL, context, externalPlexTransformOpts); + plexMetadataMap[matchMetadata.guid] = extPlexTransform.transformExternalPlexMetadata(matchMetadata, this.plexMetadataClient.serverURL, context, transformOpts); } } } @@ -442,7 +422,16 @@ export abstract class PseuplexMetadataProviderBase implements Pse // get any remaining guids from plex discover const remainingGuids = guidsToFetch.filter((guid) => !plexMetadataMap[guid]); if(remainingGuids.length > 0) { - const plexIdsToFetch: string[] = remainingGuids.map((guid) => parsePlexMetadataGuid(guid)?.id).filter((id) => id) as string[]; + const plexIdsToFetch: string[] = remainingGuids + /*.map((guid) => parsePlexMetadataGuid(guid)) + .filter((guidParts) => + guidParts?.protocol == plexTypes.PlexMetadataGuidProtocol.Plex + && guidParts.type + && guidParts.id) + .map((guidParts) => guidParts!.id);*/ + // i think we can safely assume these are all plex guids, since we got them from plex + .map((guid) => parsePlexMetadataGuid(guid)?.id) + .filter((id) => id) as string[]; const discoverTask = this.plexMetadataClient.getMetadata(plexIdsToFetch, plextvMetadataParams); // cache result if needed if(this.plexIdToInfoCache) { @@ -472,7 +461,7 @@ export abstract class PseuplexMetadataProviderBase implements Pse } for(const metadata of metadatas) { if(metadata.guid) { - plexMetadataMap[metadata.guid] = extPlexTransform.transformExternalPlexMetadata(metadata, this.plexMetadataClient.serverURL, context, externalPlexTransformOpts); + plexMetadataMap[metadata.guid] = extPlexTransform.transformExternalPlexMetadata(metadata, this.plexMetadataClient.serverURL, context, transformOpts); } } } @@ -490,20 +479,14 @@ export abstract class PseuplexMetadataProviderBase implements Pse // if the item isn't on the server, the key or ratingKey will be invalid, so in this case we also want to transform the keys if(options.transformMatchKeys || !metadataItem.Pseuplex.isOnServer) { // get full metadata id - const idParts = parsePartialMetadataID(id); - const fullMetadataId = stringifyMetadataID({ + const idParts = parsePartialPseuplexMetadataID(id); + const fullMetadataId = stringifyPseuplexMetadataID({ ...idParts, source: this.sourceSlug, isURL: false }); // transform keys back to the original key used to fetch this item - let metadataId: string; - if(transformOpts.qualifiedMetadataIds) { - metadataId = fullMetadataId; - } else { - metadataId = id; - } - metadataItem.key = `${transformOpts.metadataBasePath}/${metadataId}`; + metadataItem.key = stringifyPseuplexMetadataKeyFromIDString(fullMetadataId); //metadataItem.slug = fullMetadataId; // if the item is on the server, we want to leave the original ratingKey, // so that the plex server items will be fetched directly if any additional request is made @@ -551,21 +534,27 @@ export abstract class PseuplexMetadataProviderBase implements Pse // we don't have a way to fetch children in this provider if(options.includePlexDiscoverMatches && this.plexMetadataClient) { // fetch the children from plex discover - const extPlexTransformOpts: PseuplexMetadataTransformOptions = { - metadataBasePath: options.metadataBasePath || '/library/metadata', - qualifiedMetadataIds: options.qualifiedMetadataIds ?? true, + const transformOpts: PseuplexMetadataTransformOptions = { includeMetadataUnavailability: options.includeMetadataUnavailability, }; // get the guid for the given id const guid = await this.getPlexGUIDForID(id, context); if(guid) { - // fetch the children from plex discover + // fetch the children from plex discover if guid is a plex guid const plexGuidParts = parsePlexMetadataGuidOrThrow(guid); - const mappedMetadataPage: PseuplexMetadataPage = await this.plexMetadataClient.getMetadataChildren(plexGuidParts.id, plexParams) as PseuplexMetadataPage; - mappedMetadataPage.MediaContainer.Metadata = (await transformArrayOrSingleAsyncParallel(mappedMetadataPage.MediaContainer.Metadata, async (metadataItem) => { - return extPlexTransform.transformExternalPlexMetadata(metadataItem, this.plexMetadataClient.serverURL, context, extPlexTransformOpts); - }))!; - return mappedMetadataPage; + if(plexGuidParts.protocol == plexTypes.PlexMetadataGuidProtocol.Plex + && plexGuidParts.type + && plexGuidParts.id + ) { + // fetch from plex discover and remap + const mappedMetadataPage: PseuplexMetadataPage = await this.plexMetadataClient.getMetadataChildren(plexGuidParts.id, plexParams) as PseuplexMetadataPage; + mappedMetadataPage.MediaContainer.Metadata = (await transformArrayOrSingleAsyncParallel(mappedMetadataPage.MediaContainer.Metadata, async (metadataItem) => { + return extPlexTransform.transformExternalPlexMetadata(metadataItem, this.plexMetadataClient.serverURL, context, transformOpts); + }))!; + return mappedMetadataPage; + } else { + console.error(`Invalid plex guid ${guid}. Cannot fetch metadata children.`); + } } } return { @@ -577,8 +566,6 @@ export abstract class PseuplexMetadataProviderBase implements Pse } // we have the fetchMetadataItemChildren method, so we can call it const transformOpts: PseuplexMetadataTransformOptions = { - metadataBasePath: options.metadataBasePath || this.basePath, - qualifiedMetadataIds: options.qualifiedMetadataIds ?? false, includeMetadataUnavailability: options.includeMetadataUnavailability, }; const childItemsPage = await this.fetchMetadataItemChildren(id, { @@ -602,12 +589,14 @@ export abstract class PseuplexMetadataProviderBase implements Pse let hubEntries: plexTypes.PlexHubWithItems[] = []; if(this.relatedHubsProviders && this.relatedHubsProviders.length > 0) { const relatedHubs = (await Promise.all(this.relatedHubsProviders.map(async (hubProvider) => { + let hubPath: (string | undefined); try { + hubPath = hubProvider.path(id); const hub = await hubProvider.get(id); const hubListEntry = await hub.getHubListEntry(options.plexParams ?? {}, options.context); return [hubListEntry] } catch(error) { - console.error(`Error fetching related hub ${hubProvider.relativePath} for metadata id ${id} :`); + console.error(`Error fetching related hub ${hubPath ?? hubProvider} for metadata id ${id} :`); console.error(error); return []; } @@ -623,18 +612,4 @@ export abstract class PseuplexMetadataProviderBase implements Pse } }; } - - - - metadataIdsFromKey(metadataKey: string): PseuplexPartialMetadataIDsFromKey | null { - const metadataKeyParts = parseMetadataIDFromKey(metadataKey, this.basePath, false); - if(!metadataKeyParts) { - return null; - } - const ids = metadataKeyParts.id.split(','); - return { - ids, - relativePath: metadataKeyParts.relativePath - }; - } } diff --git a/src/pseuplex/metadataAccessCache.ts b/src/pseuplex/metadataAccessCache.ts index fda875a..09fc4c4 100644 --- a/src/pseuplex/metadataAccessCache.ts +++ b/src/pseuplex/metadataAccessCache.ts @@ -1,5 +1,5 @@ import { PseuplexMetadataProvider } from './metadata'; -import { qualifyPartialMetadataID } from './metadataidentifier'; +import { qualifyPartialPseuplexMetadataID } from './metadataidentifier'; import { PseuplexMetadataItem, PseuplexRequestContext } from './types'; export type PseuplexMetadataAccessCacheOptions = { @@ -57,7 +57,7 @@ export class PseuplexMetadataAccessCache { if(!plexGuid) { return; } - const fullMetadataId = qualifyPartialMetadataID(metadataId, metadataProvider.sourceSlug); + const fullMetadataId = qualifyPartialPseuplexMetadataID(metadataId, metadataProvider.sourceSlug); this.addMetadataAccessEntry(plexGuid, fullMetadataId, metadataKey, context); } diff --git a/src/pseuplex/metadataidentifier.ts b/src/pseuplex/metadataidentifier.ts index dff16b2..331128e 100644 --- a/src/pseuplex/metadataidentifier.ts +++ b/src/pseuplex/metadataidentifier.ts @@ -1,7 +1,16 @@ import qs from 'querystring'; -import { plexLibraryMetadataPathToHubsMetadataPath } from '../plex/metadataidentifier'; +import { + parsePlexMetadataKeyOrThrow, + parsePlexPluralMetadataKeyOrThrow, + PlexLibraryMetadataBasePath, + plexLibraryMetadataPathToHubsMetadataPath, + PlexPluralMetadataKeyParts, + PlexSingularMetadataKeyParts +} from '../plex/metadataidentifier'; import { PseuplexRelatedHubsSource } from './metadata'; +import { PseuplexMetadataItem } from './types'; + export type PseuplexMetadataIDParts = { isURL?: boolean; @@ -19,7 +28,7 @@ export type PseuplexMetadataIDString = | `${string}://${string}/${string}` | `${string}://${string}/${string}${string}`; -export const parseMetadataID = (idString: PseuplexMetadataIDString): PseuplexMetadataIDParts => { +export const parsePseuplexMetadataID = (idString: PseuplexMetadataIDString): PseuplexMetadataIDParts => { // find metadata source / protocol let delimiterIndex = idString.indexOf(':'); if(delimiterIndex === -1) { @@ -112,7 +121,146 @@ export const parseMetadataID = (idString: PseuplexMetadataIDString): PseuplexMet }; }; -export const stringifyMetadataID = (idParts: PseuplexMetadataIDParts): PseuplexMetadataIDString => { + + +export const unescapeMetadataIdStringIfNeeded = (metadataIdString: string): PseuplexMetadataIDString => { + if(!metadataIdString) { + return metadataIdString; + } + if(metadataIdString.indexOf(':') == -1 && metadataIdString.indexOf('%') != -1) { + return qs.unescape(metadataIdString); + } + return metadataIdString; +}; + + +export const parsePseuplexMetadataKeyOrThrow = (metadataKey: string): PlexSingularMetadataKeyParts => { + const metadataKeyParts = parsePlexMetadataKeyOrThrow(metadataKey); + // only unescape if the key is definitely not plural + if(metadataKeyParts.id.indexOf(',') == -1) { + if(metadataKeyParts.id.indexOf(':') == -1 && metadataKeyParts.id.indexOf('%') != -1) { + metadataKeyParts.id = qs.unescape(metadataKeyParts.id); + // TODO maybe log a warning if there's a comma after unescaping? + } + } + return metadataKeyParts; +}; + +export const parsePseuplexMetadataKey = (metadataKey: string, warnOnFailure: boolean = true): (PlexSingularMetadataKeyParts | null) => { + try { + return parsePseuplexMetadataKeyOrThrow(metadataKey); + } catch(error) { + if(warnOnFailure) { + console.warn((error as Error).message); + } + return null; + } +}; + + + +export type PseuplexSingularMetadataKeyParts = { + basePath: string; + idParts: PseuplexMetadataIDParts; + relativePath?: string; +}; + +export const parsePseuplexKeyAndIDOrThrow = (metadataKey: string): PseuplexSingularMetadataKeyParts => { + const metadataKeyParts = parsePseuplexMetadataKeyOrThrow(metadataKey); + const metadataIdString = metadataKeyParts.id; + const pseuMetadataKeyParts = (metadataKeyParts as Partial); + delete (metadataKeyParts as Partial).id; + pseuMetadataKeyParts.idParts = parsePseuplexMetadataID(metadataIdString); + return pseuMetadataKeyParts as PseuplexSingularMetadataKeyParts; +}; + +export const parsePseuplexMetadataKeyAndID = (metadataKey: string, warnOnFailure: boolean = true): (PseuplexSingularMetadataKeyParts | null) => { + try { + return parsePseuplexKeyAndIDOrThrow(metadataKey); + } catch(error) { + if(warnOnFailure) { + console.warn((error as Error).message); + } + return null; + } +}; + + + +export const parsePseuplexPluralMetadataKeyOrThrow = (metadataKey: string): PlexPluralMetadataKeyParts => { + const metadataKeyParts = parsePlexPluralMetadataKeyOrThrow(metadataKey); + metadataKeyParts.ids = metadataKeyParts.ids.map((idString) => { + return unescapeMetadataIdStringIfNeeded(idString); + }); + return metadataKeyParts; +}; + +export const parsePseuplexPluralMetadataKey = (metadataKey: string, warnOnFailure: boolean = true): (PlexPluralMetadataKeyParts | null) => { + try { + return parsePseuplexPluralMetadataKeyOrThrow(metadataKey); + } catch(error) { + if(warnOnFailure) { + console.warn((error as Error).message); + } + return null; + } +}; + + + +export type PsuplexPluralMetadataKeyParts = { + basePath: string; + idsParts: PseuplexMetadataIDParts[]; + relativePath?: string; +}; + +export const parsePseuplexMetadataKeyAndIDsOrThrow = (metadataKey: string): PsuplexPluralMetadataKeyParts => { + const metadataKeyParts = parsePseuplexPluralMetadataKeyOrThrow(metadataKey); + const metadataIdStrings = metadataKeyParts.ids; + const pseuMetadataKeyParts = (metadataKeyParts as Partial); + delete (metadataKeyParts as Partial).ids; + pseuMetadataKeyParts.idsParts = metadataIdStrings.map((idString) => parsePseuplexMetadataID(idString)); + return pseuMetadataKeyParts as PsuplexPluralMetadataKeyParts; +}; + +export const parsePseuplexMetadataKeyAndIDs = (metadataKey: string, warnOnFailure: boolean = true): (PsuplexPluralMetadataKeyParts | null) => { + try { + return parsePseuplexMetadataKeyAndIDsOrThrow(metadataKey); + } catch(error) { + if(warnOnFailure) { + console.warn((error as Error).message); + } + return null; + } +}; + + + +export const parsePseuplexMetadataIDStringFromItem = (metadataItem: PseuplexMetadataItem, warnOnFailure: boolean = true): PseuplexMetadataIDString | null => { + if(metadataItem.ratingKey) { + return metadataItem.ratingKey; + } + const metadataKeyParts = parsePseuplexMetadataKey(metadataItem.key, warnOnFailure); + if(metadataKeyParts) { + return metadataKeyParts.id; + } + if(warnOnFailure) { + console.warn(`No metadata ID could be found on metadata item ${metadataItem.title}`); + } + return null; +}; + +export const parsePseuplexMetadataIDFromItem = (metadataItem: PseuplexMetadataItem, warnOnFailure: boolean = true): PseuplexMetadataIDParts | null => { + const metadataIdString = parsePseuplexMetadataIDStringFromItem(metadataItem, warnOnFailure); + if(!metadataIdString) { + return null; + } + return parsePseuplexMetadataID(metadataIdString); +}; + + + +export const stringifyPseuplexMetadataID = (idParts: PseuplexMetadataIDParts): PseuplexMetadataIDString => { let idString: string; if(idParts.isURL) { if(idParts.directory == null && idParts.relativePath == null) { @@ -143,6 +291,39 @@ export const stringifyMetadataID = (idParts: PseuplexMetadataIDParts): PseuplexM return idString; }; +export const stringifyPseuplexMetadataKeyFromIDString = (idString: PseuplexMetadataIDString | number, relativePath?: string) => { + const escMetadataId = qs.escape(idString.toString()); + let metadataKey = `${PlexLibraryMetadataBasePath}/${escMetadataId}`; + if(relativePath) { + metadataKey += relativePath; + } + return metadataKey; +}; + +export const stringifyPseuplexMetadataKeyFromIDStrings = (idStrings: (PseuplexMetadataIDString | number)[], relativePath?: string) => { + const escMetadataIds = idStrings.map((idStr) => qs.escape(idStr.toString())).join(','); + let metadataKey = `${PlexLibraryMetadataBasePath}/${escMetadataIds}`; + if(relativePath) { + metadataKey += relativePath; + } + return metadataKey; +} + +export const stringifyPseuplexMetadataKeyAndID = (keyParts: PseuplexSingularMetadataKeyParts) => { + const metadataId = stringifyPseuplexMetadataID(keyParts.idParts); + let metadataKey = `${keyParts.basePath}/${qs.escape(metadataId)}`; + if(keyParts.relativePath) { + metadataKey += keyParts.relativePath; + } + return metadataKey; +}; + +export const stringifyPseuplexPluralMetadataKey = (keyParts: PlexPluralMetadataKeyParts) => { + return `${keyParts.basePath}${keyParts.ids.map((mid) => qs.escape(mid)).join(',')}${keyParts.relativePath ?? ''}`; +}; + + + export type PseuplexPartialMetadataIDParts = { directory?: string; id: string; @@ -152,7 +333,7 @@ export type PseuplexPartialMetadataIDString = `${string}` | `${string}:${string}`; -export const parsePartialMetadataID = (metadataId: PseuplexPartialMetadataIDString): PseuplexPartialMetadataIDParts => { +export const parsePartialPseuplexMetadataID = (metadataId: PseuplexPartialMetadataIDString): PseuplexPartialMetadataIDParts => { let colonIndex = metadataId.indexOf(':'); if(colonIndex == -1) { return {id:qs.unescape(metadataId)}; @@ -163,7 +344,9 @@ export const parsePartialMetadataID = (metadataId: PseuplexPartialMetadataIDStri }; }; -export const stringifyPartialMetadataID = (idParts: PseuplexPartialMetadataIDParts): PseuplexPartialMetadataIDString => { + + +export const stringifyPartialPseuplexMetadataID = (idParts: PseuplexPartialMetadataIDParts): PseuplexPartialMetadataIDString => { if(idParts.directory == null) { return qs.escape(idParts.id); } else { @@ -171,10 +354,12 @@ export const stringifyPartialMetadataID = (idParts: PseuplexPartialMetadataIDPar } }; -export const qualifyPartialMetadataID = (metadataId: PseuplexPartialMetadataIDString, source: string) => { +export const qualifyPartialPseuplexMetadataID = (metadataId: PseuplexPartialMetadataIDString, source: string) => { return `${source}:${metadataId}`; }; + + export const getPlexRelatedHubsEndpoints = (metadataEndpoint: string): { endpoint: string, hubsSource: PseuplexRelatedHubsSource, diff --git a/src/pseuplex/playlist.ts b/src/pseuplex/playlist.ts index a71677c..59645bd 100644 --- a/src/pseuplex/playlist.ts +++ b/src/pseuplex/playlist.ts @@ -1,7 +1,6 @@ import qs from 'querystring'; import * as plexTypes from '../plex/types'; -import { parseMetadataIDFromKey } from '../plex/metadataidentifier'; import { CachedFetcher } from '../fetching/CachedFetcher'; @@ -29,7 +28,7 @@ export type PseuplexPlaylistContext = { export abstract class PseuplexPlaylist { abstract get(params: PseuplexPlaylistPageParams, context: PseuplexPlaylistContext): Promise; - async getPlaylist(params: PseuplexPlaylistParams, context: PseuplexPlaylistContext): Promise { + async getPlaylist(params: PseuplexPlaylistParams, context: PseuplexPlaylistContext): Promise { const page = await this.get({ ...params, count: 0 diff --git a/src/pseuplex/plugin.ts b/src/pseuplex/plugin.ts index 28838ac..ff2cd12 100644 --- a/src/pseuplex/plugin.ts +++ b/src/pseuplex/plugin.ts @@ -10,7 +10,8 @@ import { PseuplexMetadataIDParts, PseuplexPartialMetadataIDString } from './metadataidentifier'; -import { PseuplexSection } from './section'; +import { PseuplexAllSectionsSource, PseuplexSection } from './section'; +import { PseuplexRouterApp } from './router'; export type PseuplexResponseFilterContext = { @@ -20,6 +21,10 @@ export type PseuplexResponseFilterContext = { previousFilterPromises?: Promise[]; }; +export type PseuplexSectionsFilterContext = PseuplexResponseFilterContext & { + from: PseuplexAllSectionsSource; +}; + export type PseuplexMetadataResponseFilterContext = PseuplexResponseFilterContext & { metadataIds: PseuplexMetadataIDParts[]; }; @@ -51,6 +56,7 @@ export type PseuplexSectionHubsResponseFilterContext = PseuplexResponseFilterCon export type PseuplexResponseFilter = (resData: TResponseData, context: TContext) => void | Promise; export type PseuplexResponseFilters = { mediaProviders?: PseuplexResponseFilter; + sections?: PseuplexResponseFilter; hubs?: PseuplexResponseFilter; promotedHubs?: PseuplexResponseFilter; sectionHubs?: PseuplexResponseFilter; @@ -58,9 +64,6 @@ export type PseuplexResponseFilters = { metadataChildren?: PseuplexResponseFilter; metadataRelatedHubs?: PseuplexResponseFilter; findGuidInLibrary?: PseuplexResponseFilter; - - metadataFromProvider?: PseuplexResponseFilter; - metadataRelatedHubsFromProvider?: PseuplexResponseFilter; }; export type PseuplexResponseFilterName = keyof PseuplexResponseFilters; export type PseuplexReadOnlyResponseFilters = { @@ -73,8 +76,8 @@ export interface PseuplexPlugin { readonly hubs?: { readonly [hubName: string]: PseuplexHubProvider }; readonly responseFilters?: PseuplexReadOnlyResponseFilters; - defineRoutes?: (router: express.Express) => void; - defineFallbackRoutes?: (router: express.Express) => void; + defineRoutes?: (router: PseuplexRouterApp) => void; + defineFallbackRoutes?: (router: PseuplexRouterApp) => void; hasSections?: (context: PseuplexRequestContext) => Promise; getSections?: (context: PseuplexRequestContext) => Promise; shouldListenToPlexServerNotifications?: () => boolean; diff --git a/src/pseuplex/requesthandling.ts b/src/pseuplex/requesthandling.ts index 2329ceb..23e3122 100644 --- a/src/pseuplex/requesthandling.ts +++ b/src/pseuplex/requesthandling.ts @@ -6,8 +6,9 @@ import { PlexAPIRequestHandlerOptions } from '../plex/requesthandling'; import { - parseMetadataID, - PseuplexMetadataIDParts + parsePseuplexMetadataID, + PseuplexMetadataIDParts, + PseuplexMetadataIDString, } from './metadataidentifier'; import { PseuplexIDRemappings, @@ -20,25 +21,26 @@ import { httpError, } from '../utils/error'; -export const parseMetadataIdsFromPathParam = (metadataIdsString: string): PseuplexMetadataIDParts[] => { - if(!metadataIdsString) { - return []; - } - return metadataIdsString.split(',').map((metadataId) => { - if(metadataId.indexOf(':') == -1 && metadataId.indexOf('%') != -1) { - metadataId = qs.unescape(metadataId); - } - return parseMetadataID(metadataId); - }); + +export const parsePseuplexMetadataIDFromPathParam = (paramString: string): PseuplexMetadataIDParts => { + // express automatically unescapes the path parameters, so no need to unescape here + // TODO check if this logic works consistently. We might just want to check if we need to unescape anyways + return parsePseuplexMetadataID(paramString); +}; + +export const parsePseuplexMetadataIDStringsFromPathParam = (idsString: string): PseuplexMetadataIDString[] => { + // express automatically unescapes the path parameters, so no need to unescape here + // TODO check if this logic works consistently. We might just want to check if we need to unescape anyways + return idsString.split(','); }; -export const parseMetadataIdFromPathParam = (metadataIdString: string): PseuplexMetadataIDParts => { - if(metadataIdString.indexOf(':') == -1 && metadataIdString.indexOf('%') != -1) { - metadataIdString = qs.unescape(metadataIdString); - } - return parseMetadataID(metadataIdString); +export const parsePseuplexMetadataIDsFromPathParam = (idsString: string): PseuplexMetadataIDParts[] => { + return parsePseuplexMetadataIDStringsFromPathParam(idsString) + .map((m) => parsePseuplexMetadataID(idsString)); }; + + export type PseuplexRemappedMetadataIdsRequest = IncomingPlexAPIRequest & { remappedPlexMetadataIds: PseuplexPrivateToPublicIDsMap; }; @@ -52,14 +54,15 @@ export const remapPublicToPrivateMetadataIdMiddleware = ( const privateToPublicIds: {[key: string]: (number | string)} = {}; const metadataIdString = req.params.metadataId; if(metadataIdString) { - const metadataIdParts = parseMetadataIdFromPathParam(metadataIdString); + const metadataIdParts = parsePseuplexMetadataIDFromPathParam(metadataIdString); if(!metadataIdParts.source) { const privateId = metadataIdMappings.getPrivateIDFromPublicID(metadataIdParts.id); if(privateId != null) { // id is a mapped ID, so we need to handle the request privateToPublicIds[privateId] = metadataIdParts.id; const escapedPrivateId = qs.escape(privateId); - const queryIndex = req.url!.indexOf('?'); + // we should assume req.url refers to the full url here, and not a sub url + const queryIndex = req.url.indexOf('?'); const queryString = (queryIndex != -1 ? req.url.slice(queryIndex) : ''); const newUrl = replaceIdInPath(req, escapedPrivateId) + queryString; req.params.metadataId = escapedPrivateId; @@ -82,11 +85,11 @@ export const remapPublicToPrivateMetadataIdsMiddleware = ( const privateToPublicIds: {[key: string]: (number | string)} = {}; const metadataIdsString = req.params.metadataId; if(metadataIdsString) { - const metadataIdStrings = metadataIdsString.split(','); + const metadataIdStrings = parsePseuplexMetadataIDStringsFromPathParam(metadataIdsString); let idsChanged = false; for(let i=0; i( metadataId: PseuplexMetadataIDParts, ) => Promise, ) => { - return asyncRequestHandler(async (req: express.Request, res): Promise => { + return asyncRequestHandler(async (req: IncomingPlexAPIRequest, res: express.Response): Promise => { let metadataId = req.params.metadataId; if(!metadataId) { // let plex handle the empty api request return false; } - let metadataIdParts = parseMetadataIdFromPathParam(metadataId); + let metadataIdParts = parsePseuplexMetadataIDFromPathParam(metadataId); if(!metadataIdParts.source) { // id is a plex ID, so no need to handle this request return false; @@ -154,7 +158,7 @@ export const pseuplexMetadataIdsRequestMiddleware = ( if(!metadataIdsString) { throw httpError(400, "No ID provided"); } - const metadataIds = parseMetadataIdsFromPathParam(metadataIdsString); + const metadataIds = parsePseuplexMetadataIDsFromPathParam(metadataIdsString); // check if any non-plex metadata IDs exist let anyNonPlexIds: boolean = false; for(let i=0; i void) => void; +type UpgradeRequestErrorHandler = (error: Error, req: UpgradeRequest, res: UpgradeResponse, next: (error?: Error) => void) => void; +type UpgradeRequestHandlerParams = UpgradeRequestHandler | UpgradeRequestErrorHandler | Array; + + +export type UpgradeRequestRouter = ((req: UpgradeRequest, res: UpgradeResponse, next: (error?: Error) => void) => void) & { + use: ((path: string, handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter) + & ((handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter); + get: (path: string, handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter; + post: (path: string, handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter; + put: (path: string, handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter; + patch: (path: string, handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter; + delete: (path: string, handler: UpgradeRequestHandlerParams) => UpgradeRequestRouter; +}; + +export const createUpgradeRouter = (options: express.RouterOptions) => { + return new Router(options) as UpgradeRequestRouter; +}; + + +export type PseuplexPluginMetadataRouters = {[sourceSlug: string]: express.Router}; +export type PseuplexHubAsyncRequestHandler = (req: express.Request, res: express.Response) => Promise; +export type PseuplexRouterGetHubOptions = { + auth?: boolean, + hubArgParam?: string, +}; + + +export type PseuplexRouterApp = express.Express & { + get upgradeRouter(): UpgradeRequestRouter; + + provideHub(route: string, hubProvider: PseuplexHubProvider, options?: PseuplexRouterGetHubOptions); + + /*get pluginLibraryMetadataRouters(): PseuplexPluginMetadataRouters; + pluginLibraryMetadataRouter(sourceSlug: string): Router; + + get pluginHubsMetadataRouters(): PseuplexPluginMetadataRouters; + pluginHubsMetadataRouter(sourceSlug: string): Router; + + metadataRoutersForPlugin(sourceSlug: string): Router[];*/ +}; + +export const pseuplexRouterApp = (appRouter: express.Express, app: PseuplexApp): PseuplexRouterApp => { + + let upgradeRouter: UpgradeRequestRouter | null = null; + function getUpgradeRouter() { + if (upgradeRouter == null) { + upgradeRouter = createUpgradeRouter({ + caseSensitive: appRouter.enabled('case sensitive routing'), + strict: appRouter.enabled('strict routing'), + }); + } + return upgradeRouter; + } + + function provideHub(this: PseuplexRouterApp, route: string, hubProvider: PseuplexHubProvider, options?: PseuplexRouterGetHubOptions) { + const hubArgParam = options?.hubArgParam ?? 'hubArg'; + return this.get(route, [ + ...((options?.auth ?? true) ? [app.middlewares.plexAuthentication()] : []), + app.middlewares.plexAPIRequestHandler(async (req: IncomingPlexAPIRequest, res): Promise => { + const arg = req.params[hubArgParam]; + if(!arg) { + throw httpError(400, "No hub argument provided"); + } + const context = app.contextForRequest(req); + const hubParams = parsePseuplexHubPageParams(req, {fromListPage:false}); + const hub = await hubProvider.get(arg); + const hubPage = await hub.getHubPage(hubParams, context); + // TODO remap private metadata IDs to public ones + return hubPage; + }), + ]); + } + + /*let pluginLibraryMetadataRouters: PseuplexPluginMetadataRouters = {}; + function getOrCreatePluginLibraryMetadataRouter(source: string) { + let router = pluginLibraryMetadataRouters[source]; + if(!router) { + router = new Router({ + caseSensitive: appRouter.enabled('case sensitive routing'), + strict: appRouter.enabled('strict routing'), + mergeParams: true, + }); + pluginLibraryMetadataRouters[source] = router; + } + return router; + } + + let pluginHubsMetadataRouters: PseuplexPluginMetadataRouters = {}; + function getOrCreatePluginHubsMetadataRouter(source: string) { + let router = pluginHubsMetadataRouters[source]; + if(!router) { + router = new Router({ + caseSensitive: appRouter.enabled('case sensitive routing'), + strict: appRouter.enabled('strict routing'), + mergeParams: true, + }); + pluginHubsMetadataRouters[source] = router; + } + return router; + } + + function getOrCreatePluginMetadataRouters(source: string) { + const libraryRouter = getOrCreatePluginLibraryMetadataRouter(source); + const hubsRouter = getOrCreatePluginHubsMetadataRouter(source); + return [libraryRouter, hubsRouter]; + }*/ + + return Object.defineProperties(appRouter, { + upgradeRouter: { + configurable: true, + enumerable: true, + get: getUpgradeRouter, + }, + provideHub: { + configurable: true, + enumerable: true, + get: function() { + return provideHub; + } + }, + /*pluginLibraryMetadataRouter: { + configurable: true, + enumerable: true, + get: function() { + return getOrCreatePluginLibraryMetadataRouter; + } + }, + pluginLibraryMetadataRouters: { + configurable: true, + enumerable: true, + get: function() { + return pluginLibraryMetadataRouters; + } + }, + pluginHubsMetadataRouter: { + configurable: true, + enumerable: true, + get: function() { + return getOrCreatePluginHubsMetadataRouter; + } + }, + pluginHubsMetadataRouters: { + configurable: true, + enumerable: true, + get: function() { + return pluginHubsMetadataRouters; + } + }, + metadataRoutersForPlugin: { + configurable: true, + enumerable: true, + get: function() { + return getOrCreatePluginMetadataRouters; + } + }*/ + }) as PseuplexRouterApp; +}; diff --git a/src/pseuplex/section.ts b/src/pseuplex/section.ts index 6203635..268ba84 100644 --- a/src/pseuplex/section.ts +++ b/src/pseuplex/section.ts @@ -1,9 +1,9 @@ import express from 'express'; import * as plexTypes from '../plex/types'; import type { PseuplexRequestContext } from './types'; -import type { - PseuplexHub, - PseuplexHubPageParams +import { + pseuplexHubPageParamsFromHubListParams, + type PseuplexHub, } from './hub'; export interface PseuplexSection { @@ -19,8 +19,25 @@ export interface PseuplexSection { getLibrarySectionsEntry(params: plexTypes.PlexLibrarySectionsPageParams, context: PseuplexRequestContext): Promise; getPromotedHubsPage(params: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise; getHubsPage(params: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise; + getCollectionsPage(params: plexTypes.PlexCollectionsPageParams, context: PseuplexRequestContext): Promise; + getAllItemsPage(params: plexTypes.PlexSectionAllItemsParams, context: PseuplexRequestContext): Promise; + getPrefsPage(context: PseuplexRequestContext): Promise; } +export type PseuplexSectionItemsPage = { + items: plexTypes.PlexMetadataItem[]; + offset: number; + more: boolean; + totalItemCount?: number; +}; + +export type PseuplexSectionCollectionsPage = { + items: plexTypes.PlexCollection[]; + offset: number; + more: boolean; + totalItemCount: number; +}; + export type PseuplexSectionOptions = { allowSync?: boolean; id: string | number; @@ -29,6 +46,9 @@ export type PseuplexSectionOptions = { title: string; path: string; hubsPath: string; + agent?: plexTypes.PlexLibraryAgent; + scanner?: plexTypes.PlexLibraryScanner; + language?: string; // "en-US" hidden?: boolean; }; @@ -36,18 +56,25 @@ export class PseuplexSectionBase implements PseuplexSection { readonly id: string | number; readonly uuid?: string | undefined; readonly type: plexTypes.PlexMediaItemType; - readonly title: string; readonly path: string; readonly hubsPath: string; + title: string; + agent?: plexTypes.PlexLibraryAgent; + scanner?: plexTypes.PlexLibraryScanner; + language?: string; // "en-US" allowSync: boolean; + refreshing = false; constructor(options: PseuplexSectionOptions) { this.id = options.id; this.uuid = options.uuid; this.type = options.type ?? plexTypes.PlexMediaItemType.Mixed; - this.title = options.title; this.path = options.path; this.hubsPath = options.hubsPath; + this.title = options.title; + this.agent = options.agent; + this.scanner = options.scanner; + this.language = options.language; this.allowSync = options.allowSync ?? false; } @@ -82,14 +109,17 @@ export class PseuplexSectionBase implements PseuplexSection { title: await titlePromise, uuid: this.uuid, type: this.type, - refreshing: false, + refreshing: this.refreshing, + agent: this.agent, + scanner: this.scanner, + language: this.language, Pivot: await pivotsPromise, }; } async getPivots?(): Promise; - async getLibrarySectionsEntry(params: plexTypes.PlexLibrarySectionsPageParams, context: PseuplexRequestContext): Promise { + async getLibrarySectionsEntry(params: plexTypes.PlexLibrarySectionsPageParams, context: PseuplexRequestContext & {from: PseuplexAllSectionsSource}): Promise { const titlePromise = this.getTitle(context); return { allowSync: this.allowSync, @@ -97,31 +127,31 @@ export class PseuplexSectionBase implements PseuplexSection { uuid: this.uuid!, type: this.type, title: await titlePromise, - refreshing: false, + refreshing: this.refreshing, + agent: this.agent, + scanner: this.scanner, + language: this.language, filters: true, content: true, directory: true, }; } - + + getHubs?(params: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise; getPromotedHubs?(params: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise; - private async hubPageFromHubs( - params: plexTypes.PlexHubListPageParams, + private async hubPageFromHubs(options: { + plexParams: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext, hubsPromise: (PseuplexHub[] | Promise | undefined), promoted: boolean, - ): Promise { - const titlePromise = this.getTitle(context); - const hubs = (await hubsPromise) ?? []; - const hubPageParams: PseuplexHubPageParams = { - count: params.count, - includeMeta: params.includeMeta, - excludeFields: params.excludeFields - }; + }): Promise { + const titlePromise = this.getTitle(options.context); + const hubs = (await options.hubsPromise) ?? []; + const hubPageParams = pseuplexHubPageParamsFromHubListParams(options.plexParams); const hubEntriesPromise = Promise.all(hubs.map((hub) => { - return hub.getHubListEntry(hubPageParams, context) + return hub.getHubListEntry(hubPageParams, options.context); })); return { MediaContainer: { @@ -130,26 +160,105 @@ export class PseuplexSectionBase implements PseuplexSection { librarySectionID: this.id, librarySectionTitle: await titlePromise, librarySectionUUID: this.uuid!, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, Hub: await hubEntriesPromise, } }; } - async getHubsPage(params: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise { - return await this.hubPageFromHubs( - params, + async getHubsPage(plexParams: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise { + return await this.hubPageFromHubs({ + plexParams, context, - this.getHubs?.(params, context), - false - ); + hubsPromise: this.getHubs?.(plexParams, context), + promoted: false, + }); } - - async getPromotedHubsPage(params: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise { - return await this.hubPageFromHubs( - params, + + async getPromotedHubsPage(plexParams: plexTypes.PlexHubListPageParams, context: PseuplexRequestContext): Promise { + return await this.hubPageFromHubs({ + plexParams, context, - this.getHubs?.(params, context), - true - ); + hubsPromise: this.getPromotedHubs?.(plexParams, context), + promoted: true, + }); + } + + + getCollections?(plexParams: plexTypes.PlexCollectionsPageParams, context: PseuplexRequestContext): Promise; + + getCollectionsMeta?(plexParams: plexTypes.PlexCollectionsPageParams, context: PseuplexRequestContext): Promise; + + async getCollectionsPage(plexParams: plexTypes.PlexCollectionsPageParams, context: PseuplexRequestContext): Promise { + const titlePromise = this.getTitle(context); + const metaPromise = this.getCollectionsMeta?.(plexParams, context); + const chunk = await this.getCollections?.(plexParams, context); + const meta = await metaPromise; + const title = await titlePromise; + return { + MediaContainer: { + size: chunk?.items.length ?? 0, + totalSize: chunk ? chunk.totalItemCount : 0, + offset: chunk ? chunk.offset : 0, + allowSync: false, + content: plexTypes.PlexLibrarySectionContentType.Secondary, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, + librarySectionID: this.id, + librarySectionTitle: title, + librarySectionUUID: this.uuid!, + title1: title, + viewGroup: this.type, + Meta: meta, + Metadata: chunk?.items ?? [], + } + }; + } + + + getAllItems?(plexParams: plexTypes.PlexSectionAllItemsParams, context: PseuplexRequestContext): Promise; + + async getAllItemsPage(plexParams: plexTypes.PlexSectionAllItemsParams, context: PseuplexRequestContext): Promise { + const titlePromise = this.getTitle(context); + const itemsPage = await this.getAllItems?.(plexParams, context); + return { + MediaContainer: { + size: itemsPage?.items.length ?? 0, + totalSize: itemsPage ? itemsPage.totalItemCount : 0, + offset: itemsPage?.offset, + allowSync: false, + librarySectionID: this.id, + librarySectionTitle: await titlePromise, + librarySectionUUID: this.uuid!, + identifier: plexTypes.PlexPluginIdentifier.PlexAppLibrary, + Metadata: itemsPage?.items ?? [], + } + }; + } + + + getPrefs?(context: PseuplexRequestContext): Promise; + + async getPrefsPage(context: PseuplexRequestContext): Promise { + const prefItems = await this.getPrefs?.(context) ?? []; + return { + MediaContainer: { + size: prefItems.length, + Setting: prefItems, + } + }; } } + + +export enum PseuplexAllSectionsSource { + Sections = '', + AllSections = 'all', +}; + +export const endpointForPseuplexSectionsSource = (source: PseuplexAllSectionsSource) => { + let endpoint = '/library/sections'; + if(source) { + endpoint += `/${source}`; + } + return endpoint; +}; diff --git a/src/utils/compat.ts b/src/utils/compat.ts new file mode 100644 index 0000000..2619f5a --- /dev/null +++ b/src/utils/compat.ts @@ -0,0 +1,9 @@ +import path from 'path'; + +export const isRunningViaBun = () => { + return typeof Bun !== 'undefined'; +}; + +export const getModuleRootPath = () => { + return path.dirname(path.dirname(__dirname)); +}; diff --git a/src/utils/console.ts b/src/utils/console.ts index 660e7b0..89f6e69 100644 --- a/src/utils/console.ts +++ b/src/utils/console.ts @@ -1,4 +1,5 @@ +// Include the stack trace of the console.error call when logging error messages let includedTracesForWarnAndError = false; export const includeTracesForConsoleWarnAndError = () => { if(includedTracesForWarnAndError) { @@ -32,15 +33,80 @@ export const includeTracesForConsoleWarnAndError = () => { const innerError = console.error; console.error = function(...args) { - innerError.call(this, ...args, traceDividerString, errorTraceString(2)); + return innerError.call(this, ...args, traceDividerString, errorTraceString(2)); }; const innerWarn = console.warn; console.warn = function(...args) { - innerWarn.call(this, ...args, traceDividerString, errorTraceString(2)); + return innerWarn.call(this, ...args, traceDividerString, errorTraceString(2)); }; }; +// Include the log level before every log +let includedLogLevel = false; +export const includeLogLevelForAllLogs = () => { + if(includedLogLevel) { + console.warn("Already including pipe names for console. Skipping..."); + return; + } + includedLogLevel = true; + + function prependArg(args: any[], arg: string) { + args.splice(0, 0, arg); + } + + const innerError = console.error; + console.error = function(...args) { + prependArg(args, '[ERR]'); + return innerError.apply(this, args); + }; + + const innerWarn = console.warn; + console.warn = function(...args) { + prependArg(args, '[WARN]'); + return innerWarn.apply(this, args); + }; + + const innerLog = console.log; + console.log = function(...args) { + prependArg(args, '[LOG]'); + return innerLog.apply(this, args); + }; +}; + +// Include the current timestamp before every log +let includedTimestamps = false; +export const includeTimestampsForAllLogs = () => { + if(includedTimestamps) { + console.warn("Already including timestamps for console. Skipping..."); + return; + } + includedTimestamps = true; + + function insertTimestampArg(args: any[]) { + args.splice(0, 0, `[${(new Date()).toLocaleString()}]`); + } + + const innerError = console.error; + console.error = function(...args) { + insertTimestampArg(args); + return innerError.apply(this, args); + }; + + const innerWarn = console.warn; + console.warn = function(...args) { + insertTimestampArg(args); + return innerWarn.apply(this, args); + }; + + const innerLog = console.log; + console.log = function(...args) { + insertTimestampArg(args); + return innerLog.apply(this, args); + }; +}; + +// Modify the colors of warnings and errors let moddedColors = false; export const modConsoleColors = () => { if(moddedColors) { @@ -52,14 +118,16 @@ export const modConsoleColors = () => { const innerConsoleError = console.error; console.error = function (...args) { process.stderr.write('\x1b[31m'); - innerConsoleError.call(this, ...args); + let retVal = innerConsoleError.apply(this, args); process.stderr.write('\x1b[0m'); + return retVal }; const innerConsoleWarn = console.warn; console.warn = function (...args) { process.stderr.write('\x1b[33m'); - innerConsoleWarn.call(this, ...args); + let retVal = innerConsoleWarn.apply(this, args); process.stderr.write('\x1b[0m'); + return retVal; }; }; diff --git a/src/utils/error.ts b/src/utils/error.ts index 79f17be..883a7c8 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -3,6 +3,12 @@ export type HttpError = Error & { statusCode: number; }; +export type SilentErrorMixin = { + silent: boolean; +}; + +export type PossiblySilentError = Error & Partial; + export const httpError = (status: number, message: string, props?: {[key: string]: any}): HttpError => { const error = new Error(message) as HttpError; error.statusCode = status; diff --git a/src/utils/files.ts b/src/utils/files.ts index ceee0ce..980f327 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,25 +1,33 @@ import fs from 'fs'; import path from 'path'; +import type { Logger } from '../logging'; type WatchOptions = { debouncer?: ((callback: () => void) => void); + logger?: Logger; }; export const watchFilepathChanges = (filePath: string, opts: WatchOptions, callback: () => void): { close: () => void } => { const dirname = path.dirname(filePath); const filename = path.basename(filePath); let closed = false; + let watchingDir = false; let watcher: fs.FSWatcher; const dirWatcherCallback = (eventType: 'rename' | 'change', changedFilename: string) => { + opts.logger?.logWatchedDirectoryFileChanged(eventType, dirname, changedFilename); if(filename == changedFilename) { // watch file instead if it exists now if(fs.existsSync(filePath)) { watcher.close(); + opts.logger?.logStoppedWatchingDirectory(dirname); if(closed) { return; } watcher = fs.watch(filePath, fileWatcherCallback); + watchingDir = false; + opts.logger?.logWatchingFile(filePath); + // wait a short delay before sending the change event, in case it changes again if(opts.debouncer) { opts.debouncer(() => { if(closed || !fs.existsSync(filePath)) { @@ -30,25 +38,35 @@ export const watchFilepathChanges = (filePath: string, opts: WatchOptions, callb } else { callback(); } + } else { + // file doesn't exist anymore } + } else { + // change was for a different file, so ignore } }; const fileWatcherCallback = (eventType: 'rename' | 'change', changedFilename: string) => { + opts.logger?.logWatchedFileChanged(eventType, filePath, changedFilename); // switch to watching the directory if the file no longer exists if(!fs.existsSync(filePath)) { watcher.close(); + opts.logger?.logStoppedWatchingFile(filePath); if(closed) { return; } if(fs.existsSync(dirname)) { watcher = fs.watch(dirname, dirWatcherCallback); + watchingDir = true; + opts.logger?.logWatchingDirectory(dirname); } else { console.error(`Directory ${dirname} no longer exists`); } return; } else if(closed) { + // we closed the watcher, so dont reopen return; } + // wait a short delay before sending the change event, in case it changes again if(opts.debouncer) { opts.debouncer(() => { if(closed || !fs.existsSync(filePath)) { @@ -56,17 +74,26 @@ export const watchFilepathChanges = (filePath: string, opts: WatchOptions, callb } callback(); }); + } else { + callback(); } }; if(fs.existsSync(filePath)) { watcher = fs.watch(filePath, fileWatcherCallback); + opts.logger?.logWatchingFile(filePath); } else { watcher = fs.watch(dirname, dirWatcherCallback); + opts.logger?.logWatchingDirectory(dirname); } return { close: () => { closed = true; watcher.close(); + if(watchingDir) { + opts.logger?.logStoppedWatchingDirectory(dirname); + } else { + opts.logger?.logStoppedWatchingFile(filePath); + } } }; }; diff --git a/src/utils/images.ts b/src/utils/images.ts index 78fbed4..bd56c99 100644 --- a/src/utils/images.ts +++ b/src/utils/images.ts @@ -62,3 +62,53 @@ export const applyOverlayToImage = async (imageBuffer: Buffer | ArrayBuffer, ove } return outputBuffer; }; + + +export const getResizedImageFromFile = async (filepath: string, options: { + width?: number, + height?: number, + keepAspectRatio?: boolean, + resizeOptions?: sharp.ResizeOptions, +}): Promise<{image: sharp.Sharp, meta: sharp.Metadata}> => { + // load image and get dimensions + const image = sharp(filepath); + const meta = await image.metadata(); + let size: {width: number, height: number} | undefined; + if(options.width) { + if(options.height) { + size = { + width: options.width, + height: options.height, + }; + if(options.keepAspectRatio) { + const ratio = meta.width / meta.height; + if(!Number.isNaN(ratio) && Number.isFinite(ratio)) { + size.width = Math.round(ratio * size.height); + } + } + } else { + const ratio = meta.height / meta.width; + if(!Number.isNaN(ratio) && Number.isFinite(ratio)) { + size = { + width: options.width, + height: Math.round(ratio * options.width), + }; + } + } + } else if(options.height) { + const ratio = meta.width / meta.height; + if(!Number.isNaN(ratio) && Number.isFinite(ratio)) { + size = { + width: Math.round(ratio * options.height), + height: options.height, + }; + } + } + if(!size) { + return {image, meta}; + } + return { + image: image.resize(size.width, size.height), + meta, + }; +}; diff --git a/src/utils/misc.ts b/src/utils/misc.ts index d37dbf9..9bd6145 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -28,46 +28,44 @@ export const combinePathSegments = (part1: string, part2: string) => { return `${part1}/${part2}`; }; -export const forArrayOrSingle = (item: T | T[] | undefined, callback: (item: T) => void) => { +export const forArrayOrSingle = (item: T | T[] | undefined, callback: (item: T, index: number) => void) => { if(item) { if(item instanceof Array) { - for(const element of item) { - callback(element); - } + item.forEach(callback); } else { - callback(item); + callback(item, 0); } } }; -export const transformArrayOrSingle = (item: T | T[] | undefined, callback: (item: T) => U): (U | U[]) => { +export const transformArrayOrSingle = (item: T | T[] | undefined, callback: (item: T, index: number) => U): (U | U[]) => { if(item) { if(item instanceof Array) { return item.map(callback); } else { - return callback(item); + return callback(item, 0); } } else { return item as any; } }; -export const forArrayOrSingleAsyncParallel = async (item: T | T[], callback: (item: T) => Promise): Promise => { +export const forArrayOrSingleAsyncParallel = async (item: T | T[], callback: (item: T, index: number) => Promise): Promise => { if(item) { if(item instanceof Array) { await Promise.all(item.map(callback)); } else { - await callback(item); + await callback(item, 0); } } }; -export const transformArrayOrSingleAsyncParallel = async (item: T | T[] | undefined, callback: (item: T) => Promise): Promise => { +export const transformArrayOrSingleAsyncParallel = async (item: T | T[] | undefined, callback: (item: T, index: number) => Promise): Promise => { if(item) { if(item instanceof Array) { return await Promise.all(item.map(callback)); } else { - return await callback(item); + return await callback(item, 0); } } else { return item as any; @@ -105,6 +103,15 @@ export const firstOrSingle = (arrayOrSingle: (T | T[] | undefined)): T | unde return undefined; }; +export const arrayFromArrayOrSingle = (arrayOrSingle: (T | T[] | undefined)): T[] => { + if(arrayOrSingle instanceof Array) { + return arrayOrSingle; + } else if(arrayOrSingle) { + return [arrayOrSingle]; + } + return []; +}; + export const isArrayNullOrEmpty = (obj: any) => { return (!obj || (obj instanceof Array && obj.length === 0)); }; diff --git a/src/utils/queryparams.ts b/src/utils/queryparams.ts index 9f4189c..cecfe9f 100644 --- a/src/utils/queryparams.ts +++ b/src/utils/queryparams.ts @@ -1,5 +1,7 @@ +import http from 'http'; import express from 'express'; import { httpError } from './error'; +import { parseURLPath } from './url'; export const parseStringQueryParam = (value: any): string | undefined => { if(typeof value === 'string') { @@ -69,11 +71,17 @@ export const parseBooleanQueryParam = (value: any): boolean | undefined => { throw httpError(400, `${value} is not a boolean`); }; -export const parseQueryParams = (req: express.Request, includeParam: (key:string) => boolean): {[key:string]: any} => { +export const parseQueryParams = (req: http.IncomingMessage | express.Request, includeParam: (key:string) => boolean): {[key:string]: any} => { const params: {[key:string]: any} = {}; - for(const key in req.query) { - if(includeParam(key)) { - params[key] = req.query[key]; + let query: {[key: string]: any} | undefined = (req as express.Request).query; + if(!query) { + query = parseURLPath(req.url!).queryItems; + } + if(query) { + for(const key of Object.keys(query)) { + if(includeParam(key)) { + params[key] = query[key]; + } } } return params; diff --git a/src/utils/ref.ts b/src/utils/ref.ts new file mode 100644 index 0000000..16b3266 --- /dev/null +++ b/src/utils/ref.ts @@ -0,0 +1,22 @@ + +export type Ref = { + val: TValue +}; + +export type OutRef = { + val?: TValue +}; + +export function setRefIfNone( + ref: OutRef | undefined, + onNone: () => TValue, + nullIsNone: boolean = true +): Ref { + if(ref == null) { + return {val:onNone()}; + } + if(nullIsNone ? (ref.val == null) : (ref.val === undefined)) { + return {val:onNone()}; + } + return ref as Ref; +} diff --git a/src/utils/requesthandling.ts b/src/utils/requesthandling.ts index c3bbb4e..486452a 100644 --- a/src/utils/requesthandling.ts +++ b/src/utils/requesthandling.ts @@ -1,38 +1,79 @@ import http from 'http'; -import express from 'express'; -import { HttpError, HttpResponseError } from './error'; +import express, { NextFunction } from 'express'; +import { httpError, HttpError, HttpResponseError } from './error'; +import type { IncomingPlexAPIRequest } from '../plex/requesthandling'; -export const asyncRequestHandler = ( - handler: (req: TRequest, res: express.Response) => Promise -) => { - return async (req: TRequest, res: express.Response, next: (error?: Error) => void) => { +export const asyncRequestHandler = ( + handler: ((req: TRequest, res: TResponse, next: NextFunction) => (boolean | Promise)) +): ((req: TRequest, res: TResponse, next: (error?: Error) => void) => (void | Promise)) => { + return async (req: TRequest, res: TResponse, next: (error?: Error) => void) => { + let calledNext = false; let done: boolean; try { - done = await handler(req,res); + const donePromise = handler(req,res,(...args) => { + calledNext = true; + return next(...args); + }); + if(donePromise instanceof Promise) { + done = await donePromise; + } else { + done = donePromise; + } } catch(error) { + if(calledNext) { + console.error(`Error during async handler after already calling next:`); + console.error(error); + return; + } next(error); return; } if(!done) { + if(calledNext) { + console.error(`Already called next for async request handler, so skipping`); + return; + } next(); } }; }; -export const expressErrorHandler = (error: Error, req: express.Request, res: express.Response, next) => { - if(error) { - console.error(`Got error while handling request:`); - console.error(`\ttimestamp: ${(new Date()).toString()}`); - console.error(`\turl: ${req.originalUrl}`); - console.error(`\tip: ${req.connection?.remoteAddress || req.socket?.remoteAddress}`); - console.error(`\theaders:\n`); - const reqHeaderList = req.rawHeaders; +export type RequestWithOriginalRemoteAddress = (http.IncomingMessage | express.Request) & { + originalRemoteAddress: string; +}; + +export const addOriginalRemoteAddressToRequest = (req: http.IncomingMessage | express.Request) => { + const reqWithAddr = (req as RequestWithOriginalRemoteAddress); + if(reqWithAddr.originalRemoteAddress) { + return; + } + reqWithAddr.originalRemoteAddress = remoteAddressOfRequest(req); +}; + +export const expressRequestDebugString = (req: express.Request) => { + const reqHeaderList = req.rawHeaders; + let reqHeaderLines: string[] = [] for(let i=0; i { + if(error) { + console.error(`Got error while handling request:\n${expressRequestDebugString(req)}`); console.error(error); let statusCode = (error as HttpError).statusCode @@ -48,6 +89,32 @@ export const expressErrorHandler = (error: Error, req: express.Request, res: exp } }; +export const urlFromServerRequest = (req: http.IncomingMessage | express.Request): string => { + console.assert(req.url != null, "incoming http message must have a url"); + const exReq = req as express.Request; + if(exReq.baseUrl) { + return exReq.baseUrl + req.url!; + } else { + return req.url!; + } +}; + +export function remoteAddressOfRequest(req: http.IncomingMessage | express.Request): string { + let remoteAddress = req.connection?.remoteAddress || req.socket?.remoteAddress; + if(!remoteAddress) { + throw httpError(400, "No remote address"); + } + return remoteAddress; +}; + +export function remoteAddressOfRequestOrNull(req: http.IncomingMessage | express.Request): string | null { + let remoteAddress = req.connection?.remoteAddress || req.socket?.remoteAddress; + if(!remoteAddress) { + return null; + } + return remoteAddress; +}; + export function requestIsEncrypted(req: http.IncomingMessage) { const connection = ((req.connection || req.socket) as {encrypted?: boolean; pair?: boolean;}) const encrypted = (connection?.encrypted || connection?.pair); @@ -55,8 +122,10 @@ export function requestIsEncrypted(req: http.IncomingMessage) { } export function getPortFromRequest(req: http.IncomingMessage) { - const port = req.headers.host?.match(/:(\d+)/)?.[1]; - return port ? - port - : (requestIsEncrypted(req) ? '443' : '80'); + const port = req.headers.host?.match(/:(\d+)/)?.[1] + || req.socket.localPort + || req.socket.remotePort; + return port + ? port + : (requestIsEncrypted(req) ? '443' : '80'); } diff --git a/src/utils/ssl.ts b/src/utils/ssl.ts index ba77b42..d4bfedb 100644 --- a/src/utils/ssl.ts +++ b/src/utils/ssl.ts @@ -2,8 +2,10 @@ import forge from 'node-forge'; import fs from 'fs'; import path from 'path'; +import { isRunningViaBun } from './compat'; import { watchFilepathChanges } from './files'; import { createDebouncer } from './timing'; +import type { Logger } from '../logging'; export type SSLConfig = { p12Path?: string; @@ -12,25 +14,41 @@ export type SSLConfig = { keyPath?: string; }; -export type CertificateData = { - ca?: (string | Buffer)[]; - cert?: string | Buffer; +export type TLSCertificateOptions = { + ca?: (string | Buffer)[] | Buffer; + cert?: string | Buffer | (string | Buffer)[]; key?: string | Buffer; }; -export const extractP12Data = (p12Data: string | Buffer, password: string | null | undefined): CertificateData => { +const readP12Data = (p12Data: string | Buffer, password: string | null | undefined) => { if(p12Data instanceof Buffer) { p12Data = p12Data.toString('binary'); } const p12Asn1 = forge.asn1.fromDer(p12Data as string); - let p12: forge.pkcs12.Pkcs12Pfx; - if(password != null) { - p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password); - } else { - p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1); + return password != null + ? forge.pkcs12.pkcs12FromAsn1(p12Asn1, password) + : forge.pkcs12.pkcs12FromAsn1(p12Asn1); +}; + +const getPrivateKeyFromP12 = (p12: forge.pkcs12.Pkcs12Pfx) => { + for (const safeContents of p12.safeContents) { + for (const safeBag of safeContents.safeBags) { + if (safeBag.type === forge.pki.oids.keyBag || safeBag.type === forge.pki.oids.pkcs8ShroudedKeyBag) { + const key = safeBag.key; + if(key) { + return forge.pki.privateKeyToPem(key); + } + } + } } + throw new Error("Private key not found"); +}; + +export const extractP12DataForNode = (p12Data: string | Buffer, password: string | null | undefined): TLSCertificateOptions => { + const p12 = readP12Data(p12Data, password); + // get ca certificates - let ca: (string | Buffer)[] | undefined; + let ca: string[] | Buffer | undefined; const certBags = p12.getBags({bagType: forge.pki.oids.certBag})[forge.pki.oids.certBag]; if(certBags) { // Check if it's a CA certificate (you might need more robust checks depending on your needs) @@ -40,34 +58,64 @@ export const extractP12Data = (p12Data: string | Buffer, password: string | null if(!ca) { ca = []; } - ca.push(pem); + (ca as string[]).push(pem); } } } + // get certificate const firstCertBag = certBags?.[0]; if(!firstCertBag?.cert) { throw new Error('No certificates found'); } - const cert = forge.pki.certificateToPem(firstCertBag.cert); + const cert: Buffer | string = forge.pki.certificateToPem(firstCertBag.cert); + // get private key - let privateKey: string | undefined; - for (const safeContents of p12.safeContents) { - for (const safeBag of safeContents.safeBags) { - if (safeBag.type === forge.pki.oids.keyBag || safeBag.type === forge.pki.oids.pkcs8ShroudedKeyBag) { - const key = safeBag.key; - privateKey = key != null ? forge.pki.privateKeyToPem(key) : undefined; - break; - } - } + const privateKey = getPrivateKeyFromP12(p12); + + return {cert, key:privateKey, ca}; +}; + +export const extractP12DataForBun = (p12Data: string | Buffer, password: string | null | undefined): TLSCertificateOptions => { + const p12 = readP12Data(p12Data, password); + + // collect all certs + const certBags = p12.getBags({bagType: forge.pki.oids.certBag})[forge.pki.oids.certBag]; + if (!certBags?.length || !certBags[0].cert) { + throw new Error("No certificates found"); } - if (!privateKey) { - throw new Error("Private key not found"); + + // leaf first + const leaf = certBags[0].cert; + const leafPem = forge.pki.certificateToPem(leaf); + + // intermediates (skip root CAs) + const isSelfSigned = (c: forge.pki.Certificate) => (c.isIssuer(c) && c.subject.hash === c.issuer.hash); + + const intermediatesPem = certBags + .slice(1) + .filter((b) => (b.cert && !isSelfSigned(b.cert))) + .map(b => forge.pki.certificateToPem(b.cert!)) + .join(""); + + const cert = leafPem + intermediatesPem; // chain in cert (required by Bun) + + // private key + const privateKey = getPrivateKeyFromP12(p12); + + // IMPORTANT: don't set `ca` for the server chain in Bun + return { cert, key:privateKey }; +}; + +export const extractP12Data = (p12Data: string | Buffer, password: string | null | undefined): TLSCertificateOptions => { + if(isRunningViaBun()) { + return extractP12DataForBun(p12Data, password); + } else { + return extractP12DataForNode(p12Data, password); } - return {cert, key:privateKey, ca}; }; -export const readSSLCertAndKey = async (sslConfig: SSLConfig): Promise => { +export const readSSLCertAndKey = async (sslConfig: SSLConfig): Promise => { if(sslConfig.p12Path) { const fileData = await fs.promises.readFile(sslConfig.p12Path); return extractP12Data(fileData, sslConfig.p12Password); @@ -80,10 +128,14 @@ export const readSSLCertAndKey = async (sslConfig: SSLConfig): Promise void): { close: () => void } | null => { +export const watchSSLCertAndKeyChanges = (sslConfig: SSLConfig, opts: { + debounceDelay?: number, + logger?: Logger, +}, callback: (certData: TLSCertificateOptions) => void): { close: () => void } | null => { + const { logger } = opts; const debouncer = opts.debounceDelay != null ? createDebouncer(opts.debounceDelay) : undefined; const onCallback = async () => { - let certData: CertificateData; + let certData: TLSCertificateOptions; try { certData = await readSSLCertAndKey(sslConfig); } catch(error) { @@ -99,14 +151,23 @@ export const watchSSLCertAndKeyChanges = (sslConfig: SSLConfig, opts: {debounceD } }; if(sslConfig.p12Path) { - return watchFilepathChanges(sslConfig.p12Path, {debouncer}, onCallback); + return watchFilepathChanges(sslConfig.p12Path, { + debouncer, + logger, + }, onCallback); } else if(sslConfig.certPath && sslConfig.keyPath) { let certWatcher: {close: () => void} | undefined; let keyWatcher: {close: () => void} | undefined; try { // TODO have some FSWatcher pool in case cert and key are in the same directory (so we're not watching the directory twice) - certWatcher = watchFilepathChanges(sslConfig.certPath, {debouncer}, onCallback); - keyWatcher = watchFilepathChanges(sslConfig.keyPath, {debouncer}, onCallback); + certWatcher = watchFilepathChanges(sslConfig.certPath, { + debouncer, + logger, + }, onCallback); + keyWatcher = watchFilepathChanges(sslConfig.keyPath, { + debouncer, + logger, + }, onCallback); } catch(error) { certWatcher?.close(); keyWatcher?.close(); diff --git a/src/utils/url.ts b/src/utils/url.ts index 812109c..a2a6003 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -56,3 +56,12 @@ export const stringifyURLPath = (urlPathObj: URLPath): string => { } return urlPath; }; + +export const parseURLQueryItems = (urlPath: string): (qs.ParsedUrlQuery | undefined) => { + const queryIndex = urlPath.indexOf('?'); + if(queryIndex == -1) { + return undefined; + } + const query = urlPath.substring(queryIndex+1); + return qs.parse(query); +}; diff --git a/src/utils/version.ts b/src/utils/version.ts index ccc2efe..d916221 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import { SpawnOptionsWithoutStdio } from 'child_process'; import { executeAndGetOutputAsync } from './subprocess'; import { getFirstLineOfString } from './strings'; +import { getModuleRootPath } from './compat'; import packageJson from '../../package.json'; export type AppVersion = { @@ -14,7 +15,7 @@ export type AppVersion = { }; export const getAppVersion = async (): Promise => { - const modulePath = `${require.main!.path}/..`; + const modulePath = getModuleRootPath(); const cmdOpts: SpawnOptionsWithoutStdio = { cwd: modulePath, } diff --git a/tools/auto_decrypt_plex_cert.sh b/tools/auto_decrypt_plex_cert.sh new file mode 100755 index 0000000..49e6e93 --- /dev/null +++ b/tools/auto_decrypt_plex_cert.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# validate that all args are flags +for arg in "$@"; do + if [[ ! $arg == -* ]]; then + >&2 echo "Invalid arg $arg" + fi +done + +# get paths +base_path="${BASH_SOURCE%/*}/.." +plexutils_path="$base_path/tools/plex_utils.sh" + +# get p12 path +p12_path=$("$plexutils_path" "$@" path ssl-cert-p12) +result=$? +if [ $result -ne 0 ]; then + exit $result +fi + +# decrypt plex cert +>&2 echo "Decrypting plex cert $p12_path" +"$base_path/tools/decrypt_plex_cert.sh" "$@" + +# watch for file changes +while "$base_path/tools/watch_filechange.sh" "$p12_path" 1> /dev/null ; do + echo "Plex certificate changed at $p12_path" + # decrypt the plex certificate + "$base_path/tools/decrypt_plex_cert.sh" "$@" +done +>&2 echo "Stopped listening for plex cert changes" diff --git a/tools/build_fswatch.sh b/tools/build_fswatch.sh new file mode 100755 index 0000000..7ee0abf --- /dev/null +++ b/tools/build_fswatch.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if [ -z "$FSWATCH_VERSION" ]; then + export FSWATCH_VERSION="1.18.3" +fi + +# enter base directory +cd "${BASH_SOURCE%/*}/../" || exit $? +mkdir -p external || exit $? + +# extract fswatch +fswatch_archive_path="external/fswatch-$FSWATCH_VERSION.tar.gz" +if [ ! -d "external/fswatch-$FSWATCH_VERSION" ]; then + if [ ! -f "external/fswatch-$FSWATCH_VERSION.tar.gz" ]; then + curl -L "https://github.com/emcrisostomo/fswatch/releases/download/$FSWATCH_VERSION/fswatch-$FSWATCH_VERSION.tar.gz" -o "$fswatch_archive_path" || exit $? + fi + tar -xzf "$fswatch_archive_path" -C external || exit $? +fi + +# compile fswatch +cd external/fswatch-$FSWATCH_VERSION || exit $? +./configure "$@" || exit $? +make || exit $? diff --git a/tools/decrypt_plex_cert.sh b/tools/decrypt_plex_cert.sh new file mode 100755 index 0000000..33bbcee --- /dev/null +++ b/tools/decrypt_plex_cert.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# validate that all args are flags +for arg in "$@"; do + if [[ ! $arg == -* ]]; then + >&2 echo "Invalid arg $arg" + fi +done + +# get paths +base_path="${BASH_SOURCE%/*}/.." +plexutils_path="$base_path/tools/plex_utils.sh" +privatekey_path="$base_path/keys/plex_privatekey.key" +certchain_path="$base_path/keys/plex_certchain.pem" + +# create keys folder if it doesnt exist +mkdir -p "$base_path/keys" || exit $? + +# extract ssl keys +>&2 echo "Extracting plex private key" +"$plexutils_path" "$@" ssl-cert output-privatekey "$privatekey_path" || exit $? +>&2 echo "Updating privatekey permissions" +chmod 600 "$privatekey_path" || exit $? + +>&2 echo "Extracting plex certificate chain" +"$plexutils_path" "$@" ssl-cert output-certchain "$certchain_path" || exit $? +>&2 echo "Updating certchain permissions" +chmod 644 "$certchain_path" || exit $? diff --git a/tools/fetch_prometheus.sh b/tools/fetch_prometheus.sh new file mode 100755 index 0000000..bae16a3 --- /dev/null +++ b/tools/fetch_prometheus.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(arch) +if [ -z "$PROMETHEUS_VERSION" ]; then + PROMETHEUS_VERSION="3.5.0" +fi + +# enter directory +cd "${BASH_SOURCE%/*}/../" || exit $? +mkdir -p external || exit $? + +# download prometheus if it doesn't exist +prometheus_archive_name="prometheus-${PROMETHEUS_VERSION}.${PLATFORM}-${ARCH}" +prometheus_archive_path="./external/$prometheus_archive_name.tar.gz" +prometheus_path="./external/$prometheus_archive_name/prometheus" +if [ ! -f "$prometheus_path" ]; then + if [ ! -f "$prometheus_archive_path" ]; then + curl -L "https://github.com/prometheus/prometheus/releases/download/v$PROMETHEUS_VERSION/$prometheus_archive_name.tar.gz" -o "$prometheus_archive_path" || exit $? + fi + mkdir -p "./external/$prometheus_archive_name" || exit $? + tar -xzf "$prometheus_archive_path" -C "./external" || exit $? +fi diff --git a/tools/fetch_traefik.sh b/tools/fetch_traefik.sh new file mode 100755 index 0000000..adb8e99 --- /dev/null +++ b/tools/fetch_traefik.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(arch) +if [ -z "$TRAEFIK_VERSION" ]; then + TRAEFIK_VERSION="3.5.0" +fi + +# enter directory +cd "${BASH_SOURCE%/*}/../" || exit $? +mkdir -p external || exit $? + +# download traefik if it doesn't exist +traefik_archive_name="traefik_v${TRAEFIK_VERSION}_${PLATFORM}_${ARCH}" +traefik_archive_path="./external/$traefik_archive_name.tar.gz" +traefik_path="./external/$traefik_archive_name/traefik" +if [ ! -f "$traefik_path" ]; then + if [ ! -f "$traefik_archive_path" ]; then + curl -L "https://github.com/traefik/traefik/releases/download/v$TRAEFIK_VERSION/$traefik_archive_name.tar.gz" -o "$traefik_archive_path" || exit $? + fi + mkdir -p "./external/$traefik_archive_name" || exit $? + tar -xzf "$traefik_archive_path" -C "./external/$traefik_archive_name" || exit $? +fi diff --git a/tools/plex_utils.sh b/tools/plex_utils.sh index c5cb0dc..2710228 100755 --- a/tools/plex_utils.sh +++ b/tools/plex_utils.sh @@ -49,7 +49,7 @@ while [[ $# -gt 0 ]]; do exit 1 fi break - ;; + ;; esac done if [ -z "$subcmd" ]; then @@ -207,6 +207,7 @@ function get_ssl_cert_p12_path { return $result fi fi + # pms_cert_filename="cert-v2.p12" case "$platform" in Linux) if [ -z "$pms_cache_path" ]; then @@ -221,6 +222,7 @@ function get_ssl_cert_p12_path { fi ;; Windows) + # pms_cert_filename="certificate.p12" if [ -z "$pms_cache_path" ]; then pms_cache_path=$(pms_cache_windows) result=$? @@ -234,7 +236,7 @@ function get_ssl_cert_p12_path { if [ $result -ne 0 ]; then return $result fi - echo "$pms_cache_path/cert-v2.p12" + echo "$pms_cache_path/$pms_cert_filename" } function get_ssl_cert_p12_password { @@ -247,12 +249,12 @@ function get_ssl_cert_p12_password { } function output_ssl_cert { - local cert_pass=$(get_ssl_cert_p12_password) + local p12_pass=$(get_ssl_cert_p12_password) local result=$? if [ $result -ne 0 ]; then return $result fi - local cert_path=$(get_ssl_cert_p12_path) + local p12_path=$(get_ssl_cert_p12_path) result=$? if [ $result -ne 0 ]; then return $result @@ -262,16 +264,35 @@ function output_ssl_cert { >&2 echo "No output path given" return 1 fi - openssl pkcs12 -in "$cert_path" -out "$cert_out_path" -clcerts -nokeys -passin "pass:$cert_pass" || return $? + openssl pkcs12 -in "$p12_path" -out "$cert_out_path" -clcerts -nokeys -passin "pass:$p12_pass" || return $? +} + +function output_ssl_certchain { + local p12_pass=$(get_ssl_cert_p12_password) + local result=$? + if [ $result -ne 0 ]; then + return $result + fi + local p12_path=$(get_ssl_cert_p12_path) + result=$? + if [ $result -ne 0 ]; then + return $result + fi + local cert_out_path="$1" + if [ -z "$cert_out_path" ]; then + >&2 echo "No output path given" + return 1 + fi + openssl pkcs12 -in "$p12_path" -out "$cert_out_path" -nokeys -passin "pass:$p12_pass" || return $? } function output_ssl_privatekey { - local cert_pass=$(get_ssl_cert_p12_password) + local p12_pass=$(get_ssl_cert_p12_password) local result=$? if [ $result -ne 0 ]; then return $result fi - local cert_path=$(get_ssl_cert_p12_path) + local p12_path=$(get_ssl_cert_p12_path) result=$? if [ $result -ne 0 ]; then return $result @@ -281,7 +302,7 @@ function output_ssl_privatekey { >&2 echo "No output path given" return 1 fi - openssl pkcs12 -in "$cert_path" -out "$key_out_path" -nocerts -nodes -passin "pass:$cert_pass" || return $? + openssl pkcs12 -in "$p12_path" -out "$key_out_path" -nocerts -nodes -passin "pass:$p12_pass" || return $? } @@ -347,7 +368,7 @@ case "$subcmd" in exit 0 ;; ssl-cert-p12) - get_ssl_cert_12_path "$@" || exit $? + get_ssl_cert_p12_path "$@" || exit $? exit 0 ;; *) @@ -372,6 +393,10 @@ case "$subcmd" in output_ssl_cert "$@" || exit $? exit 0 ;; + output-certchain) + output_ssl_certchain "$@" || exit $? + exit 0 + ;; output-privatekey) output_ssl_privatekey "$@" || exit $? exit 0 diff --git a/tools/watch_filechange.sh b/tools/watch_filechange.sh new file mode 100755 index 0000000..8975dd0 --- /dev/null +++ b/tools/watch_filechange.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +base_path="${BASH_SOURCE%/*}/.." + +function get_platform { + local unameOut=$(uname -s) + case "$unameOut" in + Linux*) echo "Linux" ;; + Darwin*) echo "MacOS" ;; + CYGWIN*) echo "Windows" ;; + MINGW*) echo "Windows" ;; + MSYS_NT*) echo "Windows" ;; + Windows*) echo "Windows" ;; + *) + >&2 echo "Unknown uname $unameOut" + return 1 + ;; + esac +} + +case "$(get_platform)" in + Linux) + file_pattern=$(basename "$1" | sed 's/[].[^$*+?(){|\\]/\\&/g') + >&2 echo "Listening for changes to $1" + inotifywait -e modify,create,move "$(dirname "$1")" --include "^$file_pattern\$" || exit $? + ;; + MacOS) + FSWATCH_VERSION=1.18.3 + fswatch_path="$base_path/external/fswatch-$FSWATCH_VERSION/fswatch/src/fswatch" + if [ ! -f "$fswatch_path" ]; then + "$base_path/tools/build_fswatch.sh" + fi + >&2 echo "Listening for changes to $1" + "$fswatch_path" -1 "$1" || exit $? + ;; + Windows) + # TODO implement windows + ;; +esac