diff --git a/cypress/e2e/providers/tvdb.cy.ts b/cypress/e2e/providers/tvdb.cy.ts new file mode 100644 index 00000000..65e629b0 --- /dev/null +++ b/cypress/e2e/providers/tvdb.cy.ts @@ -0,0 +1,148 @@ +describe('TVDB Integration', () => { + // Constants for routes and selectors + const ROUTES = { + home: '/', + metadataSettings: '/settings/metadata', + tomorrowIsOursTvShow: '/tv/72879', + monsterTvShow: '/tv/225634', + dragonnBallZKaiAnime: '/tv/61709', + }; + + const SELECTORS = { + sidebarToggle: '[data-testid=sidebar-toggle]', + sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]', + settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]', + metadataTestButton: 'button[type="button"]:contains("Test")', + metadataSaveButton: '[data-testid="metadata-save-button"]', + tmdbStatus: '[data-testid="tmdb-status"]', + tvdbStatus: '[data-testid="tvdb-status"]', + tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]', + animeMetadataProviderSelector: + '[data-testid="anime-metadata-provider-selector"]', + seasonSelector: '[data-testid="season-selector"]', + season1: 'Season 1', + season2: 'Season 2', + season3: 'Season 3', + episodeList: '[data-testid="episode-list"]', + episode9: '9 - Hang Men', + }; + + // Reusable commands + const navigateToMetadataSettings = () => { + cy.visit(ROUTES.home); + cy.get(SELECTORS.sidebarToggle).click(); + cy.get(SELECTORS.sidebarSettingsMobile).click(); + cy.get( + `${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]` + ).click(); + }; + + const testAndVerifyMetadataConnection = () => { + cy.intercept('POST', '/api/v1/settings/metadatas/test').as( + 'testConnection' + ); + cy.get(SELECTORS.metadataTestButton).click(); + return cy.wait('@testConnection'); + }; + + const saveMetadataSettings = (customBody = null) => { + if (customBody) { + cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => { + req.body = customBody; + }).as('saveMetadata'); + } else { + // Else just intercept without modifying body + cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata'); + } + + cy.get(SELECTORS.metadataSaveButton).click(); + return cy.wait('@saveMetadata'); + }; + + beforeEach(() => { + // Perform login + cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + + // Navigate to Metadata settings + navigateToMetadataSettings(); + + // Verify we're on the correct settings page + cy.contains('h3', 'Metadata Providers').should('be.visible'); + + // Configure TVDB as TV provider and test connection + cy.get(SELECTORS.tvMetadataProviderSelector).click(); + + // get id react-select-4-option-1 + cy.get('[class*="react-select__option"]').contains('TheTVDB').click(); + + // Test the connection + testAndVerifyMetadataConnection().then(({ response }) => { + expect(response.statusCode).to.equal(200); + // Check TVDB connection status + cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational'); + }); + + // Save settings + saveMetadataSettings({ + anime: 'tvdb', + tv: 'tvdb', + }).then(({ response }) => { + expect(response.statusCode).to.equal(200); + expect(response.body.tv).to.equal('tvdb'); + }); + }); + + it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => { + // Navigate to the TV show + cy.visit(ROUTES.tomorrowIsOursTvShow); + + // Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple) + // cy.get(SELECTORS.seasonSelector).should('exist'); + cy.intercept('/api/v1/tv/225634/season/1').as('season1'); + // Select Season 2 and verify it loads + cy.contains(SELECTORS.season2) + .should('be.visible') + .scrollIntoView() + .click(); + + // Verify that episodes are displayed for Season 2 + cy.contains('260 - Episode 506').should('be.visible'); + }); + + it('Should display "Monster" show information correctly when not existing on TVDB', () => { + // Navigate to the TV show + cy.visit(ROUTES.monsterTvShow); + + // Intercept season 1 request + cy.intercept('/api/v1/tv/225634/season/1').as('season1'); + + // Select Season 1 + cy.contains(SELECTORS.season1) + .should('be.visible') + .scrollIntoView() + .click(); + + // Wait for the season data to load + cy.wait('@season1'); + + // Verify specific episode exists + cy.contains(SELECTORS.episode9).should('be.visible'); + }); + + it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => { + // Navigate to the TV show + cy.visit(ROUTES.dragonnBallZKaiAnime); + + // Intercept season 1 request + cy.intercept('/api/v1/tv/61709/season/1').as('season1'); + + // Select Season 2 and verify it visible + cy.contains(SELECTORS.season2) + .should('be.visible') + .scrollIntoView() + .click(); + + // select season 3 and verify it not visible + cy.contains(SELECTORS.season3).should('not.exist'); + }); +}); diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 4254ba43..752b85f8 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -519,6 +519,20 @@ components: serverID: type: string readOnly: true + MetadataSettings: + type: object + properties: + settings: + type: object + properties: + tv: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + anime: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' TautulliSettings: type: object properties: @@ -2568,6 +2582,67 @@ paths: type: string thumb: type: string + /settings/metadatas: + get: + summary: Get Metadata settings + description: Retrieves current Metadata settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + put: + summary: Update Metadata settings + description: Updates Metadata settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + /settings/metadatas/test: + post: + summary: Test Provider 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: + tmdb: + type: boolean + example: true + tvdb: + type: boolean + example: true + responses: + '200': + description: Succesfully connected to TVDB + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Successfully connected to TVDB' /settings/tautulli: get: summary: Get Tautulli settings @@ -6472,7 +6547,7 @@ paths: application/json: schema: $ref: '#/components/schemas/TvDetails' - /tv/{tvId}/season/{seasonId}: + /tv/{tvId}/season/{seasonNumber}: get: summary: Get season details and episode list description: Returns season details with a list of episodes in a JSON object. @@ -6486,11 +6561,11 @@ paths: type: number example: 76479 - in: path - name: seasonId + name: seasonNumber required: true schema: type: number - example: 1 + example: 123456 - in: query name: language schema: diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 0d2016f7..f25fc547 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -10,7 +10,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; rateLimit?: { diff --git a/server/api/metadata.ts b/server/api/metadata.ts new file mode 100644 index 00000000..cd1936cb --- /dev/null +++ b/server/api/metadata.ts @@ -0,0 +1,39 @@ +import type { TvShowProvider } from '@server/api/provider'; +import TheMovieDb from '@server/api/themoviedb'; +import Tvdb from '@server/api/tvdb'; +import { getSettings, MetadataProviderType } from '@server/lib/settings'; +import logger from '@server/logger'; + +export const getMetadataProvider = async ( + mediaType: 'movie' | 'tv' | 'anime' +): Promise => { + try { + const settings = await getSettings(); + + if (mediaType == 'movie') { + return new TheMovieDb(); + } + + if ( + mediaType == 'tv' && + settings.metadataSettings.tv == MetadataProviderType.TVDB + ) { + return await Tvdb.getInstance(); + } + + if ( + mediaType == 'anime' && + settings.metadataSettings.anime == MetadataProviderType.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(); + } +}; diff --git a/server/api/provider.ts b/server/api/provider.ts new file mode 100644 index 00000000..93393aa3 --- /dev/null +++ b/server/api/provider.ts @@ -0,0 +1,30 @@ +import type { + TmdbSeasonWithEpisodes, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; + +export interface TvShowProvider { + getTvShow({ + tvId, + language, + }: { + tvId: number; + language?: string; + }): Promise; + getTvSeason({ + tvId, + seasonNumber, + language, + }: { + tvId: number; + seasonNumber: number; + language?: string; + }): Promise; + getShowByTvdbId({ + tvdbId, + language, + }: { + tvdbId: number; + language?: string; + }): Promise; +} diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 9d6f5089..9fc19c72 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,4 +1,5 @@ import ExternalAPI from '@server/api/externalapi'; +import type { TvShowProvider } from '@server/api/provider'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import { sortBy } from 'lodash'; @@ -120,7 +121,7 @@ interface DiscoverTvOptions { certificationCountry?: string; } -class TheMovieDb extends ExternalAPI { +class TheMovieDb extends ExternalAPI implements TvShowProvider { private locale: string; private discoverRegion?: string; private originalLanguage?: string; @@ -341,6 +342,13 @@ class TheMovieDb extends ExternalAPI { } ); + data.episodes = data.episodes.map((episode) => { + if (episode.still_path) { + episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`; + } + return episode; + }); + return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 775a8976..65ba18f8 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult { show_id: number; still_path: string; vote_average: number; - vote_cuont: number; + vote_count: number; } export interface TmdbTvSeasonResult { diff --git a/server/api/tvdb/index.ts b/server/api/tvdb/index.ts new file mode 100644 index 00000000..82134823 --- /dev/null +++ b/server/api/tvdb/index.ts @@ -0,0 +1,431 @@ +import ExternalAPI from '@server/api/externalapi'; +import type { TvShowProvider } from '@server/api/provider'; +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbSeasonWithEpisodes, + TmdbTvDetails, + TmdbTvEpisodeResult, + TmdbTvSeasonResult, +} from '@server/api/themoviedb/interfaces'; +import type { + TvdbBaseResponse, + TvdbEpisode, + TvdbLoginResponse, + TvdbSeasonDetails, + TvdbTvDetails, +} from '@server/api/tvdb/interfaces'; +import cacheManager, { type AvailableCacheIds } from '@server/lib/cache'; +import logger from '@server/logger'; + +interface TvdbConfig { + baseUrl: string; + maxRequestsPerSecond: number; + maxRequests: number; + cachePrefix: AvailableCacheIds; +} + +const DEFAULT_CONFIG: TvdbConfig = { + baseUrl: 'https://api4.thetvdb.com/v4', + maxRequestsPerSecond: 50, + maxRequests: 20, + cachePrefix: 'tvdb' as const, +}; + +const enum TvdbIdStatus { + INVALID = -1, +} + +type TvdbId = number; +type ValidTvdbId = Exclude; + +class Tvdb extends ExternalAPI implements TvShowProvider { + static instance: Tvdb; + private readonly tmdb: TheMovieDb; + private static readonly DEFAULT_CACHE_TTL = 43200; + private static readonly DEFAULT_LANGUAGE = 'eng'; + private token: string; + private pin?: string; + + constructor(pin?: string) { + const finalConfig = { ...DEFAULT_CONFIG }; + super( + finalConfig.baseUrl, + {}, + { + nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data, + rateLimit: { + maxRequests: finalConfig.maxRequests, + maxRPS: finalConfig.maxRequestsPerSecond, + }, + } + ); + this.pin = pin; + this.tmdb = new TheMovieDb(); + } + + public static async getInstance(): Promise { + if (!this.instance) { + this.instance = new Tvdb(); + await this.instance.login(); + } + + return this.instance; + } + + private async refreshToken(): Promise { + try { + if (!this.token) { + await this.login(); + return; + } + + const base64Url = this.token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(Buffer.from(base64, 'base64').toString()); + + if (!payload.exp) { + await this.login(); + } + + const now = Math.floor(Date.now() / 1000); + const diff = payload.exp - now; + + // refresh token 1 week before expiration + if (diff < 604800) { + await this.login(); + } + } catch (error) { + this.handleError('Failed to refresh token', error); + } + } + + public async test(): Promise { + try { + await this.login(); + } catch (error) { + this.handleError('Login failed', error); + throw error; + } + } + + async login(): Promise { + let body: { apiKey: string; pin?: string } = { + apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405', + }; + + if (this.pin) { + body = { + ...body, + pin: this.pin, + }; + } + + const response = await this.post>( + '/login', + { + ...body, + } + ); + + this.token = response.data.token; + + return response.data; + } + + public async getShowByTvdbId({ + tvdbId, + language, + }: { + tvdbId: number; + language?: string; + }): Promise { + try { + const tmdbTvShow = await this.tmdb.getShowByTvdbId({ + tvdbId: tvdbId, + language, + }); + + try { + await this.refreshToken(); + + const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow); + + if (this.isValidTvdbId(validTvdbId)) { + return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId); + } + + return tmdbTvShow; + } catch (error) { + return tmdbTvShow; + } + } catch (error) { + this.handleError('Failed to fetch TV show details', error); + throw error; + } + } + + public async getTvShow({ + tvId, + language, + }: { + tvId: number; + language?: string; + }): Promise { + try { + const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language }); + + try { + await this.refreshToken(); + + const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow); + + if (this.isValidTvdbId(tvdbId)) { + return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId); + } + + return tmdbTvShow; + } catch (error) { + this.handleError('Failed to fetch TV show details', error); + return tmdbTvShow; + } + } catch (error) { + this.handleError('Failed to fetch TV show details', error); + return this.tmdb.getTvShow({ tvId, language }); + } + } + + public async getTvSeason({ + tvId, + seasonNumber, + language = Tvdb.DEFAULT_LANGUAGE, + }: { + tvId: number; + seasonNumber: number; + language?: string; + }): Promise { + if (seasonNumber === 0) { + return this.createEmptySeasonResponse(tvId); + } + + try { + const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language }); + + try { + await this.refreshToken(); + + const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow); + + if (!this.isValidTvdbId(tvdbId)) { + return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); + } + + return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId); + } catch (error) { + this.handleError('Failed to fetch TV season details', error); + return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); + } + } catch (error) { + logger.error( + `[TVDB] Failed to fetch TV season details: ${error.message}` + ); + throw error; + } + } + + private async enrichTmdbShowWithTvdbData( + tmdbTvShow: TmdbTvDetails, + tvdbId: ValidTvdbId + ): Promise { + try { + await this.refreshToken(); + + const tvdbData = await this.fetchTvdbShowData(tvdbId); + const seasons = this.processSeasons(tvdbData); + + if (!seasons.length) { + return tmdbTvShow; + } + + return { ...tmdbTvShow, seasons }; + } catch (error) { + logger.error( + `Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}` + ); + return tmdbTvShow; + } + } + + private async fetchTvdbShowData(tvdbId: number): Promise { + const resp = await this.get>( + `/series/${tvdbId}/extended?meta=episodes&short=true`, + { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }, + Tvdb.DEFAULT_CACHE_TTL + ); + + return resp.data; + } + + private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] { + if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) { + return []; + } + + const seasons = tvdbData.seasons + .filter( + (season) => + season.number > 0 && season.type && season.type.type === 'official' + ) + .sort((a, b) => a.number - b.number) + .map((season) => this.createSeasonData(season, tvdbData)); + + return seasons; + } + + private createSeasonData( + season: TvdbSeasonDetails, + tvdbData: TvdbTvDetails + ): TmdbTvSeasonResult { + if (!season.number) { + return { + id: 0, + episode_count: 0, + name: '', + overview: '', + season_number: 0, + poster_path: '', + air_date: '', + }; + } + + const episodeCount = tvdbData.episodes.filter( + (episode) => episode.seasonNumber === season.number + ).length; + + return { + id: tvdbData.id, + episode_count: episodeCount, + name: `${season.number}`, + overview: '', + season_number: season.number, + poster_path: '', + air_date: '', + }; + } + + private async getTvdbSeasonData( + tvdbId: number, + seasonNumber: number, + tvId: number + //language: string = Tvdb.DEFAULT_LANGUAGE + ): Promise { + const tvdbData = await this.fetchTvdbShowData(tvdbId); + + if (!tvdbData) { + logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`); + return this.createEmptySeasonResponse(tvId); + } + + // get season id + const season = tvdbData.seasons.find( + (season) => + season.number === seasonNumber && + season.type.type && + season.type.type === 'official' + ); + + if (!season) { + logger.error( + `Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}` + ); + return this.createEmptySeasonResponse(tvId); + } + + const resp = await this.get>( + `/seasons/${season.id}/extended`, + { + headers: { + Authorization: `Bearer ${this.token}`, + }, + } + ); + + const seasons = resp.data; + + const episodes = this.processEpisodes(seasons, seasonNumber, tvId); + + return { + episodes, + external_ids: { tvdb_id: tvdbId }, + name: '', + overview: '', + id: seasons.id, + air_date: seasons.firstAired, + season_number: episodes.length, + }; + } + + private processEpisodes( + tvdbSeason: TvdbSeasonDetails, + seasonNumber: number, + tvId: number + ): TmdbTvEpisodeResult[] { + if (!tvdbSeason || !tvdbSeason.episodes) { + logger.error('No episodes found in TVDB season data'); + return []; + } + + return tvdbSeason.episodes + .filter((episode) => episode.seasonNumber === seasonNumber) + .map((episode, index) => this.createEpisodeData(episode, index, tvId)); + } + + private createEpisodeData( + episode: TvdbEpisode, + index: number, + tvId: number + ): TmdbTvEpisodeResult { + return { + 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: '', + show_id: tvId, + still_path: episode.image ? 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; diff --git a/server/api/tvdb/interfaces.ts b/server/api/tvdb/interfaces.ts new file mode 100644 index 00000000..9c245208 --- /dev/null +++ b/server/api/tvdb/interfaces.ts @@ -0,0 +1,144 @@ +export interface TvdbBaseResponse { + data: T; + errors: string; +} + +export interface TvdbLoginResponse { + token: string; +} + +interface TvDetailsAliases { + language: string; + name: string; +} + +interface TvDetailsStatus { + id: number; + name: string; + recordType: string; + keepUpdated: boolean; +} + +export interface 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[]; + episodes: TvdbEpisode[]; +} + +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; +} + +export 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 { + 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 { + name: string; + overview: string; + language: string; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 51d0e08f..64b5c79e 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -9,7 +9,8 @@ export type AvailableCacheIds = | 'github' | 'plexguid' | 'plextv' - | 'plexwatchlist'; + | 'plexwatchlist' + | 'tvdb'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -70,6 +71,10 @@ class CacheManager { checkPeriod: 60, }), plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'), + tvdb: new Cache('tvdb', 'The TVDB API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index bfef4f7e..3283e342 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -1,7 +1,12 @@ import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; +import { getMetadataProvider } from '@server/api/metadata'; import TheMovieDb from '@server/api/themoviedb'; -import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { + TmdbKeyword, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; @@ -43,6 +48,7 @@ class JellyfinScanner { constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { this.tmdb = new TheMovieDb(); + this.isRecentOnly = isRecentOnly ?? false; } @@ -192,6 +198,42 @@ class JellyfinScanner { } } + private async getTvShow({ + tmdbId, + tvdbId, + }: { + tmdbId?: number; + tvdbId?: number; + }): Promise { + let tvShow; + + if (tmdbId) { + tvShow = await this.tmdb.getTvShow({ + tvId: Number(tmdbId), + }); + } else if (tvdbId) { + tvShow = await this.tmdb.getShowByTvdbId({ + tvdbId: Number(tvdbId), + }); + } else { + throw new Error('No ID provided'); + } + + const metadataProvider = tvShow.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ) + ? await getMetadataProvider('anime') + : await getMetadataProvider('tv'); + + if (!(metadataProvider instanceof TheMovieDb)) { + tvShow = await metadataProvider.getTvShow({ + tvId: Number(tmdbId), + }); + } + + return tvShow; + } + private async processShow(jellyfinitem: JellyfinLibraryItem) { const mediaRepository = getRepository(Media); @@ -212,8 +254,8 @@ class JellyfinScanner { if (metadata.ProviderIds.Tmdb) { try { - tvShow = await this.tmdb.getTvShow({ - tvId: Number(metadata.ProviderIds.Tmdb), + tvShow = await this.getTvShow({ + tmdbId: Number(metadata.ProviderIds.Tmdb), }); } catch { this.log('Unable to find TMDb ID for this title.', 'debug', { @@ -223,7 +265,7 @@ class JellyfinScanner { } if (!tvShow && metadata.ProviderIds.Tvdb) { try { - tvShow = await this.tmdb.getShowByTvdbId({ + tvShow = await this.getTvShow({ tvdbId: Number(metadata.ProviderIds.Tvdb), }); } catch { diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 9dee904a..24862e55 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -1,7 +1,13 @@ import animeList from '@server/api/animelist'; +import { getMetadataProvider } from '@server/api/metadata'; import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; -import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { + TmdbKeyword, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import cacheManager from '@server/lib/cache'; @@ -249,6 +255,42 @@ class PlexScanner }); } + private async getTvShow({ + tmdbId, + tvdbId, + }: { + tmdbId?: number; + tvdbId?: number; + }): Promise { + let tvShow; + + if (tmdbId) { + tvShow = await this.tmdb.getTvShow({ + tvId: Number(tmdbId), + }); + } else if (tvdbId) { + tvShow = await this.tmdb.getShowByTvdbId({ + tvdbId: Number(tvdbId), + }); + } else { + throw new Error('No ID provided'); + } + + const metadataProvider = tvShow.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ) + ? await getMetadataProvider('anime') + : await getMetadataProvider('tv'); + + if (!(metadataProvider instanceof TheMovieDb)) { + tvShow = await metadataProvider.getTvShow({ + tvId: Number(tmdbId), + }); + } + + return tvShow; + } + private async processPlexShow(plexitem: PlexLibraryItem) { const ratingKey = plexitem.grandparentRatingKey ?? @@ -273,7 +315,9 @@ class PlexScanner await this.processHamaSpecials(metadata, mediaIds.tvdbId); } - const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId }); + const tvShow = await this.getTvShow({ + tmdbId: mediaIds.tmdbId, + }); const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 52272c74..60e4769d 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -100,6 +100,27 @@ interface Quota { quotaDays?: number; } +export enum MetadataProviderType { + TMDB = 'tmdb', + TVDB = 'tvdb', +} + +export interface MetadataSettings { + tv: MetadataProviderType; + anime: MetadataProviderType; +} + +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; @@ -339,6 +360,7 @@ export interface AllSettings { notifications: NotificationSettings; jobs: Record; network: NetworkSettings; + metadataSettings: MetadataSettings; } const SETTINGS_PATH = process.env.CONFIG_DIRECTORY @@ -399,6 +421,10 @@ class Settings { apiKey: '', }, tautulli: {}, + metadataSettings: { + tv: MetadataProviderType.TMDB, + anime: MetadataProviderType.TMDB, + }, radarr: [], sonarr: [], public: { @@ -593,6 +619,14 @@ class Settings { this.data.tautulli = data; } + get metadataSettings(): MetadataSettings { + return this.data.metadataSettings; + } + + set metadataSettings(data: MetadataSettings) { + this.data.metadataSettings = data; + } + get radarr(): RadarrSettings[] { return this.data.radarr; } diff --git a/server/models/Tv.ts b/server/models/Tv.ts index c79f9311..64b9669b 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ seasonNumber: episode.season_number, showId: episode.show_id, voteAverage: episode.vote_average, - voteCount: episode.vote_cuont, + voteCount: episode.vote_count, stillPath: episode.still_path, }); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 7298e2c9..68353968 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -39,6 +39,7 @@ import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; import { URL } from 'url'; +import metadataRoutes from './metadata'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; @@ -49,6 +50,7 @@ settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); +settingsRoutes.use('/metadatas', metadataRoutes); const filteredMainSettings = ( user: User, diff --git a/server/routes/settings/metadata.ts b/server/routes/settings/metadata.ts new file mode 100644 index 00000000..8e007f0e --- /dev/null +++ b/server/routes/settings/metadata.ts @@ -0,0 +1,153 @@ +import TheMovieDb from '@server/api/themoviedb'; +import Tvdb from '@server/api/tvdb'; +import { + getSettings, + MetadataProviderType, + type MetadataSettings, +} from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +function getTestResultString(testValue: number): string { + if (testValue === -1) return 'not tested'; + if (testValue === 0) return 'failed'; + return 'ok'; +} + +const metadataRoutes = Router(); + +metadataRoutes.get('/', (_req, res) => { + const settings = getSettings(); + res.status(200).json({ + tv: settings.metadataSettings.tv, + anime: settings.metadataSettings.anime, + }); +}); + +metadataRoutes.put('/', async (req, res) => { + const settings = getSettings(); + const body = req.body as MetadataSettings; + + let tvdbTest = -1; + let tmdbTest = -1; + + try { + if ( + body.tv === MetadataProviderType.TVDB || + body.anime === MetadataProviderType.TVDB + ) { + tvdbTest = 0; + const tvdb = await Tvdb.getInstance(); + await tvdb.test(); + tvdbTest = 1; + } + } catch (e) { + logger.error('Failed to test metadata provider', { + label: 'Metadata', + message: e.message, + }); + } + + try { + if ( + body.tv === MetadataProviderType.TMDB || + body.anime === MetadataProviderType.TMDB + ) { + tmdbTest = 0; + const tmdb = new TheMovieDb(); + await tmdb.getTvShow({ tvId: 1054 }); + tmdbTest = 1; + } + } catch (e) { + logger.error('Failed to test metadata provider', { + label: 'MetadataProvider', + message: e.message, + }); + } + + // If a test failed, return the test results + if (tvdbTest === 0 || tmdbTest === 0) { + return res.status(500).json({ + success: false, + tests: { + tvdb: getTestResultString(tvdbTest), + tmdb: getTestResultString(tmdbTest), + }, + }); + } + + settings.metadataSettings = { + tv: body.tv, + anime: body.anime, + }; + await settings.save(); + + res.status(200).json({ + success: true, + tv: body.tv, + anime: body.anime, + tests: { + tvdb: getTestResultString(tvdbTest), + tmdb: getTestResultString(tmdbTest), + }, + }); +}); + +metadataRoutes.post('/test', async (req, res) => { + let tvdbTest = -1; + let tmdbTest = -1; + + try { + const body = req.body as { tmdb: boolean; tvdb: boolean }; + + try { + if (body.tmdb) { + tmdbTest = 0; + const tmdb = new TheMovieDb(); + await tmdb.getTvShow({ tvId: 1054 }); + tmdbTest = 1; + } + } catch (e) { + logger.error('Failed to test metadata provider', { + label: 'MetadataProvider', + message: e.message, + }); + } + + try { + if (body.tvdb) { + tvdbTest = 0; + const tvdb = await Tvdb.getInstance(); + await tvdb.test(); + tvdbTest = 1; + } + } catch (e) { + logger.error('Failed to test metadata provider', { + label: 'MetadataProvider', + message: e.message, + }); + } + + const success = !(tvdbTest === 0 || tmdbTest === 0); + const statusCode = success ? 200 : 500; + + return res.status(statusCode).json({ + success: success, + tests: { + tmdb: getTestResultString(tmdbTest), + tvdb: getTestResultString(tvdbTest), + }, + }); + } catch (e) { + return res.status(500).json({ + success: false, + tests: { + tmdb: getTestResultString(tmdbTest), + tvdb: getTestResultString(tvdbTest), + }, + error: e.message, + }); + } +}); + +export default metadataRoutes; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 4a106d60..5d3c3e09 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,5 +1,8 @@ +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'; @@ -13,12 +16,20 @@ const tvRoutes = Router(); tvRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); + try { - const tv = await tmdb.getTvShow({ + const tmdbTv = await tmdb.getTvShow({ + tvId: Number(req.params.id), + }); + const metadataProvider = tmdbTv.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ) + ? await getMetadataProvider('anime') + : await getMetadataProvider('tv'); + const tv = await metadataProvider.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({ @@ -34,7 +45,9 @@ tvRoutes.get('/:id', async (req, res, next) => { // TMDB issue where it doesnt fallback to English when no overview is available in requested locale. if (!data.overview) { - const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) }); + const tvEnglish = await metadataProvider.getTvShow({ + tvId: Number(req.params.id), + }); data.overview = tvEnglish.overview; } @@ -53,13 +66,20 @@ tvRoutes.get('/:id', async (req, res, next) => { }); tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { - const tmdb = new TheMovieDb(); - try { - const season = await tmdb.getTvSeason({ + const tmdb = new TheMovieDb(); + const tmdbTv = await tmdb.getTvShow({ + tvId: Number(req.params.id), + }); + const metadataProvider = tmdbTv.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ) + ? await getMetadataProvider('anime') + : await getMetadataProvider('tv'); + + const season = await metadataProvider.getTvSeason({ tvId: Number(req.params.id), seasonNumber: Number(req.params.seasonNumber), - language: (req.query.language as string) ?? req.locale, }); return res.status(200).json(mapSeasonWithEpisodes(season)); diff --git a/src/components/MetadataSelector/index.tsx b/src/components/MetadataSelector/index.tsx new file mode 100644 index 00000000..5af4ce9f --- /dev/null +++ b/src/components/MetadataSelector/index.tsx @@ -0,0 +1,91 @@ +import defineMessages from '@app/utils/defineMessages'; +import { useIntl } from 'react-intl'; +import Select, { type StylesConfig } from 'react-select'; + +enum MetadataProviderType { + TMDB = 'tmdb', + TVDB = 'tvdb', +} + +type MetadataProviderOptionType = { + testId?: string; + value: MetadataProviderType; + label: string; +}; + +const messages = defineMessages('components.MetadataSelector', { + tmdbLabel: 'The Movie Database (TMDB)', + tvdbLabel: 'TheTVDB', + selectMetdataProvider: 'Select a metadata provider', +}); + +interface MetadataSelectorProps { + testId: string; + value: MetadataProviderType; + onChange: (value: MetadataProviderType) => void; + isDisabled?: boolean; +} + +const MetadataSelector = ({ + testId = 'metadata-provider-selector', + value, + onChange, + isDisabled = false, +}: MetadataSelectorProps) => { + const intl = useIntl(); + + const metadataProviderOptions: MetadataProviderOptionType[] = [ + { + testId: 'tmdb-option', + value: MetadataProviderType.TMDB, + label: intl.formatMessage(messages.tmdbLabel), + }, + { + testId: 'tvdb-option', + value: MetadataProviderType.TVDB, + label: intl.formatMessage(messages.tvdbLabel), + }, + ]; + + const customStyles: StylesConfig = { + option: (base) => ({ + ...base, + display: 'flex', + alignItems: 'center', + }), + singleValue: (base) => ({ + ...base, + display: 'flex', + alignItems: 'center', + }), + }; + + const formatOptionLabel = (option: MetadataProviderOptionType) => ( +
+ {option.label} +
+ ); + + return ( +
+