mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-26 03:43:09 -05:00
Compare commits
20 Commits
preview-po
...
preview-tv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee5535819 | ||
|
|
67a846cd58 | ||
|
|
a5e8320e8a | ||
|
|
3f16176667 | ||
|
|
85aeeb084e | ||
|
|
b865d65fad | ||
|
|
47eece9c44 | ||
|
|
72277ea983 | ||
|
|
57e2f7b374 | ||
|
|
6f8d4bf00a | ||
|
|
61ecf74b28 | ||
|
|
976781d470 | ||
|
|
422012f7b5 | ||
|
|
32f500a4e7 | ||
|
|
7b07004c5b | ||
|
|
87253e8bb7 | ||
|
|
7bcda9521e | ||
|
|
2d51b16694 | ||
|
|
2a0bcdf41c | ||
|
|
79e542ef12 |
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ config/logs/*
|
||||
config/*.json
|
||||
dist
|
||||
Dockerfile*
|
||||
compose.yaml
|
||||
docker-compose.yml
|
||||
docs
|
||||
LICENSE
|
||||
node_modules
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -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
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"webhookRoleId": "",
|
||||
"enableMentions": true
|
||||
}
|
||||
},
|
||||
|
||||
92
cypress/e2e/indexers/tvdb.cy.ts
Normal file
92
cypress/e2e/indexers/tvdb.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
@@ -1,3 +1,4 @@
|
||||
version: '3'
|
||||
services:
|
||||
jellyseerr:
|
||||
build:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 → Integrations → 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!
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
128
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
23
server/api/indexer/index.ts
Normal file
23
server/api/indexer/index.ts
Normal 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>;
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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 {
|
||||
246
server/api/indexer/tvdb/index.ts
Normal file
246
server/api/indexer/tvdb/index.ts
Normal 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;
|
||||
80
server/api/indexer/tvdb/interfaces.ts
Normal file
80
server/api/indexer/tvdb/interfaces.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user