From 4ce3526b17b2853531a5b0fa06b1caa2936131be Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 28 Jul 2026 14:58:42 +0530 Subject: [PATCH 1/5] feat(android): add AniList, TVDB v4 integration, metadata dispatcher & BYOK --- .../com/arflix/tv/data/api/AniListApi.kt | 84 ++++++++++++++++ .../com/arflix/tv/data/api/TvdbApiV4.kt | 99 +++++++++++++++++++ .../tv/data/repository/MetadataDispatcher.kt | 79 +++++++++++++++ .../main/kotlin/com/arflix/tv/di/AppModule.kt | 35 +++++++ 4 files changed, 297 insertions(+) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/AniListApi.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/TvdbApiV4.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/AniListApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/AniListApi.kt new file mode 100644 index 000000000..2da10fd23 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/AniListApi.kt @@ -0,0 +1,84 @@ +package com.arflix.tv.data.api + +import com.google.gson.annotations.SerializedName +import retrofit2.http.Body +import retrofit2.http.POST + +interface AniListApi { + + @POST("/") + suspend fun postQuery( + @Body request: AniListGraphQLRequest + ): AniListGraphQLResponse +} + +data class AniListGraphQLRequest( + val query: String, + val variables: Map = emptyMap() +) + +data class AniListGraphQLResponse( + val data: AniListDataPayload? +) + +data class AniListDataPayload( + @SerializedName("Media") val media: AniListMedia?, + @SerializedName("Page") val page: AniListPagePayload? +) + +data class AniListPagePayload( + val media: List? +) + +data class AniListMedia( + val id: Int, + val idMal: Int?, + val title: AniListTitle?, + val description: String?, + val bannerImage: String?, + val coverImage: AniListCoverImage?, + val format: String?, + val status: String?, + val episodes: Int?, + val duration: Int?, + val averageScore: Int?, + val meanScore: Int?, + val popularity: Int?, + val genres: List?, + val startDate: AniListDate?, + val endDate: AniListDate?, + val season: String?, + val seasonYear: Int?, + val studios: AniListStudiosPayload? +) + +data class AniListTitle( + val romaji: String?, + val english: String?, + val native: String? +) { + fun userPreferredTitle(): String = english ?: romaji ?: native ?: "" +} + +data class AniListCoverImage( + val extraLarge: String?, + val large: String?, + val medium: String?, + val color: String? +) + +data class AniListDate( + val year: Int?, + val month: Int?, + val day: Int? +) + +data class AniListStudiosPayload( + val nodes: List? +) + +data class AniListStudioNode( + val id: Int, + val name: String, + val isAnimationStudio: Boolean +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TvdbApiV4.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TvdbApiV4.kt new file mode 100644 index 000000000..2832d8d9e --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TvdbApiV4.kt @@ -0,0 +1,99 @@ +package com.arflix.tv.data.api + +import com.google.gson.annotations.SerializedName +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface TvdbApiV4 { + + @POST("login") + suspend fun login( + @Body request: TvdbLoginRequest + ): TvdbResponse + + @GET("series/{id}/extended") + suspend fun getSeriesExtended( + @Header("Authorization") bearerToken: String, + @Path("id") id: Int + ): TvdbResponse + + @GET("series/{id}/episodes/{seasonType}") + suspend fun getSeriesEpisodes( + @Header("Authorization") bearerToken: String, + @Path("id") id: Int, + @Path("seasonType") seasonType: String = "default", + @Query("page") page: Int = 0 + ): TvdbResponse + + @GET("search") + suspend fun search( + @Header("Authorization") bearerToken: String, + @Query("query") query: String, + @Query("type") type: String? = null + ): TvdbResponse> +} + +data class TvdbLoginRequest( + val apikey: String, + val pin: String? = null +) + +data class TvdbResponse( + val status: String, + val data: T? +) + +data class TvdbLoginData( + val token: String +) + +data class TvdbSeriesData( + val id: Int, + val name: String?, + val overview: String?, + val image: String?, + val firstAired: String?, + val lastAired: String?, + val status: TvdbStatus?, + val score: Double?, + val genres: List? +) + +data class TvdbStatus( + val name: String? +) + +data class TvdbGenre( + val id: Int, + val name: String? +) + +data class TvdbEpisodesData( + val series: TvdbSeriesData?, + val episodes: List? +) + +data class TvdbEpisodeItem( + val id: Int, + val seriesId: Int?, + val name: String?, + val overview: String?, + val image: String?, + val number: Int?, + val seasonNumber: Int?, + val aired: String?, + val runtime: Int? +) + +data class TvdbSearchItem( + val tvdb_id: String?, + val name: String?, + val overview: String?, + val image_url: String?, + val type: String?, + val year: String? +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt new file mode 100644 index 000000000..d6c22e3b2 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt @@ -0,0 +1,79 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.AniListApi +import com.arflix.tv.data.api.AniListGraphQLRequest +import com.arflix.tv.data.api.AniListMedia +import com.arflix.tv.data.api.TmdbApi +import com.arflix.tv.data.api.TvdbApiV4 +import com.arflix.tv.util.AppLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MetadataDispatcher @Inject constructor( + private val tmdbApi: TmdbApi, + private val aniListApi: AniListApi, + private val tvdbApiV4: TvdbApiV4 +) { + private val TAG = "MetadataDispatcher" + + suspend fun getAnimeDetails(query: String): AniListMedia? = withContext(Dispatchers.IO) { + try { + val graphqlQuery = """ + query (${'$'}search: String) { + Media(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 isAnimationStudio } } + } + } + """.trimIndent() + + val request = AniListGraphQLRequest( + query = graphqlQuery, + variables = mapOf("search" to query) + ) + + val response = aniListApi.postQuery(request) + response.data?.media + } catch (e: Exception) { + AppLogger.e(TAG, "AniList fetch failed for query: $query", e) + null + } + } + + suspend fun getTvdbSeries(tvdbId: Int, customApiKey: String? = null): com.arflix.tv.data.api.TvdbSeriesData? = withContext(Dispatchers.IO) { + val keyToUse = customApiKey?.trim().orEmpty() + if (keyToUse.isEmpty()) { + // TVDB disabled unless user enters a custom API key + return@withContext null + } + try { + val loginRes = tvdbApiV4.login(com.arflix.tv.data.api.TvdbLoginRequest(apikey = keyToUse)) + val token = loginRes.data?.token ?: return@withContext null + + val res = tvdbApiV4.getSeriesExtended("Bearer $token", tvdbId) + res.data + } catch (e: Exception) { + AppLogger.e(TAG, "TVDB series fetch failed for ID: $tvdbId", e) + null + } + } +} + + diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 149949109..ee6de6cdb 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -181,6 +181,40 @@ object AppModule { fun provideJikanApi(@Named("jikan") retrofit: Retrofit): com.arflix.tv.data.api.JikanApi { return retrofit.create(com.arflix.tv.data.api.JikanApi::class.java) } + @Provides + @Singleton + @Named("aniList") + fun provideAniListRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("https://graphql.anilist.co/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideAniListApi(@Named("aniList") retrofit: Retrofit): com.arflix.tv.data.api.AniListApi { + return retrofit.create(com.arflix.tv.data.api.AniListApi::class.java) + } + + @Provides + @Singleton + @Named("tvdb") + fun provideTvdbRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("https://api4.thetvdb.com/v4/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideTvdbApiV4(@Named("tvdb") retrofit: Retrofit): com.arflix.tv.data.api.TvdbApiV4 { + return retrofit.create(com.arflix.tv.data.api.TvdbApiV4::class.java) + } + @Provides @Singleton fun provideMoshi(): com.squareup.moshi.Moshi { @@ -189,3 +223,4 @@ object AppModule { .build() } } + From 5d802c2e9a4aa0fdcec70ff5224d2979549d44df Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 28 Jul 2026 14:58:49 +0530 Subject: [PATCH 2/5] feat(web): add AniList, TVDB v4 integration, metadata dispatcher & BYOK UI --- web/components/settings/SettingsScreen.tsx | 101 ++++++++++++++++ web/lib/metadata/anilist.ts | 133 +++++++++++++++++++++ web/lib/metadata/anizip.ts | 30 +++++ web/lib/metadata/dispatcher.ts | 64 ++++++++++ web/lib/metadata/tvdb.ts | 133 +++++++++++++++++++++ web/lib/metadata/types.ts | 23 ++++ web/lib/store.tsx | 9 +- web/lib/types.ts | 8 ++ 8 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 web/lib/metadata/anilist.ts create mode 100644 web/lib/metadata/anizip.ts create mode 100644 web/lib/metadata/dispatcher.ts create mode 100644 web/lib/metadata/tvdb.ts create mode 100644 web/lib/metadata/types.ts diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index 3e9b2df5f..875ef2903 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -108,8 +108,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 = { @@ -1121,11 +1123,110 @@ 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 }) { + return ( +
+ +

+ Provide your custom API keys to bypass rate limits or use personal subscriber keys. +

+ +
+
+
TMDB API Key
+
Custom v3 API key for TMDB fetches
+
+ set({ customTmdbApiKey: e.target.value })} + style={{ width: "240px", padding: "6px 12px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(0,0,0,0.3)", color: "#fff" }} + /> +
+ +
+
+
TVDB v4 API Key (Required for TVDB)
+
+ {settings.customTvdbApiKey?.trim() ? "Active — TVDB enabled" : "TVDB is disabled until a custom key is provided"} +
+
+ set({ customTvdbApiKey: e.target.value })} + style={{ width: "240px", padding: "6px 12px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(0,0,0,0.3)", color: "#fff" }} + /> +
+ + +
+
+
TVDB User PIN
+
Required if using subscriber user key
+
+ set({ customTvdbUserPin: e.target.value })} + style={{ width: "240px", padding: "6px 12px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(0,0,0,0.3)", color: "#fff" }} + /> +
+
+ + +

+ Configured fallback priority order when fetching details for different content types. +

+ +
+
+
Anime Metadata Providers
+
Active chain: AniList → TVDB → TMDB
+
+ + {(settings.metadataAnimeProviders || ["anilist", "tvdb", "tmdb"]).join(" → ").toUpperCase()} + +
+ +
+
+
TV Shows Metadata Providers
+
Active chain: TVDB → TMDB
+
+ + {(settings.metadataTvProviders || ["tvdb", "tmdb"]).join(" → ").toUpperCase()} + +
+ +
+
+
Movies Metadata Providers
+
Active chain: TMDB
+
+ + {(settings.metadataMovieProviders || ["tmdb"]).join(" → ").toUpperCase()} + +
+
+
+ ); +} + + function safeArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } diff --git a/web/lib/metadata/anilist.ts b/web/lib/metadata/anilist.ts new file mode 100644 index 000000000..9b6a8bc00 --- /dev/null +++ b/web/lib/metadata/anilist.ts @@ -0,0 +1,133 @@ +import type { MediaItem } from "../types"; +import type { 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, + 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: "anime" as any, + 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" as any, "tv"], + + async getDetails(id: string | number): 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): 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..de5f70263 --- /dev/null +++ b/web/lib/metadata/anizip.ts @@ -0,0 +1,30 @@ +export interface AniZipMapping { + anilistId: number; + malId?: number; + tvdbId?: number; + tmdbId?: number; + imdbId?: string; + episodeOffset: number; +} + +export async function fetchAniZipMapping(anilistId: number): Promise { + try { + const res = await fetch(`https://api.ani.zip/mappings?anilist_id=${anilistId}`); + if (!res.ok) return null; + + const data = await res.json(); + const mappings = data.mappings; + if (!mappings) return null; + + return { + anilistId, + malId: mappings.mal_id, + tvdbId: mappings.thetvdb_id, + tmdbId: mappings.themoviedb_id, + imdbId: mappings.imdb_id, + episodeOffset: mappings.episodeOffset ?? 0 + }; + } catch { + return null; + } +} diff --git a/web/lib/metadata/dispatcher.ts b/web/lib/metadata/dispatcher.ts new file mode 100644 index 000000000..55d751a5c --- /dev/null +++ b/web/lib/metadata/dispatcher.ts @@ -0,0 +1,64 @@ +import type { MediaItem, MediaType } from "../types"; +import { aniListResolver } from "./anilist"; +import { tvdbResolver } from "./tvdb"; +import type { MetadataProviderId, MetadataResolver, ProviderPriorityConfig } from "./types"; + +export class MetadataDispatcher { + private static resolvers: Record = { + anilist: aniListResolver, + tvdb: tvdbResolver + }; + + static registerResolver(resolver: MetadataResolver) { + this.resolvers[resolver.id] = resolver; + } + + static getPriorityList(type: MediaType, config?: ProviderPriorityConfig): MetadataProviderId[] { + if (type === ("anime" as any)) { + return config?.animeProviders ?? ["anilist", "tvdb", "tmdb" as any]; + } + if (type === "tv") { + return config?.tvProviders ?? ["tvdb", "tmdb" as any]; + } + return config?.movieProviders ?? ["tmdb" as any]; + } + + static async getDetails( + id: string | number, + type: MediaType, + config?: ProviderPriorityConfig + ): Promise { + const priority = this.getPriorityList(type, config); + + for (const providerId of priority) { + const resolver = this.resolvers[providerId]; + if (!resolver) continue; + + const result = await resolver.getDetails(id, config); + if (result) { + return result; + } + } + return null; + } + + static async search( + query: string, + type: MediaType, + config?: ProviderPriorityConfig + ): Promise { + const priority = this.getPriorityList(type, config); + + for (const providerId of priority) { + const resolver = this.resolvers[providerId]; + if (!resolver) continue; + + const results = await resolver.search(query, config); + if (results && results.length > 0) { + return results; + } + } + + return []; + } +} diff --git a/web/lib/metadata/tvdb.ts b/web/lib/metadata/tvdb.ts new file mode 100644 index 000000000..b4fd99217 --- /dev/null +++ b/web/lib/metadata/tvdb.ts @@ -0,0 +1,133 @@ +import type { EpisodeInfo, MediaItem } from "../types"; +import type { MetadataResolver, ProviderPriorityConfig } from "./types"; + + +const TVDB_API_BASE = "https://api4.thetvdb.com/v4"; + +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 now = Date.now(); + if (cachedToken && now < tokenExpiresAt) { + return cachedToken; + } + + try { + const res = await fetch(`${TVDB_API_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apikey: cleanKey, pin: pin || undefined }) + }); + + if (!res.ok) return null; + const json = await res.json(); + if (json.data?.token) { + 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" as any], + + async getDetails(id: string | number, 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 res = await fetch(`${TVDB_API_BASE}/series/${id}/episodes/default?page=0`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!res.ok) return []; + const json = await res.json(); + const episodes = json.data?.episodes ?? []; + + return episodes + .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, 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..b21818205 --- /dev/null +++ b/web/lib/metadata/types.ts @@ -0,0 +1,23 @@ +import type { EpisodeInfo, MediaItem, MediaType } from "../types"; + +export type MetadataProviderId = "tmdb" | "tvdb" | "anilist" | "kitsu" | "mal" | "omdb"; + +export interface MetadataResolver { + id: MetadataProviderId; + name: string; + supportedTypes: MediaType[]; + + getDetails(id: string | number, options?: ProviderPriorityConfig): Promise; + getEpisodes?(id: string | number, seasonNumber?: number, options?: ProviderPriorityConfig): Promise; + search(query: string, 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 1c2fed62e..b87c8c97f 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -181,9 +181,16 @@ export const defaultSettings: AppSettings = { favoriteChannelIds: [], favoriteGroupIds: [], hiddenGroupIds: [], - groupOrder: [] + groupOrder: [], + customTmdbApiKey: "", + customTvdbApiKey: "", + customTvdbUserPin: "", + metadataMovieProviders: ["tmdb"], + metadataTvProviders: ["tvdb", "tmdb"], + metadataAnimeProviders: ["anilist", "tvdb", "tmdb"] }; + const emptyIptv: IptvSnapshot = { channels: [], grouped: {}, diff --git a/web/lib/types.ts b/web/lib/types.ts index 69322411a..a582a5115 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -459,4 +459,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[]; } + From bc41cb703330154bc5563576ddd92db2c4ceeb63 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 28 Jul 2026 15:12:01 +0530 Subject: [PATCH 3/5] fix(web): support seasonNumber and episodeNumber in AniZip parser --- web/lib/metadata/anizip.ts | 45 +++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/web/lib/metadata/anizip.ts b/web/lib/metadata/anizip.ts index de5f70263..e09bb27cb 100644 --- a/web/lib/metadata/anizip.ts +++ b/web/lib/metadata/anizip.ts @@ -1,10 +1,17 @@ +export interface AniZipEpisodeMap { + season?: number; + episode?: number; +} + export interface AniZipMapping { anilistId: number; malId?: number; tvdbId?: number; tmdbId?: number; imdbId?: string; + season?: number; episodeOffset: number; + episodeMap?: Record; } export async function fetchAniZipMapping(anilistId: number): Promise { @@ -16,15 +23,51 @@ export async function fetchAniZipMapping(anilistId: number): Promise = {}; + if (data.episodes && typeof data.episodes === "object") { + for (const [epNum, epInfo] of Object.entries(data.episodes)) { + const parsedSeason = epInfo.seasonNumber ?? epInfo.tvdbSeason ?? epInfo.season; + const parsedEpisode = epInfo.episodeNumber ?? epInfo.tvdbEpisode ?? (typeof epInfo.episode === "number" ? epInfo.episode : !isNaN(Number(epInfo.episode)) ? Number(epInfo.episode) : undefined); + + episodeMap[epNum] = { + season: typeof parsedSeason === "number" ? parsedSeason : undefined, + episode: typeof parsedEpisode === "number" ? parsedEpisode : undefined + }; + } + } + + return { anilistId, malId: mappings.mal_id, tvdbId: mappings.thetvdb_id, tmdbId: mappings.themoviedb_id, imdbId: mappings.imdb_id, - episodeOffset: mappings.episodeOffset ?? 0 + season: mappings.season ?? 1, + episodeOffset: mappings.episodeOffset ?? 0, + episodeMap }; } catch { return null; } } + +/** + * Converts an AniList episode number to the corresponding TMDB/TVDB Season and Episode number. + */ +export function convertAniListToTmdbEpisode( + mapping: AniZipMapping, + aniListEpisodeNumber: number +): { season: number; episode: number } { + // 1. Direct episode map lookup if available + const mapped = mapping.episodeMap?.[String(aniListEpisodeNumber)]; + if (mapped?.season && mapped?.episode) { + return { season: mapped.season, episode: mapped.episode }; + } + + // 2. Calculated offset fallback + const season = mapping.season ?? 1; + const calculatedEpisode = Math.max(1, aniListEpisodeNumber - mapping.episodeOffset); + + return { season, episode: calculatedEpisode }; +} From 6dffa92821db422020e54f695854f4935e929e09 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 22:51:16 +0530 Subject: [PATCH 4/5] feat(web): complete metadata dispatcher vertical slice - Extend MediaType to include 'anime' - Add tmdbResolver as terminal fallback MetadataResolver - Register tmdb resolver in dispatcher with anime priority support - Route getDetails/getSeasonEpisodes through MetadataDispatcher - Add isAnime() helper and customTmdbApiKey support in tmdb.ts - Wire AniZip mapping for anime episode stream resolution in store - Invalidate TVDB token cache on key/PIN change - Remove unsafe 'as any' casts in anilist.ts - Propagate MediaType union to sync, trakt, mdblist, imdbRatings - Fix type predicate widening errors with NonNullable generics - Clean up MetadataSection settings UI to use standard CSS classes --- web/components/settings/SettingsScreen.tsx | 82 ++++++---------------- web/lib/imdbRatings.ts | 34 ++++----- web/lib/mdblist.ts | 7 +- web/lib/metadata/anilist.ts | 4 +- web/lib/metadata/dispatcher.ts | 34 +++++++-- web/lib/metadata/tmdbResolver.ts | 29 ++++++++ web/lib/metadata/tvdb.ts | 11 ++- web/lib/store.tsx | 32 +++++++-- web/lib/sync.ts | 3 +- web/lib/tmdb.ts | 66 +++++++++++++---- web/lib/trakt.ts | 5 +- web/lib/types.ts | 2 +- 12 files changed, 197 insertions(+), 112 deletions(-) create mode 100644 web/lib/metadata/tmdbResolver.ts diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index 875ef2903..ec82f14ed 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -1131,96 +1131,60 @@ function SectionBody({ section }: { section: SectionId }) { } 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 (
-

- Provide your custom API keys to bypass rate limits or use personal subscriber keys. -

- -
-
-
TMDB API Key
-
Custom v3 API key for TMDB fetches
-
+ set({ customTmdbApiKey: e.target.value })} - style={{ width: "240px", padding: "6px 12px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(0,0,0,0.3)", color: "#fff" }} /> -
+ -
-
-
TVDB v4 API Key (Required for TVDB)
-
- {settings.customTvdbApiKey?.trim() ? "Active — TVDB enabled" : "TVDB is disabled until a custom key is provided"} -
-
+ set({ customTvdbApiKey: e.target.value })} - style={{ width: "240px", padding: "6px 12px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(0,0,0,0.3)", color: "#fff" }} /> -
- + -
-
-
TVDB User PIN
-
Required if using subscriber user key
-
+ set({ customTvdbUserPin: e.target.value })} - style={{ width: "240px", padding: "6px 12px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(0,0,0,0.3)", color: "#fff" }} /> -
+
-

- Configured fallback priority order when fetching details for different content types. -

- -
-
-
Anime Metadata Providers
-
Active chain: AniList → TVDB → TMDB
-
- - {(settings.metadataAnimeProviders || ["anilist", "tvdb", "tmdb"]).join(" → ").toUpperCase()} - -
+ + {animeChain} + -
-
-
TV Shows Metadata Providers
-
Active chain: TVDB → TMDB
-
- - {(settings.metadataTvProviders || ["tvdb", "tmdb"]).join(" → ").toUpperCase()} - -
+ + {tvChain} + -
-
-
Movies Metadata Providers
-
Active chain: TMDB
-
- - {(settings.metadataMovieProviders || ["tmdb"]).join(" → ").toUpperCase()} - -
+ + {movieChain} +
); diff --git a/web/lib/imdbRatings.ts b/web/lib/imdbRatings.ts index c9e92fd4c..537886df3 100644 --- a/web/lib/imdbRatings.ts +++ b/web/lib/imdbRatings.ts @@ -1,5 +1,6 @@ import { config } from "./config"; import { loadStored, saveStored } from "./storage"; +import type { MediaType } from "./types"; // Real IMDb ratings — parity with the Android app (MediaRepository.getImdbRating). // TMDB's vote_average is a DIFFERENT score and was previously rendered under an @@ -28,16 +29,16 @@ function writeStore(store: Record) { // quota silently kills every other write in the app). const entries = Object.entries(store); if (entries.length > MAX_ENTRIES) { - const keep = entries.sort((a, b) => b[1].at - a[1].at).slice(0, MAX_ENTRIES); - store = Object.fromEntries(keep); + entries.sort((a, b) => b[1].at - a[1].at); + store = Object.fromEntries(entries.slice(0, MAX_ENTRIES)); } saveStored(STORE_KEY, store); } function cached(imdbId: string): string | null { - const hit = MEMORY.get(imdbId); - if (hit !== undefined) return hit; - const entry = readStore()[imdbId]; + if (MEMORY.has(imdbId)) return MEMORY.get(imdbId)!; + const store = readStore(); + const entry = store[imdbId]; if (entry && Date.now() - entry.at < TTL_MS) { MEMORY.set(imdbId, entry.rating); return entry.rating; @@ -45,15 +46,15 @@ function cached(imdbId: string): string | null { return null; } -function remember(imdbId: string, rating: string) { +function record(imdbId: string, rating: string) { MEMORY.set(imdbId, rating); const store = readStore(); store[imdbId] = { rating, at: Date.now() }; writeStore(store); } -function cinemetaUrl(mediaType: "movie" | "tv", imdbId: string) { - const typePath = mediaType === "tv" ? "series" : "movie"; +function cinemetaUrl(mediaType: MediaType, imdbId: string) { + const typePath = mediaType === "tv" || mediaType === "anime" ? "series" : "movie"; const target = `https://v3-cinemeta.strem.io/meta/${typePath}/${imdbId}.json`; const base = config.resolverUrl.replace(/\/+$/, ""); // Route through the resolver worker: it is CORS-clean and edge-caches @@ -80,13 +81,14 @@ const AGREGARR_ENDPOINT = "https://api.agregarr.org/api/ratings"; * limit the Android client uses). */ export async function getImdbRatings(imdbIds: string[]): Promise> { - const unique = [...new Set(imdbIds.map((id) => (id ?? "").trim().toLowerCase()).filter((id) => /^tt\d+$/.test(id)))]; const result: Record = {}; const missing: string[] = []; - unique.forEach((id) => { + imdbIds.forEach((raw) => { + const id = (raw ?? "").trim().toLowerCase(); + if (!/^tt\d+$/.test(id)) return; const hit = cached(id); - if (hit === null) missing.push(id); - else if (hit) result[id] = hit; + if (hit) result[id] = hit; + else missing.push(id); }); if (!missing.length) return result; @@ -106,12 +108,12 @@ export async function getImdbRatings(imdbIds: string[]): Promise { if (!seen.has(id)) remember(id, ""); }); + chunk.forEach((id) => { if (!seen.has(id)) record(id, ""); }); } catch { // Leave this chunk uncached so a transient failure can be retried later. } @@ -124,7 +126,7 @@ export async function getImdbRatings(imdbIds: string[]): Promise { +export async function getImdbRating(mediaType: MediaType, imdbId?: string | null): Promise { const id = (imdbId ?? "").trim().toLowerCase(); if (!/^tt\d+$/.test(id)) return null; const hit = cached(id); @@ -144,7 +146,7 @@ export async function getImdbRating(mediaType: "movie" | "tv", imdbId?: string | const rating = normalize(payload?.meta?.imdbRating); // Cache misses too (as an empty string) so a title Cinemeta has no rating // for isn't re-fetched on every render for the next week. - remember(id, rating ?? ""); + record(id, rating ?? ""); return rating; } catch { return null; diff --git a/web/lib/mdblist.ts b/web/lib/mdblist.ts index 620715203..f6ed7142d 100644 --- a/web/lib/mdblist.ts +++ b/web/lib/mdblist.ts @@ -1,10 +1,11 @@ import { jsonRequest } from "./http"; import { loadStored, removeStored, saveStored } from "./storage"; +import type { MediaType } from "./types"; const MDBLIST_KEY_STORAGE = "arvio.web.mdblist.key"; export interface MdbMediaRef { - mediaType: "movie" | "tv"; + mediaType: MediaType; tmdbId: number; season?: number | null; episode?: number | null; @@ -167,7 +168,7 @@ export class MdbListClient { private async modifyWatchlist(action: "add" | "remove", item: MdbMediaRef) { if (!this.key) return; - const body = item.mediaType === "tv" + const body = item.mediaType === "tv" || item.mediaType === "anime" ? { shows: [{ tmdb: item.tmdbId }] } : { movies: [{ tmdb: item.tmdbId }] }; await this.request(`watchlist/items/${action}`, { method: "POST", body: JSON.stringify(body) }); @@ -184,7 +185,7 @@ export class MdbListClient { } private watchedBody(item: MdbMediaRef) { - if (item.mediaType === "tv") { + if (item.mediaType === "tv" || item.mediaType === "anime") { const ids = { tmdb: item.tmdbId }; if (item.season && item.episode) { return { shows: [{ ids, seasons: [{ number: item.season, episodes: [{ number: item.episode }] }] }] }; diff --git a/web/lib/metadata/anilist.ts b/web/lib/metadata/anilist.ts index 9b6a8bc00..8ed9b2032 100644 --- a/web/lib/metadata/anilist.ts +++ b/web/lib/metadata/anilist.ts @@ -78,7 +78,7 @@ function mapAniListToMediaItem(media: any): MediaItem { year: media.seasonYear ? String(media.seasonYear) : undefined, rating, duration: media.duration ? `${media.duration}m` : undefined, - mediaType: "anime" as any, + mediaType: "anime", image: poster ?? undefined, backdrop: media.bannerImage ?? poster ?? null, badge: media.format ?? "ANIME", @@ -91,7 +91,7 @@ function mapAniListToMediaItem(media: any): MediaItem { export const aniListResolver: MetadataResolver = { id: "anilist", name: "AniList", - supportedTypes: ["anime" as any, "tv"], + supportedTypes: ["anime", "tv"], async getDetails(id: string | number): Promise { try { diff --git a/web/lib/metadata/dispatcher.ts b/web/lib/metadata/dispatcher.ts index 55d751a5c..a5425aa30 100644 --- a/web/lib/metadata/dispatcher.ts +++ b/web/lib/metadata/dispatcher.ts @@ -1,12 +1,14 @@ -import type { MediaItem, MediaType } from "../types"; +import type { EpisodeInfo, MediaItem, MediaType } from "../types"; import { aniListResolver } from "./anilist"; import { tvdbResolver } from "./tvdb"; +import { tmdbResolver } from "./tmdbResolver"; import type { MetadataProviderId, MetadataResolver, ProviderPriorityConfig } from "./types"; export class MetadataDispatcher { private static resolvers: Record = { anilist: aniListResolver, - tvdb: tvdbResolver + tvdb: tvdbResolver, + tmdb: tmdbResolver }; static registerResolver(resolver: MetadataResolver) { @@ -14,13 +16,13 @@ export class MetadataDispatcher { } static getPriorityList(type: MediaType, config?: ProviderPriorityConfig): MetadataProviderId[] { - if (type === ("anime" as any)) { - return config?.animeProviders ?? ["anilist", "tvdb", "tmdb" as any]; + if (type === "anime") { + return config?.animeProviders ?? ["anilist", "tvdb", "tmdb"]; } if (type === "tv") { - return config?.tvProviders ?? ["tvdb", "tmdb" as any]; + return config?.tvProviders ?? ["tvdb", "tmdb"]; } - return config?.movieProviders ?? ["tmdb" as any]; + return config?.movieProviders ?? ["tmdb"]; } static async getDetails( @@ -42,6 +44,26 @@ export class MetadataDispatcher { return null; } + static async getEpisodes( + id: string | number, + type: MediaType, + 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) continue; + + const episodes = await resolver.getEpisodes(id, seasonNumber, config); + if (episodes && episodes.length > 0) { + return episodes; + } + } + return []; + } + static async search( query: string, type: MediaType, diff --git a/web/lib/metadata/tmdbResolver.ts b/web/lib/metadata/tmdbResolver.ts new file mode 100644 index 000000000..f6d1d9ba7 --- /dev/null +++ b/web/lib/metadata/tmdbResolver.ts @@ -0,0 +1,29 @@ +import type { EpisodeInfo, MediaItem } from "../types"; +import { getBasicItem, getSeasonEpisodes, searchMedia } from "../tmdb"; +import type { MetadataResolver, ProviderPriorityConfig } from "./types"; + +export const tmdbResolver: MetadataResolver = { + id: "tmdb", + name: "TMDB", + supportedTypes: ["movie", "tv", "anime"], + + async getDetails(id: string | number, _options?: ProviderPriorityConfig): Promise { + const numericId = Number(id); + if (isNaN(numericId) || numericId <= 0) return null; + + const tvItem = await getBasicItem("tv", numericId).catch(() => null); + if (tvItem) return tvItem; + + return getBasicItem("movie", 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, _options?: ProviderPriorityConfig): Promise { + return searchMedia(query).catch(() => []); + } +}; diff --git a/web/lib/metadata/tvdb.ts b/web/lib/metadata/tvdb.ts index b4fd99217..100e8d9c0 100644 --- a/web/lib/metadata/tvdb.ts +++ b/web/lib/metadata/tvdb.ts @@ -4,6 +4,8 @@ import type { 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; @@ -13,9 +15,10 @@ async function getTvdbToken(apiKey?: string, pin?: string): Promise { const token = await getTvdbToken(options?.customTvdbApiKey, options?.customTvdbUserPin); diff --git a/web/lib/store.tsx b/web/lib/store.tsx index b87c8c97f..4db9feb40 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -15,6 +15,8 @@ import { buildXtreamCatchupUrl, loadIptvGuideForChannels, loadIptvSnapshot, load import { dedupeMedia, historyToItem, hydrateTraktItems, traktItemToMedia, traktPlaybackToMedia, traktUpNextToMedia } from "./mappers"; import { loadStored, purgeLegacyStorage, removeStored, saveStored } from "./storage"; import { getDetails, loadCatalog, searchMedia } from "./tmdb"; +import { convertAniListToTmdbEpisode, fetchAniZipMapping } from "./metadata/anizip"; +import type { MetadataProviderId, ProviderPriorityConfig } from "./metadata/types"; import { TraktClient, type TraktDeviceCode } from "./trakt"; import { mdblistClient } from "./mdblist"; import { activeSyncProvider, syncClient } from "./sync"; @@ -1242,7 +1244,15 @@ export function AppProvider({ } setBusy("Opening details"); setStreams([]); - const detailed = await getDetails(item).catch(() => item); + const priorityConfig: ProviderPriorityConfig = { + movieProviders: settingsRef.current.metadataMovieProviders as MetadataProviderId[], + tvProviders: settingsRef.current.metadataTvProviders as MetadataProviderId[], + animeProviders: settingsRef.current.metadataAnimeProviders as MetadataProviderId[], + customTmdbApiKey: settingsRef.current.customTmdbApiKey, + customTvdbApiKey: settingsRef.current.customTvdbApiKey, + customTvdbUserPin: settingsRef.current.customTvdbUserPin + }; + const detailed = await getDetails(item, priorityConfig).catch(() => item); const withResumeEpisode = { ...detailed, seasonNumber: item.seasonNumber ?? detailed.seasonNumber ?? null, @@ -1282,10 +1292,22 @@ export function AppProvider({ setSelectedEpisode({ season, episode }); setStreams([]); setBusy("Finding sources"); - appendVodSources(item, season, episode); - appendHomeServerSources(item, season, episode); - appendTelegramSources(item, season, episode); - const found = await getStreamsProgressive(addonsRef.current, item, season, episode, mergeStreams).catch(() => []); + + let targetSeason = season; + let targetEpisode = episode; + if (item.mediaType === "anime" || item.badge === "ANIME") { + const mapping = await fetchAniZipMapping(item.id).catch(() => null); + if (mapping) { + const converted = convertAniListToTmdbEpisode(mapping, episode); + targetSeason = converted.season; + targetEpisode = converted.episode; + } + } + + appendVodSources(item, targetSeason, targetEpisode); + appendHomeServerSources(item, targetSeason, targetEpisode); + appendTelegramSources(item, targetSeason, targetEpisode); + const found = await getStreamsProgressive(addonsRef.current, item, targetSeason, targetEpisode, mergeStreams).catch(() => []); mergeStreams(found); setBusy(""); return found; diff --git a/web/lib/sync.ts b/web/lib/sync.ts index f4a08a1f9..4e7a9c51c 100644 --- a/web/lib/sync.ts +++ b/web/lib/sync.ts @@ -1,10 +1,11 @@ +import type { MediaType } from "./types"; import { mdblistClient } from "./mdblist"; import { traktClient } from "./store"; export type SyncProvider = "trakt" | "mdblist" | "none"; export interface SyncMediaRef { - mediaType: "movie" | "tv"; + mediaType: MediaType; tmdbId: number; season?: number | null; episode?: number | null; diff --git a/web/lib/tmdb.ts b/web/lib/tmdb.ts index 2d17f73cc..e109a1533 100644 --- a/web/lib/tmdb.ts +++ b/web/lib/tmdb.ts @@ -1,5 +1,7 @@ import { config } from "./config"; import { apiProxiedUrl, jsonRequest, proxiedUrl } from "./http"; +import { MetadataDispatcher } from "./metadata/dispatcher"; +import type { ProviderPriorityConfig } from "./metadata/types"; import { tmdbImageUrl } from "./mediaImages"; import { loadStored, saveStored } from "./storage"; import type { CatalogConfig, Category, CollectionSourceConfig, EpisodeInfo, InstalledAddon, MediaItem, MediaType, PersonDetails, ReviewInfo } from "./types"; @@ -92,8 +94,23 @@ export function genreNamesFromIds(ids?: number[]): string[] { return (ids ?? []).map((id) => TMDB_GENRES[id]).filter(Boolean); } +export function isAnime(item: TmdbItem | Partial): boolean { + if ("mediaType" in item && item.mediaType === "anime") return true; + 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 = isAnime(item) + ? "anime" + : item.media_type === "tv" || fallbackType === "tv" + ? "tv" + : "movie"; const date = mediaType === "movie" ? item.release_date : item.first_air_date; const runtime = item.runtime ?? item.episode_run_time?.[0]; return { @@ -119,10 +136,13 @@ 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"); + if (customKey?.trim()) { + url.searchParams.set("api_key", customKey.trim()); + } Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== "") url.searchParams.set(key, String(value)); }); @@ -232,7 +252,7 @@ export async function loadCatalog(catalog: CatalogConfig, language = "en-US", ad } 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, @@ -280,7 +300,7 @@ async function loadCollectionSource(source: CollectionSourceConfig, language: st 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)); } @@ -458,7 +478,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); } @@ -548,7 +568,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 { @@ -783,7 +803,11 @@ 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 async function getSeasonEpisodes(tvId: number, seasonNumber: number, language = "en-US", priorityConfig?: ProviderPriorityConfig): Promise { + if (priorityConfig?.customTvdbApiKey) { + const dispatched = await MetadataDispatcher.getEpisodes(tvId, "tv", seasonNumber, priorityConfig).catch(() => []); + if (dispatched.length > 0) return dispatched; + } const key = `${tvId}:${seasonNumber}:${language}`; const memo = seasonCache.get(key); if (memo?.length) return memo; @@ -798,7 +822,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 @@ -1026,14 +1051,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]; @@ -1079,9 +1104,14 @@ export async function getTitlesForSearch( } } -export async function getDetails(item: MediaItem) { +export async function getDetails(item: MediaItem, priorityConfig?: ProviderPriorityConfig) { try { - const details = await fetchDetailsPayload(item); + let resolvedMeta: MediaItem | null = null; + if (priorityConfig || item.mediaType === "anime") { + resolvedMeta = await MetadataDispatcher.getDetails(item.id, item.mediaType, priorityConfig).catch(() => null); + } + + const details = await fetchDetailsPayload(item, priorityConfig?.customTmdbApiKey); const mapped = mapTmdbItem({ ...details, media_type: item.mediaType }, item.mediaType); 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") @@ -1093,10 +1123,18 @@ export async function getDetails(item: MediaItem) { return { ...item, ...mapped, - rating: mapped.rating || item.rating, + ...(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: details.external_ids?.imdb_id ?? item.imdbId ?? null, 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/trakt.ts b/web/lib/trakt.ts index 883cea6ea..8dc684189 100644 --- a/web/lib/trakt.ts +++ b/web/lib/trakt.ts @@ -1,6 +1,7 @@ import { config, hasTraktConfig } from "./config"; import { HttpError, jsonRequest } from "./http"; import { loadStored, removeStored, saveStored } from "./storage"; +import type { MediaType } from "./types"; const TRAKT_TOKEN_KEY = "arvio.web.trakt.token"; // v2: v1 stored FULL progress payloads (every season/episode — ~740KB across a @@ -425,7 +426,7 @@ export class TraktClient { } private mediaBody(item: TraktMediaRef) { - if (item.mediaType === "tv") { + if (item.mediaType === "tv" || item.mediaType === "anime") { const show = { ids: { tmdb: item.tmdbId } }; if (item.season && item.episode) { return { shows: [{ ...show, seasons: [{ number: item.season, episodes: [{ number: item.episode }] }] }] }; @@ -437,7 +438,7 @@ export class TraktClient { } interface TraktMediaRef { - mediaType: "movie" | "tv"; + mediaType: MediaType; tmdbId: number; season?: number | null; episode?: number | null; diff --git a/web/lib/types.ts b/web/lib/types.ts index a582a5115..ce5dc4c09 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -1,4 +1,4 @@ -export type MediaType = "movie" | "tv"; +export type MediaType = "movie" | "tv" | "anime"; export type NavSection = "home" | "tv" | "search" | "watchlist" | "addons" | "settings"; From add10693876269a452d78b9a391130fcd4624ff2 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sat, 8 Aug 2026 19:47:04 +0530 Subject: [PATCH 5/5] refactor(metadata): address PR 495 review feedback and CodeRabbit fixes --- .../tv/data/repository/MetadataDispatcher.kt | 4 +- .../main/kotlin/com/arflix/tv/di/AppModule.kt | 1 - .../data/repository/MetadataDispatcherTest.kt | 26 +++++++ web/app/api/tmdb/[...path]/route.ts | 35 +++++---- web/lib/cloud.ts | 6 +- web/lib/imdbRatings.ts | 11 ++- web/lib/mdblist.ts | 4 +- web/lib/metadata/anilist.ts | 14 ++-- web/lib/metadata/anizip.ts | 4 +- web/lib/metadata/dispatcher.ts | 10 +-- web/lib/metadata/tmdbResolver.ts | 12 ++- web/lib/metadata/tvdb.ts | 46 +++++++++--- web/lib/metadata/types.ts | 4 +- web/lib/store.tsx | 74 +++++++++++-------- web/lib/tmdb.ts | 40 ++++++---- web/lib/types.ts | 5 +- 16 files changed, 190 insertions(+), 106 deletions(-) create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/MetadataDispatcherTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt index d6c22e3b2..fd392bae8 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MetadataDispatcher.kt @@ -22,7 +22,7 @@ class MetadataDispatcher @Inject constructor( suspend fun getAnimeDetails(query: String): AniListMedia? = withContext(Dispatchers.IO) { try { val graphqlQuery = """ - query (${'$'}search: String) { + query (${'$'}search: String!) { Media(search: ${'$'}search, type: ANIME) { id idMal @@ -75,5 +75,3 @@ class MetadataDispatcher @Inject constructor( } } } - - diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index ee6de6cdb..5f77ad429 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -223,4 +223,3 @@ object AppModule { .build() } } - diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/MetadataDispatcherTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/MetadataDispatcherTest.kt new file mode 100644 index 000000000..4d34496b4 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/MetadataDispatcherTest.kt @@ -0,0 +1,26 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.AniListApi +import com.arflix.tv.data.api.TmdbApi +import com.arflix.tv.data.api.TvdbApiV4 +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNull +import org.junit.Test + +class MetadataDispatcherTest { + @Test + fun `tvdb series returns null when custom api key is null or empty`() = runBlocking { + val dispatcher = MetadataDispatcher( + tmdbApi = mockk(relaxed = true), + aniListApi = mockk(relaxed = true), + tvdbApiV4 = mockk(relaxed = true) + ) + + val resultEmpty = dispatcher.getTvdbSeries(12345, "") + val resultNull = dispatcher.getTvdbSeries(12345, null) + + assertNull(resultEmpty) + assertNull(resultNull) + } +} 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/lib/cloud.ts b/web/lib/cloud.ts index 21ce7c104..b18803a6b 100644 --- a/web/lib/cloud.ts +++ b/web/lib/cloud.ts @@ -660,6 +660,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 @@ -749,7 +752,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/imdbRatings.ts b/web/lib/imdbRatings.ts index 537886df3..4b55e9ec7 100644 --- a/web/lib/imdbRatings.ts +++ b/web/lib/imdbRatings.ts @@ -83,12 +83,17 @@ const AGREGARR_ENDPOINT = "https://api.agregarr.org/api/ratings"; export async function getImdbRatings(imdbIds: string[]): Promise> { const result: Record = {}; const missing: string[] = []; + const seen = new Set(); imdbIds.forEach((raw) => { const id = (raw ?? "").trim().toLowerCase(); - if (!/^tt\d+$/.test(id)) return; + if (!/^tt\d+$/.test(id) || seen.has(id)) return; + seen.add(id); const hit = cached(id); - if (hit) result[id] = hit; - else missing.push(id); + if (hit !== null) { + if (hit) result[id] = hit; + } else { + missing.push(id); + } }); if (!missing.length) return result; diff --git a/web/lib/mdblist.ts b/web/lib/mdblist.ts index f6ed7142d..b9181e4f4 100644 --- a/web/lib/mdblist.ts +++ b/web/lib/mdblist.ts @@ -187,7 +187,7 @@ export class MdbListClient { private watchedBody(item: MdbMediaRef) { if (item.mediaType === "tv" || item.mediaType === "anime") { const ids = { tmdb: item.tmdbId }; - if (item.season && item.episode) { + if (item.season != null && item.episode != null) { return { shows: [{ ids, seasons: [{ number: item.season, episodes: [{ number: item.episode }] }] }] }; } return { shows: [{ ids }] }; @@ -200,7 +200,7 @@ export class MdbListClient { const progress = Math.round(item.progress); let body: unknown; if (item.mediaType === "tv") { - if (!item.season || !item.episode) return; // MDBList needs season+episode to scrobble an episode + if (item.season == null || item.episode == null) return; // MDBList needs season+episode to scrobble an episode body = { progress, show: { ids: { tmdb: item.tmdbId }, season: { number: item.season, episode: { number: item.episode } } } diff --git a/web/lib/metadata/anilist.ts b/web/lib/metadata/anilist.ts index 8ed9b2032..d22b68269 100644 --- a/web/lib/metadata/anilist.ts +++ b/web/lib/metadata/anilist.ts @@ -1,4 +1,4 @@ -import type { MediaItem } from "../types"; +import type { MediaItem, MediaType } from "../types"; import type { MetadataResolver } from "./types"; const ANILIST_GRAPHQL_ENDPOINT = "https://graphql.anilist.co"; @@ -69,16 +69,18 @@ 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: "anime", + mediaType: "tv", + isAnime: true, image: poster ?? undefined, backdrop: media.bannerImage ?? poster ?? null, badge: media.format ?? "ANIME", @@ -91,9 +93,9 @@ function mapAniListToMediaItem(media: any): MediaItem { export const aniListResolver: MetadataResolver = { id: "anilist", name: "AniList", - supportedTypes: ["anime", "tv"], + supportedTypes: ["anime"], - async getDetails(id: string | number): Promise { + async getDetails(id: string | number, _mediaType?: MediaType): Promise { try { const isNumeric = !isNaN(Number(id)); const variables = isNumeric ? { id: Number(id) } : { search: String(id) }; @@ -114,7 +116,7 @@ export const aniListResolver: MetadataResolver = { } }, - async search(query: string): Promise { + async search(query: string, _mediaType?: MediaType): Promise { try { const res = await fetch(ANILIST_GRAPHQL_ENDPOINT, { method: "POST", diff --git a/web/lib/metadata/anizip.ts b/web/lib/metadata/anizip.ts index e09bb27cb..6f91ee03e 100644 --- a/web/lib/metadata/anizip.ts +++ b/web/lib/metadata/anizip.ts @@ -28,7 +28,7 @@ export async function fetchAniZipMapping(anilistId: number): Promise(data.episodes)) { const parsedSeason = epInfo.seasonNumber ?? epInfo.tvdbSeason ?? epInfo.season; const parsedEpisode = epInfo.episodeNumber ?? epInfo.tvdbEpisode ?? (typeof epInfo.episode === "number" ? epInfo.episode : !isNaN(Number(epInfo.episode)) ? Number(epInfo.episode) : undefined); - + episodeMap[epNum] = { season: typeof parsedSeason === "number" ? parsedSeason : undefined, episode: typeof parsedEpisode === "number" ? parsedEpisode : undefined @@ -61,7 +61,7 @@ export function convertAniListToTmdbEpisode( ): { season: number; episode: number } { // 1. Direct episode map lookup if available const mapped = mapping.episodeMap?.[String(aniListEpisodeNumber)]; - if (mapped?.season && mapped?.episode) { + if (typeof mapped?.season === "number" && typeof mapped?.episode === "number") { return { season: mapped.season, episode: mapped.episode }; } diff --git a/web/lib/metadata/dispatcher.ts b/web/lib/metadata/dispatcher.ts index a5425aa30..93816aeb9 100644 --- a/web/lib/metadata/dispatcher.ts +++ b/web/lib/metadata/dispatcher.ts @@ -34,9 +34,9 @@ export class MetadataDispatcher { for (const providerId of priority) { const resolver = this.resolvers[providerId]; - if (!resolver) continue; + if (!resolver || !resolver.supportedTypes.includes(type)) continue; - const result = await resolver.getDetails(id, config); + const result = await resolver.getDetails(id, type, config); if (result) { return result; } @@ -54,7 +54,7 @@ export class MetadataDispatcher { for (const providerId of priority) { const resolver = this.resolvers[providerId]; - if (!resolver || !resolver.getEpisodes) continue; + if (!resolver || !resolver.getEpisodes || !resolver.supportedTypes.includes(type)) continue; const episodes = await resolver.getEpisodes(id, seasonNumber, config); if (episodes && episodes.length > 0) { @@ -73,9 +73,9 @@ export class MetadataDispatcher { for (const providerId of priority) { const resolver = this.resolvers[providerId]; - if (!resolver) continue; + if (!resolver || !resolver.supportedTypes.includes(type)) continue; - const results = await resolver.search(query, config); + const results = await resolver.search(query, type, config); if (results && results.length > 0) { return results; } diff --git a/web/lib/metadata/tmdbResolver.ts b/web/lib/metadata/tmdbResolver.ts index f6d1d9ba7..7ae05deba 100644 --- a/web/lib/metadata/tmdbResolver.ts +++ b/web/lib/metadata/tmdbResolver.ts @@ -1,4 +1,4 @@ -import type { EpisodeInfo, MediaItem } from "../types"; +import type { EpisodeInfo, MediaItem, MediaType } from "../types"; import { getBasicItem, getSeasonEpisodes, searchMedia } from "../tmdb"; import type { MetadataResolver, ProviderPriorityConfig } from "./types"; @@ -7,14 +7,12 @@ export const tmdbResolver: MetadataResolver = { name: "TMDB", supportedTypes: ["movie", "tv", "anime"], - async getDetails(id: string | number, _options?: ProviderPriorityConfig): Promise { + async getDetails(id: string | number, mediaType?: MediaType, _options?: ProviderPriorityConfig): Promise { const numericId = Number(id); if (isNaN(numericId) || numericId <= 0) return null; - const tvItem = await getBasicItem("tv", numericId).catch(() => null); - if (tvItem) return tvItem; - - return getBasicItem("movie", numericId).catch(() => null); + const targetType = mediaType === "movie" ? "movie" : "tv"; + return getBasicItem(targetType, numericId).catch(() => null); }, async getEpisodes(id: string | number, seasonNumber = 1, _options?: ProviderPriorityConfig): Promise { @@ -23,7 +21,7 @@ export const tmdbResolver: MetadataResolver = { return getSeasonEpisodes(numericId, seasonNumber).catch(() => []); }, - async search(query: string, _options?: ProviderPriorityConfig): Promise { + async search(query: string, _mediaType?: MediaType, _options?: ProviderPriorityConfig): Promise { return searchMedia(query).catch(() => []); } }; diff --git a/web/lib/metadata/tvdb.ts b/web/lib/metadata/tvdb.ts index 100e8d9c0..5f326f281 100644 --- a/web/lib/metadata/tvdb.ts +++ b/web/lib/metadata/tvdb.ts @@ -1,4 +1,4 @@ -import type { EpisodeInfo, MediaItem } from "../types"; +import type { EpisodeInfo, MediaItem, MediaType } from "../types"; import type { MetadataResolver, ProviderPriorityConfig } from "./types"; @@ -48,7 +48,7 @@ export const tvdbResolver: MetadataResolver = { name: "TheTVDB", supportedTypes: ["tv", "anime"], - async getDetails(id: string | number, options?: ProviderPriorityConfig): Promise { + async getDetails(id: string | number, _mediaType?: MediaType, options?: ProviderPriorityConfig): Promise { const token = await getTvdbToken(options?.customTvdbApiKey, options?.customTvdbUserPin); if (!token) return null; @@ -84,15 +84,37 @@ export const tvdbResolver: MetadataResolver = { if (!token) return []; try { - const res = await fetch(`${TVDB_API_BASE}/series/${id}/episodes/default?page=0`, { - headers: { Authorization: `Bearer ${token}` } - }); - - if (!res.ok) return []; - const json = await res.json(); - const episodes = json.data?.episodes ?? []; - - return episodes + 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, @@ -109,7 +131,7 @@ export const tvdbResolver: MetadataResolver = { } }, - async search(query: string, options?: ProviderPriorityConfig): Promise { + async search(query: string, _mediaType?: MediaType, options?: ProviderPriorityConfig): Promise { const token = await getTvdbToken(options?.customTvdbApiKey, options?.customTvdbUserPin); if (!token) return []; diff --git a/web/lib/metadata/types.ts b/web/lib/metadata/types.ts index b21818205..bfb0c828b 100644 --- a/web/lib/metadata/types.ts +++ b/web/lib/metadata/types.ts @@ -7,9 +7,9 @@ export interface MetadataResolver { name: string; supportedTypes: MediaType[]; - getDetails(id: string | number, options?: ProviderPriorityConfig): Promise; + getDetails(id: string | number, mediaType: MediaType, options?: ProviderPriorityConfig): Promise; getEpisodes?(id: string | number, seasonNumber?: number, options?: ProviderPriorityConfig): Promise; - search(query: string, options?: ProviderPriorityConfig): Promise; + search(query: string, mediaType: MediaType, options?: ProviderPriorityConfig): Promise; } diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 4db9feb40..63ca6770c 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -16,6 +16,7 @@ import { dedupeMedia, historyToItem, hydrateTraktItems, traktItemToMedia, traktP import { loadStored, purgeLegacyStorage, removeStored, saveStored } from "./storage"; import { getDetails, loadCatalog, searchMedia } from "./tmdb"; import { convertAniListToTmdbEpisode, fetchAniZipMapping } from "./metadata/anizip"; +import { aniListResolver } from "./metadata/anilist"; import type { MetadataProviderId, ProviderPriorityConfig } from "./metadata/types"; import { TraktClient, type TraktDeviceCode } from "./trakt"; import { mdblistClient } from "./mdblist"; @@ -69,6 +70,34 @@ function localProfilesMatchAccount(): boolean { return owner === email; } +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 + }; +} + +async function resolveEpisodeTarget(item: MediaItem, season: number, episode: number): Promise<{ season: number; episode: number }> { + if (item.isAnime || item.mediaType === "anime" || item.badge === "ANIME") { + let anilistId = item.anilistId; + if (!anilistId && item.title) { + const resolvedAni = await aniListResolver.getDetails(item.title, "anime").catch(() => null); + anilistId = resolvedAni?.anilistId ?? resolvedAni?.id ?? null; + } + if (anilistId) { + const mapping = await fetchAniZipMapping(anilistId).catch(() => null); + if (mapping) { + return convertAniListToTmdbEpisode(mapping, episode); + } + } + } + return { season, episode }; +} + // 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 @@ -1244,14 +1273,7 @@ export function AppProvider({ } setBusy("Opening details"); setStreams([]); - const priorityConfig: ProviderPriorityConfig = { - movieProviders: settingsRef.current.metadataMovieProviders as MetadataProviderId[], - tvProviders: settingsRef.current.metadataTvProviders as MetadataProviderId[], - animeProviders: settingsRef.current.metadataAnimeProviders as MetadataProviderId[], - customTmdbApiKey: settingsRef.current.customTmdbApiKey, - customTvdbApiKey: settingsRef.current.customTvdbApiKey, - customTvdbUserPin: settingsRef.current.customTvdbUserPin - }; + const priorityConfig = getPriorityConfig(settingsRef.current); const detailed = await getDetails(item, priorityConfig).catch(() => item); const withResumeEpisode = { ...detailed, @@ -1272,14 +1294,15 @@ export function AppProvider({ } else if (withResumeEpisode.seasonNumber && withResumeEpisode.episodeNumber) { setSelectedEpisode({ season: withResumeEpisode.seasonNumber, episode: withResumeEpisode.episodeNumber }); setBusy("Finding sources"); - appendVodSources(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); - appendHomeServerSources(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); - appendTelegramSources(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); + const target = await resolveEpisodeTarget(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); + appendVodSources(withResumeEpisode, target.season, target.episode); + appendHomeServerSources(withResumeEpisode, target.season, target.episode); + appendTelegramSources(withResumeEpisode, target.season, target.episode); const found = await getStreamsProgressive( addonsRef.current, withResumeEpisode, - withResumeEpisode.seasonNumber, - withResumeEpisode.episodeNumber, + target.season, + target.episode, mergeStreams ).catch(() => []); mergeStreams(found); @@ -1293,21 +1316,12 @@ export function AppProvider({ setStreams([]); setBusy("Finding sources"); - let targetSeason = season; - let targetEpisode = episode; - if (item.mediaType === "anime" || item.badge === "ANIME") { - const mapping = await fetchAniZipMapping(item.id).catch(() => null); - if (mapping) { - const converted = convertAniListToTmdbEpisode(mapping, episode); - targetSeason = converted.season; - targetEpisode = converted.episode; - } - } + const target = await resolveEpisodeTarget(item, season, episode); - appendVodSources(item, targetSeason, targetEpisode); - appendHomeServerSources(item, targetSeason, targetEpisode); - appendTelegramSources(item, targetSeason, targetEpisode); - const found = await getStreamsProgressive(addonsRef.current, item, targetSeason, targetEpisode, mergeStreams).catch(() => []); + appendVodSources(item, target.season, target.episode); + appendHomeServerSources(item, target.season, target.episode); + appendTelegramSources(item, target.season, target.episode); + const found = await getStreamsProgressive(addonsRef.current, item, target.season, target.episode, mergeStreams).catch(() => []); mergeStreams(found); setBusy(""); return found; @@ -1318,7 +1332,8 @@ export function AppProvider({ if (!selected || selected.mediaType !== "tv" || !selectedEpisode) return false; const nextEpisode = selectedEpisode.episode + 1; setSelectedEpisode({ season: selectedEpisode.season, episode: nextEpisode }); - const found = await getStreams(addonsRef.current, selected, selectedEpisode.season, nextEpisode).catch(() => []); + const target = await resolveEpisodeTarget(selected, selectedEpisode.season, nextEpisode); + const found = await getStreams(addonsRef.current, selected, target.season, target.episode).catch(() => []); setStreams(found); const best = found.find((stream) => stream.url); setActiveStream(best ?? null); @@ -1455,7 +1470,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 e109a1533..f0254e26f 100644 --- a/web/lib/tmdb.ts +++ b/web/lib/tmdb.ts @@ -95,6 +95,7 @@ export function genreNamesFromIds(ids?: number[]): string[] { } export function isAnime(item: TmdbItem | Partial): boolean { + if ("isAnime" in item && typeof item.isAnime === "boolean") return item.isAnime; if ("mediaType" in item && item.mediaType === "anime") return true; const genreIds = ("genre_ids" in item ? item.genre_ids : "genreIds" in item ? item.genreIds : []) ?? []; const genres = ("genres" in item ? item.genres : []) ?? []; @@ -106,11 +107,10 @@ export function isAnime(item: TmdbItem | Partial): boolean { } export function mapTmdbItem(item: TmdbItem, fallbackType: MediaType): MediaItem { - const mediaType: MediaType = isAnime(item) - ? "anime" - : 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 { @@ -122,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 ?? [] @@ -140,15 +141,16 @@ export async function tmdb(path: string, params: Record { if (value !== undefined && value !== "") url.searchParams.set(key, String(value)); }); if (Date.now() < tmdbCooldownUntil) { throw new Error("Rate limited — TMDB requests are pausing briefly."); } + const headers: 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 @@ -157,6 +159,7 @@ export 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) { @@ -804,7 +807,7 @@ 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", priorityConfig?: ProviderPriorityConfig): Promise { - if (priorityConfig?.customTvdbApiKey) { + if (priorityConfig) { const dispatched = await MetadataDispatcher.getEpisodes(tvId, "tv", seasonNumber, priorityConfig).catch(() => []); if (dispatched.length > 0) return dispatched; } @@ -1106,13 +1109,19 @@ export async function getTitlesForSearch( export async function getDetails(item: MediaItem, priorityConfig?: ProviderPriorityConfig) { try { + const details = await fetchDetailsPayload(item, priorityConfig?.customTmdbApiKey); + const mapped = mapTmdbItem({ ...details, media_type: item.mediaType }, item.mediaType); + const tvdbId = details.external_ids?.tvdb_id ?? item.tvdbId ?? null; + const imdbId = details.external_ids?.imdb_id ?? item.imdbId ?? null; + let resolvedMeta: MediaItem | null = null; - if (priorityConfig || item.mediaType === "anime") { - resolvedMeta = await MetadataDispatcher.getDetails(item.id, item.mediaType, priorityConfig).catch(() => null); + if (priorityConfig || item.isAnime || isAnime(item)) { + const lookupId: string | number = (item.isAnime || isAnime(item)) + ? (item.title || details.title || details.name || item.id) + : (tvdbId ?? item.id); + resolvedMeta = await MetadataDispatcher.getDetails(lookupId, item.mediaType, priorityConfig).catch(() => null); } - const details = await fetchDetailsPayload(item, priorityConfig?.customTmdbApiKey); - const mapped = mapTmdbItem({ ...details, media_type: item.mediaType }, item.mediaType); 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"); @@ -1123,6 +1132,9 @@ export async function getDetails(item: MediaItem, priorityConfig?: ProviderPrior return { ...item, ...mapped, + id: item.id, // Preserve canonical TMDB ID + anilistId: resolvedMeta?.anilistId ?? item.anilistId ?? null, + tvdbId: tvdbId ?? resolvedMeta?.tvdbId ?? item.tvdbId ?? null, ...(resolvedMeta ? { title: resolvedMeta.title || mapped.title, overview: resolvedMeta.overview || mapped.overview, @@ -1132,7 +1144,7 @@ export async function getDetails(item: MediaItem, priorityConfig?: ProviderPrior backdrop: resolvedMeta.backdrop || mapped.backdrop } : {}), rating: resolvedMeta?.rating || mapped.rating || item.rating, - imdbId: details.external_ids?.imdb_id ?? item.imdbId ?? null, + imdbId, genres: (details.genres ?? []).map((genre) => genre.name).filter(Boolean), status: resolvedMeta?.status || (details.status ?? null), budget: details.budget ?? null, diff --git a/web/lib/types.ts b/web/lib/types.ts index ce5dc4c09..3302e3a29 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,4 +471,3 @@ export interface AppSettings { metadataTvProviders: string[]; metadataAnimeProviders: string[]; } -