diff --git a/.all-contributorsrc b/.all-contributorsrc index 4cbc6e2c17..b68f27caad 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -249,7 +249,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4", "profile": "http://www.piribisoft.com", "contributions": [ - "doc" + "doc", + "code" ] }, { @@ -711,6 +712,105 @@ "contributions": [ "code" ] + }, + { + "login": "j0srisk", + "name": "Joseph Risk", + "avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4", + "profile": "http://josephrisk.com", + "contributions": [ + "code" + ] + }, + { + "login": "Loetwiek", + "name": "Loetwiek", + "avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4", + "profile": "https://github.com/Loetwiek", + "contributions": [ + "code" + ] + }, + { + "login": "Fuochi", + "name": "Fuochi", + "avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4", + "profile": "https://github.com/Fuochi", + "contributions": [ + "doc" + ] + }, + { + "login": "demrich", + "name": "David Emrich", + "avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4", + "profile": "https://github.com/demrich", + "contributions": [ + "code" + ] + }, + { + "login": "maxnatamo", + "name": "Max T. Kristiansen", + "avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4", + "profile": "https://maxtrier.dk", + "contributions": [ + "code" + ] + }, + { + "login": "DamsDev1", + "name": "Damien Fajole", + "avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4", + "profile": "https://damsdev.me", + "contributions": [ + "code" + ] + }, + { + "login": "AhmedNSidd", + "name": "Ahmed Siddiqui", + "avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4", + "profile": "https://github.com/AhmedNSidd", + "contributions": [ + "code" + ] + }, + { + "login": "JackW6809", + "name": "JackOXI", + "avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4", + "profile": "https://github.com/JackW6809", + "contributions": [ + "code" + ] + }, + { + "login": "StancuFlorin", + "name": "Stancu Florin", + "avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4", + "profile": "http://indicus.ro", + "contributions": [ + "code" + ] + }, + { + "login": "lmiklosko", + "name": "Lukas Miklosko", + "avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4", + "profile": "https://github.com/lmiklosko", + "contributions": [ + "code" + ] + }, + { + "login": "gauthier-th", + "name": "Gauthier", + "avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4", + "profile": "https://gauthierth.fr/", + "contributions": [ + "code" + ] } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44b07fd952..8189522f01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,8 @@ jobs: push: true build-args: | COMMIT_TAG=${{ github.sha }} + BUILD_VERSION=develop + BUILD_DATE=${{ github.event.repository.updated_at }} outputs: | type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index bacd21e0f4..0084f27b4c 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -33,5 +33,7 @@ jobs: push: true build-args: | COMMIT_TAG=${{ github.sha }} + BUILD_VERSION=${{ steps.get_version.outputs.VERSION }} + BUILD_DATE=${{ github.event.repository.updated_at }} tags: | fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a87de35b..312a32b4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [2.5.2](https://github.com/fallenbagel/jellyseerr/compare/v2.5.1...v2.5.2) (2025-04-03) + + +### Bug Fixes + +* **auth:** Bitwarden autofill fix on local/Jellyfin login (2) ([#1487](https://github.com/fallenbagel/jellyseerr/issues/1487)) ([85bbc85](https://github.com/fallenbagel/jellyseerr/commit/85bbc857141d38bcf5244078437ed6a3318bba67)) +* **avatar:** fix avatar cache busting by using avatarVersion ([#1537](https://github.com/fallenbagel/jellyseerr/issues/1537)) ([29034b3](https://github.com/fallenbagel/jellyseerr/commit/29034b350d35ebaed52556448e46436aeb644e77)) +* correct "Remove from *arr" button ([#1544](https://github.com/fallenbagel/jellyseerr/issues/1544)) ([8dc1d81](https://github.com/fallenbagel/jellyseerr/commit/8dc1d8196c67bee0e772941445c294f0ca367961)), closes [#1476](https://github.com/fallenbagel/jellyseerr/issues/1476) [#1494](https://github.com/fallenbagel/jellyseerr/issues/1494) +* **helm:** apply annotations to pvc ([#1489](https://github.com/fallenbagel/jellyseerr/issues/1489)) ([e5ab847](https://github.com/fallenbagel/jellyseerr/commit/e5ab847547564869c3aa6443b1e22208c09a7810)) +* **jellyfin:** ensure deviceID is never empty ([#1538](https://github.com/fallenbagel/jellyseerr/issues/1538)) ([7438042](https://github.com/fallenbagel/jellyseerr/commit/7438042757cb0e81534cf9f766d84dd3ff57fd84)) +* **job:** handle media removal for 4k on the same server ([#1543](https://github.com/fallenbagel/jellyseerr/issues/1543)) ([63dc27d](https://github.com/fallenbagel/jellyseerr/commit/63dc27d400ecc80a18442fc42dd417cc03c3f9e1)) +* **job:** rename Plex Sync to Jellyfin Sync ([#1549](https://github.com/fallenbagel/jellyseerr/issues/1549)) ([2f6be95](https://github.com/fallenbagel/jellyseerr/commit/2f6be955b51e8920c8954413286577e6fea4aee2)) +* **migrations:** add missing Postgres migration and fix SQLite migration ([#1532](https://github.com/fallenbagel/jellyseerr/issues/1532)) ([0b0b76e](https://github.com/fallenbagel/jellyseerr/commit/0b0b76e58c583fc7c31d7821e7825e32065f7944)), closes [#1466](https://github.com/fallenbagel/jellyseerr/issues/1466) +* **ui:** handle import-from-plex response as array ([#1510](https://github.com/fallenbagel/jellyseerr/issues/1510)) ([4cd02ba](https://github.com/fallenbagel/jellyseerr/commit/4cd02babbace98c01bcef153a50d34cb36dd1d4b)) +* **ui:** resolve discover language dropdown overlap ([#1497](https://github.com/fallenbagel/jellyseerr/issues/1497)) ([f5b3a52](https://github.com/fallenbagel/jellyseerr/commit/f5b3a526cb9b12c19e5ff6a79240e3d85685ff9b)), closes [#1475](https://github.com/fallenbagel/jellyseerr/issues/1475) + ## [2.5.1](https://github.com/fallenbagel/jellyseerr/compare/v2.5.0...v2.5.1) (2025-03-17) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1835e949f1..855cecfbfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,12 +58,27 @@ All help is welcome and greatly appreciated! If you would like to contribute to - Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines. - Should you need to update your fork, you can do so by rebasing from `upstream`: + ```bash git fetch upstream git rebase upstream/develop git push origin BRANCH_NAME -f ``` +### Helm Chart + +Tools Required: + +- [Helm](https://helm.sh/docs/intro/install/) +- [helm-docs](https://github.com/norwoodj/helm-docs) + +Steps: + +1. Make the necessary changes. +2. Test your changes. +3. Update the `version` in `charts/jellyseerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/). +4. Run the `helm-docs` command to regenerate the chart's README. + ### Contributing Code - If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing. diff --git a/Dockerfile b/Dockerfile index 96fecbe983..2089513ae0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,8 +38,17 @@ RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json FROM node:22-alpine -# Metadata for Github Package Registry -LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr" +# OCI Meta information +ARG BUILD_DATE +ARG BUILD_VERSION +LABEL \ + org.opencontainers.image.authors="Fallenbagel" \ + org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \ + org.opencontainers.image.created=${BUILD_DATE} \ + org.opencontainers.image.version=${BUILD_VERSION} \ + org.opencontainers.image.title="Jellyseerr" \ + org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \ + org.opencontainers.image.licenses="MIT" WORKDIR /app diff --git a/README.md b/README.md index a275de0c66..02d8839e9b 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,8 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Ahmed Siddiqui
Ahmed Siddiqui

💻 JackOXI
JackOXI

💻 Stancu Florin
Stancu Florin

💻 + Lukas Miklosko
Lukas Miklosko

💻 + Gauthier
Gauthier

💻 diff --git a/charts/jellyseerr-chart/Chart.yaml b/charts/jellyseerr-chart/Chart.yaml index 5408ac1a5a..833c48fc7b 100644 --- a/charts/jellyseerr-chart/Chart.yaml +++ b/charts/jellyseerr-chart/Chart.yaml @@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0" name: jellyseerr-chart description: Jellyseerr helm chart for Kubernetes type: application -version: 2.3.0 -appVersion: "2.5.0" +version: 2.3.2 +appVersion: "2.5.1" maintainers: - name: Jellyseerr url: https://github.com/Fallenbagel/jellyseerr diff --git a/charts/jellyseerr-chart/README.md b/charts/jellyseerr-chart/README.md index eae3c6d693..72dce724ee 100644 --- a/charts/jellyseerr-chart/README.md +++ b/charts/jellyseerr-chart/README.md @@ -1,6 +1,6 @@ # jellyseerr-chart -![Version: 2.3.0](https://img.shields.io/badge/Version-2.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.5.0](https://img.shields.io/badge/AppVersion-2.5.0-informational?style=flat-square) +![Version: 2.3.2](https://img.shields.io/badge/Version-2.3.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.5.1](https://img.shields.io/badge/AppVersion-2.5.1-informational?style=flat-square) Jellyseerr helm chart for Kubernetes diff --git a/charts/jellyseerr-chart/templates/persistentvolumeclaim.yaml b/charts/jellyseerr-chart/templates/persistentvolumeclaim.yaml index bf0d642238..a9363ee007 100644 --- a/charts/jellyseerr-chart/templates/persistentvolumeclaim.yaml +++ b/charts/jellyseerr-chart/templates/persistentvolumeclaim.yaml @@ -4,6 +4,10 @@ metadata: name: {{ include "jellyseerr.configPersistenceName" . }} labels: {{- include "jellyseerr.labels" . | nindent 4 }} + {{- with .Values.config.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: {{- with .Values.config.persistence.accessModes }} accessModes: diff --git a/docs/getting-started/docker.mdx b/docs/getting-started/docker.mdx index 3d411e021b..b129cdb950 100644 --- a/docs/getting-started/docker.mdx +++ b/docs/getting-started/docker.mdx @@ -37,7 +37,7 @@ docker run -d \ -p 5055:5055 \ -v /path/to/appdata/config:/app/config \ --restart unless-stopped \ - fallenbagel/jellyseerr + ghcr.io/fallenbagel/jellyseerr ``` :::tip If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`. @@ -55,7 +55,7 @@ docker stop jellyseerr && docker rm Jellyseerr ``` Pull the latest image: ```bash -docker pull fallenbagel/jellyseerr +docker pull ghcr.io/fallenbagel/jellyseerr ``` Finally, run the container with the same parameters originally used to create the container: ```bash @@ -78,7 +78,7 @@ Define the `jellyseerr` service in your `compose.yaml` as follows: --- services: jellyseerr: - image: fallenbagel/jellyseerr:latest + image: ghcr.io/fallenbagel/jellyseerr:latest container_name: jellyseerr environment: - LOG_LEVEL=debug @@ -146,7 +146,7 @@ Then, create and start the Jellyseerr container: ```bash -docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest +docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped ghcr.io/fallenbagel/jellyseerr:latest ``` #### Updating: diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 00b0959686..6954992d82 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -3965,6 +3965,8 @@ paths: type: string p256dh: type: string + userAgent: + type: string required: - endpoint - auth @@ -3972,6 +3974,88 @@ paths: responses: '204': description: Successfully registered push subscription + /user/{userId}/pushSubscriptions: + get: + summary: Get all web push notification settings for a user + description: | + Returns all web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + /user/{userId}/pushSubscription/{key}: + get: + summary: Get web push notification settings for a user + description: | + Returns web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: path + name: key + required: true + schema: + type: string + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + delete: + summary: Delete user push subscription by key + description: Deletes the user push subscription with the provided key. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: path + name: key + required: true + schema: + type: string + responses: + '204': + description: Successfully removed user push subscription /user/{userId}: get: summary: Get user by ID diff --git a/package.json b/package.json index 324a618175..2cc392824e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellyseerr", - "version": "2.5.1", + "version": "2.5.2", "private": true, "scripts": { "preinstall": "npx only-allow pnpm", @@ -44,6 +44,7 @@ "@svgr/webpack": "6.5.1", "@tanem/react-nprogress": "5.0.30", "@types/wink-jaro-distance": "^2.0.2", + "@types/ua-parser-js": "^0.7.36", "ace-builds": "1.15.2", "bcrypt": "5.1.0", "bowser": "2.11.0", @@ -64,7 +65,7 @@ "gravatar-url": "3.1.0", "lodash": "4.17.21", "mime": "3", - "next": "^14.2.24", + "next": "^14.2.25", "node-cache": "5.1.2", "node-gyp": "9.3.1", "node-schedule": "2.1.1", @@ -99,6 +100,7 @@ "tailwind-merge": "^2.6.0", "typeorm": "0.3.11", "undici": "^7.3.0", + "ua-parser-js": "^1.0.35", "web-push": "3.5.0", "wink-jaro-distance": "^2.0.0", "winston": "3.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f46a78dd1..86ec16a6e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@tanem/react-nprogress': specifier: 5.0.30 version: 5.0.30(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/ua-parser-js': + specifier: ^0.7.36 + version: 0.7.39 '@types/wink-jaro-distance': specifier: ^2.0.2 version: 2.0.2 @@ -105,8 +108,8 @@ importers: specifier: '3' version: 3.0.0 next: - specifier: ^14.2.24 - version: 14.2.24(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^14.2.25 + version: 14.2.25(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) node-cache: specifier: 5.1.2 version: 5.1.2 @@ -206,6 +209,9 @@ importers: typeorm: specifier: 0.3.11 version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) + ua-parser-js: + specifier: ^1.0.35 + version: 1.0.40 undici: specifier: ^7.3.0 version: 7.3.0 @@ -2133,62 +2139,62 @@ packages: '@messageformat/runtime@3.0.1': resolution: {integrity: sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==} - '@next/env@14.2.24': - resolution: {integrity: sha512-LAm0Is2KHTNT6IT16lxT+suD0u+VVfYNQqM+EJTKuFRRuY2z+zj01kueWXPCxbMBDt0B5vONYzabHGUNbZYAhA==} + '@next/env@14.2.25': + resolution: {integrity: sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w==} '@next/eslint-plugin-next@14.2.4': resolution: {integrity: sha512-svSFxW9f3xDaZA3idQmlFw7SusOuWTpDTAeBlO3AEPDltrraV+lqs7mAc6A27YdnpQVVIA3sODqUAAHdWhVWsA==} - '@next/swc-darwin-arm64@14.2.24': - resolution: {integrity: sha512-7Tdi13aojnAZGpapVU6meVSpNzgrFwZ8joDcNS8cJVNuP3zqqrLqeory9Xec5TJZR/stsGJdfwo8KeyloT3+rQ==} + '@next/swc-darwin-arm64@14.2.25': + resolution: {integrity: sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.24': - resolution: {integrity: sha512-lXR2WQqUtu69l5JMdTwSvQUkdqAhEWOqJEYUQ21QczQsAlNOW2kWZCucA6b3EXmPbcvmHB1kSZDua/713d52xg==} + '@next/swc-darwin-x64@14.2.25': + resolution: {integrity: sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.24': - resolution: {integrity: sha512-nxvJgWOpSNmzidYvvGDfXwxkijb6hL9+cjZx1PVG6urr2h2jUqBALkKjT7kpfurRWicK6hFOvarmaWsINT1hnA==} + '@next/swc-linux-arm64-gnu@14.2.25': + resolution: {integrity: sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@14.2.24': - resolution: {integrity: sha512-PaBgOPhqa4Abxa3y/P92F3kklNPsiFjcjldQGT7kFmiY5nuFn8ClBEoX8GIpqU1ODP2y8P6hio6vTomx2Vy0UQ==} + '@next/swc-linux-arm64-musl@14.2.25': + resolution: {integrity: sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@14.2.24': - resolution: {integrity: sha512-vEbyadiRI7GOr94hd2AB15LFVgcJZQWu7Cdi9cWjCMeCiUsHWA0U5BkGPuoYRnTxTn0HacuMb9NeAmStfBCLoQ==} + '@next/swc-linux-x64-gnu@14.2.25': + resolution: {integrity: sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@14.2.24': - resolution: {integrity: sha512-df0FC9ptaYsd8nQCINCzFtDWtko8PNRTAU0/+d7hy47E0oC17tI54U/0NdGk7l/76jz1J377dvRjmt6IUdkpzQ==} + '@next/swc-linux-x64-musl@14.2.25': + resolution: {integrity: sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@14.2.24': - resolution: {integrity: sha512-ZEntbLjeYAJ286eAqbxpZHhDFYpYjArotQ+/TW9j7UROh0DUmX7wYDGtsTPpfCV8V+UoqHBPU7q9D4nDNH014Q==} + '@next/swc-win32-arm64-msvc@14.2.25': + resolution: {integrity: sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.24': - resolution: {integrity: sha512-9KuS+XUXM3T6v7leeWU0erpJ6NsFIwiTFD5nzNg8J5uo/DMIPvCp3L1Ao5HjbHX0gkWPB1VrKoo/Il4F0cGK2Q==} + '@next/swc-win32-ia32-msvc@14.2.25': + resolution: {integrity: sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@next/swc-win32-x64-msvc@14.2.24': - resolution: {integrity: sha512-cXcJ2+x0fXQ2CntaE00d7uUH+u1Bfp/E0HsNQH79YiLaZE5Rbm7dZzyAYccn3uICM7mw+DxoMqEfGXZtF4Fgaw==} + '@next/swc-win32-x64-msvc@14.2.25': + resolution: {integrity: sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3412,6 +3418,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/ua-parser-js@0.7.39': + resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -7017,8 +7026,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@14.2.24: - resolution: {integrity: sha512-En8VEexSJ0Py2FfVnRRh8gtERwDRaJGNvsvad47ShkC2Yi8AXQPXEA2vKoDJlGFSj5WE5SyF21zNi4M5gyi+SQ==} + next@14.2.25: + resolution: {integrity: sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -9223,6 +9232,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@1.0.40: + resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -11860,37 +11873,37 @@ snapshots: dependencies: make-plural: 7.4.0 - '@next/env@14.2.24': {} + '@next/env@14.2.25': {} '@next/eslint-plugin-next@14.2.4': dependencies: glob: 10.3.10 - '@next/swc-darwin-arm64@14.2.24': + '@next/swc-darwin-arm64@14.2.25': optional: true - '@next/swc-darwin-x64@14.2.24': + '@next/swc-darwin-x64@14.2.25': optional: true - '@next/swc-linux-arm64-gnu@14.2.24': + '@next/swc-linux-arm64-gnu@14.2.25': optional: true - '@next/swc-linux-arm64-musl@14.2.24': + '@next/swc-linux-arm64-musl@14.2.25': optional: true - '@next/swc-linux-x64-gnu@14.2.24': + '@next/swc-linux-x64-gnu@14.2.25': optional: true - '@next/swc-linux-x64-musl@14.2.24': + '@next/swc-linux-x64-musl@14.2.25': optional: true - '@next/swc-win32-arm64-msvc@14.2.24': + '@next/swc-win32-arm64-msvc@14.2.25': optional: true - '@next/swc-win32-ia32-msvc@14.2.24': + '@next/swc-win32-ia32-msvc@14.2.25': optional: true - '@next/swc-win32-x64-msvc@14.2.24': + '@next/swc-win32-x64-msvc@14.2.25': optional: true '@nodelib/fs.scandir@2.1.5': @@ -13506,7 +13519,7 @@ snapshots: '@swc/helpers@0.5.5': dependencies: '@swc/counter': 0.1.3 - tslib: 2.6.3 + tslib: 2.8.1 '@swc/types@0.1.17': dependencies: @@ -13778,6 +13791,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/ua-parser-js@0.7.39': {} + '@types/unist@2.0.10': {} '@types/web-push@3.3.2': @@ -18289,27 +18304,27 @@ snapshots: nerf-dart@1.0.0: {} - next@14.2.24(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.25(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 14.2.24 + '@next/env': 14.2.25 '@swc/helpers': 0.5.5 busboy: 1.6.0 - caniuse-lite: 1.0.30001636 + caniuse-lite: 1.0.30001700 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.1(@babel/core@7.24.7)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.24 - '@next/swc-darwin-x64': 14.2.24 - '@next/swc-linux-arm64-gnu': 14.2.24 - '@next/swc-linux-arm64-musl': 14.2.24 - '@next/swc-linux-x64-gnu': 14.2.24 - '@next/swc-linux-x64-musl': 14.2.24 - '@next/swc-win32-arm64-msvc': 14.2.24 - '@next/swc-win32-ia32-msvc': 14.2.24 - '@next/swc-win32-x64-msvc': 14.2.24 + '@next/swc-darwin-arm64': 14.2.25 + '@next/swc-darwin-x64': 14.2.25 + '@next/swc-linux-arm64-gnu': 14.2.25 + '@next/swc-linux-arm64-musl': 14.2.25 + '@next/swc-linux-x64-gnu': 14.2.25 + '@next/swc-linux-x64-musl': 14.2.25 + '@next/swc-win32-arm64-msvc': 14.2.25 + '@next/swc-win32-ia32-msvc': 14.2.25 + '@next/swc-win32-x64-msvc': 14.2.25 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -20672,6 +20687,8 @@ snapshots: typescript@5.5.2: {} + ua-parser-js@1.0.40: {} + uc.micro@2.1.0: optional: true diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index d17ebf99e1..85612808b8 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -289,7 +289,7 @@ class ExternalAPI { return data; } - protected removeCache(endpoint: string, options?: Record) { + protected removeCache(endpoint: string, options?: Record) { const cacheKey = this.serializeCacheKey(endpoint, { ...this.params, ...options, diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index b2323ea5b3..5ce1d9cf75 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -110,11 +110,18 @@ class JellyfinAPI extends ExternalAPI { deviceId?: string | null ) { const settings = getSettings(); + const safeDeviceId = + deviceId && deviceId.length > 0 + ? deviceId + : Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString( + 'base64' + ); + let authHeaderVal: string; if (authToken) { - authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`; + authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`; } else { - authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`; + authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`; } super( diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index f3bf3faaf8..638af88a2d 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -28,6 +28,39 @@ export interface RadarrMovie { qualityProfileId: number; added: string; hasFile: boolean; + tags: number[]; + movieFile?: { + id: number; + movieId: number; + relativePath?: string; + path?: string; + size: number; + dateAdded: string; + sceneName?: string; + releaseGroup?: string; + edition?: string; + indexerFlags?: number; + mediaInfo: { + id: number; + audioBitrate: number; + audioChannels: number; + audioCodec?: string; + audioLanguages?: string; + audioStreamCount: number; + videoBitDepth: number; + videoBitrate: number; + videoCodec?: string; + videoFps: number; + videoDynamicRange?: string; + videoDynamicRangeType?: string; + resolution?: string; + runTime?: string; + scanType?: string; + subtitles?: string; + }; + originalFilePath?: string; + qualityCutoffNotMet: boolean; + }; } class RadarrAPI extends ServarrBase<{ movieId: number }> { @@ -104,7 +137,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { minimumAvailability: options.minimumAvailability, tmdbId: options.tmdbId, year: options.year, - tags: options.tags, + tags: Array.from(new Set([...movie.tags, ...options.tags])), rootFolderPath: options.rootFolderPath, monitored: options.monitored, addOptions: { @@ -241,10 +274,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { if (tmdbId) { this.removeCache('/movie/lookup', { term: `tmdb:${tmdbId}`, + headers: this.defaultHeaders, }); } if (externalId) { - this.removeCache(`/movie/${externalId}`); + this.removeCache(`/movie/${externalId}`, { + headers: this.defaultHeaders, + }); } }; } diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 5590c9acb6..0cbd4a57dd 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -184,7 +184,9 @@ class SonarrAPI extends ServarrBase<{ // If the series already exists, we will simply just update it if (series.id) { series.monitored = options.monitored ?? series.monitored; - series.tags = options.tags ?? series.tags; + series.tags = options.tags + ? Array.from(new Set([...series.tags, ...options.tags])) + : series.tags; series.seasons = this.buildSeasonList(options.seasons, series.seasons); const newSeriesData = await this.put( @@ -366,14 +368,18 @@ class SonarrAPI extends ServarrBase<{ if (tvdbId) { this.removeCache('/series/lookup', { term: `tvdb:${tvdbId}`, + headers: this.defaultHeaders, }); } if (externalId) { - this.removeCache(`/series/${externalId}`); + this.removeCache(`/series/${externalId}`, { + headers: this.defaultHeaders, + }); } if (title) { this.removeCache('/series/lookup', { term: title, + headers: this.defaultHeaders, }); } }; diff --git a/server/entity/User.ts b/server/entity/User.ts index 91b6674037..5f51af7101 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -98,6 +98,12 @@ export class User { @Column() public avatar: string; + @Column({ type: 'varchar', nullable: true }) + public avatarETag?: string | null; + + @Column({ type: 'varchar', nullable: true }) + public avatarVersion?: string | null; + @RelationCount((user: User) => user.requests) public requestCount: number; diff --git a/server/entity/UserPushSubscription.ts b/server/entity/UserPushSubscription.ts index 6389ea0b86..f05dd0f2bb 100644 --- a/server/entity/UserPushSubscription.ts +++ b/server/entity/UserPushSubscription.ts @@ -1,4 +1,10 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { User } from './User'; @Entity() @@ -18,9 +24,15 @@ export class UserPushSubscription { @Column() public p256dh: string; - @Column({ unique: true }) + @Column() public auth: string; + @Column({ nullable: true }) + public userAgent: string; + + @CreateDateColumn({ nullable: true }) + public createdAt: Date; + constructor(init?: Partial) { Object.assign(this, init); } diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 88b1201de6..4a41ae9935 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -3,7 +3,10 @@ import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties, PaginatedResponse } from './common'; export interface RequestResultsResponse extends PaginatedResponse { - results: NonFunctionProperties[]; + results: (NonFunctionProperties & { + profileName?: string; + canRemove?: boolean; + })[]; } export type MediaRequestBody = { diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0fdfd62765..3f78551e02 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -747,7 +747,11 @@ class AvailabilitySync { } if (radarr && radarr.hasFile) { - existsInRadarr = true; + const resolution = + radarr?.movieFile?.mediaInfo?.resolution?.split('x'); + const is4kMovie = + resolution?.length === 2 && Number(resolution[0]) >= 2000; + existsInRadarr = is4k ? is4kMovie : !is4kMovie; } } catch (ex) { if (!ex.message.includes('404')) { diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 04e320a0bf..607245698b 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -193,14 +193,34 @@ class ImageProxy { public async clearCachedImage(path: string) { // find cacheKey const cacheKey = this.getCacheKey(path); + const directory = join(this.getCacheDirectory(), cacheKey); + + try { + await promises.access(directory); + } catch (e) { + if (e.code === 'ENOENT') { + logger.debug( + `Cache directory '${cacheKey}' does not exist; nothing to clear.`, + { + label: 'Image Cache', + } + ); + return; + } else { + logger.error('Error checking cache directory existence', { + label: 'Image Cache', + message: e.message, + }); + return; + } + } try { - const directory = join(this.getCacheDirectory(), cacheKey); const files = await promises.readdir(directory); await promises.rm(directory, { recursive: true }); - logger.info(`Cleared ${files[0]} from cache 'avatar'`, { + logger.debug(`Cleared ${files[0]} from cache 'avatar'`, { label: 'Image Cache', }); } catch (e) { diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index b4816ae553..bfef4f7ea8 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -65,8 +65,8 @@ class JellyfinScanner { if (!metadata?.Id) { logger.debug('No Id metadata for this title. Skipping', { - label: 'Plex Sync', - ratingKey: jellyfinitem.Id, + label: 'Jellyfin Sync', + jellyfinItemId: jellyfinitem.Id, }); return; } @@ -204,8 +204,8 @@ class JellyfinScanner { if (!metadata?.Id) { logger.debug('No Id metadata for this title. Skipping', { - label: 'Plex Sync', - ratingKey: jellyfinitem.Id, + label: 'Jellyfin Sync', + jellyfinItemId: jellyfinitem.Id, }); return; } diff --git a/server/migration/postgres/1743023615532-UpdateWebPush.ts b/server/migration/postgres/1743023615532-UpdateWebPush.ts new file mode 100644 index 0000000000..fa3f259c0d --- /dev/null +++ b/server/migration/postgres/1743023615532-UpdateWebPush.ts @@ -0,0 +1,29 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateWebPush1743023615532 implements MigrationInterface { + name = 'UpdateWebPush1743023615532'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" ADD "userAgent" character varying` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" ADD "createdAt" TIMESTAMP DEFAULT now()` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth")` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" DROP COLUMN "createdAt"` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" DROP COLUMN "userAgent"` + ); + } +} diff --git a/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts b/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts new file mode 100644 index 0000000000..1e61e996e6 --- /dev/null +++ b/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAvatarCacheFields1743107707465 + implements MigrationInterface +{ + name = 'AddUserAvatarCacheFields1743107707465'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarETag" character varying` + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarVersion" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarVersion"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarETag"`); + } +} diff --git a/server/migration/sqlite/1743023610704-UpdateWebPush.ts b/server/migration/sqlite/1743023610704-UpdateWebPush.ts new file mode 100644 index 0000000000..35ed072ccd --- /dev/null +++ b/server/migration/sqlite/1743023610704-UpdateWebPush.ts @@ -0,0 +1,203 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateWebPush1743023610704 implements MigrationInterface { + name = 'UpdateWebPush1743023610704'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` + ); + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` + ); + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); + await queryRunner.query( + `CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"` + ); + await queryRunner.query(`DROP TABLE "watchlist"`); + await queryRunner.query( + `ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` + ); + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` + ); + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))` + ); + await queryRunner.query( + `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blacklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); + await queryRunner.query( + `ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"` + ); + await queryRunner.query( + `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` + ); + await queryRunner.query( + `INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"` + ); + await queryRunner.query(`DROP TABLE "temporary_watchlist"`); + await queryRunner.query( + `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` + ); + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)` + ); + await queryRunner.query( + `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blacklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` + ); + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))` + ); + await queryRunner.query( + `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blacklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + } +} diff --git a/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts b/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts new file mode 100644 index 0000000000..aff3e357eb --- /dev/null +++ b/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts @@ -0,0 +1,69 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAvatarCacheFields1743107645301 + implements MigrationInterface +{ + name = 'AddUserAvatarCacheFields1743107645301'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 4e470831a6..df6908a127 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -10,6 +10,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { checkAvatarChanged } from '@server/routes/avatarproxy'; import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import * as EmailValidator from 'email-validator'; @@ -216,6 +217,10 @@ authRoutes.post('/plex', async (req, res, next) => { } }); +function getUserAvatarUrl(user: User): string { + return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`; +} + authRoutes.post('/jellyfin', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); @@ -343,12 +348,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, - avatar: `/avatarproxy/${account.User.Id}`, userType: body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); + user.avatar = getUserAvatarUrl(user); await userRepository.save(user); } else { @@ -375,7 +380,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { user.jellyfinDeviceId = deviceId; user.jellyfinAuthToken = account.AccessToken; user.permissions = Permission.ADMIN; - user.avatar = `/avatarproxy/${account.User.Id}`; + user.avatar = getUserAvatarUrl(user); user.userType = body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN @@ -422,7 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, } ); - user.avatar = `/avatarproxy/${account.User.Id}`; + user.avatar = getUserAvatarUrl(user); user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { @@ -460,12 +465,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, - avatar: `/avatarproxy/${account.User.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); + user.avatar = getUserAvatarUrl(user); //initialize Jellyfin/Emby users with local login const passedExplicitPassword = body.password && body.password.length > 0; @@ -475,6 +480,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => { await userRepository.save(user); } + if (user && user.jellyfinUserId) { + try { + const { changed } = await checkAvatarChanged(user); + + if (changed) { + user.avatar = getUserAvatarUrl(user); + await userRepository.save(user); + logger.debug('Avatar updated during login', { + userId: user.id, + jellyfinUserId: user.jellyfinUserId, + }); + } + } catch (error) { + logger.error('Error handling avatar during login', { + label: 'Auth', + errorMessage: error.message, + }); + } + } + // Set logged in session if (req.session) { req.session.userId = user?.id; diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index 5938fa945e..0065f011b3 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -8,10 +8,12 @@ import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; +import { createHash } from 'node:crypto'; const router = Router(); let _avatarImageProxy: ImageProxy | null = null; + async function initAvatarImageProxy() { if (!_avatarImageProxy) { const userRepository = getRepository(User); @@ -31,6 +33,79 @@ async function initAvatarImageProxy() { return _avatarImageProxy; } +function getJellyfinAvatarUrl(userId: string) { + const settings = getSettings(); + return settings.main.mediaServerType === MediaServerType.JELLYFIN + ? `${getHostname()}/UserImage?UserId=${userId}` + : `${getHostname()}/Users/${userId}/Images/Primary?quality=90`; +} + +function computeImageHash(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex'); +} + +export async function checkAvatarChanged( + user: User +): Promise<{ changed: boolean; etag?: string }> { + try { + if (!user || !user.jellyfinUserId) { + return { changed: false }; + } + + const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId); + + const headResponse = await fetch(jellyfinAvatarUrl, { method: 'HEAD' }); + if (!headResponse.ok) { + return { changed: false }; + } + + const settings = getSettings(); + let remoteVersion: string; + if (settings.main.mediaServerType === MediaServerType.JELLYFIN) { + const remoteLastModifiedStr = + headResponse.headers.get('last-modified') || ''; + remoteVersion = ( + Date.parse(remoteLastModifiedStr) || Date.now() + ).toString(); + } else if (settings.main.mediaServerType === MediaServerType.EMBY) { + remoteVersion = + headResponse.headers.get('etag')?.replace(/"/g, '') || + Date.now().toString(); + } else { + remoteVersion = Date.now().toString(); + } + + if (user.avatarVersion && user.avatarVersion === remoteVersion) { + return { changed: false, etag: user.avatarETag ?? undefined }; + } + + const avatarImageCache = await initAvatarImageProxy(); + await avatarImageCache.clearCachedImage(jellyfinAvatarUrl); + const imageData = await avatarImageCache.getImage( + jellyfinAvatarUrl, + gravatarUrl(user.email || 'none', { default: 'mm', size: 200 }) + ); + + const newHash = computeImageHash(imageData.imageBuffer); + + const hasChanged = user.avatarETag !== newHash; + + user.avatarVersion = remoteVersion; + if (hasChanged) { + user.avatarETag = newHash; + } + + await getRepository(User).save(user); + + return { changed: hasChanged, etag: newHash }; + } catch (error) { + logger.error('Error checking avatar changes', { + errorMessage: error.message, + }); + return { changed: false }; + } +} + router.get('/:jellyfinUserId', async (req, res) => { try { if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) { @@ -46,6 +121,10 @@ router.get('/:jellyfinUserId', async (req, res) => { const avatarImageCache = await initAvatarImageProxy(); + const userEtag = req.headers['if-none-match']; + + const versionParam = req.query.v; + const user = await getRepository(User).findOne({ where: { jellyfinUserId: req.params.jellyfinUserId }, }); @@ -55,13 +134,7 @@ router.get('/:jellyfinUserId', async (req, res) => { size: 200, }); - const setttings = getSettings(); - const jellyfinAvatarUrl = - setttings.main.mediaServerType === MediaServerType.JELLYFIN - ? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}` - : `${getHostname()}/Users/${ - req.params.jellyfinUserId - }/Images/Primary?quality=90`; + const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId); let imageData = await avatarImageCache.getImage( jellyfinAvatarUrl, @@ -73,10 +146,15 @@ router.get('/:jellyfinUserId', async (req, res) => { imageData = await avatarImageCache.getImage(fallbackUrl); } + if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) { + return res.status(304).end(); + } + res.writeHead(200, { 'Content-Type': `image/${imageData.meta.extension}`, 'Content-Length': imageData.imageBuffer.length, 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + ETag: `"${imageData.meta.etag}"`, 'OS-Cache-Key': imageData.meta.cacheKey, 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', }); diff --git a/server/routes/media.ts b/server/routes/media.ts index 3ad197c9db..60191e5de6 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -237,19 +237,6 @@ mediaRoutes.delete( } if (isMovie) { - // check if the movie exists - try { - await (service as RadarrAPI).getMovie({ - id: parseInt( - is4k - ? (media.externalServiceSlug4k as string) - : (media.externalServiceSlug as string) - ), - }); - } catch { - return res.status(204).send(); - } - // remove the movie await (service as RadarrAPI).removeMovie( parseInt( is4k @@ -264,13 +251,6 @@ mediaRoutes.delete( if (!tvdbId) { throw new Error('TVDB ID not found'); } - // check if the series exists - try { - await (service as SonarrAPI).getSeriesByTvdbId(tvdbId); - } catch { - return res.status(204).send(); - } - // remove the series await (service as SonarrAPI).removeSerie(tvdbId); } diff --git a/server/routes/request.ts b/server/routes/request.ts index 89e5352fad..50d4a6f003 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -189,7 +189,7 @@ requestRoutes.get, RequestResultsResponse>( ); // add profile names to the media requests, with undefined if not found - const requestsWithProfileNames = requests.map((r) => { + let mappedRequests = requests.map((r) => { switch (r.type) { case MediaType.MOVIE: { const profileName = radarrServers @@ -212,6 +212,36 @@ requestRoutes.get, RequestResultsResponse>( } }); + // add canRemove prop if user has permission + if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) { + mappedRequests = mappedRequests.map((r) => { + switch (r.type) { + case MediaType.MOVIE: { + return { + ...r, + // check if the radarr server for this request is configured + canRemove: radarrServers.some( + (server) => + server.id === + (r.is4k ? r.media.serviceId4k : r.media.serviceId) + ), + }; + } + case MediaType.TV: { + return { + ...r, + // check if the sonarr server for this request is configured + canRemove: sonarrServers.some( + (server) => + server.id === + (r.is4k ? r.media.serviceId4k : r.media.serviceId) + ), + }; + } + } + }); + } + return res.status(200).json({ pageInfo: { pages: Math.ceil(requestCount / pageSize), @@ -219,7 +249,7 @@ requestRoutes.get, RequestResultsResponse>( results: requestCount, page: Math.ceil(skip / pageSize) + 1, }, - results: requestsWithProfileNames, + results: mappedRequests, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 0c79e4f3e2..028b26e62f 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -184,13 +184,15 @@ router.post< endpoint: string; p256dh: string; auth: string; + userAgent: string; } >('/registerPushSubscription', async (req, res, next) => { try { const userPushSubRepository = getRepository(UserPushSubscription); const existingSubs = await userPushSubRepository.find({ - where: { auth: req.body.auth }, + relations: { user: true }, + where: { auth: req.body.auth, user: { id: req.user?.id } }, }); if (existingSubs.length > 0) { @@ -205,6 +207,7 @@ router.post< auth: req.body.auth, endpoint: req.body.endpoint, p256dh: req.body.p256dh, + userAgent: req.body.userAgent, user: req.user, }); @@ -219,6 +222,79 @@ router.post< } }); +router.get<{ userId: number }>( + '/:userId/pushSubscriptions', + async (req, res, next) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const userPushSubs = await userPushSubRepository.find({ + relations: { user: true }, + where: { user: { id: req.params.userId } }, + }); + + return res.status(200).json(userPushSubs); + } catch (e) { + next({ status: 404, message: 'User subscriptions not found.' }); + } + } +); + +router.get<{ userId: number; key: string }>( + '/:userId/pushSubscription/:key', + async (req, res, next) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const userPushSub = await userPushSubRepository.findOneOrFail({ + relations: { + user: true, + }, + where: { + user: { id: req.params.userId }, + p256dh: req.params.key, + }, + }); + + return res.status(200).json(userPushSub); + } catch (e) { + next({ status: 404, message: 'User subscription not found.' }); + } + } +); + +router.delete<{ userId: number; key: string }>( + '/:userId/pushSubscription/:key', + async (req, res, next) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const userPushSub = await userPushSubRepository.findOneOrFail({ + relations: { + user: true, + }, + where: { + user: { id: req.params.userId }, + p256dh: req.params.key, + }, + }); + + await userPushSubRepository.remove(userPushSub); + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong deleting the user push subcription', { + label: 'API', + key: req.params.key, + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'User push subcription not found', + }); + } + } +); + router.get<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx index 336044de63..f21dc35b69 100644 --- a/src/components/Common/SensitiveInput/index.tsx +++ b/src/components/Common/SensitiveInput/index.tsx @@ -29,7 +29,6 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => { data-form-type="other" data-1pignore="true" data-lpignore="true" - data-bwignore="true" {...componentProps} className={`rounded-l-only ${componentProps.className ?? ''}`} type={ diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 52e84d3dea..09cec4a012 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -255,7 +255,9 @@ const MobileMenu = ({ router.pathname.match(link.activeRegExp) ? 'border-indigo-600 from-indigo-700 to-purple-700' : 'border-indigo-500 from-indigo-600 to-purple-600' - } flex h-4 w-4 items-center justify-center !px-[9px] !py-[9px] text-[9px]`} + } flex ${ + pendingRequestsCount > 99 ? 'w-6' : 'w-4' + } h-4 items-center justify-center !px-[5px] !py-[7px] text-[8px]`} > {pendingRequestsCount > 99 ? '99+' diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 037590e5d2..dd72ee7e8b 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -17,10 +17,10 @@ import { TrashIcon, XMarkIcon, } from '@heroicons/react/24/solid'; -import { MediaRequestStatus, MediaType } from '@server/constants/media'; +import { MediaRequestStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties } from '@server/interfaces/api/common'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import Link from 'next/link'; @@ -292,18 +292,11 @@ const RequestItemError = ({ }; interface RequestItemProps { - request: NonFunctionProperties & { profileName?: string }; + request: RequestResultsResponse['results'][number]; revalidateList: () => void; - radarrData?: RadarrSettings[]; - sonarrData?: SonarrSettings[]; } -const RequestItem = ({ - request, - revalidateList, - radarrData, - sonarrData, -}: RequestItemProps) => { +const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const settings = useSettings(); const { ref, inView } = useInView({ triggerOnce: true, @@ -398,23 +391,6 @@ const RequestItem = ({ iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, }); - const serviceExists = () => { - if (title?.mediaInfo) { - if (title?.mediaInfo.mediaType === MediaType.MOVIE) { - return ( - radarrData?.find((radarr) => radarr.id === request.serverId) !== - undefined - ); - } else { - return ( - sonarrData?.find((sonarr) => sonarr.id === request.serverId) !== - undefined - ); - } - } - return false; - }; - if (!title && !error) { return (
deleteRequest()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.deleterequest)} - - )} - {hasPermission(Permission.MANAGE_REQUESTS) && - title?.mediaInfo?.serviceId && - serviceExists() && ( - deleteMediaFile()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - - {intl.formatMessage(messages.removearr, { - arr: request.type === 'movie' ? 'Radarr' : 'Sonarr', - })} - - + <> + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deleterequest)} + + {request.canRemove && ( + deleteMediaFile()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + + {intl.formatMessage(messages.removearr, { + arr: request.type === 'movie' ? 'Radarr' : 'Sonarr', + })} + + + )} + )} {requestData.status === MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 468c0853e5..6cdf5b0bfb 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -17,8 +17,6 @@ import { FunnelIcon, } from '@heroicons/react/24/solid'; import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; -import { Permission } from '@server/lib/permissions'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -53,7 +51,7 @@ const RequestList = () => { const { user } = useUser({ id: Number(router.query.userId), }); - const { user: currentUser, hasPermission } = useUser(); + const { user: currentUser } = useUser(); const [currentFilter, setCurrentFilter] = useState(Filter.PENDING); const [currentSort, setCurrentSort] = useState('added'); const [currentSortDirection, setCurrentSortDirection] = @@ -64,13 +62,6 @@ const RequestList = () => { const pageIndex = page - 1; const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); - const { data: radarrData } = useSWR( - hasPermission(Permission.ADMIN) ? '/api/v1/settings/radarr' : null - ); - const { data: sonarrData } = useSWR( - hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null - ); - const { data, error, @@ -254,8 +245,6 @@ const RequestList = () => { revalidate()} - radarrData={radarrData} - sonarrData={sonarrData} />
); diff --git a/src/components/ServiceWorkerSetup/index.tsx b/src/components/ServiceWorkerSetup/index.tsx index f9b42cd397..2e0313f4db 100644 --- a/src/components/ServiceWorkerSetup/index.tsx +++ b/src/components/ServiceWorkerSetup/index.tsx @@ -1,10 +1,9 @@ /* eslint-disable no-console */ -import useSettings from '@app/hooks/useSettings'; + import { useUser } from '@app/hooks/useUser'; import { useEffect } from 'react'; const ServiceWorkerSetup = () => { - const { currentSettings } = useSettings(); const { user } = useUser(); useEffect(() => { if ('serviceWorker' in navigator && user?.id) { @@ -15,40 +14,12 @@ const ServiceWorkerSetup = () => { '[SW] Registration successful, scope is:', registration.scope ); - - if (currentSettings.enablePushRegistration) { - const sub = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: currentSettings.vapidPublic, - }); - - const parsedSub = JSON.parse(JSON.stringify(sub)); - - if (parsedSub.keys.p256dh && parsedSub.keys.auth) { - const res = await fetch('/api/v1/user/registerPushSubscription', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - endpoint: parsedSub.endpoint, - p256dh: parsedSub.keys.p256dh, - auth: parsedSub.keys.auth, - }), - }); - if (!res.ok) throw new Error(); - } - } }) .catch(function (error) { console.log('[SW] Service worker registration failed, error:', error); }); } - }, [ - user, - currentSettings.vapidPublic, - currentSettings.enablePushRegistration, - ]); + }, [user]); return null; }; diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx index 8b2acaf67b..7f1f3f2c08 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx @@ -141,7 +141,7 @@ const OverrideRuleTiles = ({ } setUsers(users); })(); - }, [rules]); + }, [rules, users]); return ( <> diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx index 078d1f1a10..28bbc89811 100644 --- a/src/components/UserList/PlexImportModal.tsx +++ b/src/components/UserList/PlexImportModal.tsx @@ -57,9 +57,9 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { }), }); if (!res.ok) throw new Error(); - const { data: createdUsers } = await res.json(); + const createdUsers = await res.json(); - if (!createdUsers.length) { + if (!Array.isArray(createdUsers) || createdUsers.length === 0) { throw new Error('No users were imported from Plex.'); } diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 90fbb13c79..e0df3f5d40 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -415,7 +415,7 @@ const UserGeneralSettings = () => {
-
+
{
-
+
{ - const intl = useIntl(); - const { addToast } = useToasts(); - const router = useRouter(); - const { user } = useUser({ id: Number(router.query.userId) }); - const { - data, - error, - mutate: revalidate, - } = useSWR( - user ? `/api/v1/user/${user?.id}/settings/notifications` : null - ); - - if (!data && !error) { - return ; - } - - return ( - { - try { - const res = await fetch( - `/api/v1/user/${user?.id}/settings/notifications`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - pgpKey: data?.pgpKey, - discordId: data?.discordId, - pushbulletAccessToken: data?.pushbulletAccessToken, - pushoverApplicationToken: data?.pushoverApplicationToken, - pushoverUserKey: data?.pushoverUserKey, - telegramChatId: data?.telegramChatId, - telegramSendSilently: data?.telegramSendSilently, - notificationTypes: { - webpush: values.types, - }, - }), - } - ); - if (!res.ok) throw new Error(); - mutate('/api/v1/settings/public'); - addToast(intl.formatMessage(messages.webpushsettingssaved), { - appearance: 'success', - autoDismiss: true, - }); - } catch (e) { - addToast(intl.formatMessage(messages.webpushsettingsfailed), { - appearance: 'error', - autoDismiss: true, - }); - } finally { - revalidate(); - } - }} - > - {({ - errors, - touched, - isSubmitting, - isValid, - values, - setFieldValue, - setFieldTouched, - }) => { - return ( -
- { - setFieldValue('types', newTypes); - setFieldTouched('types'); - }} - error={ - errors.types && touched.types - ? (errors.types as string) - : undefined - } - /> -
-
- - - -
-
- - ); - }} -
- ); -}; - -export default UserWebPushSettings; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx new file mode 100644 index 0000000000..59da71093a --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx @@ -0,0 +1,110 @@ +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { + ComputerDesktopIcon, + DevicePhoneMobileIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; +import { useIntl } from 'react-intl'; +import { UAParser } from 'ua-parser-js'; + +interface DeviceItemProps { + disablePushNotifications: (p256dh: string) => void; + device: { + endpoint: string; + p256dh: string; + auth: string; + userAgent: string; + createdAt: Date; + }; +} + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush', + { + operatingsystem: 'Operating System', + browser: 'Browser', + engine: 'Engine', + deletesubscription: 'Delete Subscription', + unknown: 'Unknown', + } +); + +const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => { + const intl = useIntl(); + + return ( +
+
+
+
+ {UAParser(device.userAgent).device.type === 'mobile' ? ( + + ) : ( + + )} +
+
+
+ {device.createdAt + ? intl.formatDate(device.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} +
+
+ {device.userAgent + ? UAParser(device.userAgent).device.model + : intl.formatMessage(messages.unknown)} +
+
+
+
+
+ + {intl.formatMessage(messages.operatingsystem)} + + + {device.userAgent ? UAParser(device.userAgent).os.name : 'N/A'} + +
+
+ + {intl.formatMessage(messages.browser)} + + + {device.userAgent + ? UAParser(device.userAgent).browser.name + : 'N/A'} + +
+
+ + {intl.formatMessage(messages.engine)} + + + {device.userAgent + ? UAParser(device.userAgent).engine.name + : 'N/A'} + +
+
+
+
+ disablePushNotifications(device.p256dh)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deletesubscription)} + +
+
+ ); +}; + +export default DeviceItem; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx new file mode 100644 index 0000000000..de438e3ade --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx @@ -0,0 +1,378 @@ +import Alert from '@app/components/Common/Alert'; +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import NotificationTypeSelector, { + ALL_NOTIFICATIONS, +} from '@app/components/NotificationTypeSelector'; +import DeviceItem from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; +import { + CloudArrowDownIcon, + CloudArrowUpIcon, +} from '@heroicons/react/24/solid'; +import type { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; +import { Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR, { mutate } from 'swr'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush', + { + webpushsettingssaved: 'Web push notification settings saved successfully!', + webpushsettingsfailed: 'Web push notification settings failed to save.', + enablewebpush: 'Enable web push', + disablewebpush: 'Disable web push', + managedevices: 'Manage Devices', + type: 'type', + created: 'Created', + device: 'Device', + subscriptiondeleted: 'Subscription deleted.', + subscriptiondeleteerror: + 'Something went wrong while deleting the user subscription.', + nodevicestoshow: 'You have no web push subscriptions to show.', + webpushhasbeenenabled: 'Web push has been enabled.', + webpushhasbeendisabled: 'Web push has been disabled.', + enablingwebpusherror: 'Something went wrong while enabling web push.', + disablingwebpusherror: 'Something went wrong while disabling web push.', + } +); + +const UserWebPushSettings = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user } = useUser({ id: Number(router.query.userId) }); + const { currentSettings } = useSettings(); + const [webPushEnabled, setWebPushEnabled] = useState(false); + const { + data, + error, + mutate: revalidate, + } = useSWR( + user ? `/api/v1/user/${user?.id}/settings/notifications` : null + ); + const { data: dataDevices, mutate: revalidateDevices } = useSWR< + { + endpoint: string; + p256dh: string; + auth: string; + userAgent: string; + createdAt: Date; + }[] + >(`/api/v1/user/${user?.id}/pushSubscriptions`, { revalidateOnMount: true }); + + // Subscribes to the push manager + // Will only add to the database if subscribing for the first time + const enablePushNotifications = () => { + if ('serviceWorker' in navigator && user?.id) { + navigator.serviceWorker + .getRegistration('/sw.js') + .then(async (registration) => { + if (currentSettings.enablePushRegistration) { + const sub = await registration?.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: currentSettings.vapidPublic, + }); + const parsedSub = JSON.parse(JSON.stringify(sub)); + + if (parsedSub.keys.p256dh && parsedSub.keys.auth) { + const res = await fetch('/api/v1/user/registerPushSubscription', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: parsedSub.endpoint, + p256dh: parsedSub.keys.p256dh, + auth: parsedSub.keys.auth, + userAgent: navigator.userAgent, + }), + }); + if (!res.ok) { + throw new Error(res.statusText); + } + setWebPushEnabled(true); + addToast(intl.formatMessage(messages.webpushhasbeenenabled), { + appearance: 'success', + autoDismiss: true, + }); + } + } + }) + .catch(function () { + addToast(intl.formatMessage(messages.enablingwebpusherror), { + autoDismiss: true, + appearance: 'error', + }); + }) + .finally(function () { + revalidateDevices(); + }); + } + }; + + // Unsubscribes from the push manager + // Deletes/disables corresponding push subscription from database + const disablePushNotifications = async (p256dh?: string) => { + if ('serviceWorker' in navigator && user?.id) { + navigator.serviceWorker.getRegistration('/sw.js').then((registration) => { + registration?.pushManager + .getSubscription() + .then(async (subscription) => { + const parsedSub = JSON.parse(JSON.stringify(subscription)); + + const res = await fetch( + `/api/v1/user/${user?.id}/pushSubscription/${ + p256dh ? p256dh : parsedSub.keys.p256dh + }`, + { + method: 'DELETE', + } + ); + if (!res.ok) { + throw new Error(res.statusText); + } + if (subscription && (p256dh === parsedSub.keys.p256dh || !p256dh)) { + subscription.unsubscribe(); + setWebPushEnabled(false); + } + addToast( + intl.formatMessage( + p256dh + ? messages.subscriptiondeleted + : messages.webpushhasbeendisabled + ), + { + autoDismiss: true, + appearance: 'success', + } + ); + }) + .catch(function () { + addToast( + intl.formatMessage( + p256dh + ? messages.subscriptiondeleteerror + : messages.disablingwebpusherror + ), + { + autoDismiss: true, + appearance: 'error', + } + ); + }) + .finally(function () { + revalidateDevices(); + }); + }); + } + }; + + // Checks our current subscription on page load + // Will set the web push state to true if subscribed + useEffect(() => { + if ('serviceWorker' in navigator && user?.id) { + navigator.serviceWorker + .getRegistration('/sw.js') + .then(async (registration) => { + await registration?.pushManager + .getSubscription() + .then(async (subscription) => { + if (subscription) { + const parsedKey = JSON.parse(JSON.stringify(subscription)); + const response = await fetch( + `/api/v1/user/${user.id}/pushSubscription/${parsedKey.keys.p256dh}` + ); + + if (!response.ok) { + throw new Error(response.statusText); + } + + const currentUserPushSub = { + data: (await response.json()) as UserPushSubscription, + }; + + if (currentUserPushSub.data.p256dh !== parsedKey.keys.p256dh) { + return; + } + setWebPushEnabled(true); + } else { + setWebPushEnabled(false); + } + }); + }) + .catch(function (error) { + setWebPushEnabled(false); + // eslint-disable-next-line no-console + console.log( + '[SW] Failure retrieving push manager subscription, error:', + error + ); + }); + } + }, [user?.id]); + + if (!data && !error) { + return ; + } + + return ( + <> + { + try { + const res = await fetch( + `/api/v1/user/${user?.id}/settings/notifications`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + pgpKey: data?.pgpKey, + discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, + telegramChatId: data?.telegramChatId, + telegramSendSilently: data?.telegramSendSilently, + notificationTypes: { + webpush: values.types, + }, + }), + } + ); + if (!res.ok) { + throw new Error(res.statusText); + } + mutate('/api/v1/settings/public'); + addToast(intl.formatMessage(messages.webpushsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.webpushsettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + isValid, + values, + setFieldValue, + setFieldTouched, + }) => { + return ( +
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + }} + error={ + errors.types && touched.types + ? (errors.types as string) + : undefined + } + /> +
+
+ + + + + + +
+
+ + ); + }} +
+
+

+ {intl.formatMessage(messages.managedevices)} +

+
+ {dataDevices?.length ? ( + dataDevices + ?.sort((a, b) => { + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return dateB - dateA; + }) + .map((device, index) => ( +
+ +
+ )) + ) : ( + <> + + + )} +
+
+ + ); +}; + +export default UserWebPushSettings; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 50df170b20..121f6882ef 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1339,6 +1339,26 @@ "components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.", "components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user", "components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.browser": "Browser", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.created": "Created", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.deletesubscription": "Delete Subscription", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.device": "Device", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.disablewebpush": "Disable web push", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.disablingwebpusherror": "Something went wrong while disabling web push.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.enablewebpush": "Enable web push", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.enablingwebpusherror": "Something went wrong while enabling web push.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.engine": "Engine", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.managedevices": "Manage Devices", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.nodevicestoshow": "You have no web push subscriptions to show.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.operatingsystem": "Operating System", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.subscriptiondeleted": "Subscription deleted.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.subscriptiondeleteerror": "Something went wrong while deleting the user subscription.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.type": "type", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.unknown": "Unknown", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushhasbeendisabled": "Web push has been disabled.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushhasbeenenabled": "Web push has been enabled.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default", "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID", "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The multi-digit ID number associated with your user account", @@ -1378,8 +1398,6 @@ "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramMessageThreadId": "The thread/topic ID must be a positive whole number", "components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push", - "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingsfailed": "Web push notification settings failed to save.", - "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingssaved": "Web push notification settings saved successfully!", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 9e87cbdf0c..1b29d41e80 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -242,7 +242,9 @@ CoreApp.getInitialProps = async (initialProps) => { if (ctx.res) { // Check if app is initialized and redirect if necessary const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/settings/public` + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/settings/public` ); if (!res.ok) throw new Error(); currentSettings = await res.json(); @@ -260,7 +262,9 @@ CoreApp.getInitialProps = async (initialProps) => { try { // Attempt to get the user by running a request to the local api const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/auth/me`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/auth/me`, { headers: ctx.req && ctx.req.headers.cookie diff --git a/src/pages/collection/[collectionId]/index.tsx b/src/pages/collection/[collectionId]/index.tsx index b0c47b17c2..da9c6bf038 100644 --- a/src/pages/collection/[collectionId]/index.tsx +++ b/src/pages/collection/[collectionId]/index.tsx @@ -14,9 +14,9 @@ export const getServerSideProps: GetServerSideProps< CollectionPageProps > = async (ctx) => { const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/collection/${ - ctx.query.collectionId - }`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/collection/${ctx.query.collectionId}`, { headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie } diff --git a/src/pages/movie/[movieId]/index.tsx b/src/pages/movie/[movieId]/index.tsx index be0d2aa5a3..cf2b11b9c2 100644 --- a/src/pages/movie/[movieId]/index.tsx +++ b/src/pages/movie/[movieId]/index.tsx @@ -14,9 +14,9 @@ export const getServerSideProps: GetServerSideProps = async ( ctx ) => { const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/movie/${ - ctx.query.movieId - }`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/movie/${ctx.query.movieId}`, { headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie } diff --git a/src/pages/tv/[tvId]/index.tsx b/src/pages/tv/[tvId]/index.tsx index 3961b157a3..36fba5fcc1 100644 --- a/src/pages/tv/[tvId]/index.tsx +++ b/src/pages/tv/[tvId]/index.tsx @@ -14,7 +14,9 @@ export const getServerSideProps: GetServerSideProps = async ( ctx ) => { const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/tv/${ctx.query.tvId}`, { headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie }