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: [],