From 0bd6d57834ca050c2f7f5f4b4425932e8bd24a3b Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Mon, 17 Mar 2025 10:50:08 +0100 Subject: [PATCH 01/18] docs(helm): add contributing guidelines for helm chart (#1486) Signed-off-by: Ludovic Ortega --- CONTRIBUTING.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1835e949f..27e2bfbbd 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. From 40539cc4b16ce10a25dd64011d4a79ef52b71ff4 Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Mon, 17 Mar 2025 14:59:56 +0100 Subject: [PATCH 02/18] chore(helm): bump jellyseerr to 2.5.1 (#1488) Signed-off-by: Ludovic Ortega --- charts/jellyseerr-chart/Chart.yaml | 4 ++-- charts/jellyseerr-chart/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/jellyseerr-chart/Chart.yaml b/charts/jellyseerr-chart/Chart.yaml index 5408ac1a5..b30826c1e 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.1 +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 eae3c6d69..34643167d 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.1](https://img.shields.io/badge/Version-2.3.1-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 From e5ab847547564869c3aa6443b1e22208c09a7810 Mon Sep 17 00:00:00 2001 From: Nicolaj Vinholt <30402684+nicolajv@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:32:17 +0100 Subject: [PATCH 03/18] fix(helm): apply annotations to pvc (#1489) --- charts/jellyseerr-chart/Chart.yaml | 2 +- charts/jellyseerr-chart/README.md | 2 +- charts/jellyseerr-chart/templates/persistentvolumeclaim.yaml | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/charts/jellyseerr-chart/Chart.yaml b/charts/jellyseerr-chart/Chart.yaml index b30826c1e..833c48fc7 100644 --- a/charts/jellyseerr-chart/Chart.yaml +++ b/charts/jellyseerr-chart/Chart.yaml @@ -3,7 +3,7 @@ kubeVersion: ">=1.23.0-0" name: jellyseerr-chart description: Jellyseerr helm chart for Kubernetes type: application -version: 2.3.1 +version: 2.3.2 appVersion: "2.5.1" maintainers: - name: Jellyseerr diff --git a/charts/jellyseerr-chart/README.md b/charts/jellyseerr-chart/README.md index 34643167d..72dce724e 100644 --- a/charts/jellyseerr-chart/README.md +++ b/charts/jellyseerr-chart/README.md @@ -1,6 +1,6 @@ # jellyseerr-chart -![Version: 2.3.1](https://img.shields.io/badge/Version-2.3.1-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) +![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 bf0d64223..a9363ee00 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: From f5b3a526cb9b12c19e5ff6a79240e3d85685ff9b Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 18 Mar 2025 20:47:08 +0100 Subject: [PATCH 04/18] fix(ui): resolve discover language dropdown overlap (#1497) The discover language selection field is always in the foreground and overlaps other open dropdowns. fix #1475 --- .../UserProfile/UserSettings/UserGeneralSettings/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 90fbb13c7..e0df3f5d4 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -415,7 +415,7 @@ const UserGeneralSettings = () => {
-
+
{
-
+
Date: Mon, 24 Mar 2025 16:11:35 +0100 Subject: [PATCH 05/18] fix(ui): handle import-from-plex response as array (#1510) --- src/components/UserList/PlexImportModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx index 078d1f1a1..28bbc8981 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.'); } From 70fb1f2b00f913e7172ce79c146c1595661232a1 Mon Sep 17 00:00:00 2001 From: alorente Date: Mon, 24 Mar 2025 16:12:36 +0100 Subject: [PATCH 06/18] ci: Add OCI Meta information to docker image (#1460) * Add build version and date to docker build args * Add OCI Meta information to Dockerfile * Update ci.yml --- .github/workflows/ci.yml | 2 ++ .github/workflows/preview.yml | 2 ++ Dockerfile | 13 +++++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44b07fd95..8189522f0 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 bacd21e0f..0084f27b4 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/Dockerfile b/Dockerfile index 96fecbe98..2089513ae 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 From c1aeab9538ba7762c2e9111205fbf83f35b8eb5f Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:14:59 +0800 Subject: [PATCH 07/18] chore: update nextjs to 14.2.25 (#1521) This updates nextjs to fix a security vulnerability. fixes #1516 --- package.json | 2 +- pnpm-lock.yaml | 94 +++++++++++++++++++++++++------------------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 67cbb55e5..a0e8a0ef0 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f46a78dd..61874f355 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,8 +105,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 @@ -2133,62 +2133,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] @@ -7017,8 +7017,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: @@ -11860,37 +11860,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 +13506,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: @@ -18289,27 +18289,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 From 163593237536a1137804a930cec03fed9639a319 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 24 Mar 2025 16:45:33 +0100 Subject: [PATCH 08/18] chore: merge upstream (#1466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pushover): attach image to pushover notification payload (#3701) * fix: api language query parameter (#3720) * docs: add j0srisk as a contributor for code (#3745) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(tooltip): add tooltip to display exact time on date hover (#3773) Co-authored-by: Loetwiek * docs: add Loetwiek as a contributor for code (#3776) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix(ui): ensure title fits into the `view collection` box (#3696) * fix(docs): correct openapi docs minor issues (#3648) * docs: add Fuochi as a contributor for doc (#3826) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat: translations update from Hosted Weblate (#3597) * feat(lang): translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (1234 of 1234 strings) feat(lang): translated using Weblate (Portuguese (Brazil)) Currently translated at 99.8% (1232 of 1234 strings) Co-authored-by: Cleiton Carvalho Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (German) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (German) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Hosted Weblate Co-authored-by: Nandor Rusz Co-authored-by: Thomas Schöneberg Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Danish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Danish) Currently translated at 100.0% (1236 of 1236 strings) feat(lang): translated using Weblate (Danish) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Anders Ecklon Co-authored-by: Hosted Weblate Co-authored-by: Kenneth Hansen Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Greek) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Greek) Currently translated at 100.0% (1236 of 1236 strings) Co-authored-by: BeardedWatermelon Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/el/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Russian) Currently translated at 99.5% (1234 of 1240 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1234 of 1234 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1234 of 1234 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Hosted Weblate Co-authored-by: SoundwaveUwU Co-authored-by: SoundwaveUwU Co-authored-by: Димитър Мазнеков (Topper) Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Romanian) Currently translated at 37.1% (461 of 1240 strings) feat(lang): translated using Weblate (Romanian) Currently translated at 37.0% (459 of 1240 strings) feat(lang): translated using Weblate (Romanian) Currently translated at 34.8% (432 of 1240 strings) Co-authored-by: Don Cezar Co-authored-by: Dragos Co-authored-by: Eduard Oancea Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Bulgarian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 57.4% (712 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 13.2% (164 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 4.8% (60 of 1240 strings) feat(lang): added translation using Weblate (Bulgarian) Co-authored-by: Hosted Weblate Co-authored-by: sct Co-authored-by: Димитър Мазнеков (Topper) Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/bg/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 97.9% (1215 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 82.0% (1017 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 72.9% (905 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 72.9% (905 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 71.3% (885 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 64.9% (805 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 64.4% (799 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 63.8% (792 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 63.7% (791 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 57.5% (714 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 49.9% (619 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 35.9% (446 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 35.9% (446 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 32.1% (399 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 24.6% (306 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 18.9% (235 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 17.5% (217 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 17.3% (215 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 8.0% (100 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 3.3% (41 of 1240 strings) feat(lang): added translation using Weblate (Ukrainian) Co-authored-by: Hosted Weblate Co-authored-by: Michael Michael Co-authored-by: sct Co-authored-by: Сергій Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Catalan) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Catalan) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: dtalens Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Czech) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Czech) Currently translated at 99.6% (1236 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Karel Krýda Co-authored-by: Smexhy Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/cs/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Croatian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.8% (1238 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.8% (1238 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.6% (1236 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.5% (1235 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.5% (1235 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 97.5% (1210 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.5% (1185 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.6% (1182 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.6% (1182 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.2% (1177 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.2% (1177 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 94.3% (1166 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 91.7% (1134 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 91.7% (1134 of 1236 strings) Co-authored-by: Bruno Ševčenko Co-authored-by: Hosted Weblate Co-authored-by: Milo Ivir Co-authored-by: Stjepan Co-authored-by: lpispek Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Hungarian) Currently translated at 91.3% (1133 of 1240 strings) feat(lang): translated using Weblate (Hungarian) Currently translated at 89.3% (1108 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Levente Szajkó Co-authored-by: Nandor Rusz Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Hebrew) Currently translated at 13.9% (172 of 1236 strings) Co-authored-by: Hosted Weblate Co-authored-by: osh Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/he/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Polish) Currently translated at 99.1% (1225 of 1236 strings) Co-authored-by: Eryk Michalak Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Italian) Currently translated at 92.8% (1148 of 1236 strings) Co-authored-by: Francesco Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Arabic) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Arabic) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Arabic) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Fhd-pro Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ar/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Dutch) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Dutch) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Hosted Weblate Co-authored-by: Kobe Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Spanish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Spanish) Currently translated at 100.0% (1236 of 1236 strings) Co-authored-by: Hosted Weblate Co-authored-by: gallegonovato Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (French) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (French) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (French) Currently translated at 100.0% (1236 of 1236 strings) feat(lang): translated using Weblate (French) Currently translated at 99.9% (1235 of 1236 strings) feat(lang): translated using Weblate (French) Currently translated at 99.9% (1235 of 1236 strings) Co-authored-by: Baptiste Co-authored-by: Dimitri Co-authored-by: Hosted Weblate Co-authored-by: Maxime Lafarie Co-authored-by: Miguel Co-authored-by: asurare Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1236 of 1236 strings) Co-authored-by: Hosted Weblate Co-authored-by: Per Erik Co-authored-by: Shjosan Co-authored-by: bittin1ddc447d824349b2 Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Finnish) Currently translated at 2.6% (33 of 1240 strings) feat(lang): added translation using Weblate (Finnish) Co-authored-by: Eero Konttaniemi Co-authored-by: Hosted Weblate Co-authored-by: sct Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Serbian) Currently translated at 50.8% (630 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Milan Smudja Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sr/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Korean) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Developer J Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Haohao Zhang Co-authored-by: Hosted Weblate Co-authored-by: lkw123 Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/ Translation: Overseerr/Overseerr Frontend --------- Co-authored-by: Cleiton Carvalho Co-authored-by: Nandor Rusz Co-authored-by: Thomas Schöneberg Co-authored-by: Anders Ecklon Co-authored-by: Kenneth Hansen Co-authored-by: BeardedWatermelon Co-authored-by: SoundwaveUwU Co-authored-by: SoundwaveUwU Co-authored-by: Димитър Мазнеков (Topper) Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com> Co-authored-by: Don Cezar Co-authored-by: Dragos Co-authored-by: Eduard Oancea Co-authored-by: sct Co-authored-by: Michael Michael Co-authored-by: Сергій Co-authored-by: dtalens Co-authored-by: Karel Krýda Co-authored-by: Smexhy Co-authored-by: Bruno Ševčenko Co-authored-by: Milo Ivir Co-authored-by: Stjepan Co-authored-by: lpispek Co-authored-by: Levente Szajkó Co-authored-by: osh Co-authored-by: Eryk Michalak Co-authored-by: Francesco Co-authored-by: Fhd-pro Co-authored-by: Kobe Co-authored-by: gallegonovato Co-authored-by: Baptiste Co-authored-by: Dimitri Co-authored-by: Maxime Lafarie Co-authored-by: Miguel Co-authored-by: asurare Co-authored-by: Per Erik Co-authored-by: Shjosan Co-authored-by: bittin1ddc447d824349b2 Co-authored-by: Eero Konttaniemi Co-authored-by: Milan Smudja Co-authored-by: Developer J Co-authored-by: Haohao Zhang Co-authored-by: lkw123 * feat(lang): add lang config for Bulgarian, Finnish, Ukrainian, Indonesian, Slovak, Turkish and Maori (#3834) * fix: correct deeplinks on iPad (#3883) * feat(studios): add a24 to studios list (#3902) * docs: add demrich as a contributor for code (#3906) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(watchlist): Cache watchlist requests with matching E-Tags (#3901) * perf(watchlist): add E-Tag caching to Plex watchlist requests * refactor(watchlist): increase frequency of watchlist requests * fix: sync watchlist every 3 min instead of 3 sec * docs: add maxnatamo as a contributor for code (#3907) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(plex): refresh token schedule (#3875) * feat: refresh token schedule fix #3861 * fix(i18n): add i18n message * refactor(plextv): use randomUUID crypto instead custom function * docs: add DamsDev1 as a contributor for code (#3924) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix: correct icon showing on certain phones when not pulled (#3939) * feat: add support for requesting "Specials" for TV Shows (#3724) * feat: add support for requesting "Specials" for TV Shows This commit is responsible for adding support in Overseerr for requesting "Special" episodes for TV Shows. This request has become especially pertinent when you consider shows like "Doctor Who". These shows have Specials that are critical to understanding the plot of a TV show. fix #779 * chore(yarn.lock): undo inappropriate changes to yarn.lock I was informed by @sct in a comment on the #3724 PR that it was not appropriate to commit the changes that ended up being made to the yarn.lock file. This commit is responsible, then, for undoing the changes to the yarn.lock file that ended up being submitted. * refactor: change loose equality to strict equality I received a comment from OwsleyJr pointing out that we are using loose equality when we could alternatively just be using strict equality to increase the robustness of our code. This commit does exactly that by squashing out previous usages of loose equality in my commits and replacing them with strict equality * refactor: move 'Specials' string to a global message Owsley pointed out that we are redefining the 'Specials' string multiple times throughout this PR. Instead, we can just move it as a global message. This commit does exactly that. It squashes out and previous declarations of the 'Specials' string inside the src files, and moves it directly to the global messages file. * docs: add AhmedNSidd as a contributor for code (#3964) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(lang): Translations update from Hosted Weblate (#3835) * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Bulgarian) Currently translated at 100.0% (1240 of 1240 strings) Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Co-authored-by: Димитър Мазнеков (Topper) Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/bg/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Michael Michael Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Catalan) Currently translated at 100.0% (1241 of 1241 strings) Co-authored-by: Hosted Weblate Co-authored-by: dtalens Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Hungarian) Currently translated at 99.2% (1231 of 1240 strings) Co-authored-by: Dargo Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Polish) Currently translated at 98.8% (1227 of 1241 strings) Co-authored-by: Hosted Weblate Co-authored-by: senza Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Dutch) Currently translated at 100.0% (1241 of 1241 strings) Co-authored-by: Hosted Weblate Co-authored-by: Robin Van de Vyvere Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Spanish) Currently translated at 100.0% (1241 of 1241 strings) feat(lang): translated using Weblate (Spanish) Currently translated at 100.0% (1241 of 1241 strings) Co-authored-by: Frostar Co-authored-by: Hosted Weblate Co-authored-by: gallegonovato Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (French) Currently translated at 100.0% (1240 of 1240 strings) Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (French) Currently translated at 100.0% (1241 of 1241 strings) feat(lang): translated using Weblate (French) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Nackophilz Co-authored-by: TayZ3r Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1241 of 1241 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Per Erik Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Finnish) Currently translated at 2.9% (36 of 1240 strings) Co-authored-by: Oskari Lavinto Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Albanian) Currently translated at 95.8% (1189 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: W L Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sq/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Korean) Currently translated at 100.0% (1241 of 1241 strings) feat(lang): translated using Weblate (Korean) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Hyun Lee Co-authored-by: cutiekeek Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Portuguese (Portugal)) Currently translated at 98.4% (1221 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Rafael Souto Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.9% (1239 of 1240 strings) Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Chinese (Traditional Han script)) Currently translated at 98.2% (1219 of 1241 strings) Co-authored-by: Hosted Weblate Co-authored-by: Marc Lerno Co-authored-by: dtalens Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Norwegian Bokmål) Currently translated at 89.9% (1115 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: exentler Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nb_NO/ Translation: Overseerr/Overseerr Frontend --------- Co-authored-by: Димитър Мазнеков (Topper) Co-authored-by: Michael Michael Co-authored-by: dtalens Co-authored-by: Dargo Co-authored-by: senza Co-authored-by: Robin Van de Vyvere Co-authored-by: Frostar Co-authored-by: gallegonovato Co-authored-by: Nackophilz Co-authored-by: TayZ3r Co-authored-by: Per Erik Co-authored-by: Oskari Lavinto Co-authored-by: W L Co-authored-by: Hyun Lee Co-authored-by: cutiekeek Co-authored-by: Rafael Souto Co-authored-by: Marc Lerno Co-authored-by: exentler * feat(ui): prevent password manager interference & improve service links (#3989) * docs: add s0up4200 as a contributor for code (#4047) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix(ui): update Plex Logo (#3955) * docs: add JackW6809 as a contributor for code (#4048) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat: requests/issues menu count (#3470) * feat: request and issue count added to sidebar/mobile menu * fix: added permission check for count visibility * refactor: modified badge design for count * fix: properly update issue and request counts in certain scenarios (#4051) * fix: center count badge on sidebar and mobile menu (#4052) * fix: request english trailers as a fallback when using other languages (#4009) Co-authored-by: Stancu Florin * docs: add StancuFlorin as a contributor for code (#4053) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat: added the PWA badge indicator for requests pending (#3411) refactor: removed unnecessary code when sending web push notification fix: moved all notify user logic into webpush refactor: n refactor: remove all unnecessary prettier changes fix: n fix: n fix: n fix: n fix: increment sw version fix: n * fix: improve count badge styling (#4056) * fix: improved web push management (#3421) refactor: organized placement of new button + added comments fix: added api routes for push registration fix: modified get request to confirm key identity fix: added back notification types to always show feat: added a manageable device list refactor: modified device list to make it mobile friendly fix: correct typo for enabling notifications * Revert "fix: improved web push management (#3421)" (#4058) * fix: manage webpush notifications (#4059) * feat(lang): Translations update from Hosted Weblate (#4025) * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Felipe Garcia Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (German) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Rico Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Romanian) Currently translated at 40.8% (507 of 1240 strings) Co-authored-by: George L Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: Yaroslav Buzko Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Hungarian) Currently translated at 99.9% (1239 of 1240 strings) feat(lang): translated using Weblate (Hungarian) Currently translated at 99.7% (1237 of 1240 strings) Co-authored-by: Hosted Weblate Co-authored-by: ugyes Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. feat(lang): translated using Weblate (Italian) Currently translated at 95.3% (1182 of 1240 strings) feat(lang): translated using Weblate (Italian) Currently translated at 95.3% (1182 of 1240 strings) Co-authored-by: Alberto Giardino Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * feat(lang): added translation using Weblate (Slovenian) Co-authored-by: Hosted Weblate Co-authored-by: sct * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend * Update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ Translation: Overseerr/Overseerr Frontend --------- Co-authored-by: Felipe Garcia Co-authored-by: Rico Co-authored-by: George L Co-authored-by: Yaroslav Buzko Co-authored-by: ugyes Co-authored-by: Alberto Giardino Co-authored-by: sct * fix: change localhost to process.env.HOST for client requests (#3839) * Change localhost to process.env.HOST for client requests * refactor: reformat * docs: add lmiklosko as a contributor for code (#4063) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix: set the correct TTL for the cookie store (#3946) The time-to-live (TTL) of cookies stored in the database was incorrect because the connect-typeorm library takes a TTL in seconds and not milliseconds, making cookies valid for ~82 years instead of 30 days. Co-authored-by: Ryan Cohen * docs: add gauthier-th as a contributor for code (#4064) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix: update migration script (#4065) * fix: update migration script fix: remove insert for new entities * fix: correct migration name * fix: correct name inside migration * fix(servarr): merge series tags instead of overwriting them (#4019) * Merge series tags instead of overwriting when adding a series that already exists Currently, a request coming in for a series that already exists in sonarr nukes the tags in sonarr for the series in favor of the tags coming from overseerr. This change merges the two lists of tags and deduplicates them before sending them to sonarr. * fix(servarr api): merge request media tags with servarr instead of overwriting --------- Co-authored-by: Danshil Kokil Mungur --------- Co-authored-by: Isaac M Co-authored-by: Joseph Risk Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Loetwiek <79059734+Loetwiek@users.noreply.github.com> Co-authored-by: Loetwiek Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Co-authored-by: Fuochi Co-authored-by: Weblate (bot) Co-authored-by: Cleiton Carvalho Co-authored-by: Nandor Rusz Co-authored-by: Thomas Schöneberg Co-authored-by: Anders Ecklon Co-authored-by: Kenneth Hansen Co-authored-by: BeardedWatermelon Co-authored-by: SoundwaveUwU Co-authored-by: SoundwaveUwU Co-authored-by: Димитър Мазнеков (Topper) Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com> Co-authored-by: Don Cezar Co-authored-by: Dragos Co-authored-by: Eduard Oancea Co-authored-by: sct Co-authored-by: Michael Michael Co-authored-by: Сергій Co-authored-by: dtalens Co-authored-by: Karel Krýda Co-authored-by: Smexhy Co-authored-by: Bruno Ševčenko Co-authored-by: Milo Ivir Co-authored-by: Stjepan Co-authored-by: lpispek Co-authored-by: Levente Szajkó Co-authored-by: osh Co-authored-by: Eryk Michalak Co-authored-by: Francesco Co-authored-by: Fhd-pro Co-authored-by: Kobe Co-authored-by: gallegonovato Co-authored-by: Baptiste Co-authored-by: Dimitri Co-authored-by: Maxime Lafarie Co-authored-by: Miguel Co-authored-by: asurare Co-authored-by: Per Erik Co-authored-by: Shjosan Co-authored-by: bittin1ddc447d824349b2 Co-authored-by: Eero Konttaniemi Co-authored-by: Milan Smudja Co-authored-by: Developer J Co-authored-by: Haohao Zhang Co-authored-by: lkw123 Co-authored-by: Jordan Jones Co-authored-by: Brandon Cohen Co-authored-by: David Emrich Co-authored-by: Max T. Kristiansen Co-authored-by: Damien Fajole <60252259+DamsDev1@users.noreply.github.com> Co-authored-by: Ahmed Siddiqui <36286128+AhmedNSidd@users.noreply.github.com> Co-authored-by: Dargo Co-authored-by: senza Co-authored-by: Robin Van de Vyvere Co-authored-by: Frostar Co-authored-by: Nackophilz Co-authored-by: TayZ3r Co-authored-by: Oskari Lavinto Co-authored-by: W L Co-authored-by: Hyun Lee Co-authored-by: cutiekeek Co-authored-by: Rafael Souto Co-authored-by: Marc Lerno Co-authored-by: exentler Co-authored-by: soup Co-authored-by: JackOXI <53652452+JackW6809@users.noreply.github.com> Co-authored-by: Stancu Florin Co-authored-by: Stancu Florin Co-authored-by: Brandon Cohen Co-authored-by: Felipe Garcia Co-authored-by: Rico Co-authored-by: George L Co-authored-by: Yaroslav Buzko Co-authored-by: ugyes Co-authored-by: Alberto Giardino Co-authored-by: Lukas Miklosko <44380311+lmiklosko@users.noreply.github.com> Co-authored-by: Ryan Cohen Co-authored-by: Andrew Kennedy Co-authored-by: Danshil Kokil Mungur --- .all-contributorsrc | 102 ++++- README.md | 2 + jellyseerr-api.yml | 84 ++++ package.json | 2 + pnpm-lock.yaml | 17 + server/api/servarr/radarr.ts | 3 +- server/api/servarr/sonarr.ts | 4 +- server/entity/UserPushSubscription.ts | 16 +- .../migration/1740717744278-UpdateWebPush.ts | 31 ++ server/routes/user/index.ts | 78 +++- src/components/Layout/MobileMenu/index.tsx | 4 +- src/components/ServiceWorkerSetup/index.tsx | 33 +- .../UserNotificationsWebPush.tsx | 136 ------- .../UserNotificationsWebPush/DeviceItem.tsx | 110 +++++ .../UserNotificationsWebPush/index.tsx | 378 ++++++++++++++++++ src/i18n/locale/en.json | 22 +- src/pages/_app.tsx | 8 +- src/pages/collection/[collectionId]/index.tsx | 6 +- src/pages/movie/[movieId]/index.tsx | 6 +- src/pages/tv/[tvId]/index.tsx | 4 +- 20 files changed, 861 insertions(+), 185 deletions(-) create mode 100644 server/migration/1740717744278-UpdateWebPush.ts delete mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx create mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx create mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx diff --git a/.all-contributorsrc b/.all-contributorsrc index 4cbc6e2c1..b68f27caa 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/README.md b/README.md index a275de0c6..02d8839e9 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/jellyseerr-api.yml b/jellyseerr-api.yml index 00b095968..6954992d8 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 a0e8a0ef0..143777810 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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 61874f355..86ec16a6e 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 @@ -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 @@ -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==} @@ -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==} @@ -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': @@ -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/servarr/radarr.ts b/server/api/servarr/radarr.ts index f3bf3faaf..35d24024f 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -28,6 +28,7 @@ export interface RadarrMovie { qualityProfileId: number; added: string; hasFile: boolean; + tags: number[]; } class RadarrAPI extends ServarrBase<{ movieId: number }> { @@ -104,7 +105,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: { diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 5590c9acb..0a9c27322 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( diff --git a/server/entity/UserPushSubscription.ts b/server/entity/UserPushSubscription.ts index 6389ea0b8..f05dd0f2b 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/migration/1740717744278-UpdateWebPush.ts b/server/migration/1740717744278-UpdateWebPush.ts new file mode 100644 index 000000000..a6dcd0021 --- /dev/null +++ b/server/migration/1740717744278-UpdateWebPush.ts @@ -0,0 +1,31 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateWebPush1740717744278 implements MigrationInterface { + name = 'UpdateWebPush1740717744278'; + + 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 DEFAULT NULL, "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"` + ); + } + + 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") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + } +} diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 0c79e4f3e..028b26e62 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/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 52e84d3de..09cec4a01 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/ServiceWorkerSetup/index.tsx b/src/components/ServiceWorkerSetup/index.tsx index f9b42cd39..2e0313f4d 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/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx deleted file mode 100644 index e338c9f0a..000000000 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import Button from '@app/components/Common/Button'; -import LoadingSpinner from '@app/components/Common/LoadingSpinner'; -import NotificationTypeSelector, { - ALL_NOTIFICATIONS, -} from '@app/components/NotificationTypeSelector'; -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 type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; -import { Form, Formik } from 'formik'; -import { useRouter } from 'next/router'; -import { useIntl } from 'react-intl'; -import { useToasts } from 'react-toast-notifications'; -import useSWR, { mutate } from 'swr'; - -const messages = defineMessages( - 'components.UserProfile.UserSettings.UserNotificationSettings', - { - webpushsettingssaved: 'Web push notification settings saved successfully!', - webpushsettingsfailed: 'Web push notification settings failed to save.', - } -); - -const UserWebPushSettings = () => { - 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 000000000..59da71093 --- /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 000000000..de438e3ad --- /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 50df170b2..121f6882e 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 9e87cbdf0..1b29d41e8 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 b0c47b17c..da9c6bf03 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 be0d2aa5a..cf2b11b9c 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 3961b157a..36fba5fcc 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 } From 7cb127ec3f9634b3582ec66305c8ad4466e54c59 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Thu, 27 Mar 2025 00:19:05 +0800 Subject: [PATCH 09/18] chore: fix linting and formatting issues (#1530) --- CONTRIBUTING.md | 12 ++++++------ .../Settings/OverrideRule/OverrideRuleTiles.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27e2bfbbd..855cecfbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,18 +65,18 @@ All help is welcome and greatly appreciated! If you would like to contribute to git push origin BRANCH_NAME -f ``` -### Helm Chart +### Helm Chart Tools Required: -- [Helm](https://helm.sh/docs/intro/install/) -- [helm-docs](https://github.com/norwoodj/helm-docs) +- [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/). +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 diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx index 8b2acaf67..7f1f3f2c0 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 ( <> From a5cb505609c81e134f8039e90b926390eb83d546 Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Wed, 26 Mar 2025 22:26:06 +0100 Subject: [PATCH 10/18] chore(docs): use ghcr.io registry instead of dockerhub (#1531) Signed-off-by: Ludovic Ortega --- docs/getting-started/docker.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/getting-started/docker.mdx b/docs/getting-started/docker.mdx index 3d411e021..b129cdb95 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: From 0b0b76e58c583fc7c31d7821e7825e32065f7944 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 27 Mar 2025 21:27:07 +0100 Subject: [PATCH 11/18] fix(migrations): add missing Postgres migration and fix SQLite migration (#1532) This PR adds the missing migration for PostgreSQL and fix the migration for SQLite. re #1466 --- .../migration/1740717744278-UpdateWebPush.ts | 31 --- .../postgres/1743023615532-UpdateWebPush.ts | 29 +++ .../sqlite/1743023610704-UpdateWebPush.ts | 203 ++++++++++++++++++ 3 files changed, 232 insertions(+), 31 deletions(-) delete mode 100644 server/migration/1740717744278-UpdateWebPush.ts create mode 100644 server/migration/postgres/1743023615532-UpdateWebPush.ts create mode 100644 server/migration/sqlite/1743023610704-UpdateWebPush.ts diff --git a/server/migration/1740717744278-UpdateWebPush.ts b/server/migration/1740717744278-UpdateWebPush.ts deleted file mode 100644 index a6dcd0021..000000000 --- a/server/migration/1740717744278-UpdateWebPush.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { MigrationInterface, QueryRunner } from 'typeorm'; - -export class UpdateWebPush1740717744278 implements MigrationInterface { - name = 'UpdateWebPush1740717744278'; - - 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 DEFAULT NULL, "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"` - ); - } - - 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") 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/postgres/1743023615532-UpdateWebPush.ts b/server/migration/postgres/1743023615532-UpdateWebPush.ts new file mode 100644 index 000000000..fa3f259c0 --- /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/sqlite/1743023610704-UpdateWebPush.ts b/server/migration/sqlite/1743023610704-UpdateWebPush.ts new file mode 100644 index 000000000..35ed072cc --- /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"`); + } +} From 7438042757cb0e81534cf9f766d84dd3ff57fd84 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 28 Mar 2025 05:21:47 +0800 Subject: [PATCH 12/18] fix(jellyfin): ensure deviceID is never empty (#1538) If the deviceID becomes an empty string, login fails since jellyfin requires a non-null deviceID. This commit adds a fallback to guarantee that deviceID is always set, preventing accidental lockout. --- server/api/jellyfin.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index b2323ea5b..5ce1d9cf7 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( From 29034b350d35ebaed52556448e46436aeb644e77 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 28 Mar 2025 06:02:34 +0800 Subject: [PATCH 13/18] fix(avatar): fix avatar cache busting by using avatarVersion (#1537) * fix(avatar): fix avatar cache busting by using avatarVersion Previously, avatar caching did not update the avatar when the remote image changed. This commit adds logic to check if the avatar was modified remotely by comparing aremote last-modified timestamp with a locally stored version (avatarVersion). If a change is detected, the cache is cleared, a new image is fetched, and avatarVersionis updated. Otherwise, the cached image is retained. * chore(db): add db migrations * refactor: refactor imagehelpers util to where its used * refactor: remove remnants from previous cache busting versions --- server/entity/User.ts | 6 ++ server/lib/imageproxy.ts | 24 ++++- .../1743107707465-AddUserAvatarCacheFields.ts | 21 +++++ .../1743107645301-AddUserAvatarCacheFields.ts | 69 ++++++++++++++ server/routes/auth.ts | 33 ++++++- server/routes/avatarproxy.ts | 92 +++++++++++++++++-- 6 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts create mode 100644 server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts diff --git a/server/entity/User.ts b/server/entity/User.ts index 91b667403..5f51af710 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/lib/imageproxy.ts b/server/lib/imageproxy.ts index 04e320a0b..607245698 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/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts b/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts new file mode 100644 index 000000000..1e61e996e --- /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/1743107645301-AddUserAvatarCacheFields.ts b/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts new file mode 100644 index 000000000..aff3e357e --- /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 4e470831a..df6908a12 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 5938fa945..0065f011b 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', }); From 63dc27d400ecc80a18442fc42dd417cc03c3f9e1 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 31 Mar 2025 10:40:30 +0200 Subject: [PATCH 14/18] fix(job): handle media removal for 4k on the same server (#1543) This PR fixes a bug where the avaibility sync job was not removing properly 4k items when the same Radarr server was used for both non-4k and 4k media. --- server/api/servarr/radarr.ts | 32 ++++++++++++++++++++++++++++++++ server/lib/availabilitySync.ts | 6 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 35d24024f..b5e12d2ec 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -29,6 +29,38 @@ export interface RadarrMovie { 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 }> { diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0fdfd6276..3f78551e0 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')) { From 8dc1d8196c67bee0e772941445c294f0ca367961 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 31 Mar 2025 20:06:33 +0200 Subject: [PATCH 15/18] fix: correct "Remove from *arr" button (#1544) This PR fixes the "Delete from *arr" button in the request list. It checks from the API whether the *arr server corresponding to the request still exists before displaying the remove button, and fixes a cache removal issue that could cause problems when deleting recently added media. This PR also reverts #1476, which introduced problems during removal. fix #1494 --- server/api/externalapi.ts | 2 +- server/api/servarr/radarr.ts | 5 +- server/api/servarr/sonarr.ts | 6 +- server/interfaces/api/requestInterfaces.ts | 5 +- server/routes/media.ts | 20 ----- server/routes/request.ts | 34 +++++++- .../RequestList/RequestItem/index.tsx | 80 +++++++------------ src/components/RequestList/index.tsx | 13 +-- 8 files changed, 75 insertions(+), 90 deletions(-) diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index d17ebf99e..85612808b 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/servarr/radarr.ts b/server/api/servarr/radarr.ts index b5e12d2ec..638af88a2 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -274,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 0a9c27322..0cbd4a57d 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -368,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/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 88b1201de..4a41ae993 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/routes/media.ts b/server/routes/media.ts index 3ad197c9d..60191e5de 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 89e5352fa..50d4a6f00 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/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 037590e5d..dd72ee7e8 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 468c0853e..6cdf5b0bf 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} />
); From 85bbc857141d38bcf5244078437ed6a3318bba67 Mon Sep 17 00:00:00 2001 From: Kugelstift <100831349+Kugelstift@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:34:06 +0200 Subject: [PATCH 16/18] fix(auth): Bitwarden autofill fix on local/Jellyfin login (2) (#1487) * Update JellyfinLogin.tsx * Update LocalLogin.tsx * Update index.tsx * Update index.tsx prettier * Update JellyfinLogin.tsx * Update LocalLogin.tsx * Update index.tsx --- src/components/Common/SensitiveInput/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx index 336044de6..f21dc35b6 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={ From 2f6be955b51e8920c8954413286577e6fea4aee2 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 3 Apr 2025 18:15:39 +0200 Subject: [PATCH 17/18] fix(job): rename Plex Sync to Jellyfin Sync (#1549) Some logs for the Jellyfin scanners were labelled 'Plex Sync' instead of 'Jellyfin Sync', leading to confusion --- server/lib/scanners/jellyfin/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index b4816ae55..bfef4f7ea 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; } From fac453878e3d55b93cfda78908403cce348bb9fa Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 3 Apr 2025 17:06:57 +0000 Subject: [PATCH 18/18] chore(release): 2.5.2 --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a87de35..312a32b4c 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/package.json b/package.json index ffcc4d1e1..2cc392824 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",