Compare commits

..

3 Commits

Author SHA1 Message Date
gauthier-th
503c099cd1 fix: remove useless tags 2025-12-01 22:03:16 +01:00
Gauthier
4afcfbb598 fix: missing file 2025-11-21 22:19:56 +01:00
Gauthier
7ec5123cd0 feat(overriderules): apply override rules to advanced requests
This PR apply the override rules to the Advanced Request Modal
2025-11-21 22:15:17 +01:00
58 changed files with 4186 additions and 3687 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

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

View File

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

1216
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,7 +8,7 @@
<p align="center"> <p align="center">
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a> <a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a> <a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a> <a href="http://translate.seerr.dev/engage/seerr/"><img src="http://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -174,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

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

View File

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

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

View File

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

View File

@@ -97,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

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

152
server/lib/overrideRules.ts Normal file
View File

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

View File

@@ -34,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

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

View File

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

View File

@@ -626,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

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

View File

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

View File

@@ -29,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

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

View File

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

View File

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

View File

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

View File

@@ -49,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

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

View File

@@ -320,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

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

View File

@@ -2,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':

View File

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