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