feat(tvdb): get tv seasons/episodes with tvdb

This commit is contained in:
TOomaAh
2024-07-26 23:56:22 +02:00
parent dd6dbf1de9
commit 79e542ef12
52 changed files with 922 additions and 55 deletions

View File

@@ -385,6 +385,18 @@ components:
serverID:
type: string
readOnly: true
TvdbSettings:
type: object
properties:
apiKey:
type: string
example: 'apikey'
pin:
type: string
example: 'ABCDEFGH'
use:
type: boolean
example: true
TautulliSettings:
type: object
properties:
@@ -2343,6 +2355,75 @@ paths:
type: string
thumb:
type: string
/settings/tvdb:
get:
summary: Get TVDB settings
description: Retrieves current TVDB settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
put:
summary: Update TVDB settings
description: Updates TVDB settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
/settings/tvdb/test:
post:
summary: Test TVDB configuration
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
apiKey:
type: string
example: yourapikey
pin:
type: string
example: yourpin
required:
- apiKey
responses:
'200':
description: Succesfully connected to TVDB
content:
application/json:
schema:
type: object
properties:
languages:
type: array
items:
type: object
properties:
id:
type: number
name:
type: string
/settings/tautulli:
get:
summary: Get Tautulli settings
@@ -5787,7 +5868,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TvDetails'
/tv/{tvId}/season/{seasonId}:
/tv/{tvId}/season/{seasonNumber}/{seasonId}:
get:
summary: Get season details and episode list
description: Returns season details with a list of episodes in a JSON object.
@@ -5806,6 +5887,12 @@ paths:
schema:
type: number
example: 1
- in: path
name: seasonNumber
required: true
schema:
type: number
example: 123456
- in: query
name: language
schema:

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
@@ -97,7 +98,7 @@ interface DiscoverTvOptions {
watchProviders?: string;
}
class TheMovieDb extends ExternalAPI {
class TheMovieDb extends ExternalAPI implements TvShowIndexer {
private region?: string;
private originalLanguage?: string;
constructor({

View File

@@ -0,0 +1,226 @@
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 {
TvdbEpisodeTranslation,
TvdbLoginResponse,
TvdbSeasonDetails,
TvdbTvDetails,
} from '@server/api/indexer/tvdb/interfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class Tvdb extends ExternalAPI implements TvShowIndexer {
static instance: Tvdb;
private dateTokenExpires?: Date;
private pin?: string;
private constructor(apiKey: string, pin?: string) {
super(
'https://api4.thetvdb.com/v4',
{
apiKey: apiKey,
},
{
nodeCache: cacheManager.getCache('tvdb').data,
rateLimit: {
maxRPS: 50,
id: 'tmdb',
},
}
);
this.pin = pin;
}
public static async getInstance() {
if (!this.instance) {
const settings = getSettings();
if (!settings.tvdb.apiKey) {
throw new Error('TVDB API key is not set');
}
this.instance = new Tvdb(settings.tvdb.apiKey, settings.tvdb.pin);
await this.instance.login();
logger.info(
'Tvdb instance created with token => ' +
this.instance.defaultHeaders.Authorization
);
}
return this.instance;
}
async login() {
try {
const res = await this.post<TvdbLoginResponse>('/login', {
apiKey: this.params.apiKey,
pin: this.pin,
});
this.defaultHeaders.Authorization = `Bearer ${res.data.token}`;
this.dateTokenExpires = new Date();
this.dateTokenExpires.setMonth(this.dateTokenExpires.getMonth() + 1);
return res;
} catch (error) {
throw new Error(`[TVDB] Login failed: ${error.message}`);
}
}
public getTvShow = async ({
tvId,
language = 'en',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const tmdb = new TheMovieDb();
const tmdbTvShow = await tmdb.getTvShow({ tvId: tvId });
const tvdbId = tmdbTvShow.external_ids.tvdb_id;
if (!tvdbId) {
return tmdbTvShow;
}
const data = await this.get<TvdbTvDetails>(
`/series/${tvdbId}/extended`,
{
short: 'true',
},
43200
);
const correctSeasons = data.data.seasons.filter(
(season: TvdbSeasonDetails) =>
season.id && season.number > 0 && season.type.name === 'Aired Order'
);
tmdbTvShow.seasons = [];
for (const season of correctSeasons) {
if (season.id) {
logger.info(`Fetching TV season ${season.id}`);
try {
const tvdbSeason = await this.getTvSeason({
tvId: tvdbId,
seasonNumber: season.id,
language,
});
const seasonData = {
id: season.id,
episode_count: tvdbSeason.episodes.length,
name: tvdbSeason.name,
overview: tvdbSeason.overview,
season_number: season.number,
poster_path: '',
air_date: '',
image: tvdbSeason.poster_path,
};
tmdbTvShow.seasons.push(seasonData);
} catch (error) {
logger.error(
`Failed to get season ${season.id} for TV show ${tvdbId}: ${error.message}`,
{
label: 'Tvdb',
message: `Failed to get season ${season.id} for TV show ${tvdbId}`,
}
);
}
}
}
return tmdbTvShow;
} catch (error) {
throw new Error(
`[TVDB] Failed to fetch TV show details: ${error.message}`
);
}
};
getEpisode = async (
episodeId: number,
language: string
): Promise<TvdbEpisodeTranslation> => {
try {
const tvdbEpisode = await this.get<TvdbEpisodeTranslation>(
`/episodes/${episodeId}/translations/${language}`,
{},
43200
);
return tvdbEpisode;
} catch (error) {
throw new Error(
`[TVDB] Failed to fetch TV episode details: ${error.message}`
);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language = 'en',
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
if (seasonNumber === 0) {
return {
episodes: [],
external_ids: {
tvdb_id: tvId,
},
name: '',
overview: '',
id: seasonNumber,
air_date: '',
season_number: 0,
};
}
try {
const tvdbSeason = await this.get<TvdbSeasonDetails>(
`/seasons/${seasonNumber}/extended`,
{ lang: language },
43200
);
const episodes = tvdbSeason.data.episodes.map((episode) => ({
id: episode.id,
air_date: episode.aired,
episode_number: episode.number,
name: episode.name,
overview: episode.overview || '',
season_number: episode.seasonNumber,
production_code: '',
show_id: tvId,
still_path: episode.image,
vote_average: 1,
vote_cuont: 1,
}));
return {
episodes: episodes,
external_ids: {
tvdb_id: tvdbSeason.seriesId,
},
name: '',
overview: '',
id: tvdbSeason.id,
air_date: tvdbSeason.year,
season_number: tvdbSeason.number,
};
} catch (error) {
throw new Error(
`[TVDB] Failed to fetch TV season details: ${error.message}`
);
}
};
}
export default Tvdb;

View File

@@ -0,0 +1,143 @@
export interface TvdbBaseResponse<T> {
data: T;
errors: any;
}
export interface TvdbLoginResponse extends TvdbBaseResponse<{ token: string }> {
data: { token: string };
}
interface TvDetailsAliases {
language: string;
name: string;
}
interface TvDetailsStatus {
id: number;
name: string;
recordType: string;
keepUpdated: boolean;
}
export interface TvdbTvDetails extends TvdbBaseResponse<TvdbTvDetails> {
id: number;
name: string;
slug: string;
image: string;
nameTranslations: string[];
overwiewTranslations: string[];
aliases: TvDetailsAliases[];
firstAired: Date;
lastAired: Date;
nextAired: Date | string;
score: number;
status: TvDetailsStatus;
originalCountry: string;
originalLanguage: string;
defaultSeasonType: string;
isOrderRandomized: boolean;
lastUpdated: Date;
averageRuntime: number;
seasons: TvdbSeasonDetails[];
}
interface TvdbCompanyType {
companyTypeId: number;
companyTypeName: string;
}
interface TvdbParentCompany {
id?: number;
name?: string;
relation?: {
id?: number;
typeName?: string;
};
}
interface TvdbCompany {
id: number;
name: string;
slug: string;
nameTranslations?: string[];
overviewTranslations?: string[];
aliases?: string[];
country: string;
primaryCompanyType: number;
activeDate: string;
inactiveDate?: string;
companyType: TvdbCompanyType;
parentCompany: TvdbParentCompany;
tagOptions?: string[];
}
interface TvdbType {
id: number;
name: string;
type: string;
alternateName?: string;
}
interface TvdbArtwork {
id: number;
image: string;
thumbnail: string;
language: string;
type: number;
score: number;
width: number;
height: number;
includesText: boolean;
}
interface TvdbEpisode {
id: number;
seriesId: number;
name: string;
aired: string;
runtime: number;
nameTranslations: string[];
overview?: string;
overviewTranslations: string[];
image: string;
imageType: number;
isMovie: number;
seasons?: string[];
number: number;
absoluteNumber: number;
seasonNumber: number;
lastUpdated: string;
finaleType?: string;
year: string;
}
export interface TvdbSeasonDetails extends TvdbBaseResponse<TvdbSeasonDetails> {
id: number;
seriesId: number;
type: TvdbType;
number: number;
nameTranslations: string[];
overviewTranslations: string[];
image: string;
imageType: number;
companies: {
studio: TvdbCompany[];
network: TvdbCompany[];
production: TvdbCompany[];
distributor: TvdbCompany[];
special_effects: TvdbCompany[];
};
lastUpdated: string;
year: string;
episodes: TvdbEpisode[];
trailers: string[];
artwork: TvdbArtwork[];
tagOptions?: 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 RadarrAPI from '@server/api/servarr/radarr';
import type {
@@ -5,8 +7,6 @@ import type {
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
MediaStatus,

View File

@@ -36,6 +36,9 @@ export class UserSettings {
@Column({ nullable: true })
public originalLanguage?: string;
@Column({ nullable: true })
public tvdbToken?: string;
@Column({ nullable: true })
public pgpKey?: string;

View File

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

View File

@@ -8,7 +8,8 @@ export type AvailableCacheIds =
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
| 'plextv'
| 'tvdb';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -68,6 +69,10 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
tvdb: new Cache('tvdb', 'The TVDB API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
};
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 { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

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

View File

@@ -1,7 +1,7 @@
import animeList from '@server/api/animelist';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import cacheManager from '@server/lib/cache';

View File

@@ -1,6 +1,6 @@
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {

View File

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

View File

@@ -77,6 +77,12 @@ export interface DVRSettings {
tagRequests: boolean;
}
export interface TvdbSettings {
apiKey?: string;
pin?: string;
use: boolean;
}
export interface RadarrSettings extends DVRSettings {
minimumAvailability: string;
}
@@ -285,6 +291,7 @@ export interface AllSettings {
plex: PlexSettings;
jellyfin: JellyfinSettings;
tautulli: TautulliSettings;
tvdb: TvdbSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
public: PublicSettings;
@@ -344,6 +351,7 @@ class Settings {
serverId: '',
},
tautulli: {},
tvdb: { use: false },
radarr: [],
sonarr: [],
public: {
@@ -512,6 +520,14 @@ class Settings {
this.data.tautulli = data;
}
get tvdb(): TvdbSettings {
return this.data.tvdb;
}
set tvdb(data: TvdbSettings) {
this.data.tvdb = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import type {
TmdbTvEpisodeResult,
TmdbTvRatingResult,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,

View File

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

View File

@@ -0,0 +1,46 @@
import Tvdb from '@server/api/indexer/tvdb';
import type { TvdbSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const tvdbRoutes = Router();
tvdbRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.tvdb);
});
tvdbRoutes.put('/', (req, res) => {
const settings = getSettings();
const newTvdb = req.body as TvdbSettings;
const tvdb = settings.tvdb;
tvdb.apiKey = newTvdb.apiKey;
tvdb.pin = newTvdb.pin;
tvdb.use = newTvdb.use;
settings.tvdb = tvdb;
settings.save();
return res.status(200).json(newTvdb);
});
tvdbRoutes.post('/test', async (req, res, next) => {
try {
const tvdb = await Tvdb.getInstance();
await tvdb.login();
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,7 +1,9 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import Tvdb from '@server/api/indexer/tvdb';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -10,7 +12,13 @@ import { Router } from 'express';
const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
const settings = getSettings();
let tmdb;
if (settings.tvdb.use) {
tmdb = await Tvdb.getInstance();
} else {
tmdb = new TheMovieDb();
}
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.id),
@@ -33,14 +41,22 @@ tvRoutes.get('/:id', async (req, res, next) => {
}
});
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const tmdb = new TheMovieDb();
tvRoutes.get('/:id/season/:seasonNumber/:seasonId', async (req, res, next) => {
try {
const settings = getSettings();
let tmdb;
let seasonIdentifier;
if (settings.tvdb.use) {
tmdb = await Tvdb.getInstance();
seasonIdentifier = req.params.seasonId;
} else {
tmdb = new TheMovieDb();
seasonIdentifier = req.params.seasonNumber;
}
const season = await tmdb.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,
seasonNumber: Number(seasonIdentifier),
});
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 { MediaType } from '@server/constants/media';
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 { MediaType } from '@server/constants/media';
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 {
MediaRequestStatus,
MediaStatus,

View File

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

View File

@@ -9,7 +9,7 @@ import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
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 Error from '@app/pages/_error';
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 { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
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 { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
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 { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
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 { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

View File

@@ -1,7 +1,7 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
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';
type GenreTagProps = {

View File

@@ -9,7 +9,7 @@ import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
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 type { MediaRequest } from '@server/entity/MediaRequest';
import type SeasonRequest from '@server/entity/SeasonRequest';

View File

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

View File

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

View File

@@ -0,0 +1,273 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import CopyButton from '@app/components/Settings/CopyButton';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid';
import type { TvdbSettings } from '@server/lib/settings';
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)',
});
/*interface SettingsTvdbProps {
onEdit: () => void;
}*/
const SettingsTvdb = () => {
const intl = useIntl();
const [isTesting, setIsTesting] = useState(false);
const { addToast } = useToasts();
const testConnection = async (apiKey: string | undefined, pin?: string) => {
const response = await fetch('/api/v1/settings/tvdb/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ apiKey, pin }),
});
if (!response.ok) {
throw new Error('Failed to test Tvdb');
}
};
const saveSettings = async (values: TvdbSettings) => {
const response = await fetch('/api/v1/settings/tvdb', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
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={{
apiKey: data?.apiKey,
pin: data?.pin,
enable: data?.use,
}}
onSubmit={async (values) => {
if (values.enable && values.apiKey === '') {
addToast('Please enter an API key', { appearance: 'error' });
return;
}
try {
setIsTesting(true);
await testConnection(values.apiKey, values.pin);
setIsTesting(false);
} catch (e) {
addToast('Tvdb connection error, check your credentials', {
appearance: 'error',
});
return;
}
try {
await saveSettings({
apiKey: values.apiKey,
pin: values.pin,
use: values.enable || false,
});
} catch (e) {
addToast('Failed to save Tvdb settings', { appearance: 'error' });
return;
}
addToast('Tvdb settings saved', { appearance: 'success' });
}}
>
{({
errors,
touched,
isSubmitting,
isValid,
values,
setFieldValue,
}) => {
return (
<Form className="section" data-testid="settings-main-form">
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
{intl.formatMessage(messages.apikey)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
type="text"
id="apiKey"
className="rounded-l-only"
value={values.apiKey}
onChange={(e) => {
setFieldValue('apiKey', e.target.value);
}}
/>
<CopyButton
textToCopy={values.apiKey ?? ''}
key={'apikey'}
/>
<button
onClick={(e) => {
e.preventDefault();
}}
className="input-action"
>
<ArrowPathIcon />
</button>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="pin" className="text-label">
{intl.formatMessage(messages.pin)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
type="text"
id="pin"
className="rounded-l-only"
value={values.pin}
onChange={(e) => {
values.pin = e.target.value;
}}
/>
<CopyButton textToCopy={values.pin ?? ''} key={'pin'} />
<button
onClick={(e) => {
e.preventDefault();
}}
className="input-action"
>
<ArrowPathIcon />
</button>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.enable)}
</span>
<span className="label-tip">
{intl.formatMessage(messages.enableTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enable"
name="enable"
onChange={() => {
setFieldValue('enable', !values.enable);
addToast('Tvdb connection successful', {
appearance: 'success',
});
}}
/>
</div>
{errors.apiKey &&
touched.apiKey &&
typeof errors.apiKey === 'string' && (
<div className="error">{errors.apiKey}</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(values.apiKey, values.pin);
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
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

@@ -14,12 +14,13 @@ const messages = defineMessages('components.TvDetails.Season', {
type SeasonProps = {
seasonNumber: number;
tvId: number;
seasonId: number;
};
const Season = ({ seasonNumber, tvId }: SeasonProps) => {
const Season = ({ seasonNumber, tvId, seasonId }: SeasonProps) => {
const intl = useIntl();
const { data, error } = useSWR<SeasonWithEpisodes>(
`/api/v1/tv/${tvId}/season/${seasonNumber}`
`/api/v1/tv/${tvId}/season/${seasonNumber}/${seasonId}`
);
if (!data && !error) {

View File

@@ -41,8 +41,8 @@ import {
PlayIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
@@ -792,6 +792,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
<Season
tvId={data.id}
seasonNumber={season.seasonNumber}
seasonId={season.id}
/>
</Disclosure.Panel>
</Transition>

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;