diff --git a/overseerr-api.yml b/overseerr-api.yml index 3cb42284..f03ab1e7 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -385,6 +385,18 @@ components: serverID: type: string readOnly: true + TvdbSettings: + type: object + properties: + apiKey: + type: string + example: 'apikey' + pin: + type: string + example: 'ABCDEFGH' + use: + type: boolean + example: true TautulliSettings: type: object properties: @@ -2343,6 +2355,75 @@ paths: type: string thumb: type: string + /settings/tvdb: + get: + summary: Get TVDB settings + description: Retrieves current TVDB settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TvdbSettings' + put: + summary: Update TVDB settings + description: Updates TVDB settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TvdbSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TvdbSettings' + /settings/tvdb/test: + post: + summary: Test TVDB 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: + apiKey: + type: string + example: yourapikey + pin: + type: string + example: yourpin + required: + - apiKey + responses: + '200': + description: Succesfully connected to TVDB + content: + application/json: + schema: + type: object + properties: + languages: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string /settings/tautulli: get: summary: Get Tautulli settings @@ -5787,7 +5868,7 @@ paths: application/json: schema: $ref: '#/components/schemas/TvDetails' - /tv/{tvId}/season/{seasonId}: + /tv/{tvId}/season/{seasonNumber}/{seasonId}: get: summary: Get season details and episode list description: Returns season details with a list of episodes in a JSON object. @@ -5806,6 +5887,12 @@ paths: schema: type: number example: 1 + - in: path + name: seasonNumber + required: true + schema: + type: number + example: 123456 - in: query name: language schema: diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 752601bf..43ff0cfb 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -8,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; @@ -39,6 +39,7 @@ class ExternalAPI { Accept: 'application/json', ...options.headers, }; + this.cache = options.nodeCache; } diff --git a/server/api/indexer/index.ts b/server/api/indexer/index.ts new file mode 100644 index 00000000..aa59540b --- /dev/null +++ b/server/api/indexer/index.ts @@ -0,0 +1,25 @@ +import type { + TmdbSeasonWithEpisodes, + TmdbTvDetails, +} from '@server/api/indexer/themoviedb/interfaces'; + +export interface TvShowIndexer { + getTvShow({ + tvId, + language, + }: { + tvId: number; + language?: string; + }): Promise; + getTvSeason({ + tvId, + seasonId, + seasonNumber, + language, + }: { + tvId: number; + seasonId: number; + seasonNumber: number; + language?: string; + }): Promise; +} diff --git a/server/api/themoviedb/constants.ts b/server/api/indexer/themoviedb/constants.ts similarity index 100% rename from server/api/themoviedb/constants.ts rename to server/api/indexer/themoviedb/constants.ts diff --git a/server/api/themoviedb/index.ts b/server/api/indexer/themoviedb/index.ts similarity index 99% rename from server/api/themoviedb/index.ts rename to server/api/indexer/themoviedb/index.ts index 922ff90f..1f2b8cab 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/indexer/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 { @@ -97,7 +98,7 @@ interface DiscoverTvOptions { watchProviders?: string; } -class TheMovieDb extends ExternalAPI { +class TheMovieDb extends ExternalAPI implements TvShowIndexer { private region?: string; private originalLanguage?: string; constructor({ diff --git a/server/api/themoviedb/interfaces.ts b/server/api/indexer/themoviedb/interfaces.ts similarity index 100% rename from server/api/themoviedb/interfaces.ts rename to server/api/indexer/themoviedb/interfaces.ts diff --git a/server/api/indexer/tvdb/index.ts b/server/api/indexer/tvdb/index.ts new file mode 100644 index 00000000..5419420a --- /dev/null +++ b/server/api/indexer/tvdb/index.ts @@ -0,0 +1,226 @@ +import ExternalAPI from '@server/api/externalapi'; +import type { TvShowIndexer } from '@server/api/indexer'; +import TheMovieDb from '@server/api/indexer/themoviedb'; +import type { + TmdbSeasonWithEpisodes, + TmdbTvDetails, +} from '@server/api/indexer/themoviedb/interfaces'; +import type { + TvdbEpisodeTranslation, + TvdbLoginResponse, + TvdbSeasonDetails, + TvdbTvDetails, +} from '@server/api/indexer/tvdb/interfaces'; +import cacheManager from '@server/lib/cache'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; + +class Tvdb extends ExternalAPI implements TvShowIndexer { + static instance: Tvdb; + private dateTokenExpires?: Date; + private pin?: string; + + private constructor(apiKey: string, pin?: string) { + super( + 'https://api4.thetvdb.com/v4', + { + apiKey: apiKey, + }, + { + nodeCache: cacheManager.getCache('tvdb').data, + rateLimit: { + maxRPS: 50, + id: 'tmdb', + }, + } + ); + this.pin = pin; + } + + public static async getInstance() { + if (!this.instance) { + const settings = getSettings(); + if (!settings.tvdb.apiKey) { + throw new Error('TVDB API key is not set'); + } + this.instance = new Tvdb(settings.tvdb.apiKey, settings.tvdb.pin); + await this.instance.login(); + logger.info( + 'Tvdb instance created with token => ' + + this.instance.defaultHeaders.Authorization + ); + } + return this.instance; + } + + async login() { + try { + const res = await this.post('/login', { + apiKey: this.params.apiKey, + pin: this.pin, + }); + this.defaultHeaders.Authorization = `Bearer ${res.data.token}`; + this.dateTokenExpires = new Date(); + this.dateTokenExpires.setMonth(this.dateTokenExpires.getMonth() + 1); + return res; + } catch (error) { + throw new Error(`[TVDB] Login failed: ${error.message}`); + } + } + + public getTvShow = async ({ + tvId, + language = 'en', + }: { + tvId: number; + language?: string; + }): Promise => { + try { + const tmdb = new TheMovieDb(); + const tmdbTvShow = await tmdb.getTvShow({ tvId: tvId }); + + const tvdbId = tmdbTvShow.external_ids.tvdb_id; + + if (!tvdbId) { + return tmdbTvShow; + } + + const data = await this.get( + `/series/${tvdbId}/extended`, + { + short: 'true', + }, + 43200 + ); + + const correctSeasons = data.data.seasons.filter( + (season: TvdbSeasonDetails) => + season.id && season.number > 0 && season.type.name === 'Aired Order' + ); + + tmdbTvShow.seasons = []; + + for (const season of correctSeasons) { + if (season.id) { + logger.info(`Fetching TV season ${season.id}`); + + try { + const tvdbSeason = await this.getTvSeason({ + tvId: tvdbId, + seasonNumber: season.id, + language, + }); + const seasonData = { + id: season.id, + episode_count: tvdbSeason.episodes.length, + name: tvdbSeason.name, + overview: tvdbSeason.overview, + season_number: season.number, + poster_path: '', + air_date: '', + image: tvdbSeason.poster_path, + }; + + tmdbTvShow.seasons.push(seasonData); + } catch (error) { + logger.error( + `Failed to get season ${season.id} for TV show ${tvdbId}: ${error.message}`, + { + label: 'Tvdb', + message: `Failed to get season ${season.id} for TV show ${tvdbId}`, + } + ); + } + } + } + + return tmdbTvShow; + } catch (error) { + throw new Error( + `[TVDB] Failed to fetch TV show details: ${error.message}` + ); + } + }; + + getEpisode = async ( + episodeId: number, + language: string + ): Promise => { + try { + const tvdbEpisode = await this.get( + `/episodes/${episodeId}/translations/${language}`, + {}, + 43200 + ); + + return tvdbEpisode; + } catch (error) { + throw new Error( + `[TVDB] Failed to fetch TV episode details: ${error.message}` + ); + } + }; + + public getTvSeason = async ({ + tvId, + seasonNumber, + language = 'en', + }: { + tvId: number; + seasonNumber: number; + language?: string; + }): Promise => { + if (seasonNumber === 0) { + return { + episodes: [], + external_ids: { + tvdb_id: tvId, + }, + name: '', + overview: '', + id: seasonNumber, + air_date: '', + season_number: 0, + }; + } + try { + const tvdbSeason = await this.get( + `/seasons/${seasonNumber}/extended`, + { lang: language }, + 43200 + ); + + const episodes = tvdbSeason.data.episodes.map((episode) => ({ + id: episode.id, + air_date: episode.aired, + episode_number: episode.number, + name: episode.name, + overview: episode.overview || '', + season_number: episode.seasonNumber, + production_code: '', + show_id: tvId, + still_path: episode.image, + vote_average: 1, + vote_cuont: 1, + })); + + return { + episodes: episodes, + external_ids: { + tvdb_id: tvdbSeason.seriesId, + }, + name: '', + overview: '', + id: tvdbSeason.id, + air_date: tvdbSeason.year, + season_number: tvdbSeason.number, + }; + } catch (error) { + throw new Error( + `[TVDB] Failed to fetch TV season details: ${error.message}` + ); + } + }; +} + +export default Tvdb; diff --git a/server/api/indexer/tvdb/interfaces.ts b/server/api/indexer/tvdb/interfaces.ts new file mode 100644 index 00000000..c45e47a9 --- /dev/null +++ b/server/api/indexer/tvdb/interfaces.ts @@ -0,0 +1,143 @@ +export interface TvdbBaseResponse { + data: T; + errors: any; +} + +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[]; +} + +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; +} + +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[]; +} + +export interface TvdbEpisodeTranslation + extends TvdbBaseResponse { + name: string; + overview: string; + language: string; +} diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7b..9b9f7de2 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,3 +1,5 @@ +import TheMovieDb from '@server/api/indexer/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants'; import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; import type { @@ -5,8 +7,6 @@ import type { SonarrSeries, } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import TheMovieDb from '@server/api/themoviedb'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { MediaRequestStatus, MediaStatus, diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index ea4a7d33..ae6d793b 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -36,6 +36,9 @@ export class UserSettings { @Column({ nullable: true }) public originalLanguage?: string; + @Column({ nullable: true }) + public tvdbToken?: string; + @Column({ nullable: true }) public pgpKey?: string; diff --git a/server/entity/Watchlist.ts b/server/entity/Watchlist.ts index df820120..afc249d9 100644 --- a/server/entity/Watchlist.ts +++ b/server/entity/Watchlist.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 011205e7..6cd17286 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -8,7 +8,8 @@ export type AvailableCacheIds = | 'imdb' | 'github' | 'plexguid' - | 'plextv'; + | 'plextv' + | 'tvdb'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -68,6 +69,10 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60, }), + tvdb: new Cache('tvdb', 'The TVDB API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f0f3db7e..6f8ae9b7 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index baa8d963..6fd903f8 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -1,7 +1,7 @@ +import TheMovieDb from '@server/api/indexer/themoviedb'; +import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces'; import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; -import TheMovieDb from '@server/api/themoviedb'; -import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f074872b..2c0433bf 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -1,7 +1,7 @@ import animeList from '@server/api/animelist'; +import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces'; import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; -import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import cacheManager from '@server/lib/cache'; diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 3256c948..e64fe620 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -1,6 +1,6 @@ +import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces'; import type { SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import type { diff --git a/server/lib/search.ts b/server/lib/search.ts index be9ee3ae..68d1743a 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import type { TmdbMovieDetails, TmdbMovieResult, @@ -9,7 +9,7 @@ import type { TmdbSearchTvResponse, TmdbTvDetails, TmdbTvResult, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import { mapMovieDetailsToResult, mapPersonDetailsToResult, diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 7c117c11..acac91c3 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -77,6 +77,12 @@ export interface DVRSettings { tagRequests: boolean; } +export interface TvdbSettings { + apiKey?: string; + pin?: string; + use: boolean; +} + export interface RadarrSettings extends DVRSettings { minimumAvailability: string; } @@ -285,6 +291,7 @@ export interface AllSettings { plex: PlexSettings; jellyfin: JellyfinSettings; tautulli: TautulliSettings; + tvdb: TvdbSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; public: PublicSettings; @@ -344,6 +351,7 @@ class Settings { serverId: '', }, tautulli: {}, + tvdb: { use: false }, radarr: [], sonarr: [], public: { @@ -512,6 +520,14 @@ class Settings { this.data.tautulli = data; } + get tvdb(): TvdbSettings { + return this.data.tvdb; + } + + set tvdb(data: TvdbSettings) { + this.data.tvdb = data; + } + get radarr(): RadarrSettings[] { return this.data.radarr; } diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 20a3c715..0ac044bb 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,4 +1,4 @@ -import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; +import type { TmdbCollection } from '@server/api/indexer/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; import { sortBy } from 'lodash'; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 0b627859..077b5844 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -2,7 +2,7 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, TmdbProductionCompany, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import type Media from '@server/entity/Media'; import type { Cast, diff --git a/server/models/Person.ts b/server/models/Person.ts index 998585ee..f950215b 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -2,7 +2,7 @@ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetails, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import type Media from '@server/entity/Media'; export interface PersonDetails { diff --git a/server/models/Search.ts b/server/models/Search.ts index 2193bbe1..d4c94b27 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -6,7 +6,7 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import { MediaType as MainMediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 24362b50..0ba61ea6 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -5,7 +5,7 @@ import type { TmdbTvEpisodeResult, TmdbTvRatingResult, TmdbTvSeasonResult, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import type Media from '@server/entity/Media'; import type { Cast, diff --git a/server/models/common.ts b/server/models/common.ts index 30b40d98..40e3a120 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -7,7 +7,7 @@ import type { TmdbVideoResult, TmdbWatchProviderDetails, TmdbWatchProviders, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import type { Video } from '@server/models/Movie'; export interface ProductionCompany { diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 8b1cd9ef..6cb48d2e 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { mapCollection } from '@server/models/Collection'; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 9590d32b..576e5b03 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,7 +1,7 @@ +import type { SortOptions } from '@server/api/indexer/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; +import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces'; import PlexTvAPI from '@server/api/plextv'; -import type { SortOptions } from '@server/api/themoviedb'; -import TheMovieDb from '@server/api/themoviedb'; -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'; diff --git a/server/routes/index.ts b/server/routes/index.ts index 12434256..831d3e74 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,10 +1,10 @@ import GithubAPI from '@server/api/github'; -import PushoverAPI from '@server/api/pushover'; -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import type { TmdbMovieResult, TmdbTvResult, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; +import PushoverAPI from '@server/api/pushover'; import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; diff --git a/server/routes/media.ts b/server/routes/media.ts index 60191e5d..bf1d4c0f 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,7 +1,7 @@ +import TheMovieDb from '@server/api/indexer/themoviedb'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TautulliAPI from '@server/api/tautulli'; -import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; diff --git a/server/routes/movie.ts b/server/routes/movie.ts index b48ae9ea..9ee2ca5a 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,7 +1,7 @@ +import TheMovieDb from '@server/api/indexer/themoviedb'; import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy'; import RottenTomatoes from '@server/api/rating/rottentomatoes'; import { type RatingResponse } from '@server/api/ratings'; -import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; import logger from '@server/logger'; diff --git a/server/routes/person.ts b/server/routes/person.ts index 7462328c..5c2a9a47 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { diff --git a/server/routes/search.ts b/server/routes/search.ts index ee2fd9eb..356b9ff8 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,5 +1,5 @@ -import TheMovieDb from '@server/api/themoviedb'; -import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; +import TheMovieDb from '@server/api/indexer/themoviedb'; +import type { TmdbSearchMultiResponse } from '@server/api/indexer/themoviedb/interfaces'; import Media from '@server/entity/Media'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; diff --git a/server/routes/service.ts b/server/routes/service.ts index 083e1eb5..6f9cac67 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -1,6 +1,6 @@ +import TheMovieDb from '@server/api/indexer/themoviedb'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import TheMovieDb from '@server/api/themoviedb'; import type { ServiceCommonServer, ServiceCommonServerWithDetails, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 9e1a6220..8ad34338 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -41,6 +41,7 @@ import { URL } from 'url'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; +import tvdbRoutes from './tvdb'; const settingsRoutes = Router(); @@ -48,6 +49,7 @@ settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); +settingsRoutes.use('/tvdb', tvdbRoutes); const filteredMainSettings = ( user: User, diff --git a/server/routes/settings/tvdb.ts b/server/routes/settings/tvdb.ts new file mode 100644 index 00000000..d3b6f341 --- /dev/null +++ b/server/routes/settings/tvdb.ts @@ -0,0 +1,46 @@ +import Tvdb from '@server/api/indexer/tvdb'; +import type { TvdbSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const tvdbRoutes = Router(); + +tvdbRoutes.get('/', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.tvdb); +}); + +tvdbRoutes.put('/', (req, res) => { + const settings = getSettings(); + + const newTvdb = req.body as TvdbSettings; + const tvdb = settings.tvdb; + + tvdb.apiKey = newTvdb.apiKey; + tvdb.pin = newTvdb.pin; + tvdb.use = newTvdb.use; + + settings.tvdb = tvdb; + settings.save(); + + return res.status(200).json(newTvdb); +}); + +tvdbRoutes.post('/test', async (req, res, next) => { + try { + const tvdb = await Tvdb.getInstance(); + await tvdb.login(); + return res.status(200).json({ message: 'Successfully connected to Tvdb' }); + } 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 tvdbRoutes; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index cd69c13a..382b012d 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,7 +1,9 @@ +import TheMovieDb from '@server/api/indexer/themoviedb'; +import Tvdb from '@server/api/indexer/tvdb'; import RottenTomatoes from '@server/api/rating/rottentomatoes'; -import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; @@ -10,7 +12,13 @@ import { Router } from 'express'; const tvRoutes = Router(); tvRoutes.get('/:id', async (req, res, next) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + let tmdb; + if (settings.tvdb.use) { + tmdb = await Tvdb.getInstance(); + } else { + tmdb = new TheMovieDb(); + } try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.id), @@ -33,14 +41,22 @@ tvRoutes.get('/:id', async (req, res, next) => { } }); -tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { - const tmdb = new TheMovieDb(); - +tvRoutes.get('/:id/season/:seasonNumber/:seasonId', async (req, res, next) => { try { + const settings = getSettings(); + let tmdb; + let seasonIdentifier; + if (settings.tvdb.use) { + tmdb = await Tvdb.getInstance(); + seasonIdentifier = req.params.seasonId; + } else { + tmdb = new TheMovieDb(); + seasonIdentifier = req.params.seasonNumber; + } + const season = await tmdb.getTvSeason({ tvId: Number(req.params.id), - seasonNumber: Number(req.params.seasonNumber), - language: (req.query.language as string) ?? req.locale, + seasonNumber: Number(seasonIdentifier), }); return res.status(200).json(mapSeasonWithEpisodes(season)); diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 71db981d..c4758dd8 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index d54523cf..bfdd405c 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import Issue from '@server/entity/Issue'; diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index eecfe6f3..ec644309 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -1,4 +1,4 @@ -import TheMovieDb from '@server/api/themoviedb'; +import TheMovieDb from '@server/api/indexer/themoviedb'; import { MediaRequestStatus, MediaStatus, diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 548378ff..6153ed9c 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -6,7 +6,7 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; export const isMovie = ( movie: diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index 1d558faa..7756370b 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -9,7 +9,7 @@ import type { TmdbCompanySearchResponse, TmdbGenre, TmdbKeywordSearchResponse, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import { DiscoverSliderType } from '@server/constants/discover'; import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; diff --git a/src/components/Discover/DiscoverMovieKeyword/index.tsx b/src/components/Discover/DiscoverMovieKeyword/index.tsx index 830b95df..dee8d18c 100644 --- a/src/components/Discover/DiscoverMovieKeyword/index.tsx +++ b/src/components/Discover/DiscoverMovieKeyword/index.tsx @@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; -import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces'; import type { MovieResult } from '@server/models/Search'; import { useRouter } from 'next/router'; import { useIntl } from 'react-intl'; diff --git a/src/components/Discover/DiscoverMovies/index.tsx b/src/components/Discover/DiscoverMovies/index.tsx index 117ecb5b..14906579 100644 --- a/src/components/Discover/DiscoverMovies/index.tsx +++ b/src/components/Discover/DiscoverMovies/index.tsx @@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; -import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; +import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb'; import type { MovieResult } from '@server/models/Search'; import { useRouter } from 'next/router'; import { useState } from 'react'; diff --git a/src/components/Discover/DiscoverTv/index.tsx b/src/components/Discover/DiscoverTv/index.tsx index 1a4ce668..532eab66 100644 --- a/src/components/Discover/DiscoverTv/index.tsx +++ b/src/components/Discover/DiscoverTv/index.tsx @@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; -import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; +import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb'; import type { TvResult } from '@server/models/Search'; import { useRouter } from 'next/router'; import { useState } from 'react'; diff --git a/src/components/Discover/DiscoverTvKeyword/index.tsx b/src/components/Discover/DiscoverTvKeyword/index.tsx index a9719fd2..89c45ba1 100644 --- a/src/components/Discover/DiscoverTvKeyword/index.tsx +++ b/src/components/Discover/DiscoverTvKeyword/index.tsx @@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; -import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces'; import type { TvResult } from '@server/models/Search'; import { useRouter } from 'next/router'; import { useIntl } from 'react-intl'; diff --git a/src/components/GenreTag/index.tsx b/src/components/GenreTag/index.tsx index bbb25afe..ef315e95 100644 --- a/src/components/GenreTag/index.tsx +++ b/src/components/GenreTag/index.tsx @@ -1,7 +1,7 @@ import Spinner from '@app/assets/spinner.svg'; import Tag from '@app/components/Common/Tag'; import { RectangleStackIcon } from '@heroicons/react/24/outline'; -import type { TmdbGenre } from '@server/api/themoviedb/interfaces'; +import type { TmdbGenre } from '@server/api/indexer/themoviedb/interfaces'; import useSWR from 'swr'; type GenreTagProps = { diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 4e847294..14c6ce81 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -9,7 +9,7 @@ import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type SeasonRequest from '@server/entity/SeasonRequest'; diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index ba40c991..33f3c0f9 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -11,7 +11,7 @@ import type { TmdbCompanySearchResponse, TmdbGenre, TmdbKeywordSearchResponse, -} from '@server/api/themoviedb/interfaces'; +} from '@server/api/indexer/themoviedb/interfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { Keyword, diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index 3d201ff0..40342dcf 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -39,6 +39,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => { route: '/settings/users', regex: /^\/settings\/users/, }, + { + text: 'Tvdb', + route: '/settings/tvdb', + regex: /^\/settings\/tvdb/, + }, settings.currentSettings.mediaServerType === MediaServerType.PLEX ? { text: intl.formatMessage(messages.menuPlexSettings), diff --git a/src/components/Settings/SettingsTvdb.tsx b/src/components/Settings/SettingsTvdb.tsx new file mode 100644 index 00000000..b2149e6f --- /dev/null +++ b/src/components/Settings/SettingsTvdb.tsx @@ -0,0 +1,273 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; +import CopyButton from '@app/components/Settings/CopyButton'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; +import { ArrowPathIcon } from '@heroicons/react/24/solid'; +import type { TvdbSettings } from '@server/lib/settings'; +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', + enable: 'Enable', + enableTip: 'Enable Tvdb (only for season and episode)', +}); + +/*interface SettingsTvdbProps { + onEdit: () => void; +}*/ + +const SettingsTvdb = () => { + const intl = useIntl(); + const [isTesting, setIsTesting] = useState(false); + + const { addToast } = useToasts(); + + const testConnection = async (apiKey: string | undefined, pin?: string) => { + const response = await fetch('/api/v1/settings/tvdb/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ apiKey, pin }), + }); + + if (!response.ok) { + throw new Error('Failed to test Tvdb'); + } + }; + + const saveSettings = async (values: TvdbSettings) => { + const response = await fetch('/api/v1/settings/tvdb', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }); + + if (!response.ok) { + throw new Error('Failed to save Tvdb settings'); + } + }; + + const { data, error } = useSWR('/api/v1/settings/tvdb'); + + if (!data && !error) { + return ; + } + + return ( + <> + +
+

{'Tvdb'}

+

{'Settings for Tvdb'}

+
+
+ { + if (values.enable && values.apiKey === '') { + addToast('Please enter an API key', { appearance: 'error' }); + return; + } + + try { + setIsTesting(true); + await testConnection(values.apiKey, values.pin); + setIsTesting(false); + } catch (e) { + addToast('Tvdb connection error, check your credentials', { + appearance: 'error', + }); + return; + } + + try { + await saveSettings({ + apiKey: values.apiKey, + pin: values.pin, + use: values.enable || false, + }); + } catch (e) { + addToast('Failed to save Tvdb settings', { appearance: 'error' }); + return; + } + addToast('Tvdb settings saved', { appearance: 'success' }); + }} + > + {({ + errors, + touched, + isSubmitting, + isValid, + values, + setFieldValue, + }) => { + return ( +
+
+ +
+
+ { + setFieldValue('apiKey', e.target.value); + }} + /> + + +
+
+
+ +
+ +
+
+ { + values.pin = e.target.value; + }} + /> + + +
+
+
+ +
+ +
+ { + setFieldValue('enable', !values.enable); + addToast('Tvdb connection successful', { + appearance: 'success', + }); + }} + /> +
+ {errors.apiKey && + touched.apiKey && + typeof errors.apiKey === 'string' && ( +
{errors.apiKey}
+ )} +
+ +
+
+ + + + + + +
+
+
+ ); + }} +
+
+ + ); +}; + +export default SettingsTvdb; diff --git a/src/components/TvDetails/Season/index.tsx b/src/components/TvDetails/Season/index.tsx index 09b2b639..36d25448 100644 --- a/src/components/TvDetails/Season/index.tsx +++ b/src/components/TvDetails/Season/index.tsx @@ -14,12 +14,13 @@ const messages = defineMessages('components.TvDetails.Season', { type SeasonProps = { seasonNumber: number; tvId: number; + seasonId: number; }; -const Season = ({ seasonNumber, tvId }: SeasonProps) => { +const Season = ({ seasonNumber, tvId, seasonId }: SeasonProps) => { const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/tv/${tvId}/season/${seasonNumber}` + `/api/v1/tv/${tvId}/season/${seasonNumber}/${seasonId}` ); if (!data && !error) { diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 1b21a4a3..4e2000c1 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -41,8 +41,8 @@ import { PlayIcon, } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; +import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants'; import type { RTRating } from '@server/api/rating/rottentomatoes'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { IssueStatus } from '@server/constants/issue'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; @@ -792,6 +792,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { diff --git a/src/pages/settings/tvdb.tsx b/src/pages/settings/tvdb.tsx new file mode 100644 index 00000000..e4142d40 --- /dev/null +++ b/src/pages/settings/tvdb.tsx @@ -0,0 +1,16 @@ +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +import SettingsTvdb from '@app/components/Settings/SettingsTvdb'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const TvdbSettingsPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + ); +}; + +export default TvdbSettingsPage;