Compare commits
21 Commits
advanced-o
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa68da481 | ||
|
|
cf5a85ba0b | ||
|
|
9cbd5f4260 | ||
|
|
09233a32b3 | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 | ||
|
|
3ee69663dc | ||
|
|
539d49879d | ||
|
|
15356dfe49 | ||
|
|
1f04eeb040 | ||
|
|
e3028c21f2 | ||
|
|
9d8b343790 | ||
|
|
f4fe16608a | ||
|
|
d660a540da | ||
|
|
48ef2984e5 | ||
|
|
c5fc31c352 | ||
|
|
c3b9ea6ce4 | ||
|
|
b66b36186a | ||
|
|
fb5196bdec | ||
|
|
bde322de8e |
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
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
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
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
|
||||
id: terms
|
||||
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))
|
||||
- [ ] I have updated the documentation accordingly.
|
||||
- [ ] All new and existing tests passed.
|
||||
- [ ] Successful build `pnpm build`
|
||||
- [ ] Translation keys `pnpm i18n:extract`
|
||||
- [ ] Database migration (if required)
|
||||
|
||||
#### Issues Fixed or Closed
|
||||
|
||||
- Fixes #XXXX
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
.next/
|
||||
dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
|
||||
|
||||
1216
CHANGELOG.md
1216
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -151,9 +151,9 @@ When adding new UI text, please try to adhere to the following guidelines:
|
||||
|
||||
## Translation
|
||||
|
||||
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose).
|
||||
We use [Weblate](https://translate.seerr.dev/projects/seerr/seerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose).
|
||||
|
||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/multi-auto.svg" alt="Translation status" /></a>
|
||||
|
||||
## Migrations
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<p align="center">
|
||||
<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="http://translate.seerr.dev/engage/seerr/"><img src="http://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
|
||||
|
||||
**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/)**.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Seerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 3.0.0
|
||||
# renovate: image=ghcr.io/seerr-team/seerr
|
||||
appVersion: '2.7.3'
|
||||
appVersion: '3.0.0'
|
||||
maintainers:
|
||||
- name: Seerr Team
|
||||
url: https://github.com/orgs/seerr-team/people
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# seerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Seerr helm chart for Kubernetes
|
||||
|
||||
@@ -22,7 +22,7 @@ Kubernetes: `>=1.23.0-0`
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ sudo mkdir -p /opt/seerr && cd /opt/seerr
|
||||
```
|
||||
2. Clone the Seerr repository and checkout the main branch:
|
||||
```bash
|
||||
git clone https://github.com/seerr-team/seerr.git
|
||||
cd seerr
|
||||
git clone https://github.com/seerr-team/seerr.git .
|
||||
git checkout main
|
||||
```
|
||||
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:
|
||||
```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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
100
package.json
100
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "seerr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
@@ -33,38 +33,38 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dr.pogodin/csurf": "^1.14.1",
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@dr.pogodin/csurf": "^1.16.6",
|
||||
"@formatjs/intl-displaynames": "6.8.13",
|
||||
"@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/swc-plugin-experimental": "^0.4.0",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@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/wink-jaro-distance": "^2.0.2",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.10.0",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"ace-builds": "1.43.4",
|
||||
"axios": "1.13.2",
|
||||
"axios-rate-limit": "1.4.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"bowser": "2.13.1",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.7",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"country-flag-icons": "1.6.4",
|
||||
"cronstrue": "2.23.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dayjs": "1.11.19",
|
||||
"dns-caching": "^0.2.7",
|
||||
"email-templates": "12.0.1",
|
||||
"email-templates": "12.0.3",
|
||||
"express": "4.21.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
"formik": "^2.4.6",
|
||||
"express-session": "1.18.2",
|
||||
"formik": "^2.4.9",
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
@@ -76,19 +76,19 @@
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.11.0",
|
||||
"pg": "8.16.3",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.23.0",
|
||||
"react-aria": "3.44.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-intersection-observer": "9.4.3",
|
||||
"react-intl": "^6.6.8",
|
||||
"react-markdown": "8.0.5",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-select": "5.10.2",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
@@ -97,19 +97,19 @@
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.7.1",
|
||||
"semver": "7.7.3",
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"swr": "2.3.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.12",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"undici": "^7.3.0",
|
||||
"validator": "^13.15.15",
|
||||
"web-push": "3.5.0",
|
||||
"undici": "^7.16.0",
|
||||
"validator": "^13.15.23",
|
||||
"web-push": "3.6.7",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.8.2",
|
||||
"winston": "3.18.3",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
@@ -123,32 +123,33 @@
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/country-flag-icons": "1.2.2",
|
||||
"@types/csurf": "1.11.5",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/lodash": "4.17.21",
|
||||
"@types/mime": "3",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@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/semver": "7.3.13",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/validator": "^13.15.3",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"commitizen": "4.3.0",
|
||||
"autoprefixer": "10.4.22",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "14.1.0",
|
||||
@@ -157,22 +158,22 @@
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.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",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "3.1.9",
|
||||
"postcss": "8.4.31",
|
||||
"nodemon": "3.1.11",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -181,7 +182,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.17.6"
|
||||
"@types/express-session": "1.18.2"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -204,8 +205,11 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sqlite3",
|
||||
"bcrypt"
|
||||
"@swc/core",
|
||||
"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;
|
||||
}
|
||||
|
||||
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
|
||||
? JellyfinLibraryItemExtended[]
|
||||
: JellyfinLibraryItem[];
|
||||
|
||||
export interface JellyfinItemsReponse {
|
||||
Items: JellyfinLibraryItemExtended[];
|
||||
TotalRecordCount: number;
|
||||
@@ -145,7 +149,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': authHeaderVal,
|
||||
Authorization: authHeaderVal,
|
||||
'Content-Type': '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,
|
||||
seasonID: string
|
||||
): Promise<JellyfinLibraryItem[]> {
|
||||
seasonID: string,
|
||||
options?: T
|
||||
): Promise<EpisodeReturn<T>> {
|
||||
try {
|
||||
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(
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -133,6 +137,21 @@ class BaseScanner<T> {
|
||||
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 (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
@@ -173,6 +192,7 @@ class BaseScanner<T> {
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
newMedia.imdbId = imdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
@@ -203,6 +223,13 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||
}
|
||||
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
@@ -221,11 +248,12 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -257,7 +285,7 @@ class BaseScanner<T> {
|
||||
(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) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -271,6 +299,23 @@ class BaseScanner<T> {
|
||||
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) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
@@ -491,6 +536,22 @@ class BaseScanner<T> {
|
||||
)
|
||||
? ratingKey
|
||||
: 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
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
193
server/lib/scanners/serviceAvailabilityChecker.ts
Normal file
193
server/lib/scanners/serviceAvailabilityChecker.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
interface InstanceAvailability {
|
||||
hasStandard: boolean;
|
||||
has4k: boolean;
|
||||
serviceStandardId?: number;
|
||||
service4kId?: number;
|
||||
externalStandardId?: number;
|
||||
external4kId?: number;
|
||||
}
|
||||
|
||||
interface SeasonInstanceAvailability {
|
||||
seasonNumber: number;
|
||||
episodesStandard: number;
|
||||
episodes4k: number;
|
||||
}
|
||||
|
||||
interface ShowInstanceAvailability extends InstanceAvailability {
|
||||
seasons: SeasonInstanceAvailability[];
|
||||
}
|
||||
|
||||
class ServiceAvailabilityChecker {
|
||||
private movieCache: Map<number, InstanceAvailability>;
|
||||
private showCache: Map<number, ShowInstanceAvailability>;
|
||||
|
||||
constructor() {
|
||||
this.movieCache = new Map();
|
||||
this.showCache = new Map();
|
||||
}
|
||||
|
||||
public clearCache(): void {
|
||||
this.movieCache.clear();
|
||||
this.showCache.clear();
|
||||
}
|
||||
|
||||
public async checkMovieAvailability(
|
||||
tmdbid: number
|
||||
): Promise<InstanceAvailability> {
|
||||
const cached = this.movieCache.get(tmdbid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const result: InstanceAvailability = {
|
||||
hasStandard: false,
|
||||
has4k: false,
|
||||
};
|
||||
|
||||
if (!settings.radarr || settings.radarr.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const radarrSettings of settings.radarr) {
|
||||
try {
|
||||
const radarr = this.createRadarrClient(radarrSettings);
|
||||
const movie = await radarr.getMovieByTmdbId(tmdbid);
|
||||
|
||||
if (movie?.hasFile) {
|
||||
if (radarrSettings.is4k) {
|
||||
result.has4k = true;
|
||||
result.service4kId = radarrSettings.id;
|
||||
result.external4kId = movie.id;
|
||||
} else {
|
||||
result.hasStandard = true;
|
||||
result.serviceStandardId = radarrSettings.id;
|
||||
result.externalStandardId = movie.id;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Found movie (TMDB: ${tmdbid}) in ${
|
||||
radarrSettings.is4k ? '4K' : 'Standard'
|
||||
} Radarr instance (name: ${radarrSettings.name})`,
|
||||
{
|
||||
label: 'Service Availability',
|
||||
radarrId: radarrSettings.id,
|
||||
movieId: movie?.id,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// movie not found in this instance, continue
|
||||
}
|
||||
}
|
||||
|
||||
this.movieCache.set(tmdbid, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async checkShowAvailability(
|
||||
tvdbid: number
|
||||
): Promise<ShowInstanceAvailability> {
|
||||
const cached = this.showCache.get(tvdbid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const result: ShowInstanceAvailability = {
|
||||
hasStandard: false,
|
||||
has4k: false,
|
||||
seasons: [],
|
||||
};
|
||||
|
||||
if (!settings.sonarr || settings.sonarr.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const standardSeasons = new Map<number, number>();
|
||||
const seasons4k = new Map<number, number>();
|
||||
|
||||
for (const sonarrSettings of settings.sonarr) {
|
||||
try {
|
||||
const sonarr = this.createSonarrClient(sonarrSettings);
|
||||
const series = await sonarr.getSeriesByTvdbId(tvdbid);
|
||||
|
||||
if (series?.id && series.statistics?.episodeFileCount > 0) {
|
||||
if (sonarrSettings.is4k) {
|
||||
result.has4k = true;
|
||||
result.service4kId = sonarrSettings.id;
|
||||
result.external4kId = series.id;
|
||||
} else {
|
||||
result.hasStandard = true;
|
||||
result.serviceStandardId = sonarrSettings.id;
|
||||
result.externalStandardId = series.id;
|
||||
}
|
||||
|
||||
for (const season of series.seasons) {
|
||||
const episodeCount = season.statistics?.episodeFileCount ?? 0;
|
||||
if (episodeCount > 0) {
|
||||
const targetMap = sonarrSettings.is4k
|
||||
? seasons4k
|
||||
: standardSeasons;
|
||||
const current = targetMap.get(season.seasonNumber) ?? 0;
|
||||
targetMap.set(
|
||||
season.seasonNumber,
|
||||
Math.max(current, episodeCount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Found series (TVDB: ${tvdbid}) in ${
|
||||
sonarrSettings.is4k ? '4K' : 'Standard'
|
||||
} Sonarr instance (name: ${sonarrSettings.name})`,
|
||||
{
|
||||
label: 'Service Availability',
|
||||
sonarrId: sonarrSettings.id,
|
||||
seriesId: series.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// series not found in this instance, continue
|
||||
}
|
||||
}
|
||||
|
||||
const allSeasonNumbers = new Set([
|
||||
...standardSeasons.keys(),
|
||||
...seasons4k.keys(),
|
||||
]);
|
||||
|
||||
result.seasons = Array.from(allSeasonNumbers).map((seasonNumber) => ({
|
||||
seasonNumber,
|
||||
episodesStandard: standardSeasons.get(seasonNumber) ?? 0,
|
||||
episodes4k: seasons4k.get(seasonNumber) ?? 0,
|
||||
}));
|
||||
|
||||
this.showCache.set(tvdbid, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private createRadarrClient(settings: RadarrSettings): RadarrAPI {
|
||||
return new RadarrAPI({
|
||||
url: RadarrAPI.buildUrl(settings, '/api/v3'),
|
||||
apiKey: settings.apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
private createSonarrClient(settings: SonarrSettings): SonarrAPI {
|
||||
return new SonarrAPI({
|
||||
url: SonarrAPI.buildUrl(settings, '/api/v3'),
|
||||
apiKey: settings.apiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const serviceAvailabilityChecker = new ServiceAvailabilityChecker();
|
||||
|
||||
export default serviceAvailabilityChecker;
|
||||
@@ -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
|
||||
if (user && req.session) {
|
||||
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.' });
|
||||
}
|
||||
logger.info('Successfully logged out user', {
|
||||
logger.debug('Successfully logged out user', {
|
||||
label: 'Auth',
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||
</div>
|
||||
<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">
|
||||
<span className="font-medium text-white">{label}</span>
|
||||
<span className="font-normal text-gray-400">{description}</span>
|
||||
|
||||
@@ -46,7 +46,7 @@ const NotificationType = ({
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<span className="font-medium text-white">{option.name}</span>
|
||||
<span className="font-normal text-gray-400">
|
||||
|
||||
@@ -123,7 +123,7 @@ const PermissionOption = ({
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<span className="font-medium text-white">{option.name}</span>
|
||||
<span className="font-normal text-gray-400">
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
TmdbGenre,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import type {
|
||||
Keyword,
|
||||
@@ -185,9 +184,7 @@ export const GenreSelector = ({
|
||||
}, [defaultValue, type]);
|
||||
|
||||
const loadGenreOptions = async (inputValue: string) => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/${type}`
|
||||
);
|
||||
const results = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
|
||||
|
||||
return results.data
|
||||
.map((result) => ({
|
||||
@@ -201,7 +198,7 @@ export const GenreSelector = ({
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
key={`genre-select-${defaultDataValue}`}
|
||||
key={`genre-select-${type}-${defaultDataValue}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
||||
|
||||
@@ -337,7 +337,13 @@ const OverrideRuleModal = ({
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<GenreSelector
|
||||
type={values.radarrServiceId ? 'movie' : 'tv'}
|
||||
type={
|
||||
values.radarrServiceId != null
|
||||
? 'movie'
|
||||
: values.sonarrServiceId != null
|
||||
? 'tv'
|
||||
: 'tv'
|
||||
}
|
||||
defaultValue={values.genre}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
|
||||
@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
|
||||
|
||||
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
|
||||
),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { MutatorCallback } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -56,13 +57,21 @@ export const useUser = ({
|
||||
id,
|
||||
initialData,
|
||||
}: { id?: number; initialData?: User } = {}): UserHookResponse => {
|
||||
const router = useRouter();
|
||||
const isAuthPage = /^\/(login|setup|resetpassword(?:\/|$))/.test(
|
||||
router.pathname
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, {
|
||||
fallbackData: initialData,
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: !isAuthPage ? 30000 : 0,
|
||||
revalidateOnFocus: !isAuthPage,
|
||||
revalidateOnMount: !isAuthPage,
|
||||
revalidateOnReconnect: !isAuthPage,
|
||||
errorRetryInterval: 30000,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ class PlexOAuth {
|
||||
'X-Plex-Client-Identifier': clientId,
|
||||
'X-Plex-Model': 'Plex OAuth',
|
||||
'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-Name': `${browser.getBrowserName()} (Seerr)`,
|
||||
'X-Plex-Device-Screen-Resolution':
|
||||
|
||||
Reference in New Issue
Block a user