mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-11 17:16:50 -05:00
Compare commits
16 Commits
0xsysr3ll/
...
pr-2273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb672ec3c4 | ||
|
|
d0c9afc16e | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 | ||
|
|
3ee69663dc | ||
|
|
539d49879d | ||
|
|
15356dfe49 | ||
|
|
1f04eeb040 | ||
|
|
e3028c21f2 | ||
|
|
9d8b343790 | ||
|
|
f4fe16608a | ||
|
|
d660a540da | ||
|
|
48ef2984e5 | ||
|
|
c5fc31c352 | ||
|
|
c3b9ea6ce4 |
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Additional Context
|
label: Additional Context
|
||||||
description: Please provide any additional information that may be relevant or helpful.
|
description: Please provide any additional information that may be relevant or helpful.
|
||||||
|
- type: checkboxes
|
||||||
|
id: search-existing
|
||||||
|
attributes:
|
||||||
|
label: Search Existing Issues
|
||||||
|
description: Have you searched existing issues to see if this bug has already been reported?
|
||||||
|
options:
|
||||||
|
- label: Yes, I have searched existing issues.
|
||||||
|
required: true
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Additional Context
|
label: Additional Context
|
||||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||||
|
- type: checkboxes
|
||||||
|
id: search-existing
|
||||||
|
attributes:
|
||||||
|
label: Search Existing Issues
|
||||||
|
description: Have you searched existing issues to see if this feature has already been requested?
|
||||||
|
options:
|
||||||
|
- label: Yes, I have searched existing issues.
|
||||||
|
required: true
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,14 +1,33 @@
|
|||||||
#### Description
|
<!--
|
||||||
|
Please read contributing guide before submitting
|
||||||
|
your pull request. Please fill in each section below to help us better prioritize your pull request. Thanks!
|
||||||
|
-->
|
||||||
|
|
||||||
#### Screenshot (if UI-related)
|
## Description
|
||||||
|
|
||||||
#### To-Dos
|
<!--- Describe your changes in detail -->
|
||||||
|
<!--- Why is this change required? What problem does it solve? -->
|
||||||
|
<!--- If it fixes an open issue, please link to the issue here. -->
|
||||||
|
|
||||||
|
- Fixes #XXXX
|
||||||
|
|
||||||
|
## How Has This Been Tested?
|
||||||
|
|
||||||
|
<!--- Please describe in detail how you tested your changes. -->
|
||||||
|
<!--- Include details of your testing environment, and the tests you ran to -->
|
||||||
|
<!--- see how your change affects other areas of the code, etc. -->
|
||||||
|
|
||||||
|
## Screenshots / Logs (if applicable)
|
||||||
|
|
||||||
|
## Checklist:
|
||||||
|
|
||||||
|
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
|
||||||
|
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
|
||||||
|
|
||||||
|
- [ ] I have read and followed the contribution [guidelines](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md).
|
||||||
- [ ] Disclosed any use of AI (see our [policy](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice))
|
- [ ] Disclosed any use of AI (see our [policy](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice))
|
||||||
|
- [ ] I have updated the documentation accordingly.
|
||||||
|
- [ ] All new and existing tests passed.
|
||||||
- [ ] Successful build `pnpm build`
|
- [ ] Successful build `pnpm build`
|
||||||
- [ ] Translation keys `pnpm i18n:extract`
|
- [ ] Translation keys `pnpm i18n:extract`
|
||||||
- [ ] Database migration (if required)
|
- [ ] Database migration (if required)
|
||||||
|
|
||||||
#### Issues Fixed or Closed
|
|
||||||
|
|
||||||
- Fixes #XXXX
|
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -8,7 +8,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
|
||||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
|
||||||
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
|
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
|
||||||
|
|
||||||
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||||
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Check out our documentation for instructions on how to install and run Seerr:
|
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||||
|
|
||||||
https://docs.seerr.dev/getting-started/
|
https://docs.seerr.dev/getting-started/
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Seerr is not officially released yet.**
|
||||||
|
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||||
|
|
||||||
|
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||||
|
|
||||||
|
Instead, follow the dedicated migration guide:
|
||||||
|
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||||
|
|
||||||
|
> [!DANGER]
|
||||||
|
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database.**
|
||||||
|
> Doing so **will cause database corruption and/or irreversible data loss**.
|
||||||
|
|
||||||
|
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">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: Seerr helm chart for Kubernetes
|
|||||||
type: application
|
type: application
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
# renovate: image=ghcr.io/seerr-team/seerr
|
# renovate: image=ghcr.io/seerr-team/seerr
|
||||||
appVersion: '2.7.3'
|
appVersion: '3.0.0'
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: Seerr Team
|
- name: Seerr Team
|
||||||
url: https://github.com/orgs/seerr-team/people
|
url: https://github.com/orgs/seerr-team/people
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# seerr-chart
|
# seerr-chart
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
Seerr helm chart for Kubernetes
|
Seerr helm chart for Kubernetes
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Kubernetes: `>=1.23.0-0`
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
|
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
|
||||||
|
|
||||||
## Update Notes
|
## Update Notes
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
|
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
|
||||||
|
|
||||||
## Update Notes
|
## Update Notes
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ sudo mkdir -p /opt/seerr && cd /opt/seerr
|
|||||||
```
|
```
|
||||||
2. Clone the Seerr repository and checkout the main branch:
|
2. Clone the Seerr repository and checkout the main branch:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/seerr-team/seerr.git
|
git clone https://github.com/seerr-team/seerr.git .
|
||||||
cd seerr
|
|
||||||
git checkout main
|
git checkout main
|
||||||
```
|
```
|
||||||
3. Install the dependencies:
|
3. Install the dependencies:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Changes :
|
|||||||
|
|
||||||
If you're migrating from a previous installation, you may need to update the ownership of your config folder:
|
If you're migrating from a previous installation, you may need to update the ownership of your config folder:
|
||||||
```bash
|
```bash
|
||||||
sudo chown -R 1000:1000 /path/to/appdata/config
|
docker run --rm -v /path/to/appdata/config:/data alpine chown -R 1000:1000 /data
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the `node` user (UID 1000) owns the config directory and can read and write to it.
|
This ensures the `node` user (UID 1000) owns the config directory and can read and write to it.
|
||||||
|
|||||||
@@ -174,4 +174,36 @@ This can happen if you have a new installation of Jellyfin/Emby or if you have c
|
|||||||
|
|
||||||
This process should restore your admin privileges while preserving your settings.
|
This process should restore your admin privileges while preserving your settings.
|
||||||
|
|
||||||
|
## Failed to enable web push notifications
|
||||||
|
|
||||||
|
### Option 1: You are using Pi-hole
|
||||||
|
|
||||||
|
When using Pi-hole, you need to whitelist the proper domains in order for the queries to not be intercepted and blocked by Pi-hole.
|
||||||
|
If you are using a chromium based browser (eg: Chrome, Brave, Edge...), the domain you need to whitelist is `fcm.googleapis.com`
|
||||||
|
If you are using Firefox, the domain you need to whitelist is `push.services.mozilla.com`
|
||||||
|
|
||||||
|
1. Log into your Pi-hole through the admin interface, then click on Domains situated under GROUP MANAGEMENT.
|
||||||
|
2. Add the domain corresponding to your browser in the `Domain to be added` field and then click on Add to allowed domains.
|
||||||
|
3. Now in order for those changes to be used you need to flush your current dns cache.
|
||||||
|
4. You can do so by using this command line in your Pi-hole terminal:
|
||||||
|
```bash
|
||||||
|
pihole restartdns
|
||||||
|
```
|
||||||
|
If this command fails (which is unlikely), use this equivalent:
|
||||||
|
```bash
|
||||||
|
pihole -f && pihole restartdns
|
||||||
|
```
|
||||||
|
5. Then restart your Seerr instance and try to enable the web push notifications again.
|
||||||
|
|
||||||
|
|
||||||
|
### Option 2: You are using Brave browser
|
||||||
|
|
||||||
|
Brave is a "De-Googled" browser. So by default or if you refused a prompt in the past, it cuts the access to the FCM (Firebase Cloud Messaging) service, which is mandatory for the web push notifications on Chromium based browsers.
|
||||||
|
|
||||||
|
1. Open Brave and paste this address in the url bar: `brave://settings/privacy`
|
||||||
|
2. Look for the option: "Use Google services for push messaging"
|
||||||
|
3. Activate this option
|
||||||
|
4. Relaunch Brave completely
|
||||||
|
5. You should now see the notifications prompt appearing instead of an error message.
|
||||||
|
|
||||||
If you still encounter issues, please reach out on our support channels.
|
If you still encounter issues, please reach out on our support channels.
|
||||||
|
|||||||
100
package.json
100
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "seerr",
|
"name": "seerr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.17.1",
|
"packageManager": "pnpm@10.24.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"postinstall": "node postinstall-win.js",
|
"postinstall": "node postinstall-win.js",
|
||||||
@@ -33,38 +33,38 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dr.pogodin/csurf": "^1.14.1",
|
"@dr.pogodin/csurf": "^1.16.6",
|
||||||
"@formatjs/intl-displaynames": "6.2.6",
|
"@formatjs/intl-displaynames": "6.8.13",
|
||||||
"@formatjs/intl-locale": "3.1.1",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.10",
|
"@formatjs/intl-pluralrules": "5.4.6",
|
||||||
"@formatjs/intl-utils": "3.8.4",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||||
"@headlessui/react": "1.7.12",
|
"@headlessui/react": "1.7.12",
|
||||||
"@heroicons/react": "2.0.16",
|
"@heroicons/react": "2.2.0",
|
||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"@svgr/webpack": "6.5.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.30",
|
"@tanem/react-nprogress": "5.0.56",
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@types/wink-jaro-distance": "^2.0.2",
|
"@types/wink-jaro-distance": "^2.0.2",
|
||||||
"ace-builds": "1.15.2",
|
"ace-builds": "1.43.4",
|
||||||
"axios": "1.10.0",
|
"axios": "1.13.2",
|
||||||
"axios-rate-limit": "1.3.0",
|
"axios-rate-limit": "1.4.0",
|
||||||
"bcrypt": "5.1.0",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.13.1",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.6.4",
|
||||||
"cronstrue": "2.23.0",
|
"cronstrue": "2.23.0",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.7",
|
"dayjs": "1.11.19",
|
||||||
"dns-caching": "^0.2.7",
|
"dns-caching": "^0.2.7",
|
||||||
"email-templates": "12.0.1",
|
"email-templates": "12.0.3",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.18.2",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.9",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"http-proxy-agent": "^7.0.2",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
@@ -76,19 +76,19 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.10.0",
|
"nodemailer": "6.10.0",
|
||||||
"openpgp": "5.11.2",
|
"openpgp": "5.11.2",
|
||||||
"pg": "8.11.0",
|
"pg": "8.16.3",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.3",
|
"pug": "3.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
"react-aria": "3.23.0",
|
"react-aria": "3.44.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-intersection-observer": "9.4.3",
|
"react-intersection-observer": "9.4.3",
|
||||||
"react-intl": "^6.6.8",
|
"react-intl": "^6.6.8",
|
||||||
"react-markdown": "8.0.5",
|
"react-markdown": "8.0.5",
|
||||||
"react-popper-tooltip": "4.4.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"react-select": "5.7.0",
|
"react-select": "5.10.2",
|
||||||
"react-spring": "9.7.1",
|
"react-spring": "9.7.1",
|
||||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
@@ -97,19 +97,19 @@
|
|||||||
"react-use-clipboard": "1.0.9",
|
"react-use-clipboard": "1.0.9",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.7.1",
|
"semver": "7.7.3",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.33.4",
|
||||||
"sqlite3": "5.1.7",
|
"sqlite3": "5.1.7",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.3.7",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"typeorm": "0.3.12",
|
"typeorm": "0.3.12",
|
||||||
"ua-parser-js": "^1.0.35",
|
"ua-parser-js": "^1.0.35",
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.16.0",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.23",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.6.7",
|
||||||
"wink-jaro-distance": "^2.0.0",
|
"wink-jaro-distance": "^2.0.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.18.3",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
@@ -123,32 +123,33 @@
|
|||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.2",
|
||||||
"@types/csurf": "1.11.2",
|
"@types/csurf": "1.11.5",
|
||||||
"@types/email-templates": "8.0.4",
|
"@types/email-templates": "8.0.4",
|
||||||
"@types/express": "4.17.17",
|
"@types/express": "4.17.17",
|
||||||
"@types/express-session": "1.17.6",
|
"@types/express-session": "1.18.2",
|
||||||
"@types/lodash": "4.14.191",
|
"@types/lodash": "4.17.21",
|
||||||
"@types/mime": "3",
|
"@types/mime": "3",
|
||||||
"@types/node": "22.10.5",
|
"@types/node": "22.10.5",
|
||||||
"@types/node-schedule": "2.1.0",
|
"@types/node-schedule": "2.1.8",
|
||||||
"@types/nodemailer": "6.4.7",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-transition-group": "4.4.5",
|
"@types/react-transition-group": "4.4.12",
|
||||||
"@types/secure-random-password": "0.2.1",
|
"@types/secure-random-password": "0.2.1",
|
||||||
"@types/semver": "7.3.13",
|
"@types/semver": "7.7.1",
|
||||||
"@types/swagger-ui-express": "4.1.3",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/validator": "^13.15.3",
|
"@types/validator": "^13.15.10",
|
||||||
"@types/web-push": "3.3.2",
|
"@types/web-push": "3.6.4",
|
||||||
"@types/xml2js": "0.4.11",
|
"@types/xml2js": "0.4.11",
|
||||||
"@types/yamljs": "0.2.31",
|
"@types/yamljs": "0.2.31",
|
||||||
"@types/yup": "0.29.14",
|
"@types/yup": "0.29.14",
|
||||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||||
"@typescript-eslint/parser": "5.54.0",
|
"@typescript-eslint/parser": "5.54.0",
|
||||||
"autoprefixer": "10.4.13",
|
"autoprefixer": "10.4.22",
|
||||||
"commitizen": "4.3.0",
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
|
"commitizen": "4.3.1",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "14.1.0",
|
"cypress": "14.1.0",
|
||||||
@@ -157,22 +158,22 @@
|
|||||||
"eslint-config-next": "^14.2.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
"eslint-config-prettier": "8.6.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-formatjs": "4.9.0",
|
"eslint-plugin-formatjs": "4.9.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.2",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "3.1.9",
|
"nodemon": "3.1.11",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.5.6",
|
||||||
"prettier": "2.8.4",
|
"prettier": "2.8.4",
|
||||||
"prettier-plugin-organize-imports": "3.2.2",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"prettier-plugin-tailwindcss": "0.2.3",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
"tailwindcss": "3.2.7",
|
"tailwindcss": "3.2.7",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.2",
|
||||||
"tsc-alias": "1.8.2",
|
"tsc-alias": "1.8.16",
|
||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "4.9.5"
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"sqlite3/node-gyp": "8.4.1",
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
"@types/express-session": "1.17.6"
|
"@types/express-session": "1.18.2"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
@@ -204,8 +205,11 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"sqlite3",
|
"@swc/core",
|
||||||
"bcrypt"
|
"bcrypt",
|
||||||
|
"cypress",
|
||||||
|
"sharp",
|
||||||
|
"sqlite3"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4120
pnpm-lock.yaml
generated
4120
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,10 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
DateCreated?: string;
|
DateCreated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
|
||||||
|
? JellyfinLibraryItemExtended[]
|
||||||
|
: JellyfinLibraryItem[];
|
||||||
|
|
||||||
export interface JellyfinItemsReponse {
|
export interface JellyfinItemsReponse {
|
||||||
Items: JellyfinLibraryItemExtended[];
|
Items: JellyfinLibraryItemExtended[];
|
||||||
TotalRecordCount: number;
|
TotalRecordCount: number;
|
||||||
@@ -145,7 +149,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization': authHeaderVal,
|
Authorization: authHeaderVal,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
@@ -415,13 +419,22 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getEpisodes(
|
public async getEpisodes<
|
||||||
|
T extends { includeMediaInfo?: boolean } | undefined = undefined
|
||||||
|
>(
|
||||||
seriesID: string,
|
seriesID: string,
|
||||||
seasonID: string
|
seasonID: string,
|
||||||
): Promise<JellyfinLibraryItem[]> {
|
options?: T
|
||||||
|
): Promise<EpisodeReturn<T>> {
|
||||||
try {
|
try {
|
||||||
const episodeResponse = await this.get<any>(
|
const episodeResponse = await this.get<any>(
|
||||||
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
`/Shows/${seriesID}/Episodes`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
seasonId: seasonID,
|
||||||
|
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return episodeResponse.Items.filter(
|
return episodeResponse.Items.filter(
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@Unique(['endpoint', 'user'])
|
||||||
export class UserPushSubscription {
|
export class UserPushSubscription {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ interface PushNotificationPayload {
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebPushError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
status?: number;
|
||||||
|
body?: string | unknown;
|
||||||
|
response?: {
|
||||||
|
body?: string | unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class WebPushAgent
|
class WebPushAgent
|
||||||
extends BaseAgent<NotificationAgentConfig>
|
extends BaseAgent<NotificationAgentConfig>
|
||||||
implements NotificationAgent
|
implements NotificationAgent
|
||||||
@@ -188,19 +197,30 @@ class WebPushAgent
|
|||||||
notificationPayload
|
notificationPayload
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const webPushError = e as WebPushError;
|
||||||
|
const statusCode = webPushError.statusCode || webPushError.status;
|
||||||
|
const errorMessage = webPushError.message || String(e);
|
||||||
|
|
||||||
|
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||||
|
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Error sending web push notification; removing subscription',
|
isPermanentFailure
|
||||||
|
? 'Error sending web push notification; removing invalid subscription'
|
||||||
|
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||||
{
|
{
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: pushSub.user.displayName,
|
recipient: pushSub.user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage,
|
||||||
|
statusCode: statusCode || 'unknown',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Failed to send notification so we need to remove the subscription
|
if (isPermanentFailure) {
|
||||||
userPushSubRepository.remove(pushSub);
|
await userPushSubRepository.remove(pushSub);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
|||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
mediaAddedAt?: Date;
|
mediaAddedAt?: Date;
|
||||||
ratingKey?: string;
|
ratingKey?: string;
|
||||||
|
jellyfinMediaId?: string;
|
||||||
|
imdbId?: string;
|
||||||
serviceId?: number;
|
serviceId?: number;
|
||||||
externalServiceId?: number;
|
externalServiceId?: number;
|
||||||
externalServiceSlug?: string;
|
externalServiceSlug?: string;
|
||||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
|||||||
is4k = false,
|
is4k = false,
|
||||||
mediaAddedAt,
|
mediaAddedAt,
|
||||||
ratingKey,
|
ratingKey,
|
||||||
|
jellyfinMediaId,
|
||||||
|
imdbId,
|
||||||
serviceId,
|
serviceId,
|
||||||
externalServiceId,
|
externalServiceId,
|
||||||
externalServiceSlug,
|
externalServiceSlug,
|
||||||
@@ -133,6 +137,21 @@ class BaseScanner<T> {
|
|||||||
changedExisting = true;
|
changedExisting = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
jellyfinMediaId &&
|
||||||
|
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||||
|
jellyfinMediaId
|
||||||
|
) {
|
||||||
|
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||||
|
jellyfinMediaId;
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId && !existing.imdbId) {
|
||||||
|
existing.imdbId = imdbId;
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
serviceId !== undefined &&
|
serviceId !== undefined &&
|
||||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||||
@@ -173,6 +192,7 @@ class BaseScanner<T> {
|
|||||||
} else {
|
} else {
|
||||||
const newMedia = new Media();
|
const newMedia = new Media();
|
||||||
newMedia.tmdbId = tmdbId;
|
newMedia.tmdbId = tmdbId;
|
||||||
|
newMedia.imdbId = imdbId;
|
||||||
|
|
||||||
newMedia.status =
|
newMedia.status =
|
||||||
!is4k && !processing
|
!is4k && !processing
|
||||||
@@ -203,6 +223,13 @@ class BaseScanner<T> {
|
|||||||
newMedia.ratingKey4k =
|
newMedia.ratingKey4k =
|
||||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jellyfinMediaId) {
|
||||||
|
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||||
|
newMedia.jellyfinMediaId4k =
|
||||||
|
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
await mediaRepository.save(newMedia);
|
await mediaRepository.save(newMedia);
|
||||||
this.log(`Saved new media: ${title}`);
|
this.log(`Saved new media: ${title}`);
|
||||||
}
|
}
|
||||||
@@ -221,11 +248,12 @@ class BaseScanner<T> {
|
|||||||
*/
|
*/
|
||||||
protected async processShow(
|
protected async processShow(
|
||||||
tmdbId: number,
|
tmdbId: number,
|
||||||
tvdbId: number,
|
tvdbId: number | undefined,
|
||||||
seasons: ProcessableSeason[],
|
seasons: ProcessableSeason[],
|
||||||
{
|
{
|
||||||
mediaAddedAt,
|
mediaAddedAt,
|
||||||
ratingKey,
|
ratingKey,
|
||||||
|
jellyfinMediaId,
|
||||||
serviceId,
|
serviceId,
|
||||||
externalServiceId,
|
externalServiceId,
|
||||||
externalServiceSlug,
|
externalServiceSlug,
|
||||||
@@ -257,7 +285,7 @@ class BaseScanner<T> {
|
|||||||
(es) => es.seasonNumber === season.seasonNumber
|
(es) => es.seasonNumber === season.seasonNumber
|
||||||
);
|
);
|
||||||
|
|
||||||
// We update the rating keys in the seasons loop because we need episode counts
|
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||||
media.ratingKey = ratingKey;
|
media.ratingKey = ratingKey;
|
||||||
}
|
}
|
||||||
@@ -271,6 +299,23 @@ class BaseScanner<T> {
|
|||||||
media.ratingKey4k = ratingKey;
|
media.ratingKey4k = ratingKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media &&
|
||||||
|
season.episodes > 0 &&
|
||||||
|
media.jellyfinMediaId !== jellyfinMediaId
|
||||||
|
) {
|
||||||
|
media.jellyfinMediaId = jellyfinMediaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media &&
|
||||||
|
season.episodes4k > 0 &&
|
||||||
|
this.enable4kShow &&
|
||||||
|
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||||
|
) {
|
||||||
|
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||||
|
}
|
||||||
|
|
||||||
if (existingSeason) {
|
if (existingSeason) {
|
||||||
// Here we update seasons if they already exist.
|
// Here we update seasons if they already exist.
|
||||||
// If the season is already marked as available, we
|
// If the season is already marked as available, we
|
||||||
@@ -491,6 +536,22 @@ class BaseScanner<T> {
|
|||||||
)
|
)
|
||||||
? ratingKey
|
? ratingKey
|
||||||
: undefined,
|
: undefined,
|
||||||
|
jellyfinMediaId: newSeasons.some(
|
||||||
|
(sn) =>
|
||||||
|
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
sn.status === MediaStatus.AVAILABLE
|
||||||
|
)
|
||||||
|
? jellyfinMediaId
|
||||||
|
: undefined,
|
||||||
|
jellyfinMediaId4k:
|
||||||
|
this.enable4kShow &&
|
||||||
|
newSeasons.some(
|
||||||
|
(sn) =>
|
||||||
|
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
sn.status4k === MediaStatus.AVAILABLE
|
||||||
|
)
|
||||||
|
? jellyfinMediaId
|
||||||
|
: undefined,
|
||||||
status: isAllStandardSeasons
|
status: isAllStandardSeasons
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: newSeasons.some(
|
: newSeasons.some(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import animeList from '@server/api/animelist';
|
import animeList from '@server/api/animelist';
|
||||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
import type {
|
||||||
|
JellyfinLibraryItem,
|
||||||
|
JellyfinLibraryItemExtended,
|
||||||
|
} from '@server/api/jellyfin';
|
||||||
import JellyfinAPI from '@server/api/jellyfin';
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
import { getMetadataProvider } from '@server/api/metadata';
|
import { getMetadataProvider } from '@server/api/metadata';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
@@ -8,132 +11,119 @@ import type {
|
|||||||
TmdbKeyword,
|
TmdbKeyword,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
|
||||||
import Season from '@server/entity/Season';
|
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
|
import type {
|
||||||
|
ProcessableSeason,
|
||||||
|
RunnableScanner,
|
||||||
|
StatusBase,
|
||||||
|
} from '@server/lib/scanners/baseScanner';
|
||||||
|
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||||
import type { Library } from '@server/lib/settings';
|
import type { Library } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
|
||||||
import AsyncLock from '@server/utils/asyncLock';
|
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { randomUUID as uuid } from 'crypto';
|
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
const BUNDLE_SIZE = 20;
|
interface JellyfinSyncStatus extends StatusBase {
|
||||||
const UPDATE_RATE = 4 * 1000;
|
|
||||||
|
|
||||||
interface SyncStatus {
|
|
||||||
running: boolean;
|
|
||||||
progress: number;
|
|
||||||
total: number;
|
|
||||||
currentLibrary: Library;
|
currentLibrary: Library;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class JellyfinScanner {
|
class JellyfinScanner
|
||||||
private sessionId: string;
|
extends BaseScanner<JellyfinLibraryItem>
|
||||||
private tmdb: TheMovieDb;
|
implements RunnableScanner<JellyfinSyncStatus>
|
||||||
|
{
|
||||||
private jfClient: JellyfinAPI;
|
private jfClient: JellyfinAPI;
|
||||||
private items: JellyfinLibraryItem[] = [];
|
|
||||||
private progress = 0;
|
|
||||||
private libraries: Library[];
|
private libraries: Library[];
|
||||||
private currentLibrary: Library;
|
private currentLibrary: Library;
|
||||||
private running = false;
|
|
||||||
private isRecentOnly = false;
|
private isRecentOnly = false;
|
||||||
private enable4kMovie = false;
|
|
||||||
private enable4kShow = false;
|
|
||||||
private asyncLock = new AsyncLock();
|
|
||||||
private processedAnidbSeason: Map<number, Map<number, number>>;
|
private processedAnidbSeason: Map<number, Map<number, number>>;
|
||||||
|
|
||||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||||
this.tmdb = new TheMovieDb();
|
super('Jellyfin Sync');
|
||||||
|
|
||||||
this.isRecentOnly = isRecentOnly ?? false;
|
this.isRecentOnly = isRecentOnly ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
|
||||||
const mediaRepository = getRepository(Media);
|
tmdbId: number;
|
||||||
|
imdbId?: string;
|
||||||
|
metadata: JellyfinLibraryItemExtended;
|
||||||
|
} | null> {
|
||||||
|
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||||
|
|
||||||
const existing = await mediaRepository.findOne({
|
if (!metadata?.Id) {
|
||||||
where: { tmdbId: tmdbId, mediaType },
|
this.log('No Id metadata for this title. Skipping', 'debug', {
|
||||||
});
|
jellyfinItemId: jellyfinitem.Id,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return existing;
|
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
|
||||||
|
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
||||||
|
let imdbId = metadata.ProviderIds.Imdb;
|
||||||
|
|
||||||
|
// We use anidb only if we have the anidbId and nothing else
|
||||||
|
if (anidbId && !imdbId && !tmdbId) {
|
||||||
|
const result = animeList.getFromAnidbId(anidbId);
|
||||||
|
tmdbId = Number(result?.tmdbId ?? null);
|
||||||
|
imdbId = result?.imdbId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId && !tmdbId) {
|
||||||
|
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
||||||
|
imdbId: imdbId,
|
||||||
|
});
|
||||||
|
tmdbId = tmdbMovie.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tmdbId) {
|
||||||
|
throw new Error('Unable to find TMDb ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// With AniDB we can have mixed libraries with movies in a "show" library
|
||||||
|
// We take the first episode of the first season (the movie) and use it to
|
||||||
|
// get more information, like the MediaSource
|
||||||
|
if (anidbId && metadata.Type === 'Series') {
|
||||||
|
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
|
||||||
|
(md) => {
|
||||||
|
return md.IndexNumber === 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!season) {
|
||||||
|
this.log('No season found for anidb movie', 'debug', {
|
||||||
|
jellyfinitem,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const episodes = await this.jfClient.getEpisodes(
|
||||||
|
jellyfinitem.Id,
|
||||||
|
season.Id
|
||||||
|
);
|
||||||
|
if (!episodes[0]) {
|
||||||
|
this.log('No episode found for anidb movie', 'debug', {
|
||||||
|
jellyfinitem,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
metadata = await this.jfClient.getItemData(episodes[0].Id);
|
||||||
|
if (!metadata) {
|
||||||
|
this.log('No metadata found for anidb movie', 'debug', {
|
||||||
|
jellyfinitem,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tmdbId, imdbId, metadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
|
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
const extracted = await this.extractMovieIds(jellyfinitem);
|
||||||
const newMedia = new Media();
|
if (!extracted) return;
|
||||||
|
|
||||||
if (!metadata?.Id) {
|
const { tmdbId, imdbId, metadata } = extracted;
|
||||||
logger.debug('No Id metadata for this title. Skipping', {
|
|
||||||
label: 'Jellyfin Sync',
|
|
||||||
jellyfinItemId: jellyfinitem.Id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
|
|
||||||
|
|
||||||
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
|
||||||
newMedia.imdbId = metadata.ProviderIds.Imdb;
|
|
||||||
|
|
||||||
// We use anidb only if we have the anidbId and nothing else
|
|
||||||
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) {
|
|
||||||
const result = animeList.getFromAnidbId(anidbId);
|
|
||||||
newMedia.tmdbId = Number(result?.tmdbId ?? null);
|
|
||||||
newMedia.imdbId = result?.imdbId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
|
|
||||||
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
|
||||||
imdbId: newMedia.imdbId,
|
|
||||||
});
|
|
||||||
newMedia.tmdbId = tmdbMovie.id;
|
|
||||||
}
|
|
||||||
if (!newMedia.tmdbId) {
|
|
||||||
throw new Error('Unable to find TMDb ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// With AniDB we can have mixed libraries with movies in a "show" library
|
|
||||||
// We take the first episode of the first season (the movie) and use it to
|
|
||||||
// get more information, like the MediaSource
|
|
||||||
if (anidbId && metadata.Type === 'Series') {
|
|
||||||
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
|
|
||||||
(md) => {
|
|
||||||
return md.IndexNumber === 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!season) {
|
|
||||||
this.log('No season found for anidb movie', 'debug', {
|
|
||||||
jellyfinitem,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const episodes = await this.jfClient.getEpisodes(
|
|
||||||
jellyfinitem.Id,
|
|
||||||
season.Id
|
|
||||||
);
|
|
||||||
if (!episodes[0]) {
|
|
||||||
this.log('No episode found for anidb movie', 'debug', {
|
|
||||||
jellyfinitem,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
metadata = await this.jfClient.getItemData(episodes[0].Id);
|
|
||||||
if (!metadata) {
|
|
||||||
this.log('No metadata found for anidb movie', 'debug', {
|
|
||||||
jellyfinitem,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.filter(
|
return MediaSource.MediaStreams.filter(
|
||||||
@@ -151,93 +141,29 @@ class JellyfinScanner {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
|
const mediaAddedAt = metadata.DateCreated
|
||||||
if (!metadata) {
|
? new Date(metadata.DateCreated)
|
||||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
: undefined;
|
||||||
// outer scope back to null before this async gets called
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await this.getExisting(
|
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
||||||
newMedia.tmdbId,
|
await this.processMovie(tmdbId, {
|
||||||
MediaType.MOVIE
|
is4k: false,
|
||||||
);
|
mediaAddedAt,
|
||||||
|
jellyfinMediaId: metadata.Id,
|
||||||
|
imdbId,
|
||||||
|
title: metadata.Name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (has4k && this.enable4kMovie) {
|
||||||
let changedExisting = false;
|
await this.processMovie(tmdbId, {
|
||||||
|
is4k: true,
|
||||||
if (
|
mediaAddedAt,
|
||||||
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
|
jellyfinMediaId: metadata.Id,
|
||||||
existing.status !== MediaStatus.AVAILABLE
|
imdbId,
|
||||||
) {
|
title: metadata.Name,
|
||||||
existing.status = MediaStatus.AVAILABLE;
|
});
|
||||||
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
}
|
||||||
changedExisting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
has4k &&
|
|
||||||
this.enable4kMovie &&
|
|
||||||
existing.status4k !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
existing.status4k = MediaStatus.AVAILABLE;
|
|
||||||
changedExisting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existing.mediaAddedAt && !changedExisting) {
|
|
||||||
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
|
||||||
changedExisting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
|
|
||||||
existing.jellyfinMediaId !== metadata.Id
|
|
||||||
) {
|
|
||||||
existing.jellyfinMediaId = metadata.Id;
|
|
||||||
changedExisting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
has4k &&
|
|
||||||
this.enable4kMovie &&
|
|
||||||
existing.jellyfinMediaId4k !== metadata.Id
|
|
||||||
) {
|
|
||||||
existing.jellyfinMediaId4k = metadata.Id;
|
|
||||||
changedExisting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changedExisting) {
|
|
||||||
await mediaRepository.save(existing);
|
|
||||||
this.log(
|
|
||||||
`Request for ${metadata.Name} exists. New media types set to AVAILABLE`,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.log(
|
|
||||||
`Title already exists and no new media types found ${metadata.Name}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newMedia.status =
|
|
||||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN;
|
|
||||||
newMedia.status4k =
|
|
||||||
has4k && this.enable4kMovie
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN;
|
|
||||||
newMedia.mediaType = MediaType.MOVIE;
|
|
||||||
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
|
||||||
newMedia.jellyfinMediaId =
|
|
||||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
|
||||||
? metadata.Id
|
|
||||||
: null;
|
|
||||||
newMedia.jellyfinMediaId4k =
|
|
||||||
has4k && this.enable4kMovie ? metadata.Id : null;
|
|
||||||
await mediaRepository.save(newMedia);
|
|
||||||
this.log(`Saved ${metadata.Name}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log(
|
this.log(
|
||||||
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
||||||
@@ -286,9 +212,7 @@ class JellyfinScanner {
|
|||||||
return tvShow;
|
return tvShow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
|
|
||||||
let tvShow: TmdbTvDetails | null = null;
|
let tvShow: TmdbTvDetails | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -297,8 +221,7 @@ class JellyfinScanner {
|
|||||||
const metadata = await this.jfClient.getItemData(Id);
|
const metadata = await this.jfClient.getItemData(Id);
|
||||||
|
|
||||||
if (!metadata?.Id) {
|
if (!metadata?.Id) {
|
||||||
logger.debug('No Id metadata for this title. Skipping', {
|
this.log('No Id metadata for this title. Skipping', 'debug', {
|
||||||
label: 'Jellyfin Sync',
|
|
||||||
jellyfinItemId: jellyfinitem.Id,
|
jellyfinItemId: jellyfinitem.Id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -315,6 +238,7 @@ class JellyfinScanner {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
||||||
try {
|
try {
|
||||||
tvShow = await this.getTvShow({
|
tvShow = await this.getTvShow({
|
||||||
@@ -326,6 +250,7 @@ class JellyfinScanner {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let tvdbSeasonFromAnidb: number | undefined;
|
let tvdbSeasonFromAnidb: number | undefined;
|
||||||
if (!tvShow && metadata.ProviderIds.AniDB) {
|
if (!tvShow && metadata.ProviderIds.AniDB) {
|
||||||
const anidbId = Number(metadata.ProviderIds.AniDB);
|
const anidbId = Number(metadata.ProviderIds.AniDB);
|
||||||
@@ -344,71 +269,49 @@ class JellyfinScanner {
|
|||||||
}
|
}
|
||||||
// With AniDB we can have mixed libraries with movies in a "show" library
|
// With AniDB we can have mixed libraries with movies in a "show" library
|
||||||
else if (result?.imdbId || result?.tmdbId) {
|
else if (result?.imdbId || result?.tmdbId) {
|
||||||
await this.processMovie(jellyfinitem);
|
await this.processJellyfinMovie(jellyfinitem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tvShow) {
|
if (tvShow) {
|
||||||
await this.asyncLock.dispatch(tvShow.id, async () => {
|
const seasons = tvShow.seasons;
|
||||||
if (!tvShow) {
|
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
|
||||||
// outer scope back to null before this async gets called
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lets get the available seasons from Jellyfin
|
const processableSeasons: ProcessableSeason[] = [];
|
||||||
const seasons = tvShow.seasons;
|
|
||||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
|
||||||
|
|
||||||
const newSeasons: Season[] = [];
|
const settings = getSettings();
|
||||||
|
const filteredSeasons = settings.main.enableSpecialEpisodes
|
||||||
|
? seasons
|
||||||
|
: seasons.filter((sn) => sn.season_number !== 0);
|
||||||
|
|
||||||
const currentStandardSeasonAvailable = (
|
for (const season of filteredSeasons) {
|
||||||
media?.seasons.filter(
|
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
||||||
(season) => season.status === MediaStatus.AVAILABLE
|
if (tvdbSeasonFromAnidb) {
|
||||||
) ?? []
|
// In AniDB we don't have the concept of seasons,
|
||||||
).length;
|
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
|
||||||
const current4kSeasonAvailable = (
|
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
|
||||||
media?.seasons.filter(
|
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
|
||||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
return (
|
||||||
) ?? []
|
tvdbSeasonFromAnidb === season.season_number &&
|
||||||
).length;
|
md.IndexNumber === 1
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Number(md.IndexNumber) === season.season_number;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const season of seasons) {
|
// Check if we found the matching season and it has all the available episodes
|
||||||
const JellyfinSeasons = await this.jfClient.getSeasons(Id);
|
if (matchedJellyfinSeason) {
|
||||||
const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
|
let totalStandard = 0;
|
||||||
if (tvdbSeasonFromAnidb) {
|
let total4k = 0;
|
||||||
// In AniDB we don't have the concept of seasons,
|
|
||||||
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
|
|
||||||
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
|
|
||||||
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
|
|
||||||
return (
|
|
||||||
tvdbSeasonFromAnidb === season.season_number &&
|
|
||||||
md.IndexNumber === 1
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Number(md.IndexNumber) === season.season_number;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingSeason = media?.seasons.find(
|
if (!this.enable4kShow) {
|
||||||
(es) => es.seasonNumber === season.season_number
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if we found the matching season and it has all the available episodes
|
|
||||||
if (matchedJellyfinSeason) {
|
|
||||||
// If we have a matched Jellyfin season, get its children metadata so we can check details
|
|
||||||
const episodes = await this.jfClient.getEpisodes(
|
const episodes = await this.jfClient.getEpisodes(
|
||||||
Id,
|
Id,
|
||||||
matchedJellyfinSeason.Id
|
matchedJellyfinSeason.Id
|
||||||
);
|
);
|
||||||
|
|
||||||
//Get count of episodes that are HD and 4K
|
|
||||||
let totalStandard = 0;
|
|
||||||
let total4k = 0;
|
|
||||||
|
|
||||||
//use for loop to make sure this loop _completes_ in full
|
|
||||||
//before the next section
|
|
||||||
for (const episode of episodes) {
|
for (const episode of episodes) {
|
||||||
let episodeCount = 1;
|
let episodeCount = 1;
|
||||||
|
|
||||||
@@ -421,238 +324,94 @@ class JellyfinScanner {
|
|||||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.enable4kShow) {
|
totalStandard += episodeCount;
|
||||||
totalStandard += episodeCount;
|
}
|
||||||
} else {
|
} else {
|
||||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
// 4K detection enabled - request media info to check resolution
|
||||||
episode.Id
|
const episodes = await this.jfClient.getEpisodes(
|
||||||
);
|
Id,
|
||||||
|
matchedJellyfinSeason.Id,
|
||||||
|
{ includeMediaInfo: true }
|
||||||
|
);
|
||||||
|
|
||||||
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
|
for (const episode of episodes) {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
let episodeCount = 1;
|
||||||
if (MediaStream.Type === 'Video') {
|
|
||||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
// count number of combined episodes
|
||||||
total4k += episodeCount;
|
if (
|
||||||
} else {
|
episode.IndexNumber !== undefined &&
|
||||||
totalStandard += episodeCount;
|
episode.IndexNumberEnd !== undefined
|
||||||
}
|
) {
|
||||||
}
|
episodeCount =
|
||||||
});
|
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// With AniDB we can have multiple shows for one season, so we need to save
|
const has4k = episode.MediaSources?.some((MediaSource) =>
|
||||||
// the episode from all the jellyfin entries to get the total
|
MediaSource.MediaStreams.some(
|
||||||
if (tvdbSeasonFromAnidb) {
|
(MediaStream) =>
|
||||||
if (this.processedAnidbSeason.has(tvShow.id)) {
|
MediaStream.Type === 'Video' &&
|
||||||
const show = this.processedAnidbSeason.get(tvShow.id)!;
|
(MediaStream.Width ?? 0) > 2000
|
||||||
if (show.has(season.season_number)) {
|
)
|
||||||
show.set(
|
|
||||||
season.season_number,
|
|
||||||
show.get(season.season_number)! + totalStandard
|
|
||||||
);
|
|
||||||
|
|
||||||
totalStandard = show.get(season.season_number)!;
|
|
||||||
} else {
|
|
||||||
show.set(season.season_number, totalStandard);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.processedAnidbSeason.set(
|
|
||||||
tvShow.id,
|
|
||||||
new Map([[season.season_number, totalStandard]])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media &&
|
|
||||||
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
|
|
||||||
media.jellyfinMediaId !== Id
|
|
||||||
) {
|
|
||||||
media.jellyfinMediaId = Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media &&
|
|
||||||
total4k > 0 &&
|
|
||||||
this.enable4kShow &&
|
|
||||||
media.jellyfinMediaId4k !== Id
|
|
||||||
) {
|
|
||||||
media.jellyfinMediaId4k = Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingSeason) {
|
|
||||||
// These ternary statements look super confusing, but they are simply
|
|
||||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
|
||||||
// and then not modifying the status if there are 0 items
|
|
||||||
existingSeason.status =
|
|
||||||
totalStandard >= season.episode_count ||
|
|
||||||
existingSeason.status === MediaStatus.AVAILABLE
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: totalStandard > 0
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: existingSeason.status;
|
|
||||||
existingSeason.status4k =
|
|
||||||
(this.enable4kShow && total4k >= season.episode_count) ||
|
|
||||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: this.enable4kShow && total4k > 0
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: existingSeason.status4k;
|
|
||||||
} else {
|
|
||||||
newSeasons.push(
|
|
||||||
new Season({
|
|
||||||
seasonNumber: season.season_number,
|
|
||||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
|
||||||
// if we dont have any items for the season
|
|
||||||
status:
|
|
||||||
totalStandard >= season.episode_count
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: totalStandard > 0
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN,
|
|
||||||
status4k:
|
|
||||||
this.enable4kShow && total4k >= season.episode_count
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: this.enable4kShow && total4k > 0
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasStandard = episode.MediaSources?.some((MediaSource) =>
|
||||||
|
MediaSource.MediaStreams.some(
|
||||||
|
(MediaStream) =>
|
||||||
|
MediaStream.Type === 'Video' &&
|
||||||
|
(MediaStream.Width ?? 0) <= 2000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count in both if episode has both versions
|
||||||
|
// TODO: Make this more robust in the future
|
||||||
|
// Currently, this detection is based solely on file resolution, not which
|
||||||
|
// Radarr/Sonarr instance the file came from. If a 4K request results in
|
||||||
|
// 1080p files (no 4K release available yet), those files will be counted
|
||||||
|
// as "standard" even though they're in the 4K library. This can cause
|
||||||
|
// non-4K users to see content as "available" when they can't access it.
|
||||||
|
// See issue https://github.com/seerr-team/seerr/issues/1744 for details.
|
||||||
|
if (hasStandard) totalStandard += episodeCount;
|
||||||
|
if (has4k) total4k += episodeCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Remove extras season. We dont count it for determining availability
|
// With AniDB we can have multiple shows for one season, so we need to save
|
||||||
const filteredSeasons = tvShow.seasons.filter(
|
// the episode from all the jellyfin entries to get the total
|
||||||
(season) => season.season_number !== 0
|
if (tvdbSeasonFromAnidb) {
|
||||||
);
|
let show = this.processedAnidbSeason.get(tvShow.id);
|
||||||
|
|
||||||
const isAllStandardSeasons =
|
if (!show) {
|
||||||
newSeasons.filter(
|
show = new Map([[season.season_number, totalStandard]]);
|
||||||
(season) => season.status === MediaStatus.AVAILABLE
|
this.processedAnidbSeason.set(tvShow.id, show);
|
||||||
).length +
|
} else {
|
||||||
(media?.seasons.filter(
|
const currentCount = show.get(season.season_number) ?? 0;
|
||||||
(season) => season.status === MediaStatus.AVAILABLE
|
const newCount = currentCount + totalStandard;
|
||||||
).length ?? 0) >=
|
show.set(season.season_number, newCount);
|
||||||
filteredSeasons.length;
|
totalStandard = newCount;
|
||||||
|
}
|
||||||
const isAll4kSeasons =
|
|
||||||
newSeasons.filter(
|
|
||||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
|
||||||
).length +
|
|
||||||
(media?.seasons.filter(
|
|
||||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
|
||||||
).length ?? 0) >=
|
|
||||||
filteredSeasons.length;
|
|
||||||
|
|
||||||
if (media) {
|
|
||||||
// Update existing
|
|
||||||
media.seasons = [...media.seasons, ...newSeasons];
|
|
||||||
|
|
||||||
const newStandardSeasonAvailable = (
|
|
||||||
media.seasons.filter(
|
|
||||||
(season) => season.status === MediaStatus.AVAILABLE
|
|
||||||
) ?? []
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const new4kSeasonAvailable = (
|
|
||||||
media.seasons.filter(
|
|
||||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
|
||||||
) ?? []
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// If at least one new season has become available, update
|
|
||||||
// the lastSeasonChange field so we can trigger notifications
|
|
||||||
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
|
|
||||||
this.log(
|
|
||||||
`Detected ${
|
|
||||||
newStandardSeasonAvailable - currentStandardSeasonAvailable
|
|
||||||
} new standard season(s) for ${tvShow.name}`,
|
|
||||||
'debug'
|
|
||||||
);
|
|
||||||
media.lastSeasonChange = new Date();
|
|
||||||
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new4kSeasonAvailable > current4kSeasonAvailable) {
|
processableSeasons.push({
|
||||||
this.log(
|
seasonNumber: season.season_number,
|
||||||
`Detected ${
|
totalEpisodes: season.episode_count,
|
||||||
new4kSeasonAvailable - current4kSeasonAvailable
|
episodes: totalStandard,
|
||||||
} new 4K season(s) for ${tvShow.name}`,
|
episodes4k: total4k,
|
||||||
'debug'
|
|
||||||
);
|
|
||||||
media.lastSeasonChange = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!media.mediaAddedAt) {
|
|
||||||
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the show is already available, and there are no new seasons, dont adjust
|
|
||||||
// the status
|
|
||||||
const shouldStayAvailable =
|
|
||||||
media.status === MediaStatus.AVAILABLE &&
|
|
||||||
newSeasons.filter(
|
|
||||||
(season) => season.status !== MediaStatus.UNKNOWN
|
|
||||||
).length === 0;
|
|
||||||
const shouldStayAvailable4k =
|
|
||||||
media.status4k === MediaStatus.AVAILABLE &&
|
|
||||||
newSeasons.filter(
|
|
||||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
|
||||||
).length === 0;
|
|
||||||
|
|
||||||
media.status =
|
|
||||||
isAllStandardSeasons || shouldStayAvailable
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: media.seasons.some(
|
|
||||||
(season) => season.status !== MediaStatus.UNKNOWN
|
|
||||||
)
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN;
|
|
||||||
media.status4k =
|
|
||||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: this.enable4kShow &&
|
|
||||||
media.seasons.some(
|
|
||||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
|
||||||
)
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN;
|
|
||||||
await mediaRepository.save(media);
|
|
||||||
this.log(`Updating existing title: ${tvShow.name}`);
|
|
||||||
} else {
|
|
||||||
const newMedia = new Media({
|
|
||||||
mediaType: MediaType.TV,
|
|
||||||
seasons: newSeasons,
|
|
||||||
tmdbId: tvShow.id,
|
|
||||||
tvdbId: tvShow.external_ids.tvdb_id,
|
|
||||||
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
|
|
||||||
jellyfinMediaId: isAllStandardSeasons ? Id : null,
|
|
||||||
jellyfinMediaId4k:
|
|
||||||
isAll4kSeasons && this.enable4kShow ? Id : null,
|
|
||||||
status: isAllStandardSeasons
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: newSeasons.some(
|
|
||||||
(season) => season.status !== MediaStatus.UNKNOWN
|
|
||||||
)
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN,
|
|
||||||
status4k:
|
|
||||||
isAll4kSeasons && this.enable4kShow
|
|
||||||
? MediaStatus.AVAILABLE
|
|
||||||
: this.enable4kShow &&
|
|
||||||
newSeasons.some(
|
|
||||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
|
||||||
)
|
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
: MediaStatus.UNKNOWN,
|
|
||||||
});
|
});
|
||||||
await mediaRepository.save(newMedia);
|
|
||||||
this.log(`Saved ${tvShow.name}`);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
await this.processShow(
|
||||||
|
tvShow.id,
|
||||||
|
tvShow.external_ids?.tvdb_id,
|
||||||
|
processableSeasons,
|
||||||
|
{
|
||||||
|
mediaAddedAt: metadata.DateCreated
|
||||||
|
? new Date(metadata.DateCreated)
|
||||||
|
: undefined,
|
||||||
|
jellyfinMediaId: Id,
|
||||||
|
title: tvShow.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.log(
|
this.log(
|
||||||
`No information found for the show: ${metadata.Name}`,
|
`No information found for the show: ${metadata.Name}`,
|
||||||
@@ -668,70 +427,17 @@ class JellyfinScanner {
|
|||||||
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
|
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
|
||||||
}`,
|
}`,
|
||||||
'error',
|
'error',
|
||||||
{
|
{ errorMessage: e.message, jellyfinitem }
|
||||||
errorMessage: e.message,
|
|
||||||
jellyfinitem,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processItems(slicedItems: JellyfinLibraryItem[]) {
|
private async processItem(item: JellyfinLibraryItem): Promise<void> {
|
||||||
this.processedAnidbSeason = new Map();
|
if (item.Type === 'Movie') {
|
||||||
await Promise.all(
|
await this.processJellyfinMovie(item);
|
||||||
slicedItems.map(async (item) => {
|
} else if (item.Type === 'Series') {
|
||||||
if (item.Type === 'Movie') {
|
await this.processJellyfinShow(item);
|
||||||
await this.processMovie(item);
|
|
||||||
} else if (item.Type === 'Series') {
|
|
||||||
await this.processShow(item);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loop({
|
|
||||||
start = 0,
|
|
||||||
end = BUNDLE_SIZE,
|
|
||||||
sessionId,
|
|
||||||
}: {
|
|
||||||
start?: number;
|
|
||||||
end?: number;
|
|
||||||
sessionId?: string;
|
|
||||||
} = {}) {
|
|
||||||
const slicedItems = this.items.slice(start, end);
|
|
||||||
|
|
||||||
if (!this.running) {
|
|
||||||
throw new Error('Sync was aborted.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sessionId !== sessionId) {
|
|
||||||
throw new Error('New session was started. Old session aborted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start < this.items.length) {
|
|
||||||
this.progress = start;
|
|
||||||
await this.processItems(slicedItems);
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
|
||||||
setTimeout(() => {
|
|
||||||
this.loop({
|
|
||||||
start: start + BUNDLE_SIZE,
|
|
||||||
end: end + BUNDLE_SIZE,
|
|
||||||
sessionId,
|
|
||||||
})
|
|
||||||
.then(() => resolve())
|
|
||||||
.catch((e) => reject(new Error(e.message)));
|
|
||||||
}, UPDATE_RATE)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private log(
|
|
||||||
message: string,
|
|
||||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
|
||||||
optional?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
logger[level](message, { label: 'Jellyfin Sync', ...optional });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async run(): Promise<void> {
|
public async run(): Promise<void> {
|
||||||
@@ -744,14 +450,9 @@ class JellyfinScanner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = uuid();
|
const sessionId = this.startRun();
|
||||||
this.sessionId = sessionId;
|
|
||||||
logger.info('Jellyfin Sync Starting', {
|
|
||||||
sessionId,
|
|
||||||
label: 'Jellyfin Sync',
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
this.running = true;
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
@@ -777,25 +478,11 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
await animeList.sync();
|
await animeList.sync();
|
||||||
|
|
||||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
|
||||||
if (this.enable4kMovie) {
|
|
||||||
this.log(
|
|
||||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
|
|
||||||
if (this.enable4kShow) {
|
|
||||||
this.log(
|
|
||||||
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isRecentOnly) {
|
if (this.isRecentOnly) {
|
||||||
for (const library of this.libraries) {
|
for (const library of this.libraries) {
|
||||||
this.currentLibrary = library;
|
this.currentLibrary = library;
|
||||||
|
// Reset AniDB season tracking per library
|
||||||
|
this.processedAnidbSeason = new Map();
|
||||||
this.log(
|
this.log(
|
||||||
`Beginning to process recently added for library: ${library.name}`,
|
`Beginning to process recently added for library: ${library.name}`,
|
||||||
'info'
|
'info'
|
||||||
@@ -815,16 +502,19 @@ class JellyfinScanner {
|
|||||||
return mediaA.Id === mediaB.Id;
|
return mediaA.Id === mediaB.Id;
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.loop({ sessionId });
|
await this.loop(this.processItem.bind(this), { sessionId });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const library of this.libraries) {
|
for (const library of this.libraries) {
|
||||||
this.currentLibrary = library;
|
this.currentLibrary = library;
|
||||||
|
// Reset AniDB season tracking per library
|
||||||
|
this.processedAnidbSeason = new Map();
|
||||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||||
this.items = await this.jfClient.getLibraryContents(library.id);
|
this.items = await this.jfClient.getLibraryContents(library.id);
|
||||||
await this.loop({ sessionId });
|
await this.loop(this.processItem.bind(this), { sessionId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log(
|
this.log(
|
||||||
this.isRecentOnly
|
this.isRecentOnly
|
||||||
? 'Recently Added Scan Complete'
|
? 'Recently Added Scan Complete'
|
||||||
@@ -832,19 +522,13 @@ class JellyfinScanner {
|
|||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Sync interrupted', {
|
this.log('Sync interrupted', 'error', { errorMessage: e.message });
|
||||||
label: 'Jellyfin Sync',
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
// If a new scanning session hasnt started, set running back to false
|
this.endRun(sessionId);
|
||||||
if (this.sessionId === sessionId) {
|
|
||||||
this.running = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public status(): SyncStatus {
|
public status(): JellyfinSyncStatus {
|
||||||
return {
|
return {
|
||||||
running: this.running,
|
running: this.running,
|
||||||
progress: this.progress,
|
progress: this.progress,
|
||||||
@@ -853,10 +537,6 @@ class JellyfinScanner {
|
|||||||
libraries: this.libraries,
|
libraries: this.libraries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public cancel(): void {
|
|
||||||
this.running = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jellyfinFullScanner = new JellyfinScanner();
|
export const jellyfinFullScanner = new JellyfinScanner();
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -626,76 +626,6 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainUser = await userRepository.findOneOrFail({
|
|
||||||
select: { id: true, plexToken: true, plexId: true },
|
|
||||||
where: { id: 1 },
|
|
||||||
});
|
|
||||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
|
||||||
|
|
||||||
if (!user.plexId) {
|
|
||||||
try {
|
|
||||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
|
||||||
const account = plexUsersResponse.MediaContainer.User.find(
|
|
||||||
(account) =>
|
|
||||||
account.$.email &&
|
|
||||||
account.$.email.toLowerCase() === user.email.toLowerCase()
|
|
||||||
)?.$;
|
|
||||||
|
|
||||||
if (
|
|
||||||
account &&
|
|
||||||
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
|
|
||||||
) {
|
|
||||||
logger.info(
|
|
||||||
'Found matching Plex user; updating user with Plex data',
|
|
||||||
{
|
|
||||||
label: 'API',
|
|
||||||
ip: req.ip,
|
|
||||||
email: body.email,
|
|
||||||
userId: user.id,
|
|
||||||
plexId: account.id,
|
|
||||||
plexUsername: account.username,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
user.plexId = parseInt(account.id);
|
|
||||||
user.avatar = account.thumb;
|
|
||||||
user.email = account.email;
|
|
||||||
user.plexUsername = account.username;
|
|
||||||
user.userType = UserType.PLEX;
|
|
||||||
|
|
||||||
await userRepository.save(user);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong fetching Plex users', {
|
|
||||||
label: 'API',
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
user.plexId &&
|
|
||||||
user.plexId !== mainUser.plexId &&
|
|
||||||
!(await mainPlexTv.checkUserAccess(user.plexId))
|
|
||||||
) {
|
|
||||||
logger.warn(
|
|
||||||
'Failed sign-in attempt from Plex user without access to the media server',
|
|
||||||
{
|
|
||||||
label: 'API',
|
|
||||||
account: {
|
|
||||||
ip: req.ip,
|
|
||||||
email: body.email,
|
|
||||||
userId: user.id,
|
|
||||||
plexId: user.plexId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return next({
|
|
||||||
status: 403,
|
|
||||||
message: 'Access denied.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set logged in session
|
// Set logged in session
|
||||||
if (user && req.session) {
|
if (user && req.session) {
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
@@ -775,7 +705,7 @@ authRoutes.post('/logout', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
return next({ status: 500, message: 'Failed to destroy session.' });
|
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||||
}
|
}
|
||||||
logger.info('Successfully logged out user', {
|
logger.debug('Successfully logged out user', {
|
||||||
label: 'Auth',
|
label: 'Auth',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
|
|||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
import { findIndex, sortBy } from 'lodash';
|
import { findIndex, sortBy } from 'lodash';
|
||||||
import { In } from 'typeorm';
|
import type { EntityManager } from 'typeorm';
|
||||||
|
import { In, Not } from 'typeorm';
|
||||||
import userSettingsRoutes from './usersettings';
|
import userSettingsRoutes from './usersettings';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -188,30 +189,82 @@ router.post<
|
|||||||
}
|
}
|
||||||
>('/registerPushSubscription', async (req, res, next) => {
|
>('/registerPushSubscription', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
// This prevents race conditions where two requests both pass the checks
|
||||||
|
await dataSource.transaction(
|
||||||
|
async (transactionalEntityManager: EntityManager) => {
|
||||||
|
const transactionalRepo =
|
||||||
|
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||||
|
|
||||||
const existingSubs = await userPushSubRepository.find({
|
// Check for existing subscription by auth or endpoint within transaction
|
||||||
relations: { user: true },
|
const existingSubscription = await transactionalRepo.findOne({
|
||||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
relations: { user: true },
|
||||||
});
|
where: [
|
||||||
|
{ auth: req.body.auth, user: { id: req.user?.id } },
|
||||||
|
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
if (existingSubs.length > 0) {
|
if (existingSubscription) {
|
||||||
logger.debug(
|
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
|
||||||
'User push subscription already exists. Skipping registration.',
|
if (
|
||||||
{ label: 'API' }
|
existingSubscription.endpoint === req.body.endpoint &&
|
||||||
);
|
existingSubscription.auth !== req.body.auth
|
||||||
return res.status(204).send();
|
) {
|
||||||
}
|
existingSubscription.auth = req.body.auth;
|
||||||
|
existingSubscription.p256dh = req.body.p256dh;
|
||||||
|
existingSubscription.userAgent = req.body.userAgent;
|
||||||
|
|
||||||
const userPushSubscription = new UserPushSubscription({
|
await transactionalRepo.save(existingSubscription);
|
||||||
auth: req.body.auth,
|
|
||||||
endpoint: req.body.endpoint,
|
|
||||||
p256dh: req.body.p256dh,
|
|
||||||
userAgent: req.body.userAgent,
|
|
||||||
user: req.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
userPushSubRepository.save(userPushSubscription);
|
logger.debug(
|
||||||
|
'Updated existing push subscription with new keys for same endpoint.',
|
||||||
|
{ label: 'API' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Duplicate subscription detected. Skipping registration.',
|
||||||
|
{ label: 'API' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old subscriptions from the same device (userAgent) for this user
|
||||||
|
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
|
||||||
|
// Only clean up if we're creating a new subscription (not updating an existing one)
|
||||||
|
if (req.body.userAgent) {
|
||||||
|
const staleSubscriptions = await transactionalRepo.find({
|
||||||
|
relations: { user: true },
|
||||||
|
where: {
|
||||||
|
userAgent: req.body.userAgent,
|
||||||
|
user: { id: req.user?.id },
|
||||||
|
// Only remove subscriptions with different endpoints (stale ones)
|
||||||
|
// Keep subscriptions that might be from different browsers/tabs
|
||||||
|
endpoint: Not(req.body.endpoint),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (staleSubscriptions.length > 0) {
|
||||||
|
await transactionalRepo.remove(staleSubscriptions);
|
||||||
|
logger.debug(
|
||||||
|
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
|
||||||
|
{ label: 'API' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPushSubscription = new UserPushSubscription({
|
||||||
|
auth: req.body.auth,
|
||||||
|
endpoint: req.body.endpoint,
|
||||||
|
p256dh: req.body.p256dh,
|
||||||
|
userAgent: req.body.userAgent,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transactionalRepo.save(userPushSubscription);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
|||||||
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm leading-6">
|
<div className="ml-3 text-sm leading-6">
|
||||||
<label htmlFor="localLogin" className="block">
|
<label htmlFor="localLogin" className="block" aria-label={label}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-white">{label}</span>
|
<span className="font-medium text-white">{label}</span>
|
||||||
<span className="font-normal text-gray-400">{description}</span>
|
<span className="font-normal text-gray-400">{description}</span>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const NotificationType = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm leading-6">
|
<div className="ml-3 text-sm leading-6">
|
||||||
<label htmlFor={option.id} className="block">
|
<label htmlFor={option.id} className="block" aria-label={option.name}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-white">{option.name}</span>
|
<span className="font-medium text-white">{option.name}</span>
|
||||||
<span className="font-normal text-gray-400">
|
<span className="font-normal text-gray-400">
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const PermissionOption = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm leading-6">
|
<div className="ml-3 text-sm leading-6">
|
||||||
<label htmlFor={option.id} className="block">
|
<label htmlFor={option.id} className="block" aria-label={option.name}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-white">{option.name}</span>
|
<span className="font-medium text-white">{option.name}</span>
|
||||||
<span className="font-normal text-gray-400">
|
<span className="font-normal text-gray-400">
|
||||||
|
|||||||
@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
|
|||||||
|
|
||||||
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||||
{
|
{
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
|
|||||||
),
|
),
|
||||||
{
|
{
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
|
|||||||
// Deletes/disables corresponding push subscription from database
|
// Deletes/disables corresponding push subscription from database
|
||||||
const disablePushNotifications = async (endpoint?: string) => {
|
const disablePushNotifications = async (endpoint?: string) => {
|
||||||
try {
|
try {
|
||||||
await unsubscribeToPushNotifications(user?.id, endpoint);
|
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
|
||||||
|
user?.id,
|
||||||
// Delete from backend if endpoint is available
|
endpoint
|
||||||
if (subEndpoint) {
|
);
|
||||||
await deletePushSubscriptionFromBackend(subEndpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('pushNotificationsEnabled', 'false');
|
localStorage.setItem('pushNotificationsEnabled', 'false');
|
||||||
setWebPushEnabled(false);
|
setWebPushEnabled(false);
|
||||||
|
|
||||||
|
// Only delete the current browser's subscription, not all devices
|
||||||
|
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
|
||||||
|
if (endpointToDelete) {
|
||||||
|
try {
|
||||||
|
await axios.delete(
|
||||||
|
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
|
||||||
|
endpointToDelete
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore deletion failures - backend cleanup is best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const verifyWebPush = async () => {
|
const verifyWebPush = async () => {
|
||||||
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
||||||
setWebPushEnabled(enabled);
|
let isEnabled = enabled;
|
||||||
|
|
||||||
|
if (!enabled && 'serviceWorker' in navigator) {
|
||||||
|
const { subscription } = await getPushSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
isEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEnabled && dataDevices && dataDevices.length > 0) {
|
||||||
|
const currentUserAgent = navigator.userAgent;
|
||||||
|
const hasMatchingDevice = dataDevices.some(
|
||||||
|
(device) => device.userAgent === currentUserAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasMatchingDevice) {
|
||||||
|
isEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWebPushEnabled(isEnabled);
|
||||||
|
if (localStorage.getItem('pushNotificationsEnabled') === null) {
|
||||||
|
localStorage.setItem(
|
||||||
|
'pushNotificationsEnabled',
|
||||||
|
isEnabled ? 'true' : 'false'
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
|
|||||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import type { MutatorCallback } from 'swr';
|
import type { MutatorCallback } from 'swr';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
@@ -56,13 +57,21 @@ export const useUser = ({
|
|||||||
id,
|
id,
|
||||||
initialData,
|
initialData,
|
||||||
}: { id?: number; initialData?: User } = {}): UserHookResponse => {
|
}: { id?: number; initialData?: User } = {}): UserHookResponse => {
|
||||||
|
const router = useRouter();
|
||||||
|
const isAuthPage = /^\/(login|setup|resetpassword(?:\/|$))/.test(
|
||||||
|
router.pathname
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, {
|
} = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, {
|
||||||
fallbackData: initialData,
|
fallbackData: initialData,
|
||||||
refreshInterval: 30000,
|
refreshInterval: !isAuthPage ? 30000 : 0,
|
||||||
|
revalidateOnFocus: !isAuthPage,
|
||||||
|
revalidateOnMount: !isAuthPage,
|
||||||
|
revalidateOnReconnect: !isAuthPage,
|
||||||
errorRetryInterval: 30000,
|
errorRetryInterval: 30000,
|
||||||
shouldRetryOnError: false,
|
shouldRetryOnError: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class PlexOAuth {
|
|||||||
'X-Plex-Client-Identifier': clientId,
|
'X-Plex-Client-Identifier': clientId,
|
||||||
'X-Plex-Model': 'Plex OAuth',
|
'X-Plex-Model': 'Plex OAuth',
|
||||||
'X-Plex-Platform': browser.getBrowserName(),
|
'X-Plex-Platform': browser.getBrowserName(),
|
||||||
'X-Plex-Platform-Version': browser.getBrowserVersion(),
|
'X-Plex-Platform-Version': browser.getBrowserVersion() || 'Unknown',
|
||||||
'X-Plex-Device': browser.getOSName(),
|
'X-Plex-Device': browser.getOSName(),
|
||||||
'X-Plex-Device-Name': `${browser.getBrowserName()} (Seerr)`,
|
'X-Plex-Device-Name': `${browser.getBrowserName()} (Seerr)`,
|
||||||
'X-Plex-Device-Screen-Resolution':
|
'X-Plex-Device-Screen-Resolution':
|
||||||
|
|||||||
@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
|
|||||||
currentSettings.vapidPublic
|
currentSettings.vapidPublic
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
|
if (currentServerKey !== expectedServerKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = subscription.endpoint;
|
const endpoint = subscription.endpoint;
|
||||||
|
|
||||||
const { data } = await axios.get<UserPushSubscription>(
|
const { data } = await axios.get<UserPushSubscription>(
|
||||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
|
return data.endpoint === endpoint;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
|
|||||||
userId: number | undefined,
|
userId: number | undefined,
|
||||||
currentSettings: PublicSettingsResponse
|
currentSettings: PublicSettingsResponse
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscription } = await getPushSubscription();
|
||||||
const isValid = await verifyPushSubscription(userId, currentSettings);
|
const isValid = await verifyPushSubscription(userId, currentSettings);
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentSettings.enablePushRegistration) {
|
if (currentSettings.enablePushRegistration) {
|
||||||
try {
|
try {
|
||||||
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
|
const oldEndpoint = await unsubscribeToPushNotifications(userId);
|
||||||
await unsubscribeToPushNotifications(userId);
|
|
||||||
|
|
||||||
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
|
|
||||||
await subscribeToPushNotifications(userId, currentSettings);
|
await subscribeToPushNotifications(userId, currentSettings);
|
||||||
|
|
||||||
|
if (oldEndpoint) {
|
||||||
|
try {
|
||||||
|
await axios.delete(
|
||||||
|
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
|
||||||
|
oldEndpoint
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors when deleting old endpoint (it might not exist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
||||||
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
|
|||||||
export const unsubscribeToPushNotifications = async (
|
export const unsubscribeToPushNotifications = async (
|
||||||
userId: number | undefined,
|
userId: number | undefined,
|
||||||
endpoint?: string
|
endpoint?: string
|
||||||
) => {
|
): Promise<string | null> => {
|
||||||
if (!('serviceWorker' in navigator) || !userId) {
|
if (!('serviceWorker' in navigator) || !userId) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { subscription } = await getPushSubscription();
|
const { subscription } = await getPushSubscription();
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { endpoint: currentEndpoint } = subscription.toJSON();
|
const { endpoint: currentEndpoint } = subscription.toJSON();
|
||||||
|
|
||||||
if (!endpoint || endpoint === currentEndpoint) {
|
if (!endpoint || endpoint === currentEndpoint) {
|
||||||
await subscription.unsubscribe();
|
await subscription.unsubscribe();
|
||||||
return true;
|
return currentEndpoint ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Issue unsubscribing to push notifications: ${error.message}`
|
`Issue unsubscribing to push notifications: ${error.message}`
|
||||||
|
|||||||
Reference in New Issue
Block a user