Compare commits

..

29 Commits

Author SHA1 Message Date
0xsysr3ll
275d6aaf08 fix(webpush): rework web push notification status verification logic
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-09 00:04:28 +01:00
0xsysr3ll
9d41ecfecc fix(webpush): ensure the old endpoint is cleared only when necessary
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 23:51:41 +01:00
0xsysr3ll
3e0e02a7ea feat(push-subscription): add unique constraint on endpoint and userId
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 23:50:52 +01:00
0xsysr3ll
002f4aeadd fix(webpush): only remove the current browser's subscription
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
9f97ab1d60 fix(webpush): remove error throw
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
a47b8db48f fix(webpush): ensure the local storage reflects the correct notification status
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
bd52d1fa9d refactor(webpush): remove redundant checks
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
07938a6fe9 refactor(webpush): remove redundant try-catch
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
9180d178ba fix(webpush): throw error after notification failure
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
94219195e6 fix(webpush): notification must reflect the actual outcome
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
b41a0b3b95 fix(webpush): remove backend checks
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
f606a64684 fix(webpush): delete push subscriptions for multiple devices
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
3886e649f9 fix(webpush): remove redundant backend subscription checks
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
b6373498c3 fix(webpush): remove unnecessary dependency for user ID verification
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
8f1b81becc fix(webpush): update localStorage handling for push notification status
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
44b34a0081 fix(webpush): update existing subscriptions with new keys only if the endpoint matches and the auth differs
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
194e33a19a fix(webpush): remove the redundant userId check
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
2447c385f4 fix(webpush): add user ID validation to push subscription verification
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
432e970de4 refactor(webpush): Remove nested error checks
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
13edfe36a6 fix(webpush): add backend subscription check to determine if a valid push subscription exists.
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
4dbb7cdf2d fix(webpush): store push notification status in localStorage
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
bde07e02c1 fix(webpush): use transaction for race condition prevention
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
8c1ce8565d fix(webpush): preserve original creation timestamp when updating subscriptions
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
13c0f33c0a fix(webpush): cleanup is too agressive - avoid removing active subscriptions
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
4e9264a31d fix(webpush): clean up stale push subscriptions for the same device
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
3a9f6cd669 fix(webpush): update existing subscriptions with new keys
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
f1f7d6af3a fix(webpush): add logs for AggregateError error
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
caa1716374 fix(webpush): improve push notification error handling
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
0xsysr3ll
036c006aab fix(webpush): improve iOS push subscription endpoint cleanup
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-08 22:10:04 +01:00
36 changed files with 2641 additions and 3311 deletions

View File

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

View File

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

View File

@@ -174,36 +174,4 @@ 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.24.0", "packageManager": "pnpm@10.17.1",
"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.16.6", "@dr.pogodin/csurf": "^1.14.1",
"@formatjs/intl-displaynames": "6.8.13", "@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.1.1", "@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.4.6", "@formatjs/intl-pluralrules": "5.1.10",
"@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.2.0", "@heroicons/react": "2.0.16",
"@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.56", "@tanem/react-nprogress": "5.0.30",
"@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.43.4", "ace-builds": "1.15.2",
"axios": "1.13.2", "axios": "1.10.0",
"axios-rate-limit": "1.4.0", "axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.13.1", "bowser": "2.11.0",
"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.6.4", "country-flag-icons": "1.5.5",
"cronstrue": "2.23.0", "cronstrue": "2.23.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"dayjs": "1.11.19", "dayjs": "1.11.7",
"dns-caching": "^0.2.7", "dns-caching": "^0.2.7",
"email-templates": "12.0.3", "email-templates": "12.0.1",
"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.18.2", "express-session": "1.17.3",
"formik": "^2.4.9", "formik": "^2.4.6",
"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.16.3", "pg": "8.11.0",
"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.44.0", "react-aria": "3.23.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.10.2", "react-select": "5.7.0",
"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.3", "semver": "7.7.1",
"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.3.7", "swr": "2.2.5",
"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.16.0", "undici": "^7.3.0",
"validator": "^13.15.23", "validator": "^13.15.15",
"web-push": "3.6.7", "web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0", "wink-jaro-distance": "^2.0.0",
"winston": "3.18.3", "winston": "3.8.2",
"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,33 +123,32 @@
"@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.10", "@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.2", "@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.5", "@types/csurf": "1.11.2",
"@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.18.2", "@types/express-session": "1.17.6",
"@types/lodash": "4.17.21", "@types/lodash": "4.14.191",
"@types/mime": "3", "@types/mime": "3",
"@types/node": "22.10.5", "@types/node": "22.10.5",
"@types/node-schedule": "2.1.8", "@types/node-schedule": "2.1.0",
"@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.12", "@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1", "@types/secure-random-password": "0.2.1",
"@types/semver": "7.7.1", "@types/semver": "7.3.13",
"@types/swagger-ui-express": "4.1.8", "@types/swagger-ui-express": "4.1.3",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.3",
"@types/web-push": "3.6.4", "@types/web-push": "3.3.2",
"@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.22", "autoprefixer": "10.4.13",
"baseline-browser-mapping": "^2.8.32", "commitizen": "4.3.0",
"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",
@@ -158,22 +157,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.10.2", "eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-no-relative-import-paths": "1.6.1", "eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.32.2",
"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.11", "nodemon": "3.1.9",
"postcss": "8.5.6", "postcss": "8.4.31",
"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.2", "ts-node": "10.9.1",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.1.2",
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"engines": { "engines": {
@@ -182,7 +181,7 @@
}, },
"overrides": { "overrides": {
"sqlite3/node-gyp": "8.4.1", "sqlite3/node-gyp": "8.4.1",
"@types/express-session": "1.18.2" "@types/express-session": "1.17.6"
}, },
"config": { "config": {
"commitizen": { "commitizen": {
@@ -205,11 +204,8 @@
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@swc/core", "sqlite3",
"bcrypt", "bcrypt"
"cypress",
"sharp",
"sqlite3"
] ]
} }
} }

4118
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -209,34 +209,6 @@ 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);
} }
@@ -346,38 +318,6 @@ 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

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

View File

@@ -143,9 +143,7 @@ class AvailabilitySync {
const { existsInPlex: existsInPlex4k } = const { existsInPlex: existsInPlex4k } =
await this.mediaExistsInPlex(media, true); await this.mediaExistsInPlex(media, true);
// Media must exist in Plex to be considered available if (existsInPlex || existsInRadarr) {
// 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.`,
@@ -155,7 +153,7 @@ class AvailabilitySync {
); );
} }
if (existsInPlex4k) { if (existsInPlex4k || existsInRadarr4k) {
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.`,
@@ -242,9 +240,7 @@ class AvailabilitySync {
//plex //plex
if (mediaServerType === MediaServerType.PLEX) { if (mediaServerType === MediaServerType.PLEX) {
// Media must exist in Plex to be considered available if (existsInPlex || existsInSonarr) {
// 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.`,
@@ -256,7 +252,7 @@ class AvailabilitySync {
} }
if (mediaServerType === MediaServerType.PLEX) { if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex4k) { if (existsInPlex4k || existsInSonarr4k) {
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.`,
@@ -304,6 +300,7 @@ 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) =>
@@ -314,7 +311,48 @@ 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) =>
@@ -325,32 +363,44 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false) filteredSeasonsMap4k.set(season.seasonNumber, false)
); );
let finalSeasons: Map<number, boolean>; // 4k
let finalSeasons4k: Map<number, boolean>; const finalSeasons4k: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) { if (mediaServerType === MediaServerType.PLEX) {
finalSeasons = new Map([ plexSeasonsMap4k.forEach((value, key) => {
...filteredSeasonsMap, finalSeasons4k.set(key, value);
...plexSeasonsMap, });
...sonarrSeasonsMap,
]); filteredSeasonsMap4k.forEach((value, key) => {
finalSeasons4k = new Map([ if (!finalSeasons4k.has(key)) {
...filteredSeasonsMap4k, finalSeasons4k.set(key, value);
...plexSeasonsMap4k, }
...sonarrSeasonsMap4k, });
]);
} else { sonarrSeasonsMap4k.forEach((value, key) => {
// Jellyfin/Emby if (!finalSeasons4k.has(key)) {
finalSeasons = new Map([ finalSeasons4k.set(key, value);
...filteredSeasonsMap, }
...jellyfinSeasonsMap, });
...sonarrSeasonsMap, } else if (
]); mediaServerType === MediaServerType.JELLYFIN ||
finalSeasons4k = new Map([ mediaServerType === MediaServerType.EMBY
...filteredSeasonsMap4k, ) {
...jellyfinSeasonsMap4k, jellyfinSeasonsMap4k.forEach((value, key) => {
...sonarrSeasonsMap4k, finalSeasons4k.set(key, value);
]); });
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 (
@@ -615,21 +665,6 @@ 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
@@ -656,63 +691,22 @@ class AvailabilitySync {
}); });
} }
if (radarr) { if (radarr && radarr.hasFile) {
if (radarr.hasFile) { const resolution =
const resolution = radarr?.movieFile?.mediaInfo?.resolution?.split('x');
radarr?.movieFile?.mediaInfo?.resolution?.split('x'); const is4kMovie =
const is4kMovie = resolution?.length === 2 && Number(resolution[0]) >= 2000;
resolution?.length === 2 && Number(resolution[0]) >= 2000; existsInRadarr = is4k ? is4kMovie : !is4kMovie;
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')) {
logger.debug( existsInRadarr = true;
`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',
} }
); );
@@ -760,6 +754,7 @@ 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 ${
@@ -858,66 +853,31 @@ 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 (!currentRatingKey) { if (ratingKey && !is4k) {
logger.debug( plexMedia = await this.plexClient?.getMetadata(ratingKey);
`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) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
}
if (plexMedia) {
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',
}
);
} }
} }
if (ratingKey4k && is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
}
if (plexMedia) {
existsInPlex = true;
}
} catch (ex) { } catch (ex) {
if (ex.message.includes('404')) { if (!ex.message.includes('404')) {
logger.debug( existsInPlex = true;
`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'} ${
@@ -1033,8 +993,8 @@ class AvailabilitySync {
existsInJellyfin = true; existsInJellyfin = true;
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404') && !ex.message.includes('500')) { if (!ex.message.includes('404' || '500')) {
existsInJellyfin = true; existsInJellyfin = false;
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,17 +45,7 @@ class PushoverAgent
} }
public shouldSend(): boolean { public shouldSend(): boolean {
const settings = this.getSettings(); return true;
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
} }
private async getImagePayload( private async getImagePayload(

View File

@@ -34,8 +34,6 @@ 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;
@@ -97,8 +95,6 @@ class BaseScanner<T> {
is4k = false, is4k = false,
mediaAddedAt, mediaAddedAt,
ratingKey, ratingKey,
jellyfinMediaId,
imdbId,
serviceId, serviceId,
externalServiceId, externalServiceId,
externalServiceSlug, externalServiceSlug,
@@ -115,11 +111,9 @@ 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.AVAILABLE ? MediaStatus.PROCESSING
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED : MediaStatus.AVAILABLE;
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
if (mediaAddedAt) { if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt; existing.mediaAddedAt = mediaAddedAt;
} }
@@ -139,21 +133,6 @@ 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
@@ -194,7 +173,6 @@ 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
@@ -225,13 +203,6 @@ 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}`);
} }
@@ -250,12 +221,11 @@ class BaseScanner<T> {
*/ */
protected async processShow( protected async processShow(
tmdbId: number, tmdbId: number,
tvdbId: number | undefined, tvdbId: number,
seasons: ProcessableSeason[], seasons: ProcessableSeason[],
{ {
mediaAddedAt, mediaAddedAt,
ratingKey, ratingKey,
jellyfinMediaId,
serviceId, serviceId,
externalServiceId, externalServiceId,
externalServiceSlug, externalServiceSlug,
@@ -287,7 +257,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber (es) => es.seasonNumber === season.seasonNumber
); );
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts // We update the rating keys 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;
} }
@@ -301,23 +271,6 @@ 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
@@ -332,11 +285,6 @@ 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
@@ -352,11 +300,6 @@ 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(
@@ -548,22 +491,6 @@ 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,8 +1,5 @@
import animeList from '@server/api/animelist'; import animeList from '@server/api/animelist';
import type { import type { JellyfinLibraryItem } from '@server/api/jellyfin';
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';
@@ -11,119 +8,132 @@ 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';
interface JellyfinSyncStatus extends StatusBase { const BUNDLE_SIZE = 20;
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 {
extends BaseScanner<JellyfinLibraryItem> private sessionId: string;
implements RunnableScanner<JellyfinSyncStatus> private tmdb: TheMovieDb;
{
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 } = {}) {
super('Jellyfin Sync'); this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false; this.isRecentOnly = isRecentOnly ?? false;
} }
private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{ private async getExisting(tmdbId: number, mediaType: MediaType) {
tmdbId: number; const mediaRepository = getRepository(Media);
imdbId?: string;
metadata: JellyfinLibraryItemExtended;
} | null> {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
if (!metadata?.Id) { const existing = await mediaRepository.findOne({
this.log('No Id metadata for this title. Skipping', 'debug', { where: { tmdbId: tmdbId, mediaType },
jellyfinItemId: jellyfinitem.Id, });
});
return null;
}
const anidbId = Number(metadata.ProviderIds.AniDB ?? null); return existing;
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 processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) { private async processMovie(jellyfinitem: JellyfinLibraryItem) {
try { const mediaRepository = getRepository(Media);
const extracted = await this.extractMovieIds(jellyfinitem);
if (!extracted) return;
const { tmdbId, imdbId, metadata } = extracted; try {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media();
if (!metadata?.Id) {
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(
@@ -141,29 +151,93 @@ class JellyfinScanner
}); });
}); });
const mediaAddedAt = metadata.DateCreated await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
? new Date(metadata.DateCreated) if (!metadata) {
: undefined; // this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
if (hasOtherResolution || (!this.enable4kMovie && has4k)) { const existing = await this.getExisting(
await this.processMovie(tmdbId, { newMedia.tmdbId,
is4k: false, MediaType.MOVIE
mediaAddedAt, );
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
if (has4k && this.enable4kMovie) { if (existing) {
await this.processMovie(tmdbId, { let changedExisting = false;
is4k: true,
mediaAddedAt, if (
jellyfinMediaId: metadata.Id, (hasOtherResolution || (!this.enable4kMovie && has4k)) &&
imdbId, existing.status !== MediaStatus.AVAILABLE
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}`,
@@ -212,7 +286,9 @@ class JellyfinScanner
return tvShow; return tvShow;
} }
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) { private async processShow(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null; let tvShow: TmdbTvDetails | null = null;
try { try {
@@ -221,7 +297,8 @@ class JellyfinScanner
const metadata = await this.jfClient.getItemData(Id); const metadata = await this.jfClient.getItemData(Id);
if (!metadata?.Id) { if (!metadata?.Id) {
this.log('No Id metadata for this title. Skipping', 'debug', { logger.debug('No Id metadata for this title. Skipping', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id, jellyfinItemId: jellyfinitem.Id,
}); });
return; return;
@@ -238,7 +315,6 @@ class JellyfinScanner
}); });
} }
} }
if (!tvShow && metadata.ProviderIds.Tvdb) { if (!tvShow && metadata.ProviderIds.Tvdb) {
try { try {
tvShow = await this.getTvShow({ tvShow = await this.getTvShow({
@@ -250,7 +326,6 @@ 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);
@@ -269,49 +344,71 @@ 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.processJellyfinMovie(jellyfinitem); await this.processMovie(jellyfinitem);
return; return;
} }
} }
if (tvShow) { if (tvShow) {
const seasons = tvShow.seasons; await this.asyncLock.dispatch(tvShow.id, async () => {
const jellyfinSeasons = await this.jfClient.getSeasons(Id); if (!tvShow) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
const processableSeasons: ProcessableSeason[] = []; // Lets get the available seasons from Jellyfin
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
const settings = getSettings(); const newSeasons: Season[] = [];
const filteredSeasons = settings.main.enableSpecialEpisodes
? seasons
: seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) { const currentStandardSeasonAvailable = (
const matchedJellyfinSeason = jellyfinSeasons.find((md) => { media?.seasons.filter(
if (tvdbSeasonFromAnidb) { (season) => season.status === MediaStatus.AVAILABLE
// 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). ).length;
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and const current4kSeasonAvailable = (
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin media?.seasons.filter(
return ( (season) => season.status4k === MediaStatus.AVAILABLE
tvdbSeasonFromAnidb === season.season_number && ) ?? []
md.IndexNumber === 1 ).length;
);
} else {
return Number(md.IndexNumber) === season.season_number;
}
});
// Check if we found the matching season and it has all the available episodes for (const season of seasons) {
if (matchedJellyfinSeason) { const JellyfinSeasons = await this.jfClient.getSeasons(Id);
let totalStandard = 0; const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
let total4k = 0; if (tvdbSeasonFromAnidb) {
// 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;
}
});
if (!this.enable4kShow) { const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes
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;
@@ -324,94 +421,238 @@ class JellyfinScanner
episode.IndexNumberEnd - episode.IndexNumber + 1; episode.IndexNumberEnd - episode.IndexNumber + 1;
} }
totalStandard += episodeCount; if (!this.enable4kShow) {
} totalStandard += episodeCount;
} else { } else {
// 4K detection enabled - request media info to check resolution const ExtendedEpisodeData = await this.jfClient.getItemData(
const episodes = await this.jfClient.getEpisodes( episode.Id
Id, );
matchedJellyfinSeason.Id,
{ includeMediaInfo: true }
);
for (const episode of episodes) { ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
let episodeCount = 1; return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') {
// count number of combined episodes if ((MediaStream.Width ?? 0) >= 2000) {
if ( total4k += episodeCount;
episode.IndexNumber !== undefined && } else {
episode.IndexNumberEnd !== undefined totalStandard += episodeCount;
) { }
episodeCount = }
episode.IndexNumberEnd - episode.IndexNumber + 1; });
});
} }
const has4k = episode.MediaSources?.some((MediaSource) =>
MediaSource.MediaStreams.some(
(MediaStream) =>
MediaStream.Type === 'Video' &&
(MediaStream.Width ?? 0) > 2000
)
);
const hasStandard = episode.MediaSources?.some((MediaSource) =>
MediaSource.MediaStreams.some(
(MediaStream) =>
MediaStream.Type === 'Video' &&
(MediaStream.Width ?? 0) <= 2000
)
);
// Count in both if episode has both versions
// TODO: Make this more robust in the future
// Currently, this detection is based solely on file resolution, not which
// Radarr/Sonarr instance the file came from. If a 4K request results in
// 1080p files (no 4K release available yet), those files will be counted
// as "standard" even though they're in the 4K library. This can cause
// non-4K users to see content as "available" when they can't access it.
// See issue https://github.com/seerr-team/seerr/issues/1744 for details.
if (hasStandard) totalStandard += episodeCount;
if (has4k) total4k += episodeCount;
} }
}
// With AniDB we can have multiple shows for one season, so we need to save // With AniDB we can have multiple shows for one season, so we need to save
// the episode from all the jellyfin entries to get the total // the episode from all the jellyfin entries to get the total
if (tvdbSeasonFromAnidb) { if (tvdbSeasonFromAnidb) {
let show = this.processedAnidbSeason.get(tvShow.id); if (this.processedAnidbSeason.has(tvShow.id)) {
const show = this.processedAnidbSeason.get(tvShow.id)!;
if (show.has(season.season_number)) {
show.set(
season.season_number,
show.get(season.season_number)! + totalStandard
);
if (!show) { totalStandard = show.get(season.season_number)!;
show = new Map([[season.season_number, totalStandard]]); } else {
this.processedAnidbSeason.set(tvShow.id, show); 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 { } else {
const currentCount = show.get(season.season_number) ?? 0; newSeasons.push(
const newCount = currentCount + totalStandard; new Season({
show.set(season.season_number, newCount); seasonNumber: season.season_number,
totalStandard = newCount; // 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,
})
);
} }
} }
}
processableSeasons.push({ // Remove extras season. We dont count it for determining availability
seasonNumber: season.season_number, const filteredSeasons = tvShow.seasons.filter(
totalEpisodes: season.episode_count, (season) => season.season_number !== 0
episodes: totalStandard, );
episodes4k: total4k,
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt) {
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: isAllStandardSeasons ? Id : null,
jellyfinMediaId4k:
isAll4kSeasons && this.enable4kShow ? Id : null,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
}); });
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
} }
} });
await this.processShow(
tvShow.id,
tvShow.external_ids?.tvdb_id,
processableSeasons,
{
mediaAddedAt: metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined,
jellyfinMediaId: Id,
title: tvShow.name,
}
);
} else { } else {
this.log( this.log(
`No information found for the show: ${metadata.Name}`, `No information found for the show: ${metadata.Name}`,
@@ -427,17 +668,70 @@ 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 processItem(item: JellyfinLibraryItem): Promise<void> { private async processItems(slicedItems: JellyfinLibraryItem[]) {
if (item.Type === 'Movie') { this.processedAnidbSeason = new Map();
await this.processJellyfinMovie(item); await Promise.all(
} else if (item.Type === 'Series') { slicedItems.map(async (item) => {
await this.processJellyfinShow(item); if (item.Type === 'Movie') {
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> {
@@ -450,9 +744,14 @@ class JellyfinScanner
return; return;
} }
const sessionId = this.startRun(); const sessionId = uuid();
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 },
@@ -478,11 +777,25 @@ 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'
@@ -502,19 +815,16 @@ class JellyfinScanner
return mediaA.Id === mediaB.Id; return mediaA.Id === mediaB.Id;
}); });
await this.loop(this.processItem.bind(this), { sessionId }); await this.loop({ 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(this.processItem.bind(this), { sessionId }); await this.loop({ sessionId });
} }
} }
this.log( this.log(
this.isRecentOnly this.isRecentOnly
? 'Recently Added Scan Complete' ? 'Recently Added Scan Complete'
@@ -522,13 +832,19 @@ class JellyfinScanner
'info' 'info'
); );
} catch (e) { } catch (e) {
this.log('Sync interrupted', 'error', { errorMessage: e.message }); logger.error('Sync interrupted', {
label: 'Jellyfin Sync',
errorMessage: e.message,
});
} finally { } finally {
this.endRun(sessionId); // If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
} }
} }
public status(): JellyfinSyncStatus { public status(): SyncStatus {
return { return {
running: this.running, running: this.running,
progress: this.progress, progress: this.progress,
@@ -537,6 +853,10 @@ 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,7 +13,9 @@ 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;
@@ -28,26 +30,15 @@ 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( const userTag = radarrTags.find((v) =>
(v) => v.label.startsWith(user.id + ' - ')
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: label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
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) {
@@ -70,26 +61,15 @@ 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( const userTag = sonarrTags.find((v) =>
(v) => v.label.startsWith(user.id + ' - ')
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: label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
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

@@ -6,15 +6,6 @@ export class AddUniqueConstraintToPushSubscription1765233385034
name = 'AddUniqueConstraintToPushSubscription1765233385034'; name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> { 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( await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")` `ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
); );

View File

@@ -6,15 +6,6 @@ export class AddUniqueConstraintToPushSubscription1765233385034
name = 'AddUniqueConstraintToPushSubscription1765233385034'; name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> { 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( await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")` `CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
); );

View File

@@ -626,6 +626,76 @@ 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;
@@ -705,7 +775,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.debug('Successfully logged out user', { logger.info('Successfully logged out user', {
label: 'Auth', label: 'Auth',
userId, userId,
}); });

View File

@@ -29,16 +29,6 @@ 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>
@@ -320,15 +310,11 @@ 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.id + '-' + entity.requestedBy.displayName,
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
userTag = await radarr.createTag({ userTag = await radarr.createTag({
label: label:
entity.requestedBy.id + entity.requestedBy.id + '-' + entity.requestedBy.displayName,
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
} }
if (userTag.id) { if (userTag.id) {
@@ -645,15 +631,11 @@ 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.id + '-' + entity.requestedBy.displayName,
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
userTag = await sonarr.createTag({ userTag = await sonarr.createTag({
label: label:
entity.requestedBy.id + entity.requestedBy.id + '-' + entity.requestedBy.displayName,
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
} }
if (userTag.id) { if (userTag.id) {

View File

@@ -11,14 +11,9 @@ 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({ const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
const skipUrl = (url: string | URL) => { const skipUrl = (url: string | URL) => {
const hostname = const hostname =
@@ -72,23 +67,16 @@ 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));
const agentOptions = { axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined, headers: token ? { 'proxy-authorization': token } : undefined,
keepAlive: true, });
maxSockets: 50, axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
maxFreeSockets: 10, headers: token ? { 'proxy-authorization': token } : undefined,
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" aria-label={label}> <label htmlFor="localLogin" className="block">
<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,7 +3,6 @@ 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', {
@@ -47,12 +46,8 @@ const PlexLoginButton = ({
> >
{(chunks) => ( {(chunks) => (
<> <>
{chunks.map((c, index) => {chunks.map((c) =>
typeof c === 'string' ? ( typeof c === 'string' ? <span>{c}</span> : c
<span key={index}>{c}</span>
) : (
<Fragment key={index}>{c}</Fragment>
)
)} )}
</> </>
)} )}

View File

@@ -25,7 +25,6 @@ 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';
@@ -34,17 +33,6 @@ 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',
@@ -242,30 +230,26 @@ 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>
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map( {data.mediaInfo?.downloadStatus?.map((status, index) => (
(status, index) => ( <Tooltip
<Tooltip key={`dl-status-${status.externalId}-${index}`}
key={`dl-status-${status.externalId}-${index}`} content={status.title}
content={status.title} >
> <li className="border-b border-gray-700 last:border-b-0">
<li className="border-b border-gray-700 last:border-b-0"> <DownloadBlock downloadItem={status} />
<DownloadBlock downloadItem={status} /> </li>
</li> </Tooltip>
</Tooltip> ))}
) {data.mediaInfo?.downloadStatus4k?.map((status, index) => (
)} <Tooltip
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map( key={`dl-status-${status.externalId}-${index}`}
(status, index) => ( content={status.title}
<Tooltip >
key={`dl-status-4k-${status.externalId}-${index}`} <li className="border-b border-gray-700 last:border-b-0">
content={status.title} <DownloadBlock downloadItem={status} is4k />
> </li>
<li className="border-b border-gray-700 last:border-b-0"> </Tooltip>
<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" aria-label={option.name}> <label htmlFor={option.id} className="block">
<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" aria-label={option.name}> <label htmlFor={option.id} className="block">
<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,6 +5,7 @@ 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';
@@ -218,6 +219,7 @@ 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,
}); });
@@ -400,7 +402,14 @@ 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: request.seasons.length, seasonCount:
(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,6 +5,7 @@ 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';
@@ -294,6 +295,7 @@ 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,
}); });
@@ -468,7 +470,14 @@ 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: request.seasons.length, seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})} })}
</span> </span>
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll"> <div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">

View File

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

View File

@@ -320,14 +320,12 @@ 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,
} }
); );
} }
@@ -424,7 +422,6 @@ const SettingsMetadata = () => {
), ),
{ {
appearance: 'success', appearance: 'success',
autoDismiss: true,
} }
); );
} }

View File

@@ -38,8 +38,6 @@ 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.',
@@ -66,20 +64,6 @@ 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(
@@ -136,8 +120,8 @@ const SettingsNetwork = () => {
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
dnsCache: { dnsCache: {
enabled: values.dnsCacheEnabled, enabled: values.dnsCacheEnabled,
forceMinTtl: Number(values.dnsCacheForceMinTtl), forceMinTtl: values.dnsCacheForceMinTtl,
forceMaxTtl: Number(values.dnsCacheForceMaxTtl), forceMaxTtl: values.dnsCacheForceMaxTtl,
}, },
proxy: { proxy: {
enabled: values.proxyEnabled, enabled: values.proxyEnabled,
@@ -297,7 +281,7 @@ const SettingsNetwork = () => {
<Field <Field
id="dnsCacheForceMinTtl" id="dnsCacheForceMinTtl"
name="dnsCacheForceMinTtl" name="dnsCacheForceMinTtl"
type="number" type="text"
/> />
</div> </div>
{errors.dnsCacheForceMinTtl && {errors.dnsCacheForceMinTtl &&
@@ -321,7 +305,7 @@ const SettingsNetwork = () => {
<Field <Field
id="dnsCacheForceMaxTtl" id="dnsCacheForceMaxTtl"
name="dnsCacheForceMaxTtl" name="dnsCacheForceMaxTtl"
type="number" type="text"
/> />
</div> </div>
{errors.dnsCacheForceMaxTtl && {errors.dnsCacheForceMaxTtl &&
@@ -391,7 +375,7 @@ const SettingsNetwork = () => {
<Field <Field
id="proxyPort" id="proxyPort"
name="proxyPort" name="proxyPort"
type="number" type="text"
/> />
</div> </div>
{errors.proxyPort && {errors.proxyPort &&

View File

@@ -377,7 +377,6 @@ 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 {
@@ -424,7 +423,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
values, values,
handleSubmit, handleSubmit,
setFieldValue, setFieldValue,
setValues,
isSubmitting, isSubmitting,
isValid, isValid,
}) => { }) => {
@@ -447,12 +445,9 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
availablePresets[Number(e.target.value)]; availablePresets[Number(e.target.value)];
if (targPreset) { if (targPreset) {
setValues({ setFieldValue('hostname', targPreset.address);
...values, setFieldValue('port', targPreset.port);
hostname: targPreset.address, setFieldValue('useSsl', targPreset.ssl);
port: targPreset.port,
useSsl: targPreset.ssl,
});
} }
}} }}
> >

View File

@@ -28,8 +28,7 @@ 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) {
const { data: user } = await axios.get('/api/v1/auth/me'); revalidate();
revalidate(user, false);
} }
}; };
if (authToken) { if (authToken) {

View File

@@ -139,11 +139,7 @@ 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.DELETED status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
? '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={{
@@ -377,66 +373,11 @@ const StatusBadge = ({
case MediaStatus.DELETED: case MediaStatus.DELETED:
return ( return (
<Tooltip <Tooltip content={mediaLinkDescription}>
content={inProgress ? tooltipContent : mediaLinkDescription} <Badge badgeType="danger">
className={`${ {intl.formatMessage(is4k ? messages.status4k : messages.status, {
inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block' status: intl.formatMessage(globalMessages.deleted),
}`} })}
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

@@ -185,18 +185,16 @@ const UserWebPushSettings = () => {
(device) => device.userAgent === currentUserAgent (device) => device.userAgent === currentUserAgent
); );
if (hasMatchingDevice) { if (hasMatchingDevice || dataDevices.length === 1) {
isEnabled = true; isEnabled = true;
} }
} }
setWebPushEnabled(isEnabled); setWebPushEnabled(isEnabled);
if (localStorage.getItem('pushNotificationsEnabled') === null) { localStorage.setItem(
localStorage.setItem( 'pushNotificationsEnabled',
'pushNotificationsEnabled', isEnabled ? 'true' : 'false'
isEnabled ? 'true' : 'false' );
);
}
}; };
if (user?.id) { if (user?.id) {

View File

@@ -2,7 +2,6 @@ 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';
@@ -57,21 +56,13 @@ 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: !isAuthPage ? 30000 : 0, refreshInterval: 30000,
revalidateOnFocus: !isAuthPage,
revalidateOnMount: !isAuthPage,
revalidateOnReconnect: !isAuthPage,
errorRetryInterval: 30000, errorRetryInterval: 30000,
shouldRetryOnError: false, shouldRetryOnError: false,
}); });

View File

@@ -1022,8 +1022,6 @@
"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() || 'Unknown', 'X-Plex-Platform-Version': browser.getBrowserVersion(),
'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':