Compare commits

..

20 Commits

Author SHA1 Message Date
THOMAS B
2ee5535819 Merge branch 'develop' into feat-add-tvdb-indexer 2024-11-03 13:15:11 +01:00
TOomaAh
67a846cd58 test(tvdb): change 'use' to 'tvdb' condition check 2024-10-29 23:28:28 +01:00
TOomaAh
a5e8320e8a refactor(tmdb): reduce still path condition 2024-10-29 23:20:41 +01:00
TOomaAh
3f16176667 refactor(settings): replace tvdb object to boolean type 2024-10-29 23:10:35 +01:00
TOomaAh
85aeeb084e refactor(indexer): remove unused getSeasonIdentifier method 2024-10-28 18:37:52 +01:00
TOomaAh
b865d65fad style(tvdb): rename pokemon to correct tv show 2024-10-27 13:06:27 +01:00
TOomaAh
47eece9c44 test(tvdb): add tvdb tests 2024-10-27 11:23:05 +01:00
TOomaAh
72277ea983 fix(test): wrong url tv-details 2024-10-27 01:48:07 +02:00
TOomaAh
57e2f7b374 fix(test): fix discover test 2024-10-27 01:18:27 +02:00
TOomaAh
6f8d4bf00a style: tvdb.login to tvdb.test 2024-10-26 16:13:52 +02:00
TOomaAh
61ecf74b28 style: replace avalaible to available 2024-10-26 15:52:40 +02:00
TOomaAh
976781d470 fix: wrong language with tmdb indexer 2024-10-23 12:36:37 +02:00
TOomaAh
422012f7b5 refactor: clean tvdb indexer code 2024-10-22 22:57:54 +02:00
TOomaAh
32f500a4e7 fix: error if tmdb poster is null 2024-10-20 16:27:08 +02:00
TOomaAh
7b07004c5b fix: error during get episodes 2024-10-20 16:17:42 +02:00
TOomaAh
87253e8bb7 refactor(tvdb): replace tvdb api by skyhook 2024-10-20 01:46:08 +02:00
TOomaAh
7bcda9521e Merge branch 'develop' into feat-add-tvdb-indexer
# Conflicts:
#	server/routes/tv.ts
#	src/components/TvDetails/index.tsx
2024-10-19 23:49:26 +02:00
TOomaAh
2d51b16694 fix(usersettings): remove unused column tvdbtoken 2024-07-27 01:28:28 +02:00
TOomaAh
2a0bcdf41c fix: fix rate limiter index tvdb indexer 2024-07-27 00:11:22 +02:00
TOomaAh
79e542ef12 feat(tvdb): get tv seasons/episodes with tvdb 2024-07-26 23:56:22 +02:00
146 changed files with 1159 additions and 1419 deletions

View File

@@ -448,69 +448,6 @@
"contributions": [
"security"
]
},
{
"login": "j0srisk",
"name": "Joseph Risk",
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
"profile": "http://josephrisk.com",
"contributions": [
"code"
]
},
{
"login": "Loetwiek",
"name": "Loetwiek",
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
"profile": "https://github.com/Loetwiek",
"contributions": [
"code"
]
},
{
"login": "Fuochi",
"name": "Fuochi",
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
"profile": "https://github.com/Fuochi",
"contributions": [
"doc"
]
},
{
"login": "demrich",
"name": "David Emrich",
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
"profile": "https://github.com/demrich",
"contributions": [
"code"
]
},
{
"login": "maxnatamo",
"name": "Max T. Kristiansen",
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
"profile": "https://maxtrier.dk",
"contributions": [
"code"
]
},
{
"login": "DamsDev1",
"name": "Damien Fajole",
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
"profile": "https://damsdev.me",
"contributions": [
"code"
]
},
{
"login": "AhmedNSidd",
"name": "Ahmed Siddiqui",
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
"profile": "https://github.com/AhmedNSidd",
"contributions": [
"code"
]
}
]
}

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json
dist
Dockerfile*
compose.yaml
docker-compose.yml
docs
LICENSE
node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore
.editorconfig export-ignore
Dockerfile.local export-ignore
compose.yaml export-ignore
docker-compose.yml export-ignore
stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore

View File

@@ -55,14 +55,6 @@ body:
- tablet
validations:
required: true
- type: dropdown
id: database
attributes:
options:
- SQLite (default)
- PostgreSQL
label: Database
description: Which database backend are you using?
- type: input
id: device
attributes:

View File

@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment:
```bash
pnpm install
pnpm
pnpm dev
```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes.

View File

@@ -291,12 +291,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=demrich" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=maxnatamo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=DamsDev1" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -75,7 +75,6 @@
"types": 0,
"options": {
"webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true
}
},

View File

@@ -0,0 +1,92 @@
describe('TVDB Integration', () => {
// Constants for routes and selectors
const ROUTES = {
home: '/',
tvdbSettings: '/settings/tvdb',
tomorrowIsOursTvShow: '/tv/72879',
monsterTvShow: '/tv/225634',
};
const SELECTORS = {
sidebarToggle: '[data-testid=sidebar-toggle]',
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
tvdbEnable: 'input[data-testid="tvdb-enable"]',
tvdbSaveButton: '[data-testid=tvbd-save-button]',
heading: '.heading',
season1: 'Season 1',
season2: 'Season 2',
};
// Reusable commands
const toggleTVDBSetting = () => {
cy.intercept('/api/v1/settings/tvdb').as('tvdbRequest');
cy.get(SELECTORS.tvdbSaveButton).click();
return cy.wait('@tvdbRequest');
};
const verifyTVDBResponse = (response, expectedUseValue) => {
expect(response.statusCode).to.equal(200);
expect(response.body.tvdb).to.equal(expectedUseValue);
};
beforeEach(() => {
// Perform login
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
// Navigate to TVDB settings
cy.visit(ROUTES.home);
cy.get(SELECTORS.sidebarToggle).click();
cy.get(SELECTORS.sidebarSettingsMobile).click();
cy.get(
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.tvdbSettings}"]`
).click();
// Verify heading
cy.get(SELECTORS.heading).should('contain', 'Tvdb');
// Configure TVDB settings
cy.get(SELECTORS.tvdbEnable).then(($checkbox) => {
const isChecked = $checkbox.is(':checked');
if (!isChecked) {
// If disabled, enable TVDB
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, true);
});
} else {
// If enabled, disable then re-enable TVDB
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, false);
});
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, true);
});
}
});
});
it('should display "Tomorrow is Ours" show information correctly (1 season on TMDB >1 seasons on TVDB)', () => {
cy.visit(ROUTES.tomorrowIsOursTvShow);
cy.contains(SELECTORS.season2)
.should('be.visible')
.scrollIntoView()
.click();
});
it('Should display "Monster" show information correctly (Not existing on TVDB)', () => {
cy.visit(ROUTES.monsterTvShow);
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
cy.contains(SELECTORS.season1)
.should('be.visible')
.scrollIntoView()
.click();
cy.wait('@season1');
cy.contains('9 - Hang Men').should('be.visible');
});
});

View File

@@ -1,38 +0,0 @@
---
version: '3.8'
services:
jellyseerr:
build:
context: .
dockerfile: Dockerfile.local
ports:
- '5055:5055'
environment:
DB_TYPE: 'postgres' # Which DB engine to use. The default is "sqlite". To use postgres, this needs to be set to "postgres"
DB_HOST: 'postgres' # The host (url) of the database
DB_PORT: '5432' # The port to connect to
DB_USER: 'jellyseerr' # Username used to connect to the database
DB_PASS: 'jellyseerr' # Password of the user used to connect to the database
DB_NAME: 'jellyseerr' # The name of the database to connect to
DB_LOG_QUERIES: 'false' # Whether to log the DB queries for debugging
DB_USE_SSL: 'false' # Whether to enable ssl for database connection
volumes:
- .:/app:rw,cached
- /app/node_modules
- /app/.next
depends_on:
- postgres
links:
- postgres
postgres:
image: postgres
environment:
POSTGRES_USER: jellyseerr
POSTGRES_PASSWORD: jellyseerr
POSTGRES_DB: jellyseerr
ports:
- '5432:5432'
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:

View File

@@ -1,3 +1,4 @@
version: '3'
services:
jellyseerr:
build:

View File

@@ -17,7 +17,6 @@ Welcome to the Jellyseerr Documentation.
- **Mobile-friendly design**, for when you need to approve requests on the go.
- Granular permission system.
- Localization into other languages.
- Support for PostgreSQL and SQLite databases.
- More features to come!
## Motivation

View File

@@ -1,56 +0,0 @@
---
title: Configuring the Database (Advanced)
description: Configure the database for Jellyseerr
sidebar_position: 2
---
# Configuring the Database
Jellyseerr supports SQLite and PostgreSQL. The database connection can be configured using the following environment variables:
## SQLite Options
```dotenv
DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
## PostgreSQL Options
```dotenv
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite". To use postgres, this needs to be set to "postgres"
DB_HOST="localhost" # (optional) The host (url) of the database. The default is "localhost".
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
DB_USER= # (required) Username used to connect to the database
DB_PASS= # (required) Password of the user used to connect to the database
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
### SSL configuration
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
```dotenv
DB_USE_SSL="false" # (optional) Whether to enable ssl for database connection. This must be "true" to use the other ssl options. The default is "false".
DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections with unverifiable certificates i.e. self-signed certificates without providing the below settings. The default is "true".
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "".
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
```
### Migrating from SQLite to PostgreSQL
1. Set up your PostgreSQL database and configure Jellyseerr to use it
2. Run Jellyseerr to create the tables in the PostgreSQL database
3. Stop Jellyseerr
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
- Edit the postgres connection string to match your setup
- WARNING: The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
- "I don't have or don't want to use docker" - You can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
```bash
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro -v pgloader/pgloader.load:/pgloader.load ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
```
5. Start Jellyseerr

View File

@@ -95,8 +95,6 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';
@@ -192,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2)
Add the following labels to the Jellyseerr service in your `compose.yaml` file:
Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
```yaml
labels:

View File

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation:
Define the `jellyseerr` service in your `compose.yaml` as follows:
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
```yaml
---
services:
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
Then, start all services defined in the Compose file:
```bash
docker compose up -d
docker-compose up -d
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
docker-compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
docker-compose up -d
```
:::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.

View File

@@ -18,10 +18,6 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.
### Notification Role ID (optional)
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
### Bot Username (optional)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

View File

@@ -400,6 +400,12 @@ components:
serverID:
type: string
readOnly: true
TvdbSettings:
type: object
properties:
use:
type: boolean
example: true
TautulliSettings:
type: object
properties:
@@ -1273,8 +1279,6 @@ components:
type: string
webhookUrl:
type: string
webhookRoleId:
type: string
enableMentions:
type: boolean
SlackSettings:
@@ -2363,6 +2367,60 @@ paths:
type: string
thumb:
type: string
/settings/tvdb:
get:
summary: Get TVDB settings
description: Retrieves current TVDB settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
put:
summary: Update TVDB settings
description: Updates TVDB settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
/settings/tvdb/test:
post:
summary: Test TVDB configuration
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
tags:
- settings
responses:
'200':
description: Succesfully connected to TVDB
content:
application/json:
schema:
type: object
properties:
languages:
type: array
items:
type: object
properties:
id:
type: number
name:
type: string
/settings/tautulli:
get:
summary: Get Tautulli settings
@@ -4144,21 +4202,6 @@ paths:
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
get:
summary: Get media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blacklist details in JSON
delete:
summary: Remove media from blacklist
tags:
@@ -5486,7 +5529,7 @@ paths:
- type: array
items:
type: number
minimum: 0
minimum: 1
- type: string
enum: [all]
is4k:
@@ -5592,7 +5635,7 @@ paths:
type: array
items:
type: number
minimum: 0
minimum: 1
is4k:
type: boolean
example: false
@@ -5926,7 +5969,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TvDetails'
/tv/{tvId}/season/{seasonId}:
/tv/{tvId}/season/{seasonNumber}:
get:
summary: Get season details and episode list
description: Returns season details with a list of episodes in a JSON object.
@@ -5940,11 +5983,11 @@ paths:
type: number
example: 76479
- in: path
name: seasonId
name: seasonNumber
required: true
schema:
type: number
example: 1
example: 123456
- in: query
name: language
schema:

View File

@@ -69,7 +69,6 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"pg": "8.11.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"react": "^18.3.1",

128
pnpm-lock.yaml generated
View File

@@ -49,7 +49,7 @@ importers:
version: 2.11.0
connect-typeorm:
specifier: 1.1.4
version: 1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
version: 1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
cookie-parser:
specifier: 1.4.6
version: 1.4.6
@@ -119,9 +119,6 @@ importers:
openpgp:
specifier: 5.7.0
version: 5.7.0
pg:
specifier: 8.11.0
version: 8.11.0
plex-api:
specifier: 5.3.2
version: 5.3.2
@@ -196,7 +193,7 @@ importers:
version: 2.2.5(react@18.3.1)
typeorm:
specifier: 0.3.11
version: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
undici:
specifier: ^6.20.1
version: 6.20.1
@@ -3533,10 +3530,6 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer-writer@2.0.0:
resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==}
engines: {node: '>=4'}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -7057,9 +7050,6 @@ packages:
resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==}
engines: {node: '>=8'}
packet-reader@1.0.0:
resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -7151,40 +7141,6 @@ packages:
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
pg-cloudflare@1.1.1:
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
pg-connection-string@2.7.0:
resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.7.0:
resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.7.0:
resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.11.0:
resolution: {integrity: sha512-meLUVPn2TWgJyLmy7el3fQQVwft4gU5NGyvV0XbD41iU9Jbg8lCH4zexhIkihDzVHJStlt6r088G6/fWeNjhXA==}
engines: {node: '>= 8.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
@@ -7290,22 +7246,6 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.0:
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -8216,10 +8156,6 @@ packages:
split2@3.2.2:
resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
split@1.0.1:
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==}
@@ -13493,8 +13429,6 @@ snapshots:
buffer-from@1.1.2: {}
buffer-writer@2.0.0: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -13885,13 +13819,13 @@ snapshots:
ini: 1.3.8
proto-list: 1.2.4
connect-typeorm@1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
connect-typeorm@1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
dependencies:
'@types/debug': 0.0.31
'@types/express-session': 1.17.6
debug: 4.3.5(supports-color@8.1.1)
express-session: 1.18.0
typeorm: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
typeorm: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
transitivePeerDependencies:
- supports-color
@@ -17644,8 +17578,6 @@ snapshots:
dependencies:
p-timeout: 3.2.0
packet-reader@1.0.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -17724,43 +17656,6 @@ snapshots:
performance-now@2.1.0: {}
pg-cloudflare@1.1.1:
optional: true
pg-connection-string@2.7.0: {}
pg-int8@1.0.1: {}
pg-pool@3.7.0(pg@8.11.0):
dependencies:
pg: 8.11.0
pg-protocol@1.7.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.0
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.11.0:
dependencies:
buffer-writer: 2.0.0
packet-reader: 1.0.0
pg-connection-string: 2.7.0
pg-pool: 3.7.0(pg@8.11.0)
pg-protocol: 1.7.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.1.1
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.0.1: {}
picomatch@2.3.1: {}
@@ -17858,16 +17753,6 @@ snapshots:
picocolors: 1.0.1
source-map-js: 1.2.0
postgres-array@2.0.0: {}
postgres-bytea@1.0.0: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
@@ -18997,8 +18882,6 @@ snapshots:
dependencies:
readable-stream: 3.6.2
split2@4.2.0: {}
split@1.0.1:
dependencies:
through: 2.3.8
@@ -19535,7 +19418,7 @@ snapshots:
typedarray@0.0.6: {}
typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
dependencies:
'@sqltools/formatter': 1.2.5
app-root-path: 3.1.0
@@ -19555,7 +19438,6 @@ snapshots:
xml2js: 0.4.23
yargs: 17.7.2
optionalDependencies:
pg: 8.11.0
sqlite3: 5.1.4(encoding@0.1.13)
ts-node: 10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)
transitivePeerDependencies:

View File

@@ -8,7 +8,7 @@ const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
export interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
@@ -53,6 +53,7 @@ class ExternalAPI {
this.baseUrl = baseUrl;
this.params = params;
this.cache = options.nodeCache;
}

View File

@@ -0,0 +1,23 @@
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/indexer/themoviedb/interfaces';
export interface TvShowIndexer {
getTvShow({
tvId,
language,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails>;
getTvSeason({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes>;
}

View File

@@ -1,4 +1,5 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
@@ -98,7 +99,7 @@ interface DiscoverTvOptions {
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
}
class TheMovieDb extends ExternalAPI {
class TheMovieDb extends ExternalAPI implements TvShowIndexer {
private region?: string;
private originalLanguage?: string;
constructor({
@@ -308,6 +309,12 @@ class TheMovieDb extends ExternalAPI {
}
);
data.episodes = data.episodes.map((episode) => {
if (episode.still_path) {
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
}
return episode;
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);

View File

@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
vote_count: number;
}
export interface TmdbTvSeasonResult {

View File

@@ -0,0 +1,246 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/indexer/themoviedb/interfaces';
import type {
TvdbEpisode,
TvdbLoginResponse,
TvdbSeason,
TvdbTvShowDetail,
} from '@server/api/indexer/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import logger from '@server/logger';
interface TvdbConfig {
baseUrl: string;
maxRequestsPerSecond: number;
cachePrefix: AvailableCacheIds;
}
const DEFAULT_CONFIG: TvdbConfig = {
baseUrl: 'https://skyhook.sonarr.tv/v1/tvdb/shows',
maxRequestsPerSecond: 50,
cachePrefix: 'tvdb' as const,
};
const enum TvdbIdStatus {
INVALID = -1,
}
type TvdbId = number;
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
class Tvdb extends ExternalAPI implements TvShowIndexer {
private readonly tmdb: TheMovieDb;
private static readonly DEFAULT_CACHE_TTL = 43200;
private static readonly DEFAULT_LANGUAGE = 'en';
constructor(config: Partial<TvdbConfig> = {}) {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
super(
finalConfig.baseUrl,
{},
{
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
rateLimit: {
maxRPS: finalConfig.maxRequestsPerSecond,
id: finalConfig.cachePrefix,
},
}
);
this.tmdb = new TheMovieDb();
}
public async test(): Promise<TvdbLoginResponse> {
try {
return await this.get<TvdbLoginResponse>('/en/445009', {});
} catch (error) {
this.handleError('Login failed', error);
throw error;
}
}
public async getTvShow({
tvId,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (this.isValidTvdbId(tvdbId)) {
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
}
return tmdbTvShow;
} catch (error) {
this.handleError('Failed to fetch TV show details', error);
throw error;
}
}
public async getTvSeason({
tvId,
seasonNumber,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> {
if (seasonNumber === 0) {
return this.createEmptySeasonResponse(tvId);
}
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (!this.isValidTvdbId(tvdbId)) {
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId);
} catch (error) {
logger.error(
`[TVDB] Failed to fetch TV season details: ${error.message}`
);
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
}
private async enrichTmdbShowWithTvdbData(
tmdbTvShow: TmdbTvDetails,
tvdbId: ValidTvdbId
): Promise<TmdbTvDetails> {
try {
const tvdbData = await this.fetchTvdbShowData(tvdbId);
const seasons = this.processSeasons(tvdbData);
return { ...tmdbTvShow, seasons };
} catch (error) {
logger.error(
`Failed to enrich TMDB show with TVDB data: ${error.message}`
);
return tmdbTvShow;
}
}
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvShowDetail> {
return await this.get<TvdbTvShowDetail>(
`/en/${tvdbId}`,
{},
Tvdb.DEFAULT_CACHE_TTL
);
}
private processSeasons(tvdbData: TvdbTvShowDetail): any[] {
return tvdbData.seasons
.filter((season) => season.seasonNumber !== 0)
.map((season) => this.createSeasonData(season, tvdbData));
}
private createSeasonData(
season: TvdbSeason,
tvdbData: TvdbTvShowDetail
): any {
if (!season.seasonNumber) return null;
const episodeCount = tvdbData.episodes.filter(
(episode) => episode.seasonNumber === season.seasonNumber
).length;
return {
id: tvdbData.tvdbId,
episode_count: episodeCount,
name: `${season.seasonNumber}`,
overview: '',
season_number: season.seasonNumber,
poster_path: '',
air_date: '',
image: '',
};
}
private async getTvdbSeasonData(
tvdbId: number,
seasonNumber: number,
tvId: number
): Promise<TmdbSeasonWithEpisodes> {
const tvdbSeason = await this.fetchTvdbShowData(tvdbId);
const episodes = this.processEpisodes(tvdbSeason, seasonNumber, tvId);
return {
episodes,
external_ids: { tvdb_id: tvdbSeason.tvdbId },
name: '',
overview: '',
id: tvdbSeason.tvdbId,
air_date: tvdbSeason.firstAired,
season_number: episodes.length,
};
}
private processEpisodes(
tvdbSeason: TvdbTvShowDetail,
seasonNumber: number,
tvId: number
): any[] {
return tvdbSeason.episodes
.filter((episode) => episode.seasonNumber === seasonNumber)
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
}
private createEpisodeData(
episode: TvdbEpisode,
index: number,
tvId: number
): any {
return {
id: episode.tvdbId,
air_date: episode.airDate,
episode_number: episode.episodeNumber,
name: episode.title || `Episode ${index + 1}`,
overview: episode.overview || '',
season_number: episode.seasonNumber,
production_code: '',
show_id: tvId,
still_path: episode.image || '',
vote_average: 1,
vote_count: 1,
};
}
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
return {
episodes: [],
external_ids: { tvdb_id: tvId },
name: '',
overview: '',
id: 0,
air_date: '',
season_number: 0,
};
}
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
}
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
return tvdbId !== TvdbIdStatus.INVALID;
}
private handleError(context: string, error: Error): void {
throw new Error(`[TVDB] ${context}: ${error.message}`);
}
}
export default Tvdb;

View File

@@ -0,0 +1,80 @@
export interface TvdbBaseResponse<T> {
data: T;
errors: string;
}
export interface TvdbLoginResponse extends TvdbBaseResponse<{ token: string }> {
data: { token: string };
}
export interface TvdbTvShowDetail {
tvdbId: number;
title: string;
overview: string;
slug: string;
originalCountry: string;
originalLanguage: string;
language: string;
firstAired: string;
lastAired: string;
tvMazeId: number;
tmdbId: number;
imdbId: string;
lastUpdated: string;
status: string;
runtime: number;
timeOfDay: TvdbTimeOfDay;
originalNetwork: string;
network: string;
genres: string[];
alternativeTitles: TvdbAlternativeTitle[];
actors: TvdbActor[];
images: TvdbImage[];
seasons: TvdbSeason[];
episodes: TvdbEpisode[];
}
export interface TvdbTimeOfDay {
hours: number;
minutes: number;
}
export interface TvdbAlternativeTitle {
title: string;
}
export interface TvdbActor {
name: string;
character: string;
image?: string;
}
export interface TvdbImage {
coverType: string;
url: string;
}
export interface TvdbSeason {
seasonNumber: number;
}
export interface TvdbEpisode {
tvdbShowId: number;
tvdbId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
title?: string;
airDate: string;
airDateUtc: string;
runtime?: number;
overview?: string;
image?: string;
}
export interface TvdbEpisodeTranslation
extends TvdbBaseResponse<TvdbEpisodeTranslation> {
name: string;
overview: string;
language: string;
}

View File

@@ -138,38 +138,39 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(true);
} catch (e) {
logger.debug('Failed to authenticate with headers', {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
});
if (!e.cause.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
const status = e.cause?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
}
logger.error(
'Something went wrong while authenticating with the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
}
);
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
}
}
@@ -197,8 +198,8 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName;
} catch (e) {
logger.error(
'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
@@ -212,8 +213,8 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse };
} catch (e) {
logger.error(
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -228,8 +229,8 @@ class JellyfinAPI extends ExternalAPI {
return userReponse;
} catch (e) {
logger.error(
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -252,11 +253,8 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
'Something went wrong while getting libraries from the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
}
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
return [];
@@ -310,8 +308,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -331,8 +329,8 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse;
} catch (e) {
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -356,8 +354,8 @@ class JellyfinAPI extends ExternalAPI {
}
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
@@ -370,8 +368,8 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items;
} catch (e) {
logger.error(
'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -395,8 +393,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -412,8 +410,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
logger.error(
'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -3,7 +3,6 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { randomUUID } from 'node:crypto';
import xml2js from 'xml2js';
interface PlexAccountResponse {
@@ -128,11 +127,6 @@ export interface PlexWatchlistItem {
title: string;
}
export interface PlexWatchlistCache {
etag: string;
response: WatchlistResponse;
}
class PlexTvAPI extends ExternalAPI {
private authToken: string;
@@ -267,11 +261,6 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[];
}> {
try {
const watchlistCache = cacheManager.getCache('plexwatchlist');
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
this.authToken
);
const params = new URLSearchParams({
'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
@@ -279,62 +268,42 @@ class PlexTvAPI extends ExternalAPI {
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{
headers: {
...this.defaultHeaders,
...(cachedWatchlist?.etag
? { 'If-None-Match': cachedWatchlist.etag }
: {}),
},
headers: this.defaultHeaders,
}
);
const data = (await response.json()) as WatchlistResponse;
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
if (response.status >= 200 && response.status <= 299) {
cachedWatchlist = {
etag: response.headers.get('etag') ?? '',
response: data,
};
watchlistCache.data.set<PlexWatchlistCache>(
this.authToken,
cachedWatchlist
);
}
const watchlistDetails = await Promise.all(
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
})
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -342,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
return {
offset,
size,
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
totalSize: data.MediaContainer.totalSize,
items: filteredList,
};
} catch (e) {
@@ -358,29 +327,6 @@ class PlexTvAPI extends ExternalAPI {
};
}
}
public async pingToken() {
try {
const data: { pong: unknown } = await this.get(
'/api/v2/ping',
{},
undefined,
{
headers: {
'X-Plex-Client-Identifier': randomUUID(),
},
}
);
if (!data?.pong) {
throw new Error('No pong response');
}
} catch (e) {
logger.error('Failed to ping token', {
label: 'Plex Refresh Token',
errorMessage: e.message,
});
}
}
}
export default PlexTvAPI;

View File

@@ -1,43 +1,7 @@
import fs from 'fs';
import type { TlsOptions } from 'tls';
import 'reflect-metadata';
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
import { DataSource } from 'typeorm';
const DB_SSL_PREFIX = 'DB_SSL_';
function boolFromEnv(envVar: string, defaultVal = false) {
if (process.env[envVar]) {
return process.env[envVar]?.toLowerCase() === 'true';
}
return defaultVal;
}
function stringOrReadFileFromEnv(envVar: string): Buffer | string | undefined {
if (process.env[envVar]) {
return process.env[envVar];
}
const filePath = process.env[`${envVar}_FILE`];
if (filePath) {
return fs.readFileSync(filePath);
}
return undefined;
}
function buildSslConfig(): TlsOptions | undefined {
if (process.env.DB_USE_SSL?.toLowerCase() !== 'true') {
return undefined;
}
return {
rejectUnauthorized: boolFromEnv(
`${DB_SSL_PREFIX}REJECT_UNAUTHORIZED`,
true
),
ca: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CA`),
key: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}KEY`),
cert: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CERT`),
};
}
const devConfig: DataSourceOptions = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
@@ -45,10 +9,10 @@ const devConfig: DataSourceOptions = {
: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
logging: false,
enableWAL: true,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/sqlite/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
@@ -59,56 +23,16 @@ const prodConfig: DataSourceOptions = {
: 'config/db/db.sqlite3',
synchronize: false,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
logging: false,
enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/sqlite/**/*.js'],
migrations: ['dist/migration/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'],
};
const postgresDevConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME ?? 'jellyseerr',
ssl: buildSslConfig(),
synchronize: false,
migrationsRun: true,
logging: boolFromEnv('DB_LOG_QUERIES'),
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/postgres/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
const postgresProdConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME ?? 'jellyseerr',
ssl: buildSslConfig(),
synchronize: false,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/postgres/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'],
};
export const isPgsql = process.env.DB_TYPE === 'postgres';
function getDataSource(): DataSourceOptions {
if (process.env.NODE_ENV === 'production') {
return isPgsql ? postgresProdConfig : prodConfig;
} else {
return isPgsql ? postgresDevConfig : devConfig;
}
}
const dataSource = new DataSource(getDataSource());
const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
export const getRepository = <Entity extends object>(
target: EntityTarget<Entity>

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist),
blacklist: blacklist,
});
await mediaRepository.save(media);
} else {
media.blacklist = Promise.resolve(blacklist);
media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -10,7 +10,6 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
@@ -43,10 +42,6 @@ class Media {
finalIds = tmdbIds;
}
if (finalIds.length === 0) {
return [];
}
const media = await mediaRepository
.createQueryBuilder('media')
.leftJoinAndSelect(
@@ -123,8 +118,10 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn()
public createdAt: Date;
@@ -132,23 +129,10 @@ class Media {
@UpdateDateColumn()
public updatedAt: Date;
/**
* The `lastSeasonChange` column stores the date and time when the media was added to the library.
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
*/
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public lastSeasonChange: Date;
/**
* The `mediaAddedAt` column stores the date and time when the media was added to the library.
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
* This column is nullable because it can be null when the media is not yet synced to the library.
*/
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
nullable: true,
})
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true, type: 'int' })

View File

@@ -1,3 +1,5 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type {
@@ -5,8 +7,6 @@ import type {
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
MediaStatus,
@@ -257,7 +257,9 @@ export class MediaRequest {
>;
const requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons.map((season) => season.season_number)
? tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
: (requestBody.seasons as number[]);
let existingSeasons: number[] = [];
@@ -385,7 +387,6 @@ export class MediaRequest {
@ManyToOne(() => Media, (media) => media.requests, {
eager: true,
onDelete: 'CASCADE',
nullable: false,
})
public media: Media;
@@ -858,7 +859,7 @@ export class MediaRequest {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
await requestRepository.save(this);
requestRepository.save(this);
logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED',
@@ -1133,14 +1134,13 @@ export class MediaRequest {
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
await requestRepository.save(this);
requestRepository.save(this);
logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED',

View File

@@ -23,10 +23,7 @@ class Season {
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons, {
onDelete: 'CASCADE',
nullable: false,
})
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
public media: Promise<Media>;
@CreateDateColumn()

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -53,7 +53,6 @@ export class Watchlist implements WatchlistItem {
@ManyToOne(() => Media, (media) => media.watchlists, {
eager: true,
onDelete: 'CASCADE',
nullable: false,
})
public media: Media;

View File

@@ -1,5 +1,5 @@
import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository, isPgsql } from '@server/datasource';
import dataSource, { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
@@ -66,13 +66,9 @@ app
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
if (isPgsql) {
await dbConnection.runMigrations();
} else {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
}
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
}
// Load Settings

View File

@@ -2,7 +2,6 @@ import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import refreshToken from '@server/lib/refreshToken';
import {
jellyfinFullScanner,
jellyfinRecentScanner,
@@ -14,6 +13,7 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@@ -113,20 +113,30 @@ export const startJobs = (): void => {
}
// Watchlist Sync
scheduledJobs.push({
const watchlistSyncJob: ScheduledJob = {
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'seconds',
interval: 'fixed',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
});
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
@@ -223,19 +233,5 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'plex-refresh-token',
name: 'Plex Refresh Token',
type: 'process',
interval: 'fixed',
cronSchedule: jobs['plex-refresh-token'].schedule,
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
logger.info('Starting scheduled job: Plex Refresh Token', {
label: 'Jobs',
});
refreshToken.run();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -9,7 +9,7 @@ export type AvailableCacheIds =
| 'github'
| 'plexguid'
| 'plextv'
| 'plexwatchlist';
| 'tvdb';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -69,7 +69,10 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
tvdb: new Cache('tvdb', 'The TVDB API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -291,10 +291,6 @@ class DiscordAgent
}
}
if (settings.options.webhookRoleId) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {

View File

@@ -1,37 +0,0 @@
import PlexTvAPI from '@server/api/plextv';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import logger from '@server/logger';
class RefreshToken {
public async run() {
const userRepository = getRepository(User);
const users = await userRepository
.createQueryBuilder('user')
.addSelect('user.plexToken')
.where("user.plexToken != ''")
.getMany();
for (const user of users) {
await this.refreshUserToken(user);
}
}
private async refreshUserToken(user: User) {
if (!user.plexToken) {
logger.warn('Skipping user refresh token for user without plex token', {
label: 'Plex Refresh Token',
user: user.displayName,
});
return;
}
const plexTvApi = new PlexTvAPI(user.plexToken);
plexTvApi.pingToken();
}
}
const refreshToken = new RefreshToken();
export default refreshToken;

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';

View File

@@ -1,7 +1,7 @@
import animeList from '@server/api/animelist';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import cacheManager from '@server/lib/cache';
@@ -278,7 +278,9 @@ class PlexScanner
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
for (const season of seasons) {
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);

View File

@@ -1,6 +1,6 @@
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {
@@ -103,8 +103,10 @@ class SonarrScanner
const tmdbId = tvShow.id;
const filteredSeasons = sonarrSeries.seasons.filter((sn) =>
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
const filteredSeasons = sonarrSeries.seasons.filter(
(sn) =>
sn.seasonNumber !== 0 &&
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
);
for (const season of filteredSeasons) {

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbMovieDetails,
TmdbMovieResult,
@@ -9,7 +9,7 @@ import type {
TmdbSearchTvResponse,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import {
mapMovieDetailsToResult,
mapPersonDetailsToResult,

View File

@@ -1,3 +1,6 @@
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Tvdb from '@server/api/indexer/tvdb';
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
@@ -170,7 +173,6 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean;
};
}
@@ -282,7 +284,6 @@ export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'plex-watchlist-sync'
| 'plex-refresh-token'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
@@ -305,6 +306,7 @@ export interface AllSettings {
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
tvdb: boolean;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -370,6 +372,7 @@ class Settings {
apiKey: '',
},
tautulli: {},
tvdb: false,
radarr: [],
sonarr: [],
public: {
@@ -396,7 +399,6 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
},
},
@@ -470,10 +472,7 @@ class Settings {
schedule: '0 0 3 * * *',
},
'plex-watchlist-sync': {
schedule: '0 */3 * * * *',
},
'plex-refresh-token': {
schedule: '0 0 5 * * *',
schedule: '0 */10 * * * *',
},
'radarr-scan': {
schedule: '0 0 4 * * *',
@@ -538,6 +537,14 @@ class Settings {
this.data.tautulli = data;
}
get tvdb(): boolean {
return this.data.tvdb;
}
set tvdb(data: boolean) {
this.data.tvdb = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}
@@ -705,4 +712,13 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
return settings;
};
export const getIndexer = (): TvShowIndexer => {
const settings = getSettings();
if (settings.tvdb) {
return new Tvdb();
} else {
return new TheMovieDb();
}
};
export default Settings;

View File

@@ -62,7 +62,7 @@ class WatchlistSync {
const plexTvApi = new PlexTvAPI(user.plexToken);
const response = await plexTvApi.getWatchlist({ size: 20 });
const response = await plexTvApi.getWatchlist({ size: 200 });
const mediaItems = await Media.getRelatedMedia(
user,

View File

@@ -1,304 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialMigration1705599190375 implements MigrationInterface {
name = 'InitialMigration1705599190375';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`create table if not exists session
(
"expiredAt" bigint,
id text,
json text
);`
);
await queryRunner.query(
`create index if not exists "idx_194703_IDX_28c5d1d16da7908c97c9bc2f74"
on session ("expiredAt");`
);
await queryRunner.query(
`create unique index if not exists idx_194703_sqlite_autoindex_session_1
on session (id);`
);
await queryRunner.query(
`create table if not exists media
(
id serial,
"mediaType" text,
"tmdbId" int,
"tvdbId" int,
"imdbId" text,
status int default '1'::int,
status4k int default '1'::int,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
"lastSeasonChange" timestamp with time zone default CURRENT_TIMESTAMP,
"mediaAddedAt" timestamp with time zone,
"serviceId" int,
"serviceId4k" int,
"externalServiceId" int,
"externalServiceId4k" int,
"externalServiceSlug" text,
"externalServiceSlug4k" text,
"ratingKey" text,
"ratingKey4k" text,
"jellyfinMediaId" text,
"jellyfinMediaId4k" text,
constraint idx_194722_media_pkey
primary key (id)
);`
);
await queryRunner.query(
`create table if not exists season
(
id serial,
"seasonNumber" int,
status int default '1'::int,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
"mediaId" int not null,
status4k int default '1'::int,
constraint idx_194715_season_pkey
primary key (id),
foreign key ("mediaId") references media
on delete cascade
);`
);
await queryRunner.query(
`create index if not exists "idx_194722_IDX_7ff2d11f6a83cb52386eaebe74"
on media ("imdbId");`
);
await queryRunner.query(
`create index if not exists "idx_194722_IDX_41a289eb1fa489c1bc6f38d9c3"
on media ("tvdbId");`
);
await queryRunner.query(
`create index if not exists "idx_194722_IDX_7157aad07c73f6a6ae3bbd5ef5"
on media ("tmdbId");`
);
await queryRunner.query(
`create unique index if not exists idx_194722_sqlite_autoindex_media_1
on media ("tvdbId");`
);
await queryRunner.query(
`create table if not exists "user"
(
id serial,
email text,
username text,
"plexId" int,
"plexToken" text,
permissions int default '0'::int,
avatar text,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
password text,
"userType" int default '1'::int,
"plexUsername" text,
"resetPasswordGuid" text,
"recoveryLinkExpirationDate" date,
"movieQuotaLimit" int,
"movieQuotaDays" int,
"tvQuotaLimit" int,
"tvQuotaDays" int,
"jellyfinUsername" text,
"jellyfinAuthToken" text,
"jellyfinUserId" text,
"jellyfinDeviceId" text,
constraint idx_194731_user_pkey
primary key (id)
);`
);
await queryRunner.query(
`create unique index if not exists idx_194731_sqlite_autoindex_user_1
on "user" (email);`
);
await queryRunner.query(
`create table if not exists user_push_subscription
(
id serial,
endpoint text,
p256dh text,
auth text,
"userId" int,
constraint idx_194740_user_push_subscription_pkey
primary key (id),
foreign key ("userId") references "user"
on delete cascade
);`
);
await queryRunner.query(
`create unique index if not exists idx_194740_sqlite_autoindex_user_push_subscription_1
on user_push_subscription (auth);`
);
await queryRunner.query(
`create table if not exists issue
(
id serial,
"issueType" int,
status int default '1'::int,
"problemSeason" int default '0'::int,
"problemEpisode" int default '0'::int,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
"mediaId" int not null,
"createdById" int,
"modifiedById" int,
constraint idx_194747_issue_pkey
primary key (id),
foreign key ("modifiedById") references "user"
on delete cascade,
foreign key ("createdById") references "user"
on delete cascade,
foreign key ("mediaId") references media
on delete cascade
);`
);
await queryRunner.query(
`create table if not exists issue_comment
(
id serial,
message text,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
"userId" int,
"issueId" int,
constraint idx_194755_issue_comment_pkey
primary key (id),
foreign key ("issueId") references issue
on delete cascade,
foreign key ("userId") references "user"
on delete cascade
);`
);
await queryRunner.query(
`create table if not exists user_settings
(
id serial,
"notificationTypes" text,
"discordId" text,
"userId" int,
region text,
"originalLanguage" text,
"telegramChatId" text,
"telegramSendSilently" boolean,
"pgpKey" text,
locale text default ''::text,
"pushbulletAccessToken" text,
"pushoverApplicationToken" text,
"pushoverUserKey" text,
"watchlistSyncMovies" boolean,
"watchlistSyncTv" boolean,
"pushoverSound" varchar,
constraint idx_194762_user_settings_pkey
primary key (id),
foreign key ("userId") references "user"
on delete cascade
);`
);
await queryRunner.query(
`create unique index if not exists idx_194762_sqlite_autoindex_user_settings_1
on user_settings ("userId");`
);
await queryRunner.query(
`create table if not exists media_request
(
id serial,
status int,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
type text,
"mediaId" int not null,
"requestedById" int,
"modifiedById" int,
is4k boolean default false,
"serverId" int,
"profileId" int,
"rootFolder" text,
"languageProfileId" int,
tags text,
"isAutoRequest" boolean default false,
constraint idx_194770_media_request_pkey
primary key (id),
foreign key ("modifiedById") references "user"
on delete set null,
foreign key ("requestedById") references "user"
on delete cascade,
foreign key ("mediaId") references media
on delete cascade
);`
);
await queryRunner.query(
`create table if not exists season_request
(
id serial NOT NULL,
"seasonNumber" int,
status int default '1'::int,
"createdAt" timestamp with time zone default now(),
"updatedAt" timestamp with time zone default now(),
"requestId" int,
constraint idx_194709_season_request_pkey
primary key (id),
foreign key ("requestId") references media_request
on delete cascade
);`
);
await queryRunner.query(
`create table if not exists discover_slider
(
id serial,
type integer,
"order" integer,
"isBuiltIn" boolean default false,
enabled boolean default true,
title text,
data text,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
constraint idx_194779_discover_slider_pkey
primary key (id)
);`
);
await queryRunner.query(
`create table if not exists watchlist
(
id serial,
"ratingKey" text,
"mediaType" text,
title text,
"tmdbId" int,
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
"requestedById" int,
"mediaId" int not null,
constraint idx_194788_watchlist_pkey
primary key (id)
);`
);
await queryRunner.query(
`create index if not exists "idx_194788_IDX_939f205946256cc0d2a1ac51a8"
on watchlist ("tmdbId");`
);
await queryRunner.query(
`create unique index if not exists idx_194788_sqlite_autoindex_watchlist_1
on watchlist ("tmdbId", "requestedById");`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table if exists session cascade`);
await queryRunner.query(`drop table if exists season_request cascade`);
await queryRunner.query(`drop table if exists season cascade`);
await queryRunner.query(
`drop table if exists user_push_subscription cascade`
);
await queryRunner.query(`drop table if exists issue_comment cascade`);
await queryRunner.query(`drop table if exists issue cascade`);
await queryRunner.query(`drop table if exists user_settings cascade`);
await queryRunner.query(`drop table if exists media_request cascade`);
await queryRunner.query(`drop table if exists media cascade`);
await queryRunner.query(`drop table if exists "user" cascade`);
await queryRunner.query(`drop table if exists discover_slider cascade`);
await queryRunner.query(`drop table if exists watchlist cascade`);
}
}

View File

@@ -1,32 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklist1730770837441 implements MigrationInterface {
name = 'AddBlacklist1730770837441';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blacklist"
(
"id" SERIAL PRIMARY KEY,
"mediaType" VARCHAR NOT NULL,
"title" VARCHAR,
"tmdbId" INTEGER NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
"userId" INTEGER,
"mediaId" INTEGER,
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId")
)`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_6bbafa28411e6046421991ea21"`
);
await queryRunner.query(`DROP TABLE IF EXISTS "blacklist"`);
}
}

View File

@@ -1,4 +1,4 @@
import type { TmdbCollection } from '@server/api/themoviedb/interfaces';
import type { TmdbCollection } from '@server/api/indexer/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
import { sortBy } from 'lodash';

View File

@@ -2,7 +2,7 @@ import type {
TmdbMovieDetails,
TmdbMovieReleaseResult,
TmdbProductionCompany,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,

View File

@@ -2,7 +2,7 @@ import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
TmdbPersonDetails,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
export interface PersonDetails {

View File

@@ -6,7 +6,7 @@ import type {
TmdbPersonResult,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';

View File

@@ -5,7 +5,7 @@ import type {
TmdbTvEpisodeResult,
TmdbTvRatingResult,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
seasonNumber: episode.season_number,
showId: episode.show_id,
voteAverage: episode.vote_average,
voteCount: episode.vote_cuont,
voteCount: episode.vote_count,
stillPath: episode.still_path,
});

View File

@@ -7,7 +7,7 @@ import type {
TmdbVideoResult,
TmdbWatchProviderDetails,
TmdbWatchProviders,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type { Video } from '@server/models/Movie';
export interface ProductionCompany {

View File

@@ -299,84 +299,54 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id },
});
const missingAdminUser = !user && !(await userRepository.count());
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
if (!user && !(await userRepository.count())) {
// Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin);
}
if (
body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY
) {
throw new Error('select_server_type');
}
settings.main.mediaServerType = body.serverType;
if (missingAdminUser) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
user = new User({
id: 1,
email: body.email || account.User.Name,
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(user);
} else {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User alread exist but settings.json is not configured, we'll edit the admin user
user = await userRepository.findOne({
where: { id: 1 },
});
if (!user) {
throw new Error('Unable to find admin user to edit');
}
user.email = body.email || account.User.Name;
user.jellyfinUsername = account.User.Name;
user.jellyfinUserId = account.User.Id;
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY;
);
await userRepository.save(user);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
}
// Create an API key on Jellyfin from this admin user
@@ -398,6 +368,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.apiKey = apiKey;
await settings.save();
startJobs();
await userRepository.save(user);
}
// User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) {

View File

@@ -2,12 +2,14 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
import rateLimit from 'express-rate-limit';
import { QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
@@ -24,6 +26,7 @@ blacklistRoutes.get(
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
@@ -68,32 +71,6 @@ blacklistRoutes.get(
}
);
blacklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
blacklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
@@ -157,7 +134,7 @@ blacklistRoutes.delete(
return res.status(204).send();
} catch (e) {
if (e instanceof EntityNotFoundError) {
if (e instanceof NotFoundError) {
return next({
status: 401,
message: e.message,

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapCollection } from '@server/models/Collection';

View File

@@ -1,7 +1,7 @@
import type { SortOptions } from '@server/api/indexer/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,10 +1,10 @@
import GithubAPI from '@server/api/github';
import PushoverAPI from '@server/api/pushover';
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import PushoverAPI from '@server/api/pushover';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TautulliAPI from '@server/api/tautulli';
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import {

View File

@@ -113,7 +113,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
requestStatus: statusFilter,
})
.andWhere(
'((request.is4k = false AND media.status IN (:...mediaStatus)) OR (request.is4k = true AND media.status4k IN (:...mediaStatus)))',
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
{
mediaStatus: mediaStatusFilter,
}

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/indexer/themoviedb/interfaces';
import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search';
import logger from '@server/logger';

Some files were not shown because too many files have changed in this diff Show More