diff --git a/web/app/api/tmdb/[...path]/route.ts b/web/app/api/tmdb/[...path]/route.ts index d3595bbcc..68a5db7b7 100644 --- a/web/app/api/tmdb/[...path]/route.ts +++ b/web/app/api/tmdb/[...path]/route.ts @@ -12,11 +12,13 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat "https://auth.arvio.tv/.netlify/functions" ).replace(/\/+$/, ""); const appAnonKey = envValue(process.env.NEXT_PUBLIC_ARVIO_APP_ANON_KEY, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""); - const tmdbKey = process.env.TMDB_API_KEY ?? ""; const input = new URL(request.url); + const customKey = (request.headers.get("x-tmdb-api-key") ?? "").trim(); + const tmdbKey = customKey || process.env.TMDB_API_KEY || ""; + input.searchParams.delete("api_key"); let target: URL; - const usesNetlifyProxy = netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40; + const usesNetlifyProxy = !customKey && netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40; if (usesNetlifyProxy) { target = new URL(`${netlifyBackendUrl}/tmdb-proxy`); target.searchParams.set("path", `/${path.join("/")}`); @@ -41,7 +43,7 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat Authorization: `Bearer ${appAnonKey}` } : undefined, - next: { revalidate: 86400 }, + next: { revalidate: customKey ? 0 : 86400 }, signal: AbortSignal.timeout(8000) }); } catch { @@ -53,7 +55,7 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat input.searchParams.forEach((value, key) => direct.searchParams.set(key, value)); direct.searchParams.set("api_key", tmdbKey); try { - response = await fetch(direct, { next: { revalidate: 86400 }, signal: AbortSignal.timeout(10000) }); + response = await fetch(direct, { next: { revalidate: customKey ? 0 : 86400 }, signal: AbortSignal.timeout(10000) }); } catch { response = null; } @@ -65,20 +67,17 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat const headers = new Headers(); headers.set("content-type", response.headers.get("content-type") ?? "application/json"); - // Short browser cache; long CDN cache. `durable` opts this response into - // Netlify's global cross-region cache so one upstream fetch serves users - // worldwide (not just per-edge-PoP) — the key scale lever. Without durable, - // a cold PoP re-invokes the function; with it, function hits stay flat as the - // user base grows. - headers.set("cache-control", "public, max-age=300, s-maxage=21600, stale-while-revalidate=21600"); - headers.set("netlify-cdn-cache-control", "public, durable, max-age=21600, stale-while-revalidate=21600"); - headers.set("cdn-cache-control", "public, max-age=21600, stale-while-revalidate=21600"); - // CRITICAL: cache key must vary by query string (else every discover/search - // variant collapses into one entry) but MUST NOT vary by the request-specific - // headers Next.js injects (x-nextjs-data|rsc|…) — those forced the durable - // cache to bypass. Setting query-only Vary is what lets durable engage. - headers.set("netlify-vary", "query"); - headers.set("vary", "Accept-Encoding"); + + if (customKey) { + headers.set("cache-control", "private, no-store"); + } else { + // Short browser cache; long CDN cache for shared system key requests. + headers.set("cache-control", "public, max-age=300, s-maxage=21600, stale-while-revalidate=21600"); + headers.set("netlify-cdn-cache-control", "public, durable, max-age=21600, stale-while-revalidate=21600"); + headers.set("cdn-cache-control", "public, max-age=21600, stale-while-revalidate=21600"); + headers.set("netlify-vary", "query"); + headers.set("vary", "Accept-Encoding"); + } return new NextResponse(response.body, { status: response.status, diff --git a/web/components/details/DetailsDrawer.tsx b/web/components/details/DetailsDrawer.tsx index 9dd152c1f..b0a642178 100644 --- a/web/components/details/DetailsDrawer.tsx +++ b/web/components/details/DetailsDrawer.tsx @@ -14,7 +14,7 @@ import { cachedDebridDirectUrl, isUncachedDebridStream, parseDebridStream, prefe import { canonicalServiceName, IMDB_LOGO, serviceClearLogo } from "@/lib/serviceLogos"; import { getImdbRating } from "@/lib/imdbRatings"; import { sourcePickerScore } from "@/lib/sourceRank"; -import { authClient, useApp } from "@/lib/store"; +import { authClient, getPriorityConfig, useApp } from "@/lib/store"; import { syncClient } from "@/lib/sync"; import { getDetails, getLogoUrl, getPersonDetails, getReviews, getSeasonEpisodes } from "@/lib/tmdb"; import type { EpisodeInfo, InstalledAddon, MediaItem, PersonCredit, PersonDetails, ReviewInfo, StreamSource, SubtitleTrack } from "@/lib/types"; @@ -46,6 +46,7 @@ function DetailsView({ item }: { item: MediaItem }) { const [sourcePickerVisible, setSourcePickerVisible] = useState(false); const [logo, setLogo] = useState(null); const displayItem = detailsItem ?? item; + const priorityConfig = useMemo(() => getPriorityConfig(settings), [settings]); useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); @@ -67,7 +68,7 @@ function DetailsView({ item }: { item: MediaItem }) { : Boolean(details.cast?.length || details.related?.length || details.trailerUrl); void (async () => { for (let attempt = 0; attempt < 3 && active; attempt += 1) { - const details = await getDetails(item).catch(() => null); + const details = await getDetails(item, priorityConfig).catch(() => null); if (!active) return; if (details) setDetailsItem(details); if (details && looksHydrated(details)) break; @@ -76,7 +77,7 @@ function DetailsView({ item }: { item: MediaItem }) { if (active) setDetailsLoading(false); })(); return () => { active = false; }; - }, [item.id, item.mediaType]); + }, [item, priorityConfig]); useEffect(() => { let active = true; @@ -871,12 +872,18 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl isWatched: (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => boolean; onPlayEpisode: (season: number, episode: number) => void; }) { - const { openContextMenu, setToast, toggleWatched } = useApp(); + const { openContextMenu, setToast, settings, toggleWatched } = useApp(); const seasons = item.seasons ?? []; const [season, setSeason] = useState(seasons[0]?.seasonNumber ?? 1); const [episodes, setEpisodes] = useState([]); const [loading, setLoading] = useState(false); const [retryNonce, setRetryNonce] = useState(0); + const priorityConfig = useMemo(() => getPriorityConfig(settings), [settings]); + const metadataContext = useMemo(() => ({ + tvdbId: item.tvdbId, + anilistId: item.anilistId, + isAnime: item.isAnime + }), [item.anilistId, item.isAnime, item.tvdbId]); useEffect(() => { if (seasons.length && !seasons.some((entry) => entry.seasonNumber === season)) { @@ -887,16 +894,16 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl useEffect(() => { let active = true; setLoading(true); - void getSeasonEpisodes(item.id, season) + void getSeasonEpisodes(item.id, season, "en-US", priorityConfig, metadataContext) .then((eps) => { if (active) setEpisodes(eps); }) .catch(() => undefined) .finally(() => active && setLoading(false)); return () => { active = false; }; - }, [item.id, season, retryNonce]); + }, [item.id, metadataContext, priorityConfig, retryNonce, season]); const updateSeasonWatched = async (seasonNum: number, watched: boolean) => { try { - const targetEpisodes = await getSeasonEpisodes(item.id, seasonNum); + const targetEpisodes = await getSeasonEpisodes(item.id, seasonNum, "en-US", priorityConfig, metadataContext); for (const ep of targetEpisodes) { if (isWatched(item, seasonNum, ep.episodeNumber) !== watched) { await toggleWatched(item, seasonNum, ep.episodeNumber); diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index f9737c5f5..c0446b0c2 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -109,8 +109,10 @@ const SECTIONS = [ { id: "telegram", label: "Telegram", icon: Send }, { id: "catalogs", label: "Catalogs", icon: ListVideo }, { id: "addons", label: "Addons", icon: Sparkles }, + { id: "metadata", label: "Metadata & Keys", icon: Sparkles }, ] as const; + type SectionId = (typeof SECTIONS)[number]["id"]; const SUBTITLE_COLOR_HEX: Record = { @@ -1125,11 +1127,77 @@ function SectionBody({ section }: { section: SectionId }) { return ; case "vlc": return ; + case "metadata": + return ; default: return null; } } +function MetadataSection({ settings, set }: { settings: AppSettings; set: (patch: Partial) => void }) { + const animeChain = (settings.metadataAnimeProviders || ["anilist", "tvdb", "tmdb"]).join(" → ").toUpperCase(); + const tvChain = (settings.metadataTvProviders || ["tvdb", "tmdb"]).join(" → ").toUpperCase(); + const movieChain = (settings.metadataMovieProviders || ["tmdb"]).join(" → ").toUpperCase(); + const tvdbActive = Boolean(settings.customTvdbApiKey?.trim()); + + return ( +
+ + + set({ customTmdbApiKey: e.target.value })} + /> + + + + set({ customTvdbApiKey: e.target.value })} + /> + + + + set({ customTvdbUserPin: e.target.value })} + /> + + + + + + {animeChain} + + + + {tvChain} + + + + {movieChain} + + +
+ ); +} + + function safeArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } diff --git a/web/lib/cloud.ts b/web/lib/cloud.ts index 780747487..b3dcce8db 100644 --- a/web/lib/cloud.ts +++ b/web/lib/cloud.ts @@ -663,6 +663,9 @@ export async function pullCloudPayload(auth: AuthClient, profileId?: string | nu const hiddenCatalogIds = scopedValue(root, "hiddenPreinstalledByProfile", profileId); const profileAddons = scopedValue(root, "addonsByProfile", profileId); const legacySettings = objectRecord(root.settings) as Partial; + delete legacySettings.customTmdbApiKey; + delete legacySettings.customTvdbApiKey; + delete legacySettings.customTvdbUserPin; const legacyCatalogs = arrayValue(root.catalogs) as AppSettings["catalogs"]; const legacyHiddenCatalogIds = arrayValue(root.hiddenPreinstalledCatalogs); // Canonical, timestamp-managed GLOBAL settings live at the top level of the payload (written by @@ -752,7 +755,8 @@ export async function saveCloudSettings( // exclusively through saveCloudAddons (merge-protected). A stale session's // partial in-memory addon list must never leak into the shared payload. void addons; - root.settings = settings; + const { customTmdbApiKey: _k1, customTvdbApiKey: _k2, customTvdbUserPin: _k3, ...sanitizedSettings } = settings; + root.settings = sanitizedSettings; // ── Genuine global settings that Android merges by per-field timestamp. Only write + bump the // timestamp when the web actually changed the field vs its baseline; otherwise leave the diff --git a/web/lib/metadata/anilist.ts b/web/lib/metadata/anilist.ts new file mode 100644 index 000000000..2a1347326 --- /dev/null +++ b/web/lib/metadata/anilist.ts @@ -0,0 +1,135 @@ +import type { MediaItem } from "../types"; +import type { MetadataMediaType, MetadataResolver } from "./types"; + +const ANILIST_GRAPHQL_ENDPOINT = "https://graphql.anilist.co"; + +const MEDIA_QUERY = ` +query ($id: Int, $search: String) { + Media(id: $id, search: $search, type: ANIME) { + id + idMal + title { + romaji + english + native + } + description + bannerImage + coverImage { + extraLarge + large + medium + color + } + format + status + episodes + duration + averageScore + popularity + genres + season + seasonYear + studios(isMain: true) { + nodes { + id + name + } + } + } +} +`; + +const SEARCH_QUERY = ` +query ($search: String) { + Page(page: 1, perPage: 20) { + media(search: $search, type: ANIME) { + id + idMal + title { + romaji + english + native + } + description + bannerImage + coverImage { + large + medium + } + averageScore + seasonYear + episodes + } + } +} +`; + +function mapAniListToMediaItem(media: any): MediaItem { + const title = media.title?.english || media.title?.romaji || media.title?.native || "Untitled Anime"; + const poster = media.coverImage?.extraLarge || media.coverImage?.large || media.coverImage?.medium || null; + const rating = media.averageScore ? (media.averageScore / 10).toFixed(1) : undefined; + + return { + id: media.id, + anilistId: media.id, + title, + subtitle: media.title?.romaji !== title ? media.title?.romaji : undefined, + overview: media.description ? media.description.replace(/<[^>]*>?/gm, "") : "", + year: media.seasonYear ? String(media.seasonYear) : undefined, + rating, + duration: media.duration ? `${media.duration}m` : undefined, + mediaType: "tv", + isAnime: true, + image: poster ?? undefined, + backdrop: media.bannerImage ?? poster ?? null, + badge: media.format ?? "ANIME", + genres: media.genres ?? [], + status: media.status, + numberOfEpisodes: media.episodes ?? null + }; +} + +export const aniListResolver: MetadataResolver = { + id: "anilist", + name: "AniList", + supportedTypes: ["anime"], + + async getDetails(id: string | number, _mediaType?: MetadataMediaType): Promise { + try { + const isNumeric = !isNaN(Number(id)); + const variables = isNumeric ? { id: Number(id) } : { search: String(id) }; + + const res = await fetch(ANILIST_GRAPHQL_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ query: MEDIA_QUERY, variables }) + }); + + if (!res.ok) return null; + const json = await res.json(); + if (!json.data?.Media) return null; + + return mapAniListToMediaItem(json.data.Media); + } catch { + return null; + } + }, + + async search(query: string, _mediaType?: MetadataMediaType): Promise { + try { + const res = await fetch(ANILIST_GRAPHQL_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ query: SEARCH_QUERY, variables: { search: query } }) + }); + + if (!res.ok) return []; + const json = await res.json(); + const items = json.data?.Page?.media ?? []; + return items.map(mapAniListToMediaItem); + } catch { + return []; + } + } +}; diff --git a/web/lib/metadata/anizip.ts b/web/lib/metadata/anizip.ts new file mode 100644 index 000000000..204df8c27 --- /dev/null +++ b/web/lib/metadata/anizip.ts @@ -0,0 +1,41 @@ +export interface AniZipMapping { + anilistId?: number; + malId?: number; + tvdbId?: number; + tmdbId?: number; + imdbId?: string; +} + +function numericId(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +async function fetchAniZipMappingBy(query: "anilist_id" | "themoviedb_id", id: number): Promise { + try { + const res = await fetch(`https://api.ani.zip/mappings?${query}=${id}`); + if (!res.ok) return null; + + const data = await res.json(); + const mappings = data.mappings; + if (!mappings) return null; + + return { + anilistId: numericId(mappings.anilist_id) ?? (query === "anilist_id" ? id : undefined), + malId: numericId(mappings.mal_id), + tvdbId: numericId(mappings.thetvdb_id), + tmdbId: numericId(mappings.themoviedb_id) ?? (query === "themoviedb_id" ? id : undefined), + imdbId: mappings.imdb_id, + }; + } catch { + return null; + } +} + +export function fetchAniZipMapping(anilistId: number): Promise { + return fetchAniZipMappingBy("anilist_id", anilistId); +} + +export function fetchAniZipMappingByTmdbId(tmdbId: number): Promise { + return fetchAniZipMappingBy("themoviedb_id", tmdbId); +} diff --git a/web/lib/metadata/dispatcher.ts b/web/lib/metadata/dispatcher.ts new file mode 100644 index 000000000..ea1aa3d88 --- /dev/null +++ b/web/lib/metadata/dispatcher.ts @@ -0,0 +1,90 @@ +import type { EpisodeInfo, MediaItem } from "../types"; +import { aniListResolver } from "./anilist"; +import { tvdbResolver } from "./tvdb"; +import { tmdbResolver } from "./tmdbResolver"; +import type { MetadataLookup, MetadataMediaType, MetadataProviderId, MetadataResolver, ProviderPriorityConfig } from "./types"; + +export class MetadataDispatcher { + private static resolvers: Record = { + anilist: aniListResolver, + tvdb: tvdbResolver, + tmdb: tmdbResolver + }; + + static registerResolver(resolver: MetadataResolver) { + this.resolvers[resolver.id] = resolver; + } + + static getPriorityList(type: MetadataMediaType, config?: ProviderPriorityConfig): MetadataProviderId[] { + if (type === "anime") { + return config?.animeProviders ?? ["anilist", "tvdb", "tmdb"]; + } + if (type === "tv") { + return config?.tvProviders ?? ["tvdb", "tmdb"]; + } + return config?.movieProviders ?? ["tmdb"]; + } + + static async getDetails( + lookup: MetadataLookup, + type: MetadataMediaType, + config?: ProviderPriorityConfig + ): Promise { + const priority = this.getPriorityList(type, config); + + for (const providerId of priority) { + const resolver = this.resolvers[providerId]; + if (!resolver || !resolver.supportedTypes.includes(type)) continue; + + const id = typeof lookup === "object" ? lookup[providerId] : lookup; + if (id == null || id === "") continue; + const result = await resolver.getDetails(id, type, config).catch(() => null); + if (result) { + return result; + } + } + return null; + } + + static async getEpisodes( + lookup: MetadataLookup, + type: MetadataMediaType, + seasonNumber = 1, + config?: ProviderPriorityConfig + ): Promise { + const priority = this.getPriorityList(type, config); + + for (const providerId of priority) { + const resolver = this.resolvers[providerId]; + if (!resolver || !resolver.getEpisodes || !resolver.supportedTypes.includes(type)) continue; + + const id = typeof lookup === "object" ? lookup[providerId] : lookup; + if (id == null || id === "") continue; + const episodes = await resolver.getEpisodes(id, seasonNumber, config).catch(() => []); + if (episodes && episodes.length > 0) { + return episodes; + } + } + return []; + } + + static async search( + query: string, + type: MetadataMediaType, + config?: ProviderPriorityConfig + ): Promise { + const priority = this.getPriorityList(type, config); + + for (const providerId of priority) { + const resolver = this.resolvers[providerId]; + if (!resolver || !resolver.supportedTypes.includes(type)) continue; + + const results = await resolver.search(query, type, config).catch(() => []); + if (results && results.length > 0) { + return results; + } + } + + return []; + } +} diff --git a/web/lib/metadata/tmdbResolver.ts b/web/lib/metadata/tmdbResolver.ts new file mode 100644 index 000000000..32300ccb4 --- /dev/null +++ b/web/lib/metadata/tmdbResolver.ts @@ -0,0 +1,27 @@ +import type { EpisodeInfo, MediaItem } from "../types"; +import { getBasicItem, getSeasonEpisodes, searchMedia } from "../tmdb"; +import type { MetadataMediaType, MetadataResolver, ProviderPriorityConfig } from "./types"; + +export const tmdbResolver: MetadataResolver = { + id: "tmdb", + name: "TMDB", + supportedTypes: ["movie", "tv", "anime"], + + async getDetails(id: string | number, mediaType?: MetadataMediaType, _options?: ProviderPriorityConfig): Promise { + const numericId = Number(id); + if (isNaN(numericId) || numericId <= 0) return null; + + const targetType = mediaType === "movie" ? "movie" : "tv"; + return getBasicItem(targetType, numericId).catch(() => null); + }, + + async getEpisodes(id: string | number, seasonNumber = 1, _options?: ProviderPriorityConfig): Promise { + const numericId = Number(id); + if (isNaN(numericId) || numericId <= 0) return []; + return getSeasonEpisodes(numericId, seasonNumber).catch(() => []); + }, + + async search(query: string, _mediaType?: MetadataMediaType, _options?: ProviderPriorityConfig): Promise { + return searchMedia(query).catch(() => []); + } +}; diff --git a/web/lib/metadata/tvdb.ts b/web/lib/metadata/tvdb.ts new file mode 100644 index 000000000..cd0671628 --- /dev/null +++ b/web/lib/metadata/tvdb.ts @@ -0,0 +1,160 @@ +import type { EpisodeInfo, MediaItem } from "../types"; +import type { MetadataMediaType, MetadataResolver, ProviderPriorityConfig } from "./types"; + + +const TVDB_API_BASE = "https://api4.thetvdb.com/v4"; + +let cachedKey: string | null = null; +let cachedPin: string | null = null; +let cachedToken: string | null = null; +let tokenExpiresAt = 0; + +async function getTvdbToken(apiKey?: string, pin?: string): Promise { + const cleanKey = apiKey?.trim(); + if (!cleanKey) { + // TVDB is disabled unless user supplies a custom API key + return null; + } + const cleanPin = pin?.trim() || undefined; + + const now = Date.now(); + if (cachedToken && now < tokenExpiresAt && cachedKey === cleanKey && cachedPin === cleanPin) { + return cachedToken; + } + + try { + const res = await fetch(`${TVDB_API_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apikey: cleanKey, pin: cleanPin }) + }); + + if (!res.ok) return null; + const json = await res.json(); + if (json.data?.token) { + cachedKey = cleanKey; + cachedPin = cleanPin ?? null; + cachedToken = json.data.token; + tokenExpiresAt = now + 23 * 3600 * 1000; // Cache 23 hours + return cachedToken; + } + } catch {} + return null; +} + + +export const tvdbResolver: MetadataResolver = { + id: "tvdb", + name: "TheTVDB", + supportedTypes: ["tv", "anime"], + + async getDetails(id: string | number, _mediaType?: MetadataMediaType, options?: ProviderPriorityConfig): Promise { + const token = await getTvdbToken(options?.customTvdbApiKey, options?.customTvdbUserPin); + if (!token) return null; + + try { + const res = await fetch(`${TVDB_API_BASE}/series/${id}/extended`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!res.ok) return null; + const json = await res.json(); + const series = json.data; + if (!series) return null; + + return { + id: series.id, + title: series.name ?? "Untitled Series", + overview: series.overview ?? "", + year: series.firstAired ? series.firstAired.slice(0, 4) : undefined, + rating: series.score ? String(series.score) : undefined, + mediaType: "tv", + image: series.image ?? undefined, + backdrop: series.image ?? null, + genres: (series.genres ?? []).map((g: any) => g.name), + status: series.status?.name ?? null + }; + } catch { + return null; + } + }, + + async getEpisodes(id: string | number, seasonNumber = 1, options?: ProviderPriorityConfig): Promise { + const token = await getTvdbToken(options?.customTvdbApiKey, options?.customTvdbUserPin); + if (!token) return []; + + try { + const allEpisodes: any[] = []; + let page = 0; + let totalPages = 1; + + while (page < totalPages && page < 50) { + const res = await fetch(`${TVDB_API_BASE}/series/${id}/episodes/default?page=${page}`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!res.ok) break; + const json = await res.json(); + const pageEpisodes = Array.isArray(json.data?.episodes) + ? json.data.episodes + : Array.isArray(json.data) + ? json.data + : []; + if (pageEpisodes.length > 0) { + allEpisodes.push(...pageEpisodes); + } else { + break; + } + + if (json.links?.next) { + totalPages = typeof json.links.total_pages === "number" ? json.links.total_pages : page + 2; + page += 1; + } else { + break; + } + } + + return allEpisodes + .filter((ep: any) => ep.seasonNumber === seasonNumber) + .map((ep: any) => ({ + id: ep.id, + episodeNumber: ep.number ?? 0, + seasonNumber: ep.seasonNumber ?? 0, + name: ep.name ?? `Episode ${ep.number}`, + overview: ep.overview ?? "", + still: ep.image ?? undefined, + airDate: ep.aired ?? undefined, + runtime: ep.runtime ?? undefined + })); + } catch { + return []; + } + }, + + async search(query: string, _mediaType?: MetadataMediaType, options?: ProviderPriorityConfig): Promise { + const token = await getTvdbToken(options?.customTvdbApiKey, options?.customTvdbUserPin); + if (!token) return []; + + try { + const res = await fetch(`${TVDB_API_BASE}/search?query=${encodeURIComponent(query)}&type=series`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!res.ok) return []; + + const json = await res.json(); + const results = json.data ?? []; + + return results.map((item: any) => ({ + id: Number(item.tvdb_id || item.id), + title: item.name ?? "Untitled", + overview: item.overview ?? "", + year: item.year ?? "", + mediaType: "tv", + image: item.image_url ?? undefined + })); + } catch { + return []; + } + } +}; diff --git a/web/lib/metadata/types.ts b/web/lib/metadata/types.ts new file mode 100644 index 000000000..d46477f0a --- /dev/null +++ b/web/lib/metadata/types.ts @@ -0,0 +1,26 @@ +import type { EpisodeInfo, MediaItem, MediaType } from "../types"; + +export type MetadataProviderId = "tmdb" | "tvdb" | "anilist" | "kitsu" | "mal" | "omdb"; +export type MetadataMediaType = MediaType | "anime"; +export type MetadataLookupIds = Partial>; +export type MetadataLookup = string | number | MetadataLookupIds; + +export interface MetadataResolver { + id: MetadataProviderId; + name: string; + supportedTypes: MetadataMediaType[]; + + getDetails(id: string | number, mediaType: MetadataMediaType, options?: ProviderPriorityConfig): Promise; + getEpisodes?(id: string | number, seasonNumber?: number, options?: ProviderPriorityConfig): Promise; + search(query: string, mediaType: MetadataMediaType, options?: ProviderPriorityConfig): Promise; +} + + +export interface ProviderPriorityConfig { + movieProviders: MetadataProviderId[]; + tvProviders: MetadataProviderId[]; + animeProviders: MetadataProviderId[]; + customTmdbApiKey?: string; + customTvdbApiKey?: string; + customTvdbUserPin?: string; +} diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 3dc4c9a5f..0280d996a 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -15,6 +15,7 @@ import { buildXtreamCatchupUrl, iptvPlaylistSignature, loadIptvGuideForChannels, import { dedupeMedia, historyToItem, hydrateTraktItems, traktItemToMedia, traktPlaybackToMedia, traktUpNextToMedia } from "./mappers"; import { loadStored, purgeLegacyStorage, removeStored, saveStored } from "./storage"; import { getDetails, loadCatalog, searchMedia } from "./tmdb"; +import type { MetadataProviderId, ProviderPriorityConfig } from "./metadata/types"; import { TraktClient, type TraktDeviceCode } from "./trakt"; import { mdblistClient } from "./mdblist"; import { simklClient, type SimklPinCode } from "./simkl"; @@ -68,6 +69,17 @@ function localProfilesMatchAccount(): boolean { return owner === email; } +export function getPriorityConfig(settings: AppSettings): ProviderPriorityConfig { + return { + movieProviders: (settings.metadataMovieProviders as MetadataProviderId[]) ?? ["tmdb"], + tvProviders: (settings.metadataTvProviders as MetadataProviderId[]) ?? ["tvdb", "tmdb"], + animeProviders: (settings.metadataAnimeProviders as MetadataProviderId[]) ?? ["anilist", "tvdb", "tmdb"], + customTmdbApiKey: settings.customTmdbApiKey, + customTvdbApiKey: settings.customTvdbApiKey, + customTvdbUserPin: settings.customTvdbUserPin + }; +} + // Instant-paint caches for Continue Watching / Watchlist. The TTL is deliberately // long: a stale list is strictly better than a blank rail (the fresh fetch // replaces it seconds later). The old 24h TTL left the rail blank on the first @@ -183,9 +195,16 @@ export const defaultSettings: AppSettings = { favoriteGroupIds: [], hiddenGroupIds: [], groupOrder: [], + customTmdbApiKey: "", + customTvdbApiKey: "", + customTvdbUserPin: "", + metadataMovieProviders: ["tmdb"], + metadataTvProviders: ["tvdb", "tmdb"], + metadataAnimeProviders: ["anilist", "tvdb", "tmdb"], iptvSortOrder: "provider" }; + const emptyIptv: IptvSnapshot = { channels: [], grouped: {}, @@ -1294,7 +1313,8 @@ export function AppProvider({ } setBusy("Opening details"); setStreams([]); - const detailed = await getDetails(item).catch(() => item); + const priorityConfig = getPriorityConfig(settingsRef.current); + const detailed = await getDetails(item, priorityConfig).catch(() => item); const withResumeEpisode = { ...detailed, seasonNumber: item.seasonNumber ?? detailed.seasonNumber ?? null, @@ -1334,6 +1354,7 @@ export function AppProvider({ setSelectedEpisode({ season, episode }); setStreams([]); setBusy("Finding sources"); + appendVodSources(item, season, episode); appendHomeServerSources(item, season, episode); appendTelegramSources(item, season, episode); @@ -1485,7 +1506,8 @@ export function AppProvider({ const playTrailer = useCallback(async (item: MediaItem) => { let url = item.trailerUrl ?? null; if (!url) { - const detailed = await getDetails(item).catch(() => item); + const priorityConfig = getPriorityConfig(settingsRef.current); + const detailed = await getDetails(item, priorityConfig).catch(() => item); url = detailed.trailerUrl ?? null; setSelected((current) => current ?? detailed); } diff --git a/web/lib/tmdb.ts b/web/lib/tmdb.ts index b5e2dd389..0576096bb 100644 --- a/web/lib/tmdb.ts +++ b/web/lib/tmdb.ts @@ -1,5 +1,8 @@ import { config } from "./config"; import { apiProxiedUrl, jsonRequest, proxiedUrl } from "./http"; +import { fetchAniZipMappingByTmdbId } from "./metadata/anizip"; +import { MetadataDispatcher } from "./metadata/dispatcher"; +import type { MetadataLookupIds, MetadataMediaType, MetadataProviderId, ProviderPriorityConfig } from "./metadata/types"; import { tmdbImageUrl } from "./mediaImages"; import { loadStored, saveStored } from "./storage"; import type { CatalogConfig, Category, CollectionSourceConfig, EpisodeInfo, HomeServerConfig, InstalledAddon, MediaItem, MediaType, PersonDetails, ReviewInfo } from "./types"; @@ -92,8 +95,22 @@ export function genreNamesFromIds(ids?: number[]): string[] { return (ids ?? []).map((id) => TMDB_GENRES[id]).filter(Boolean); } +export function isAnime(item: TmdbItem | Partial): boolean { + if ("isAnime" in item && typeof item.isAnime === "boolean") return item.isAnime; + const genreIds = ("genre_ids" in item ? item.genre_ids : "genreIds" in item ? item.genreIds : []) ?? []; + const genres = ("genres" in item ? item.genres : []) ?? []; + const hasAnimation = genreIds.includes(16) || (genres as any[]).some((g: any) => + typeof g === "string" ? g.toLowerCase() === "animation" : g.id === 16 || g.name === "Animation" + ); + const origLang = ("original_language" in item ? item.original_language : "originalLanguage" in item ? item.originalLanguage : "") ?? ""; + return hasAnimation && (origLang === "ja" || origLang === "jp"); +} + export function mapTmdbItem(item: TmdbItem, fallbackType: MediaType): MediaItem { - const mediaType: MediaType = item.media_type === "tv" || fallbackType === "tv" ? "tv" : "movie"; + const mediaType: MediaType = item.media_type === "tv" || fallbackType === "tv" + ? "tv" + : "movie"; + const animeFlag = isAnime(item); const date = mediaType === "movie" ? item.release_date : item.first_air_date; const runtime = item.runtime ?? item.episode_run_time?.[0]; return { @@ -105,6 +122,7 @@ export function mapTmdbItem(item: TmdbItem, fallbackType: MediaType): MediaItem rating: item.vote_average ? item.vote_average.toFixed(1) : "", duration: runtime ? `${runtime}m` : "", mediaType, + isAnime: animeFlag, image: tmdbImageUrl(config.imageBase, item.poster_path), backdrop: tmdbImageUrl(config.backdropBase, item.backdrop_path) || null, genreIds: item.genre_ids ?? [] @@ -119,7 +137,7 @@ export function mapTmdbItem(item: TmdbItem, fallbackType: MediaType): MediaItem let tmdbCooldownUntil = 0; const TMDB_COOLDOWN_MS = 30_000; -async function tmdb(path: string, params: Record = {}) { +export async function tmdb(path: string, params: Record = {}, customKey?: string) { const url = new URL(`/api/tmdb/${path.replace(/^\/+/, "")}`, window.location.origin); // Never surface adult titles anywhere in the app (discover/search/trending). url.searchParams.set("include_adult", "false"); @@ -129,6 +147,10 @@ async function tmdb(path: string, params: Record = {}; + if (customKey?.trim()) { + headers["X-TMDB-API-Key"] = customKey.trim(); + } // Timeout + one retry. Callers swallow failures and render partial UI (a // details page without seasons/cast, "No episodes found"), so a single // dropped request during the startup burst must not be terminal — and a @@ -137,6 +159,7 @@ async function tmdb(path: string, params: Record(url.toString(), { + headers: Object.keys(headers).length > 0 ? headers : undefined, signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(12_000) : undefined }); } catch (error) { @@ -237,7 +260,7 @@ export async function loadCatalog( } if (catalog.sourceType === "trakt" && catalog.sourceUrl) { - const refs = await loadTraktPublicList(catalog.sourceUrl); + const refs = await loadTraktPublicList(catalog.sourceUrl) as Array<{ type: MediaType; id: number }>; const details = await hydrateRefs(refs, language); return { id: catalog.id, @@ -323,7 +346,7 @@ async function loadCollectionSource( if (kind === "CURATED_IDS") { const refs = (source.curatedRefs ?? []) .map(parseCuratedRef) - .filter((ref): ref is { type: MediaType; id: number } => Boolean(ref)); + .filter((ref: T | null): ref is NonNullable => Boolean(ref)); const items = await Promise.all(refs.map((ref) => getBasicItem(ref.type, ref.id, language).catch(() => null))); return items.filter((item): item is MediaItem => Boolean(item)); } @@ -510,7 +533,7 @@ async function loadMdblist(catalog: CatalogConfig, language: string) { : []; const ids = rawItems .map((item) => extractMdblistIdentity(item as MdblistItem, catalog.mediaType)) - .filter((item): item is { id: number; type: MediaType } => Boolean(item?.id)); + .filter((item: T | null | undefined): item is NonNullable => Boolean((item as any)?.id)); return hydrateRefs(ids, language); } @@ -600,7 +623,7 @@ async function loadTraktPublicList(sourceUrl: string) { const id = type === "tv" ? item.show?.ids?.tmdb : item.movie?.ids?.tmdb; return id ? { type, id } : null; }) - .filter((item): item is { type: MediaType; id: number } => Boolean(item)); + .filter((item: T | null): item is NonNullable => Boolean(item)); } function parseTraktUrl(sourceUrl: string): { type: "user"; user: string; slug: string } | { type: "list"; slug: string } | null { @@ -835,14 +858,76 @@ const seriesEpisodeRatingsCache = new Map>(); const SEASON_EPISODE_CACHE_KEY = "arvio.web.seasonEpisodes.v1"; const SEASON_EPISODE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; -export async function getSeasonEpisodes(tvId: number, seasonNumber: number, language = "en-US"): Promise { +export interface SeasonMetadataContext { + tvdbId?: number | null; + anilistId?: number | null; + isAnime?: boolean; +} + +function providersBeforeTmdb(type: MetadataMediaType, configOverride?: ProviderPriorityConfig): ProviderPriorityConfig | null { + const priority = MetadataDispatcher.getPriorityList(type, configOverride); + const tmdbIndex = priority.indexOf("tmdb"); + const providers = priority.slice(0, tmdbIndex >= 0 ? tmdbIndex : priority.length); + if (providers.length === 0) return null; + + const movieProviders: MetadataProviderId[] = configOverride?.movieProviders ?? ["tmdb"]; + const tvProviders: MetadataProviderId[] = configOverride?.tvProviders ?? ["tvdb", "tmdb"]; + const animeProviders: MetadataProviderId[] = configOverride?.animeProviders ?? ["anilist", "tvdb", "tmdb"]; + return { + ...configOverride, + movieProviders: type === "movie" ? providers : movieProviders, + tvProviders: type === "tv" ? providers : tvProviders, + animeProviders: type === "anime" ? providers : animeProviders + }; +} + +export async function getSeasonEpisodes( + tvId: number, + seasonNumber: number, + language = "en-US", + priorityConfig?: ProviderPriorityConfig, + metadataContext?: SeasonMetadataContext +): Promise { + const metadataType: MetadataMediaType = metadataContext?.isAnime ? "anime" : "tv"; + const externalConfig = providersBeforeTmdb(metadataType, priorityConfig); + const lookup: MetadataLookupIds = { + tmdb: tvId, + tvdb: metadataContext?.tvdbId, + anilist: metadataContext?.anilistId + }; + // TMDB numbering remains canonical for playback. TVDB metadata can enrich a + // matching normal-TV episode, but must never replace the list or its numbers. + // Anime numbering differs frequently between providers, so it is deliberately + // excluded until a verified per-episode ID mapping exists. + const externalEpisodesPromise = externalConfig && metadataType === "tv" + ? MetadataDispatcher.getEpisodes(lookup, metadataType, seasonNumber, externalConfig).catch(() => []) + : Promise.resolve([] as EpisodeInfo[]); + const enrichEpisodes = async (episodes: EpisodeInfo[]) => { + const externalEpisodes = await externalEpisodesPromise; + if (externalEpisodes.length === 0) return episodes; + const externalByNumber = new Map( + externalEpisodes.map((episode) => [`${episode.seasonNumber}:${episode.episodeNumber}`, episode]) + ); + return episodes.map((episode) => { + const external = externalByNumber.get(`${episode.seasonNumber}:${episode.episodeNumber}`); + if (!external) return episode; + return { + ...episode, + name: external.name || episode.name, + overview: external.overview || episode.overview, + still: external.still || episode.still, + airDate: external.airDate || episode.airDate, + runtime: external.runtime || episode.runtime + }; + }); + }; const key = `${tvId}:${seasonNumber}:${language}`; const memo = seasonCache.get(key); - if (memo?.length) return memo; + if (memo?.length) return enrichEpisodes(memo); const cached = readSeasonEpisodesCache(key); if (cached) { seasonCache.set(key, cached); - return cached; + return enrichEpisodes(cached); } try { // IMDb episode ratings (external_ids + cinemeta) run in parallel but are @@ -850,7 +935,8 @@ export async function getSeasonEpisodes(tvId: number, seasonNumber: number, lang // list (the cinemeta proxy call has no timeout and can hang for a long time). const season = await tmdb<{ episodes?: Array<{ id: number; episode_number: number; name?: string; overview?: string; still_path?: string | null; vote_average?: number; air_date?: string; runtime?: number }> }>( `tv/${tvId}/season/${seasonNumber}`, - { language } + { language }, + priorityConfig?.customTmdbApiKey ); // Real IMDb ratings need the episode numbers first (the imdb id lives on the // per-episode endpoint), so this starts after the season lands and still @@ -887,7 +973,7 @@ export async function getSeasonEpisodes(tvId: number, seasonNumber: number, lang // session then refetches with ratings instead of pinning them missing. if (!ratingsTimedOut) writeSeasonEpisodesCache(key, episodes); } - return episodes; + return enrichEpisodes(episodes); } catch { return []; } @@ -1078,14 +1164,14 @@ export async function getBasicItem(mediaType: MediaType, id: number, language = const detailsPayloadCache = new Map(); const DETAILS_CACHE_TTL_MS = 10 * 60 * 1000; -async function fetchDetailsPayload(item: MediaItem) { +async function fetchDetailsPayload(item: MediaItem, customApiKey?: string) { const key = `${item.mediaType}-${item.id}`; const cached = detailsPayloadCache.get(key); if (cached && Date.now() - cached.at < DETAILS_CACHE_TTL_MS) return cached.payload; const payload = await tmdb(`${item.mediaType}/${item.id}`, { language: "en-US", append_to_response: "credits,videos,similar,recommendations,external_ids,watch/providers" - }); + }, customApiKey); detailsPayloadCache.set(key, { at: Date.now(), payload }); if (detailsPayloadCache.size > 60) { const oldest = [...detailsPayloadCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]; @@ -1131,10 +1217,30 @@ export async function getTitlesForSearch( } } -export async function getDetails(item: MediaItem) { +export async function getDetails(item: MediaItem, priorityConfig?: ProviderPriorityConfig) { try { - const details = await fetchDetailsPayload(item); + const details = await fetchDetailsPayload(item, priorityConfig?.customTmdbApiKey); const mapped = mapTmdbItem({ ...details, media_type: item.mediaType }, item.mediaType); + const anime = item.isAnime || isAnime(item) || isAnime(details); + const aniZipMapping = anime + ? await fetchAniZipMappingByTmdbId(item.id).catch(() => null) + : null; + const anilistId = item.anilistId ?? aniZipMapping?.anilistId ?? null; + const tvdbId = details.external_ids?.tvdb_id ?? item.tvdbId ?? aniZipMapping?.tvdbId ?? null; + const imdbId = details.external_ids?.imdb_id ?? item.imdbId ?? null; + + let resolvedMeta: MediaItem | null = null; + const metadataType: MetadataMediaType = anime ? "anime" : item.mediaType; + const externalConfig = providersBeforeTmdb(metadataType, priorityConfig); + if (externalConfig) { + const lookup: MetadataLookupIds = { + tmdb: item.id, + tvdb: tvdbId, + anilist: anilistId + }; + resolvedMeta = await MetadataDispatcher.getDetails(lookup, metadataType, externalConfig).catch(() => null); + } + const trailer = details.videos?.results?.find((video) => video.site === "YouTube" && video.type === "Trailer" && video.official) ?? details.videos?.results?.find((video) => video.site === "YouTube" && video.type === "Trailer") ?? details.videos?.results?.find((video) => video.site === "YouTube"); @@ -1145,10 +1251,21 @@ export async function getDetails(item: MediaItem) { return { ...item, ...mapped, - rating: mapped.rating || item.rating, - imdbId: details.external_ids?.imdb_id ?? item.imdbId ?? null, + id: item.id, // Preserve canonical TMDB ID + anilistId: resolvedMeta?.anilistId ?? anilistId, + tvdbId: tvdbId ?? resolvedMeta?.tvdbId ?? item.tvdbId ?? null, + ...(resolvedMeta ? { + title: resolvedMeta.title || mapped.title, + overview: resolvedMeta.overview || mapped.overview, + rating: resolvedMeta.rating || mapped.rating, + badge: resolvedMeta.badge || mapped.badge, + image: resolvedMeta.image || mapped.image, + backdrop: resolvedMeta.backdrop || mapped.backdrop + } : {}), + rating: resolvedMeta?.rating || mapped.rating || item.rating, + imdbId, genres: (details.genres ?? []).map((genre) => genre.name).filter(Boolean), - status: details.status ?? null, + status: resolvedMeta?.status || (details.status ?? null), budget: details.budget ?? null, revenue: details.revenue ?? null, originalLanguage: details.original_language ?? null, diff --git a/web/lib/types.ts b/web/lib/types.ts index d45f35b30..600b2bd9d 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -45,6 +45,10 @@ export interface MediaItem { // Home server (Plex/Jellyfin/Emby) direct playback isHomeServer?: boolean; homeServerUrl?: string | null; + // External metadata IDs & classification + isAnime?: boolean; + anilistId?: number | null; + tvdbId?: number | null; } export interface NextEpisode { @@ -467,5 +471,12 @@ export interface AppSettings { favoriteGroupIds: string[]; hiddenGroupIds: string[]; groupOrder: string[]; + // Metadata & API Keys + customTmdbApiKey: string; + customTvdbApiKey: string; + customTvdbUserPin: string; + metadataMovieProviders: string[]; + metadataTvProviders: string[]; + metadataAnimeProviders: string[]; iptvSortOrder?: "provider" | "number" | "name"; }