From 5b998bef82388dccaaa462ff2ff3a526dd03338c Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 10 Mar 2025 23:59:42 +0100 Subject: [PATCH 01/33] fix(users): correct user list for Postgres (#1443) PostgreSQL requires that the ORDER BY expression must appear in the SELECT list when using DISTINCT. Since we were using a computed expression in the ORDER BY clause, we need to include it in the SELECT list as well. re #1333 --- server/routes/user/index.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 568124f7..0c79e4f3 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -60,22 +60,24 @@ router.get('/', async (req, res, next) => { query = query.orderBy('user.updatedAt', 'DESC'); break; case 'displayname': - query = query.orderBy( - `CASE WHEN (user.username IS NULL OR user.username = '') THEN ( - CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( - CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN - "user"."email" - ELSE - LOWER(user.jellyfinUsername) - END) - ELSE - LOWER(user.jellyfinUsername) - END) - ELSE - LOWER(user.username) - END`, - 'ASC' - ); + query = query + .addSelect( + `CASE WHEN (user.username IS NULL OR user.username = '') THEN ( + CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( + CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN + "user"."email" + ELSE + LOWER(user.jellyfinUsername) + END) + ELSE + LOWER(user.jellyfinUsername) + END) + ELSE + LOWER(user.username) + END`, + 'displayname_sort_key' + ) + .orderBy('displayname_sort_key', 'ASC'); break; case 'requests': query = query From 863b675c77ca182f58b6211b96a51bb6eb3917ef Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Tue, 11 Mar 2025 07:06:56 +0800 Subject: [PATCH 02/33] ci(cypress): always run the upload video files step (#1445) --- .github/workflows/cypress.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index ce543097..6f8e2d8a 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -37,6 +37,7 @@ jobs: COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}} COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}} - name: Upload video files + if: always() uses: actions/upload-artifact@v4 with: name: cypress-videos From 771ecdf7812004eec0f516cc424f9982936c8a2a Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 11 Mar 2025 00:08:49 +0100 Subject: [PATCH 03/33] fix(ui): correct media action icon size (#1444) This PR fixes an UI issue with inconsistent size for media action icons. fix #1440 --- src/components/MovieDetails/index.tsx | 12 ++++-------- src/components/TvDetails/index.tsx | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 2340cb2e..45cb0388 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -590,7 +590,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { buttonSize={'md'} onClick={() => setShowBlacklistModal(true)} > - + )} @@ -608,9 +608,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { onClick={onClickWatchlistBtn} > {isUpdating ? ( - + ) : ( - + )} @@ -623,11 +623,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { buttonSize={'md'} onClick={onClickDeleteWatchlistBtn} > - {isUpdating ? ( - - ) : ( - - )} + {isUpdating ? : } )} diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index ec27fba1..66b4176d 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -632,7 +632,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { buttonSize={'md'} onClick={() => setShowBlacklistModal(true)} > - + )} @@ -650,9 +650,9 @@ const TvDetails = ({ tv }: TvDetailsProps) => { onClick={onClickWatchlistBtn} > {isUpdating ? ( - + ) : ( - + )} @@ -665,11 +665,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { buttonSize={'md'} onClick={onClickDeleteWatchlistBtn} > - {isUpdating ? ( - - ) : ( - - )} + {isUpdating ? : } )} From cdfb30ea1651d7b9c32d62dba54a97eb2777e11c Mon Sep 17 00:00:00 2001 From: Mihkel Date: Tue, 11 Mar 2025 01:48:43 +0200 Subject: [PATCH 04/33] docs: update steps for service installation with NSSM (#1446) --- docs/getting-started/buildfromsource.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/buildfromsource.mdx b/docs/getting-started/buildfromsource.mdx index 9e139dcd..9bfff1a2 100644 --- a/docs/getting-started/buildfromsource.mdx +++ b/docs/getting-started/buildfromsource.mdx @@ -255,7 +255,8 @@ To run jellyseerr as a service: 1. Download the [Non-Sucking Service Manager](https://nssm.cc/download) 2. Install NSSM: ```powershell -nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" ["C:\jellyseerr\dist\index.js"] +nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" "C:\jellyseerr\dist\index.js" +nssm set Jellyseerr AppDirectory "C:\jellyseerr" nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production ``` 3. Start the service: From 21ab20bba97102fe9eb9d4af4213a604c05e0acc Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:33:40 +0800 Subject: [PATCH 05/33] revert: reverts csrf-csrf back to csurf (#1442) * revert: reverts csrf-csrf back to csurf This reverts csrf-csrf change brought on by `9e3119` in #1393 back to `csurf` that is maintained * fix: type declarations for csurf --- package.json | 2 +- pnpm-lock.yaml | 49 ++++++++++++++++++++++++++++++-------- server/index.ts | 29 ++++++++++------------ server/types/custom.d.ts | 4 ++++ src/utils/fetchOverride.ts | 2 +- 5 files changed, 57 insertions(+), 29 deletions(-) create mode 100644 server/types/custom.d.ts diff --git a/package.json b/package.json index 830f9930..67cbb55e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "license": "MIT", "dependencies": { + "@dr.pogodin/csurf": "^1.14.1", "@formatjs/intl-displaynames": "6.2.6", "@formatjs/intl-locale": "3.1.1", "@formatjs/intl-pluralrules": "5.1.10", @@ -51,7 +52,6 @@ "copy-to-clipboard": "3.3.3", "country-flag-icons": "1.5.5", "cronstrue": "2.23.0", - "csrf-csrf": "^3.1.0", "date-fns": "2.29.3", "dayjs": "1.11.7", "email-templates": "12.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a14c0716..4f46a78d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@dr.pogodin/csurf': + specifier: ^1.14.1 + version: 1.14.1 '@formatjs/intl-displaynames': specifier: 6.2.6 version: 6.2.6 @@ -65,9 +68,6 @@ importers: cronstrue: specifier: 2.23.0 version: 2.23.0 - csrf-csrf: - specifier: ^3.1.0 - version: 3.1.0 date-fns: specifier: 2.29.3 version: 2.29.3 @@ -1549,6 +1549,10 @@ packages: '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + '@dr.pogodin/csurf@1.14.1': + resolution: {integrity: sha512-ijqJsKSDlepDYbprkEEcqbiYero2y4DeL4X5ivnkbKonliLtH8SfHCEtdUwoRZLPTUy2WeFPHI+gveU+Z8ZxLA==} + engines: {node: '>= 18'} + '@emnapi/runtime@1.2.0': resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} @@ -4405,6 +4409,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} @@ -4417,6 +4425,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -4509,9 +4521,6 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} - csrf-csrf@3.1.0: - resolution: {integrity: sha512-kZacFfFbdYFxNnFdigRHCzVAq019vJyUUtgPLjCtzh6jMXcWmf8bGUx/hsqtSEMXaNcPm8iXpjC+hW5aeOsRMg==} - css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} @@ -8317,6 +8326,9 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rndm@1.2.0: + resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==} + run-applescript@3.2.0: resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==} engines: {node: '>=4'} @@ -9064,6 +9076,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsutils@3.21.0: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} @@ -11290,6 +11306,15 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@dr.pogodin/csurf@1.14.1': + dependencies: + cookie: 1.0.2 + cookie-signature: 1.2.2 + http-errors: 2.0.0 + rndm: 1.2.0 + tsscmp: 1.0.6 + uid-safe: 2.1.5 + '@emnapi/runtime@1.2.0': dependencies: tslib: 2.6.3 @@ -14928,12 +14953,16 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.4.2: {} cookie@0.7.1: {} cookie@0.7.2: {} + cookie@1.0.2: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -15044,10 +15073,6 @@ snapshots: crypto-random-string@2.0.0: {} - csrf-csrf@3.1.0: - dependencies: - http-errors: 2.0.0 - css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -19665,6 +19690,8 @@ snapshots: dependencies: glob: 7.2.3 + rndm@1.2.0: {} + run-applescript@3.2.0: dependencies: execa: 0.10.0 @@ -20534,6 +20561,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsutils@3.21.0(typescript@4.9.5): dependencies: tslib: 1.14.1 diff --git a/server/index.ts b/server/index.ts index d672f622..d8aadfa0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,3 +1,4 @@ +import csurf from '@dr.pogodin/csurf'; import PlexAPI from '@server/api/plexapi'; import dataSource, { getRepository, isPgsql } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; @@ -28,7 +29,6 @@ import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; -import { doubleCsrf } from 'csrf-csrf'; import type { NextFunction, Request, Response } from 'express'; import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; @@ -162,23 +162,18 @@ app } }); if (settings.network.csrfProtection) { - const { doubleCsrfProtection, generateToken } = doubleCsrf({ - getSecret: () => settings.clientId, - cookieName: 'XSRF-TOKEN', - cookieOptions: { - httpOnly: true, - sameSite: 'strict', - secure: !dev, - }, - size: 64, - ignoredMethods: ['GET', 'HEAD', 'OPTIONS'], - }); - - server.use(doubleCsrfProtection); - + server.use( + csurf({ + cookie: { + httpOnly: true, + sameSite: true, + secure: !dev, + }, + }) + ); server.use((req, res, next) => { - res.cookie('XSRF-TOKEN', generateToken(req, res), { - sameSite: 'strict', + res.cookie('XSRF-TOKEN', req.csrfToken(), { + sameSite: true, secure: !dev, }); next(); diff --git a/server/types/custom.d.ts b/server/types/custom.d.ts new file mode 100644 index 00000000..f2f47c60 --- /dev/null +++ b/server/types/custom.d.ts @@ -0,0 +1,4 @@ +declare module '@dr.pogodin/csurf' { + import csrf = require('csurf'); + export = csrf; +} diff --git a/src/utils/fetchOverride.ts b/src/utils/fetchOverride.ts index bd4dcd43..e0a90012 100644 --- a/src/utils/fetchOverride.ts +++ b/src/utils/fetchOverride.ts @@ -31,7 +31,7 @@ if (typeof window !== 'undefined') { const headers = { ...(init?.headers || {}), - ...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}), + ...(csrfToken ? { 'XSRF-TOKEN': csrfToken } : {}), }; const newInit: RequestInit = { From 077e355c775af92ff4dd2341543555d473c1abbb Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Tue, 11 Mar 2025 02:33:58 +0100 Subject: [PATCH 06/33] feat(helm): upgrade jellyseerr to 2.4.0 (#1438) 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 dd595adf..15f18210 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.1.1 -appVersion: "2.3.0" +version: 2.2.0 +appVersion: "2.4.0" maintainers: - name: Jellyseerr url: https://github.com/Fallenbagel/jellyseerr diff --git a/charts/jellyseerr-chart/README.md b/charts/jellyseerr-chart/README.md index 678ba00f..94850b96 100644 --- a/charts/jellyseerr-chart/README.md +++ b/charts/jellyseerr-chart/README.md @@ -1,6 +1,6 @@ # jellyseerr-chart -![Version: 2.1.1](https://img.shields.io/badge/Version-2.1.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.3.0](https://img.shields.io/badge/AppVersion-2.3.0-informational?style=flat-square) +![Version: 2.2.0](https://img.shields.io/badge/Version-2.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.4.0](https://img.shields.io/badge/AppVersion-2.4.0-informational?style=flat-square) Jellyseerr helm chart for Kubernetes From 9891a7577cc0874f41c38ff0e6e5a6b4d8315281 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Wed, 12 Mar 2025 08:25:54 +0100 Subject: [PATCH 07/33] fix(proxy): update http proxy to accept bypass list with undici v7 (#1456) With the update of undici to v7, the bypass list of addresses (no_proxy addresses) was not ignored anymore. fix #1454 --- server/utils/customProxyAgent.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/server/utils/customProxyAgent.ts b/server/utils/customProxyAgent.ts index 96ea7fed..5f163c3d 100644 --- a/server/utils/customProxyAgent.ts +++ b/server/utils/customProxyAgent.ts @@ -8,8 +8,9 @@ export default async function createCustomProxyAgent( ) { const defaultAgent = new Agent({ keepAliveTimeout: 5000 }); - const skipUrl = (url: string) => { - const hostname = new URL(url).hostname; + const skipUrl = (url: string | URL) => { + const hostname = + typeof url === 'string' ? new URL(url).hostname : url.hostname; if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) { return true; @@ -38,8 +39,7 @@ export default async function createCustomProxyAgent( dispatch: Dispatcher['dispatch'] ): Dispatcher['dispatch'] => { return (opts, handler) => { - const url = opts.origin?.toString(); - return url && skipUrl(url) + return opts.origin && skipUrl(opts.origin) ? defaultAgent.dispatch(opts, handler) : dispatch(opts, handler); }; @@ -60,13 +60,10 @@ export default async function createCustomProxyAgent( ':' + proxySettings.port, token, - interceptors: { - Client: [noProxyInterceptor], - }, keepAliveTimeout: 5000, }); - setGlobalDispatcher(proxyAgent); + setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor)); } catch (e) { logger.error('Failed to connect to the proxy: ' + e.message, { label: 'Proxy', @@ -95,7 +92,11 @@ export default async function createCustomProxyAgent( } function isLocalAddress(hostname: string) { - if (hostname === 'localhost' || hostname === '127.0.0.1') { + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' + ) { return true; } From 33e7a153aa64461a715595d070fba53d52b34767 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Wed, 12 Mar 2025 08:28:31 +0100 Subject: [PATCH 08/33] fix(requestlist): hide the remove from *arr button when no service exists (#1457) This PR hide the "Remove from *arr" button in the request list when the service of the request doesn't exist anymore. fix #1449 --- .../RequestList/RequestItem/index.tsx | 75 +++++++++++++------ src/components/RequestList/index.tsx | 13 +++- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 5e764ecb..037590e5 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -17,9 +17,10 @@ import { TrashIcon, XMarkIcon, } from '@heroicons/react/24/solid'; -import { MediaRequestStatus } from '@server/constants/media'; +import { MediaRequestStatus, MediaType } 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 { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import Link from 'next/link'; @@ -293,9 +294,16 @@ const RequestItemError = ({ interface RequestItemProps { request: NonFunctionProperties & { profileName?: string }; revalidateList: () => void; + radarrData?: RadarrSettings[]; + sonarrData?: SonarrSettings[]; } -const RequestItem = ({ request, revalidateList }: RequestItemProps) => { +const RequestItem = ({ + request, + revalidateList, + radarrData, + sonarrData, +}: RequestItemProps) => { const settings = useSettings(); const { ref, inView } = useInView({ triggerOnce: true, @@ -390,6 +398,23 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { 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 (
{ )} {requestData.status !== MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( - <> - deleteRequest()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.deleterequest)} - - 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)} + + )} + {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', + })} + + )} {requestData.status === MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 6cdf5b0b..468c0853 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -17,6 +17,8 @@ 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'; @@ -51,7 +53,7 @@ const RequestList = () => { const { user } = useUser({ id: Number(router.query.userId), }); - const { user: currentUser } = useUser(); + const { user: currentUser, hasPermission } = useUser(); const [currentFilter, setCurrentFilter] = useState(Filter.PENDING); const [currentSort, setCurrentSort] = useState('added'); const [currentSortDirection, setCurrentSortDirection] = @@ -62,6 +64,13 @@ 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, @@ -245,6 +254,8 @@ const RequestList = () => { revalidate()} + radarrData={radarrData} + sonarrData={sonarrData} />
); From b085e12ff9df9f57d71ca1fe27fefa8319229a2a Mon Sep 17 00:00:00 2001 From: Kugelstift <100831349+Kugelstift@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:20:33 +0100 Subject: [PATCH 09/33] fix(auth): Bitwarden autofill fix on local/Jellyfin login (#1459) * Update LocalLogin.tsx remove data-bwignore="false" from attributes to let Bitwarden Autofill * Update JellyfinLogin.tsx remove data-bwignore="false" from attributes to let Bitwarden Autofill --- src/components/Login/JellyfinLogin.tsx | 1 - src/components/Login/LocalLogin.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 0b2776e3..239e4569 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -161,7 +161,6 @@ const JellyfinLogin: React.FC = ({ data-form-type="password" data-1pignore="false" data-lpignore="false" - data-bwignore="false" />
diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 169a5a8f..1b9a5fe7 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -118,7 +118,6 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { className="!bg-gray-700/80 placeholder:text-gray-400" data-1pignore="false" data-lpignore="false" - data-bwignore="false" />
From 4d1163c34384efa59fe9b5401c5bd42d7f0435fc Mon Sep 17 00:00:00 2001 From: Gauthier Date: Wed, 12 Mar 2025 21:05:16 +0100 Subject: [PATCH 10/33] fix(blacklist): add back the blacklist button on TitleCard for Plex (#1463) The PR #1398 introduced an issue where the blacklist button was not visible anymore on the TitleCards for Plex. This PR fixes it. --- src/components/TitleCard/index.tsx | 41 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 5bfb90e9..037afbc8 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -373,11 +373,10 @@ const TitleCard = ({ : intl.formatMessage(globalMessages.tvshow)}
- {showDetail && - currentStatus !== MediaStatus.BLACKLISTED && - user?.userType !== UserType.PLEX && ( -
- {toggleWatchlist ? ( + {showDetail && currentStatus !== MediaStatus.BLACKLISTED && ( +
+ {user?.userType !== UserType.PLEX && + (toggleWatchlist ? ( + ))} + {showHideButton && + currentStatus !== MediaStatus.PROCESSING && + currentStatus !== MediaStatus.AVAILABLE && + currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && + currentStatus !== MediaStatus.PENDING && ( + )} - {showHideButton && - currentStatus !== MediaStatus.PROCESSING && - currentStatus !== MediaStatus.AVAILABLE && - currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && - currentStatus !== MediaStatus.PENDING && ( - - )} -
- )} +
+ )} {showDetail && showHideButton && currentStatus == MediaStatus.BLACKLISTED && ( From a6dd4a8fedb9af9810581b1cc18cfea53b3cfd39 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Wed, 12 Mar 2025 22:13:00 +0100 Subject: [PATCH 11/33] fix(ui): move watch trailer button above the 4k request button (#1465) Fix a z-index issue with the "Watch Trailer" button being under the "Request in 4k" button fix #1462 --- src/components/MovieDetails/index.tsx | 4 +++- src/components/TvDetails/index.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 45cb0388..6d4a3be3 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -629,7 +629,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )} )} - +
+ +
{ )} )} - +
+ +
revalidate()} From 418d51590d658a0148d7ab67a0c9fccf445bf6ba Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Wed, 12 Mar 2025 22:20:19 +0100 Subject: [PATCH 12/33] chore(helm): upgrade jellyseerr app to 2.5.0 (#1464) 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 15f18210..5408ac1a 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.2.0 -appVersion: "2.4.0" +version: 2.3.0 +appVersion: "2.5.0" maintainers: - name: Jellyseerr url: https://github.com/Fallenbagel/jellyseerr diff --git a/charts/jellyseerr-chart/README.md b/charts/jellyseerr-chart/README.md index 94850b96..eae3c6d6 100644 --- a/charts/jellyseerr-chart/README.md +++ b/charts/jellyseerr-chart/README.md @@ -1,6 +1,6 @@ # jellyseerr-chart -![Version: 2.2.0](https://img.shields.io/badge/Version-2.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.4.0](https://img.shields.io/badge/AppVersion-2.4.0-informational?style=flat-square) +![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) Jellyseerr helm chart for Kubernetes From ebb7f00305d5be0c3d60baa351d4820afea38c0b Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:22:24 +0800 Subject: [PATCH 13/33] docs: add more troubleshooting steps (#1468) --- docs/troubleshooting.mdx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 2f3fed66..78b7e073 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -24,6 +24,12 @@ or for Cloudflare's DNS: ```bash --dns=1.1.1.1 ``` +or for Quad9 DNS: +```bash +--dns=9.9.9.9 +``` + +You can try them all and see which one works for your network. @@ -45,6 +51,16 @@ services: dns: - 1.1.1.1 ``` +or for Quad9's DNS: +```yaml +--- +services: + jellyseerr: + dns: + - 9.9.9.9 +``` + +You can try them all and see which one works for your network. @@ -56,7 +72,7 @@ services: 4. Click on Change adapter settings. 5. Right-click the network interface connected to the internet and select Properties. 6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties. -7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS. +7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS or `9.9.9.9` for Quad9's DNS. @@ -73,6 +89,10 @@ services: ```bash nameserver 1.1.1.1 ``` + or for Quad9's DNS: + ```bash + nameserver 9.9.9.9 + ``` @@ -81,7 +101,7 @@ services: Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly. -You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`: +You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting. You can also add the environment variable, `FORCE_IPV4_FIRST=true`: From b8425d6388003322edd7b4b2473aeb24c06e4802 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Thu, 13 Mar 2025 12:52:30 +0100 Subject: [PATCH 14/33] fix(smtp-notification-test): missing allowSelfSigned option in test function (#1461) * fix(smtp-notification-test): missing allowSelfSigned option in test function * fix: indent error --- src/components/Settings/Notifications/NotificationsEmail.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx index ac3853dd..a2431be0 100644 --- a/src/components/Settings/Notifications/NotificationsEmail.tsx +++ b/src/components/Settings/Notifications/NotificationsEmail.tsx @@ -221,6 +221,7 @@ const NotificationsEmail = () => { requireTls: values.encryption === 'opportunistic', authUser: values.authUser, authPass: values.authPass, + allowSelfSigned: values.allowSelfSigned, senderName: values.senderName, pgpPrivateKey: values.pgpPrivateKey, pgpPassword: values.pgpPassword, From 8394eb5ad405a90e840952d5977712e1ab890530 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Sat, 15 Mar 2025 03:49:54 +0800 Subject: [PATCH 15/33] revert(airdate): reverts airdate offset & changes relative time to only display date (not time) (#1467) * revert(airdate): reverts airdate offset and changes relative time to only display date (not time) This reverts #1390 as it created more confusion when we offsetted the air date in relevance to the timezone. It also changes the relative time to use date instead of time (so it will say `aired yesterday` `today` `5 days ago` instead of `aired x hours ago` since we dont really the airtime data. * fix: relate time in days instead of hours * fix: relative time in days * fix: relative time in days (but properly) --- src/components/AirDateBadge/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/AirDateBadge/index.tsx b/src/components/AirDateBadge/index.tsx index a51f39fc..1143c3e3 100644 --- a/src/components/AirDateBadge/index.tsx +++ b/src/components/AirDateBadge/index.tsx @@ -14,17 +14,13 @@ type AirDateBadgeProps = { const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { const WEEK = 1000 * 60 * 60 * 24 * 8; const intl = useIntl(); - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const dAirDate = new Date(airDate); const nowDate = new Date(); const alreadyAired = dAirDate.getTime() < nowDate.getTime(); - const compareWeek = new Date( alreadyAired ? Date.now() - WEEK : Date.now() + WEEK ); - let showRelative = false; - if ( (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) @@ -32,6 +28,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { showRelative = true; } + const diffInDays = Math.round( + (dAirDate.getTime() - nowDate.getTime()) / (1000 * 60 * 60 * 24) + ); + return (
@@ -39,7 +39,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { year: 'numeric', month: 'long', day: 'numeric', - timeZone, + timeZone: 'UTC', })} {showRelative && ( @@ -49,9 +49,9 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { { relativeTime: ( ), } From 767a24164d6c9d101e613c53960985f4fbe2ce93 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sat, 15 Mar 2025 16:35:23 +0100 Subject: [PATCH 16/33] fix(ui): resolve streaming region dropdown overlap (#1477) The streaming region 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 d8f0ded0..90fbb13c 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -415,7 +415,7 @@ const UserGeneralSettings = () => {
-
+
{
-
+
Date: Sat, 15 Mar 2025 22:42:17 +0100 Subject: [PATCH 17/33] fix: check if the file still exists in the service before deleting (#1476) This PR add a check to verify if the item to be deleted inside the *arr service still exists before actually sending the delete request. --- server/routes/media.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/routes/media.ts b/server/routes/media.ts index 60191e5d..3ad197c9 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -237,6 +237,19 @@ 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 @@ -251,6 +264,13 @@ 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); } From 77a36f971444ee5dc0d15b2d34a8daaf4e1f28b5 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sun, 16 Mar 2025 21:57:14 +0100 Subject: [PATCH 18/33] fix(job): resolve edge case issue with season availability updates (#1483) Ensure media availability updates correctly for shows marked as UNKNOWN and yet having AVAILABLE or PARTIALLY_AVAILABLE seasons --- server/lib/availabilitySync.ts | 48 ++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index b85d29e4..0fdfd627 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -404,6 +404,34 @@ class AvailabilitySync { }); } + if ( + !showExists && + (media.status === MediaStatus.AVAILABLE || + media.status === MediaStatus.PARTIALLY_AVAILABLE || + media.seasons.some( + (season) => season.status === MediaStatus.AVAILABLE + ) || + media.seasons.some( + (season) => season.status === MediaStatus.PARTIALLY_AVAILABLE + )) + ) { + await this.mediaUpdater(media, false, mediaServerType); + } + + if ( + !showExists4k && + (media.status4k === MediaStatus.AVAILABLE || + media.status4k === MediaStatus.PARTIALLY_AVAILABLE || + media.seasons.some( + (season) => season.status4k === MediaStatus.AVAILABLE + ) || + media.seasons.some( + (season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE + )) + ) { + await this.mediaUpdater(media, true, mediaServerType); + } + // TODO: Figure out how to run seasonUpdater for each season if ([...finalSeasons.values()].includes(false)) { @@ -423,22 +451,6 @@ class AvailabilitySync { mediaServerType ); } - - if ( - !showExists && - (media.status === MediaStatus.AVAILABLE || - media.status === MediaStatus.PARTIALLY_AVAILABLE) - ) { - await this.mediaUpdater(media, false, mediaServerType); - } - - if ( - !showExists4k && - (media.status4k === MediaStatus.AVAILABLE || - media.status4k === MediaStatus.PARTIALLY_AVAILABLE) - ) { - await this.mediaUpdater(media, true, mediaServerType); - } } } } catch (ex) { @@ -466,6 +478,10 @@ class AvailabilitySync { { status: MediaStatus.PARTIALLY_AVAILABLE }, { status4k: MediaStatus.AVAILABLE }, { status4k: MediaStatus.PARTIALLY_AVAILABLE }, + { seasons: { status: MediaStatus.AVAILABLE } }, + { seasons: { status: MediaStatus.PARTIALLY_AVAILABLE } }, + { seasons: { status4k: MediaStatus.AVAILABLE } }, + { seasons: { status4k: MediaStatus.PARTIALLY_AVAILABLE } }, ]; let mediaPage: Media[]; From c2d9d00b415fecbb5a8d7ca28a6ed76ea3ba3c19 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sun, 16 Mar 2025 22:44:25 +0100 Subject: [PATCH 19/33] fix(mediarequest): correct download sync for Radarr (#1484) This PR fixes a bug introduced by #1376, where `radarrSettings` was incorrectly replaced by `radarrMovie`. --- server/entity/MediaRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 8ef614b1..a0605374 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -999,7 +999,7 @@ export class MediaRequest { radarrMovie.id, [this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']: radarrMovie.titleSlug, - [this.is4k ? 'serviceId4k' : 'serviceId']: radarrMovie?.id, + [this.is4k ? 'serviceId4k' : 'serviceId']: radarrSettings?.id, }; await mediaRepository.update({ id: this.media.id }, updateFields); From f884ac9c660d1931c8b3815dcaefd109da249f2a Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sun, 16 Mar 2025 23:24:28 +0100 Subject: [PATCH 20/33] fix(ui): correct seasons badge order (#1485) This PR corrects the order of the seasons displayed on the request card, because it was not always ordered. --- src/components/RequestModal/TvRequestModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 0ef1afd1..339b3768 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -217,7 +217,7 @@ const TvRequestModal = ({ mediaType: 'tv', is4k, seasons: settings.currentSettings.partialRequestsEnabled - ? selectedSeasons + ? selectedSeasons.sort((a, b) => a - b) : getAllSeasons().filter( (season) => !getAllRequestedSeasons().includes(season) ), From 0bd6d57834ca050c2f7f5f4b4425932e8bd24a3b Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Mon, 17 Mar 2025 10:50:08 +0100 Subject: [PATCH 21/33] 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 1835e949..27e2bfbb 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 22/33] 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 5408ac1a..b30826c1 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 eae3c6d6..34643167 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 23/33] 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 b30826c1..833c48fc 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 34643167..72dce724 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 bf0d6422..a9363ee0 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 24/33] 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 90fbb13c..e0df3f5d 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 25/33] 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 078d1f1a..28bbc898 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 26/33] 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 44b07fd9..8189522f 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 bacd21e0..0084f27b 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 96fecbe9..2089513a 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 27/33] 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 67cbb55e..a0e8a0ef 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 4f46a78d..61874f35 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 28/33] 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 4cbc6e2c..b68f27ca 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 a275de0c..02d8839e 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 00b09596..6954992d 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 a0e8a0ef..14377781 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 61874f35..86ec16a6 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 f3bf3faa..35d24024 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 5590c9ac..0a9c2732 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 6389ea0b..f05dd0f2 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 00000000..a6dcd002 --- /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 0c79e4f3..028b26e6 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 52e84d3d..09cec4a0 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 f9b42cd3..2e0313f4 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 e338c9f0..00000000 --- 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 00000000..59da7109 --- /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 00000000..de438e3a --- /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 50df170b..121f6882 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 9e87cbdf..1b29d41e 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 b0c47b17..da9c6bf0 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 be0d2aa5..cf2b11b9 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 3961b157..36fba5fc 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 29/33] 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 27e2bfbb..855cecfb 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 8b2acaf6..7f1f3f2c 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 30/33] 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 3d411e02..b129cdb9 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 31/33] 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 a6dcd002..00000000 --- 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 00000000..fa3f259c --- /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 00000000..35ed072c --- /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 32/33] 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 b2323ea5..5ce1d9cf 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 33/33] 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 91b66740..5f51af71 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 04e320a0..60724569 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 00000000..1e61e996 --- /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 00000000..aff3e357 --- /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 4e470831..df6908a1 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 5938fa94..0065f011 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', });