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": [
|
"contributions": [
|
||||||
"security"
|
"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
|
config/*.json
|
||||||
dist
|
dist
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
compose.yaml
|
docker-compose.yml
|
||||||
docs
|
docs
|
||||||
LICENSE
|
LICENSE
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -40,7 +40,7 @@ docs export-ignore
|
|||||||
.all-contributorsrc export-ignore
|
.all-contributorsrc export-ignore
|
||||||
.editorconfig export-ignore
|
.editorconfig export-ignore
|
||||||
Dockerfile.local export-ignore
|
Dockerfile.local export-ignore
|
||||||
compose.yaml export-ignore
|
docker-compose.yml export-ignore
|
||||||
stylelint.config.js export-ignore
|
stylelint.config.js export-ignore
|
||||||
|
|
||||||
public/os_logo_filled.png 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
|
- tablet
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
id: database
|
|
||||||
attributes:
|
|
||||||
options:
|
|
||||||
- SQLite (default)
|
|
||||||
- PostgreSQL
|
|
||||||
label: Database
|
|
||||||
description: Which database backend are you using?
|
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
4. Run the development environment:
|
4. Run the development environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm
|
||||||
pnpm dev
|
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.
|
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="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/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/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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -75,7 +75,6 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"webhookRoleId": "",
|
|
||||||
"enableMentions": true
|
"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:
|
services:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
build:
|
build:
|
||||||
@@ -17,7 +17,6 @@ Welcome to the Jellyseerr Documentation.
|
|||||||
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Localization into other languages.
|
- Localization into other languages.
|
||||||
- Support for PostgreSQL and SQLite databases.
|
|
||||||
- More features to come!
|
- More features to come!
|
||||||
|
|
||||||
## Motivation
|
## 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 '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
sub_filter '/images/' '/$app/images/';
|
sub_filter '/images/' '/$app/images/';
|
||||||
sub_filter '/imageproxy/' '/$app/imageproxy/';
|
|
||||||
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
|
|
||||||
sub_filter '/android-' '/$app/android-';
|
sub_filter '/android-' '/$app/android-';
|
||||||
sub_filter '/apple-' '/$app/apple-';
|
sub_filter '/apple-' '/$app/apple-';
|
||||||
sub_filter '/favicon' '/$app/favicon';
|
sub_filter '/favicon' '/$app/favicon';
|
||||||
@@ -192,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
|
|||||||
|
|
||||||
## Traefik (v2)
|
## 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
|
```yaml
|
||||||
labels:
|
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/).
|
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
||||||
|
|
||||||
#### Installation:
|
#### Installation:
|
||||||
Define the `jellyseerr` service in your `compose.yaml` as follows:
|
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
services:
|
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:
|
Then, start all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating:
|
#### Updating:
|
||||||
Pull the latest image:
|
Pull the latest image:
|
||||||
```bash
|
```bash
|
||||||
docker compose pull jellyseerr
|
docker-compose pull jellyseerr
|
||||||
```
|
```
|
||||||
Then, restart all services defined in the Compose file:
|
Then, restart all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
:::tip
|
:::tip
|
||||||
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
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**.
|
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)
|
### 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!
|
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:
|
serverID:
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
TvdbSettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
use:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
TautulliSettings:
|
TautulliSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1273,8 +1279,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
webhookRoleId:
|
|
||||||
type: string
|
|
||||||
enableMentions:
|
enableMentions:
|
||||||
type: boolean
|
type: boolean
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
@@ -2363,6 +2367,60 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
thumb:
|
thumb:
|
||||||
type: string
|
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:
|
/settings/tautulli:
|
||||||
get:
|
get:
|
||||||
summary: Get Tautulli settings
|
summary: Get Tautulli settings
|
||||||
@@ -4144,21 +4202,6 @@ paths:
|
|||||||
'412':
|
'412':
|
||||||
description: Item has already been blacklisted
|
description: Item has already been blacklisted
|
||||||
/blacklist/{tmdbId}:
|
/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:
|
delete:
|
||||||
summary: Remove media from blacklist
|
summary: Remove media from blacklist
|
||||||
tags:
|
tags:
|
||||||
@@ -5486,7 +5529,7 @@ paths:
|
|||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
minimum: 0
|
minimum: 1
|
||||||
- type: string
|
- type: string
|
||||||
enum: [all]
|
enum: [all]
|
||||||
is4k:
|
is4k:
|
||||||
@@ -5592,7 +5635,7 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
minimum: 0
|
minimum: 1
|
||||||
is4k:
|
is4k:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
@@ -5926,7 +5969,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/TvDetails'
|
$ref: '#/components/schemas/TvDetails'
|
||||||
/tv/{tvId}/season/{seasonId}:
|
/tv/{tvId}/season/{seasonNumber}:
|
||||||
get:
|
get:
|
||||||
summary: Get season details and episode list
|
summary: Get season details and episode list
|
||||||
description: Returns season details with a list of episodes in a JSON object.
|
description: Returns season details with a list of episodes in a JSON object.
|
||||||
@@ -5940,11 +5983,11 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
example: 76479
|
example: 76479
|
||||||
- in: path
|
- in: path
|
||||||
name: seasonId
|
name: seasonNumber
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 1
|
example: 123456
|
||||||
- in: query
|
- in: query
|
||||||
name: language
|
name: language
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -69,7 +69,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.9.1",
|
||||||
"openpgp": "5.7.0",
|
"openpgp": "5.7.0",
|
||||||
"pg": "8.11.0",
|
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
128
pnpm-lock.yaml
generated
128
pnpm-lock.yaml
generated
@@ -49,7 +49,7 @@ importers:
|
|||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
connect-typeorm:
|
connect-typeorm:
|
||||||
specifier: 1.1.4
|
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:
|
cookie-parser:
|
||||||
specifier: 1.4.6
|
specifier: 1.4.6
|
||||||
version: 1.4.6
|
version: 1.4.6
|
||||||
@@ -119,9 +119,6 @@ importers:
|
|||||||
openpgp:
|
openpgp:
|
||||||
specifier: 5.7.0
|
specifier: 5.7.0
|
||||||
version: 5.7.0
|
version: 5.7.0
|
||||||
pg:
|
|
||||||
specifier: 8.11.0
|
|
||||||
version: 8.11.0
|
|
||||||
plex-api:
|
plex-api:
|
||||||
specifier: 5.3.2
|
specifier: 5.3.2
|
||||||
version: 5.3.2
|
version: 5.3.2
|
||||||
@@ -196,7 +193,7 @@ importers:
|
|||||||
version: 2.2.5(react@18.3.1)
|
version: 2.2.5(react@18.3.1)
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: 0.3.11
|
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:
|
undici:
|
||||||
specifier: ^6.20.1
|
specifier: ^6.20.1
|
||||||
version: 6.20.1
|
version: 6.20.1
|
||||||
@@ -3533,10 +3530,6 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
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:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
@@ -7057,9 +7050,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==}
|
resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
packet-reader@1.0.0:
|
|
||||||
resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==}
|
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -7151,40 +7141,6 @@ packages:
|
|||||||
performance-now@2.1.0:
|
performance-now@2.1.0:
|
||||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
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:
|
picocolors@1.0.1:
|
||||||
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
||||||
|
|
||||||
@@ -7290,22 +7246,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -8216,10 +8156,6 @@ packages:
|
|||||||
split2@3.2.2:
|
split2@3.2.2:
|
||||||
resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==}
|
resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==}
|
||||||
|
|
||||||
split2@4.2.0:
|
|
||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
|
||||||
engines: {node: '>= 10.x'}
|
|
||||||
|
|
||||||
split@1.0.1:
|
split@1.0.1:
|
||||||
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==}
|
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==}
|
||||||
|
|
||||||
@@ -13493,8 +13429,6 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer-writer@2.0.0: {}
|
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
@@ -13885,13 +13819,13 @@ snapshots:
|
|||||||
ini: 1.3.8
|
ini: 1.3.8
|
||||||
proto-list: 1.2.4
|
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:
|
dependencies:
|
||||||
'@types/debug': 0.0.31
|
'@types/debug': 0.0.31
|
||||||
'@types/express-session': 1.17.6
|
'@types/express-session': 1.17.6
|
||||||
debug: 4.3.5(supports-color@8.1.1)
|
debug: 4.3.5(supports-color@8.1.1)
|
||||||
express-session: 1.18.0
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -17644,8 +17578,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-timeout: 3.2.0
|
p-timeout: 3.2.0
|
||||||
|
|
||||||
packet-reader@1.0.0: {}
|
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -17724,43 +17656,6 @@ snapshots:
|
|||||||
|
|
||||||
performance-now@2.1.0: {}
|
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: {}
|
picocolors@1.0.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
@@ -17858,16 +17753,6 @@ snapshots:
|
|||||||
picocolors: 1.0.1
|
picocolors: 1.0.1
|
||||||
source-map-js: 1.2.0
|
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: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier-linter-helpers@1.0.0:
|
prettier-linter-helpers@1.0.0:
|
||||||
@@ -18997,8 +18882,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
split2@4.2.0: {}
|
|
||||||
|
|
||||||
split@1.0.1:
|
split@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
through: 2.3.8
|
through: 2.3.8
|
||||||
@@ -19535,7 +19418,7 @@ snapshots:
|
|||||||
|
|
||||||
typedarray@0.0.6: {}
|
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:
|
dependencies:
|
||||||
'@sqltools/formatter': 1.2.5
|
'@sqltools/formatter': 1.2.5
|
||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
@@ -19555,7 +19438,6 @@ snapshots:
|
|||||||
xml2js: 0.4.23
|
xml2js: 0.4.23
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
pg: 8.11.0
|
|
||||||
sqlite3: 5.1.4(encoding@0.1.13)
|
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)
|
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:
|
transitivePeerDependencies:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const DEFAULT_TTL = 300;
|
|||||||
// 10 seconds default rolling buffer (in ms)
|
// 10 seconds default rolling buffer (in ms)
|
||||||
const DEFAULT_ROLLING_BUFFER = 10000;
|
const DEFAULT_ROLLING_BUFFER = 10000;
|
||||||
|
|
||||||
interface ExternalAPIOptions {
|
export interface ExternalAPIOptions {
|
||||||
nodeCache?: NodeCache;
|
nodeCache?: NodeCache;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
rateLimit?: RateLimitOptions;
|
rateLimit?: RateLimitOptions;
|
||||||
@@ -53,6 +53,7 @@ class ExternalAPI {
|
|||||||
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.params = params;
|
this.params = params;
|
||||||
|
|
||||||
this.cache = options.nodeCache;
|
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 ExternalAPI from '@server/api/externalapi';
|
||||||
|
import type { TvShowIndexer } from '@server/api/indexer';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
@@ -98,7 +99,7 @@ interface DiscoverTvOptions {
|
|||||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
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 region?: string;
|
||||||
private originalLanguage?: string;
|
private originalLanguage?: string;
|
||||||
constructor({
|
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;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||||
@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
|
|||||||
show_id: number;
|
show_id: number;
|
||||||
still_path: string;
|
still_path: string;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
vote_cuont: number;
|
vote_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbTvSeasonResult {
|
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 {
|
try {
|
||||||
return await authenticate(true);
|
return await authenticate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Failed to authenticate with headers', {
|
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
ip: ClientIP,
|
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 {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.cause.status === 401) {
|
const status = e.cause?.status;
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
|
||||||
|
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(
|
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +198,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the server name from the Jellyfin server',
|
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||||
@@ -212,8 +213,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -228,8 +229,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -252,11 +253,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting libraries from the Jellyfin server',
|
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||||
{
|
{ label: 'Jellyfin API' }
|
||||||
label: 'Jellyfin API',
|
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -310,8 +308,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -331,8 +329,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -356,8 +354,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
@@ -370,8 +368,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -395,8 +393,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -412,8 +410,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
).AccessToken;
|
).AccessToken;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while creating an API key from the Jellyfin server',
|
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
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 cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
@@ -128,11 +127,6 @@ export interface PlexWatchlistItem {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlexWatchlistCache {
|
|
||||||
etag: string;
|
|
||||||
response: WatchlistResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlexTvAPI extends ExternalAPI {
|
class PlexTvAPI extends ExternalAPI {
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
|
|
||||||
@@ -267,11 +261,6 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
items: PlexWatchlistItem[];
|
items: PlexWatchlistItem[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const watchlistCache = cacheManager.getCache('plexwatchlist');
|
|
||||||
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
|
|
||||||
this.authToken
|
|
||||||
);
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
'X-Plex-Container-Start': offset.toString(),
|
'X-Plex-Container-Start': offset.toString(),
|
||||||
'X-Plex-Container-Size': size.toString(),
|
'X-Plex-Container-Size': size.toString(),
|
||||||
@@ -279,62 +268,42 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
const response = await this.fetch(
|
const response = await this.fetch(
|
||||||
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: this.defaultHeaders,
|
||||||
...this.defaultHeaders,
|
|
||||||
...(cachedWatchlist?.etag
|
|
||||||
? { 'If-None-Match': cachedWatchlist.etag }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as WatchlistResponse;
|
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(
|
const watchlistDetails = await Promise.all(
|
||||||
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
|
||||||
async (watchlistItem) => {
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
{},
|
||||||
{},
|
undefined,
|
||||||
undefined,
|
{},
|
||||||
{},
|
'https://metadata.provider.plex.tv'
|
||||||
'https://metadata.provider.plex.tv'
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
|
|
||||||
const tmdbString = metadata.Guid.find((guid) =>
|
const tmdbString = metadata.Guid.find((guid) =>
|
||||||
guid.id.startsWith('tmdb')
|
guid.id.startsWith('tmdb')
|
||||||
);
|
);
|
||||||
const tvdbString = metadata.Guid.find((guid) =>
|
const tvdbString = metadata.Guid.find((guid) =>
|
||||||
guid.id.startsWith('tvdb')
|
guid.id.startsWith('tvdb')
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ratingKey: metadata.ratingKey,
|
ratingKey: metadata.ratingKey,
|
||||||
// This should always be set? But I guess it also cannot be?
|
// This should always be set? But I guess it also cannot be?
|
||||||
// We will filter out the 0's afterwards
|
// We will filter out the 0's afterwards
|
||||||
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||||
tvdbId: tvdbString
|
tvdbId: tvdbString
|
||||||
? Number(tvdbString.id.split('//')[1])
|
? Number(tvdbString.id.split('//')[1])
|
||||||
: undefined,
|
: undefined,
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
type: metadata.type,
|
type: metadata.type,
|
||||||
};
|
};
|
||||||
}
|
})
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||||
@@ -342,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
return {
|
return {
|
||||||
offset,
|
offset,
|
||||||
size,
|
size,
|
||||||
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
|
totalSize: data.MediaContainer.totalSize,
|
||||||
items: filteredList,
|
items: filteredList,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} 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;
|
export default PlexTvAPI;
|
||||||
|
|||||||
@@ -1,43 +1,7 @@
|
|||||||
import fs from 'fs';
|
import 'reflect-metadata';
|
||||||
import type { TlsOptions } from 'tls';
|
|
||||||
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
||||||
import { DataSource } 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 = {
|
const devConfig: DataSourceOptions = {
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
database: process.env.CONFIG_DIRECTORY
|
database: process.env.CONFIG_DIRECTORY
|
||||||
@@ -45,10 +9,10 @@ const devConfig: DataSourceOptions = {
|
|||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['server/entity/**/*.ts'],
|
entities: ['server/entity/**/*.ts'],
|
||||||
migrations: ['server/migration/sqlite/**/*.ts'],
|
migrations: ['server/migration/**/*.ts'],
|
||||||
subscribers: ['server/subscriber/**/*.ts'],
|
subscribers: ['server/subscriber/**/*.ts'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,56 +23,16 @@ const prodConfig: DataSourceOptions = {
|
|||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['dist/entity/**/*.js'],
|
entities: ['dist/entity/**/*.js'],
|
||||||
migrations: ['dist/migration/sqlite/**/*.js'],
|
migrations: ['dist/migration/**/*.js'],
|
||||||
subscribers: ['dist/subscriber/**/*.js'],
|
subscribers: ['dist/subscriber/**/*.js'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const postgresDevConfig: DataSourceOptions = {
|
const dataSource = new DataSource(
|
||||||
type: 'postgres',
|
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||||
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());
|
|
||||||
|
|
||||||
export const getRepository = <Entity extends object>(
|
export const getRepository = <Entity extends object>(
|
||||||
target: EntityTarget<Entity>
|
target: EntityTarget<Entity>
|
||||||
|
|||||||
@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
|
|||||||
status: MediaStatus.BLACKLISTED,
|
status: MediaStatus.BLACKLISTED,
|
||||||
status4k: MediaStatus.BLACKLISTED,
|
status4k: MediaStatus.BLACKLISTED,
|
||||||
mediaType: blacklistRequest.mediaType,
|
mediaType: blacklistRequest.mediaType,
|
||||||
blacklist: Promise.resolve(blacklist),
|
blacklist: blacklist,
|
||||||
});
|
});
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
} else {
|
} else {
|
||||||
media.blacklist = Promise.resolve(blacklist);
|
media.blacklist = blacklist;
|
||||||
media.status = MediaStatus.BLACKLISTED;
|
media.status = MediaStatus.BLACKLISTED;
|
||||||
media.status4k = MediaStatus.BLACKLISTED;
|
media.status4k = MediaStatus.BLACKLISTED;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
|
|||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
@@ -43,10 +42,6 @@ class Media {
|
|||||||
finalIds = tmdbIds;
|
finalIds = tmdbIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = await mediaRepository
|
const media = await mediaRepository
|
||||||
.createQueryBuilder('media')
|
.createQueryBuilder('media')
|
||||||
.leftJoinAndSelect(
|
.leftJoinAndSelect(
|
||||||
@@ -123,8 +118,10 @@ class Media {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||||
public issues: Issue[];
|
public issues: Issue[];
|
||||||
|
|
||||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
|
||||||
public blacklist: Promise<Blacklist>;
|
eager: true,
|
||||||
|
})
|
||||||
|
public blacklist: Blacklist;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
@@ -132,23 +129,10 @@ class Media {
|
|||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
/**
|
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
* 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' })
|
|
||||||
public lastSeasonChange: Date;
|
public lastSeasonChange: Date;
|
||||||
|
|
||||||
/**
|
@Column({ type: 'datetime', nullable: true })
|
||||||
* 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,
|
|
||||||
})
|
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'int' })
|
@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 type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import type {
|
import type {
|
||||||
@@ -5,8 +7,6 @@ import type {
|
|||||||
SonarrSeries,
|
SonarrSeries,
|
||||||
} from '@server/api/servarr/sonarr';
|
} from '@server/api/servarr/sonarr';
|
||||||
import SonarrAPI 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 {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -257,7 +257,9 @@ export class MediaRequest {
|
|||||||
>;
|
>;
|
||||||
const requestedSeasons =
|
const requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
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[]);
|
: (requestBody.seasons as number[]);
|
||||||
let existingSeasons: number[] = [];
|
let existingSeasons: number[] = [];
|
||||||
|
|
||||||
@@ -385,7 +387,6 @@ export class MediaRequest {
|
|||||||
@ManyToOne(() => Media, (media) => media.requests, {
|
@ManyToOne(() => Media, (media) => media.requests, {
|
||||||
eager: true,
|
eager: true,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
nullable: false,
|
|
||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@@ -858,7 +859,7 @@ export class MediaRequest {
|
|||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
await requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
@@ -1133,14 +1134,13 @@ export class MediaRequest {
|
|||||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
sonarrSeries.titleSlug;
|
sonarrSeries.titleSlug;
|
||||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
await requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ class Season {
|
|||||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||||
public status4k: MediaStatus;
|
public status4k: MediaStatus;
|
||||||
|
|
||||||
@ManyToOne(() => Media, (media) => media.seasons, {
|
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
|
||||||
onDelete: 'CASCADE',
|
|
||||||
nullable: false,
|
|
||||||
})
|
|
||||||
public media: Promise<Media>;
|
public media: Promise<Media>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@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 { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
@@ -53,7 +53,6 @@ export class Watchlist implements WatchlistItem {
|
|||||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||||
eager: true,
|
eager: true,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
nullable: false,
|
|
||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
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 DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import { Session } from '@server/entity/Session';
|
import { Session } from '@server/entity/Session';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -66,13 +66,9 @@ app
|
|||||||
|
|
||||||
// Run migrations in production
|
// Run migrations in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
if (isPgsql) {
|
await dbConnection.query('PRAGMA foreign_keys=OFF');
|
||||||
await dbConnection.runMigrations();
|
await dbConnection.runMigrations();
|
||||||
} else {
|
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
|
// Load Settings
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { MediaServerType } from '@server/constants/server';
|
|||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
import refreshToken from '@server/lib/refreshToken';
|
|
||||||
import {
|
import {
|
||||||
jellyfinFullScanner,
|
jellyfinFullScanner,
|
||||||
jellyfinRecentScanner,
|
jellyfinRecentScanner,
|
||||||
@@ -14,6 +13,7 @@ import type { JobId } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import watchlistSync from '@server/lib/watchlistsync';
|
import watchlistSync from '@server/lib/watchlistsync';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import random from 'lodash/random';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
@@ -113,20 +113,30 @@ export const startJobs = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watchlist Sync
|
// Watchlist Sync
|
||||||
scheduledJobs.push({
|
const watchlistSyncJob: ScheduledJob = {
|
||||||
id: 'plex-watchlist-sync',
|
id: 'plex-watchlist-sync',
|
||||||
name: 'Plex Watchlist Sync',
|
name: 'Plex Watchlist Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'seconds',
|
interval: 'fixed',
|
||||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
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', {
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
watchlistSync.syncWatchlist();
|
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
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
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' });
|
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type AvailableCacheIds =
|
|||||||
| 'github'
|
| 'github'
|
||||||
| 'plexguid'
|
| 'plexguid'
|
||||||
| 'plextv'
|
| 'plextv'
|
||||||
| 'plexwatchlist';
|
| 'tvdb';
|
||||||
|
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
const DEFAULT_CHECK_PERIOD = 120;
|
const DEFAULT_CHECK_PERIOD = 120;
|
||||||
@@ -69,7 +69,10 @@ class CacheManager {
|
|||||||
stdTtl: 86400 * 7, // 1 week cache
|
stdTtl: 86400 * 7, // 1 week cache
|
||||||
checkPeriod: 60,
|
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 {
|
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, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
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 { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
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 type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||||
import JellyfinAPI 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 { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import animeList from '@server/api/animelist';
|
import animeList from '@server/api/animelist';
|
||||||
|
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
@@ -278,7 +278,9 @@ class PlexScanner
|
|||||||
const seasons = tvShow.seasons;
|
const seasons = tvShow.seasons;
|
||||||
const processableSeasons: ProcessableSeason[] = [];
|
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(
|
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||||
(md) => Number(md.index) === season.season_number
|
(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 type { SonarrSeries } from '@server/api/servarr/sonarr';
|
||||||
import SonarrAPI 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 { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import type {
|
import type {
|
||||||
@@ -103,8 +103,10 @@ class SonarrScanner
|
|||||||
|
|
||||||
const tmdbId = tvShow.id;
|
const tmdbId = tvShow.id;
|
||||||
|
|
||||||
const filteredSeasons = sonarrSeries.seasons.filter((sn) =>
|
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||||
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
|
(sn) =>
|
||||||
|
sn.seasonNumber !== 0 &&
|
||||||
|
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const season of filteredSeasons) {
|
for (const season of filteredSeasons) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/indexer/themoviedb';
|
||||||
import type {
|
import type {
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
TmdbSearchTvResponse,
|
TmdbSearchTvResponse,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbTvResult,
|
TmdbTvResult,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import {
|
import {
|
||||||
mapMovieDetailsToResult,
|
mapMovieDetailsToResult,
|
||||||
mapPersonDetailsToResult,
|
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 { MediaServerType } from '@server/constants/server';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { runMigrations } from '@server/lib/settings/migrator';
|
import { runMigrations } from '@server/lib/settings/migrator';
|
||||||
@@ -170,7 +173,6 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
|||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
botAvatarUrl?: string;
|
botAvatarUrl?: string;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
webhookRoleId?: string;
|
|
||||||
enableMentions: boolean;
|
enableMentions: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -282,7 +284,6 @@ export type JobId =
|
|||||||
| 'plex-recently-added-scan'
|
| 'plex-recently-added-scan'
|
||||||
| 'plex-full-scan'
|
| 'plex-full-scan'
|
||||||
| 'plex-watchlist-sync'
|
| 'plex-watchlist-sync'
|
||||||
| 'plex-refresh-token'
|
|
||||||
| 'radarr-scan'
|
| 'radarr-scan'
|
||||||
| 'sonarr-scan'
|
| 'sonarr-scan'
|
||||||
| 'download-sync'
|
| 'download-sync'
|
||||||
@@ -305,6 +306,7 @@ export interface AllSettings {
|
|||||||
public: PublicSettings;
|
public: PublicSettings;
|
||||||
notifications: NotificationSettings;
|
notifications: NotificationSettings;
|
||||||
jobs: Record<JobId, JobSettings>;
|
jobs: Record<JobId, JobSettings>;
|
||||||
|
tvdb: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
@@ -370,6 +372,7 @@ class Settings {
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
},
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
|
tvdb: false,
|
||||||
radarr: [],
|
radarr: [],
|
||||||
sonarr: [],
|
sonarr: [],
|
||||||
public: {
|
public: {
|
||||||
@@ -396,7 +399,6 @@ class Settings {
|
|||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
webhookRoleId: '',
|
|
||||||
enableMentions: true,
|
enableMentions: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -470,10 +472,7 @@ class Settings {
|
|||||||
schedule: '0 0 3 * * *',
|
schedule: '0 0 3 * * *',
|
||||||
},
|
},
|
||||||
'plex-watchlist-sync': {
|
'plex-watchlist-sync': {
|
||||||
schedule: '0 */3 * * * *',
|
schedule: '0 */10 * * * *',
|
||||||
},
|
|
||||||
'plex-refresh-token': {
|
|
||||||
schedule: '0 0 5 * * *',
|
|
||||||
},
|
},
|
||||||
'radarr-scan': {
|
'radarr-scan': {
|
||||||
schedule: '0 0 4 * * *',
|
schedule: '0 0 4 * * *',
|
||||||
@@ -538,6 +537,14 @@ class Settings {
|
|||||||
this.data.tautulli = data;
|
this.data.tautulli = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get tvdb(): boolean {
|
||||||
|
return this.data.tvdb;
|
||||||
|
}
|
||||||
|
|
||||||
|
set tvdb(data: boolean) {
|
||||||
|
this.data.tvdb = data;
|
||||||
|
}
|
||||||
|
|
||||||
get radarr(): RadarrSettings[] {
|
get radarr(): RadarrSettings[] {
|
||||||
return this.data.radarr;
|
return this.data.radarr;
|
||||||
}
|
}
|
||||||
@@ -705,4 +712,13 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
|
|||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getIndexer = (): TvShowIndexer => {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.tvdb) {
|
||||||
|
return new Tvdb();
|
||||||
|
} else {
|
||||||
|
return new TheMovieDb();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class WatchlistSync {
|
|||||||
|
|
||||||
const plexTvApi = new PlexTvAPI(user.plexToken);
|
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(
|
const mediaItems = await Media.getRelatedMedia(
|
||||||
user,
|
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 { MediaType } from '@server/constants/media';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbMovieReleaseResult,
|
TmdbMovieReleaseResult,
|
||||||
TmdbProductionCompany,
|
TmdbProductionCompany,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import type {
|
import type {
|
||||||
Cast,
|
Cast,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
TmdbPersonCreditCast,
|
TmdbPersonCreditCast,
|
||||||
TmdbPersonCreditCrew,
|
TmdbPersonCreditCrew,
|
||||||
TmdbPersonDetails,
|
TmdbPersonDetails,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
|
|
||||||
export interface PersonDetails {
|
export interface PersonDetails {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
TmdbPersonResult,
|
TmdbPersonResult,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbTvResult,
|
TmdbTvResult,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
TmdbTvEpisodeResult,
|
TmdbTvEpisodeResult,
|
||||||
TmdbTvRatingResult,
|
TmdbTvRatingResult,
|
||||||
TmdbTvSeasonResult,
|
TmdbTvSeasonResult,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import type {
|
import type {
|
||||||
Cast,
|
Cast,
|
||||||
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
|||||||
seasonNumber: episode.season_number,
|
seasonNumber: episode.season_number,
|
||||||
showId: episode.show_id,
|
showId: episode.show_id,
|
||||||
voteAverage: episode.vote_average,
|
voteAverage: episode.vote_average,
|
||||||
voteCount: episode.vote_cuont,
|
voteCount: episode.vote_count,
|
||||||
stillPath: episode.still_path,
|
stillPath: episode.still_path,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
TmdbVideoResult,
|
TmdbVideoResult,
|
||||||
TmdbWatchProviderDetails,
|
TmdbWatchProviderDetails,
|
||||||
TmdbWatchProviders,
|
TmdbWatchProviders,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import type { Video } from '@server/models/Movie';
|
import type { Video } from '@server/models/Movie';
|
||||||
|
|
||||||
export interface ProductionCompany {
|
export interface ProductionCompany {
|
||||||
|
|||||||
@@ -299,84 +299,54 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
where: { jellyfinUserId: account.User.Id },
|
where: { jellyfinUserId: account.User.Id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const missingAdminUser = !user && !(await userRepository.count());
|
if (!user && !(await userRepository.count())) {
|
||||||
if (
|
|
||||||
missingAdminUser ||
|
|
||||||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
|
|
||||||
) {
|
|
||||||
// Check if user is admin on jellyfin
|
// Check if user is admin on jellyfin
|
||||||
if (account.User.Policy.IsAdministrator === false) {
|
if (account.User.Policy.IsAdministrator === false) {
|
||||||
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
logger.info(
|
||||||
body.serverType !== MediaServerType.JELLYFIN &&
|
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
|
||||||
body.serverType !== MediaServerType.EMBY
|
{
|
||||||
) {
|
label: 'API',
|
||||||
throw new Error('select_server_type');
|
ip: req.ip,
|
||||||
}
|
|
||||||
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,
|
|
||||||
jellyfinUsername: 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:
|
|
||||||
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
|
// 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;
|
settings.jellyfin.apiKey = apiKey;
|
||||||
await settings.save();
|
await settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
}
|
}
|
||||||
// User already exists, let's update their information
|
// User already exists, let's update their information
|
||||||
else if (account.User.Id === user?.jellyfinUserId) {
|
else if (account.User.Id === user?.jellyfinUserId) {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { Blacklist } from '@server/entity/Blacklist';
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import { NotFoundError } from '@server/entity/Watchlist';
|
||||||
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { QueryFailedError } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const blacklistRoutes = Router();
|
const blacklistRoutes = Router();
|
||||||
@@ -24,6 +26,7 @@ blacklistRoutes.get(
|
|||||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
|
rateLimit({ windowMs: 60 * 1000, max: 50 }),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
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(
|
blacklistRoutes.post(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
@@ -157,7 +134,7 @@ blacklistRoutes.delete(
|
|||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof EntityNotFoundError) {
|
if (e instanceof NotFoundError) {
|
||||||
return next({
|
return next({
|
||||||
status: 401,
|
status: 401,
|
||||||
message: e.message,
|
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 Media from '@server/entity/Media';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapCollection } from '@server/models/Collection';
|
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 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 { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import GithubAPI from '@server/api/github';
|
import GithubAPI from '@server/api/github';
|
||||||
import PushoverAPI from '@server/api/pushover';
|
import TheMovieDb from '@server/api/indexer/themoviedb';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
|
||||||
import type {
|
import type {
|
||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
TmdbTvResult,
|
TmdbTvResult,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/indexer/themoviedb/interfaces';
|
||||||
|
import PushoverAPI from '@server/api/pushover';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
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 RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
|
||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
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 IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
|
||||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||||
import { type RatingResponse } from '@server/api/ratings';
|
import { type RatingResponse } from '@server/api/ratings';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
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 Media from '@server/entity/Media';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
requestStatus: statusFilter,
|
requestStatus: statusFilter,
|
||||||
})
|
})
|
||||||
.andWhere(
|
.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,
|
mediaStatus: mediaStatusFilter,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/indexer/themoviedb';
|
||||||
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
|
import type { TmdbSearchMultiResponse } from '@server/api/indexer/themoviedb/interfaces';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { findSearchProvider } from '@server/lib/search';
|
import { findSearchProvider } from '@server/lib/search';
|
||||||
import logger from '@server/logger';
|
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