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/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/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/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..26176a891 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"; @@ -24,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"; @@ -221,6 +223,7 @@ export const defaultSettings: AppSettings = { oledBlack: false, clockFormat: "24h", showBudget: true, + iptvFavoritesOnHome: true, smoothScrolling: true, spoilerBlur: false, accentColor: "arctic", @@ -543,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; @@ -1229,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; @@ -1238,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, @@ -1252,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 }; @@ -1276,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; @@ -1689,7 +1729,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 }; @@ -2705,6 +2746,7 @@ export function AppProvider({ refreshData, refreshIptv, loadIptvGuide, + favoriteTvChannels, openDetails, closeDetails, playStream, @@ -2741,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/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..c4f511ce9 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"; @@ -258,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 }>; @@ -692,13 +681,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 +918,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 +994,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 +1028,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 +1108,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 +1217,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 +1362,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 +1447,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..3dec0f4bc 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; @@ -496,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/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/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/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; 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: [],