Compare commits

...

30 Commits

Author SHA1 Message Date
fallenbagel
13c71b5ae3 fix(availability-sync): handle resolution check for single-server setups
PR #1543 introduced resolution checking to check 4k from non4k media when users have both server
types configured with the same service. Howerver, this causes false deletions for users with only a
single non4k service when radarr upgrades file to 4k resolution. This fix only applies resolution to
checking when both 4k and non4k servers are configured. Otherwise then if file exists then it counts
as available
2026-01-26 05:05:21 +08:00
fallenbagel
65844a2f23 chore(deps): migrate to @seerr-team/react-tailwindcss-datepicker (#2330)
Migrates from `react-tailwindcss-datepicker-sct` to `@seerr-team/react-tailwindcss-datepicker`, our
own fork published on npm. This fork includes a fix for keyboard input not working in single date
mode (typing a date and pressing enter now properly applies the filter).

fix #1585
2026-01-25 17:09:05 +01:00
0xsysr3ll
62755692e9 fix(availability-sync): fix 4K media availability detection (#2298) 2026-01-23 12:26:07 +01:00
fallenbagel
beba2ea099 fix(mediarequest): explicitly set mediaId when creating request (#2316)
* fix(mediarequest): explicitly set mediaId when creating

Intermittent issue where media_request records were created with mediaId = NULL,causing TypeError
when accessing request.media.tmdbId on the profile page. TypeORM's implicit relation-to-foreign-key
mapping was failing intermittently. This sets the mediaId column explicitly and adds a guard to
check to fail fast if media.id is not populated after save.

fix #2315

* refactor: better logging when media id not found
2026-01-23 14:32:46 +05:00
fallenbagel
88b2e7843f fix(sonarr): re-monitor episodes when re-requesting deleted but monitored seasons (#2312) 2026-01-20 18:34:21 +01:00
fallenbagel
dbd5935ade fix(proxy): configure proxy agent connection limits and IPv4 support (#2303)
* fix: configure axios proxy agent socket limits to prevent connection leaks

Add socket pool configuration to HttpProxyAgent and HttpsProxyAgent to
prevent connection leaks.

fix #2297

* fix(proxy): pass forceIpv4First option to custom proxy agent

* fix(proxy): add connection limits and IPv4 support to undici agents
2026-01-20 12:37:41 +01:00
fallenbagel
bb2120c14d fix(base-scanner): fix PROCESSING status persisting for unmonitored seasons (#2311)
BaseScanner's fallthrough logic was preventing unmonitored seasons from
resetting to UNKNOWN status.

fix #2310
2026-01-18 22:32:57 +05:00
fallenbagel
c9037f77e6 fix(network-settings): convert DNS cache TTL values to numbers (#2299)
This PR ensures DNS cache TTL values are properly converted to numbers before being sent to the
backend.

fix #2294
2026-01-17 13:46:05 +01:00
Brandon Cohen
48631db989 fix: preserve deleted status when processing movies (#2066)
* fix: prevent the delete status from changing unless a new request is made"

refactor: remove parent remove change until later date

refactor: remove console log

* fix: add download progress for deleted badge

fix: check if not processing first for movies

* fix: add season pack change
2026-01-17 06:48:14 +05:00
fallenbagel
ac7c2983d3 fix(pushover): prevent notifications when agent is disabled or unconfigured (#2304) 2026-01-16 22:39:15 +01:00
fallenbagel
767dc529e8 fix(ui): correct season pluralization in RequestItem (#2307)
Fixes incorrect "Seasons" label when only one season is requested.

fix #2263
2026-01-16 22:12:21 +01:00
fallenbagel
448a25e2a4 fix(availability-sync): prevent incorrect season deletion when media server is unreachable (#2302) 2026-01-16 10:47:47 +01:00
fallenbagel
3f35b8c886 fix(ui): correct season pluralisation in RequestCard (#2305)
Fixes incorrect "seasons" label when only one season is requested. The plural form was being used
regardless of the actual count

fix #2263
2026-01-16 09:19:13 +01:00
fallenbagel
d0f029b46e fix(login): add missing keys to PlexLoginButton FormattedMessage children (#2291) 2026-01-12 10:50:33 +01:00
fallenbagel
e0a81038cd fix(plex-settings): fix Plex preset selection not enabling save button (#2289)
Uses setValues instead of setFieldValue calls to properly trigger the validation and also added a
validateOnMOunt to ensure form validity is checked on initial render.

fix #2287
2026-01-11 19:17:47 +01:00
fallenbagel
4ab919360a fix(setup): fix Plex login not proceeding after authentication (#2290)
Directly fetch and populate SWR cache with user data instead of relying on revalidate() which is
disabled on auth pages since #2213

fix #2288
2026-01-11 18:43:27 +01:00
Someone
adbcf80333 fix(ui): remove duplicate download items in manage slide over (#1916)
* fix(ui): filter duplicate downloads in ManageSlideOver using downloadId

Apply the same logic as PR #927 to deduplicate season pack downloads
in the "Manage Series" slide-over panel.

* Update src/components/ManageSlideOver/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/ManageSlideOver/index.tsx

Co-authored-by: Gauthier <mail@gauthierth.fr>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauthier <mail@gauthierth.fr>
2026-01-07 16:06:11 +00:00
fallenbagel
f91a26befe fix(servarr): replace spaces in arr user tags with - (#2231)
* fix: sanitize disallowed characters in arr tags

Updates the tag creation to normalize diacritics, replace spaces with hyphens and stip any
non-alphanumeric characters from display name

fix #2229, fix #1897

* refactor: improve display name sanitization in tag creation

* fix: include displayName in user selection for tag migration

* fix(migrator): retrieve all user fields in tag migration

This is a one time migration so performance is neglible. This should trigger the @AfterLoad hooks
which sets the `displayName`
2026-01-06 03:18:06 +08:00
0xsysr3ll
0c95b5ec91 fix(migration): add cleanup step for duplicate push subscriptions before enforcing unique constraint (#2269)
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-05 10:54:31 +01:00
fallenbagel
193d4dc668 docs: temporarily make it clear seerr is not released (#2273) 2026-01-03 04:53:18 +00:00
0xsysr3ll
d0c9afc16e fix(webpush): improve iOS push subscription endpoint cleanup (#2140) 2025-12-31 13:44:45 +01:00
fallenbagel
57d583e1bd refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner (#2226)
* refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner

Refactors JellyfinScanner to extend BaseScanner class to align the jellyfin scanner architecture
with the plex scanner and reduce code duplication.

* fix(jellyfin-scanner): add imdbId handling back to fix a regression from original behaviour

* fix: add imdbId assignment for existing media entries

* fix: include imdbId in processed 4k media items and improve 4k detection

* fix(jellyfin-scanner): filter seasons based on settings for special episodes (regression)
2025-12-29 20:05:47 +08:00
samohtxotom
8bbe7864af chore(metadata-settings): add autoDismiss to toast notifications (#2254) 2025-12-27 06:27:12 +08:00
Gauthier
66b4e2c871 chore(issuetemplate): add a checkbox to search for existing issues (#2255) 2025-12-27 06:26:16 +08:00
fallenbagel
3ee69663dc fix(local-login): remove automatic plex linking and reduce logout log verbosity (#2225)
Removed redundant Plex user discovery logic that applies to all media servers currently. This is now
handled explicitly via linked accounts settings page. Also changed the successful logout log level
from info to debug since its routine behaviour
2025-12-15 19:44:43 +08:00
Ludovic Ortega
539d49879d chore: fix translate badge svg url (#2228)
* chore: fix translate badge svg url

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* fix: use https instead of http

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

---------

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-14 05:37:36 +08:00
RolliePollie18
15356dfe49 fix(jellyfin-scan): reduce jellyfin API calls during recently added scan (#2205)
* fix(jellyfin scanner): reduce jellyfin API calls during recently added scan

Significantly reduce number of API calls, addressing CPU spikes on Jellyfin 10.10+ servers.- Move
getSeasons() call outside the seasons loop (N calls to 1)- Request MediaSources via getEpisodes()
field parameter instead of  individual getItemData() calls per episode (N calls to 1 per season)
Performance improvements (tested on library with 12 TV shows):- Scan duration: 43.7s to 9.1s - Peak
CPU: 277% to 115% - CPU spike duration: 36s to 2s Functionality is unchanged, all availability
statuses identicalbefore and after.

* fix: add getEpisodes overloads to remove unsafe type assertion

* refactor(jellyfin): use generics instead of overloads

---------

Co-authored-by: patrick-acland <patrick.acland@kraken.tech>
2025-12-09 22:20:47 +08:00
fallenbagel
1f04eeb040 fix: disable automatic auth revalidation on auth pages (#2213)
* fix: disable automatic auth revalidation on auth pages

Prevents unnecessary `/api/v1/auth/me` requests on login, setup, and password reset pages.

fix #738

* fix: update regex to include resetpassword guid & add missing condition in refreshInterval
2025-12-09 13:17:17 +01:00
Thibaut Noah
e3028c21f2 docs: add webpush related troubleshooting steps (#2170)
* Update troubleshooting.mdx

Add potential fixes for users who fail to enable their web push notifications

* Update docs/troubleshooting.mdx

Modify appName syntax for better coding norm

Co-authored-by: Gauthier <mail@gauthierth.fr>

* refactor: apply suggestions from review comments

Co-authored-by: Gauthier <mail@gauthierth.fr>

* docs(troubleshooting): fix typos in troubleshooting doc page

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
Co-authored-by: fallenbagel <98979876+fallenbagel@users.noreply.github.com>
2025-12-09 08:49:42 +00:00
Gauthier
9d8b343790 chore(deps): update all non-major dependencies (#2188)
Update all non-major dependencies. Modifications in `src` files are there to fix linting issues.
2025-12-09 09:40:35 +01:00
43 changed files with 3540 additions and 2659 deletions

View File

@@ -91,6 +91,14 @@ body:
attributes: attributes:
label: Additional Context label: Additional Context
description: Please provide any additional information that may be relevant or helpful. description: Please provide any additional information that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this bug has already been reported?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes - type: checkboxes
id: terms id: terms
attributes: attributes:

View File

@@ -27,6 +27,14 @@ body:
attributes: attributes:
label: Additional Context label: Additional Context
description: Provide any additional information or screenshots that may be relevant or helpful. description: Provide any additional information or screenshots that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this feature has already been requested?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes - type: checkboxes
id: terms id: terms
attributes: attributes:

View File

@@ -8,7 +8,7 @@
<p align="center"> <p align="center">
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a> <a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a> <a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a> <a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a> <a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. **Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
## Getting Started ## Getting Started
Check out our documentation for instructions on how to install and run Seerr: For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
https://docs.seerr.dev/getting-started/ https://docs.seerr.dev/getting-started/
> [!IMPORTANT]
> **Seerr is not officially released yet.**
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
The documentation linked above is for running the **latest Jellyseerr** release.
> [!WARNING]
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
Instead, follow the dedicated migration guide (with `:develop` tag):
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
> [!CAUTION]
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
## Preview ## Preview
<img src="./public/preview.jpg"> <img src="./public/preview.jpg">

View File

@@ -174,4 +174,36 @@ This can happen if you have a new installation of Jellyfin/Emby or if you have c
This process should restore your admin privileges while preserving your settings. This process should restore your admin privileges while preserving your settings.
## Failed to enable web push notifications
### Option 1: You are using Pi-hole
When using Pi-hole, you need to whitelist the proper domains in order for the queries to not be intercepted and blocked by Pi-hole.
If you are using a chromium based browser (eg: Chrome, Brave, Edge...), the domain you need to whitelist is `fcm.googleapis.com`
If you are using Firefox, the domain you need to whitelist is `push.services.mozilla.com`
1. Log into your Pi-hole through the admin interface, then click on Domains situated under GROUP MANAGEMENT.
2. Add the domain corresponding to your browser in the `Domain to be added` field and then click on Add to allowed domains.
3. Now in order for those changes to be used you need to flush your current dns cache.
4. You can do so by using this command line in your Pi-hole terminal:
```bash
pihole restartdns
```
If this command fails (which is unlikely), use this equivalent:
```bash
pihole -f && pihole restartdns
```
5. Then restart your Seerr instance and try to enable the web push notifications again.
### Option 2: You are using Brave browser
Brave is a "De-Googled" browser. So by default or if you refused a prompt in the past, it cuts the access to the FCM (Firebase Cloud Messaging) service, which is mandatory for the web push notifications on Chromium based browsers.
1. Open Brave and paste this address in the url bar: `brave://settings/privacy`
2. Look for the option: "Use Google services for push messaging"
3. Activate this option
4. Relaunch Brave completely
5. You should now see the notifications prompt appearing instead of an error message.
If you still encounter issues, please reach out on our support channels. If you still encounter issues, please reach out on our support channels.

View File

@@ -2,7 +2,7 @@
"name": "seerr", "name": "seerr",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.17.1", "packageManager": "pnpm@10.24.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"postinstall": "node postinstall-win.js", "postinstall": "node postinstall-win.js",
@@ -33,38 +33,39 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dr.pogodin/csurf": "^1.14.1", "@dr.pogodin/csurf": "^1.16.6",
"@formatjs/intl-displaynames": "6.2.6", "@formatjs/intl-displaynames": "6.8.13",
"@formatjs/intl-locale": "3.1.1", "@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10", "@formatjs/intl-pluralrules": "5.4.6",
"@formatjs/intl-utils": "3.8.4", "@formatjs/intl-utils": "3.8.4",
"@formatjs/swc-plugin-experimental": "^0.4.0", "@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12", "@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16", "@heroicons/react": "2.2.0",
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
"@supercharge/request-ip": "1.2.0", "@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1", "@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30", "@tanem/react-nprogress": "5.0.56",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2", "@types/wink-jaro-distance": "^2.0.2",
"ace-builds": "1.15.2", "ace-builds": "1.43.4",
"axios": "1.10.0", "axios": "1.13.2",
"axios-rate-limit": "1.3.0", "axios-rate-limit": "1.4.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.13.1",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5", "country-flag-icons": "1.6.4",
"cronstrue": "2.23.0", "cronstrue": "2.23.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"dayjs": "1.11.7", "dayjs": "1.11.19",
"dns-caching": "^0.2.7", "dns-caching": "^0.2.7",
"email-templates": "12.0.1", "email-templates": "12.0.3",
"express": "4.21.2", "express": "4.21.2",
"express-openapi-validator": "4.13.8", "express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0", "express-rate-limit": "6.7.0",
"express-session": "1.17.3", "express-session": "1.18.2",
"formik": "^2.4.6", "formik": "^2.4.9",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"http-proxy-agent": "^7.0.2", "http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
@@ -76,40 +77,39 @@
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
"nodemailer": "6.10.0", "nodemailer": "6.10.0",
"openpgp": "5.11.2", "openpgp": "5.11.2",
"pg": "8.11.0", "pg": "8.16.3",
"plex-api": "5.3.2", "plex-api": "5.3.2",
"pug": "3.0.3", "pug": "3.0.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-ace": "10.1.0", "react-ace": "10.1.0",
"react-animate-height": "2.1.2", "react-animate-height": "2.1.2",
"react-aria": "3.23.0", "react-aria": "3.44.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-intersection-observer": "9.4.3", "react-intersection-observer": "9.4.3",
"react-intl": "^6.6.8", "react-intl": "^6.6.8",
"react-markdown": "8.0.5", "react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2", "react-popper-tooltip": "4.4.2",
"react-select": "5.7.0", "react-select": "5.10.2",
"react-spring": "9.7.1", "react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1", "react-toast-notifications": "2.5.1",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-truncate-markup": "5.1.2", "react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.9", "react-use-clipboard": "1.0.9",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3", "secure-random-password": "0.2.3",
"semver": "7.7.1", "semver": "7.7.3",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"sqlite3": "5.1.7", "sqlite3": "5.1.7",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.2.5", "swr": "2.3.7",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"typeorm": "0.3.12", "typeorm": "0.3.12",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"undici": "^7.3.0", "undici": "^7.16.0",
"validator": "^13.15.15", "validator": "^13.15.23",
"web-push": "3.5.0", "web-push": "3.6.7",
"wink-jaro-distance": "^2.0.0", "wink-jaro-distance": "^2.0.0",
"winston": "3.8.2", "winston": "3.18.3",
"winston-daily-rotate-file": "4.7.1", "winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23", "xml2js": "0.4.23",
"yamljs": "0.3.0", "yamljs": "0.3.0",
@@ -123,32 +123,33 @@
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/bcrypt": "5.0.0", "@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3", "@types/cookie-parser": "1.4.10",
"@types/country-flag-icons": "1.2.0", "@types/country-flag-icons": "1.2.2",
"@types/csurf": "1.11.2", "@types/csurf": "1.11.5",
"@types/email-templates": "8.0.4", "@types/email-templates": "8.0.4",
"@types/express": "4.17.17", "@types/express": "4.17.17",
"@types/express-session": "1.17.6", "@types/express-session": "1.18.2",
"@types/lodash": "4.14.191", "@types/lodash": "4.17.21",
"@types/mime": "3", "@types/mime": "3",
"@types/node": "22.10.5", "@types/node": "22.10.5",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.8",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-transition-group": "4.4.5", "@types/react-transition-group": "4.4.12",
"@types/secure-random-password": "0.2.1", "@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13", "@types/semver": "7.7.1",
"@types/swagger-ui-express": "4.1.3", "@types/swagger-ui-express": "4.1.8",
"@types/validator": "^13.15.3", "@types/validator": "^13.15.10",
"@types/web-push": "3.3.2", "@types/web-push": "3.6.4",
"@types/xml2js": "0.4.11", "@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31", "@types/yamljs": "0.2.31",
"@types/yup": "0.29.14", "@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.54.0", "@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0", "@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13", "autoprefixer": "10.4.22",
"commitizen": "4.3.0", "baseline-browser-mapping": "^2.8.32",
"commitizen": "4.3.1",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0", "cy-mobile-commands": "0.3.0",
"cypress": "14.1.0", "cypress": "14.1.0",
@@ -157,22 +158,22 @@
"eslint-config-next": "^14.2.4", "eslint-config-next": "^14.2.4",
"eslint-config-prettier": "8.6.0", "eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0", "eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-no-relative-import-paths": "1.5.2", "eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"husky": "8.0.3", "husky": "8.0.3",
"lint-staged": "13.1.2", "lint-staged": "13.1.2",
"nodemon": "3.1.9", "nodemon": "3.1.11",
"postcss": "8.4.31", "postcss": "8.5.6",
"prettier": "2.8.4", "prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2", "prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3", "prettier-plugin-tailwindcss": "0.2.3",
"tailwindcss": "3.2.7", "tailwindcss": "3.2.7",
"ts-node": "10.9.1", "ts-node": "10.9.2",
"tsc-alias": "1.8.2", "tsc-alias": "1.8.16",
"tsconfig-paths": "4.1.2", "tsconfig-paths": "4.2.0",
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"engines": { "engines": {
@@ -181,7 +182,7 @@
}, },
"overrides": { "overrides": {
"sqlite3/node-gyp": "8.4.1", "sqlite3/node-gyp": "8.4.1",
"@types/express-session": "1.17.6" "@types/express-session": "1.18.2"
}, },
"config": { "config": {
"commitizen": { "commitizen": {
@@ -204,8 +205,11 @@
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"sqlite3", "@swc/core",
"bcrypt" "bcrypt",
"cypress",
"sharp",
"sqlite3"
] ]
} }
} }

4142
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -112,6 +112,10 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string; DateCreated?: string;
} }
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
? JellyfinLibraryItemExtended[]
: JellyfinLibraryItem[];
export interface JellyfinItemsReponse { export interface JellyfinItemsReponse {
Items: JellyfinLibraryItemExtended[]; Items: JellyfinLibraryItemExtended[];
TotalRecordCount: number; TotalRecordCount: number;
@@ -415,13 +419,22 @@ class JellyfinAPI extends ExternalAPI {
} }
} }
public async getEpisodes( public async getEpisodes<
T extends { includeMediaInfo?: boolean } | undefined = undefined
>(
seriesID: string, seriesID: string,
seasonID: string seasonID: string,
): Promise<JellyfinLibraryItem[]> { options?: T
): Promise<EpisodeReturn<T>> {
try { try {
const episodeResponse = await this.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}` `/Shows/${seriesID}/Episodes`,
{
params: {
seasonId: seasonID,
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
},
}
); );
return episodeResponse.Items.filter( return episodeResponse.Items.filter(

View File

@@ -209,6 +209,34 @@ class SonarrAPI extends ServarrBase<{
series: newSeriesResponse.data, series: newSeriesResponse.data,
}); });
try {
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
const episodeIdsToMonitor = episodes
.filter(
(ep) =>
options.seasons.includes(ep.seasonNumber) && !ep.monitored
)
.map((ep) => ep.id);
if (episodeIdsToMonitor.length > 0) {
logger.debug(
'Re-monitoring unmonitored episodes for requested seasons.',
{
label: 'Sonarr',
seriesId: newSeriesResponse.data.id,
episodeCount: episodeIdsToMonitor.length,
}
);
await this.monitorEpisodes(episodeIdsToMonitor);
}
} catch (e) {
logger.warn('Failed to re-monitor episodes', {
label: 'Sonarr',
errorMessage: e.message,
seriesId: newSeriesResponse.data.id,
});
}
if (options.searchNow) { if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id); this.searchSeries(newSeriesResponse.data.id);
} }
@@ -318,6 +346,38 @@ class SonarrAPI extends ServarrBase<{
} }
} }
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
try {
const response = await this.axios.get<EpisodeResult[]>('/episode', {
params: { seriesId },
});
return response.data;
} catch (e) {
logger.error('Failed to retrieve episodes', {
label: 'Sonarr API',
errorMessage: e.message,
seriesId,
});
throw new Error('Failed to get episodes');
}
}
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
try {
await this.axios.put('/episode/monitor', {
episodeIds,
monitored: true,
});
} catch (e) {
logger.error('Failed to monitor episodes', {
label: 'Sonarr API',
errorMessage: e.message,
episodeIds,
});
throw new Error('Failed to monitor episodes');
}
}
private buildSeasonList( private buildSeasonList(
seasons: number[], seasons: number[],
existingSeasons?: SonarrSeason[] existingSeasons?: SonarrSeason[]

View File

@@ -332,9 +332,16 @@ export class MediaRequest {
if (requestBody.mediaType === MediaType.MOVIE) { if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media); await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: ${requestBody.mediaType}, TMDB ID: ${requestBody.mediaId}, persisted media id: ${media.id}`
);
}
const request = new MediaRequest({ const request = new MediaRequest({
type: MediaType.MOVIE, type: MediaType.MOVIE,
media, media,
mediaId: media.id,
requestedBy: requestUser, requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request // If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission( status: user.hasPermission(
@@ -442,9 +449,16 @@ export class MediaRequest {
await mediaRepository.save(media); await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: TV, TMDB ID: ${requestBody.mediaId}, is4k: ${requestBody.is4k}`
);
}
const request = new MediaRequest({ const request = new MediaRequest({
type: MediaType.TV, type: MediaType.TV,
media, media,
mediaId: media.id,
requestedBy: requestUser, requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request // If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission( status: user.hasPermission(
@@ -521,6 +535,9 @@ export class MediaRequest {
}) })
public media: Media; public media: Media;
@Column({ name: 'mediaId', nullable: true })
public mediaId: number;
@ManyToOne(() => User, (user) => user.requests, { @ManyToOne(() => User, (user) => user.requests, {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',

View File

@@ -1,8 +1,15 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from './User'; import { User } from './User';
@Entity() @Entity()
@Unique(['endpoint', 'user'])
export class UserPushSubscription { export class UserPushSubscription {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
public id: number; public id: number;

View File

@@ -97,7 +97,10 @@ app
// Register HTTP proxy // Register HTTP proxy
if (settings.network.proxy.enabled) { if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy); await createCustomProxyAgent(
settings.network.proxy,
settings.network.forceIpv4First
);
} }
// Migrate library types // Migrate library types

View File

@@ -300,7 +300,6 @@ class AvailabilitySync {
// Sonarr finds that season, we will change the final seasons value // Sonarr finds that season, we will change the final seasons value
// to true. // to true.
const filteredSeasonsMap: Map<number, boolean> = new Map(); const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons media.seasons
.filter( .filter(
(season) => (season) =>
@@ -311,48 +310,7 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false) filteredSeasonsMap.set(season.seasonNumber, false)
); );
// non-4k
const finalSeasons: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
plexSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
}
const filteredSeasonsMap4k: Map<number, boolean> = new Map(); const filteredSeasonsMap4k: Map<number, boolean> = new Map();
media.seasons media.seasons
.filter( .filter(
(season) => (season) =>
@@ -363,44 +321,32 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false) filteredSeasonsMap4k.set(season.seasonNumber, false)
); );
// 4k let finalSeasons: Map<number, boolean>;
const finalSeasons4k: Map<number, boolean> = new Map(); let finalSeasons4k: Map<number, boolean>;
if (mediaServerType === MediaServerType.PLEX) { if (mediaServerType === MediaServerType.PLEX) {
plexSeasonsMap4k.forEach((value, key) => { finalSeasons = new Map([
finalSeasons4k.set(key, value); ...filteredSeasonsMap,
}); ...plexSeasonsMap,
...sonarrSeasonsMap,
filteredSeasonsMap4k.forEach((value, key) => { ]);
if (!finalSeasons4k.has(key)) { finalSeasons4k = new Map([
finalSeasons4k.set(key, value); ...filteredSeasonsMap4k,
} ...plexSeasonsMap4k,
}); ...sonarrSeasonsMap4k,
]);
sonarrSeasonsMap4k.forEach((value, key) => { } else {
if (!finalSeasons4k.has(key)) { // Jellyfin/Emby
finalSeasons4k.set(key, value); finalSeasons = new Map([
} ...filteredSeasonsMap,
}); ...jellyfinSeasonsMap,
} else if ( ...sonarrSeasonsMap,
mediaServerType === MediaServerType.JELLYFIN || ]);
mediaServerType === MediaServerType.EMBY finalSeasons4k = new Map([
) { ...filteredSeasonsMap4k,
jellyfinSeasonsMap4k.forEach((value, key) => { ...jellyfinSeasonsMap4k,
finalSeasons4k.set(key, value); ...sonarrSeasonsMap4k,
}); ]);
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
} }
if ( if (
@@ -666,6 +612,24 @@ class AvailabilitySync {
): Promise<boolean> { ): Promise<boolean> {
let existsInRadarr = false; let existsInRadarr = false;
const has4kServer = this.radarrServers.some((s) => s.is4k);
const hasNon4kServer = this.radarrServers.some((s) => !s.is4k);
logger.debug(
`Checking Radarr for ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}]`,
{
label: 'AvailabilitySync',
has4kServer,
hasNon4kServer,
externalServiceId: media.externalServiceId,
externalServiceId4k: media.externalServiceId4k,
serversToCheck: this.radarrServers.filter((s) => s.is4k === is4k)
.length,
}
);
// Check for availability in all of the available radarr servers // Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists // If any find the media, we will assume the media exists
for (const server of this.radarrServers.filter( for (const server of this.radarrServers.filter(
@@ -696,7 +660,55 @@ class AvailabilitySync {
radarr?.movieFile?.mediaInfo?.resolution?.split('x'); radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie = const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000; resolution?.length === 2 && Number(resolution[0]) >= 2000;
logger.debug(
`Radarr file found for movie [TMDB ID ${media.tmdbId}]`,
{
label: 'AvailabilitySync',
serverId: server.id,
serverIs4k: server.is4k,
hasFile: radarr.hasFile,
resolution: radarr?.movieFile?.mediaInfo?.resolution,
parsedWidth: resolution?.[0],
is4kMovie,
checkingFor: is4k ? '4K' : 'non-4K',
}
);
if (has4kServer && hasNon4kServer) {
// User has both server types so use resolution to distinguish
// This handles the case where same content exists in both qualities
existsInRadarr = is4k ? is4kMovie : !is4kMovie; existsInRadarr = is4k ? is4kMovie : !is4kMovie;
logger.debug(
`Dual-server setup: using resolution check for movie [TMDB ID ${media.tmdbId}]`,
{
label: 'AvailabilitySync',
is4kMovie,
is4kCheck: is4k,
existsInRadarr,
}
);
} else {
// User only has one server type so if file exists, count it
// Don't penalize users whose Radarr upgrades to 4K on a non-4K server
existsInRadarr = true;
logger.debug(
`Single-server setup: file exists, marking as available for movie [TMDB ID ${media.tmdbId}]`,
{
label: 'AvailabilitySync',
is4kMovie,
is4kCheck: is4k,
existsInRadarr,
}
);
}
} else {
logger.debug(`Radarr response for movie [TMDB ID ${media.tmdbId}]`, {
label: 'AvailabilitySync',
serverId: server.id,
found: !!radarr,
hasFile: radarr?.hasFile ?? false,
});
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404')) { if (!ex.message.includes('404')) {
@@ -870,6 +882,50 @@ class AvailabilitySync {
this.plexSeasonsCache[ratingKey4k] = this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k); await this.plexClient?.getChildrenMetadata(ratingKey4k);
} }
if (plexMedia) {
if (ratingKey === ratingKey4k) {
plexMedia = undefined;
}
if (
plexMedia &&
media.mediaType === 'movie' &&
!plexMedia.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
) {
plexMedia = undefined;
}
if (plexMedia && media.mediaType === 'tv') {
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
if (cachedSeasons?.length) {
let has4kInAnySeason = false;
for (const season of cachedSeasons) {
try {
const episodes = await this.plexClient?.getChildrenMetadata(
season.ratingKey
);
const has4kEpisode = episodes?.some((episode) =>
episode.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
);
if (has4kEpisode) {
has4kInAnySeason = true;
break;
}
} catch {
// If we can't fetch episodes for a season, continue checking other seasons
}
}
if (!has4kInAnySeason) {
plexMedia = undefined;
}
}
}
}
} }
if (plexMedia) { if (plexMedia) {
@@ -993,8 +1049,8 @@ class AvailabilitySync {
existsInJellyfin = true; existsInJellyfin = true;
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404' || '500')) { if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = false; existsInJellyfin = true;
preventSeasonSearch = true; preventSeasonSearch = true;
logger.debug( logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

@@ -45,9 +45,19 @@ class PushoverAgent
} }
public shouldSend(): boolean { public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true; return true;
} }
return false;
}
private async getImagePayload( private async getImagePayload(
imageUrl: string imageUrl: string
): Promise<Partial<PushoverImagePayload>> { ): Promise<Partial<PushoverImagePayload>> {

View File

@@ -24,6 +24,15 @@ interface PushNotificationPayload {
isAdmin?: boolean; isAdmin?: boolean;
} }
interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}
class WebPushAgent class WebPushAgent
extends BaseAgent<NotificationAgentConfig> extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent implements NotificationAgent
@@ -188,19 +197,30 @@ class WebPushAgent
notificationPayload notificationPayload
); );
} catch (e) { } catch (e) {
const webPushError = e as WebPushError;
const statusCode = webPushError.statusCode || webPushError.status;
const errorMessage = webPushError.message || String(e);
// RFC 8030: 410/404 are permanent failures, others are transient
const isPermanentFailure = statusCode === 410 || statusCode === 404;
logger.error( logger.error(
'Error sending web push notification; removing subscription', isPermanentFailure
? 'Error sending web push notification; removing invalid subscription'
: 'Error sending web push notification (transient error, keeping subscription)',
{ {
label: 'Notifications', label: 'Notifications',
recipient: pushSub.user.displayName, recipient: pushSub.user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage,
statusCode: statusCode || 'unknown',
} }
); );
// Failed to send notification so we need to remove the subscription if (isPermanentFailure) {
userPushSubRepository.remove(pushSub); await userPushSubRepository.remove(pushSub);
}
} }
}; };

View File

@@ -34,6 +34,8 @@ interface ProcessOptions {
is4k?: boolean; is4k?: boolean;
mediaAddedAt?: Date; mediaAddedAt?: Date;
ratingKey?: string; ratingKey?: string;
jellyfinMediaId?: string;
imdbId?: string;
serviceId?: number; serviceId?: number;
externalServiceId?: number; externalServiceId?: number;
externalServiceSlug?: string; externalServiceSlug?: string;
@@ -95,6 +97,8 @@ class BaseScanner<T> {
is4k = false, is4k = false,
mediaAddedAt, mediaAddedAt,
ratingKey, ratingKey,
jellyfinMediaId,
imdbId,
serviceId, serviceId,
externalServiceId, externalServiceId,
externalServiceSlug, externalServiceSlug,
@@ -111,9 +115,11 @@ class BaseScanner<T> {
let changedExisting = false; let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) { if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.PROCESSING ? MediaStatus.AVAILABLE
: MediaStatus.AVAILABLE; : existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
if (mediaAddedAt) { if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt; existing.mediaAddedAt = mediaAddedAt;
} }
@@ -133,6 +139,21 @@ class BaseScanner<T> {
changedExisting = true; changedExisting = true;
} }
if (
jellyfinMediaId &&
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
jellyfinMediaId
) {
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
jellyfinMediaId;
changedExisting = true;
}
if (imdbId && !existing.imdbId) {
existing.imdbId = imdbId;
changedExisting = true;
}
if ( if (
serviceId !== undefined && serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
@@ -173,6 +194,7 @@ class BaseScanner<T> {
} else { } else {
const newMedia = new Media(); const newMedia = new Media();
newMedia.tmdbId = tmdbId; newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status = newMedia.status =
!is4k && !processing !is4k && !processing
@@ -203,6 +225,13 @@ class BaseScanner<T> {
newMedia.ratingKey4k = newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined; is4k && this.enable4kMovie ? ratingKey : undefined;
} }
if (jellyfinMediaId) {
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
newMedia.jellyfinMediaId4k =
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
}
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`); this.log(`Saved new media: ${title}`);
} }
@@ -221,11 +250,12 @@ class BaseScanner<T> {
*/ */
protected async processShow( protected async processShow(
tmdbId: number, tmdbId: number,
tvdbId: number, tvdbId: number | undefined,
seasons: ProcessableSeason[], seasons: ProcessableSeason[],
{ {
mediaAddedAt, mediaAddedAt,
ratingKey, ratingKey,
jellyfinMediaId,
serviceId, serviceId,
externalServiceId, externalServiceId,
externalServiceSlug, externalServiceSlug,
@@ -257,7 +287,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber (es) => es.seasonNumber === season.seasonNumber
); );
// We update the rating keys in the seasons loop because we need episode counts // We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) { if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey; media.ratingKey = ratingKey;
} }
@@ -271,6 +301,23 @@ class BaseScanner<T> {
media.ratingKey4k = ratingKey; media.ratingKey4k = ratingKey;
} }
if (
media &&
season.episodes > 0 &&
media.jellyfinMediaId !== jellyfinMediaId
) {
media.jellyfinMediaId = jellyfinMediaId;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== jellyfinMediaId
) {
media.jellyfinMediaId4k = jellyfinMediaId;
}
if (existingSeason) { if (existingSeason) {
// Here we update seasons if they already exist. // Here we update seasons if they already exist.
// If the season is already marked as available, we // If the season is already marked as available, we
@@ -285,6 +332,11 @@ class BaseScanner<T> {
season.processing && season.processing &&
existingSeason.status !== MediaStatus.DELETED existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING ? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status; : existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled // Same thing here, except we only do updates if 4k is enabled
@@ -300,6 +352,11 @@ class BaseScanner<T> {
season.processing && season.processing &&
existingSeason.status4k !== MediaStatus.DELETED existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING ? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k; : existingSeason.status4k;
} else { } else {
newSeasons.push( newSeasons.push(
@@ -491,6 +548,22 @@ class BaseScanner<T> {
) )
? ratingKey ? ratingKey
: undefined, : undefined,
jellyfinMediaId: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
jellyfinMediaId4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
status: isAllStandardSeasons status: isAllStandardSeasons
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: newSeasons.some( : newSeasons.some(

View File

@@ -1,5 +1,8 @@
import animeList from '@server/api/animelist'; import animeList from '@server/api/animelist';
import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import type {
JellyfinLibraryItem,
JellyfinLibraryItemExtended,
} from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import { getMetadataProvider } from '@server/api/metadata'; import { getMetadataProvider } from '@server/api/metadata';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
@@ -8,96 +11,73 @@ import type {
TmdbKeyword, TmdbKeyword,
TmdbTvDetails, TmdbTvDetails,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import type {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { Library } from '@server/lib/settings'; import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
const BUNDLE_SIZE = 20; interface JellyfinSyncStatus extends StatusBase {
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library; currentLibrary: Library;
libraries: Library[]; libraries: Library[];
} }
class JellyfinScanner { class JellyfinScanner
private sessionId: string; extends BaseScanner<JellyfinLibraryItem>
private tmdb: TheMovieDb; implements RunnableScanner<JellyfinSyncStatus>
{
private jfClient: JellyfinAPI; private jfClient: JellyfinAPI;
private items: JellyfinLibraryItem[] = [];
private progress = 0;
private libraries: Library[]; private libraries: Library[];
private currentLibrary: Library; private currentLibrary: Library;
private running = false;
private isRecentOnly = false; private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock();
private processedAnidbSeason: Map<number, Map<number, number>>; private processedAnidbSeason: Map<number, Map<number, number>>;
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb(); super('Jellyfin Sync');
this.isRecentOnly = isRecentOnly ?? false; this.isRecentOnly = isRecentOnly ?? false;
} }
private async getExisting(tmdbId: number, mediaType: MediaType) { private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
const mediaRepository = getRepository(Media); tmdbId: number;
imdbId?: string;
const existing = await mediaRepository.findOne({ metadata: JellyfinLibraryItemExtended;
where: { tmdbId: tmdbId, mediaType }, } | null> {
});
return existing;
}
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
try {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id); let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media();
if (!metadata?.Id) { if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', { this.log('No Id metadata for this title. Skipping', 'debug', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id, jellyfinItemId: jellyfinitem.Id,
}); });
return; return null;
} }
const anidbId = Number(metadata.ProviderIds.AniDB ?? null); const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null); let imdbId = metadata.ProviderIds.Imdb;
newMedia.imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else // We use anidb only if we have the anidbId and nothing else
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) { if (anidbId && !imdbId && !tmdbId) {
const result = animeList.getFromAnidbId(anidbId); const result = animeList.getFromAnidbId(anidbId);
newMedia.tmdbId = Number(result?.tmdbId ?? null); tmdbId = Number(result?.tmdbId ?? null);
newMedia.imdbId = result?.imdbId; imdbId = result?.imdbId;
} }
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) { if (imdbId && !tmdbId) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({ const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId, imdbId: imdbId,
}); });
newMedia.tmdbId = tmdbMovie.id; tmdbId = tmdbMovie.id;
} }
if (!newMedia.tmdbId) {
if (!tmdbId) {
throw new Error('Unable to find TMDb ID'); throw new Error('Unable to find TMDb ID');
} }
@@ -114,7 +94,7 @@ class JellyfinScanner {
this.log('No season found for anidb movie', 'debug', { this.log('No season found for anidb movie', 'debug', {
jellyfinitem, jellyfinitem,
}); });
return; return null;
} }
const episodes = await this.jfClient.getEpisodes( const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id, jellyfinitem.Id,
@@ -124,17 +104,27 @@ class JellyfinScanner {
this.log('No episode found for anidb movie', 'debug', { this.log('No episode found for anidb movie', 'debug', {
jellyfinitem, jellyfinitem,
}); });
return; return null;
} }
metadata = await this.jfClient.getItemData(episodes[0].Id); metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) { if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', { this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem, jellyfinitem,
}); });
return; return null;
} }
} }
return { tmdbId, imdbId, metadata };
}
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
try {
const extracted = await this.extractMovieIds(jellyfinitem);
if (!extracted) return;
const { tmdbId, imdbId, metadata } = extracted;
const has4k = metadata.MediaSources?.some((MediaSource) => { const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter( return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video' (MediaStream) => MediaStream.Type === 'Video'
@@ -151,93 +141,29 @@ class JellyfinScanner {
}); });
}); });
await this.asyncLock.dispatch(newMedia.tmdbId, async () => { const mediaAddedAt = metadata.DateCreated
if (!metadata) { ? new Date(metadata.DateCreated)
// this will never execute, but typescript thinks somebody could reset tvShow from : undefined;
// outer scope back to null before this async gets called
return;
}
const existing = await this.getExisting( if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
newMedia.tmdbId, await this.processMovie(tmdbId, {
MediaType.MOVIE is4k: false,
); mediaAddedAt,
jellyfinMediaId: metadata.Id,
if (existing) { imdbId,
let changedExisting = false; title: metadata.Name,
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (!existing.mediaAddedAt && !changedExisting) {
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.jellyfinMediaId !== metadata.Id
) {
existing.jellyfinMediaId = metadata.Id;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.jellyfinMediaId4k !== metadata.Id
) {
existing.jellyfinMediaId4k = metadata.Id;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.Name} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.Name}`
);
}
} else {
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
newMedia.jellyfinMediaId =
hasOtherResolution || (!this.enable4kMovie && has4k)
? metadata.Id
: null;
newMedia.jellyfinMediaId4k =
has4k && this.enable4kMovie ? metadata.Id : null;
await mediaRepository.save(newMedia);
this.log(`Saved ${metadata.Name}`);
}
}); });
}
if (has4k && this.enable4kMovie) {
await this.processMovie(tmdbId, {
is4k: true,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
} catch (e) { } catch (e) {
this.log( this.log(
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`, `Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
@@ -286,9 +212,7 @@ class JellyfinScanner {
return tvShow; return tvShow;
} }
private async processShow(jellyfinitem: JellyfinLibraryItem) { private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null; let tvShow: TmdbTvDetails | null = null;
try { try {
@@ -297,8 +221,7 @@ class JellyfinScanner {
const metadata = await this.jfClient.getItemData(Id); const metadata = await this.jfClient.getItemData(Id);
if (!metadata?.Id) { if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', { this.log('No Id metadata for this title. Skipping', 'debug', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id, jellyfinItemId: jellyfinitem.Id,
}); });
return; return;
@@ -315,6 +238,7 @@ class JellyfinScanner {
}); });
} }
} }
if (!tvShow && metadata.ProviderIds.Tvdb) { if (!tvShow && metadata.ProviderIds.Tvdb) {
try { try {
tvShow = await this.getTvShow({ tvShow = await this.getTvShow({
@@ -326,6 +250,7 @@ class JellyfinScanner {
}); });
} }
} }
let tvdbSeasonFromAnidb: number | undefined; let tvdbSeasonFromAnidb: number | undefined;
if (!tvShow && metadata.ProviderIds.AniDB) { if (!tvShow && metadata.ProviderIds.AniDB) {
const anidbId = Number(metadata.ProviderIds.AniDB); const anidbId = Number(metadata.ProviderIds.AniDB);
@@ -344,39 +269,24 @@ class JellyfinScanner {
} }
// With AniDB we can have mixed libraries with movies in a "show" library // With AniDB we can have mixed libraries with movies in a "show" library
else if (result?.imdbId || result?.tmdbId) { else if (result?.imdbId || result?.tmdbId) {
await this.processMovie(jellyfinitem); await this.processJellyfinMovie(jellyfinitem);
return; return;
} }
} }
if (tvShow) { if (tvShow) {
await this.asyncLock.dispatch(tvShow.id, async () => {
if (!tvShow) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
// Lets get the available seasons from Jellyfin
const seasons = tvShow.seasons; const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV); const jellyfinSeasons = await this.jfClient.getSeasons(Id);
const newSeasons: Season[] = []; const processableSeasons: ProcessableSeason[] = [];
const currentStandardSeasonAvailable = ( const settings = getSettings();
media?.seasons.filter( const filteredSeasons = settings.main.enableSpecialEpisodes
(season) => season.status === MediaStatus.AVAILABLE ? seasons
) ?? [] : seasons.filter((sn) => sn.season_number !== 0);
).length;
const current4kSeasonAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) { for (const season of filteredSeasons) {
const JellyfinSeasons = await this.jfClient.getSeasons(Id); const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
if (tvdbSeasonFromAnidb) { if (tvdbSeasonFromAnidb) {
// In AniDB we don't have the concept of seasons, // In AniDB we don't have the concept of seasons,
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials). // we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
@@ -391,24 +301,17 @@ class JellyfinScanner {
} }
}); });
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes // Check if we found the matching season and it has all the available episodes
if (matchedJellyfinSeason) { if (matchedJellyfinSeason) {
// If we have a matched Jellyfin season, get its children metadata so we can check details let totalStandard = 0;
let total4k = 0;
if (!this.enable4kShow) {
const episodes = await this.jfClient.getEpisodes( const episodes = await this.jfClient.getEpisodes(
Id, Id,
matchedJellyfinSeason.Id matchedJellyfinSeason.Id
); );
//Get count of episodes that are HD and 4K
let totalStandard = 0;
let total4k = 0;
//use for loop to make sure this loop _completes_ in full
//before the next section
for (const episode of episodes) { for (const episode of episodes) {
let episodeCount = 1; let episodeCount = 1;
@@ -421,238 +324,94 @@ class JellyfinScanner {
episode.IndexNumberEnd - episode.IndexNumber + 1; episode.IndexNumberEnd - episode.IndexNumber + 1;
} }
if (!this.enable4kShow) {
totalStandard += episodeCount; totalStandard += episodeCount;
}
} else { } else {
const ExtendedEpisodeData = await this.jfClient.getItemData( // 4K detection enabled - request media info to check resolution
episode.Id const episodes = await this.jfClient.getEpisodes(
Id,
matchedJellyfinSeason.Id,
{ includeMediaInfo: true }
); );
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => { for (const episode of episodes) {
return MediaSource.MediaStreams.some((MediaStream) => { let episodeCount = 1;
if (MediaStream.Type === 'Video') {
if ((MediaStream.Width ?? 0) >= 2000) { // count number of combined episodes
total4k += episodeCount; if (
} else { episode.IndexNumber !== undefined &&
totalStandard += episodeCount; episode.IndexNumberEnd !== undefined
) {
episodeCount =
episode.IndexNumberEnd - episode.IndexNumber + 1;
} }
}
}); const has4k = episode.MediaSources?.some((MediaSource) =>
}); MediaSource.MediaStreams.some(
(MediaStream) =>
MediaStream.Type === 'Video' &&
(MediaStream.Width ?? 0) > 2000
)
);
const hasStandard = episode.MediaSources?.some((MediaSource) =>
MediaSource.MediaStreams.some(
(MediaStream) =>
MediaStream.Type === 'Video' &&
(MediaStream.Width ?? 0) <= 2000
)
);
// Count in both if episode has both versions
// TODO: Make this more robust in the future
// Currently, this detection is based solely on file resolution, not which
// Radarr/Sonarr instance the file came from. If a 4K request results in
// 1080p files (no 4K release available yet), those files will be counted
// as "standard" even though they're in the 4K library. This can cause
// non-4K users to see content as "available" when they can't access it.
// See issue https://github.com/seerr-team/seerr/issues/1744 for details.
if (hasStandard) totalStandard += episodeCount;
if (has4k) total4k += episodeCount;
} }
} }
// With AniDB we can have multiple shows for one season, so we need to save // With AniDB we can have multiple shows for one season, so we need to save
// the episode from all the jellyfin entries to get the total // the episode from all the jellyfin entries to get the total
if (tvdbSeasonFromAnidb) { if (tvdbSeasonFromAnidb) {
if (this.processedAnidbSeason.has(tvShow.id)) { let show = this.processedAnidbSeason.get(tvShow.id);
const show = this.processedAnidbSeason.get(tvShow.id)!;
if (show.has(season.season_number)) {
show.set(
season.season_number,
show.get(season.season_number)! + totalStandard
);
totalStandard = show.get(season.season_number)!; if (!show) {
show = new Map([[season.season_number, totalStandard]]);
this.processedAnidbSeason.set(tvShow.id, show);
} else { } else {
show.set(season.season_number, totalStandard); const currentCount = show.get(season.season_number) ?? 0;
} const newCount = currentCount + totalStandard;
} else { show.set(season.season_number, newCount);
this.processedAnidbSeason.set( totalStandard = newCount;
tvShow.id,
new Map([[season.season_number, totalStandard]])
);
} }
} }
if ( processableSeasons.push({
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.jellyfinMediaId !== Id
) {
media.jellyfinMediaId = Id;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== Id
) {
media.jellyfinMediaId4k = Id;
}
if (existingSeason) {
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard >= season.episode_count ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
(this.enable4kShow && total4k >= season.episode_count) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number, seasonNumber: season.season_number,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN" totalEpisodes: season.episode_count,
// if we dont have any items for the season episodes: totalStandard,
status: episodes4k: total4k,
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
);
}
}
}
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt) {
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: isAllStandardSeasons ? Id : null,
jellyfinMediaId4k:
isAll4kSeasons && this.enable4kShow ? Id : null,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
}); });
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
} }
}); }
await this.processShow(
tvShow.id,
tvShow.external_ids?.tvdb_id,
processableSeasons,
{
mediaAddedAt: metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined,
jellyfinMediaId: Id,
title: tvShow.name,
}
);
} else { } else {
this.log( this.log(
`No information found for the show: ${metadata.Name}`, `No information found for the show: ${metadata.Name}`,
@@ -668,70 +427,17 @@ class JellyfinScanner {
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
}`, }`,
'error', 'error',
{ { errorMessage: e.message, jellyfinitem }
errorMessage: e.message,
jellyfinitem,
}
); );
} }
} }
private async processItems(slicedItems: JellyfinLibraryItem[]) { private async processItem(item: JellyfinLibraryItem): Promise<void> {
this.processedAnidbSeason = new Map();
await Promise.all(
slicedItems.map(async (item) => {
if (item.Type === 'Movie') { if (item.Type === 'Movie') {
await this.processMovie(item); await this.processJellyfinMovie(item);
} else if (item.Type === 'Series') { } else if (item.Type === 'Series') {
await this.processShow(item); await this.processJellyfinShow(item);
} }
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Jellyfin Sync', ...optional });
} }
public async run(): Promise<void> { public async run(): Promise<void> {
@@ -744,14 +450,9 @@ class JellyfinScanner {
return; return;
} }
const sessionId = uuid(); const sessionId = this.startRun();
this.sessionId = sessionId;
logger.info('Jellyfin Sync Starting', {
sessionId,
label: 'Jellyfin Sync',
});
try { try {
this.running = true;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
@@ -777,25 +478,11 @@ class JellyfinScanner {
await animeList.sync(); await animeList.sync();
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
if (this.isRecentOnly) { if (this.isRecentOnly) {
for (const library of this.libraries) { for (const library of this.libraries) {
this.currentLibrary = library; this.currentLibrary = library;
// Reset AniDB season tracking per library
this.processedAnidbSeason = new Map();
this.log( this.log(
`Beginning to process recently added for library: ${library.name}`, `Beginning to process recently added for library: ${library.name}`,
'info' 'info'
@@ -815,16 +502,19 @@ class JellyfinScanner {
return mediaA.Id === mediaB.Id; return mediaA.Id === mediaB.Id;
}); });
await this.loop({ sessionId }); await this.loop(this.processItem.bind(this), { sessionId });
} }
} else { } else {
for (const library of this.libraries) { for (const library of this.libraries) {
this.currentLibrary = library; this.currentLibrary = library;
// Reset AniDB season tracking per library
this.processedAnidbSeason = new Map();
this.log(`Beginning to process library: ${library.name}`, 'info'); this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.jfClient.getLibraryContents(library.id); this.items = await this.jfClient.getLibraryContents(library.id);
await this.loop({ sessionId }); await this.loop(this.processItem.bind(this), { sessionId });
} }
} }
this.log( this.log(
this.isRecentOnly this.isRecentOnly
? 'Recently Added Scan Complete' ? 'Recently Added Scan Complete'
@@ -832,19 +522,13 @@ class JellyfinScanner {
'info' 'info'
); );
} catch (e) { } catch (e) {
logger.error('Sync interrupted', { this.log('Sync interrupted', 'error', { errorMessage: e.message });
label: 'Jellyfin Sync',
errorMessage: e.message,
});
} finally { } finally {
// If a new scanning session hasnt started, set running back to false this.endRun(sessionId);
if (this.sessionId === sessionId) {
this.running = false;
}
} }
} }
public status(): SyncStatus { public status(): JellyfinSyncStatus {
return { return {
running: this.running, running: this.running,
progress: this.progress, progress: this.progress,
@@ -853,10 +537,6 @@ class JellyfinScanner {
libraries: this.libraries, libraries: this.libraries,
}; };
} }
public cancel(): void {
this.running = false;
}
} }
export const jellyfinFullScanner = new JellyfinScanner(); export const jellyfinFullScanner = new JellyfinScanner();

View File

@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
} }
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find({ const users = await userRepository.find();
select: ['id'],
});
let errorOccurred = false; let errorOccurred = false;
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}); });
const radarrTags = await radarr.getTags(); const radarrTags = await radarr.getTags();
for (const user of users) { for (const user of users) {
const userTag = radarrTags.find((v) => const userTag = radarrTags.find(
v.label.startsWith(user.id + ' - ') (v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
); );
if (!userTag) { if (!userTag) {
continue; continue;
} }
await radarr.renameTag({ await radarr.renameTag({
id: userTag.id, id: userTag.id,
label: userTag.label.replace(`${user.id} - `, `${user.id}-`), label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
}); });
} }
} catch (error) { } catch (error) {
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}); });
const sonarrTags = await sonarr.getTags(); const sonarrTags = await sonarr.getTags();
for (const user of users) { for (const user of users) {
const userTag = sonarrTags.find((v) => const userTag = sonarrTags.find(
v.label.startsWith(user.id + ' - ') (v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
); );
if (!userTag) { if (!userTag) {
continue; continue;
} }
await sonarr.renameTag({ await sonarr.renameTag({
id: userTag.id, id: userTag.id,
label: userTag.label.replace(`${user.id} - `, `${user.id}-`), label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034
implements MigrationInterface
{
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
);
}
}

View File

@@ -0,0 +1,26 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034
implements MigrationInterface
{
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
}
}

View File

@@ -626,76 +626,6 @@ authRoutes.post('/local', async (req, res, next) => {
}); });
} }
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!user.plexId) {
try {
const plexUsersResponse = await mainPlexTv.getUsers();
const account = plexUsersResponse.MediaContainer.User.find(
(account) =>
account.$.email &&
account.$.email.toLowerCase() === user.email.toLowerCase()
)?.$;
if (
account &&
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
) {
logger.info(
'Found matching Plex user; updating user with Plex data',
{
label: 'API',
ip: req.ip,
email: body.email,
userId: user.id,
plexId: account.id,
plexUsername: account.username,
}
);
user.plexId = parseInt(account.id);
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
user.userType = UserType.PLEX;
await userRepository.save(user);
}
} catch (e) {
logger.error('Something went wrong fetching Plex users', {
label: 'API',
errorMessage: e.message,
});
}
}
if (
user.plexId &&
user.plexId !== mainUser.plexId &&
!(await mainPlexTv.checkUserAccess(user.plexId))
) {
logger.warn(
'Failed sign-in attempt from Plex user without access to the media server',
{
label: 'API',
account: {
ip: req.ip,
email: body.email,
userId: user.id,
plexId: user.plexId,
},
}
);
return next({
status: 403,
message: 'Access denied.',
});
}
// Set logged in session // Set logged in session
if (user && req.session) { if (user && req.session) {
req.session.userId = user.id; req.session.userId = user.id;
@@ -775,7 +705,7 @@ authRoutes.post('/logout', async (req, res, next) => {
}); });
return next({ status: 500, message: 'Failed to destroy session.' }); return next({ status: 500, message: 'Failed to destroy session.' });
} }
logger.info('Successfully logged out user', { logger.debug('Successfully logged out user', {
label: 'Auth', label: 'Auth',
userId, userId,
}); });

View File

@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import dataSource, { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm'; import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import userSettingsRoutes from './usersettings'; import userSettingsRoutes from './usersettings';
const router = Router(); const router = Router();
@@ -188,19 +189,69 @@ router.post<
} }
>('/registerPushSubscription', async (req, res, next) => { >('/registerPushSubscription', async (req, res, next) => {
try { try {
const userPushSubRepository = getRepository(UserPushSubscription); // This prevents race conditions where two requests both pass the checks
await dataSource.transaction(
async (transactionalEntityManager: EntityManager) => {
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);
const existingSubs = await userPushSubRepository.find({ // Check for existing subscription by auth or endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true }, relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } }, where: [
{ auth: req.body.auth, user: { id: req.user?.id } },
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
],
}); });
if (existingSubs.length > 0) { if (existingSubscription) {
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
if (
existingSubscription.endpoint === req.body.endpoint &&
existingSubscription.auth !== req.body.auth
) {
existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;
await transactionalRepo.save(existingSubscription);
logger.debug( logger.debug(
'User push subscription already exists. Skipping registration.', 'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' } { label: 'API' }
); );
return res.status(204).send(); return;
}
logger.debug(
'Duplicate subscription detected. Skipping registration.',
{ label: 'API' }
);
return;
}
// Clean up old subscriptions from the same device (userAgent) for this user
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
// Only clean up if we're creating a new subscription (not updating an existing one)
if (req.body.userAgent) {
const staleSubscriptions = await transactionalRepo.find({
relations: { user: true },
where: {
userAgent: req.body.userAgent,
user: { id: req.user?.id },
// Only remove subscriptions with different endpoints (stale ones)
// Keep subscriptions that might be from different browsers/tabs
endpoint: Not(req.body.endpoint),
},
});
if (staleSubscriptions.length > 0) {
await transactionalRepo.remove(staleSubscriptions);
logger.debug(
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
{ label: 'API' }
);
}
} }
const userPushSubscription = new UserPushSubscription({ const userPushSubscription = new UserPushSubscription({
@@ -211,7 +262,9 @@ router.post<
user: req.user, user: req.user,
}); });
userPushSubRepository.save(userPushSubscription); await transactionalRepo.save(userPushSubscription);
}
);
return res.status(204).send(); return res.status(204).send();
} catch (e) { } catch (e) {

View File

@@ -29,6 +29,16 @@ import type {
} from 'typeorm'; } from 'typeorm';
import { EventSubscriber } from 'typeorm'; import { EventSubscriber } from 'typeorm';
const sanitizeDisplayName = (displayName: string): string => {
return displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
@EventSubscriber() @EventSubscriber()
export class MediaRequestSubscriber export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest> implements EntitySubscriberInterface<MediaRequest>
@@ -310,11 +320,15 @@ export class MediaRequestSubscriber
mediaId: entity.media.id, mediaId: entity.media.id,
userId: entity.requestedBy.id, userId: entity.requestedBy.id,
newTag: newTag:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
userTag = await radarr.createTag({ userTag = await radarr.createTag({
label: label:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
} }
if (userTag.id) { if (userTag.id) {
@@ -631,11 +645,15 @@ export class MediaRequestSubscriber
mediaId: entity.media.id, mediaId: entity.media.id,
userId: entity.requestedBy.id, userId: entity.requestedBy.id,
newTag: newTag:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
userTag = await sonarr.createTag({ userTag = await sonarr.createTag({
label: label:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
} }
if (userTag.id) { if (userTag.id) {

View File

@@ -11,9 +11,14 @@ export let requestInterceptorFunction: (
) => InternalAxiosRequestConfig; ) => InternalAxiosRequestConfig;
export default async function createCustomProxyAgent( export default async function createCustomProxyAgent(
proxySettings: ProxySettings proxySettings: ProxySettings,
forceIpv4First?: boolean
) { ) {
const defaultAgent = new Agent({ keepAliveTimeout: 5000 }); const defaultAgent = new Agent({
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
const skipUrl = (url: string | URL) => { const skipUrl = (url: string | URL) => {
const hostname = const hostname =
@@ -67,16 +72,23 @@ export default async function createCustomProxyAgent(
uri: proxyUrl, uri: proxyUrl,
token, token,
keepAliveTimeout: 5000, keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
}); });
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor)); setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, { const agentOptions = {
headers: token ? { 'proxy-authorization': token } : undefined, headers: token ? { 'proxy-authorization': token } : undefined,
}); keepAlive: true,
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, { maxSockets: 50,
headers: token ? { 'proxy-authorization': token } : undefined, maxFreeSockets: 10,
}); timeout: 5000,
scheduling: 'lifo' as const,
family: forceIpv4First ? 4 : undefined,
};
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, agentOptions);
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, agentOptions);
requestInterceptorFunction = (config) => { requestInterceptorFunction = (config) => {
const url = config.baseURL const url = config.baseURL

View File

@@ -25,7 +25,7 @@ const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
<Field type="checkbox" id={id} name={id} onChange={onChange} /> <Field type="checkbox" id={id} name={id} onChange={onChange} />
</div> </div>
<div className="ml-3 text-sm leading-6"> <div className="ml-3 text-sm leading-6">
<label htmlFor="localLogin" className="block"> <label htmlFor="localLogin" className="block" aria-label={label}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-white">{label}</span> <span className="font-medium text-white">{label}</span>
<span className="font-normal text-gray-400">{description}</span> <span className="font-normal text-gray-400">{description}</span>

View File

@@ -19,8 +19,8 @@ import {
} from '@app/hooks/useUpdateQueryParams'; } from '@app/hooks/useUpdateQueryParams';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { XCircleIcon } from '@heroicons/react/24/outline'; import { XCircleIcon } from '@heroicons/react/24/outline';
import Datepicker from '@seerr-team/react-tailwindcss-datepicker';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages('components.Discover.FilterSlideover', { const messages = defineMessages('components.Discover.FilterSlideover', {
filters: 'Filters', filters: 'Filters',

View File

@@ -3,6 +3,7 @@ import Button from '@app/components/Common/Button';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import usePlexLogin from '@app/hooks/usePlexLogin'; import usePlexLogin from '@app/hooks/usePlexLogin';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Fragment } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const messages = defineMessages('components.Login', { const messages = defineMessages('components.Login', {
@@ -46,8 +47,12 @@ const PlexLoginButton = ({
> >
{(chunks) => ( {(chunks) => (
<> <>
{chunks.map((c) => {chunks.map((c, index) =>
typeof c === 'string' ? <span>{c}</span> : c typeof c === 'string' ? (
<span key={index}>{c}</span>
) : (
<Fragment key={index}>{c}</Fragment>
)
)} )}
</> </>
)} )}

View File

@@ -25,6 +25,7 @@ import {
} from '@server/constants/media'; } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces'; import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
@@ -33,6 +34,17 @@ import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const filterDuplicateDownloads = (
items: DownloadingItem[] = []
): DownloadingItem[] => {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.downloadId)) return false;
seen.add(item.downloadId);
return true;
});
};
const messages = defineMessages('components.ManageSlideOver', { const messages = defineMessages('components.ManageSlideOver', {
manageModalTitle: 'Manage {mediaType}', manageModalTitle: 'Manage {mediaType}',
manageModalIssues: 'Open Issues', manageModalIssues: 'Open Issues',
@@ -230,7 +242,8 @@ const ManageSlideOver = ({
</h3> </h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow"> <div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul> <ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => ( {filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map(
(status, index) => (
<Tooltip <Tooltip
key={`dl-status-${status.externalId}-${index}`} key={`dl-status-${status.externalId}-${index}`}
content={status.title} content={status.title}
@@ -239,17 +252,20 @@ const ManageSlideOver = ({
<DownloadBlock downloadItem={status} /> <DownloadBlock downloadItem={status} />
</li> </li>
</Tooltip> </Tooltip>
))} )
{data.mediaInfo?.downloadStatus4k?.map((status, index) => ( )}
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
(status, index) => (
<Tooltip <Tooltip
key={`dl-status-${status.externalId}-${index}`} key={`dl-status-4k-${status.externalId}-${index}`}
content={status.title} content={status.title}
> >
<li className="border-b border-gray-700 last:border-b-0"> <li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k /> <DownloadBlock downloadItem={status} is4k />
</li> </li>
</Tooltip> </Tooltip>
))} )
)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -46,7 +46,7 @@ const NotificationType = ({
/> />
</div> </div>
<div className="ml-3 text-sm leading-6"> <div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="block"> <label htmlFor={option.id} className="block" aria-label={option.name}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-white">{option.name}</span> <span className="font-medium text-white">{option.name}</span>
<span className="font-normal text-gray-400"> <span className="font-normal text-gray-400">

View File

@@ -123,7 +123,7 @@ const PermissionOption = ({
/> />
</div> </div>
<div className="ml-3 text-sm leading-6"> <div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="block"> <label htmlFor={option.id} className="block" aria-label={option.name}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-white">{option.name}</span> <span className="font-medium text-white">{option.name}</span>
<span className="font-normal text-gray-400"> <span className="font-normal text-gray-400">

View File

@@ -5,7 +5,6 @@ import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal'; import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks'; import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -219,7 +218,6 @@ interface RequestCardProps {
} }
const RequestCard = ({ request, onTitleData }: RequestCardProps) => { const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const settings = useSettings();
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
@@ -402,14 +400,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex"> <div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold "> <span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
seasonCount: seasonCount: request.seasons.length,
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})} })}
</span> </span>
<div className="hide-scrollbar overflow-x-scroll"> <div className="hide-scrollbar overflow-x-scroll">

View File

@@ -5,7 +5,6 @@ import ConfirmButton from '@app/components/Common/ConfirmButton';
import RequestModal from '@app/components/RequestModal'; import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks'; import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -295,7 +294,6 @@ interface RequestItemProps {
} }
const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const settings = useSettings();
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
@@ -470,14 +468,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <span className="card-field-name">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
seasonCount: seasonCount: request.seasons.length,
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})} })}
</span> </span>
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll"> <div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">

View File

@@ -49,7 +49,12 @@ const NotificationsPushover = () => {
const { data: soundsData } = useSWR<PushoverSound[]>( const { data: soundsData } = useSWR<PushoverSound[]>(
data?.options.accessToken data?.options.accessToken
? `/api/v1/settings/notifications/pushover/sounds?token=${data.options.accessToken}` ? `/api/v1/settings/notifications/pushover/sounds?token=${data.options.accessToken}`
: null : null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
}
); );
const NotificationsPushoverSchema = Yup.object().shape({ const NotificationsPushoverSchema = Yup.object().shape({

View File

@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
addToast(intl.formatMessage(messages.metadataSettingsSaved), { addToast(intl.formatMessage(messages.metadataSettingsSaved), {
appearance: 'success', appearance: 'success',
autoDismiss: true,
}); });
} catch (e) { } catch (e) {
addToast( addToast(
intl.formatMessage(messages.failedToSaveMetadataSettings), intl.formatMessage(messages.failedToSaveMetadataSettings),
{ {
appearance: 'error', appearance: 'error',
autoDismiss: true,
} }
); );
} }
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
), ),
{ {
appearance: 'success', appearance: 'success',
autoDismiss: true,
} }
); );
} }

View File

@@ -38,6 +38,8 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
proxyBypassFilterTip: proxyBypassFilterTip:
"Use ',' as a separator, and '*.' as a wildcard for subdomains", "Use ',' as a separator, and '*.' as a wildcard for subdomains",
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses', proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
validationDnsCacheMinTtl: 'You must provide a valid minimum TTL',
validationDnsCacheMaxTtl: 'You must provide a valid maximum TTL',
validationProxyPort: 'You must provide a valid port', validationProxyPort: 'You must provide a valid port',
networkDisclaimer: networkDisclaimer:
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.', 'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
@@ -64,6 +66,20 @@ const SettingsNetwork = () => {
} = useSWR<NetworkSettings>('/api/v1/settings/network'); } = useSWR<NetworkSettings>('/api/v1/settings/network');
const NetworkSettingsSchema = Yup.object().shape({ const NetworkSettingsSchema = Yup.object().shape({
dnsCacheForceMinTtl: Yup.number().when('dnsCacheEnabled', {
is: true,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationDnsCacheMinTtl))
.required(intl.formatMessage(messages.validationDnsCacheMinTtl))
.min(0),
}),
dnsCacheForceMaxTtl: Yup.number().when('dnsCacheEnabled', {
is: true,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationDnsCacheMaxTtl))
.required(intl.formatMessage(messages.validationDnsCacheMaxTtl))
.min(0),
}),
proxyPort: Yup.number().when('proxyEnabled', { proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled, is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required( then: Yup.number().required(
@@ -120,8 +136,8 @@ const SettingsNetwork = () => {
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
dnsCache: { dnsCache: {
enabled: values.dnsCacheEnabled, enabled: values.dnsCacheEnabled,
forceMinTtl: values.dnsCacheForceMinTtl, forceMinTtl: Number(values.dnsCacheForceMinTtl),
forceMaxTtl: values.dnsCacheForceMaxTtl, forceMaxTtl: Number(values.dnsCacheForceMaxTtl),
}, },
proxy: { proxy: {
enabled: values.proxyEnabled, enabled: values.proxyEnabled,
@@ -281,7 +297,7 @@ const SettingsNetwork = () => {
<Field <Field
id="dnsCacheForceMinTtl" id="dnsCacheForceMinTtl"
name="dnsCacheForceMinTtl" name="dnsCacheForceMinTtl"
type="text" type="number"
/> />
</div> </div>
{errors.dnsCacheForceMinTtl && {errors.dnsCacheForceMinTtl &&
@@ -305,7 +321,7 @@ const SettingsNetwork = () => {
<Field <Field
id="dnsCacheForceMaxTtl" id="dnsCacheForceMaxTtl"
name="dnsCacheForceMaxTtl" name="dnsCacheForceMaxTtl"
type="text" type="number"
/> />
</div> </div>
{errors.dnsCacheForceMaxTtl && {errors.dnsCacheForceMaxTtl &&
@@ -375,7 +391,7 @@ const SettingsNetwork = () => {
<Field <Field
id="proxyPort" id="proxyPort"
name="proxyPort" name="proxyPort"
type="text" type="number"
/> />
</div> </div>
{errors.proxyPort && {errors.proxyPort &&

View File

@@ -377,6 +377,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
webAppUrl: data?.webAppUrl, webAppUrl: data?.webAppUrl,
}} }}
validationSchema={PlexSettingsSchema} validationSchema={PlexSettingsSchema}
validateOnMount={true}
onSubmit={async (values) => { onSubmit={async (values) => {
let toastId: string | null = null; let toastId: string | null = null;
try { try {
@@ -423,6 +424,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
values, values,
handleSubmit, handleSubmit,
setFieldValue, setFieldValue,
setValues,
isSubmitting, isSubmitting,
isValid, isValid,
}) => { }) => {
@@ -445,9 +447,12 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
availablePresets[Number(e.target.value)]; availablePresets[Number(e.target.value)];
if (targPreset) { if (targPreset) {
setFieldValue('hostname', targPreset.address); setValues({
setFieldValue('port', targPreset.port); ...values,
setFieldValue('useSsl', targPreset.ssl); hostname: targPreset.address,
port: targPreset.port,
useSsl: targPreset.ssl,
});
} }
}} }}
> >

View File

@@ -28,7 +28,8 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
const response = await axios.post('/api/v1/auth/plex', { authToken }); const response = await axios.post('/api/v1/auth/plex', { authToken });
if (response.data?.id) { if (response.data?.id) {
revalidate(); const { data: user } = await axios.get('/api/v1/auth/me');
revalidate(user, false);
} }
}; };
if (authToken) { if (authToken) {

View File

@@ -139,7 +139,11 @@ const StatusBadge = ({
<div <div
className={` className={`
absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${ absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500' status === MediaStatus.DELETED
? 'bg-red-600'
: status === MediaStatus.PROCESSING
? 'bg-indigo-500'
: 'bg-green-500'
} transition-all duration-200 ease-in-out } transition-all duration-200 ease-in-out
`} `}
style={{ style={{
@@ -373,11 +377,66 @@ const StatusBadge = ({
case MediaStatus.DELETED: case MediaStatus.DELETED:
return ( return (
<Tooltip content={mediaLinkDescription}> <Tooltip
<Badge badgeType="danger"> content={inProgress ? tooltipContent : mediaLinkDescription}
{intl.formatMessage(is4k ? messages.status4k : messages.status, { className={`${
status: intl.formatMessage(globalMessages.deleted), inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block'
}`}
tooltipConfig={{
...(inProgress && { interactive: true, delayHide: 100 }),
}}
>
<Badge
badgeType="danger"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
<div
className={`relative z-20 flex items-center ${
inProgress && 'px-2'
}`}
>
<span>
{intl.formatMessage(
is4k ? messages.status4k : messages.status,
{
status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.deleted),
}
)}
</span>
{inProgress && (
<>
{mediaType === 'tv' &&
downloadItem[0].episode &&
(downloadItem.length > 1 &&
downloadItem.every(
(item) =>
item.downloadId &&
item.downloadId === downloadItem[0].downloadId
) ? (
<span className="ml-1">
{intl.formatMessage(messages.seasonnumber, {
seasonNumber: downloadItem[0].episode.seasonNumber,
})} })}
</span>
) : (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode.episodeNumber,
})}
</span>
))}
<Spinner className="ml-1 h-3 w-3" />
</>
)}
</div>
</Badge> </Badge>
</Tooltip> </Tooltip>
); );

View File

@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
// Deletes/disables corresponding push subscription from database // Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => { const disablePushNotifications = async (endpoint?: string) => {
try { try {
await unsubscribeToPushNotifications(user?.id, endpoint); const unsubscribedEndpoint = await unsubscribeToPushNotifications(
user?.id,
// Delete from backend if endpoint is available endpoint
if (subEndpoint) { );
await deletePushSubscriptionFromBackend(subEndpoint);
}
localStorage.setItem('pushNotificationsEnabled', 'false'); localStorage.setItem('pushNotificationsEnabled', 'false');
setWebPushEnabled(false); setWebPushEnabled(false);
// Only delete the current browser's subscription, not all devices
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
if (endpointToDelete) {
try {
await axios.delete(
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
endpointToDelete
)}`
);
} catch {
// Ignore deletion failures - backend cleanup is best effort
}
}
addToast(intl.formatMessage(messages.webpushhasbeendisabled), { addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
autoDismiss: true, autoDismiss: true,
appearance: 'success', appearance: 'success',
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
useEffect(() => { useEffect(() => {
const verifyWebPush = async () => { const verifyWebPush = async () => {
const enabled = await verifyPushSubscription(user?.id, currentSettings); const enabled = await verifyPushSubscription(user?.id, currentSettings);
setWebPushEnabled(enabled); let isEnabled = enabled;
if (!enabled && 'serviceWorker' in navigator) {
const { subscription } = await getPushSubscription();
if (subscription) {
isEnabled = true;
}
}
if (!isEnabled && dataDevices && dataDevices.length > 0) {
const currentUserAgent = navigator.userAgent;
const hasMatchingDevice = dataDevices.some(
(device) => device.userAgent === currentUserAgent
);
if (hasMatchingDevice) {
isEnabled = true;
}
}
setWebPushEnabled(isEnabled);
if (localStorage.getItem('pushNotificationsEnabled') === null) {
localStorage.setItem(
'pushNotificationsEnabled',
isEnabled ? 'true' : 'false'
);
}
}; };
if (user?.id) { if (user?.id) {

View File

@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
import type { PermissionCheckOptions } from '@server/lib/permissions'; import type { PermissionCheckOptions } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions'; import { hasPermission, Permission } from '@server/lib/permissions';
import type { NotificationAgentKey } from '@server/lib/settings'; import type { NotificationAgentKey } from '@server/lib/settings';
import { useRouter } from 'next/router';
import type { MutatorCallback } from 'swr'; import type { MutatorCallback } from 'swr';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -56,13 +57,21 @@ export const useUser = ({
id, id,
initialData, initialData,
}: { id?: number; initialData?: User } = {}): UserHookResponse => { }: { id?: number; initialData?: User } = {}): UserHookResponse => {
const router = useRouter();
const isAuthPage = /^\/(login|setup|resetpassword(?:\/|$))/.test(
router.pathname
);
const { const {
data, data,
error, error,
mutate: revalidate, mutate: revalidate,
} = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, { } = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, {
fallbackData: initialData, fallbackData: initialData,
refreshInterval: 30000, refreshInterval: !isAuthPage ? 30000 : 0,
revalidateOnFocus: !isAuthPage,
revalidateOnMount: !isAuthPage,
revalidateOnReconnect: !isAuthPage,
errorRetryInterval: 30000, errorRetryInterval: 30000,
shouldRetryOnError: false, shouldRetryOnError: false,
}); });

View File

@@ -1022,6 +1022,8 @@
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!", "components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support", "components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Seerr to correctly register client IP addresses behind a proxy", "components.Settings.SettingsNetwork.trustProxyTip": "Allow Seerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsNetwork.validationDnsCacheMaxTtl": "You must provide a valid maximum TTL",
"components.Settings.SettingsNetwork.validationDnsCacheMinTtl": "You must provide a valid minimum TTL",
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port", "components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.", "components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions", "components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",

View File

@@ -63,7 +63,7 @@ class PlexOAuth {
'X-Plex-Client-Identifier': clientId, 'X-Plex-Client-Identifier': clientId,
'X-Plex-Model': 'Plex OAuth', 'X-Plex-Model': 'Plex OAuth',
'X-Plex-Platform': browser.getBrowserName(), 'X-Plex-Platform': browser.getBrowserName(),
'X-Plex-Platform-Version': browser.getBrowserVersion(), 'X-Plex-Platform-Version': browser.getBrowserVersion() || 'Unknown',
'X-Plex-Device': browser.getOSName(), 'X-Plex-Device': browser.getOSName(),
'X-Plex-Device-Name': `${browser.getBrowserName()} (Seerr)`, 'X-Plex-Device-Name': `${browser.getBrowserName()} (Seerr)`,
'X-Plex-Device-Screen-Resolution': 'X-Plex-Device-Screen-Resolution':

View File

@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
currentSettings.vapidPublic currentSettings.vapidPublic
).toString(); ).toString();
if (currentServerKey !== expectedServerKey) {
return false;
}
const endpoint = subscription.endpoint; const endpoint = subscription.endpoint;
const { data } = await axios.get<UserPushSubscription>( const { data } = await axios.get<UserPushSubscription>(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}` `/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
); );
return expectedServerKey === currentServerKey && data.endpoint === endpoint; return data.endpoint === endpoint;
} catch { } catch {
return false; return false;
} }
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
userId: number | undefined, userId: number | undefined,
currentSettings: PublicSettingsResponse currentSettings: PublicSettingsResponse
): Promise<boolean> => { ): Promise<boolean> => {
if (!userId) {
return false;
}
const { subscription } = await getPushSubscription();
const isValid = await verifyPushSubscription(userId, currentSettings); const isValid = await verifyPushSubscription(userId, currentSettings);
if (isValid) { if (isValid) {
return true; return true;
} }
if (subscription) {
return false;
}
if (currentSettings.enablePushRegistration) { if (currentSettings.enablePushRegistration) {
try { try {
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint) const oldEndpoint = await unsubscribeToPushNotifications(userId);
await unsubscribeToPushNotifications(userId);
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
await subscribeToPushNotifications(userId, currentSettings); await subscribeToPushNotifications(userId, currentSettings);
if (oldEndpoint) {
try {
await axios.delete(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
oldEndpoint
)}`
);
} catch (error) {
// Ignore errors when deleting old endpoint (it might not exist)
}
}
return true; return true;
} catch (error) { } catch (error) {
throw new Error(`[SW] Resubscribe failed: ${error.message}`); throw new Error(`[SW] Resubscribe failed: ${error.message}`);
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
export const unsubscribeToPushNotifications = async ( export const unsubscribeToPushNotifications = async (
userId: number | undefined, userId: number | undefined,
endpoint?: string endpoint?: string
) => { ): Promise<string | null> => {
if (!('serviceWorker' in navigator) || !userId) { if (!('serviceWorker' in navigator) || !userId) {
return; return null;
} }
try { try {
const { subscription } = await getPushSubscription(); const { subscription } = await getPushSubscription();
if (!subscription) { if (!subscription) {
return false; return null;
} }
const { endpoint: currentEndpoint } = subscription.toJSON(); const { endpoint: currentEndpoint } = subscription.toJSON();
if (!endpoint || endpoint === currentEndpoint) { if (!endpoint || endpoint === currentEndpoint) {
await subscription.unsubscribe(); await subscription.unsubscribe();
return true; return currentEndpoint ?? null;
} }
return null;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Issue unsubscribing to push notifications: ${error.message}` `Issue unsubscribing to push notifications: ${error.message}`

View File

@@ -5,7 +5,7 @@ const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = { module.exports = {
mode: 'jit', mode: 'jit',
content: [ content: [
'./node_modules/react-tailwindcss-datepicker-sct/dist/index.esm.js', './node_modules/@seerr-team/react-tailwindcss-datepicker/dist/index.esm.js',
'./src/pages/**/*.{ts,tsx}', './src/pages/**/*.{ts,tsx}',
'./src/components/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}',
], ],