diff --git a/cypress/e2e/indexers/tvdb.cy.ts b/cypress/e2e/indexers/tvdb.cy.ts new file mode 100644 index 000000000..d79aa8736 --- /dev/null +++ b/cypress/e2e/indexers/tvdb.cy.ts @@ -0,0 +1,92 @@ +describe('TVDB Integration', () => { + // Constants for routes and selectors + const ROUTES = { + home: '/', + tvdbSettings: '/settings/tvdb', + tomorrowIsOursTvShow: '/tv/72879', + monsterTvShow: '/tv/225634', + }; + + const SELECTORS = { + sidebarToggle: '[data-testid=sidebar-toggle]', + sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]', + settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]', + tvdbEnable: 'input[data-testid="tvdb-enable"]', + tvdbSaveButton: '[data-testid=tvbd-save-button]', + heading: '.heading', + season1: 'Season 1', + season2: 'Season 2', + }; + + // Reusable commands + const toggleTVDBSetting = () => { + cy.intercept('/api/v1/settings/tvdb').as('tvdbRequest'); + cy.get(SELECTORS.tvdbSaveButton).click(); + return cy.wait('@tvdbRequest'); + }; + + const verifyTVDBResponse = (response, expectedUseValue) => { + expect(response.statusCode).to.equal(200); + expect(response.body.tvdb).to.equal(expectedUseValue); + }; + + beforeEach(() => { + // Perform login + cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + + // Navigate to TVDB settings + cy.visit(ROUTES.home); + cy.get(SELECTORS.sidebarToggle).click(); + cy.get(SELECTORS.sidebarSettingsMobile).click(); + cy.get( + `${SELECTORS.settingsNavDesktop} a[href="${ROUTES.tvdbSettings}"]` + ).click(); + + // Verify heading + cy.get(SELECTORS.heading).should('contain', 'Tvdb'); + + // Configure TVDB settings + cy.get(SELECTORS.tvdbEnable).then(($checkbox) => { + const isChecked = $checkbox.is(':checked'); + + if (!isChecked) { + // If disabled, enable TVDB + cy.wrap($checkbox).click(); + toggleTVDBSetting().then(({ response }) => { + verifyTVDBResponse(response, true); + }); + } else { + // If enabled, disable then re-enable TVDB + cy.wrap($checkbox).click(); + toggleTVDBSetting().then(({ response }) => { + verifyTVDBResponse(response, false); + }); + + cy.wrap($checkbox).click(); + toggleTVDBSetting().then(({ response }) => { + verifyTVDBResponse(response, true); + }); + } + }); + }); + + it('should display "Tomorrow is Ours" show information correctly (1 season on TMDB >1 seasons on TVDB)', () => { + cy.visit(ROUTES.tomorrowIsOursTvShow); + cy.contains(SELECTORS.season2) + .should('be.visible') + .scrollIntoView() + .click(); + }); + + it('Should display "Monster" show information correctly (Not existing on TVDB)', () => { + cy.visit(ROUTES.monsterTvShow); + cy.intercept('/api/v1/tv/225634/season/1').as('season1'); + cy.contains(SELECTORS.season1) + .should('be.visible') + .scrollIntoView() + .click(); + cy.wait('@season1'); + + cy.contains('9 - Hang Men').should('be.visible'); + }); +}); diff --git a/overseerr-api.yml b/overseerr-api.yml index b707ae8b3..d0e8ea609 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -405,6 +405,32 @@ 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' + providers: + type: object + properties: + tvdb: + type: object + properties: + apiKey: + type: string + example: '123456789' + pin: + type: string + example: '1234' TautulliSettings: type: object properties: @@ -2378,6 +2404,54 @@ 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 + 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 @@ -5953,7 +6027,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. @@ -5967,11 +6041,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 d17ebf99e..78c5232c3 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,5 +1,3 @@ -import { MediaServerType } from '@server/constants/server'; -import { getSettings } from '@server/lib/settings'; import type { RateLimitOptions } from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit'; import type NodeCache from 'node-cache'; @@ -10,7 +8,7 @@ const DEFAULT_TTL = 300; // 10 seconds default rolling buffer (in ms) const DEFAULT_ROLLING_BUFFER = 10000; -interface ExternalAPIOptions { +export interface ExternalAPIOptions { nodeCache?: NodeCache; headers?: Record; rateLimit?: RateLimitOptions; @@ -36,8 +34,6 @@ class ExternalAPI { const url = new URL(baseUrl); - const settings = getSettings(); - this.defaultHeaders = { 'Content-Type': 'application/json', Accept: 'application/json', @@ -46,9 +42,7 @@ class ExternalAPI { `${url.username}:${url.password}` ).toString('base64')}`, }), - ...(settings.main.mediaServerType === MediaServerType.EMBY && { - 'Accept-Encoding': 'gzip', - }), + ...options.headers, }; diff --git a/server/api/indexer.ts b/server/api/indexer.ts new file mode 100644 index 000000000..d741e7194 --- /dev/null +++ b/server/api/indexer.ts @@ -0,0 +1,30 @@ +import type { + TmdbSeasonWithEpisodes, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; + +export interface TvShowIndexer { + 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/jellyfin.ts b/server/api/jellyfin.ts index d0c4d7c74..0712abb6c 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import ExternalAPI from '@server/api/externalapi'; import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType } from '@server/constants/server'; import availabilitySync from '@server/lib/availabilitySync'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; @@ -103,12 +105,17 @@ class JellyfinAPI extends ExternalAPI { authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`; } + const settings = getSettings(); + super( jellyfinHost, {}, { headers: { 'X-Emby-Authorization': authHeaderVal, + ...(settings.main.mediaServerType === MediaServerType.EMBY && { + 'Accept-Encoding': 'gzip', + }), }, } ); diff --git a/server/api/metadata.ts b/server/api/metadata.ts new file mode 100644 index 000000000..b9e33858a --- /dev/null +++ b/server/api/metadata.ts @@ -0,0 +1,36 @@ +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 => { + try { + const settings = await getSettings(); + + if (!settings.tvdb.apiKey || mediaType == 'movie') { + return new TheMovieDb(); + } + + if (mediaType == 'tv' && settings.metadataType.tv == IndexerType.TVDB) { + return await Tvdb.getInstance(); + } + + if ( + mediaType == 'anime' && + settings.metadataType.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(); + } +}; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 5cf449ea9..ce8b89c76 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 { TvShowIndexer } from '@server/api/indexer'; import cacheManager from '@server/lib/cache'; import { sortBy } from 'lodash'; import type { @@ -98,7 +99,7 @@ interface DiscoverTvOptions { withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5 } -class TheMovieDb extends ExternalAPI { +class TheMovieDb extends ExternalAPI implements TvShowIndexer { private discoverRegion?: string; private originalLanguage?: string; constructor({ @@ -308,6 +309,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 775a89765..65ba18f80 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 000000000..ffcab177b --- /dev/null +++ b/server/api/tvdb/index.ts @@ -0,0 +1,343 @@ +import ExternalAPI from '@server/api/externalapi'; +import type { TvShowIndexer } from '@server/api/indexer'; +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbSeasonWithEpisodes, + TmdbTvDetails, + TmdbTvEpisodeResult, + TmdbTvSeasonResult, +} from '@server/api/themoviedb/interfaces'; +import type { + TvdbEpisode, + TvdbLoginResponse, + 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 { + baseUrl: string; + maxRequestsPerSecond: number; + cachePrefix: AvailableCacheIds; +} + +const DEFAULT_CONFIG: TvdbConfig = { + baseUrl: 'https://api4.thetvdb.com/v4', + maxRequestsPerSecond: 50, + cachePrefix: 'tvdb' as const, +}; + +const enum TvdbIdStatus { + INVALID = -1, +} + +type TvdbId = number; +type ValidTvdbId = Exclude; + +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 = '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: { + maxRPS: finalConfig.maxRequestsPerSecond, + id: finalConfig.cachePrefix, + }, + } + ); + this.apiKey = apiKey; + this.pin = pin; + this.tmdb = new TheMovieDb(); + } + + public static async getInstance(): Promise { + 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 { + try { + await this.login(); + } catch (error) { + this.handleError('Login failed', error); + throw error; + } + } + + async handleRenewToken(): Promise { + throw new Error('Method not implemented.'); + } + + async login(): Promise { + const response = await this.post('/login', { + apiKey: process.env.TVDB_API_KEY, + }); + + this.defaultHeaders.Authorization = `Bearer ${response.token}`; + this.token = response.token; + + return response; + } + + public async getShowByTvdbId({ + tvdbId, + }: { + tvdbId: number; + language?: string; + }): Promise { + return await this.get( + `/en/${tvdbId}`, + {}, + Tvdb.DEFAULT_CACHE_TTL + ); + } + + public async getTvShow({ + tvId, + language = Tvdb.DEFAULT_LANGUAGE, + }: { + tvId: number; + language?: string; + }): Promise { + try { + const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language }); + 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); + throw error; + } + } + + 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 }); + 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) { + logger.error( + `[TVDB] Failed to fetch TV season details: ${error.message}` + ); + return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); + } + } + + private async enrichTmdbShowWithTvdbData( + tmdbTvShow: TmdbTvDetails, + tvdbId: ValidTvdbId + ): Promise { + try { + 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}` + ); + return tmdbTvShow; + } + } + + private async fetchTvdbShowData(tvdbId: number): Promise { + return await this.get( + `/series/${tvdbId}/extended?meta=episodes`, + { + short: 'true', + }, + Tvdb.DEFAULT_CACHE_TTL + ); + } + + private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] { + if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) { + return []; + } + + return tvdbData.seasons + .filter( + (season) => + season.number > 0 && season.type && season.type.type === 'official' + ) + .map((season) => this.createSeasonData(season, tvdbData)); + } + + 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) { + return this.createEmptySeasonResponse(tvId); + } + + const seasons = await this.get( + `/series/${tvdbId}/episodes/official/${language}`, + {} + ); + + 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) { + 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 || '', + 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 000000000..532ff0e67 --- /dev/null +++ b/server/api/tvdb/interfaces.ts @@ -0,0 +1,149 @@ +export interface TvdbBaseResponse { + data: T; + errors: string; +} + +export interface TvdbLoginResponse { + token: string; +} + +export interface TvdbLoginResponse extends TvdbBaseResponse<{ token: string }> { + data: { token: string }; +} + +interface TvDetailsAliases { + language: string; + name: string; +} + +interface TvDetailsStatus { + id: number; + name: string; + recordType: string; + keepUpdated: boolean; +} + +export interface TvdbTvDetails extends TvdbBaseResponse { + 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 extends TvdbBaseResponse { + 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 + extends TvdbBaseResponse { + name: string; + overview: string; + language: string; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 51d0e08f2..64b5c79ee 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 b4816ae55..f1f2df8dd 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -1,3 +1,4 @@ +import type { TvShowIndexer } from '@server/api/indexer'; import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; import TheMovieDb from '@server/api/themoviedb'; @@ -30,6 +31,7 @@ interface SyncStatus { class JellyfinScanner { private sessionId: string; private tmdb: TheMovieDb; + private tvShowIndexer: TvShowIndexer; private jfClient: JellyfinAPI; private items: JellyfinLibraryItem[] = []; private progress = 0; @@ -43,6 +45,7 @@ class JellyfinScanner { constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { this.tmdb = new TheMovieDb(); + this.isRecentOnly = isRecentOnly ?? false; } @@ -212,7 +215,7 @@ class JellyfinScanner { if (metadata.ProviderIds.Tmdb) { try { - tvShow = await this.tmdb.getTvShow({ + tvShow = await this.tvShowIndexer.getTvShow({ tvId: Number(metadata.ProviderIds.Tmdb), }); } catch { @@ -223,7 +226,7 @@ class JellyfinScanner { } if (!tvShow && metadata.ProviderIds.Tvdb) { try { - tvShow = await this.tmdb.getShowByTvdbId({ + tvShow = await this.tvShowIndexer.getShowByTvdbId({ tvdbId: Number(metadata.ProviderIds.Tvdb), }); } catch { diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 9dee904aa..a4fcfcf54 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -273,7 +273,9 @@ class PlexScanner await this.processHamaSpecials(metadata, mediaIds.tvdbId); } - const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId }); + const tvShow = await this.tmdb.getTvShow({ + tvId: mediaIds.tmdbId, + }); const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index cd8ebb974..9290db16c 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -100,6 +100,29 @@ interface Quota { quotaDays?: number; } +export enum IndexerType { + TMDB, + TVDB, +} + +export interface MetadataSettings { + settings: MetadataTypeSettings; + providers: ProviderSettings; +} + +export interface TvdbSettings { + apiKey?: string; + pin?: string; +} + +export interface ProviderSettings { + tvdb: TvdbSettings; +} +export interface MetadataTypeSettings { + tv: IndexerType; + anime: IndexerType; +} + export interface ProxySettings { enabled: boolean; hostname: string; @@ -311,6 +334,8 @@ export interface AllSettings { public: PublicSettings; notifications: NotificationSettings; jobs: Record; + tvdb: TvdbSettings; + metadataSettings: MetadataSettings; } const SETTINGS_PATH = process.env.CONFIG_DIRECTORY @@ -378,6 +403,22 @@ class Settings { apiKey: '', }, tautulli: {}, + metadataSettings: { + settings: { + tv: IndexerType.TMDB, + anime: IndexerType.TVDB, + }, + providers: { + tvdb: { + apiKey: '', + pin: '', + }, + }, + }, + tvdb: { + apiKey: '', + pin: '', + }, radarr: [], sonarr: [], public: { @@ -547,6 +588,30 @@ class Settings { this.data.tautulli = data; } + 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: TvdbSettings) { + this.data.tvdb = data; + } + + get metadataType(): MetadataTypeSettings { + return this.data.metadataSettings.settings; + } + + set metadataType(data: MetadataTypeSettings) { + this.data.metadataSettings.settings = data; + } + get radarr(): RadarrSettings[] { return this.data.radarr; } diff --git a/server/models/Tv.ts b/server/models/Tv.ts index c79f93117..64b9669bb 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 bc8c5ef7c..ac4338b5e 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -37,6 +37,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'; @@ -47,6 +48,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 000000000..3b5190dc4 --- /dev/null +++ b/server/routes/settings/metadata.ts @@ -0,0 +1,47 @@ +import Tvdb from '@server/api/tvdb'; +import { getSettings, type MetadataSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const metadataRoutes = Router(); + +metadataRoutes.get('/', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.metadataSettings); +}); + +metadataRoutes.put('/', (req, res) => { + const settings = getSettings(); + + const body = req.body as MetadataSettings; + + settings.metadataSettings = { + providers: body.providers, + settings: body.settings, + }; + settings.save(); + + return res.status(200).json({ + tvdb: settings.tvdb, + }); +}); + +metadataRoutes.post('/test', async (req, res, next) => { + try { + const tvdb = await Tvdb.getInstance(); + await tvdb.test(); + + // TODO: add tmdb test + return res.status(200).json({ tvdb: true }); + } catch (e) { + logger.error('Failed to test Tvdb', { + label: 'Tvdb', + message: e.message, + }); + + return next({ status: 500, message: 'Failed to connect to Tvdb' }); + } +}); + +export default metadataRoutes; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 4a106d60e..d49a01e0f 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 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({ @@ -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 indexer.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 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), 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.ts b/src/components/MetadataSelector/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index 6336bad01..9cd3099d1 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -53,6 +53,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => { route: '/settings/services', regex: /^\/settings\/services/, }, + { + text: 'Metadata', + route: '/settings/metadata', + regex: /^\/settings\/metadata/, + }, { text: intl.formatMessage(messages.menuNotifications), route: '/settings/notifications/email', diff --git a/src/components/Settings/SettingsMetadata.tsx b/src/components/Settings/SettingsMetadata.tsx new file mode 100644 index 000000000..4a31d6066 --- /dev/null +++ b/src/components/Settings/SettingsMetadata.tsx @@ -0,0 +1,265 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; +import { Field, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +const messages = defineMessages('components.Settings', { + general: 'General', + settings: 'Settings', + apiKey: 'Api Key', + pin: 'Pin', + enableTip: + 'Enable Tvdb (only for season and episode).' + + ' Due to a limitation of the api used, only English is available.', +}); + +interface providerResponse { + tvdb: boolean; + tmdb: boolean; +} + +enum indexerType { + TMDB, + TVDB, +} + +interface metadataSettings { + settings: metadataTypeSettings; + providers: providerSettings; +} + +interface metadataTypeSettings { + tv: indexerType; + anime: indexerType; +} + +interface providerSettings { + tvdb: tvdbSettings; +} + +interface tvdbSettings { + apiKey: string; + pin: string; +} + +const SettingsMetadata = () => { + const intl = useIntl(); + const [isTesting, setIsTesting] = useState(false); + + const { addToast } = useToasts(); + + const testConnection = async () => { + const response = await fetch('/api/v1/settings/metadatas/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const body = (await response.json()) as providerResponse; + + if (!response.ok) { + throw new Error('Failed to test Tvdb connection'); + } + + console.log(body); + }; + + const saveSettings = async ( + value: metadataSettings + ): Promise => { + const response = await fetch('/api/v1/settings/metadatas', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(value), + }); + + if (!response.ok) { + throw new Error('Failed to save Metadata settings'); + } + + return (await response.json()) as metadataSettings; + }; + + const { data, error } = useSWR( + '/api/v1/settings/metadatas' + ); + + if (!data && !error) { + return ; + } + + return ( + <> + +
+

{'Metadata'}

+

{'Settings for metadata indexer'}

+
+
+ { + try { + await saveSettings( + data ?? { + providers: { + tvdb: { + apiKey: '', + pin: '', + }, + }, + settings: { + tv: indexerType.TMDB, + anime: indexerType.TMDB, + }, + } + ); + if (data) { + data.providers = values.providers; + data.settings = values.settings; + } + } catch (e) { + addToast('Failed to save Tvdb settings', { appearance: 'error' }); + return; + } + addToast('Tvdb settings saved', { appearance: 'success' }); + }} + > + {({ isSubmitting, isValid, values }) => { + return ( +
+
+

{'TVDB'}

+

{'Settings for TVDB indexer'}

+
+
+ +
+ ) => { + values.providers.tvdb.apiKey = e.target.value; + }} + /> +
+
+
+ +
+ +
+ ) => { + values.providers.tvdb.pin = e.target.value; + }} + /> +
+
+
+ +
+
+ + + + + + +
+
+
+ ); + }} +
+
+ + ); +}; + +export default SettingsMetadata; diff --git a/src/components/TvDetails/Season/index.tsx b/src/components/TvDetails/Season/index.tsx index 09b2b6398..d71b3c4ba 100644 --- a/src/components/TvDetails/Season/index.tsx +++ b/src/components/TvDetails/Season/index.tsx @@ -59,7 +59,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 0c10ed10a..91c80bd78 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -119,9 +119,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { const intl = useIntl(); const { locale } = useLocale(); const [showRequestModal, setShowRequestModal] = useState(false); - const [showManager, setShowManager] = useState( - router.query.manage == '1' ? true : false - ); + const [showManager, setShowManager] = useState(router.query.manage == '1'); const [showIssueModal, setShowIssueModal] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [toggleWatchlist, setToggleWatchlist] = useState( @@ -157,7 +155,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { ); useEffect(() => { - setShowManager(router.query.manage == '1' ? true : false); + setShowManager(router.query.manage == '1'); }, [router.query.manage]); const closeBlacklistModal = useCallback( @@ -189,7 +187,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { }) ) { mediaLinks.push({ - text: getAvalaibleMediaServerName(), + text: getAvailableMediaServerName(), url: plexUrl, svg: , }); @@ -203,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { }) ) { mediaLinks.push({ - text: getAvalaible4kMediaServerName(), + text: getAvailable4kMediaServerName(), url: plexUrl4k, svg: , }); @@ -324,7 +322,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { (provider) => provider.iso_3166_1 === streamingRegion )?.flatrate ?? []; - function getAvalaibleMediaServerName() { + function getAvailableMediaServerName() { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); } @@ -336,7 +334,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' }); } - function getAvalaible4kMediaServerName() { + function getAvailable4kMediaServerName() { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); } diff --git a/src/pages/settings/metadata.tsx b/src/pages/settings/metadata.tsx new file mode 100644 index 000000000..e248cfab1 --- /dev/null +++ b/src/pages/settings/metadata.tsx @@ -0,0 +1,16 @@ +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +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 MetadataSettingsPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + ); +}; + +export default MetadataSettingsPage;