Compare commits

..

36 Commits

Author SHA1 Message Date
0xsysr3ll
ed3a9b1c8d fix(availability-sync): added some diagnostic logging
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-20 23:10:24 +01:00
0xsysr3ll
8431ef3f3b fix(availability-sync): remove incorrect existence flags for media in Radarr, Sonarr, and Plex
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-20 20:07:16 +01: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
fallenbagel
f4fe16608a fix(jellyfin-api): use standard Authorization header (#2211)
Replace X-Emby-Authorization with Authorization header to fix authentication failures when users
have <EnableLegacyAuthorization>false</EnableLegacyAuthorization> in their Jellyfin system.xml.
2025-12-08 15:46:47 +01:00
Ludovic Ortega
d660a540da chore(helm): prepare for release (#2189)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-07 17:22:28 +01:00
Ludovic Ortega
48ef2984e5 docs: fix chown command for windows users (#2192)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-03 14:39:03 +01:00
Disparate2761
c5fc31c352 docs(buildfromsource): touch up path inconsistencies (#2184) 2025-12-01 14:57:01 +01:00
Ludovic Ortega
c3b9ea6ce4 chore: improve PR template (#2175) 2025-11-28 13:05:47 +01:00
Ludovic Ortega
b66b36186a docs: update weblate links (#2168)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-11-22 23:29:35 +01:00
Ludovic Ortega
fb5196bdec chore: remove CHANGELOG.md (#2169) 2025-11-22 23:05:42 +01:00
0xsysr3ll
bde322de8e fix(override-rules): show correct genres for both *arr services (#2155) 2025-11-21 22:24:14 +01:00
58 changed files with 3682 additions and 4181 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

@@ -1,14 +1,33 @@
#### Description <!--
Please read contributing guide before submitting
your pull request. Please fill in each section below to help us better prioritize your pull request. Thanks!
-->
#### Screenshot (if UI-related) ## Description
#### To-Dos <!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
- Fixes #XXXX
## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
## Screenshots / Logs (if applicable)
## Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] I have read and followed the contribution [guidelines](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md).
- [ ] Disclosed any use of AI (see our [policy](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice)) - [ ] Disclosed any use of AI (see our [policy](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice))
- [ ] I have updated the documentation accordingly.
- [ ] All new and existing tests passed.
- [ ] Successful build `pnpm build` - [ ] Successful build `pnpm build`
- [ ] Translation keys `pnpm i18n:extract` - [ ] Translation keys `pnpm i18n:extract`
- [ ] Database migration (if required) - [ ] Database migration (if required)
#### Issues Fixed or Closed
- Fixes #XXXX

View File

@@ -2,7 +2,6 @@
.next/ .next/
dist/ dist/
config/ config/
CHANGELOG.md
pnpm-lock.yaml pnpm-lock.yaml
cypress/config/settings.cypress.json cypress/config/settings.cypress.json

File diff suppressed because it is too large Load Diff

View File

@@ -151,9 +151,9 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation ## Translation
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose). We use [Weblate](https://translate.seerr.dev/projects/seerr/seerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose).
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a> <a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/multi-auto.svg" alt="Translation status" /></a>
## Migrations ## Migrations

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="http://translate.seerr.dev/engage/seerr/"><img src="http://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

@@ -5,7 +5,7 @@ description: Seerr helm chart for Kubernetes
type: application type: application
version: 3.0.0 version: 3.0.0
# renovate: image=ghcr.io/seerr-team/seerr # renovate: image=ghcr.io/seerr-team/seerr
appVersion: '2.7.3' appVersion: '3.0.0'
maintainers: maintainers:
- name: Seerr Team - name: Seerr Team
url: https://github.com/orgs/seerr-team/people url: https://github.com/orgs/seerr-team/people

View File

@@ -1,6 +1,6 @@
# seerr-chart # seerr-chart
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.7.3](https://img.shields.io/badge/AppVersion-2.7.3-informational?style=flat-square) ![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square)
Seerr helm chart for Kubernetes Seerr helm chart for Kubernetes
@@ -22,7 +22,7 @@ Kubernetes: `>=1.23.0-0`
## Installation ## Installation
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation) Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
## Update Notes ## Update Notes

View File

@@ -16,7 +16,7 @@
## Installation ## Installation
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation) Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
## Update Notes ## Update Notes

View File

@@ -26,8 +26,7 @@ sudo mkdir -p /opt/seerr && cd /opt/seerr
``` ```
2. Clone the Seerr repository and checkout the main branch: 2. Clone the Seerr repository and checkout the main branch:
```bash ```bash
git clone https://github.com/seerr-team/seerr.git git clone https://github.com/seerr-team/seerr.git .
cd seerr
git checkout main git checkout main
``` ```
3. Install the dependencies: 3. Install the dependencies:

View File

@@ -28,7 +28,7 @@ Changes :
If you're migrating from a previous installation, you may need to update the ownership of your config folder: If you're migrating from a previous installation, you may need to update the ownership of your config folder:
```bash ```bash
sudo chown -R 1000:1000 /path/to/appdata/config docker run --rm -v /path/to/appdata/config:/data alpine chown -R 1000:1000 /data
``` ```
This ensures the `node` user (UID 1000) owns the config directory and can read and write to it. This ensures the `node` user (UID 1000) owns the config directory and can read and write to it.

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,38 @@
}, },
"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",
"@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,19 +76,19 @@
"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-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1", "react-toast-notifications": "2.5.1",
@@ -97,19 +97,19 @@
"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"
] ]
} }
} }

4120
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7755,32 +7755,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/OverrideRule' $ref: '#/components/schemas/OverrideRule'
/overrideRule/advancedRequest:
post:
summary: Advanced override rule request
description: Processes an advanced override rule request.
tags:
- overriderule
responses:
'200':
description: Advanced override rule request processed
content:
application/json:
schema:
type: object
properties:
rootFolder:
type: string
nullable: true
profileId:
type: number
nullable: true
tags:
type: array
items:
type: number
nullable: true
security: security:
- cookieAuth: [] - cookieAuth: []
- apiKey: [] - apiKey: []

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;
@@ -145,7 +149,7 @@ class JellyfinAPI extends ExternalAPI {
{}, {},
{ {
headers: { headers: {
'X-Emby-Authorization': authHeaderVal, Authorization: authHeaderVal,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}, },
@@ -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

@@ -1,13 +1,15 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
MediaType, MediaType,
} from '@server/constants/media'; } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import overrideRules from '@server/lib/overrideRules';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
@@ -209,20 +211,121 @@ export class MediaRequest {
let tags = requestBody.tags; let tags = requestBody.tags;
if (useOverrides) { if (useOverrides) {
const overrideRulesResult = await overrideRules({ const defaultRadarrId = requestBody.is4k
mediaType: requestBody.mediaType, ? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
is4k: requestBody.is4k || false, : settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
tmdbMedia, const defaultSonarrId = requestBody.is4k
requestUser, ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where:
requestBody.mediaType === MediaType.MOVIE
? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId },
}); });
if (overrideRulesResult.rootFolder) {
rootFolder = overrideRulesResult.rootFolder; const appliedOverrideRules = overrideRules.filter((rule) => {
} const hasAnimeKeyword =
if (overrideRulesResult.profileId) { 'results' in tmdbMedia.keywords &&
profileId = overrideRulesResult.profileId; tmdbMedia.keywords.results.some(
} (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
if (overrideRulesResult.tags) { );
tags = overrideRulesResult.tags;
// Skip override rules if the media is an anime TV show as anime TV
// is handled by default and override rules do not explicitly include
// the anime keyword
if (
requestBody.mediaType === MediaType.TV &&
hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
) {
return false;
}
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === requestUser.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
tmdbMedia.genres
.map((genre) => genre.id)
.includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === tmdbMedia.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords.split(',').some((keywordId) => {
let keywordList: TmdbKeyword[] = [];
if ('keywords' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.keywords;
} else if ('results' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.results;
}
return keywordList
.map((keyword: TmdbKeyword) => keyword.id)
.includes(Number(keywordId));
})
) {
return false;
}
return true;
});
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
// Take the rule with the most specific condition first
return bSpecificity - aSpecificity;
})[0];
if (prioritizedRule) {
if (prioritizedRule.rootFolder) {
rootFolder = prioritizedRule.rootFolder;
}
if (prioritizedRule.profileId) {
profileId = prioritizedRule.profileId;
}
if (prioritizedRule.tags) {
tags = [
...new Set([
...(tags || []),
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
});
} }
} }

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

@@ -143,7 +143,9 @@ class AvailabilitySync {
const { existsInPlex: existsInPlex4k } = const { existsInPlex: existsInPlex4k } =
await this.mediaExistsInPlex(media, true); await this.mediaExistsInPlex(media, true);
if (existsInPlex || existsInRadarr) { // Media must exist in Plex to be considered available
// If it exists in Radarr but not in Plex, it should be marked as deleted
if (existsInPlex) {
movieExists = true; movieExists = true;
logger.info( logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -153,7 +155,7 @@ class AvailabilitySync {
); );
} }
if (existsInPlex4k || existsInRadarr4k) { if (existsInPlex4k) {
movieExists4k = true; movieExists4k = true;
logger.info( logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -240,7 +242,9 @@ class AvailabilitySync {
//plex //plex
if (mediaServerType === MediaServerType.PLEX) { if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex || existsInSonarr) { // Media must exist in Plex to be considered available
// If it exists in Sonarr but not in Plex, it should be marked as deleted
if (existsInPlex) {
showExists = true; showExists = true;
logger.info( logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -252,7 +256,7 @@ class AvailabilitySync {
} }
if (mediaServerType === MediaServerType.PLEX) { if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex4k || existsInSonarr4k) { if (existsInPlex4k) {
showExists4k = true; showExists4k = true;
logger.info( logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -300,7 +304,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 +314,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 +325,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 (
@@ -665,6 +615,21 @@ class AvailabilitySync {
is4k: boolean is4k: boolean
): Promise<boolean> { ): Promise<boolean> {
let existsInRadarr = false; let existsInRadarr = false;
const externalServiceId = is4k
? media.externalServiceId4k
: media.externalServiceId;
if (!externalServiceId) {
logger.debug(
`Skipping Radarr check for ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] - no externalServiceId available`,
{
label: 'Availability Sync',
}
);
return false;
}
// 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
@@ -691,22 +656,63 @@ class AvailabilitySync {
}); });
} }
if (radarr && radarr.hasFile) { if (radarr) {
const resolution = if (radarr.hasFile) {
radarr?.movieFile?.mediaInfo?.resolution?.split('x'); const resolution =
const is4kMovie = radarr?.movieFile?.mediaInfo?.resolution?.split('x');
resolution?.length === 2 && Number(resolution[0]) >= 2000; const is4kMovie =
existsInRadarr = is4k ? is4kMovie : !is4kMovie; resolution?.length === 2 && Number(resolution[0]) >= 2000;
const matches4k = is4k ? is4kMovie : !is4kMovie;
if (matches4k) {
existsInRadarr = true;
logger.debug(
`Found ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] in Radarr`,
{
radarrId: radarr.id,
radarrTitle: radarr.title,
hasFile: radarr.hasFile,
externalServiceId: externalServiceId,
label: 'Availability Sync',
}
);
} else {
logger.debug(
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but resolution doesn't match (is4k: ${is4k}, movie resolution: ${radarr?.movieFile?.mediaInfo?.resolution})`,
{
label: 'Availability Sync',
}
);
}
} else {
logger.debug(
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but has no file`,
{
radarrId: radarr.id,
radarrTitle: radarr.title,
externalServiceId: externalServiceId,
label: 'Availability Sync',
}
);
}
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404')) { if (ex.message.includes('404')) {
existsInRadarr = true; logger.debug(
`Movie [TMDB ID ${media.tmdbId}] not found in Radarr (404) - externalServiceId may be stale: ${externalServiceId}`,
{
label: 'Availability Sync',
}
);
} else {
logger.debug( logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${ `Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId media.tmdbId
}] from Radarr.`, }] from Radarr.`,
{ {
errorMessage: ex.message, errorMessage: ex.message,
externalServiceId: externalServiceId,
label: 'Availability Sync', label: 'Availability Sync',
} }
); );
@@ -754,7 +760,6 @@ class AvailabilitySync {
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404')) { if (!ex.message.includes('404')) {
existsInSonarr = true;
preventSeasonSearch = true; preventSeasonSearch = true;
logger.debug( logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${ `Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
@@ -853,31 +858,66 @@ class AvailabilitySync {
// We can use the cache we built when we fetched the series with mediaExistsInPlex // We can use the cache we built when we fetched the series with mediaExistsInPlex
try { try {
let plexMedia: PlexMetadata | undefined; let plexMedia: PlexMetadata | undefined;
const currentRatingKey = is4k ? ratingKey4k : ratingKey;
if (ratingKey && !is4k) { if (!currentRatingKey) {
plexMedia = await this.plexClient?.getMetadata(ratingKey); logger.debug(
`Skipping Plex check for ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] - no ratingKey available`,
{
label: 'Availability Sync',
}
);
} else {
if (ratingKey && !is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey);
if (media.mediaType === 'tv') { if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey] = this.plexSeasonsCache[ratingKey] =
await this.plexClient?.getChildrenMetadata(ratingKey); await this.plexClient?.getChildrenMetadata(ratingKey);
}
} }
}
if (ratingKey4k && is4k) { if (ratingKey4k && is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k); plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (media.mediaType === 'tv') { if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] = this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k); await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
} }
}
if (plexMedia) { if (plexMedia) {
existsInPlex = true; existsInPlex = true;
logger.debug(
`Found ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] in Plex`,
{
ratingKey: is4k ? ratingKey4k : ratingKey,
plexTitle: plexMedia.title,
plexRatingKey: plexMedia.ratingKey,
plexGuid: plexMedia.guid,
label: 'Availability Sync',
}
);
}
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404')) { if (ex.message.includes('404')) {
existsInPlex = true; logger.debug(
`Media ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${
media.tmdbId
}] not found in Plex (404) - ratingKey may be stale`,
{
ratingKey: is4k ? ratingKey4k : ratingKey,
label: 'Availability Sync',
}
);
} else {
preventSeasonSearch = true; preventSeasonSearch = true;
logger.debug( logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
@@ -993,8 +1033,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,7 +45,17 @@ class PushoverAgent
} }
public shouldSend(): boolean { public shouldSend(): boolean {
return true; const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
} }
private async getImagePayload( private async getImagePayload(

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

@@ -1,152 +0,0 @@
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbMovieDetails,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { User } from '@server/entity/User';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
export type OverrideRulesResult = {
rootFolder: string | null;
profileId: number | null;
tags: number[] | null;
};
async function overrideRules({
mediaType,
is4k,
tmdbMedia,
requestUser,
}: {
mediaType: MediaType;
is4k: boolean;
tmdbMedia: TmdbMovieDetails | TmdbTvDetails;
requestUser: User;
}): Promise<OverrideRulesResult> {
const settings = getSettings();
let rootFolder: string | null = null;
let profileId: number | null = null;
let tags: number[] | null = null;
const defaultRadarrId = is4k
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
const defaultSonarrId = is4k
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where:
mediaType === MediaType.MOVIE
? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
const hasAnimeKeyword =
'results' in tmdbMedia.keywords &&
tmdbMedia.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
);
// Skip override rules if the media is an anime TV show as anime TV
// is handled by default and override rules do not explicitly include
// the anime keyword
if (
mediaType === MediaType.TV &&
hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
) {
return false;
}
if (
rule.users &&
!rule.users.split(',').some((userId) => Number(userId) === requestUser.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
tmdbMedia.genres.map((genre) => genre.id).includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === tmdbMedia.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords.split(',').some((keywordId) => {
let keywordList: TmdbKeyword[] = [];
if ('keywords' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.keywords;
} else if ('results' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.results;
}
return keywordList
.map((keyword: TmdbKeyword) => keyword.id)
.includes(Number(keywordId));
})
) {
return false;
}
return true;
});
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
// Take the rule with the most specific condition first
return bSpecificity - aSpecificity;
})[0];
if (prioritizedRule) {
if (prioritizedRule.rootFolder) {
rootFolder = prioritizedRule.rootFolder;
}
if (prioritizedRule.profileId) {
profileId = prioritizedRule.profileId;
}
if (prioritizedRule.tags) {
tags = [
...new Set([
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
});
}
return { rootFolder, profileId, tags };
}
export default overrideRules;

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,132 +11,119 @@ 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;
metadata: JellyfinLibraryItemExtended;
} | null> {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const existing = await mediaRepository.findOne({ if (!metadata?.Id) {
where: { tmdbId: tmdbId, mediaType }, this.log('No Id metadata for this title. Skipping', 'debug', {
}); jellyfinItemId: jellyfinitem.Id,
});
return null;
}
return existing; const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
let imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else
if (anidbId && !imdbId && !tmdbId) {
const result = animeList.getFromAnidbId(anidbId);
tmdbId = Number(result?.tmdbId ?? null);
imdbId = result?.imdbId;
}
if (imdbId && !tmdbId) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: imdbId,
});
tmdbId = tmdbMovie.id;
}
if (!tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// With AniDB we can have mixed libraries with movies in a "show" library
// We take the first episode of the first season (the movie) and use it to
// get more information, like the MediaSource
if (anidbId && metadata.Type === 'Series') {
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
(md) => {
return md.IndexNumber === 1;
}
);
if (!season) {
this.log('No season found for anidb movie', 'debug', {
jellyfinitem,
});
return null;
}
const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id,
season.Id
);
if (!episodes[0]) {
this.log('No episode found for anidb movie', 'debug', {
jellyfinitem,
});
return null;
}
metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem,
});
return null;
}
}
return { tmdbId, imdbId, metadata };
} }
private async processMovie(jellyfinitem: JellyfinLibraryItem) { private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
try { try {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id); const extracted = await this.extractMovieIds(jellyfinitem);
const newMedia = new Media(); if (!extracted) return;
if (!metadata?.Id) { const { tmdbId, imdbId, metadata } = extracted;
logger.debug('No Id metadata for this title. Skipping', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id,
});
return;
}
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) {
const result = animeList.getFromAnidbId(anidbId);
newMedia.tmdbId = Number(result?.tmdbId ?? null);
newMedia.imdbId = result?.imdbId;
}
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId,
});
newMedia.tmdbId = tmdbMovie.id;
}
if (!newMedia.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// With AniDB we can have mixed libraries with movies in a "show" library
// We take the first episode of the first season (the movie) and use it to
// get more information, like the MediaSource
if (anidbId && metadata.Type === 'Series') {
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
(md) => {
return md.IndexNumber === 1;
}
);
if (!season) {
this.log('No season found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id,
season.Id
);
if (!episodes[0]) {
this.log('No episode found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
}
const has4k = metadata.MediaSources?.some((MediaSource) => { const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter( return MediaSource.MediaStreams.filter(
@@ -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,
imdbId,
title: metadata.Name,
});
}
if (existing) { if (has4k && this.enable4kMovie) {
let changedExisting = false; await this.processMovie(tmdbId, {
is4k: true,
if ( mediaAddedAt,
(hasOtherResolution || (!this.enable4kMovie && has4k)) && jellyfinMediaId: metadata.Id,
existing.status !== MediaStatus.AVAILABLE imdbId,
) { title: metadata.Name,
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}`);
}
});
} 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,71 +269,49 @@ 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 () => { const seasons = tvShow.seasons;
if (!tvShow) { const jellyfinSeasons = await this.jfClient.getSeasons(Id);
// 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 processableSeasons: ProcessableSeason[] = [];
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
const newSeasons: Season[] = []; const settings = getSettings();
const filteredSeasons = settings.main.enableSpecialEpisodes
? seasons
: seasons.filter((sn) => sn.season_number !== 0);
const currentStandardSeasonAvailable = ( for (const season of filteredSeasons) {
media?.seasons.filter( const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
(season) => season.status === MediaStatus.AVAILABLE if (tvdbSeasonFromAnidb) {
) ?? [] // In AniDB we don't have the concept of seasons,
).length; // we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
const current4kSeasonAvailable = ( // We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
media?.seasons.filter( // md.IndexNumber === 1 to be sure to find the correct season on jellyfin
(season) => season.status4k === MediaStatus.AVAILABLE return (
) ?? [] tvdbSeasonFromAnidb === season.season_number &&
).length; md.IndexNumber === 1
);
} else {
return Number(md.IndexNumber) === season.season_number;
}
});
for (const season of seasons) { // Check if we found the matching season and it has all the available episodes
const JellyfinSeasons = await this.jfClient.getSeasons(Id); if (matchedJellyfinSeason) {
const matchedJellyfinSeason = JellyfinSeasons.find((md) => { let totalStandard = 0;
if (tvdbSeasonFromAnidb) { let total4k = 0;
// 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 use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
return (
tvdbSeasonFromAnidb === season.season_number &&
md.IndexNumber === 1
);
} else {
return Number(md.IndexNumber) === season.season_number;
}
});
const existingSeason = media?.seasons.find( if (!this.enable4kShow) {
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes
if (matchedJellyfinSeason) {
// If we have a matched Jellyfin season, get its children metadata so we can check details
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;
});
} }
}
// With AniDB we can have multiple shows for one season, so we need to save const has4k = episode.MediaSources?.some((MediaSource) =>
// the episode from all the jellyfin entries to get the total MediaSource.MediaStreams.some(
if (tvdbSeasonFromAnidb) { (MediaStream) =>
if (this.processedAnidbSeason.has(tvShow.id)) { MediaStream.Type === 'Video' &&
const show = this.processedAnidbSeason.get(tvShow.id)!; (MediaStream.Width ?? 0) > 2000
if (show.has(season.season_number)) { )
show.set(
season.season_number,
show.get(season.season_number)! + totalStandard
);
totalStandard = show.get(season.season_number)!;
} else {
show.set(season.season_number, totalStandard);
}
} else {
this.processedAnidbSeason.set(
tvShow.id,
new Map([[season.season_number, totalStandard]])
);
}
}
if (
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,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
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,
})
); );
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;
} }
} }
}
// Remove extras season. We dont count it for determining availability // With AniDB we can have multiple shows for one season, so we need to save
const filteredSeasons = tvShow.seasons.filter( // the episode from all the jellyfin entries to get the total
(season) => season.season_number !== 0 if (tvdbSeasonFromAnidb) {
); let show = this.processedAnidbSeason.get(tvShow.id);
const isAllStandardSeasons = if (!show) {
newSeasons.filter( show = new Map([[season.season_number, totalStandard]]);
(season) => season.status === MediaStatus.AVAILABLE this.processedAnidbSeason.set(tvShow.id, show);
).length + } else {
(media?.seasons.filter( const currentCount = show.get(season.season_number) ?? 0;
(season) => season.status === MediaStatus.AVAILABLE const newCount = currentCount + totalStandard;
).length ?? 0) >= show.set(season.season_number, newCount);
filteredSeasons.length; totalStandard = newCount;
}
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) { processableSeasons.push({
this.log( seasonNumber: season.season_number,
`Detected ${ totalEpisodes: season.episode_count,
new4kSeasonAvailable - current4kSeasonAvailable episodes: totalStandard,
} new 4K season(s) for ${tvShow.name}`, episodes4k: total4k,
'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(); if (item.Type === 'Movie') {
await Promise.all( await this.processJellyfinMovie(item);
slicedItems.map(async (item) => { } else if (item.Type === 'Series') {
if (item.Type === 'Movie') { await this.processJellyfinShow(item);
await this.processMovie(item);
} else if (item.Type === 'Series') {
await this.processShow(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

@@ -1,12 +1,6 @@
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule'; import OverrideRule from '@server/entity/OverrideRule';
import { User } from '@server/entity/User';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import overrideRules, {
type OverrideRulesResult,
} from '@server/lib/overrideRules';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express'; import { Router } from 'express';
@@ -67,40 +61,6 @@ overrideRuleRoutes.post<
} }
}); });
overrideRuleRoutes.post(
'/advancedRequest',
isAuthenticated(Permission.REQUEST_ADVANCED),
async (req, res, next) => {
try {
const tmdb = new TheMovieDb();
const tmdbMedia =
req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.tmdbId })
: await tmdb.getTvShow({ tvId: req.body.tmdbId });
const userRepository = getRepository(User);
const user = await userRepository.findOne({
where: { id: req.body.requestUser },
relations: { requests: true },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
const overrideRulesResult: OverrideRulesResult = await overrideRules({
mediaType: req.body.mediaType,
is4k: req.body.is4k,
tmdbMedia,
requestUser: user,
});
res.status(200).json(overrideRulesResult);
} catch {
next({ status: 404, message: 'Media not found' });
}
}
);
overrideRuleRoutes.put< overrideRuleRoutes.put<
{ ruleId: string }, { ruleId: string },
OverrideRule, OverrideRule,

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,30 +189,82 @@ 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
relations: { user: true }, const existingSubscription = await transactionalRepo.findOne({
where: { auth: req.body.auth, user: { id: req.user?.id } }, relations: { user: true },
}); 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) {
logger.debug( // If endpoint matches but auth is different, update with new keys (iOS refresh case)
'User push subscription already exists. Skipping registration.', if (
{ label: 'API' } existingSubscription.endpoint === req.body.endpoint &&
); existingSubscription.auth !== req.body.auth
return res.status(204).send(); ) {
} existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;
const userPushSubscription = new UserPushSubscription({ await transactionalRepo.save(existingSubscription);
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
userPushSubRepository.save(userPushSubscription); logger.debug(
'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' }
);
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({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
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

@@ -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,26 +242,30 @@ 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(
<Tooltip (status, index) => (
key={`dl-status-${status.externalId}-${index}`} <Tooltip
content={status.title} key={`dl-status-${status.externalId}-${index}`}
> content={status.title}
<li className="border-b border-gray-700 last:border-b-0"> >
<DownloadBlock downloadItem={status} /> <li className="border-b border-gray-700 last:border-b-0">
</li> <DownloadBlock downloadItem={status} />
</Tooltip> </li>
))} </Tooltip>
{data.mediaInfo?.downloadStatus4k?.map((status, index) => ( )
<Tooltip )}
key={`dl-status-${status.externalId}-${index}`} {filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
content={status.title} (status, index) => (
> <Tooltip
<li className="border-b border-gray-700 last:border-b-0"> key={`dl-status-4k-${status.externalId}-${index}`}
<DownloadBlock downloadItem={status} is4k /> content={status.title}
</li> >
</Tooltip> <li className="border-b border-gray-700 last:border-b-0">
))} <DownloadBlock downloadItem={status} is4k />
</li>
</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

@@ -13,9 +13,7 @@ import type {
ServiceCommonServerWithDetails, ServiceCommonServerWithDetails,
} from '@server/interfaces/api/serviceInterfaces'; } from '@server/interfaces/api/serviceInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type { OverrideRulesResult } from '@server/lib/overrideRules';
import { hasPermission } from '@server/lib/permissions'; import { hasPermission } from '@server/lib/permissions';
import axios from 'axios';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -53,7 +51,6 @@ export type RequestOverrides = {
interface AdvancedRequesterProps { interface AdvancedRequesterProps {
type: 'movie' | 'tv'; type: 'movie' | 'tv';
tmdbId?: number;
is4k: boolean; is4k: boolean;
isAnime?: boolean; isAnime?: boolean;
defaultOverrides?: RequestOverrides; defaultOverrides?: RequestOverrides;
@@ -63,7 +60,6 @@ interface AdvancedRequesterProps {
const AdvancedRequester = ({ const AdvancedRequester = ({
type, type,
tmdbId,
is4k = false, is4k = false,
isAnime = false, isAnime = false,
defaultOverrides, defaultOverrides,
@@ -288,35 +284,6 @@ const AdvancedRequester = ({
selectedTags, selectedTags,
]); ]);
useEffect(() => {
(async () => {
if (tmdbId) {
try {
const { data: override } = await axios.post<OverrideRulesResult>(
'/api/v1/overrideRule/advancedRequest',
{
mediaType: type,
is4k,
requestUser: requestUser?.id ?? currentUser?.id,
tmdbId,
}
);
if (override.rootFolder) {
setSelectedFolder(override.rootFolder);
}
if (override.profileId) {
setSelectedProfile(override.profileId);
}
if (override.tags) {
setSelectedTags(override.tags);
}
} catch {
/* empty */
}
}
})();
}, [serverData, is4k, requestUser]);
if (!data && !error) { if (!data && !error) {
return ( return (
<div className="mb-2 w-full"> <div className="mb-2 w-full">

View File

@@ -288,7 +288,6 @@ const MovieRequestModal = ({
hasPermission(Permission.MANAGE_REQUESTS)) && ( hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester <AdvancedRequester
type="movie" type="movie"
tmdbId={tmdbId}
is4k={is4k} is4k={is4k}
requestUser={editRequest.requestedBy} requestUser={editRequest.requestedBy}
defaultOverrides={{ defaultOverrides={{
@@ -358,7 +357,6 @@ const MovieRequestModal = ({
{(hasPermission(Permission.REQUEST_ADVANCED) || {(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && ( hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester <AdvancedRequester
tmdbId={tmdbId}
type="movie" type="movie"
is4k={is4k} is4k={is4k}
onChange={(overrides) => { onChange={(overrides) => {

View File

@@ -722,7 +722,6 @@ const TvRequestModal = ({
hasPermission(Permission.MANAGE_REQUESTS)) && ( hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester <AdvancedRequester
type="tv" type="tv"
tmdbId={tmdbId}
is4k={is4k} is4k={is4k}
isAnime={data?.keywords.some( isAnime={data?.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID (keyword) => keyword.id === ANIME_KEYWORD_ID

View File

@@ -12,7 +12,6 @@ import type {
TmdbGenre, TmdbGenre,
TmdbKeywordSearchResponse, TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type { import type {
Keyword, Keyword,
@@ -185,9 +184,7 @@ export const GenreSelector = ({
}, [defaultValue, type]); }, [defaultValue, type]);
const loadGenreOptions = async (inputValue: string) => { const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>( const results = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
`/api/v1/discover/genreslider/${type}`
);
return results.data return results.data
.map((result) => ({ .map((result) => ({
@@ -201,7 +198,7 @@ export const GenreSelector = ({
return ( return (
<AsyncSelect <AsyncSelect
key={`genre-select-${defaultDataValue}`} key={`genre-select-${type}-${defaultDataValue}`}
className="react-select-container" className="react-select-container"
classNamePrefix="react-select" classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]} defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}

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

@@ -337,7 +337,13 @@ const OverrideRuleModal = ({
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <div className="form-input-field">
<GenreSelector <GenreSelector
type={values.radarrServiceId ? 'movie' : 'tv'} type={
values.radarrServiceId != null
? 'movie'
: values.sonarrServiceId != null
? 'tv'
: 'tv'
}
defaultValue={values.genre} defaultValue={values.genre}
isMulti isMulti
isDisabled={!isValidated || isTesting} isDisabled={!isValidated || isTesting}

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}`