Compare commits

..

20 Commits

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

View File

@@ -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
View File

@@ -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

View File

@@ -13,7 +13,7 @@ jobs:
name: Lint & Test Build name: Lint & Test Build
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: node:22-alpine container: node:20-alpine
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -17,7 +17,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Pnpm Setup - name: Pnpm Setup
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:

View File

@@ -20,7 +20,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Pnpm Setup - name: Pnpm Setup
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx

View File

@@ -20,7 +20,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Pnpm Setup - name: Pnpm Setup
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- HTML/Typescript/Javascript editor - HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install. - [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 22.x) - [NodeJS](https://nodejs.org/en/download/) (Node 20.x)
- [Pnpm](https://pnpm.io/cli/install) - [Pnpm](https://pnpm.io/cli/install)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
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.

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine AS BUILD_IMAGE FROM node:20-alpine AS BUILD_IMAGE
WORKDIR /app WORKDIR /app
@@ -36,7 +36,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:22-alpine FROM node:20-alpine
# Metadata for Github Package Registry # Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr" LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine FROM node:20-alpine
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app

View File

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

View File

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

View File

@@ -190,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:

View File

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). 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.

2
next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -10,6 +10,7 @@ module.exports = {
remotePatterns: [ remotePatterns: [
{ hostname: 'gravatar.com' }, { hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' }, { hostname: 'image.tmdb.org' },
{ hostname: 'artworks.thetvdb.com' },
], ],
}, },
webpack(config) { webpack(config) {

View File

@@ -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:
@@ -2361,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
@@ -5909,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.
@@ -5923,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:

View File

@@ -168,7 +168,7 @@
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^20.0.0",
"pnpm": "^9.0.0" "pnpm": "^9.0.0"
}, },
"overrides": { "overrides": {

8012
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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;
} }

View File

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

View File

@@ -1,4 +1,5 @@
import ExternalAPI from '@server/api/externalapi'; import 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}`);

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaType } from '@server/constants/media'; import { 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';

View File

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

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media'; import { 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';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import 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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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,

View File

@@ -1,3 +1,6 @@
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Tvdb from '@server/api/indexer/tvdb';
import { MediaServerType } from '@server/constants/server'; import { 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';
@@ -303,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
@@ -368,6 +372,7 @@ class Settings {
apiKey: '', apiKey: '',
}, },
tautulli: {}, tautulli: {},
tvdb: false,
radarr: [], radarr: [],
sonarr: [], sonarr: [],
public: { public: {
@@ -532,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;
} }
@@ -699,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;

View File

@@ -1,4 +1,4 @@
import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; import type { TmdbCollection } from '@server/api/indexer/themoviedb/interfaces';
import { MediaType } from '@server/constants/media'; import { 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';

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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,
}); });

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media'; import 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';

View File

@@ -1,7 +1,7 @@
import type { SortOptions } from '@server/api/indexer/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import PlexTvAPI from '@server/api/plextv'; import 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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

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

View File

@@ -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';

View File

@@ -1,6 +1,6 @@
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 TheMovieDb from '@server/api/themoviedb';
import type { import type {
ServiceCommonServer, ServiceCommonServer,
ServiceCommonServerWithDetails, ServiceCommonServerWithDetails,

View File

@@ -40,6 +40,7 @@ import { URL } from 'url';
import notificationRoutes from './notifications'; import notificationRoutes from './notifications';
import radarrRoutes from './radarr'; import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr'; import sonarrRoutes from './sonarr';
import tvdbRoutes from './tvdb';
const settingsRoutes = Router(); const settingsRoutes = Router();
@@ -47,6 +48,7 @@ settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/tvdb', tvdbRoutes);
const filteredMainSettings = ( const filteredMainSettings = (
user: User, user: User,

View File

@@ -0,0 +1,48 @@
import Tvdb from '@server/api/indexer/tvdb';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const tvdbRoutes = Router();
export interface TvdbSettings {
tvdb: boolean;
}
tvdbRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json({
tvdb: settings.tvdb,
});
});
tvdbRoutes.put('/', (req, res) => {
const settings = getSettings();
const body = req.body as TvdbSettings;
settings.tvdb = body.tvdb ?? settings.tvdb ?? false;
settings.save();
return res.status(200).json({
tvdb: settings.tvdb,
});
});
tvdbRoutes.post('/test', async (req, res, next) => {
try {
const tvdb = new Tvdb();
await tvdb.test();
return res.status(200).json({ message: 'Successfully connected to Tvdb' });
} catch (e) {
logger.error('Failed to test Tvdb', {
label: 'Tvdb',
message: e.message,
});
return next({ status: 500, message: 'Failed to connect to Tvdb' });
}
});
export default tvdbRoutes;

View File

@@ -1,9 +1,10 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RottenTomatoes from '@server/api/rating/rottentomatoes'; import RottenTomatoes from '@server/api/rating/rottentomatoes';
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';
import { Watchlist } from '@server/entity/Watchlist'; import { Watchlist } from '@server/entity/Watchlist';
import { getIndexer } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search'; import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -12,9 +13,10 @@ import { Router } from 'express';
const tvRoutes = Router(); const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => { tvRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb(); const indexer = getIndexer();
try { try {
const tv = await tmdb.getTvShow({ const tv = await indexer.getTvShow({
tvId: Number(req.params.id), tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, language: (req.query.language as string) ?? req.locale,
}); });
@@ -34,7 +36,9 @@ tvRoutes.get('/:id', async (req, res, next) => {
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale. // TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) { if (!data.overview) {
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) }); const tvEnglish = await indexer.getTvShow({
tvId: Number(req.params.id),
});
data.overview = tvEnglish.overview; data.overview = tvEnglish.overview;
} }
@@ -53,13 +57,12 @@ tvRoutes.get('/:id', async (req, res, next) => {
}); });
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const tmdb = new TheMovieDb();
try { try {
const season = await tmdb.getTvSeason({ const indexer = getIndexer();
const season = await indexer.getTvSeason({
tvId: Number(req.params.id), tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber), seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,
}); });
return res.status(200).json(mapSeasonWithEpisodes(season)); return res.status(200).json(mapSeasonWithEpisodes(season));

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/indexer/themoviedb';
import { IssueType, IssueTypeName } from '@server/constants/issue'; import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/indexer/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import Issue from '@server/entity/Issue'; import Issue from '@server/entity/Issue';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/indexer/themoviedb';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,

View File

@@ -6,7 +6,7 @@ import type {
TmdbPersonResult, TmdbPersonResult,
TmdbTvDetails, TmdbTvDetails,
TmdbTvResult, TmdbTvResult,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/indexer/themoviedb/interfaces';
export const isMovie = ( export const isMovie = (
movie: movie:

View File

@@ -9,7 +9,7 @@ import type {
TmdbCompanySearchResponse, TmdbCompanySearchResponse,
TmdbGenre, TmdbGenre,
TmdbKeywordSearchResponse, TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/indexer/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover'; import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';

View File

@@ -1,7 +1,7 @@
import Spinner from '@app/assets/spinner.svg'; import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag'; import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline'; import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces'; import type { TmdbGenre } from '@server/api/indexer/themoviedb/interfaces';
import useSWR from 'swr'; import useSWR from 'swr';
type GenreTagProps = { type GenreTagProps = {

View File

@@ -9,7 +9,7 @@ import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type SeasonRequest from '@server/entity/SeasonRequest'; import type SeasonRequest from '@server/entity/SeasonRequest';

View File

@@ -11,7 +11,7 @@ import type {
TmdbCompanySearchResponse, TmdbCompanySearchResponse,
TmdbGenre, TmdbGenre,
TmdbKeywordSearchResponse, TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/indexer/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
Keyword, Keyword,

View File

@@ -37,6 +37,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/users', route: '/settings/users',
regex: /^\/settings\/users/, regex: /^\/settings\/users/,
}, },
{
text: 'Tvdb',
route: '/settings/tvdb',
regex: /^\/settings\/tvdb/,
},
settings.currentSettings.mediaServerType === MediaServerType.PLEX settings.currentSettings.mediaServerType === MediaServerType.PLEX
? { ? {
text: intl.formatMessage(messages.menuPlexSettings), text: intl.formatMessage(messages.menuPlexSettings),

View File

@@ -0,0 +1,198 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import type { TvdbSettings } from '@server/routes/settings/tvdb';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Settings', {
general: 'General',
settings: 'Settings',
apikey: 'API Key',
pin: 'PIN',
enable: 'Enable',
enableTip:
'Enable Tvdb (only for season and episode).' +
' Due to a limitation of the api used, only English is available.',
});
const SettingsTvdb = () => {
const intl = useIntl();
const [isTesting, setIsTesting] = useState(false);
const { addToast } = useToasts();
const testConnection = async () => {
const response = await fetch('/api/v1/settings/tvdb/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to test Tvdb');
}
};
const saveSettings = async (value: TvdbSettings) => {
const response = await fetch('/api/v1/settings/tvdb', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tvdb: value.tvdb,
}),
});
if (!response.ok) {
throw new Error('Failed to save Tvdb settings');
}
};
const { data, error } = useSWR<TvdbSettings>('/api/v1/settings/tvdb');
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle
title={[
intl.formatMessage(messages.general),
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">{'Tvdb'}</h3>
<p className="description">{'Settings for Tvdb'}</p>
</div>
<div className="section">
<Formik
initialValues={{
enable: data?.tvdb ?? false,
}}
onSubmit={async (values) => {
try {
setIsTesting(true);
await testConnection();
setIsTesting(false);
} catch (e) {
addToast('Tvdb connection error, check your credentials', {
appearance: 'error',
});
return;
}
try {
await saveSettings({
tvdb: values.enable ?? false,
});
if (data) {
data.tvdb = values.enable;
}
} catch (e) {
addToast('Failed to save Tvdb settings', { appearance: 'error' });
return;
}
addToast('Tvdb settings saved', { appearance: 'success' });
}}
>
{({ isSubmitting, isValid, values, setFieldValue }) => {
return (
<Form className="section" data-testid="settings-main-form">
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.enable)}
</span>
<SettingsBadge badgeType="experimental" />
<span className="label-tip">
{intl.formatMessage(messages.enableTip)}
</span>
</label>
<div className="form-input-area">
<Field
data-testid="tvdb-enable"
type="checkbox"
id="enable"
name="enable"
onChange={() => {
setFieldValue('enable', !values.enable);
addToast('Tvdb connection successful', {
appearance: 'success',
});
}}
/>
</div>
<div className="error"></div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
type="button"
disabled={isSubmitting || !isValid}
onClick={async () => {
setIsTesting(true);
try {
await testConnection();
addToast('Tvdb connection successful', {
appearance: 'success',
});
} catch (e) {
addToast(
'Tvdb connection error, check your credentials',
{ appearance: 'error' }
);
}
setIsTesting(false);
}}
>
<BeakerIcon />
<span>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
data-testid="tvbd-save-button"
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
</div>
</>
);
};
export default SettingsTvdb;

View File

@@ -59,7 +59,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
<div className="relative aspect-video xl:h-32"> <div className="relative aspect-video xl:h-32">
<Image <Image
className="rounded-lg object-contain" className="rounded-lg object-contain"
src={`https://image.tmdb.org/t/p/original/${episode.stillPath}`} src={episode.stillPath}
alt="" alt=""
fill fill
/> />

View File

@@ -48,8 +48,8 @@ import {
MinusCircleIcon, MinusCircleIcon,
StarIcon, StarIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import type { RTRating } from '@server/api/rating/rottentomatoes'; import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue';
import { import {
MediaRequestStatus, MediaRequestStatus,
@@ -119,9 +119,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const intl = useIntl(); const intl = useIntl();
const { locale } = useLocale(); const { locale } = useLocale();
const [showRequestModal, setShowRequestModal] = useState(false); const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState( const [showManager, setShowManager] = useState(router.query.manage == '1');
router.query.manage == '1' ? true : false
);
const [showIssueModal, setShowIssueModal] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>( const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
@@ -157,7 +155,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
); );
useEffect(() => { useEffect(() => {
setShowManager(router.query.manage == '1' ? true : false); setShowManager(router.query.manage == '1');
}, [router.query.manage]); }, [router.query.manage]);
const closeBlacklistModal = useCallback( const closeBlacklistModal = useCallback(
@@ -189,7 +187,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
}) })
) { ) {
mediaLinks.push({ mediaLinks.push({
text: getAvalaibleMediaServerName(), text: getAvailableMediaServerName(),
url: plexUrl, url: plexUrl,
svg: <PlayIcon />, svg: <PlayIcon />,
}); });
@@ -203,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
}) })
) { ) {
mediaLinks.push({ mediaLinks.push({
text: getAvalaible4kMediaServerName(), text: getAvailable4kMediaServerName(),
url: plexUrl4k, url: plexUrl4k,
svg: <PlayIcon />, svg: <PlayIcon />,
}); });
@@ -307,7 +305,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region) data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
?.flatrate ?? []; ?.flatrate ?? [];
function getAvalaibleMediaServerName() { function getAvailableMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
} }
@@ -319,7 +317,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' }); return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
} }
function getAvalaible4kMediaServerName() { function getAvailable4kMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
} }

View File

@@ -0,0 +1,16 @@
import SettingsLayout from '@app/components/Settings/SettingsLayout';
import SettingsTvdb from '@app/components/Settings/SettingsTvdb';
import useRouteGuard from '@app/hooks/useRouteGuard';
import { Permission } from '@app/hooks/useUser';
import type { NextPage } from 'next';
const TvdbSettingsPage: NextPage = () => {
useRouteGuard(Permission.ADMIN);
return (
<SettingsLayout>
<SettingsTvdb />
</SettingsLayout>
);
};
export default TvdbSettingsPage;