diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index 0cc8f40d..d4f800d6 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -33,14 +33,15 @@ export interface SearchMetadata extends TitleMetadata { primaryTitle?: string; year?: number; imdbId?: string | null; - tmdbId?: string | null; - tvdbId?: string | null; + tmdbId?: number | null; + tvdbId?: number | null; } export const BaseDebridConfigSchema = z.object({ services: BuiltinDebridServices, tmdbApiKey: z.string().optional(), tmdbReadAccessToken: z.string().optional(), + tvdbApiKey: z.string().optional(), }); export type BaseDebridConfig = z.infer; @@ -269,6 +270,7 @@ export abstract class BaseDebridAddon { const metadata = await new MetadataService({ tmdbAccessToken: this.userData.tmdbReadAccessToken, tmdbApiKey: this.userData.tmdbApiKey, + tvdbApiKey: this.userData.tvdbApiKey, }).getMetadata(parsedId, type === 'movie' ? 'movie' : 'series'); // Calculate absolute episode if needed @@ -288,19 +290,19 @@ export abstract class BaseDebridAddon { ); } - // Map IDs + // // Map IDs const imdbId = parsedId.type === 'imdbId' ? parsedId.value.toString() - : (animeEntry?.mappings?.imdbId?.toString() ?? null); - const tmdbId = - parsedId.type === 'themoviedbId' - ? parsedId.value.toString() - : (animeEntry?.mappings?.themoviedbId?.toString() ?? null); - const tvdbId = - parsedId.type === 'thetvdbId' - ? parsedId.value.toString() - : (animeEntry?.mappings?.thetvdbId?.toString() ?? null); + : animeEntry?.mappings?.imdbId?.toString(); + // const tmdbId = + // parsedId.type === 'themoviedbId' + // ? parsedId.value.toString() + // : (animeEntry?.mappings?.themoviedbId?.toString() ?? null); + // const tvdbId = + // parsedId.type === 'thetvdbId' + // ? parsedId.value.toString() + // : (animeEntry?.mappings?.thetvdbId?.toString() ?? null); const searchMetadata: SearchMetadata = { primaryTitle: metadata.title, @@ -310,8 +312,8 @@ export abstract class BaseDebridAddon { absoluteEpisode, year: metadata.year, imdbId, - tmdbId, - tvdbId, + tmdbId: metadata.tmdbId ?? null, + tvdbId: metadata.tvdbId ?? null, }; this.logger.debug( diff --git a/packages/core/src/builtins/base/nab/addon.ts b/packages/core/src/builtins/base/nab/addon.ts index cc426d80..86fd50cc 100644 --- a/packages/core/src/builtins/base/nab/addon.ts +++ b/packages/core/src/builtins/base/nab/addon.ts @@ -64,7 +64,7 @@ export abstract class BaseNabAddon< searchCapabilities.supportedParams.includes('tvdbid') && metadata.tvdbId ) { - queryParams.tvdbid = metadata.tvdbId; + queryParams.tvdbid = metadata.tvdbId.toString(); } else if ( searchCapabilities.supportedParams.includes('imdbid') && metadata.imdbId @@ -74,12 +74,12 @@ export abstract class BaseNabAddon< searchCapabilities.supportedParams.includes('tmdbid') && metadata.tmdbId ) - queryParams.tmdbid = metadata.tmdbId; + queryParams.tmdbid = metadata.tmdbId.toString(); else if ( searchCapabilities.supportedParams.includes('tvdbid') && metadata.tvdbId ) - queryParams.tvdbid = metadata.tvdbId; + queryParams.tvdbid = metadata.tvdbId.toString(); if ( !this.userData.forceQuerySearch && diff --git a/packages/core/src/builtins/gdrive/addon.ts b/packages/core/src/builtins/gdrive/addon.ts index 7bd86dab..baffb0ca 100644 --- a/packages/core/src/builtins/gdrive/addon.ts +++ b/packages/core/src/builtins/gdrive/addon.ts @@ -230,10 +230,7 @@ export class GDriveAddon { const tmdbMetadata = new TMDBMetadata({ accessToken: this.userData.tmdbReadAccessToken, }); - const metadata = await tmdbMetadata.getMetadata( - parsedId.value.toString(), - type as any - ); + const metadata = await tmdbMetadata.getMetadata(parsedId); titles = metadata.titles ?? [metadata.title]; year = Number(metadata.year); if (parsedId.type === 'imdbId') { diff --git a/packages/core/src/builtins/torbox-search/source-handlers.ts b/packages/core/src/builtins/torbox-search/source-handlers.ts index 7b2b3022..33472b0e 100644 --- a/packages/core/src/builtins/torbox-search/source-handlers.ts +++ b/packages/core/src/builtins/torbox-search/source-handlers.ts @@ -9,7 +9,7 @@ import { getTimeTakenSincePoint, } from '../../utils/index.js'; // import { DebridService, DebridFile } from './debrid-service'; -import { ParsedId } from '../../utils/id-parser.js'; +import { IdParser, ParsedId } from '../../utils/id-parser.js'; import { TorBoxSearchAddonUserDataSchema } from './schemas.js'; import TorboxSearchApi, { TorboxSearchApiError, @@ -165,17 +165,23 @@ abstract class SourceHandler { parsedId.type, parsedId.value ); - const tmdbId = animeEntry?.mappings?.themoviedbId ?? tmdb_id; + const tmdbId = + animeEntry?.mappings?.themoviedbId || tmdb_id + ? IdParser.parse( + `tmdb:${animeEntry?.mappings?.themoviedbId || tmdb_id}`, + 'series' + ) + : null; const traktAliases = await getTraktAliases(parsedId); // For anime sources, fetch additional season info from TMDB - if (animeEntry && parsedId.season && parsedId.episode) { + if (animeEntry && parsedId.season && parsedId.episode && tmdbId) { const seasonFetchStart = Date.now(); try { const tmdbMetadata = await new TMDBMetadata({ accessToken: tmdbAccessToken, - }).getMetadata(`tmdb:${tmdbId}`, 'series'); + }).getMetadata(tmdbId); const seasons = tmdbMetadata?.seasons?.map( ({ season_number, episode_count }) => ({ diff --git a/packages/core/src/db/queue.ts b/packages/core/src/db/queue.ts index 78221658..78313e97 100644 --- a/packages/core/src/db/queue.ts +++ b/packages/core/src/db/queue.ts @@ -1,7 +1,5 @@ -import { createLogger } from '../utils/index.js'; import { DB } from './db.js'; -const logger = createLogger('db'); const db = DB.getInstance(); interface QueuedOperation { @@ -48,7 +46,6 @@ export class TransactionQueue { const result = await operation(); resolve(result); } catch (error) { - logger.error('Error processing queued operation:', error); reject(error); } finally { this.processing = false; diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 30c0da5e..42b2c984 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -414,6 +414,7 @@ export const UserDataSchema = z.object({ statisticsPosition: z.enum(['top', 'bottom']).optional(), tmdbAccessToken: z.string().optional(), tmdbApiKey: z.string().optional(), + tvdbApiKey: z.string().optional(), yearMatching: z .object({ enabled: z.boolean().optional(), diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 3f98e655..3d4c6714 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -16,6 +16,8 @@ import { ExtrasParser, makeUrlLogSafe, AnimeDatabase, + ParsedId, + IdParser, } from './utils/index.js'; import { Wrapper } from './wrapper.js'; import { PresetManager } from './presets/index.js'; @@ -1237,16 +1239,16 @@ export class AIOStreams { }); } - private async getMetadata(id: string): Promise { + private async getMetadata(parsedId: ParsedId): Promise { try { const metadata = await new TMDBMetadata({ accessToken: this.userData.tmdbAccessToken, apiKey: this.userData.tmdbApiKey, - }).getMetadata(id, 'series'); + }).getMetadata(parsedId); return metadata; } catch (error) { logger.warn( - `Error getting metadata for ${id}, will not be able to precache next season if necessary`, + `Error getting metadata for ${parsedId.fullId}, will not be able to precache next season if necessary`, { error: error instanceof Error ? error.message : String(error), } @@ -1256,15 +1258,16 @@ export class AIOStreams { } private _getNextEpisode( - currentSeason: number, + currentSeason: number | undefined, currentEpisode: number, metadata?: Metadata ): { - season: number; + season: number | undefined; episode: number; } { let season = currentSeason; let episode = currentEpisode + 1; + if (!currentSeason) return { season, episode }; const episodeCount = metadata?.seasons?.find( (s) => s.season_number === season )?.episode_count; @@ -1383,22 +1386,31 @@ export class AIOStreams { } private async precacheNextEpisode(type: string, id: string) { - const seasonEpisodeRegex = /:(\d+):(\d+)$/; - const match = id.match(seasonEpisodeRegex); - if (!match) { + const parsedId = IdParser.parse(id, type); + if (!parsedId) { return; } - const titleId = id.replace(seasonEpisodeRegex, ''); - const currentSeason = Number(match[1]); - const currentEpisode = Number(match[2]); - const metadata = await this.getMetadata(id); + const currentSeason = parsedId.season ? Number(parsedId.season) : undefined; + const currentEpisode = parsedId.episode + ? Number(parsedId.episode) + : undefined; + if (!currentEpisode) { + return; + } + + const metadata = await this.getMetadata(parsedId); const { season: seasonToPrecache, episode: episodeToPrecache } = this._getNextEpisode(currentSeason, currentEpisode, metadata); - const precacheId = `${titleId}:${seasonToPrecache}:${episodeToPrecache}`; - logger.info(`Pre-caching next episode of ${titleId}`, { + const precacheId = parsedId.generator( + parsedId.value, + seasonToPrecache?.toString(), + episodeToPrecache?.toString() + ); + logger.info(`Pre-caching next episode`, { + titleId: parsedId.value, currentSeason, currentEpisode, episodeToPrecache, diff --git a/packages/core/src/metadata/service.ts b/packages/core/src/metadata/service.ts index 47f1442c..4755f4a0 100644 --- a/packages/core/src/metadata/service.ts +++ b/packages/core/src/metadata/service.ts @@ -5,14 +5,16 @@ import { getTraktAliases } from './trakt.js'; import { IMDBMetadata } from './imdb.js'; import { createLogger, getTimeTakenSincePoint } from '../utils/logger.js'; import { TYPES } from '../utils/constants.js'; -import { AnimeDatabase, ParsedId } from '../utils/index.js'; +import { AnimeDatabase, IdParser, ParsedId } from '../utils/index.js'; import { Meta } from '../db/schemas.js'; +import { TVDBMetadata } from './tvdb.js'; const logger = createLogger('metadata-service'); export interface MetadataServiceConfig { tmdbAccessToken?: string; tmdbApiKey?: string; + tvdbApiKey?: string; } export class MetadataService { @@ -48,18 +50,22 @@ export class MetadataService { id.value ); - const tmdbId = + let tmdbId: number | null = id.type === 'themoviedbId' - ? id.value.toString() - : (animeEntry?.mappings?.themoviedbId?.toString() ?? null); - const imdbId = + ? Number(id.value) + : animeEntry?.mappings?.themoviedbId + ? Number(animeEntry.mappings.themoviedbId) + : null; + const imdbId: string | null = id.type === 'imdbId' ? id.value.toString() : (animeEntry?.mappings?.imdbId?.toString() ?? null); - const tvdbId = + let tvdbId: number | null = id.type === 'thetvdbId' - ? id.value.toString() - : (animeEntry?.mappings?.thetvdbId?.toString() ?? null); + ? Number(id.value) + : animeEntry?.mappings?.thetvdbId + ? Number(animeEntry.mappings.thetvdbId) + : null; if (animeEntry) { if (animeEntry.imdb?.title) titles.push(animeEntry.imdb.title); @@ -73,17 +79,38 @@ export class MetadataService { const promises = []; // TMDB metadata - - if (tmdbId || imdbId || tvdbId) { - let id = tmdbId - ? `tmdb:${tmdbId}` - : (imdbId ?? (tvdbId ? `tvdb:${tvdbId}` : null)); + const idForTmdb = tmdbId + ? `tmdb:${tmdbId}` + : (imdbId ?? (tvdbId ? `tvdb:${tvdbId}` : null)); + const parsedIdForTmdb = idForTmdb + ? IdParser.parse(idForTmdb, type) + : null; + if (parsedIdForTmdb) { promises.push( (async () => { return new TMDBMetadata({ accessToken: this.config.tmdbAccessToken, apiKey: this.config.tmdbApiKey, - }).getMetadata(id!, type); + }).getMetadata(parsedIdForTmdb); + })() + ); + } else { + promises.push(Promise.resolve(undefined)); + } + + // TVDB metadata + const idForTvdb = tvdbId + ? `tvdb:${tvdbId}` + : (imdbId ?? (tmdbId ? `tmdb:${tmdbId}` : null)); + const parsedIdForTvdb = idForTvdb + ? IdParser.parse(idForTvdb, type) + : null; + if (parsedIdForTvdb) { + promises.push( + (async () => { + return new TVDBMetadata({ + apiKey: this.config.tvdbApiKey, + }).getMetadata(parsedIdForTvdb); })() ); } else { @@ -105,17 +132,18 @@ export class MetadataService { } // Execute all promises in parallel - const [tmdbResult, traktResult, imdbResult] = (await Promise.allSettled( - promises - )) as [ - PromiseSettledResult, - PromiseSettledResult, - PromiseSettledResult, - ]; + const [tmdbResult, tvdbResult, traktResult, imdbResult] = + (await Promise.allSettled(promises)) as [ + PromiseSettledResult<(Metadata & { tmdbId: string }) | undefined>, + PromiseSettledResult<(Metadata & { tvdbId: number }) | undefined>, + PromiseSettledResult, + PromiseSettledResult, + ]; // Process TMDB results if (tmdbResult.status === 'fulfilled' && tmdbResult.value) { const tmdbMetadata = tmdbResult.value; + logger.debug(`TMDB metadata: ${JSON.stringify(tmdbMetadata)}`); if (tmdbMetadata.title) titles.unshift(tmdbMetadata.title); if (tmdbMetadata.titles) titles.push(...tmdbMetadata.titles); if (!year && tmdbMetadata.year) year = tmdbMetadata.year; @@ -124,12 +152,26 @@ export class MetadataService { seasons = tmdbMetadata.seasons.sort( (a, b) => a.season_number - b.season_number ); + tmdbId = tmdbMetadata.tmdbId; } else if (tmdbResult.status === 'rejected') { logger.warn( `Failed to fetch TMDB metadata for ${id.fullId}: ${tmdbResult.reason}` ); } + // Process TVDB results + if (tvdbResult.status === 'fulfilled' && tvdbResult.value) { + const tvdbMetadata = tvdbResult.value; + if (tvdbMetadata.title) titles.unshift(tvdbMetadata.title); + if (tvdbMetadata.titles) titles.push(...tvdbMetadata.titles); + if (!year && tvdbMetadata.year) year = tvdbMetadata.year; + if (tvdbMetadata.yearEnd) yearEnd = tvdbMetadata.yearEnd; + tvdbId = tvdbMetadata.tvdbId; + } else if (tvdbResult.status === 'rejected') { + logger.warn( + `Failed to fetch TVDB metadata for ${id.fullId}: ${tvdbResult.reason}` + ); + } // Process Trakt results if (traktResult.status === 'fulfilled' && traktResult.value) { titles.push(...traktResult.value); @@ -231,6 +273,8 @@ export class MetadataService { year, yearEnd, seasons, + tmdbId, + tvdbId, }; }, { diff --git a/packages/core/src/metadata/tmdb.ts b/packages/core/src/metadata/tmdb.ts index bc6940d7..b638b9b1 100644 --- a/packages/core/src/metadata/tmdb.ts +++ b/packages/core/src/metadata/tmdb.ts @@ -1,14 +1,14 @@ import { Headers } from 'undici'; -import { Env, Cache, TYPES, makeRequest } from '../utils/index.js'; +import { Env, Cache, makeRequest, ParsedId, IdType } from '../utils/index.js'; import { Metadata } from './utils.js'; import { z } from 'zod'; -export type ExternalIdType = 'imdb' | 'tmdb' | 'tvdb'; +export type TMDBIdType = 'imdb_id' | 'tmdb_id' | 'tvdb_id'; -interface ExternalId { - type: ExternalIdType; - value: string; -} +// interface ExternalId { +// type: ExternalIdType; +// value: string; +// } const API_BASE_URL = 'https://api.themoviedb.org/3'; const FIND_BY_ID_PATH = '/find'; @@ -72,6 +72,12 @@ const FindResultsSchema = z.object({ ), }); +const IdTypeMap: Partial> = { + imdbId: 'imdb_id', + thetvdbId: 'tvdb_id', + themoviedbId: 'tmdb_id', +}; + export class TMDBMetadata { private readonly TMDB_ID_REGEX = /^(?:tmdb)[-:](\d+)(?::\d+:\d+)?$/; private readonly TVDB_ID_REGEX = /^(?:tvdb)[-:](\d+)(?::\d+:\d+)?$/; @@ -111,39 +117,20 @@ export class TMDBMetadata { return headers; } - private parseExternalId(id: string): ExternalId | null { - if (this.TMDB_ID_REGEX.test(id)) { - const match = id.match(this.TMDB_ID_REGEX); - return match ? { type: 'tmdb', value: match[1] } : null; - } - if (this.IMDB_ID_REGEX.test(id)) { - const match = id.match(this.IMDB_ID_REGEX); - return match ? { type: 'imdb', value: `tt${match[1]}` } : null; - } - if (this.TVDB_ID_REGEX.test(id)) { - const match = id.match(this.TVDB_ID_REGEX); - return match ? { type: 'tvdb', value: match[1] } : null; - } - return null; - } - - private async convertToTmdbId( - id: ExternalId, - type: (typeof TYPES)[number] - ): Promise { - if (id.type === 'tmdb') { - return id.value; + private async convertToTmdbId(parsedId: ParsedId): Promise { + if (parsedId.type === 'themoviedbId') { + return parsedId.value.toString(); } // Check cache first - const cacheKey = `${id.type}:${id.value}:${type}`; + const cacheKey = `${parsedId.type}:${parsedId.value}:${parsedId.mediaType}`; const cachedId = await TMDBMetadata.idCache.get(cacheKey); if (cachedId) { return cachedId; } - const url = new URL(API_BASE_URL + FIND_BY_ID_PATH + `/${id.value}`); - url.searchParams.set('external_source', `${id.type}_id`); + const url = new URL(API_BASE_URL + FIND_BY_ID_PATH + `/${parsedId.value}`); + url.searchParams.set('external_source', `${IdTypeMap[parsedId.type]}`); this.addSearchParams(url); const response = await makeRequest(url.toString(), { timeout: 10000, @@ -155,11 +142,14 @@ export class TMDBMetadata { } const data = FindResultsSchema.parse(await response.json()); - const results = type === 'movie' ? data.movie_results : data.tv_results; + const results = + parsedId.mediaType === 'movie' ? data.movie_results : data.tv_results; const meta = results[0]; if (!meta) { - throw new Error(`No ${type} metadata found for ID: ${id.value}`); + throw new Error( + `No ${parsedId.mediaType} metadata found for ID: ${parsedId.type}:${parsedId.value}` + ); } const tmdbId = meta.id.toString(); @@ -174,34 +164,29 @@ export class TMDBMetadata { return date.getFullYear().toString(); } - public async getMetadata( - id: string, - type: (typeof TYPES)[number] - ): Promise { - if (!['movie', 'series', 'anime'].includes(type)) { - throw new Error(`Invalid type: ${type}`); + public async getMetadata(parsedId: ParsedId): Promise { + if (!['movie', 'series', 'anime'].includes(parsedId.mediaType)) { + throw new Error(`Invalid media type: ${parsedId.mediaType}`); + } + if (!['imdbId', 'thetvdbId', 'themoviedbId'].includes(parsedId.type)) { + throw new Error(`Invalid ID type: ${parsedId.type}`); } - const externalId = this.parseExternalId(id); - if (!externalId) { - throw new Error( - 'Invalid ID format. Must be TMDB (tmdb:123) or IMDB (tt123) or TVDB (tvdb:123) format' - ); - } - - const tmdbId = await this.convertToTmdbId(externalId, type); + const tmdbId = await this.convertToTmdbId(parsedId); // Check cache first - const cacheKey = `${tmdbId}:${type}`; + const cacheKey = `${tmdbId}:${parsedId.mediaType}`; const cachedMetadata = await TMDBMetadata.metadataCache.get(cacheKey); if (cachedMetadata) { - return cachedMetadata; + return { ...cachedMetadata, tmdbId: Number(tmdbId) }; } // Fetch primary title from details endpoint const detailsUrl = new URL( API_BASE_URL + - (type === 'movie' ? MOVIE_DETAILS_PATH : TV_DETAILS_PATH) + + (parsedId.mediaType === 'movie' + ? MOVIE_DETAILS_PATH + : TV_DETAILS_PATH) + `/${tmdbId}` ); this.addSearchParams(detailsUrl); @@ -216,21 +201,21 @@ export class TMDBMetadata { const detailsJson = await detailsResponse.json(); const detailsData = - type === 'movie' + parsedId.mediaType === 'movie' ? MovieDetailsSchema.parse(detailsJson) : TVDetailsSchema.parse(detailsJson); const primaryTitle = - type === 'movie' + parsedId.mediaType === 'movie' ? (detailsData as z.infer).title : (detailsData as z.infer).name; const year = this.parseReleaseDate( - type === 'movie' + parsedId.mediaType === 'movie' ? (detailsData as z.infer).release_date : (detailsData as z.infer).first_air_date ); const yearEnd = - type === 'series' + parsedId.mediaType !== 'movie' ? (detailsData as z.infer).last_air_date ? this.parseReleaseDate( (detailsData as z.infer).last_air_date @@ -238,14 +223,16 @@ export class TMDBMetadata { : undefined : undefined; const seasons = - type === 'series' + parsedId.mediaType !== 'movie' ? (detailsData as z.infer).seasons : undefined; // Fetch alternative titles const altTitlesUrl = new URL( API_BASE_URL + - (type === 'movie' ? MOVIE_DETAILS_PATH : TV_DETAILS_PATH) + + (parsedId.mediaType === 'movie' + ? MOVIE_DETAILS_PATH + : TV_DETAILS_PATH) + `/${tmdbId}` + ALTERNATIVE_TITLES_PATH ); @@ -263,11 +250,11 @@ export class TMDBMetadata { const altTitlesJson = await altTitlesResponse.json(); const altTitlesData = - type === 'movie' + parsedId.mediaType === 'movie' ? MovieAlternativeTitlesSchema.parse(altTitlesJson) : TVAlternativeTitlesSchema.parse(altTitlesJson); const alternativeTitles = - type === 'movie' + parsedId.mediaType === 'movie' ? ( altTitlesData as z.infer ).titles.map((title) => title.title) @@ -284,10 +271,12 @@ export class TMDBMetadata { year: Number(year), yearEnd: yearEnd ? Number(yearEnd) : undefined, seasons, + tmdbId: Number(tmdbId), + tvdbId: null, }; // Cache the result TMDBMetadata.metadataCache.set(cacheKey, metadata, TITLE_CACHE_TTL); - return metadata; + return { ...metadata, tmdbId: Number(tmdbId) }; } private addSearchParams(url: URL) { diff --git a/packages/core/src/metadata/tvdb.ts b/packages/core/src/metadata/tvdb.ts new file mode 100644 index 00000000..ee170fcb --- /dev/null +++ b/packages/core/src/metadata/tvdb.ts @@ -0,0 +1,401 @@ +import { createLogger } from '../utils/logger.js'; +import { Cache, DistributedLock, Env, ParsedId } from '../utils/index.js'; +import { Metadata } from './utils.js'; +import { makeRequest } from '../utils/http.js'; +import { z } from 'zod'; + +const logger = createLogger('tvdb'); + +interface TVDBMetadataConfig { + apiKey?: string; +} + +const API_VERSION = '4'; +const API_BASE_URL = `https://api${API_VERSION}.thetvdb.com`; +const TVDBAliasSchema = z.object({ + language: z.string(), + name: z.string(), +}); + +const TVDBErrorSchema = z.object({ + status: z.enum(['failure', 'error']), + data: z.null(), + message: z.string(), +}); + +type TVDBError = z.infer; + +const TVDBSuccessSchema = (dataSchema: T) => + z.object({ + status: z.literal('success'), + data: dataSchema, + }); + +const AuthTokenDataSchema = z.object({ + token: z.string(), +}); + +const AuthTokenSchema = z.discriminatedUnion('status', [ + TVDBSuccessSchema(AuthTokenDataSchema), + TVDBErrorSchema, +]); + +// --- /search/remoteId endpoint schema --- +const TVDBStatusSchema = z.object({ + id: z.number().nullable(), + name: z.string().nullable(), + recordType: z.string(), + keepUpdated: z.boolean(), +}); + +// Base schemas for common fields +const TVDBBaseRecordSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + image: z.string().url(), + nameTranslations: z.array(z.string()), + overviewTranslations: z.array(z.string()), + aliases: z.array(TVDBAliasSchema), + score: z.number(), + lastUpdated: z.string(), + year: z.string(), + status: TVDBStatusSchema, +}); + +const TVDBSeriesRecordSchema = TVDBBaseRecordSchema.extend({ + firstAired: z.string().optional(), + lastAired: z.string().optional(), + nextAired: z.string().optional(), + originalCountry: z.string().optional(), + originalLanguage: z.string().optional(), + defaultSeasonType: z.number().optional(), + isOrderRandomized: z.boolean().optional(), + averageRuntime: z.number().optional(), + episodes: z.unknown().nullable().optional(), + overview: z.string().optional(), +}); + +const TVDBMovieRecordSchema = TVDBBaseRecordSchema.extend({ + runtime: z.number(), +}); + +const TVDBMovieSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + image: z.string().url(), + nameTranslations: z.array(z.string()), + overviewTranslations: z.array(z.string()), + aliases: z.array(TVDBAliasSchema), + score: z.number(), + runtime: z.number(), + status: TVDBStatusSchema, + lastUpdated: z.string(), + year: z.string(), +}); + +const TVDBSeriesSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + image: z.string().url(), + nameTranslations: z.array(z.string()), + overviewTranslations: z.array(z.string()), + aliases: z.array(TVDBAliasSchema), + firstAired: z.string().optional(), + lastAired: z.string().optional(), + nextAired: z.string().optional(), + score: z.number(), + status: TVDBStatusSchema, + originalCountry: z.string().optional(), + originalLanguage: z.string().optional(), + defaultSeasonType: z.number().optional(), + isOrderRandomized: z.boolean().optional(), + lastUpdated: z.string(), + averageRuntime: z.number().optional(), + episodes: z.unknown().nullable().optional(), + overview: z.string().optional(), + year: z.string(), +}); + +const TVDBRemoteIdDataSchema = z.union([ + z.object({ movie: TVDBMovieSchema }), + z.object({ series: TVDBSeriesSchema }), +]); + +export const RemoteIdSearchResponseSchema = z.discriminatedUnion('status', [ + TVDBSuccessSchema(z.array(TVDBRemoteIdDataSchema)), + TVDBErrorSchema, +]); + +export const SeriesResponseSchema = z.discriminatedUnion('status', [ + TVDBSuccessSchema(TVDBSeriesRecordSchema), + TVDBErrorSchema, +]); + +export const MovieResponseSchema = z.discriminatedUnion('status', [ + TVDBSuccessSchema(TVDBMovieRecordSchema), + TVDBErrorSchema, +]); + +export type RemoteIdSearchResponse = z.infer< + typeof RemoteIdSearchResponseSchema +>; + +export class TVDBMetadata { + private readonly api: TVDBApi; + public constructor(config: TVDBMetadataConfig) { + const apiKey = config.apiKey || Env.TVDB_API_KEY; + if (!apiKey) { + throw new Error('TVDB API key is not set'); + } + this.api = new TVDBApi(apiKey); + } + + private async ensureToken(): Promise { + await this.api.ensureToken(); + } + + public async validateApiKey() { + await this.ensureToken(); + } + + public async getMetadata(id: ParsedId): Promise { + if (!['imdbId', 'themoviedbId', 'thetvdbId'].includes(id.type)) { + throw new Error(`Invalid ID type: ${id.type}`); + } + await this.ensureToken(); + + if (id.type !== 'thetvdbId') { + const response = await this.api.searchRemoteId(id.value.toString()); + if (!response.data?.[0]) { + throw new Error(`No results found for ${id.value}`); + } + + const item = response.data[0]; + if ('movie' in item) { + const movie = item.movie; + return { + title: movie.name, + titles: movie.aliases.map((a) => a.name), + year: parseInt(movie.year), + tvdbId: movie.id, + tmdbId: null, + }; + } else { + const series = item.series; + return { + title: series.name, + titles: series.aliases.map((a) => a.name), + year: parseInt(series.year), + yearEnd: series.lastAired + ? new Date(series.lastAired).getFullYear() + : undefined, + tvdbId: series.id, + tmdbId: null, + }; + } + } else { + // Direct TVDB ID lookup + const tvdbId = parseInt(id.value.toString()); + + if (id.mediaType === 'movie') { + const response = await this.api.getMovie(tvdbId); + if (!response.data) { + throw new Error(`No movie found for TVDB ID ${tvdbId}`); + } + return { + title: response.data.name, + titles: response.data.aliases.map((a) => a.name), + year: parseInt(response.data.year), + tvdbId: response.data.id, + tmdbId: null, + }; + } else { + // Handle both series and anime the same way + const response = await this.api.getSeries(tvdbId); + if (!response.data) { + throw new Error(`No series found for TVDB ID ${tvdbId}`); + } + const series = response.data; + return { + title: series.name, + titles: series.aliases.map((a) => a.name), + year: parseInt(series.year), + yearEnd: series.lastAired + ? new Date(series.lastAired).getFullYear() + : undefined, + tvdbId: series.id, + tmdbId: null, + }; + } + } + } +} + +class TVDBApi { + private headers: Record; + private readonly apiKey: string; + + // Cache instances + private readonly cache = { + token: Cache.getInstance('tvdb:token'), + series: Cache.getInstance>( + 'tvdb:series' + ), + movie: Cache.getInstance>( + 'tvdb:movie' + ), + // prettier-ignore + remoteId: Cache.getInstance>('tvdb:remoteId'), + }; + + constructor(apiKey: string) { + this.headers = { + 'Content-Type': 'application/json', + 'User-Agent': Env.DEFAULT_USER_AGENT, + Accept: 'application/json', + }; + this.apiKey = apiKey; + } + + private setToken(token: string): void { + this.headers.Authorization = `Bearer ${token}`; + } + + public async ensureToken(): Promise { + const getToken = async () => { + logger.debug('Logging in to TVDB API'); + const response = await this.request>( + '/login', + { + schema: AuthTokenSchema, + method: 'POST', + body: { + apikey: this.apiKey, + }, + } + ); + + if (response.status === 'success') { + return response.data.token; + } + throw new Error(`Failed to authenticate with TVDB: ${response.message}`); + }; + + const token = await this.cache.token.wrap( + getToken, + 'token', + 30 * 24 * 60 * 60 // 30 days + ); + + this.setToken(token); + } + + public async searchRemoteId( + remoteId: string + ): Promise> { + return this.cache.remoteId.wrap( + async () => { + logger.debug(`Searching for remote ID: ${remoteId}`); + return this.request>( + `/search/remoteid/${remoteId}`, + { + schema: RemoteIdSearchResponseSchema, + } + ); + }, + remoteId, + 7 * 24 * 60 * 60 // 7 days + ); + } + + public async getSeries( + id: number + ): Promise> { + return this.cache.series.wrap( + async () => { + logger.debug(`Getting series: ${id}`); + return this.request>( + `/series/${id}`, + { + schema: SeriesResponseSchema, + } + ); + }, + id, + 7 * 24 * 60 * 60 // 7 days + ); + } + + public async getMovie( + id: number + ): Promise> { + return this.cache.movie.wrap( + async () => { + logger.debug(`Getting movie: ${id}`); + return this.request>( + `/movies/${id}`, + { + schema: MovieResponseSchema, + } + ); + }, + id, + 7 * 24 * 60 * 60 // 7 days + ); + } + + private async request( + endpoint: string, + options: { + schema: z.ZodSchema; + body?: unknown; + method?: string; + timeout?: number; + } + ): Promise { + const { schema, body, method = 'GET' } = options; + const path = `/v${API_VERSION}/${endpoint.startsWith('/') ? endpoint.slice(1) : endpoint}`; + const url = new URL(path, API_BASE_URL); + + logger.debug(`Making ${method} request to ${path}`); + + try { + const response = await makeRequest(url.toString(), { + method, + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + timeout: options.timeout ?? Env.MAX_TIMEOUT, + }); + + const data = (await response.json()) as unknown; + + // Check for API error response + if (typeof data === 'object' && data && 'status' in data) { + const status = (data as { status: unknown }).status; + if (!response.ok || status === 'error' || status === 'failure') { + const message = + 'message' in data + ? String((data as { message: unknown }).message) + : response.statusText; + throw new Error(`TVDB API error (${response.status}): ${message}`); + } + } else if (!response.ok) { + throw new Error( + `TVDB API error (${response.status}): ${response.statusText}` + ); + } + + return schema.parse(data); + } catch (error) { + logger.error( + `Request to ${path} failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + throw error instanceof Error + ? error + : new Error('Unknown error occurred'); + } + } +} diff --git a/packages/core/src/metadata/utils.ts b/packages/core/src/metadata/utils.ts index 852a9025..f59b8a1a 100644 --- a/packages/core/src/metadata/utils.ts +++ b/packages/core/src/metadata/utils.ts @@ -7,4 +7,6 @@ export interface Metadata { season_number: number; episode_count: number; }[]; + tmdbId?: number | null; + tvdbId?: number | null; } diff --git a/packages/core/src/parser/utils.ts b/packages/core/src/parser/utils.ts index bb748213..cdd2db66 100644 --- a/packages/core/src/parser/utils.ts +++ b/packages/core/src/parser/utils.ts @@ -39,6 +39,7 @@ export function normaliseTitle(title: string) { export function cleanTitle(title: string) { return title .normalize('NFD') + .replace(/-/g, ' ') .replace(/[\u0300-\u036f]/g, '') .replace(/[^\p{L}\p{N}\s]/gu, '') .replace(/\s+/g, ' ') diff --git a/packages/core/src/presets/animetosho.ts b/packages/core/src/presets/animetosho.ts index 8c92a8cc..d7eb2166 100644 --- a/packages/core/src/presets/animetosho.ts +++ b/packages/core/src/presets/animetosho.ts @@ -54,14 +54,9 @@ export class AnimeToshoPreset extends TorznabPreset { const animetoshoUrl = this.METADATA.URL; const config = { + ...this.getBaseConfig(userData, services), url: animetoshoUrl, apiPath: '/api', - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), }; const configString = this.base64EncodeJSON(config); diff --git a/packages/core/src/presets/builtin.ts b/packages/core/src/presets/builtin.ts index baf1e603..5610e963 100644 --- a/packages/core/src/presets/builtin.ts +++ b/packages/core/src/presets/builtin.ts @@ -67,4 +67,16 @@ export class BuiltinAddonPreset extends Preset { ...specialCases, }); } + + protected static getBaseConfig(userData: UserData, services: ServiceId[]) { + return { + tmdbAccessToken: userData.tmdbAccessToken, + tmdbApiKey: userData.tmdbApiKey, + tvdbApiKey: userData.tvdbApiKey, + services: services.map((service) => ({ + id: service, + credential: this.getServiceCredential(service, userData), + })), + }; + } } diff --git a/packages/core/src/presets/jackett.ts b/packages/core/src/presets/jackett.ts index e2144222..3b635771 100644 --- a/packages/core/src/presets/jackett.ts +++ b/packages/core/src/presets/jackett.ts @@ -103,15 +103,10 @@ export class JackettPreset extends TorznabPreset { } const config = { + ...this.getBaseConfig(userData, services), url: `${jackettUrl.replace(/\/$/, '')}/api/v2.0/results/all/torznab`, apiPath: '/api', apiKey: jackettApiKey, - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), forceQuerySearch: true, }; diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts index 4eef0f86..1f5a5836 100644 --- a/packages/core/src/presets/newznab.ts +++ b/packages/core/src/presets/newznab.ts @@ -130,16 +130,11 @@ export class NewznabPreset extends BuiltinAddonPreset { options: Record ) { const config = { + ...this.getBaseConfig(userData, services), url: options.newznabUrl, apiPath: options.apiPath, apiKey: options.apiKey, - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, forceQuerySearch: options.forceQuerySearch ?? false, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), }; const configString = this.base64EncodeJSON(config); diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts index 89bb6c4d..017c2332 100644 --- a/packages/core/src/presets/nzbhydra.ts +++ b/packages/core/src/presets/nzbhydra.ts @@ -94,16 +94,11 @@ export class NZBHydraPreset extends NewznabPreset { } const config = { + ...this.getBaseConfig(userData, services), url: nzbhydraUrl, apiPath: options.apiPath, apiKey: nzbhydraApiKey, - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, forceQuerySearch: true, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), }; const configString = this.base64EncodeJSON(config); diff --git a/packages/core/src/presets/prowlarr.ts b/packages/core/src/presets/prowlarr.ts index 9e12bcef..47aab1d9 100644 --- a/packages/core/src/presets/prowlarr.ts +++ b/packages/core/src/presets/prowlarr.ts @@ -167,15 +167,10 @@ export class ProwlarrPreset extends BuiltinAddonPreset { } const config = { + ...this.getBaseConfig(userData, services), url: prowlarrUrl, apiKey: prowlarrApiKey, indexers: Env.BUILTIN_PROWLARR_INDEXERS || [], - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), }; const configString = this.base64EncodeJSON(config); diff --git a/packages/core/src/presets/torznab.ts b/packages/core/src/presets/torznab.ts index d9fd2ba0..eda896f2 100644 --- a/packages/core/src/presets/torznab.ts +++ b/packages/core/src/presets/torznab.ts @@ -146,16 +146,11 @@ export class TorznabPreset extends BuiltinAddonPreset { options: Record ) { const config = { + ...this.getBaseConfig(userData, services), url: options.torznabUrl, apiPath: options.apiPath, apiKey: options.apiKey, - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, forceQuerySearch: options.forceQuerySearch ?? false, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), }; const configString = this.base64EncodeJSON(config); diff --git a/packages/core/src/presets/zilean.ts b/packages/core/src/presets/zilean.ts index 958b94e4..5c362427 100644 --- a/packages/core/src/presets/zilean.ts +++ b/packages/core/src/presets/zilean.ts @@ -61,14 +61,9 @@ export class ZileanPreset extends TorznabPreset { const zileanUrl = (options.url || this.METADATA.URL).replace(/\/$/, ''); const config = { + ...this.getBaseConfig(userData, services), url: `${zileanUrl}/torznab`, apiPath: '/api', - tmdbAccessToken: userData.tmdbAccessToken, - tmdbApiKey: userData.tmdbApiKey, - services: services.map((service) => ({ - id: service, - credential: this.getServiceCredential(service, userData), - })), }; const configString = this.base64EncodeJSON(config); diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index a3a4c886..837887e0 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -28,6 +28,7 @@ import { StreamSelector, } from '../parser/streamExpression.js'; import { createLogger } from './logger.js'; +import { TVDBMetadata } from '../metadata/tvdb.js'; const logger = createLogger('core'); @@ -418,6 +419,20 @@ export async function validateConfig( } } + if (config.tvdbApiKey) { + try { + const tvdb = new TVDBMetadata({ + apiKey: config.tvdbApiKey, + }); + await tvdb.validateApiKey(); + } catch (error) { + if (!options?.skipErrorsFromAddonsOrProxies) { + throw new Error(`Invalid TVDB API key: ${error}`); + } + logger.warn(`Invalid TVDB API key: ${error}`); + } + } + if (FeatureControl.disabledServices.size > 0) { for (const service of config.services ?? []) { if (FeatureControl.disabledServices.has(service.id)) { diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 50295a1e..fc2c7ba4 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -374,6 +374,10 @@ export const Env = cleanEnv(process.env, { default: undefined, desc: 'TMDB API Key. Used for fetching metadata for the strict title matching option.', }), + TVDB_API_KEY: str({ + default: undefined, + desc: 'TVDB API Key. Used for fetching metadata.', + }), TRAKT_CLIENT_ID: str({ default: undefined, desc: 'Trakt Client ID. Used for fetching Trakt aliases.', diff --git a/packages/core/src/utils/id-parser.ts b/packages/core/src/utils/id-parser.ts index 42685851..310e974a 100644 --- a/packages/core/src/utils/id-parser.ts +++ b/packages/core/src/utils/id-parser.ts @@ -39,6 +39,11 @@ export interface ParsedId { mediaType: string; season?: string; episode?: string; + generator: ( + value: string | number, + season?: string, + episode?: string + ) => string; } interface IdParserDefinition { @@ -51,6 +56,11 @@ interface IdParserDefinition { // - episode: the episode number (optional) regex: RegExp; format: (id: string) => string | number; + generator: ( + value: string | number, + season?: string, + episode?: string + ) => string; } export class IdParser { @@ -61,6 +71,7 @@ export class IdParser { prefixes: ['tt', 'imdb'], regex: /^(?:tt|imdb)[:-]?(?\d+)(?::(?\d+):(?\d+))?$/, format: (id) => `tt${id}`, + generator: (value, season, episode) => `${value}:${season}:${episode}`, }, { type: 'malId', @@ -68,6 +79,7 @@ export class IdParser { prefixes: ['mal'], regex: /^mal[:-]?(?\d+)(?::(?\d+))?$/, format: (id) => Number(id), + generator: (value, season, episode) => `mal:${value}:${episode}`, }, { type: 'thetvdbId', @@ -75,6 +87,8 @@ export class IdParser { prefixes: ['tvdb'], regex: /^tvdb[:-]?(?\d+)(?::(?\d+):(?\d+))?$/, format: (id) => Number(id), + generator: (value, season, episode) => + `tvdb:${value}:${season}:${episode}`, }, { type: 'themoviedbId', @@ -82,6 +96,8 @@ export class IdParser { prefixes: ['tmdb'], regex: /^tmdb[:-]?(?\d+)(?::(?\d+):(?\d+))?$/, format: (id) => Number(id), + generator: (value, season, episode) => + `tmdb:${value}:${season}:${episode}`, }, { type: 'kitsuId', @@ -89,6 +105,7 @@ export class IdParser { prefixes: ['kitsu'], regex: /^kitsu[:-]?(?\d+)(?::(?\d+))?$/, format: (id) => Number(id), + generator: (value, season, episode) => `kitsu:${value}:${episode}`, }, { type: 'anilistId', @@ -96,6 +113,7 @@ export class IdParser { prefixes: ['anilist'], regex: /^anilist[:-]?(?\d+)(?::(?\d+))?$/, format: (id) => Number(id), + generator: (value, season, episode) => `anilist:${value}:${episode}`, }, { type: 'anidbId', @@ -103,6 +121,7 @@ export class IdParser { prefixes: ['anidb', 'anidb_id', 'anidbid'], regex: /^(?:anidb|anidb_id|anidbid)[:-]?(?\d+)(?::(?\d+))?$/, format: (id) => Number(id), + generator: (value, season, episode) => `anidb:${value}:${episode}`, }, { type: 'animePlanetId', @@ -110,6 +129,7 @@ export class IdParser { prefixes: ['animeplanet', 'ap'], regex: /^(?:animeplanet|ap)[:-]?(?\d+)$/, format: (id) => Number(id), + generator: (value, season, episode) => `animeplanet:${value}:${episode}`, }, { type: 'animecountdownId', @@ -117,6 +137,7 @@ export class IdParser { prefixes: ['acd'], regex: /^acd[:-]?(?\d+)$/, format: (id) => Number(id), + generator: (value, season, episode) => `acd:${value}:${episode}`, }, { type: 'anisearchId', @@ -124,6 +145,7 @@ export class IdParser { prefixes: ['anisearch'], regex: /^anisearch[:-]?(?\d+)$/, format: (id) => Number(id), + generator: (value, season, episode) => `anisearch:${value}:${episode}`, }, { type: 'notifyMoeId', @@ -131,6 +153,7 @@ export class IdParser { prefixes: ['notifymoe', 'nm'], regex: /^(?:notifymoe|nm)[:-]?(?[a-zA-Z0-9]+)$/, format: (id) => id, + generator: (value, season, episode) => `notifymoe:${value}:${episode}`, }, { type: 'simklId', @@ -138,6 +161,7 @@ export class IdParser { prefixes: ['simkl'], regex: /^simkl[:-]?(?\d+)$/, format: (id) => Number(id), + generator: (value, season, episode) => `simkl:${value}:${episode}`, }, ]; @@ -160,6 +184,7 @@ export class IdParser { fullId: stremioId, externalType: parser.externalType, mediaType, + generator: parser.generator, }; if (season) parsedId.season = season; diff --git a/packages/frontend/src/components/menu/services.tsx b/packages/frontend/src/components/menu/services.tsx index 2e92e6c1..f0e7b43c 100644 --- a/packages/frontend/src/components/menu/services.tsx +++ b/packages/frontend/src/components/menu/services.tsx @@ -418,6 +418,36 @@ function Content() { /> + + + Sign up for a free API Key at{' '} + + TVDB.{' '} + + + } + onValueChange={(value) => { + setUserData((prev) => ({ + ...prev, + tvdbApiKey: value, + })); + }} + /> + +