refactor(tvdb): use tvdb api

This commit is contained in:
TOomaAh
2025-01-20 01:48:11 +01:00
parent ac76be5014
commit 3c9ed469a8
15 changed files with 342 additions and 128 deletions

39
server/api/metadata.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/themoviedb';
import Tvdb from '@server/api/tvdb';
import { getSettings, IndexerType } from '@server/lib/settings';
import logger from '@server/logger';
export const getMetadataProvider = async (
mediaType: 'movie' | 'tv' | 'anime'
): Promise<TvShowIndexer> => {
try {
const settings = await getSettings();
if (!settings.tvdb.apiKey || mediaType == 'movie') {
return new TheMovieDb();
}
if (
mediaType == 'tv' &&
settings.metadataSettings.tvShow == IndexerType.TVDB
) {
return await Tvdb.getInstance();
}
if (
mediaType == 'anime' &&
settings.metadataSettings.anime == IndexerType.TVDB
) {
return await Tvdb.getInstance();
}
return new TheMovieDb();
} catch (e) {
logger.error('Failed to get metadata provider', {
label: 'Metadata',
message: e.message,
});
return new TheMovieDb();
}
};

View File

@@ -10,10 +10,11 @@ import type {
import type {
TvdbEpisode,
TvdbLoginResponse,
TvdbSeason,
TvdbTvShowDetail,
TvdbSeasonDetails,
TvdbTvDetails,
} from '@server/api/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
interface TvdbConfig {
@@ -36,16 +37,21 @@ type TvdbId = number;
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
class Tvdb extends ExternalAPI implements TvShowIndexer {
static instance: Tvdb;
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 };
private static readonly DEFAULT_LANGUAGE = 'eng';
private token: string;
private apiKey?: string;
private pin?: string;
constructor(apiKey: string, pin?: string) {
const finalConfig = { ...DEFAULT_CONFIG };
super(
finalConfig.baseUrl,
{},
{
apiKey: apiKey,
},
{
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
rateLimit: {
@@ -54,9 +60,33 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
},
}
);
this.apiKey = apiKey;
this.pin = pin;
this.tmdb = new TheMovieDb();
}
public static async getInstance(): Promise<Tvdb> {
if (!this.instance) {
const settings = await getSettings();
if (!settings.tvdb.apiKey) {
throw new Error('TVDB API key is not set');
}
try {
this.instance = new Tvdb(settings.tvdb.apiKey, settings.tvdb.pin);
await this.instance.login();
} catch (error) {
logger.error(`Failed to login to TVDB: ${error.message}`);
throw new Error('TVDB API key is not set');
}
this.instance = new Tvdb(settings.tvdb.apiKey, settings.tvdb.pin);
}
return this.instance;
}
public async test(): Promise<TvdbLoginResponse> {
try {
return await this.get<TvdbLoginResponse>('/en/445009', {});
@@ -66,6 +96,21 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
}
}
async handleRenewToken(): Promise<TvdbLoginResponse> {
throw new Error('Method not implemented.');
}
async login(): Promise<TvdbLoginResponse> {
const response = await this.post<TvdbLoginResponse>('/login', {
apiKey: process.env.TVDB_API_KEY,
});
this.defaultHeaders.Authorization = `Bearer ${response.token}`;
this.token = response.token;
return response;
}
public async getShowByTvdbId({
tvdbId,
}: {
@@ -152,29 +197,34 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
}
}
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvShowDetail> {
return await this.get<TvdbTvShowDetail>(
`/en/${tvdbId}`,
{},
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvDetails> {
return await this.get<TvdbTvDetails>(
`/series/${tvdbId}/extended?meta=episodes`,
{
short: 'true',
},
Tvdb.DEFAULT_CACHE_TTL
);
}
private processSeasons(tvdbData: TvdbTvShowDetail): TmdbTvSeasonResult[] {
if (!tvdbData || !tvdbData.seasons) {
private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] {
if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) {
return [];
}
return tvdbData.seasons
.filter((season) => season.seasonNumber !== 0)
.filter(
(season) =>
season.number > 0 && season.type && season.type.type === 'official'
)
.map((season) => this.createSeasonData(season, tvdbData));
}
private createSeasonData(
season: TvdbSeason,
tvdbData: TvdbTvShowDetail
season: TvdbSeasonDetails,
tvdbData: TvdbTvDetails
): TmdbTvSeasonResult {
if (!season.seasonNumber) {
if (!season.number) {
return {
id: 0,
episode_count: 0,
@@ -187,15 +237,15 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
}
const episodeCount = tvdbData.episodes.filter(
(episode) => episode.seasonNumber === season.seasonNumber
(episode) => episode.seasonNumber === season.number
).length;
return {
id: tvdbData.tvdbId,
id: tvdbData.id,
episode_count: episodeCount,
name: `${season.seasonNumber}`,
name: `${season.number}`,
overview: '',
season_number: season.seasonNumber,
season_number: season.number,
poster_path: '',
air_date: '',
};
@@ -204,25 +254,35 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
private async getTvdbSeasonData(
tvdbId: number,
seasonNumber: number,
tvId: number
tvId: number,
language: string = Tvdb.DEFAULT_LANGUAGE
): Promise<TmdbSeasonWithEpisodes> {
const tvdbSeason = await this.fetchTvdbShowData(tvdbId);
const tvdbData = await this.fetchTvdbShowData(tvdbId);
const episodes = this.processEpisodes(tvdbSeason, seasonNumber, tvId);
if (!tvdbData) {
return this.createEmptySeasonResponse(tvId);
}
const seasons = await this.get<TvdbSeasonDetails>(
`/series/${tvdbId}/episodes/official/${language}`,
{}
);
const episodes = this.processEpisodes(seasons, seasonNumber, tvId);
return {
episodes,
external_ids: { tvdb_id: tvdbSeason.tvdbId },
external_ids: { tvdb_id: tvdbId },
name: '',
overview: '',
id: tvdbSeason.tvdbId,
air_date: tvdbSeason.firstAired,
id: seasons.id,
air_date: seasons.firstAired,
season_number: episodes.length,
};
}
private processEpisodes(
tvdbSeason: TvdbTvShowDetail,
tvdbSeason: TvdbSeasonDetails,
seasonNumber: number,
tvId: number
): TmdbTvEpisodeResult[] {
@@ -241,10 +301,10 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
tvId: number
): TmdbTvEpisodeResult {
return {
id: episode.tvdbId,
air_date: episode.airDate,
episode_number: episode.episodeNumber,
name: episode.title || `Episode ${index + 1}`,
id: episode.id,
air_date: episode.aired,
episode_number: episode.number,
name: episode.name || `Episode ${index + 1}`,
overview: episode.overview || '',
season_number: episode.seasonNumber,
production_code: '',

View File

@@ -3,73 +3,142 @@ export interface TvdbBaseResponse<T> {
errors: string;
}
export interface TvdbLoginResponse {
token: string;
}
export interface TvdbLoginResponse extends TvdbBaseResponse<{ token: string }> {
data: { token: string };
}
export interface TvdbTvShowDetail {
tvdbId: number;
title: string;
overview: 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;
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[];
defaultSeasonType: string;
isOrderRandomized: boolean;
lastUpdated: Date;
averageRuntime: number;
seasons: TvdbSeasonDetails[];
episodes: TvdbEpisode[];
}
export interface TvdbTimeOfDay {
hours: number;
minutes: number;
interface TvdbCompanyType {
companyTypeId: number;
companyTypeName: string;
}
export interface TvdbAlternativeTitle {
title: string;
interface TvdbParentCompany {
id?: number;
name?: string;
relation?: {
id?: number;
typeName?: string;
};
}
export interface TvdbActor {
interface TvdbCompany {
id: number;
name: string;
character: string;
image?: string;
slug: string;
nameTranslations?: string[];
overviewTranslations?: string[];
aliases?: string[];
country: string;
primaryCompanyType: number;
activeDate: string;
inactiveDate?: string;
companyType: TvdbCompanyType;
parentCompany: TvdbParentCompany;
tagOptions?: string[];
}
export interface TvdbImage {
coverType: string;
url: string;
interface TvdbType {
id: number;
name: string;
type: string;
alternateName?: string;
}
export interface TvdbSeason {
seasonNumber: number;
interface TvdbArtwork {
id: number;
image: string;
thumbnail: string;
language: string;
type: number;
score: number;
width: number;
height: number;
includesText: boolean;
}
export interface TvdbEpisode {
tvdbShowId: number;
tvdbId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
title?: string;
airDate: string;
airDateUtc: string;
runtime?: number;
id: number;
seriesId: number;
name: string;
aired: string;
runtime: number;
nameTranslations: string[];
overview?: string;
image?: 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[];
firstAired: string;
}
export interface TvdbEpisodeTranslation

View File

@@ -3,7 +3,7 @@ import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { getIndexer, getSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { randomUUID } from 'crypto';
@@ -62,7 +62,6 @@ class BaseScanner<T> {
protected sessionId: string;
protected running = false;
readonly asyncLock = new AsyncLock();
readonly tvShowIndexer = getIndexer();
readonly tmdb = new TheMovieDb();
protected constructor(

View File

@@ -10,7 +10,7 @@ import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User';
import type { Library } from '@server/lib/settings';
import { getIndexer, getSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
@@ -45,7 +45,6 @@ class JellyfinScanner {
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb();
this.tvShowIndexer = getIndexer();
this.isRecentOnly = isRecentOnly ?? false;
}

View File

@@ -273,7 +273,7 @@ class PlexScanner
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tvShowIndexer.getTvShow({
const tvShow = await this.tmdb.getTvShow({
tvId: mediaIds.tmdbId,
});
@@ -431,7 +431,7 @@ class PlexScanner
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
if (matchedtvdb) {
const show = await this.tvShowIndexer.getShowByTvdbId({
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
@@ -465,7 +465,7 @@ class PlexScanner
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
tvShow = await this.tvShowIndexer.getTvShow({
tvShow = await this.tmdb.getTvShow({
tvId: extResponse.tv_results[0].id,
});
mediaIds.tvdbId = result.tvdbId;

View File

@@ -94,11 +94,11 @@ class SonarrScanner
});
if (!media || !media.tmdbId) {
tvShow = await this.tvShowIndexer.getShowByTvdbId({
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
} else {
tvShow = await this.tvShowIndexer.getTvShow({ tvId: media.tmdbId });
tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId });
}
const tmdbId = tvShow.id;

View File

@@ -1,6 +1,3 @@
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/themoviedb';
import Tvdb from '@server/api/tvdb';
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
@@ -103,6 +100,32 @@ interface Quota {
quotaDays?: number;
}
export enum IndexerType {
TMDB,
TVDB,
}
export interface MetadataSettings {
tvShow: IndexerType;
anime: IndexerType;
}
export interface TvdbSettings {
apiKey?: string;
pin?: string;
}
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface MainSettings {
apiKey: string;
applicationTitle: string;
@@ -342,7 +365,8 @@ export interface AllSettings {
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
network: NetworkSettings;
tvdb: boolean;
tvdb: TvdbSettings;
metadataSettings: MetadataSettings;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -403,7 +427,14 @@ class Settings {
apiKey: '',
},
tautulli: {},
tvdb: false,
metadataSettings: {
tvShow: IndexerType.TMDB,
anime: IndexerType.TVDB,
},
tvdb: {
apiKey: '',
pin: '',
},
radarr: [],
sonarr: [],
public: {
@@ -598,11 +629,19 @@ class Settings {
this.data.tautulli = data;
}
get tvdb(): boolean {
get metadataSettings(): MetadataSettings {
return this.data.metadataSettings;
}
set metadataSettings(data: MetadataSettings) {
this.data.metadataSettings = data;
}
get tvdb(): TvdbSettings {
return this.data.tvdb;
}
set tvdb(data: boolean) {
set tvdb(data: TvdbSettings) {
this.data.tvdb = data;
}
@@ -786,13 +825,4 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
return settings;
};
export const getIndexer = (): TvShowIndexer => {
const settings = getSettings();
if (settings.tvdb) {
return new Tvdb();
} else {
return new TheMovieDb();
}
};
export default Settings;

View File

@@ -39,10 +39,10 @@ import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
import { URL } from 'url';
import tvdbRoutes from './metadata';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
import tvdbRoutes from './tvdb';
const settingsRoutes = Router();

View File

@@ -1,11 +1,11 @@
import Tvdb from '@server/api/tvdb';
import { getSettings } from '@server/lib/settings';
import { getSettings, type TvdbSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const tvdbRoutes = Router();
export interface TvdbSettings {
export interface MetadataSettings {
tvdb: boolean;
}
@@ -22,7 +22,10 @@ tvdbRoutes.put('/', (req, res) => {
const body = req.body as TvdbSettings;
settings.tvdb = body.tvdb ?? settings.tvdb ?? false;
settings.tvdb = {
apiKey: body.apiKey,
pin: body.pin,
};
settings.save();
return res.status(200).json({
@@ -32,7 +35,7 @@ tvdbRoutes.put('/', (req, res) => {
tvdbRoutes.post('/test', async (req, res, next) => {
try {
const tvdb = new Tvdb();
const tvdb = await Tvdb.getInstance();
await tvdb.test();
return res.status(200).json({ message: 'Successfully connected to Tvdb' });
} catch (e) {

View File

@@ -1,10 +1,12 @@
import { getMetadataProvider } from '@server/api/metadata';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
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';
import { Watchlist } from '@server/entity/Watchlist';
import { getIndexer } from '@server/lib/settings';
import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -13,14 +15,21 @@ import { Router } from 'express';
const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => {
const indexer = getIndexer();
const tmdb = new TheMovieDb();
try {
const tmdbTv = await tmdb.getTvShow({
tvId: Number(req.params.id),
});
const indexer = tmdbTv.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
const tv = await indexer.getTvShow({
tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
const media = await Media.getMedia(tv.id, MediaType.TV);
const onUserWatchlist = await getRepository(Watchlist).exist({
@@ -58,7 +67,15 @@ tvRoutes.get('/:id', async (req, res, next) => {
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
try {
const indexer = getIndexer();
const tmdb = new TheMovieDb();
const tmdbTv = await tmdb.getTvShow({
tvId: Number(req.params.id),
});
const indexer = tmdbTv.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
const season = await indexer.getTvSeason({
tvId: Number(req.params.id),

View File

View File

@@ -38,11 +38,6 @@ 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),
@@ -64,6 +59,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/network',
regex: /^\/settings\/network/,
},
{
text: 'Metadata',
route: '/settings/metadata',
regex: /^\/settings\/metadata/,
},
{
text: intl.formatMessage(messages.menuNotifications),
route: '/settings/notifications/email',

View File

@@ -5,7 +5,7 @@ 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 type { MetadataSettings } from '@server/routes/settings/metadata';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -15,15 +15,13 @@ 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 SettingsMetadata = () => {
const intl = useIntl();
const [isTesting, setIsTesting] = useState(false);
@@ -42,7 +40,7 @@ const SettingsTvdb = () => {
}
};
const saveSettings = async (value: TvdbSettings) => {
const saveSettings = async (value: MetadataSettings) => {
const response = await fetch('/api/v1/settings/tvdb', {
method: 'PUT',
headers: {
@@ -58,7 +56,7 @@ const SettingsTvdb = () => {
}
};
const { data, error } = useSWR<TvdbSettings>('/api/v1/settings/tvdb');
const { data, error } = useSWR<MetadataSettings>('/api/v1/settings/tvdb');
if (!data && !error) {
return <LoadingSpinner />;
@@ -73,8 +71,8 @@ const SettingsTvdb = () => {
]}
/>
<div className="mb-6">
<h3 className="heading">{'Tvdb'}</h3>
<p className="description">{'Settings for Tvdb'}</p>
<h3 className="heading">{'Metadata'}</h3>
<p className="description">{'Settings for metadata indexer'}</p>
</div>
<div className="section">
<Formik
@@ -195,4 +193,4 @@ const SettingsTvdb = () => {
);
};
export default SettingsTvdb;
export default SettingsMetadata;

View File

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