From d1fe17dbc968d607a598c3d7e9cc8796e810bae2 Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 7 Oct 2026 15:22:40 +0300 Subject: [PATCH 1/2] fix(web): load VOD collections and native addon items Collections backed by an addon catalog failed with "Some sources could not be loaded", and their shows opened with "No episodes found". Addons installed on Android reach the web through cloud sync with an install id (_) and their catalogs, resources, types and idPrefixes only inside `manifest`. The collection lookup matched neither the id nor a catalog, so it threw before any request. normalizeAddon now falls back to the manifest's fields. The web also had no notion of addon titles without a TMDB identity: they got a positive hash id and every lookup went to TMDB. Port Android's AddonNativeCatalog as lib/addonNative.ts: a stable negative id with the same formula as Android (watch history ids agree), a localStorage registry mapping it back to the addon, details and episodes from the addon's /meta, and streams from the owning addon by the episode's own video id. TMDB logo/provider/review lookups, tracker writes and MDBList ratings skip ids below 1. --- web/lib/addonNative.ts | 228 ++++++++++++++++++++++++++++++++ web/lib/addons.ts | 37 ++++-- web/lib/mdblist.ts | 2 +- web/lib/store.tsx | 4 +- web/lib/sync.ts | 12 +- web/lib/tmdb.ts | 18 ++- web/lib/types.ts | 3 + web/tests/addon-native.test.cjs | 123 +++++++++++++++++ web/tests/load.cjs | 2 +- 9 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 web/lib/addonNative.ts create mode 100644 web/tests/addon-native.test.cjs diff --git a/web/lib/addonNative.ts b/web/lib/addonNative.ts new file mode 100644 index 000000000..36139140d --- /dev/null +++ b/web/lib/addonNative.ts @@ -0,0 +1,228 @@ +import { jsonRequest, proxiedUrl } from "./http"; +import { loadStored, saveStored } from "./storage"; +import type { EpisodeInfo, InstalledAddon, MediaItem, MediaType } from "./types"; + +/** + * Native addon items, ported from the Android app's AddonNativeCatalog: addon catalog + * entries with no TMDB/IMDb identity whose addon serves its own metadata, such as a + * broadcaster's VOD catalogue. Matching those to TMDB would hide everything TMDB doesn't + * list, so they keep a stable negative id, take details and episodes from the addon's + * /meta, and request streams by the addon's own video ids. + * + * Details, the player and watch history only carry the numeric id, so a registry in + * localStorage maps it back to the addon. The id formula matches Android's, so a cloud + * watch-history entry written by either app points at the same show. + */ + +type Entry = { addonId: string; manifestUrl: string; metaId: string; type: "movie" | "series"; title: string; poster?: string }; + +export type AddonMetaPreview = { id?: string; name?: string; title?: string; poster?: string; background?: string; description?: string }; + +type AddonMetaVideo = { + id?: string; + title?: string; + name?: string; + season?: number; + episode?: number; + released?: string; + thumbnail?: string; + overview?: string; + description?: string; +}; + +type AddonMeta = AddonMetaPreview & { releaseInfo?: string; year?: string | number; released?: string; videos?: AddonMetaVideo[] }; + +const REGISTRY_KEY = "arvio.web.addonNativeItems.v1"; +const MAX_ENTRIES = 2000; +const META_TTL_MS = 30 * 60 * 1000; +// Ids TMDB lookups understand and the anime id families keep their existing handling. +const NON_NATIVE_ID_FAMILIES = ["tt", "tmdb:", "imdb:", "kitsu:", "mal:", "anilist:", "anidb:", "tvdb:"]; + +let registry: Map | null = null; +let saveTimer: ReturnType | null = null; +const metas = new Map }>(); + +// Java's String.hashCode, so ids agree with the Android app. +function javaHash(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) hash = (Math.imul(31, hash) + value.charCodeAt(index)) | 0; + return hash; +} + +export function nativeItemId(addonId: string, metaId: string) { + return -Math.max(1, javaHash(`native:${addonId}:${metaId}`) & 0x7fffffff); +} + +function entries() { + if (registry) return registry; + registry = new Map(); + const saved = loadStored>(REGISTRY_KEY, {}); + for (const [key, entry] of Object.entries(saved)) { + const id = Number(key); + if (id < 0 && entry?.metaId && entry.manifestUrl) registry.set(id, entry); + } + return registry; +} + +function persistSoon() { + if (saveTimer || typeof window === "undefined") return; + saveTimer = setTimeout(() => { + saveTimer = null; + const map = entries(); + // Re-registered items move to the end, so the least recently seen go first. + while (map.size > MAX_ENTRIES) map.delete(map.keys().next().value!); + saveStored(REGISTRY_KEY, Object.fromEntries(map)); + }, 500); +} + +/** + * True when [addon] serves its own metadata for [contentId]: its manifest offers a `meta` + * resource whose id prefixes cover that id. + */ +export function addonServesOwnMeta(addon: InstalledAddon, type: "movie" | "series", contentId: string) { + const id = contentId.trim().toLowerCase(); + if (!id || addon.enabled === false || NON_NATIVE_ID_FAMILIES.some((family) => id.startsWith(family))) return false; + const aliases = type === "movie" ? ["movie", "film"] : ["series", "tv", "show"]; + return (addon.resources ?? []).some((resource) => { + const name = typeof resource === "string" ? resource : resource.name; + if (name?.toLowerCase() !== "meta") return false; + const types = typeof resource === "string" ? undefined : resource.types; + if (types?.length && !types.some((value) => aliases.includes(value.trim().toLowerCase()))) return false; + // An addon that names no prefix at all can't be said to own any id. + const prefixes = (typeof resource === "string" ? undefined : resource.idPrefixes) ?? addon.idPrefixes ?? []; + return prefixes.some((prefix) => prefix.trim() && id.startsWith(prefix.trim().toLowerCase())); + }); +} + +function cardFor(id: number, entry: Entry): MediaItem { + return { + id, + title: entry.title, + overview: "", + year: "", + mediaType: entry.type === "series" ? "tv" : "movie", + image: entry.poster ?? "", + backdrop: null, + rating: "", + duration: "", + addonNativeId: entry.metaId, + addonNativeAddonId: entry.addonId + }; +} + +/** Builds (and remembers) the card for a catalog entry, or null when it has no id or title. */ +export function registerNativeItem(addon: InstalledAddon, meta: AddonMetaPreview, mediaType: MediaType): MediaItem | null { + const metaId = meta.id?.trim(); + const title = (meta.name ?? meta.title)?.trim(); + if (!metaId || !title) return null; + const id = nativeItemId(addon.id, metaId); + const entry: Entry = { + addonId: addon.id, + manifestUrl: addon.manifestUrl, + metaId, + type: mediaType === "tv" ? "series" : "movie", + title, + ...(meta.poster ? { poster: meta.poster } : {}) + }; + const map = entries(); + if (JSON.stringify(map.get(id)) !== JSON.stringify(entry)) { + map.delete(id); + map.set(id, entry); + persistSoon(); + } + return { ...cardFor(id, entry), overview: meta.description ?? "", backdrop: meta.background ?? null }; +} + +export function isAddonNative(item: { id: number }) { + return item.id < 0 && entries().has(item.id); +} + +function manifestBase(manifestUrl: string) { + const [path, query = ""] = manifestUrl.split("?"); + return { base: path.replace(/\/manifest\.json$/, "").replace(/\/+$/, ""), query }; +} + +async function fetchMeta(entry: Entry) { + const { base, query } = manifestBase(entry.manifestUrl); + const url = `${base}/meta/${entry.type}/${encodeURIComponent(entry.metaId)}.json${query ? `?${query}` : ""}`; + const payload = await jsonRequest<{ meta?: AddonMeta }>(url) + .catch(() => jsonRequest<{ meta?: AddonMeta }>(proxiedUrl(url))); + return payload?.meta ?? null; +} + +function loadMeta(id: number, entry: Entry) { + const cached = metas.get(id); + if (cached && Date.now() - cached.at < META_TTL_MS) return cached.meta; + const meta = fetchMeta(entry).catch(() => null); + metas.set(id, { at: Date.now(), meta }); + // A failure is not remembered, so the next open retries. + void meta.then((value) => { if (!value && metas.get(id)?.meta === meta) metas.delete(id); }); + return meta; +} + +function yearOf(value: unknown) { + return /(19|20)\d{2}/.exec(String(value ?? ""))?.[0]; +} + +/** Full details from the addon's /meta; the catalog card when that fails. Null for other items. */ +export async function getNativeDetails(item: MediaItem): Promise { + const entry = entries().get(item.id); + if (!entry) return null; + const base = { ...cardFor(item.id, entry), ...item }; + const meta = await loadMeta(item.id, entry); + if (!meta) return base; + const videos = meta.videos ?? []; + const seasonNumbers = [...new Set(videos.map((video) => video.season).filter((season): season is number => typeof season === "number" && season > 0))] + .sort((a, b) => a - b); + const firstRelease = videos.map((video) => video.released).filter(Boolean).sort()[0]; + return { + ...base, + title: meta.name?.trim() || base.title, + overview: meta.description?.trim() || base.overview, + image: meta.poster || base.image, + backdrop: meta.background || base.backdrop, + year: yearOf(meta.releaseInfo ?? meta.year ?? meta.released) ?? yearOf(firstRelease) ?? base.year, + seasons: base.mediaType === "tv" ? seasonNumbers.map((seasonNumber) => ({ + id: seasonNumber, + seasonNumber, + name: `Season ${seasonNumber}`, + episodeCount: videos.filter((video) => video.season === seasonNumber).length + })) : [], + numberOfSeasons: base.mediaType === "tv" ? seasonNumbers.length : null, + numberOfEpisodes: base.mediaType === "tv" ? videos.length : null + }; +} + +export async function getNativeSeasonEpisodes(id: number, season: number): Promise { + const entry = entries().get(id); + if (!entry) return []; + const meta = await loadMeta(id, entry); + return (meta?.videos ?? []) + .filter((video): video is AddonMetaVideo & { episode: number } => video.season === season && typeof video.episode === "number") + .sort((a, b) => a.episode - b.episode) + .map((video) => ({ + // Episode ids only need to be unique within the show for the UI. + id: javaHash(video.id ?? `${season}:${video.episode}`), + episodeNumber: video.episode, + seasonNumber: season, + name: video.title?.trim() || video.name?.trim() || `Episode ${video.episode}`, + overview: video.overview ?? video.description ?? "", + still: video.thumbnail || undefined, + airDate: video.released?.slice(0, 10) ?? "" + })); +} + +/** + * The addon's own ids to request streams with: the show id for a movie, the matching + * video's id for an episode. Null when [item] isn't a native addon item. + */ +export async function nativeStreamTarget(item: MediaItem, season?: number, episode?: number) { + const entry = entries().get(item.id); + if (!entry) return null; + const target = { metaId: entry.metaId, type: entry.type }; + if (entry.type === "movie") return { ...target, ids: [entry.metaId] }; + if (!season || !episode) return { ...target, ids: [] as string[] }; + const meta = await loadMeta(item.id, entry); + const video = meta?.videos?.find((candidate) => candidate.season === season && candidate.episode === episode); + return { ...target, ids: video?.id ? [video.id] : [] }; +} diff --git a/web/lib/addons.ts b/web/lib/addons.ts index 934c21816..8f092b9cd 100644 --- a/web/lib/addons.ts +++ b/web/lib/addons.ts @@ -1,3 +1,4 @@ +import { addonServesOwnMeta, nativeStreamTarget } from "./addonNative"; import { jsonRequest, proxiedUrl } from "./http"; import { hasResolverConfig } from "./config"; import { getResolverStreamsProgressive } from "./resolver"; @@ -109,6 +110,10 @@ function normalizeResources(resources: unknown) { }); } +function stringList(values: unknown) { + return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string") : undefined; +} + function normalizeCatalogs(catalogs: unknown) { return Array.isArray(catalogs) ? catalogs.filter((catalog): catalog is AddonCatalog => Boolean(catalog && typeof catalog === "object")) : []; } @@ -122,11 +127,18 @@ export function normalizeAddon(addon: unknown): InstalledAddon | null { logo?: string | null; background?: string | null; isEnabled?: boolean; + manifest?: RawManifest | null; }; const manifestUrl = manifestUrlFor(raw as InstalledAddon); if (!manifestUrl) return null; const id = typeof raw.id === "string" && raw.id ? raw.id : manifestUrl; const enabled = raw.enabled !== false && raw.isEnabled !== false; + // Android-installed entries carry catalogs/resources/types/idPrefixes only + // inside `manifest`; without this fallback their collections and catalog + // rows cannot find the addon on web. + const manifest = raw.manifest && typeof raw.manifest === "object" ? raw.manifest : undefined; + const pick = (own: T[] | undefined, fallback: unknown) => + Array.isArray(own) && own.length ? own : Array.isArray(fallback) ? fallback : own; return { // CRITICAL: preserve every field we don't understand. The Android app's // addon entries carry `type`/`url`/`transportUrl`/`isInstalled`/`manifest` @@ -140,10 +152,10 @@ export function normalizeAddon(addon: unknown): InstalledAddon | null { version: typeof raw.version === "string" && raw.version ? raw.version : "1.0.0", manifestUrl, description: raw.description ?? null, - catalogs: normalizeCatalogs(raw.catalogs), - resources: normalizeResources(raw.resources), - types: Array.isArray(raw.types) ? raw.types.filter((type): type is string => typeof type === "string") : undefined, - idPrefixes: Array.isArray(raw.idPrefixes) ? raw.idPrefixes.filter((prefix): prefix is string => typeof prefix === "string") : undefined, + catalogs: normalizeCatalogs(pick(raw.catalogs, manifest?.catalogs)), + resources: normalizeResources(pick(raw.resources, manifest?.resources)), + types: stringList(pick(raw.types, manifest?.types)), + idPrefixes: stringList(pick(raw.idPrefixes, manifest?.idPrefixes)), logo: raw.logo ?? null, background: raw.background ?? null, enabled, @@ -248,6 +260,13 @@ export async function getStreamsProgressive( ) { const update = onUpdate ? (streams: StreamSource[], batch: StreamSource[]) => onUpdate(streams.filter(stream => !isInformationalAddonStream(stream)), batch.filter(stream => !isInformationalAddonStream(stream))) : undefined; + // Native addon items are only known to the addon that serves them, by its own ids. + const native = await nativeStreamTarget(item, season, episode); + if (native) { + if (!native.ids.length) return []; + const owners = addons.filter((addon) => addonServesOwnMeta(addon, native.type, native.metaId)); + return getBrowserStreamsProgressive(owners, item, season, episode, onUpdate, native.ids); + } if (hasResolverConfig()) { const browserPromise = getBrowserStreamsProgressive(addons, item, season, episode, update) .catch((error) => { @@ -274,17 +293,19 @@ async function getBrowserStreamsProgressive( item: MediaItem, season?: number, episode?: number, - onUpdate?: (streams: StreamSource[], batch: StreamSource[]) => void + onUpdate?: (streams: StreamSource[], batch: StreamSource[]) => void, + nativeIds?: string[] ) { const type = item.mediaType === "tv" ? "series" : "movie"; - const ids = streamIds(item, season, episode); + const ids = nativeIds ?? streamIds(item, season, episode); const enabled = addons.filter((addon) => addon.enabled !== false && manifestUrlFor(addon) && supportsResource(addon, "stream")); const subtitleAddons = addons.filter((addon) => addon.enabled !== false && manifestUrlFor(addon) && supportsResource(addon, "subtitles")); // OpenSubtitles is always available as a built-in subtitle source (like the // Android app) — most stream addons don't provide subtitles at all. Exact // manifest match only: some users carry an OpenSubtitles entry with a broken - // base path (…/subtitles/manifest.json) that returns empty results. - if (!subtitleAddons.some((addon) => manifestUrlFor(addon) === BUILTIN_OPENSUBTITLES.manifestUrl)) { + // base path (…/subtitles/manifest.json) that returns empty results. It only + // knows IMDb/TMDB ids, so native addon items skip it. + if (!nativeIds && !subtitleAddons.some((addon) => manifestUrlFor(addon) === BUILTIN_OPENSUBTITLES.manifestUrl)) { subtitleAddons.push(BUILTIN_OPENSUBTITLES); } let aggregate: StreamSource[] = []; diff --git a/web/lib/mdblist.ts b/web/lib/mdblist.ts index 5c67eb7f1..4a57172cb 100644 --- a/web/lib/mdblist.ts +++ b/web/lib/mdblist.ts @@ -120,7 +120,7 @@ export class MdbListClient { } async externalRatings(mediaType: "movie" | "tv", tmdbId: number): Promise { - if (!this.isConnected || !tmdbId) return []; + if (!this.isConnected || !(tmdbId > 0)) return []; const cacheKey = `arvio.web.mdblist.ratings.v1:${mediaType}:${tmdbId}`; const cached = loadStored<{ at: number; ratings: MdbExternalRating[] } | null>(cacheKey, null); if (cached && Date.now() - cached.at < 12 * 60 * 60 * 1000) return cached.ratings; diff --git a/web/lib/store.tsx b/web/lib/store.tsx index a8527468c..52e91577d 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -9,6 +9,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { LanguageProvider } from "./i18n"; import { shouldRefreshAutomatically } from "./automaticRefresh"; import { getStreams, getStreamsProgressive, installAddon as installAddonManifest, loadLocalAddons, normalizeAddons, saveLocalAddons } from "./addons"; +import { isAddonNative } from "./addonNative"; import { AuthClient, SESSION_KEY, decodeJwtPayload } from "./auth"; import { config, getAuthPortalUrl, isDisabledTelegramSource, TELEGRAM_DISABLED_MESSAGE } from "./config"; import { defaultCatalogs, mergeCatalogs } from "./catalogs"; @@ -1689,7 +1690,8 @@ export function AppProvider({ setBusy("Opening details"); setStreams([]); const priorityConfig = getPriorityConfig(settingsRef.current); - const resolvedId = item.id > 0 ? item.id : await resolveTmdbId(item).catch(() => null); + // Native addon items keep their own (negative) id; their details come from the addon. + const resolvedId = item.id > 0 || isAddonNative(item) ? item.id : await resolveTmdbId(item).catch(() => null); if (sourceGeneration.current !== generation) return; if (!resolvedId && !item.isHomeServer) { setBusy(""); setToast("Metadata could not be matched for this title."); return; } const detailsTarget = item.isHomeServer && item.tmdbId ? { ...item, id: item.tmdbId } : { ...item, id: resolvedId ?? item.id }; diff --git a/web/lib/sync.ts b/web/lib/sync.ts index 786e63c96..1e8eb0491 100644 --- a/web/lib/sync.ts +++ b/web/lib/sync.ts @@ -134,6 +134,7 @@ async function writeAll(operation: (client: SyncClient) => Promise, profil export async function syncSeasonWatched( item: SyncMediaRef, season: number, episodes: number[], watched: boolean ): Promise { + if (item.tmdbId <= 0) return; await writeAll(async client => { if (client === simklClient) { await simklClient.markSeasonWatched(item, season, watched); @@ -157,11 +158,12 @@ class TrackingRouter implements SyncClient { async watched(type: "movies" | "shows", feature: "watched" | "continueWatching" = "watched") { return readAll(feature, (client) => client.watched(type)); } - addToWatchlist(item: SyncMediaRef) { return writeAll((client) => client.addToWatchlist(item)); } - removeFromWatchlist(item: SyncMediaRef) { return writeAll((client) => client.removeFromWatchlist(item)); } - addToHistory(item: SyncMediaRef) { return writeAll((client) => client.addToHistory(item)); } - removeFromHistory(item: SyncMediaRef) { return writeAll((client) => client.removeFromHistory(item)); } - dismissFromContinueWatching(item: SyncMediaRef) { return writeAll((client) => client.dismissFromContinueWatching(item)); } + // Ids below 1 (native addon items) have no tracker identity. + addToWatchlist(item: SyncMediaRef) { return item.tmdbId > 0 ? writeAll((client) => client.addToWatchlist(item)) : Promise.resolve(); } + removeFromWatchlist(item: SyncMediaRef) { return item.tmdbId > 0 ? writeAll((client) => client.removeFromWatchlist(item)) : Promise.resolve(); } + addToHistory(item: SyncMediaRef) { return item.tmdbId > 0 ? writeAll((client) => client.addToHistory(item)) : Promise.resolve(); } + removeFromHistory(item: SyncMediaRef) { return item.tmdbId > 0 ? writeAll((client) => client.removeFromHistory(item)) : Promise.resolve(); } + dismissFromContinueWatching(item: SyncMediaRef) { return item.tmdbId > 0 ? writeAll((client) => client.dismissFromContinueWatching(item)) : Promise.resolve(); } scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }) { if (!Number.isSafeInteger(item.tmdbId) || item.tmdbId <= 0) return Promise.resolve(); if (item.mediaType === "tv" && (!Number.isInteger(item.season) || item.season! < 0 || diff --git a/web/lib/tmdb.ts b/web/lib/tmdb.ts index f955bab68..130466598 100644 --- a/web/lib/tmdb.ts +++ b/web/lib/tmdb.ts @@ -1,3 +1,4 @@ +import { addonServesOwnMeta, getNativeDetails, getNativeSeasonEpisodes, isAddonNative, registerNativeItem } from "./addonNative"; import { config } from "./config"; import { apiProxiedUrl, jsonRequest, proxiedUrl } from "./http"; import { fetchAniZipMappingByTmdbId } from "./metadata/anizip"; @@ -692,13 +693,18 @@ async function loadAddonCatalog(catalog: CatalogConfig, addons: InstalledAddon[] const url = proxiedUrl(`${base}/catalog/${encodeURIComponent(catalogType)}/${encodeURIComponent(catalogId)}${extra}.json`); const payload = await jsonRequest<{ metas?: StremioMeta[] }>(url); const metas = payload.metas ?? []; - const hydrated = await Promise.all(metas.map((meta) => hydrateAddonMeta(meta, catalog.mediaType, language).catch(() => null))); + const hydrated = await Promise.all(metas.map((meta) => hydrateAddonMeta(meta, catalog.mediaType, language, addon).catch(() => null))); return hydrated.filter((item): item is MediaItem => Boolean(item)); } -async function hydrateAddonMeta(meta: StremioMeta, preferred: CatalogConfig["mediaType"], language: string): Promise { +async function hydrateAddonMeta(meta: StremioMeta, preferred: CatalogConfig["mediaType"], language: string, addon?: InstalledAddon): Promise { const tmdbId = numberValue(meta.tmdb_id); const mediaType: MediaType = String(meta.type ?? preferred ?? "").toLowerCase().includes("series") || preferred === "tv" ? "tv" : "movie"; + // Items the addon describes itself open from its own /meta instead of a TMDB guess. + if (addon && !tmdbId && !meta.imdb_id && meta.id && addonServesOwnMeta(addon, mediaType === "tv" ? "series" : "movie", meta.id)) { + const native = registerNativeItem(addon, meta, mediaType); + if (native) return native; + } if (tmdbId) { const detailed = await getBasicItem(mediaType, tmdbId, language).catch(() => null); if (detailed) return detailed; @@ -924,6 +930,8 @@ function persistLogoCache() { /** Title-treatment (clearlogo) URL for a movie/show — mirrors MediaRepository.getImages logo pick. */ export async function getLogoUrl(item: { mediaType: MediaType; id: number }): Promise { + // Ids below 1 are never TMDB titles (native addon items, unmatched entries). + if (item.id <= 0) return null; const key = `${item.mediaType}:${item.id}`; restoreLogoCache(); if (logoCache.has(key)) return logoCache.get(key) ?? null; @@ -998,6 +1006,7 @@ function persistCardMetaCache() { } export async function getCardMeta(item: { mediaType: MediaType; id: number }): Promise<{ runtime: number; image: string; backdrop: string | null; imdbId: string | null }> { + if (item.id <= 0) return { runtime: 0, image: "", backdrop: null, imdbId: null }; const key = `${item.mediaType}:${item.id}`; restoreCardMetaCache(); const cached = cardMetaCache.get(key); @@ -1031,6 +1040,7 @@ export async function getCardMeta(item: { mediaType: MediaType; id: number }): P } export async function getCardProviders(item: { mediaType: MediaType; id: number }): Promise { + if (item.id <= 0) return []; const key = `${item.mediaType}:${item.id}`; restoreProviderCache(); const cached = providerCache.get(key); @@ -1110,6 +1120,7 @@ export async function getSeasonEpisodes( priorityConfig?: ProviderPriorityConfig, metadataContext?: SeasonMetadataContext ): Promise { + if (isAddonNative({ id: tvId })) return getNativeSeasonEpisodes(tvId, seasonNumber).catch(() => []); const metadataType: MetadataMediaType = metadataContext?.isAnime ? "anime" : "tv"; const externalConfig = providersBeforeTmdb(metadataType, priorityConfig); const lookup: MetadataLookupIds = { @@ -1218,6 +1229,7 @@ function writeSeasonEpisodesCache(key: string, episodes: EpisodeInfo[]) { } export async function getReviews(item: { mediaType: MediaType; id: number }): Promise { + if (item.id <= 0) return []; try { const response = await tmdb<{ results?: Array<{ id: string; author?: string; content?: string; created_at?: string; author_details?: { rating?: number | null; avatar_path?: string | null } }> }>( `${item.mediaType}/${item.id}/reviews` @@ -1362,6 +1374,7 @@ const basicItemCache = new Map(); /** Lightweight details fetch (no append_to_response) with an in-memory cache — used to hydrate catalog rows. */ export async function getCollectionPreview(item: MediaItem, language: string): Promise { + if (isAddonNative(item)) return (await getNativeDetails(item)) ?? item; const details = await tmdb(`${item.mediaType}/${item.id}`, { language, append_to_response: "external_ids" }); return { ...item, ...mapTmdbItem(details, item.mediaType), budget: details.budget, imdbId: details.external_ids?.imdb_id ?? item.imdbId, genres: details.genres?.map(genre => genre.name) }; @@ -1446,6 +1459,7 @@ export async function getTitlesForSearch( } export async function getDetails(item: MediaItem, priorityConfig?: ProviderPriorityConfig) { + if (isAddonNative(item)) return (await getNativeDetails(item).catch(() => null)) ?? item; try { const details = await fetchDetailsPayload(item, priorityConfig?.customTmdbApiKey); const mapped = mapTmdbItem({ ...details, media_type: item.mediaType }, item.mediaType); diff --git a/web/lib/types.ts b/web/lib/types.ts index 4c5db151c..8ec878093 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -55,6 +55,9 @@ export interface MediaItem { homeServerId?: string | null; homeServerType?: "plex" | "jellyfin" | "emby" | null; tmdbId?: number | null; + // Native addon items (no TMDB identity, see addonNative.ts): the addon's own id. + addonNativeId?: string | null; + addonNativeAddonId?: string | null; // External metadata IDs & classification isAnime?: boolean; anilistId?: number | null; diff --git a/web/tests/addon-native.test.cjs b/web/tests/addon-native.test.cjs new file mode 100644 index 000000000..8e02a8cc9 --- /dev/null +++ b/web/tests/addon-native.test.cjs @@ -0,0 +1,123 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { load, storage } = require('./load.cjs'); +// Values built inside the vm sandbox have their own Array prototype. +const plain = value => JSON.parse(JSON.stringify(value)); + +// An addon as the Android app syncs it: install id, catalogs/resources only inside `manifest`. +const androidAddon = { + id: 'example.vod_0123456789ab', name: 'Example VOD', version: '1.0.0', isEnabled: true, + url: 'https://vod.example.test/key/manifest.json', + manifest: { + id: 'example.vod', name: 'Example VOD', types: ['series'], idPrefixes: ['exvod:'], + catalogs: [{ type: 'series', id: 'ex-news', name: 'News' }], + resources: [{ name: 'catalog', types: [] }, { name: 'meta', types: ['series'], idPrefixes: ['exvod:'] }, { name: 'stream', types: ['series'], idPrefixes: ['exvod:'] }] + } +}; + +const meta = { + id: 'exvod:news:show', type: 'series', name: 'Show', description: 'About the show', poster: 'https://img.test/p.jpg', background: 'https://img.test/b.jpg', + videos: [ + { id: 'exvod:news:show:1:2', title: 'Second', season: 1, episode: 2, released: '2026-02-03T00:00:00.000Z', thumbnail: 'https://img.test/2.jpg' }, + { id: 'exvod:news:show:1:1', title: 'First', season: 1, episode: 1, released: '2025-01-02T00:00:00.000Z' }, + { id: 'exvod:news:show:2:1', season: 2, episode: 1 } + ] +}; + +function fixture() { + const calls = []; + const http = { + proxiedUrl: url => `proxy:${url}`, + jsonRequest: async url => { + calls.push(url); + if (url.includes('/meta/series/')) return { meta }; + if (url.includes('/stream/series/')) return { streams: [{ name: 'HD', url: 'https://vod.example.test/key/vodplay/x.m3u8' }] }; + if (url.includes('/subtitles/')) return { subtitles: [] }; + return {}; + } + }; + const store = storage(); + const native = load('lib/addonNative.ts', { './http': http, './storage': store }); + const addons = load('lib/addons.ts', { + './http': http, './addonNative': native, + './config': { hasResolverConfig: () => false }, + './resolver': { getResolverStreamsProgressive: async () => { throw new Error('native items must not use the resolver'); } }, + './storage': store, + './streamCompatibility': { isBrowserPlayableStream: () => true, isIosPlayableStream: () => true }, + './addonStreamInfo': { isInformationalAddonStream: () => false } + }); + return { calls, native, addons }; +} + +test('Android-synced addons take their catalogs, resources and id prefixes from the manifest', () => { + const { addons } = fixture(); + const addon = addons.normalizeAddon(androidAddon); + assert.equal(addon.id, 'example.vod_0123456789ab'); + assert.deepEqual(plain(addon.catalogs.map(c => `${c.type}:${c.id}`)), ['series:ex-news']); + assert.deepEqual(plain(addon.resources.map(r => typeof r === 'string' ? r : r.name)), ['catalog', 'meta', 'stream']); + assert.deepEqual(plain(addon.idPrefixes), ['exvod:']); + // A web-installed entry keeps its own top-level fields. + const own = addons.normalizeAddon({ ...androidAddon, catalogs: [{ type: 'movie', id: 'top', name: 'Top' }] }); + assert.deepEqual(plain(own.catalogs.map(c => c.id)), ['top']); +}); + +test('native ids match the Android app and only self-describing addon ids qualify', () => { + const { native, addons } = fixture(); + assert.equal(native.nativeItemId('example.vod_0123456789ab', 'exvod:news:evening-show'), -433811470); + const addon = addons.normalizeAddon(androidAddon); + assert.equal(native.addonServesOwnMeta(addon, 'series', 'exvod:news:show'), true); + assert.equal(native.addonServesOwnMeta(addon, 'series', 'tt1234567'), false); + assert.equal(native.addonServesOwnMeta(addon, 'movie', 'exvod:news:show'), false); + assert.equal(native.addonServesOwnMeta({ ...addon, resources: ['stream'] }, 'series', 'exvod:news:show'), false); +}); + +test('native shows get details, seasons and sorted episodes from the addon meta', async () => { + const { native, addons, calls } = fixture(); + const card = native.registerNativeItem(addons.normalizeAddon(androidAddon), meta, 'tv'); + assert.ok(card.id < 0); + assert.equal(card.addonNativeId, 'exvod:news:show'); + assert.equal(native.isAddonNative(card), true); + assert.equal(native.isAddonNative({ id: -5 }), false); + const details = await native.getNativeDetails(card); + assert.equal(details.overview, 'About the show'); + assert.equal(details.year, '2025'); + assert.deepEqual(plain(details.seasons.map(s => [s.seasonNumber, s.episodeCount])), [[1, 2], [2, 1]]); + const episodes = await native.getNativeSeasonEpisodes(card.id, 1); + assert.deepEqual(plain(episodes.map(e => [e.episodeNumber, e.name, e.airDate])), [[1, 'First', '2025-01-02'], [2, 'Second', '2026-02-03']]); + assert.equal((await native.getNativeSeasonEpisodes(card.id, 2))[0].name, 'Episode 1'); + assert.ok(calls[0].endsWith('/key/meta/series/exvod%3Anews%3Ashow.json')); + assert.equal(calls.filter(url => url.includes('/meta/')).length, 1, 'meta is cached'); +}); + +test('a VOD collection finds the Android-synced addon and shows its titles natively', async () => { + const { addons } = fixture(); + const calls = []; + const http = { proxiedUrl: url => url, apiProxiedUrl: url => url, jsonRequest: async url => { + calls.push(url); + if (url.includes('/catalog/series/ex-news.json')) return { metas: [{ id: 'exvod:news:show', type: 'series', name: 'Show', poster: 'https://img.test/p.jpg' }] }; + return {}; + } }; + const api = load('lib/tmdb.ts', { + './config': { config: {} }, './storage': storage(), './http': http, + './metadata/anizip': {}, './metadata/dispatcher': {}, './mediaImages': { tmdbImageUrl: () => '' } + }, { window: { location: { origin: 'https://web.invalid' } } }); + const items = await api.loadCollectionSource({ kind: 'ADDON_CATALOG', mediaType: 'tv', addonId: 'example.vod', addonCatalogType: 'series', addonCatalogId: 'ex-news' }, + 'he', [addons.normalizeAddon(androidAddon)]); + assert.equal(items.length, 1); + assert.ok(items[0].id < 0); + assert.equal(items[0].addonNativeId, 'exvod:news:show'); + assert.equal(calls.some(url => url.includes('/tv/')), false, 'no TMDB lookup for native titles'); +}); + +test('native episodes request streams from the owning addon by the video id', async () => { + const { native, addons, calls } = fixture(); + const owner = addons.normalizeAddon(androidAddon); + const other = addons.normalizeAddon({ id: 'other', name: 'Other', manifestUrl: 'https://other.test/manifest.json', resources: ['stream'], types: ['series'] }); + const card = native.registerNativeItem(owner, meta, 'tv'); + const streams = await addons.getStreamsProgressive([owner, other], card, 1, 2); + assert.equal(streams.length, 1); + const streamCalls = calls.filter(url => url.includes('/stream/')); + assert.deepEqual(plain(streamCalls), ['https://vod.example.test/key/stream/series/exvod%3Anews%3Ashow%3A1%3A2.json']); + assert.equal(calls.some(url => url.includes('opensubtitles')), false); + assert.deepEqual(plain(await addons.getStreamsProgressive([owner], card, 9, 9)), []); +}); diff --git a/web/tests/load.cjs b/web/tests/load.cjs index 47c4bac1d..6b2fc9ba2 100644 --- a/web/tests/load.cjs +++ b/web/tests/load.cjs @@ -8,7 +8,7 @@ exports.load = (relative, mocks = {}, globals = {}) => { const code = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText; const module = { exports: {} }; vm.runInNewContext(code, { - module, exports: module.exports, require: (name) => { if (name in mocks) return mocks[name]; if (name === './config' || name === '@/lib/config') return exports.load('lib/config.ts', {}, globals); if (name === './selfhostRuntimeConfig' || name === '../selfhostRuntimeConfig') return exports.load('lib/selfhostRuntimeConfig.ts', {}, globals); if (name === './watchlistChanges') return exports.load('lib/watchlistChanges.ts'); if (name === './storage') return exports.load('lib/storage.ts'); if (name === './addonChanges') return exports.load('lib/addonChanges.ts'); if (name === './stalker') return exports.load('lib/stalker.ts', { './http': mocks['./http'] ?? {} }); if (name === './iptvSession') return exports.load('lib/iptvSession.ts'); if (name.startsWith('node:') || ['ipaddr.js', 'undici'].includes(name)) return require(name); throw new Error(`Unmocked dependency ${name}`); }, + module, exports: module.exports, require: (name) => { if (name in mocks) return mocks[name]; if (name === './config' || name === '@/lib/config') return exports.load('lib/config.ts', {}, globals); if (name === './selfhostRuntimeConfig' || name === '../selfhostRuntimeConfig') return exports.load('lib/selfhostRuntimeConfig.ts', {}, globals); if (name === './watchlistChanges') return exports.load('lib/watchlistChanges.ts'); if (name === './storage') return exports.load('lib/storage.ts'); if (name === './addonChanges') return exports.load('lib/addonChanges.ts'); if (name === './stalker') return exports.load('lib/stalker.ts', { './http': mocks['./http'] ?? {} }); if (name === './addonNative') return exports.load('lib/addonNative.ts', { './http': mocks['./http'] ?? {}, ...(mocks['./storage'] ? { './storage': mocks['./storage'] } : {}) }, globals); if (name === './iptvSession') return exports.load('lib/iptvSession.ts'); if (name.startsWith('node:') || ['ipaddr.js', 'undici'].includes(name)) return require(name); throw new Error(`Unmocked dependency ${name}`); }, URL, URLSearchParams, Headers, Request, Response, ReadableStream, TextEncoder, TextDecoder, Uint8Array, Buffer, Date, Map, Set, AbortController, AbortSignal, structuredClone, setTimeout, clearTimeout, atob, btoa, crypto: crypto.webcrypto, console, process: { env: {} }, ...globals }, { filename }); return module.exports; From f4abdc5f38567a57ea98ab178e2bf9a86647b9b2 Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 7 Oct 2026 15:22:40 +0300 Subject: [PATCH 2/2] feat(web): restore the Favorite TV home row Android shows the profile's favorite IPTV channels as a "Favorite TV" row on Home; the web dropped the `favorite_tv` catalog entirely (its earlier web version filled the row with TMDB's top-rated shows instead). Keep `favorite_tv` in the catalog list so the row sits where the user placed it (or after the other rows when the list lacks it, as on Android) and render it from the IPTV favorites, in the user's order, minus hidden and locked groups. Playlists only load in Live TV and are too heavy for Home, so each loaded playlist saves a small per-profile copy of just the favorite channels (lib/favoriteTv.ts) and Home paints the row from it. The playlist loads quietly in the background only when no copy exists or a favorite was added since. Now/next shows once a playlist is loaded. Adds the "Favorite channels on Home" switch to Settings > TV (IPTV), synced with Android's `iptvFavoritesOnHome` profile setting. The new wording is plain English, so no translation dictionaries change. --- web/app/dev/stabilization/fixture.tsx | 2 +- web/app/globals.css | 9 ++++ web/components/home/HomeScreen.tsx | 5 ++ web/components/media/FavoriteTvRail.tsx | 61 ++++++++++++++++++++++ web/components/settings/SettingsScreen.tsx | 6 +++ web/lib/catalogs.ts | 3 +- web/lib/cloud.ts | 2 + web/lib/favoriteTv.ts | 48 +++++++++++++++++ web/lib/store.tsx | 54 ++++++++++++++++--- web/lib/tmdb.ts | 16 +----- web/lib/types.ts | 2 + web/tests/favorite-tv.test.cjs | 45 ++++++++++++++++ web/tests/partner-ui/store.tsx | 2 +- 13 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 web/components/media/FavoriteTvRail.tsx create mode 100644 web/lib/favoriteTv.ts create mode 100644 web/tests/favorite-tv.test.cjs diff --git a/web/app/dev/stabilization/fixture.tsx b/web/app/dev/stabilization/fixture.tsx index 10648d86f..237bf5aab 100644 --- a/web/app/dev/stabilization/fixture.tsx +++ b/web/app/dev/stabilization/fixture.tsx @@ -73,7 +73,7 @@ export function StabilizationFixture({ testLiveUrl, useSourceUrls = false }: { t const app = { view: "app", section: page === "onboarding" ? "home" : page, addonsReady: true, closeDetails: noop, settings, setSettings, updateSettings: (patch: object) => setSettings((old) => ({ ...old, ...patch })), - iptvSnapshot: visibleSnapshot, loadIptvGuide, refreshIptv: async () => {}, busy: "", auth: null, activeProfile: { id: "fixture", name: "Test profile" }, + iptvSnapshot: visibleSnapshot, loadIptvGuide, refreshIptv: async () => {}, favoriteTvChannels: [], busy: "", auth: null, activeProfile: { id: "fixture", name: "Test profile" }, profiles: [], addons: showOnboarding ? sportsAddons.map(addon => ({ ...addon, resources: ["catalog"] })) : sportsAddons, watchlist: media, continueWatching: media.slice(0, 4), traktConnected: true, simklConnected: true, mdblistConnected: false, openDetails: (item: MediaItem) => setToast(`Selected: ${item.title}`), openContextMenu: noop, isWatched: () => false, loadTrackerLibrary, loadTraktLists, loadTraktListItems: async () => media, diff --git a/web/app/globals.css b/web/app/globals.css index 1b3a7b883..d5a172b36 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2483,6 +2483,15 @@ a.login-submit { background: rgba(255, 255, 255, 0.92); } +/* Favorite TV row: channel logos sit whole on the card instead of filling it */ +.favorite-tv-poster img { + object-fit: contain; + padding: 12% 18%; +} +.favorite-tv-live { + letter-spacing: 0.06em; +} + /* Lazy rail skeletons (rows load as they scroll into view) */ .rail-skeleton { min-height: clamp(220px, 28vh, 320px); diff --git a/web/components/home/HomeScreen.tsx b/web/components/home/HomeScreen.tsx index 603afc659..1f770517e 100644 --- a/web/components/home/HomeScreen.tsx +++ b/web/components/home/HomeScreen.tsx @@ -12,6 +12,8 @@ import { useApp } from "@/lib/store"; import { LazyRail } from "@/components/media/LazyRail"; import { CustomCollectionRail } from "@/components/media/CustomCollectionRail"; import { MediaRail } from "@/components/media/MediaRail"; +import { FavoriteTvRail } from "@/components/media/FavoriteTvRail"; +import { FAVORITE_TV_CATALOG_ID } from "@/lib/favoriteTv"; import type { Category, MediaItem } from "@/lib/types"; export function HomeScreen({ onCollectionOpenChange }: { onCollectionOpenChange?: (open: boolean) => void }) { @@ -190,6 +192,7 @@ export function HomeScreen({ onCollectionOpenChange }: { onCollectionOpenChange? ))} {collectionHomeCatalogs(catalogConfigs).map((catalog, index) => ( + catalog.id === FAVORITE_TV_CATALOG_ID ? : String(catalog.kind).toUpperCase() === "COLLECTION_RAIL" ? : @@ -202,6 +205,8 @@ export function HomeScreen({ onCollectionOpenChange }: { onCollectionOpenChange? onLoaded={seedHeroFromRow} /> ))} + {/* Like Android, a catalog list without the row still shows it, after the others. */} + {!catalogConfigs.some((catalog) => catalog.id === FAVORITE_TV_CATALOG_ID) && } {homeServerRows.map((category) => ( ))} diff --git a/web/components/media/FavoriteTvRail.tsx b/web/components/media/FavoriteTvRail.tsx new file mode 100644 index 000000000..ea2d360fa --- /dev/null +++ b/web/components/media/FavoriteTvRail.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Tv } from "lucide-react"; +import { useTranslation } from "@/lib/i18n"; +import { useApp } from "@/lib/store"; +import type { IptvChannel } from "@/lib/types"; +import { RailScroller } from "./RailScroller"; + +// Guide requests are per channel; the first screenful is what the row can show. +const GUIDE_CHANNELS = 16; + +/** Home row of the profile's favorite IPTV channels (Android "Favorite TV"). */ +export function FavoriteTvRail({ title }: { title?: string }) { + const { favoriteTvChannels, iptvSnapshot, loadIptvGuide, playChannel } = useApp(); + const label = title || "Favorite TV"; + // The guide only applies to a loaded playlist; the saved copy shows channels alone. + const playlistLoaded = Boolean((iptvSnapshot.allChannels ?? iptvSnapshot.channels).length); + useEffect(() => { + if (playlistLoaded && favoriteTvChannels.length) void loadIptvGuide(favoriteTvChannels.slice(0, GUIDE_CHANNELS)); + }, [playlistLoaded, favoriteTvChannels, loadIptvGuide]); + if (!favoriteTvChannels.length) return null; + return ( +
+
+

{label}

+
+ + {favoriteTvChannels.map((channel) => ( + + ))} + +
+ ); +} + +function FavoriteTvCard({ channel, now, onPlay }: { + channel: IptvChannel; + now?: { title: string; startUtcMillis: number; endUtcMillis: number }; + onPlay: (channel: IptvChannel) => void; +}) { + const translateUi = useTranslation(); + const [logoFailed, setLogoFailed] = useState(false); + const onAir = now && now.endUtcMillis > Date.now() ? now : undefined; + const progress = onAir ? Math.min(100, Math.max(0, ((Date.now() - onAir.startUtcMillis) / (onAir.endUtcMillis - onAir.startUtcMillis)) * 100)) : 0; + return ( + + ); +} diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index c80f4447b..059a8a6fc 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -2108,6 +2108,12 @@ function TvSettingsSection() { ]} /> + + updateSettings({ iptvFavoritesOnHome: v })} + /> +

{playlists.length} {translateUi(" playlist(s) configured. These are cloud-saved and used by the TV page.")}

diff --git a/web/lib/catalogs.ts b/web/lib/catalogs.ts index 3944f7abb..bfe232f2d 100644 --- a/web/lib/catalogs.ts +++ b/web/lib/catalogs.ts @@ -1,6 +1,8 @@ import type { CatalogConfig } from "./types"; export const defaultCatalogs: CatalogConfig[] = [ + // The profile's favorite IPTV channels (see favoriteTv.ts), not a TMDB list. + { id: "favorite_tv", name: "Favorite TV", sourceType: "preinstalled", mediaType: "tv", enabled: true, isPreinstalled: true }, { id: "trending_movies", name: "Trending in Movies", sourceType: "mdblist", mediaType: "movie", sourceUrl: "https://mdblist.com/lists/snoak/trending-movies", enabled: true, isPreinstalled: true }, { id: "trending_tv", name: "Trending in Shows", sourceType: "mdblist", mediaType: "tv", sourceUrl: "https://mdblist.com/lists/snoak/trakt-s-trending-shows", enabled: true, isPreinstalled: true }, { id: "trending_anime", name: "Trending in Anime", sourceType: "mdblist", mediaType: "tv", sourceUrl: "https://mdblist.com/lists/snoak/trending-anime-shows", enabled: true, isPreinstalled: true }, @@ -84,7 +86,6 @@ export function mergeCatalogs(saved: CatalogConfig[] | undefined, hiddenIds: str const cleaned = (saved ?? defaultCatalogs) .filter(isValidCatalog) .map(normalizedCatalog) - .filter((catalog) => catalog.id !== "favorite_tv") .filter((catalog) => !( ["sports", "popular_live_tv"].includes(catalog.id) && catalog.sourceType === "preinstalled" && diff --git a/web/lib/cloud.ts b/web/lib/cloud.ts index 20287e6fd..5487f6a7a 100644 --- a/web/lib/cloud.ts +++ b/web/lib/cloud.ts @@ -417,6 +417,7 @@ function androidProfileSettings(settings: AppSettings) { trailerInCards: settings.trailerInCards, clockFormat: settings.clockFormat, showBudget: settings.showBudget, + iptvFavoritesOnHome: settings.iptvFavoritesOnHome, showLoadingStats: settings.showLoadingStats, spoilerBlurEnabled: settings.spoilerBlur, volumeBoostDb: Math.max(0, Math.min(15, Number(settings.volumeBoostDb) || 0)), @@ -455,6 +456,7 @@ function settingsFromAndroidProfile(value: unknown): Partial { if ("trailerInCards" in state) partial.trailerInCards = Boolean(state.trailerInCards); if ("clockFormat" in state) partial.clockFormat = String(state.clockFormat) === "12h" ? "12h" : "24h"; if ("showBudget" in state) partial.showBudget = Boolean(state.showBudget); + if ("iptvFavoritesOnHome" in state) partial.iptvFavoritesOnHome = state.iptvFavoritesOnHome !== false; if ("showLoadingStats" in state) partial.showLoadingStats = Boolean(state.showLoadingStats); if ("spoilerBlurEnabled" in state) partial.spoilerBlur = Boolean(state.spoilerBlurEnabled); if ("volumeBoostDb" in state) partial.volumeBoostDb = Math.max(0, Math.min(15, Number(state.volumeBoostDb) || 0)); diff --git a/web/lib/favoriteTv.ts b/web/lib/favoriteTv.ts new file mode 100644 index 000000000..da411f8dc --- /dev/null +++ b/web/lib/favoriteTv.ts @@ -0,0 +1,48 @@ +import { channelIdentityIndex, resolveChannelReferences } from "./iptvSession"; +import { loadStored, saveStored } from "./storage"; +import type { IptvChannel } from "./types"; + +/** + * The Home "Favorite TV" row, as on Android (HomeViewModel.buildFavoriteTvOutcome): the + * profile's favorite IPTV channels in the user's own order. Playlists only load in Live TV, + * which is far too heavy for Home, so each loaded playlist leaves a copy of just the + * favorite channels and Home paints the row from that copy. + */ + +export const FAVORITE_TV_CATALOG_ID = "favorite_tv"; + +const CACHE_KEY = "arvio.web.favoriteTv.v1"; +const MAX_SCOPES = 8; + +type Entry = { signature: string; at: number; favoriteIds: string[]; channels: IptvChannel[] }; + +export function resolveFavoriteChannels(favoriteIds: string[], channels: IptvChannel[]) { + return resolveChannelReferences(favoriteIds, channelIdentityIndex(channels)); +} + +/** The saved copy for this account/profile and playlist set, or null when there is none. */ +export function cachedFavoriteChannels(scope: string, signature: string): Omit | null { + const entry = loadStored>(CACHE_KEY, {})[scope]; + if (!entry || entry.signature !== signature || !Array.isArray(entry.channels)) return null; + return { favoriteIds: Array.isArray(entry.favoriteIds) ? entry.favoriteIds : [], channels: entry.channels }; +} + +export function saveFavoriteChannels(scope: string, signature: string, favoriteIds: string[], channels: IptvChannel[]) { + const cache = loadStored>(CACHE_KEY, {}); + const previous = cache[scope]; + if (previous?.signature === signature && JSON.stringify(previous.favoriteIds) === JSON.stringify(favoriteIds) + && JSON.stringify(previous.channels) === JSON.stringify(channels)) return; + const next = { ...cache, [scope]: { signature, at: Date.now(), favoriteIds, channels } }; + saveStored(CACHE_KEY, Object.fromEntries(Object.entries(next).sort((a, b) => b[1].at - a[1].at).slice(0, MAX_SCOPES))); +} + +/** + * True when the saved copy cannot answer for every favorite: none was saved yet, or a + * favorite was added (on this or another device) after it was built. A favorite the + * playlist no longer carries is not a reason to reload it again. + */ +export function favoriteCopyIsStale(copy: Omit | null, favoriteIds: string[]) { + if (!copy) return favoriteIds.length > 0; + const known = new Set(copy.favoriteIds); + return favoriteIds.some((id) => !known.has(id)); +} diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 52e91577d..26176a891 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -25,7 +25,8 @@ import { playbackPlan } from "./streamCompatibility"; import { prepareBrowserStream } from "./prepareBrowserStream"; import { reportHomeServerPlayback } from "./homeServerPlayback"; import { loadHomeServerRows } from "./homeserver"; -import { buildXtreamCatchupUrl, iptvPlaylistSignature, loadIptvChannelIdentities, loadIptvGuideForChannels, loadIptvSnapshot, loadPlaylists, savePlaylists } from "./iptv"; +import { accessibleChannels, buildXtreamCatchupUrl, iptvPlaylistSignature, loadIptvChannelIdentities, loadIptvGuideForChannels, loadIptvSnapshot, loadPlaylists, savePlaylists } from "./iptv"; +import { cachedFavoriteChannels, favoriteCopyIsStale, resolveFavoriteChannels, saveFavoriteChannels } from "./favoriteTv"; import { isCurrentIptvSnapshot, recordTvPlayback } from "./iptvSession"; import { dedupeMedia, historyToItem, hydrateTraktItems, traktItemToMedia, traktPlaybackToMedia, traktUpNextToMedia } from "./mappers"; import { loadStored, purgeLegacyStorage, removeStored, saveStored } from "./storage"; @@ -222,6 +223,7 @@ export const defaultSettings: AppSettings = { oledBlack: false, clockFormat: "24h", showBudget: true, + iptvFavoritesOnHome: true, smoothScrolling: true, spoilerBlur: false, accentColor: "arctic", @@ -544,8 +546,10 @@ export interface AppStore { setToast: (value: string | null) => void; refreshData: (profileIdOverride?: string | null) => Promise; - refreshIptv: () => Promise; + refreshIptv: (options?: { quiet?: boolean }) => Promise; loadIptvGuide: (channels: IptvChannel[]) => Promise; + /** The Home "Favorite TV" row's channels, in the user's favorite order. */ + favoriteTvChannels: IptvChannel[]; openDetails: (item: MediaItem) => Promise; closeDetails: () => void; playStream: (stream: StreamSource, options?: { forceTranscode?: boolean; forceRemux?: boolean; forceBrowser?: boolean }) => void; @@ -1230,7 +1234,9 @@ export function AppProvider({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeProfileId]); - const refreshIptv = useCallback(async () => { + // `quiet` is the Home background load for the Favorite TV row: no status text, no + // error toast — Live TV reports playlist problems when the user opens it. + const refreshIptv = useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => { const currentSettings = settingsRef.current; const profileId = activeProfileIdRef.current; const account = authClient.session?.userId; @@ -1239,7 +1245,7 @@ export function AppProvider({ if (iptvRefresh.current?.key === key) return iptvRefresh.current.promise; const isCurrent = () => activeProfileIdRef.current === profileId && authClient.session?.userId === account && iptvPlaylistSignature(settingsRef.current.iptvPlaylists) + (settingsRef.current.iptvStalkerUrl ? JSON.stringify([settingsRef.current.iptvStalkerUrl, settingsRef.current.iptvStalkerMac]) : "") === signature; const run = (async () => { - setBusy("Loading TV"); + if (!quiet) setBusy("Loading TV"); try { const loadedIptv = await loadIptvSnapshot( currentSettings.iptvPlaylists, @@ -1253,9 +1259,9 @@ export function AppProvider({ // on re-entry instead of rebuilding ~139k channels every visit. if (isCurrent()) setIptvSnapshot({ ...loadedIptv, signature, scopeKey: `${account ?? "local"}:${profileId ?? "local"}` }); } catch (error) { - if (isCurrent()) setToast(error instanceof Error ? error.message : "Failed to load Live TV"); + if (isCurrent() && !quiet) setToast(error instanceof Error ? error.message : "Failed to load Live TV"); } finally { - if (isCurrent()) setBusy(""); + if (isCurrent() && !quiet) setBusy(""); } })(); iptvRefresh.current = { key, promise: run }; @@ -1277,6 +1283,39 @@ export function AppProvider({ return () => { cancelled = true; }; }, [iptvSnapshot.allChannels, iptvSnapshot.channels, iptvSnapshot.signature, iptvSnapshot.identitiesLoaded, settings.iptvPlaylists, settings.iptvStalkerUrl, settings.iptvStalkerMac, settings.customUserAgent, activeProfileId]); + // Home "Favorite TV" row (see favoriteTv.ts): from the loaded playlist when Live TV + // has one, otherwise from the copy the last loaded playlist left behind. + const iptvScopeKey = `${auth?.userId ?? "local"}:${activeProfileId ?? "local"}`; + const iptvSourceSignature = iptvPlaylistSignature(settings.iptvPlaylists) + (settings.iptvStalkerUrl ? JSON.stringify([settings.iptvStalkerUrl, settings.iptvStalkerMac]) : ""); + const currentIptvChannels = isCurrentIptvSnapshot(iptvSnapshot, iptvScopeKey, iptvSourceSignature) ? iptvSnapshot.allChannels ?? iptvSnapshot.channels : null; + const favoriteTvChannels = useMemo(() => { + const favorites = settings.favoriteChannelIds ?? []; + if (!settings.iptvFavoritesOnHome || !favorites.length) return []; + const source = currentIptvChannels?.length ? currentIptvChannels : cachedFavoriteChannels(iptvScopeKey, iptvSourceSignature)?.channels ?? []; + return resolveFavoriteChannels(favorites, accessibleChannels(source, [...(settings.hiddenGroupIds ?? []), ...(settings.lockedIptvGroupIds ?? [])])); + }, [currentIptvChannels, iptvScopeKey, iptvSourceSignature, settings.favoriteChannelIds, settings.iptvFavoritesOnHome, settings.hiddenGroupIds, settings.lockedIptvGroupIds]); + useEffect(() => { + // An empty (cold or failed) playlist says nothing about the favorites: keep the copy. + if (!currentIptvChannels?.length) return; + const favorites = settings.favoriteChannelIds ?? []; + saveFavoriteChannels(iptvScopeKey, iptvSourceSignature, favorites, resolveFavoriteChannels(favorites, currentIptvChannels)); + }, [currentIptvChannels, iptvScopeKey, iptvSourceSignature, settings.favoriteChannelIds]); + // Without a usable copy, load the playlist once in the background so the row can appear. + const favoriteTvLoadKey = useRef(""); + useEffect(() => { + const favorites = settings.favoriteChannelIds ?? []; + if (view !== "app" || section !== "home" || !settings.iptvFavoritesOnHome || !favorites.length || currentIptvChannels?.length) return; + if (!settings.iptvPlaylists.some((playlist) => playlist.enabled && playlist.m3uUrl.trim()) && !settings.iptvStalkerUrl) return; + const key = `${iptvScopeKey}:${iptvSourceSignature}`; + if (favoriteTvLoadKey.current === key || !favoriteCopyIsStale(cachedFavoriteChannels(iptvScopeKey, iptvSourceSignature), favorites)) return; + // Home's own rows go first. + const timer = setTimeout(() => { + favoriteTvLoadKey.current = key; + void refreshIptv({ quiet: true }); + }, 2500); + return () => clearTimeout(timer); + }, [view, section, currentIptvChannels, iptvScopeKey, iptvSourceSignature, refreshIptv, settings.favoriteChannelIds, settings.iptvFavoritesOnHome, settings.iptvPlaylists, settings.iptvStalkerUrl]); + const loadIptvGuide = useCallback(async (channels: IptvChannel[]) => { if (!channels.length) return; const currentSettings = settingsRef.current; @@ -2707,6 +2746,7 @@ export function AppProvider({ refreshData, refreshIptv, loadIptvGuide, + favoriteTvChannels, openDetails, closeDetails, playStream, @@ -2743,7 +2783,7 @@ export function AppProvider({ section, categories, catalogConfigs, loadCatalogRow, homeServerRows, continueWatching, watchlist, isWatched, hero, heroPreview, selected, streams, selectedEpisode, loadEpisodeStreams, advanceEpisode, activeStream, activeChannel, addons, addonsReady, iptvSnapshot, query, results, searchState, settingsSyncState, settings, auth, traktConnected, mdblistConnected, simklConnected, trackingPreferences, deviceCode, simklDeviceCode, busy, toast, updateSettings, refreshData, openDetails, closeDetails, playStream, playTrailer, playChannel, recordChannelPlayback, playCatchup, closePlayer, - refreshIptv, loadIptvGuide, + refreshIptv, loadIptvGuide, favoriteTvChannels, installAddon, removeAddon, setAddonsState, signIn, signOut, beginTrakt, pollTrakt, disconnectTrakt, connectMdblist, disconnectMdblist, beginSimkl, pollSimkl, disconnectSimkl, updateTrackingPreferences, loadTraktLists, loadTraktListItems, loadTrackerLibrary, diff --git a/web/lib/tmdb.ts b/web/lib/tmdb.ts index 130466598..c4f511ce9 100644 --- a/web/lib/tmdb.ts +++ b/web/lib/tmdb.ts @@ -259,20 +259,8 @@ export async function loadCatalog( }; } - if (catalog.sourceType === "preinstalled" && catalog.id === "favorite_tv") { - const response = await tmdb("discover/tv", { - language, - sort_by: "vote_average.desc", - "vote_count.gte": 500 - }); - return { - id: catalog.id, - title: catalog.name, - items: response.results.map((x) => mapTmdbItem(x, "tv")), - sourceLabel: "TMDB", - layout: catalog.layout ?? "landscape" - }; - } + // Favorite TV is the profile's IPTV favorites; Home renders it from the channel list. + if (catalog.sourceType === "preinstalled" && catalog.id === "favorite_tv") return null; if (catalog.sourceType === "trakt" && catalog.sourceUrl) { const refs = await loadTraktPublicList(catalog.sourceUrl) as Array<{ type: MediaType; id: number }>; diff --git a/web/lib/types.ts b/web/lib/types.ts index 8ec878093..3dec0f4bc 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -499,6 +499,8 @@ export interface AppSettings { oledBlack: boolean; clockFormat: "12h" | "24h"; showBudget: boolean; + /** Settings > IPTV "Favorite channels on Home" (Android `iptvFavoritesOnHome`). */ + iptvFavoritesOnHome: boolean; smoothScrolling: boolean; spoilerBlur: boolean; accentColor: string; diff --git a/web/tests/favorite-tv.test.cjs b/web/tests/favorite-tv.test.cjs new file mode 100644 index 000000000..0be8aa8cf --- /dev/null +++ b/web/tests/favorite-tv.test.cjs @@ -0,0 +1,45 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { load, storage } = require('./load.cjs'); + +// Values built inside the vm sandbox have their own Array prototype. +const plain = value => JSON.parse(JSON.stringify(value)); +const channel = (id, extra = {}) => ({ id, name: id.toUpperCase(), group: 'News', streamUrl: `https://tv.test/${id}.m3u8`, ...extra }); + +function favoriteTv() { + const store = storage(); + return { api: load('lib/favoriteTv.ts', { './storage': store }), store }; +} + +test('favorites keep the user order and match Android cloud ids', () => { + const { api } = favoriteTv(); + const channels = [channel('list_2:a'), channel('list_2:b', { cloudId: 'list_2:m3u:b:1-x' }), channel('list_2:c', { syncAliases: ['old-c'] })]; + const resolved = api.resolveFavoriteChannels(['old-c', 'missing', 'list_2:m3u:b:1-x', 'list_2:a', 'list_2:a'], channels); + assert.deepEqual(plain(resolved.map(c => c.id)), ['list_2:c', 'list_2:b', 'list_2:a']); +}); + +test('the saved copy belongs to one profile and playlist set', () => { + const { api } = favoriteTv(); + api.saveFavoriteChannels('acct:p1', 'sig-1', ['a', 'b'], [channel('a')]); + assert.deepEqual(plain(api.cachedFavoriteChannels('acct:p1', 'sig-1')), { favoriteIds: ['a', 'b'], channels: [channel('a')] }); + assert.equal(api.cachedFavoriteChannels('acct:p1', 'sig-2'), null, 'a changed playlist set invalidates it'); + assert.equal(api.cachedFavoriteChannels('acct:p2', 'sig-1'), null, 'another profile has its own copy'); +}); + +test('only a missing copy or a newly added favorite reloads the playlist', () => { + const { api } = favoriteTv(); + assert.equal(api.favoriteCopyIsStale(null, ['a']), true); + assert.equal(api.favoriteCopyIsStale(null, []), false); + const copy = { favoriteIds: ['a', 'gone'], channels: [channel('a')] }; + assert.equal(api.favoriteCopyIsStale(copy, ['a', 'gone']), false, 'a favorite the playlist dropped is not a reason to reload'); + assert.equal(api.favoriteCopyIsStale(copy, ['a']), false); + assert.equal(api.favoriteCopyIsStale(copy, ['new', 'a']), true); +}); + +test('the Favorite TV catalog stays in the saved list so Home keeps its position', () => { + const { mergeCatalogs, defaultCatalogs } = load('lib/catalogs.ts'); + const saved = [{ id: 'trending_movies', name: 'Trending', sourceType: 'mdblist', sourceUrl: 'https://mdblist.com/lists/x', enabled: true }, + { id: 'favorite_tv', name: 'Favorite TV', sourceType: 'preinstalled', mediaType: 'tv', enabled: true, isPreinstalled: true }]; + assert.deepEqual(plain(mergeCatalogs(saved).map(c => c.id)), ['trending_movies', 'favorite_tv']); + assert.equal(defaultCatalogs[0].id, 'favorite_tv'); +}); diff --git a/web/tests/partner-ui/store.tsx b/web/tests/partner-ui/store.tsx index a61a1dd99..fa7d583c7 100644 --- a/web/tests/partner-ui/store.tsx +++ b/web/tests/partner-ui/store.tsx @@ -25,7 +25,7 @@ const settings: AppSettings = { subtitleOffsetMs: 0, subtitleOffset: "bottom", subtitleStyle: "outline", subtitleStylized: false, filterSubtitlesByLanguage: false, removeHearingImpaired: false, aiSubtitlesEnabled: false, aiSubtitleModel: "off", aiAutoSelect: false, aiApiKey: "", defaultPlayer: "browser", cardLayoutMode: "landscape", deviceModeOverride: "auto", - oledBlack: true, clockFormat: "24h", showBudget: false, smoothScrolling: false, spoilerBlur: false, accentColor: "arctic", + oledBlack: true, clockFormat: "24h", showBudget: false, iptvFavoritesOnHome: false, smoothScrolling: false, spoilerBlur: false, accentColor: "arctic", dnsProvider: "system", showLoadingStats: false, customUserAgent: "", torrServerBaseUrl: "", skipProfileSelection: false, cardDensity: "comfortable", catalogs: [], hiddenCatalogIds: [], hiddenAddonCatalogIds: [], hiddenHomeServerCatalogIds: [], disabledAddonIds: [], homeServers: [], iptvPlaylists: [], iptvStalkerUrl: "", iptvStalkerMac: "", favoriteChannelIds: [], favoriteGroupIds: [],