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