From b13ea731c3cb9ca6cd211df109b64045f8ff65e6 Mon Sep 17 00:00:00 2001 From: Christopher Klint Date: Wed, 7 Oct 2026 14:21:25 +0200 Subject: [PATCH] fix(live): unblock iPhone first frames and bound app startup work --- server/src/directory-status-cache.test.ts | 66 +++++++++++++ server/src/directory-status-cache.ts | 36 +++++++ server/src/index.ts | 8 +- src/components/PlaybackLoading.test.tsx | 26 +++++ src/components/PlaybackLoading.tsx | 23 +++++ .../Player.epg.integration.test.tsx | 43 +++++++++ src/components/Player.tsx | 41 ++++++-- src/hooks/usePlayer.liveHls.test.tsx | 18 ++++ src/hooks/usePlayer.ts | 4 + src/pages/Home.test.tsx | 61 ++++++++++++ src/pages/Home.tsx | 35 ++++++- src/services/api.timeout.test.ts | 73 ++++++++++++++ src/services/api.ts | 95 ++++++++++++++----- src/stores/channelStore.bootstrap.test.ts | 49 ++++++++++ src/stores/channelStore.epgRefresh.test.ts | 37 ++++++++ src/stores/channelStore.ts | 26 ++--- src/stores/playerStore.test.ts | 25 +++++ src/stores/playerStore.ts | 23 ++++- src/utils/__tests__/pwa-cache.test.ts | 11 +++ src/utils/epg-batch.test.ts | 71 ++++++++++++++ src/utils/epg-batch.ts | 34 ++++--- src/utils/pwa-cache.ts | 2 +- 22 files changed, 742 insertions(+), 65 deletions(-) create mode 100644 server/src/directory-status-cache.test.ts create mode 100644 server/src/directory-status-cache.ts create mode 100644 src/components/PlaybackLoading.test.tsx create mode 100644 src/components/PlaybackLoading.tsx create mode 100644 src/pages/Home.test.tsx create mode 100644 src/services/api.timeout.test.ts create mode 100644 src/stores/channelStore.bootstrap.test.ts create mode 100644 src/stores/channelStore.epgRefresh.test.ts create mode 100644 src/utils/epg-batch.test.ts diff --git a/server/src/directory-status-cache.test.ts b/server/src/directory-status-cache.test.ts new file mode 100644 index 0000000..515dd9d --- /dev/null +++ b/server/src/directory-status-cache.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +import { expect, it, vi } from 'vitest'; +import { createDirectoryStatusCache } from './directory-status-cache.js'; + +it('serves the last real directory snapshot immediately while a cold-disk refresh is pending', async () => { + let now = 0; + let release!: (value: { count: number }) => void; + const read = vi.fn().mockResolvedValueOnce({ count: 238661 }).mockImplementationOnce(() => new Promise(resolve => { release = resolve; })); + const cache = createDirectoryStatusCache(read, () => 'catalog-a', { now: () => now, maxAgeMs: 30_000 }); + expect(await cache.get()).toEqual({ count: 238661 }); + now = 31_000; + expect(await cache.get()).toEqual({ count: 238661 }); + expect(await cache.get()).toEqual({ count: 238661 }); + expect(read).toHaveBeenCalledTimes(2); + release({ count: 238662 }); + await Promise.resolve(); await Promise.resolve(); + expect(await cache.get()).toEqual({ count: 238662 }); +}); + +it('does not serve a previous provider snapshot after its generation changes', async () => { + let catalog = 'a'; + const read = vi.fn().mockResolvedValueOnce({ count: 5 }).mockResolvedValueOnce({ count: 2 }); + const cache = createDirectoryStatusCache(read, () => catalog); + await cache.get(); catalog = 'b'; + expect(await cache.get()).toEqual({ count: 2 }); + expect(read).toHaveBeenCalledTimes(2); +}); + +it('cannot publish a delayed old-provider refresh over a new-provider snapshot', async () => { + let catalog = 'a'; let now = 0; + let release!: (value: { count: number }) => void; + const read = vi.fn().mockResolvedValueOnce({ count: 5 }) + .mockImplementationOnce(() => new Promise(resolve => { release = resolve; })) + .mockResolvedValueOnce({ count: 2 }); + const cache = createDirectoryStatusCache(read, () => catalog, { now: () => now, maxAgeMs: 1000 }); + await cache.get(); now = 2000; + expect(await cache.get()).toEqual({ count: 5 }); + catalog = 'b'; + expect(await cache.get()).toEqual({ count: 2 }); + release({ count: 999 }); + await new Promise(resolve => setImmediate(resolve)); + expect(await cache.get()).toEqual({ count: 2 }); +}); + +it('coalesces concurrent cold startup requests without inventing empty directory counts', async () => { + let release!: (value: { count: number }) => void; + const read = vi.fn(() => new Promise<{ count: number }>(resolve => { release = resolve; })); + const cache = createDirectoryStatusCache(read, () => 'a'); + const first = cache.get(); const second = cache.get(); + expect(read).toHaveBeenCalledOnce(); + release({ count: 17 }); + expect(await first).toEqual({ count: 17 }); + expect(await second).toEqual({ count: 17 }); +}); + +it('retains a real snapshot after refresh failure, but propagates a cold failure', async () => { + let now = 0; + const warn = vi.fn(); + const read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ count: 10 }).mockRejectedValueOnce(new Error('refresh failed')); + const cache = createDirectoryStatusCache(read, () => 'a', { now: () => now, maxAgeMs: 1000, onError: warn }); + await expect(cache.get()).rejects.toThrow('read failed'); + expect(await cache.get()).toEqual({ count: 10 }); + now = 2000; + expect(await cache.get()).toEqual({ count: 10 }); + await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce()); +}); diff --git a/server/src/directory-status-cache.ts b/server/src/directory-status-cache.ts new file mode 100644 index 0000000..9503828 --- /dev/null +++ b/server/src/directory-status-cache.ts @@ -0,0 +1,36 @@ +export function createDirectoryStatusCache(read: () => Promise, catalog: () => string, + options: { now?: () => number; maxAgeMs?: number; onError?: (error: unknown) => void } = {}) { + const now = options.now ?? Date.now; + const maxAgeMs = options.maxAgeMs ?? 30_000; + let cached: { catalog: string; value: T; at: number } | undefined; + let pending: { catalog: string; promise: Promise } | undefined; + let retryAt = 0; + const refresh = (generation: string) => { + if (pending?.catalog === generation) return pending.promise; + const promise = read().then(value => { + if (catalog() !== generation) throw new Error('Directory status snapshot superseded'); + cached = { catalog: generation, value, at: now() }; + retryAt = 0; + return value; + }).finally(() => { if (pending?.promise === promise) pending = undefined; }); + pending = { catalog: generation, promise }; + return promise; + }; + return { + async get(): Promise { + const generation = catalog(); + if (!cached || cached.catalog !== generation) return refresh(generation); + // Directory counts may lag a catalog write by one refresh interval. + // Never block startup on a second HDD scan; dynamic sync/crawl state is + // merged by the route after this real, generation-bound snapshot returns. + if (now() - cached.at >= maxAgeMs && now() >= retryAt) { + void refresh(generation).catch(error => { + if (catalog() !== generation) return; + retryAt = now() + maxAgeMs; + options.onError?.(error); + }); + } + return cached.value; + }, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts index 5ce575f..14a0c70 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,6 +35,7 @@ import { fetchXtreamStreamsByCategory, fetchXtreamShortEpg, fetchAllCategoryStre import { createOnDemandEpg } from './on-demand-epg.js'; import { createEpgReadWorker } from './epg-read-worker.js'; import { createChannelReadWorker } from './channel-read-worker.js'; +import { createDirectoryStatusCache } from './directory-status-cache.js'; import { saveCategorySnapshot, closeCategorySnapshotWorker, getCatalogGeneration, rotateCatalogGeneration } from './category-write-service.js'; import { createEpgWriteWorker } from './epg-write-worker.js'; import type { XtreamConfig } from './xtream.js'; @@ -368,6 +369,11 @@ app.get('/api/channels', async (req, res, next) => { const channelReader = createChannelReadWorker(DB_PATH, message => logger.warn(message)); // Keep dashboard counts independent of UK browsing when either SQLite read stalls. const channelStatusReader = createChannelReadWorker(DB_PATH, message => logger.warn(message)); +const directoryStatusCache = createDirectoryStatusCache(channelStatusReader.status, getCatalogGeneration, { + onError: () => logger.warn('Directory status background refresh failed; retaining the last snapshot'), +}); +// Warm the real directory snapshot before the next client opens the app. +void directoryStatusCache.get().catch(() => logger.warn('Directory status prewarm failed; next request will retry')); app.post('/api/channels/by-ids', async (req, res, next) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { @@ -2277,7 +2283,7 @@ app.put('/api/config', requireAuth, (req, res) => { // ---------- Sync ---------- app.get('/api/status', async (_req, res, next) => { - try { res.json(getStatus(await channelStatusReader.status())); } + try { res.json(getStatus(await directoryStatusCache.get())); } catch (error) { next(error); } }); diff --git a/src/components/PlaybackLoading.test.tsx b/src/components/PlaybackLoading.test.tsx new file mode 100644 index 0000000..0460d29 --- /dev/null +++ b/src/components/PlaybackLoading.test.tsx @@ -0,0 +1,26 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import PlaybackLoading from './PlaybackLoading'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let cleanup: (() => Promise) | undefined; +afterEach(async () => { await cleanup?.(); cleanup = undefined; vi.useRealTimers(); }); + +it('shows elapsed startup time and offers retry after a slow live start', async () => { + vi.useFakeTimers(); + const retry = vi.fn(); + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container); + cleanup = async () => { await act(async () => root.unmount()); container.remove(); }; + await act(async () => root.render()); + expect(container.textContent).toContain('Starting live stream'); + expect(container.querySelector('button')).toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(8000)); + expect(container.textContent).toContain('8s'); + expect(container.textContent).toContain('Taking longer than usual'); + await act(async () => (container.querySelector('button') as HTMLButtonElement).click()); + expect(retry).toHaveBeenCalledOnce(); + await act(async () => root.unmount()); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/components/PlaybackLoading.tsx b/src/components/PlaybackLoading.tsx new file mode 100644 index 0000000..ba9a81e --- /dev/null +++ b/src/components/PlaybackLoading.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +export default function PlaybackLoading({ live, onRetry }: { live: boolean; onRetry: () => void }) { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + const started = Date.now(); + const timer = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); + return () => clearInterval(timer); + }, []); + const slow = elapsed >= 8; + return ( +
+ + ); +} diff --git a/src/components/Player.epg.integration.test.tsx b/src/components/Player.epg.integration.test.tsx index 41c85b0..f29a6fe 100644 --- a/src/components/Player.epg.integration.test.tsx +++ b/src/components/Player.epg.integration.test.tsx @@ -141,6 +141,49 @@ describe('player channel-list EPG', () => { expect(useChannelStore.getState().programsByChannel.get(channel.id)?.[0]?.title).toBe('SportsCenter'); }); + it('loads only the player viewport guide and does not refetch it as distant group pages arrive', async () => { + const groups = Array.from({ length: 819 }, (_, index) => ({ ...channel, id: index === 0 ? channel.id : `live_${index}` })); + const batches: string[][] = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), window.location.origin); + if (url.pathname === '/api/epg/batch') { + const ids = url.searchParams.get('ids')!.split(','); + batches.push(ids); + return new Response(JSON.stringify({ programs: {} }), { status: 200 }); + } + if (url.pathname.startsWith('/api/epg/channel/')) return new Response(JSON.stringify({ programs: [] }), { status: 200 }); + throw new Error(`Unexpected request: ${url.pathname}`); + })); + usePlayerStore.setState({ currentChannel: channel, groupChannels: groups.slice(0, 200), channelListVisible: true }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); + expect(batches).toHaveLength(1); + expect(batches[0].length).toBeLessThanOrEqual(25); + expect(batches[0]).toContain(channel.id); + await act(async () => usePlayerStore.setState({ groupChannels: groups.slice(0, 400) })); + await act(async () => usePlayerStore.setState({ groupChannels: groups })); + expect(batches).toHaveLength(1); + }); + + it('aborts guide work when the live channel pane closes', async () => { + let batchSignal: AbortSignal | null | undefined; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, options?: RequestInit) => { + if (String(input).includes('/api/epg/batch')) { + batchSignal = options?.signal; + return new Promise(() => {}); + } + return new Response(JSON.stringify({ programs: [] }), { status: 200 }); + })); + usePlayerStore.setState({ currentChannel: channel, groupChannels: [channel], channelListVisible: true }); + container = document.createElement('div'); document.body.append(container); root = createRoot(container); + await act(async () => root.render()); + expect(batchSignal?.aborted).toBe(false); + await act(async () => usePlayerStore.setState({ channelListVisible: false })); + expect(batchSignal?.aborted).toBe(true); + }); + it('fills every ESPN row after an initially empty batch while the player stays open', async () => { vi.useFakeTimers(); const now = Date.now(); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index f1a8815..bccb757 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -24,6 +24,7 @@ import { getAbsoluteSkipTarget } from '../utils/media-progress'; import { shouldStartPlayerPlayback } from '../utils/player-lifecycle'; import { getCommercialMarkers } from '../utils/commercial-markers'; import { getCommercialPlayerUiState, isCommercialUndoKey } from '../utils/commercial-player-ui'; +import PlaybackLoading from './PlaybackLoading'; const OSD_TIMEOUT = 5000; const MOBILE = isMobile(); @@ -111,6 +112,27 @@ function LiveChannelList({ channels, currentId, onSelect }: { const listRef = useRef(null); const [epgMap, setEpgMap] = useState({}); const cachedPrograms = useChannelStore((s) => s.programsByChannel); + const [guideWindow, setGuideWindow] = useState<{ currentId: string; start: number } | null>(null); + const currentIndex = Math.max(0, channels.findIndex(ch => ch.id === currentId)); + const windowStart = guideWindow?.currentId === currentId ? guideWindow.start : Math.max(0, currentIndex - 4); + // A first click must not schedule guide work for an entire 800-channel group. + // Keep the selected channel plus a small scroll-aware viewport/overscan window. + const guideIds = [...new Set([currentId, ...channels.slice(windowStart, windowStart + 24).map(ch => ch.id)])].join(','); + const updateGuideWindow = () => { + const list = listRef.current; + if (!list) return; + const viewport = list.getBoundingClientRect(); + if (viewport.height <= 0) return; + const rows = list.querySelectorAll('[data-live-channel-row]'); + for (let index = 0; index < rows.length; index++) { + if (rows[index].getBoundingClientRect().bottom > viewport.top) { + const start = Math.max(0, index - 4); + setGuideWindow(previous => previous?.currentId === currentId && previous.start === start + ? previous : { currentId, start }); + break; + } + } + }; // Scroll current channel into view on mount useEffect(() => { @@ -121,17 +143,18 @@ function LiveChannelList({ channels, currentId, onSelect }: { // Retry briefly when the guide is still being populated, then refresh at // programme boundaries without requiring the user to close the player. useEffect(() => { - if (channels.length === 0) return; + if (!guideIds) return; + const controller = typeof AbortController === 'undefined' ? null : new AbortController(); let cancelled = false; let timer: ReturnType | undefined; let quickRetries = 0; - const ids = channels.map(ch => ch.id); + const ids = guideIds.split(','); const refresh = async () => { let missing = true; try { - const data = await fetchBatchEpg(ids); + const data = await fetchBatchEpg(ids, undefined, undefined, { signal: controller?.signal }); if (cancelled) return; - setEpgMap(data); + setEpgMap(previous => ({ ...previous, ...data })); missing = ids.some(id => !getCurrentEpg(data[id]).current); } catch { if (cancelled) return; @@ -140,13 +163,14 @@ function LiveChannelList({ channels, currentId, onSelect }: { timer = setTimeout(() => { void refresh(); }, delay); }; void refresh(); - return () => { cancelled = true; if (timer) clearTimeout(timer); }; - }, [channels]); + return () => { cancelled = true; controller?.abort(); if (timer) clearTimeout(timer); }; + }, [guideIds]); return (
{channels.map((ch) => ( @@ -797,10 +821,7 @@ export default function Player() { {/* Loading spinner */} {playerState.status === 'loading' && ( -
-
- Loading... -
+ )} {/* Error display */} diff --git a/src/hooks/usePlayer.liveHls.test.tsx b/src/hooks/usePlayer.liveHls.test.tsx index 222c504..b08409e 100644 --- a/src/hooks/usePlayer.liveHls.test.tsx +++ b/src/hooks/usePlayer.liveHls.test.tsx @@ -147,6 +147,24 @@ describe('signed live HLS startup and recovery', () => { expect(video.play).not.toHaveBeenCalled(); }); + it('starts native iPhone HLS from its first loaded frame without waiting for paused canplay', async () => { + // happy-dom load() synthesizes canplay; real paused WebKit can stop at loadeddata. + vi.spyOn(video, 'load').mockImplementation(() => {}); + let nativeSrc = ''; + Object.defineProperty(video, 'src', { configurable: true, get: () => nativeSrc, set: (url: string) => { nativeSrc = url; } }); + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); + vi.mocked(video.canPlayType).mockReturnValue('maybe'); + ranges = []; + await act(async () => hookRef.current?.play()); + await act(async () => video.dispatchEvent(new Event('loadeddata'))); + expect(video.play).toHaveBeenCalledOnce(); + expect(mocks.authorize).toHaveBeenCalledOnce(); + expect(mocks.instances).toHaveLength(0); + // WebKit may emit canplay only after playback has been requested. + await act(async () => video.dispatchEvent(new Event('canplay'))); + expect(video.play).toHaveBeenCalledOnce(); + }); + it.each(['iPhone', 'Safari'])( 'starts native %s HLS normally without twelve seconds of lead', async platform => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform === 'iPhone' diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 4c3e8f2..8479fdc 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -998,7 +998,11 @@ export function usePlayer(): { }; video.ondurationchange = updateHtml5Clock; video.onloadeddata = () => { + if (!isCurrentPlayback()) return; log.info(`HTML5 event: loadeddata, readyState=${video.readyState}`); + // Paused WebKit can load one frame without emitting canplay until + // play() is requested. Do not wait for a larger paused preload on iPhone. + if (nativeLiveHls && signedLiveHlsStartup) canPlay = true; armFiniteHlsStallTimer(); void completeHtml5Startup(); if (signedLiveHlsStartup) attemptPlay(); diff --git a/src/pages/Home.test.tsx b/src/pages/Home.test.tsx new file mode 100644 index 0000000..a331086 --- /dev/null +++ b/src/pages/Home.test.tsx @@ -0,0 +1,61 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Home from './Home'; +import { useChannelStore } from '../stores/channelStore'; +import { useFavoritesStore } from '../stores/favoritesStore'; + +let root: Root; +let container: HTMLElement; +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useChannelStore.setState({ _hydrated: false, backendConnection: 'unknown', contentTypeCounts: {}, programsByChannel: new Map(), isLoading: false, error: null, loadingMessage: '' }); + useFavoritesStore.setState({ favoriteIds: new Set(), lists: [] }); + container = document.createElement('div'); document.body.append(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe('Home startup state', () => { + it('defers optional home requests until the backend bootstrap succeeds', async () => { + useFavoritesStore.setState({ favoriteIds: new Set(['live_1']) }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"channels":[]}')); + await act(async () => root.render()); + expect(fetchMock).not.toHaveBeenCalled(); + await act(async () => useChannelStore.setState({ _hydrated: true, backendConnection: 'connected' })); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('shows library synchronization instead of onboarding while counts are still empty', async () => { + useChannelStore.setState({ _hydrated: true, backendConnection: 'connected', isLoading: true, loadingMessage: 'Downloading channels…' }); + await act(async () => root.render()); + expect(container.querySelector('[role="status"]')?.textContent).toContain('Downloading channels'); + expect(container.textContent).not.toContain('add a playlist'); + }); + + it('shows connection progress rather than add-playlist onboarding before bootstrap finishes', async () => { + await act(async () => root.render()); + expect(container.querySelector('[role="status"]')?.textContent).toContain('Connecting to StreamVault'); + expect(container.textContent).not.toContain('add a playlist'); + }); + + it('shows the startup error with working Retry and Settings actions', async () => { + useChannelStore.setState({ _hydrated: true, backendConnection: 'disconnected', error: 'Server took too long. Retry.' }); + let resolveStatus!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockImplementation(input => String(input).endsWith('/api/status') + ? new Promise(resolve => { resolveStatus = resolve; }) + : Promise.resolve(new Response('{}'))); + await act(async () => root.render()); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Server took too long'); + expect(container.textContent).not.toContain('add a playlist'); + const retry = [...container.querySelectorAll('button')].find(button => button.textContent === 'Retry'); + expect(retry).toBeDefined(); + expect(container.textContent).toContain('Open Settings'); + await act(async () => retry?.click()); + expect(container.textContent).toContain('Connecting to StreamVault'); + await act(async () => { + resolveStatus(new Response('{"isSyncing":false,"contentTypeCounts":{"livetv":23}}')); + }); + expect(container.textContent).toContain('Live TV (23)'); + }); +}); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ba9a86d..1abd5b8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -23,6 +23,12 @@ export default function Home() { const programsByChannel = useChannelStore((s) => s.programsByChannel); const fetchProgramsForChannel = useChannelStore((s) => s.fetchProgramsForChannel); const contentTypeCounts = useChannelStore((s) => s.contentTypeCounts); + const hydrated = useChannelStore((s) => s._hydrated); + const backendConnection = useChannelStore((s) => s.backendConnection); + const startupError = useChannelStore((s) => s.error); + const hydrate = useChannelStore((s) => s.hydrate); + const isLoading = useChannelStore((s) => s.isLoading); + const loadingMessage = useChannelStore((s) => s.loadingMessage); const fetchChannelsByIds = useChannelStore((s) => s.fetchChannelsByIds); const favoriteIds = useFavoritesStore((s) => s.favoriteIds); const lists = useFavoritesStore((s) => s.lists); @@ -50,6 +56,7 @@ export default function Home() { // Collect all IDs we need and batch-fetch from server useEffect(() => { + if (!hydrated || backendConnection !== 'connected') return; const allIds = new Set(); for (const id of favoriteIds) allIds.add(id); for (const id of recentIds) allIds.add(id); @@ -67,11 +74,11 @@ export default function Home() { setChannelMap(map); }); return () => { cancelled = true; }; - }, [favoriteIds, recentIds, lastWatchedId, continueWatchingIds, listChannelIds, fetchChannelsByIds]); + }, [hydrated, backendConnection, favoriteIds, recentIds, lastWatchedId, continueWatchingIds, listChannelIds, fetchChannelsByIds]); useEffect(() => { - if (lastWatchedId) void fetchProgramsForChannel(lastWatchedId); - }, [fetchProgramsForChannel, lastWatchedId]); + if (hydrated && backendConnection === 'connected' && lastWatchedId) void fetchProgramsForChannel(lastWatchedId); + }, [hydrated, backendConnection, fetchProgramsForChannel, lastWatchedId]); const favoriteChannels = useMemo( () => Array.from(favoriteIds).map(id => channelMap.get(id)).filter(Boolean) as Channel[], @@ -184,6 +191,28 @@ export default function Home() { }, []); const hasContent = typeCounts.livetv > 0 || typeCounts.movies > 0 || typeCounts.series > 0 || channelMap.size > 0; + if (!hydrated || backendConnection !== 'connected') { + return ( +
+

{hydrated ? 'Unable to connect to StreamVault' : 'Connecting to StreamVault'}

+

{hydrated ? startupError || 'Check the StreamVault Server URL in Settings, then retry.' : 'Checking the server and loading your library…'}

+ {hydrated && ( +
+ + +
+ )} +
+ ); + } + if (!hasContent && isLoading) { + return ( +
+

Loading your library

+

{loadingMessage || 'Synchronizing channels…'}

+
+ ); + } if (!hasContent) { return (
diff --git a/src/services/api.timeout.test.ts b/src/services/api.timeout.test.ts new file mode 100644 index 0000000..573cdc7 --- /dev/null +++ b/src/services/api.timeout.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { apiFetch, rotateBackendRequestScope } from './api'; + +describe('opt-in API deadlines', () => { + beforeEach(() => { vi.useFakeTimers(); rotateBackendRequestScope(); }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + + it('bounds a stalled response body and aborts the transport', async () => { + const response = new Response('{}'); + vi.spyOn(response, 'text').mockReturnValue(new Promise(() => {})); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + let failure: unknown; + const request = apiFetch('', '/api/config', { timeoutMs: 100 }).catch(error => { failure = error; }); + await vi.advanceTimersByTimeAsync(100); + expect(failure).toMatchObject({ name: 'ApiTimeoutError', message: expect.stringContaining('Retry') }); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + await request; + expect(vi.getTimerCount()).toBe(0); + }); + + it('bounds stalled headers without AbortController support', async () => { + vi.stubGlobal('AbortController', undefined); + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise(() => {})); + let failure: unknown; + const request = apiFetch('', '/api/config', { timeoutMs: 100 }).catch(error => { failure = error; }); + await vi.advanceTimersByTimeAsync(100); + expect(failure).toMatchObject({ name: 'ApiTimeoutError' }); + await request; + }); + + it('does not impose a default deadline on streaming authorization', async () => { + let resolve!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise(done => { resolve = done; })); + const request = apiFetch('', '/api/live-compatible/live_1/authorize'); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + resolve(new Response('{"playlistUrl":"/media"}')); + await expect(request).resolves.toEqual({ playlistUrl: '/media' }); + }); + + it.each(['caller', 'backend'])('cancels a stalled body on %s cancellation and clears the deadline', async source => { + const caller = new AbortController(); + const response = new Response('{}'); + vi.spyOn(response, 'text').mockReturnValue(new Promise(() => {})); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + const request = apiFetch('', '/api/config', { timeoutMs: 100, signal: caller.signal }); + const result = expect(request).rejects.toMatchObject({ name: source === 'caller' ? 'AbortError' : 'StaleBackendRequestError' }); + await vi.advanceTimersByTimeAsync(1); + if (source === 'caller') caller.abort(); + else rotateBackendRequestScope(); + await result; + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([401, 403])('keeps %s terminal before a stalled error body', async status => { + const response = new Response(null, { status }); + const text = vi.spyOn(response, 'text').mockReturnValue(new Promise(() => {})); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + await expect(apiFetch('', '/api/config', { timeoutMs: 100 })).rejects.toMatchObject({ status }); + expect(text).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears the deadline and signal listeners after successful JSON', async () => { + const caller = new AbortController(); + const remove = vi.spyOn(caller.signal, 'removeEventListener'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"ok":true}')); + await expect(apiFetch('', '/api/config', { timeoutMs: 100, signal: caller.signal })).resolves.toEqual({ ok: true }); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); +}); diff --git a/src/services/api.ts b/src/services/api.ts index 25b8d3b..801275c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -29,6 +29,15 @@ export class StaleBackendRequestError extends Error { } } +export class ApiTimeoutError extends Error { + constructor() { + super('The StreamVault backend took too long to respond. Retry, or check the server connection in Settings.'); + this.name = 'ApiTimeoutError'; + } +} + +export type ApiRequestOptions = RequestInit & { timeoutMs?: number }; + let backendRequestGeneration = 0; let backendRequestController = typeof AbortController === 'undefined' ? null : new AbortController(); @@ -112,15 +121,53 @@ async function parseJsonResponse(response: Response): Promise { return (text ? JSON.parse(text) : undefined) as T; } +/** Opt-in deadline covers headers and body, even when abort is unsupported. */ +async function withRequestDeadline( + options: ApiRequestOptions | undefined, + request: (options: RequestInit | undefined) => Promise, +): Promise { + const { timeoutMs, ...init } = options ?? {}; + if (timeoutMs === undefined) return request(options); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError('timeoutMs must be positive and finite'); + const generation = backendRequestGeneration; + const controller = typeof AbortController === 'undefined' ? null : new AbortController(); + const cleanups: (() => void)[] = []; + let timer: ReturnType | undefined; + const cancelled = new Promise((_resolve, reject) => { + const fail = (error: unknown) => { + reject(generation !== backendRequestGeneration ? new StaleBackendRequestError() : error); + controller?.abort(); + }; + for (const signal of new Set([init.signal, backendRequestController?.signal])) { + if (!signal) continue; + const onAbort = () => fail(signal.reason ?? new DOMException('Request cancelled', 'AbortError')); + if (signal.aborted) onAbort(); + else { + signal.addEventListener('abort', onAbort, { once: true }); + cleanups.push(() => signal.removeEventListener('abort', onAbort)); + } + } + timer = setTimeout(() => fail(new ApiTimeoutError()), timeoutMs); + }); + try { + return await Promise.race([request({ ...init, signal: controller?.signal ?? init.signal }), cancelled]); + } finally { + clearTimeout(timer); + for (const cleanup of cleanups) cleanup(); + } +} + /** Probe a user-entered backend without forwarding credentials for another origin. */ // The status endpoint is intentionally public and returns heterogeneous status fields. // eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function probeBackend(baseUrl: string, signal?: AbortSignal): Promise { - const generation = backendRequestGeneration; - const response = await fetchBackend(`${baseUrl}/api/status`, { signal, cache: 'no-store' }); - const data = await parseJsonResponse(response); - if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); - return data; +export async function probeBackend(baseUrl: string, signal?: AbortSignal, timeoutMs?: number): Promise { + return withRequestDeadline({ signal, cache: 'no-store', timeoutMs }, async init => { + const generation = backendRequestGeneration; + const response = await fetchBackend(`${baseUrl}/api/status`, init); + const data = await parseJsonResponse(response); + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + return data; + }); } /** Fetch JSON from StreamVault with the protected-endpoint token in a header. */ @@ -129,25 +176,27 @@ export async function probeBackend(baseUrl: string, signal?: AbortSigna export async function apiFetch( baseUrl: string, path: string, - options?: RequestInit, + options?: ApiRequestOptions, tokenOverride?: string | null, ): Promise { - const generation = backendRequestGeneration; - const headers = new Headers(options?.headers); - const token = tokenOverride === undefined ? getApiToken() : tokenOverride || ''; - if (token && !headers.has('x-streamvault-token')) headers.set('x-streamvault-token', token); - - const response = await fetchBackend(`${baseUrl}${path}`, { ...options, headers }); - if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); - // Auth rejection is terminal on headers: a stalled body must not hide it - // behind a caller's timeout or allow a fallback to unauthenticated playback. - if (response.status === 401 || response.status === 403) { - throw new ApiError(response.status, response.statusText, undefined, response.headers.get('Retry-After')); - } - const data = await parseJsonResponse(response).catch(error => { + return withRequestDeadline(options, async init => { + const generation = backendRequestGeneration; + const headers = new Headers(init?.headers); + const token = tokenOverride === undefined ? getApiToken() : tokenOverride || ''; + if (token && !headers.has('x-streamvault-token')) headers.set('x-streamvault-token', token); + + const response = await fetchBackend(`${baseUrl}${path}`, { ...init, headers }); + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + // Auth rejection is terminal on headers: a stalled body must not hide it + // behind a caller's timeout or allow a fallback to unauthenticated playback. + if (response.status === 401 || response.status === 403) { + throw new ApiError(response.status, response.statusText, undefined, response.headers.get('Retry-After')); + } + const data = await parseJsonResponse(response).catch(error => { + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + throw error; + }); if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); - throw error; + return data; }); - if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); - return data; } diff --git a/src/stores/channelStore.bootstrap.test.ts b/src/stores/channelStore.bootstrap.test.ts new file mode 100644 index 0000000..727a4ae --- /dev/null +++ b/src/stores/channelStore.bootstrap.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useChannelStore } from './channelStore'; +import { rotateBackendRequestScope } from '../services/api'; + +describe('bounded backend bootstrap', () => { + beforeEach(() => { + vi.useFakeTimers(); + rotateBackendRequestScope(); + useChannelStore.setState({ apiBaseUrl: '', backendGeneration: 0, backendConnection: 'unknown', _hydrated: false, error: null, isLoading: false, isCrawling: false }); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + + it.each(['status', 'config'])('finishes startup with an actionable error when %s never responds', async stalled => { + vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + if (String(input).endsWith(`/api/${stalled}`)) return new Promise(() => {}); + return Promise.resolve(new Response('{"isSyncing":false}')); + }); + const startup = useChannelStore.getState().hydrate(); + await vi.advanceTimersByTimeAsync(10_000); + expect(useChannelStore.getState()).toMatchObject({ _hydrated: true, backendConnection: 'disconnected', error: expect.stringContaining('Retry') }); + await startup; + }); + + it.each(['status', 'config'])('bounds a candidate %s bootstrap without replacing the active backend', async stalled => { + useChannelStore.setState({ backendConnection: 'connected', _hydrated: true, apiBaseUrl: 'https://active.test' }); + vi.spyOn(globalThis, 'fetch').mockImplementation(input => { + if (String(input).endsWith(`/api/${stalled}`)) return new Promise(() => {}); + return Promise.resolve(new Response('{}')); + }); + let result: boolean | undefined; + const attempt = useChannelStore.getState().connectBackend('https://candidate.test').then(value => { result = value; }); + await vi.advanceTimersByTimeAsync(10_000); + expect(result).toBe(false); + expect(useChannelStore.getState()).toMatchObject({ apiBaseUrl: 'https://active.test', backendConnection: 'connected', error: expect.stringContaining('Retry') }); + await attempt; + }); + + it('allows an explicit retry after failed startup and shows connection in progress', async () => { + useChannelStore.setState({ _hydrated: true, backendConnection: 'disconnected', error: 'Previous timeout' }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => new Response(JSON.stringify( + String(input).endsWith('/api/status') ? { channelCount: 23, contentTypeCounts: { livetv: 23 } } : {}, + ))); + const retry = useChannelStore.getState().hydrate(true); + expect(useChannelStore.getState()).toMatchObject({ _hydrated: false, backendConnection: 'unknown', error: null }); + await retry; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(useChannelStore.getState()).toMatchObject({ _hydrated: true, backendConnection: 'connected', contentTypeCounts: { livetv: 23 }, error: null }); + }); +}); diff --git a/src/stores/channelStore.epgRefresh.test.ts b/src/stores/channelStore.epgRefresh.test.ts new file mode 100644 index 0000000..f49116f --- /dev/null +++ b/src/stores/channelStore.epgRefresh.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useChannelStore } from './channelStore'; + +beforeEach(() => { + vi.useFakeTimers(); + useChannelStore.setState({ apiBaseUrl: '', backendGeneration: 0, error: null }); +}); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + +describe('obsolete EPG completion', () => { + it.each(['success', 'failure'])('does not rearm global refresh after obsolete %s', async outcome => { + let resolve!: (response: Response) => void; + let reject!: (error: Error) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((done, fail) => { resolve = done; reject = fail; })); + const old = useChannelStore.getState().fetchPrograms(); + useChannelStore.setState({ backendGeneration: 1 }); + if (outcome === 'success') resolve(new Response('{"programs":[]}')); + else reject(new Error('Old backend failed')); + await old; + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not replace the current backend refresh timer with an obsolete one', async () => { + let resolveOld!: (response: Response) => void; + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; })) + .mockImplementation(async () => new Response('{"programs":[]}')); + const old = useChannelStore.getState().fetchPrograms(); + useChannelStore.setState({ backendGeneration: 1 }); + await useChannelStore.getState().fetchPrograms(); + await vi.advanceTimersByTimeAsync(1000); + resolveOld(new Response('{"programs":[]}')); + await old; + await vi.advanceTimersByTimeAsync(15 * 60 * 1000 - 1000); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/stores/channelStore.ts b/src/stores/channelStore.ts index 93486ab..6c18fa6 100644 --- a/src/stores/channelStore.ts +++ b/src/stores/channelStore.ts @@ -21,6 +21,7 @@ export interface XtreamCredentials { const PAGE_SIZE = 20; const PROGRAM_WINDOW_MS = 30 * 60 * 1000; const PROGRAM_REFRESH_MS = 15 * 60 * 1000; +const BOOTSTRAP_REQUEST_TIMEOUT_MS = 10_000; interface ChannelState { channels: Channel[]; @@ -164,7 +165,7 @@ interface ChannelActions { pollStatus: (expectedGeneration?: number) => Promise; setSelectedGroup: (group: string) => void; setSelectedRegion: (region: string) => void; - hydrate: () => Promise; + hydrate: (retry?: boolean) => Promise; } @@ -218,13 +219,13 @@ export const useChannelStore = create()((set, get const active = get(); const switchingOrigins = backendOrigin(active.apiBaseUrl) !== backendOrigin(url); try { - const status = await probeBackend(url); + const status = await probeBackend(url, undefined, BOOTSTRAP_REQUEST_TIMEOUT_MS); const candidateToken = token?.trim(); const tokenOverride = candidateToken || (switchingOrigins ? null : undefined); const config = await apiFetch>( url, '/api/config', - { cache: 'no-store' }, + { cache: 'no-store', timeoutMs: BOOTSTRAP_REQUEST_TIMEOUT_MS }, tokenOverride, ); if (attempt !== connectionAttempt) return false; @@ -423,10 +424,12 @@ export const useChannelStore = create()((set, get set({ error: msg }); throw err; } finally { - if (programRefreshTimer) clearTimeout(programRefreshTimer); - programRefreshTimer = setTimeout(() => { - void get().fetchPrograms().catch(() => undefined); - }, PROGRAM_REFRESH_MS); + if (isCurrent()) { + if (programRefreshTimer) clearTimeout(programRefreshTimer); + programRefreshTimer = setTimeout(() => { + if (isCurrent()) void get().fetchPrograms().catch(() => undefined); + }, PROGRAM_REFRESH_MS); + } } }, @@ -547,7 +550,7 @@ export const useChannelStore = create()((set, get const isCurrent = () => get().backendGeneration === generation; if (!hasApi(apiBaseUrl)) return; try { - const data = await apiFetch>(apiBaseUrl, '/api/config'); + const data = await apiFetch>(apiBaseUrl, '/api/config', { cache: 'no-store', timeoutMs: BOOTSTRAP_REQUEST_TIMEOUT_MS }); if (!isCurrent()) return; set(configPatch(data, get())); } catch (err) { @@ -702,8 +705,9 @@ export const useChannelStore = create()((set, get }, setSelectedRegion: (region: string) => set({ selectedRegion: region }), - hydrate: async () => { - if (get()._hydrated) return; + hydrate: async (retry = false) => { + if (get()._hydrated && !retry) return; + set({ _hydrated: false, backendConnection: 'unknown', error: null }); const { apiBaseUrl, backendGeneration } = get(); if (!SAME_ORIGIN && !apiBaseUrl) { set({ backendConnection: 'disconnected', _hydrated: true }); @@ -713,7 +717,7 @@ export const useChannelStore = create()((set, get try { // A configured URL is not a connection. Probe the backend before // revealing provider credentials or attempting any other API work. - const status = await probeBackend(apiBaseUrl); + const status = await probeBackend(apiBaseUrl, undefined, BOOTSTRAP_REQUEST_TIMEOUT_MS); if (!isCurrentBackend()) return; // Global EPG payloads grow with the provider catalog and can exceed diff --git a/src/stores/playerStore.test.ts b/src/stores/playerStore.test.ts index 03923ea..e1968f1 100644 --- a/src/stores/playerStore.test.ts +++ b/src/stores/playerStore.test.ts @@ -42,6 +42,31 @@ describe('player store backend switching', () => { expect(usePlayerStore.getState().groupChannels.map(channel => channel.id)).toEqual(['live_new']); }); + it('does not replace cached group A when a pending group B finishes after selecting A again', async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise(resolve => { release = resolve; })); + const cached = { id: 'live_a', name: 'A', group: 'A', url: '', logo: '', region: '', contentType: 'livetv' as const }; + usePlayerStore.setState({ groupChannels: [cached] }); + const pending = usePlayerStore.getState().fetchGroupChannels('B'); + usePlayerStore.getState().setChannel(cached); + release(new Response(JSON.stringify({ channels: [{ ...cached, id: 'live_b', group: 'B' }] }), { status: 200 })); + await pending; + expect(usePlayerStore.getState().groupChannels.map(channel => channel.id)).toEqual(['live_a']); + expect(usePlayerStore.getState().groupChannelsLoading).toBe(false); + }); + + it('aborts replaced group reads rather than leaving catalog traffic behind playback', async () => { + let signal: AbortSignal | null | undefined; + vi.spyOn(globalThis, 'fetch').mockImplementationOnce((_input, options) => { + signal = options?.signal; + return new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))); + }).mockResolvedValue(new Response(JSON.stringify({ channels: [], nextCursor: null }), { status: 200 })); + const old = usePlayerStore.getState().fetchGroupChannels('Old'); + await usePlayerStore.getState().fetchGroupChannels('New'); + expect(signal?.aborted).toBe(true); + await old; + }); + it('does not let an old backend failure clear the new backend loading state', async () => { let rejectOld!: (reason: unknown) => void; const pendingNew = new Promise(() => undefined); diff --git a/src/stores/playerStore.ts b/src/stores/playerStore.ts index 29364d7..7493b5e 100644 --- a/src/stores/playerStore.ts +++ b/src/stores/playerStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import type { Channel, PlayerState } from '../types'; import { trackWatch } from '../services/channel-service'; import { useChannelStore } from './channelStore'; +import { apiFetch } from '../services/api'; interface PlayerStoreState extends PlayerState { volume: number; @@ -24,6 +25,8 @@ interface PlayerStoreActions { } let groupFetchGeneration = 0; +let pendingGroup: string | null = null; +let groupRequestController: AbortController | null = null; export const usePlayerStore = create()((set, get) => ({ status: 'idle', @@ -50,6 +53,12 @@ export const usePlayerStore = create()((s const existingGroup = state.groupChannels[0]?.group; if (existingGroup !== channel.group) { get().fetchGroupChannels(channel.group); + } else if (pendingGroup && pendingGroup !== channel.group) { + // Returning to cached A must invalidate B, including its finally path. + groupFetchGeneration++; + groupRequestController?.abort(); + pendingGroup = null; + set({ groupChannelsLoading: false }); } } }, @@ -68,6 +77,10 @@ export const usePlayerStore = create()((s fetchGroupChannels: async (group: string) => { const fetchGeneration = ++groupFetchGeneration; + groupRequestController?.abort(); + const controller = typeof AbortController === 'undefined' ? null : new AbortController(); + groupRequestController = controller; + pendingGroup = group; const { apiBaseUrl: base, backendGeneration } = useChannelStore.getState(); const isCurrent = () => groupFetchGeneration === fetchGeneration && useChannelStore.getState().backendGeneration === backendGeneration; @@ -78,9 +91,8 @@ export const usePlayerStore = create()((s do { const params = new URLSearchParams({ type: 'livetv', group, limit: '200' }); if (after) params.set('after', after); - const res = await fetch(`${base}/api/browse?${params}`); - if (!res.ok) throw new Error('Failed to fetch group channels'); - const data = await res.json() as { channels?: Channel[]; nextCursor?: string | null }; + const data = await apiFetch<{ channels?: Channel[]; nextCursor?: string | null }>(base, `/api/browse?${params}`, + { signal: controller?.signal, timeoutMs: 10_000 }); if (!isCurrent()) return; channels.push(...(data.channels || [])); after = data.nextCursor || null; @@ -89,6 +101,8 @@ export const usePlayerStore = create()((s } catch { if (!isCurrent()) return; set({ groupChannelsLoading: false }); + } finally { + if (isCurrent()) { pendingGroup = null; groupRequestController = null; } } }, @@ -129,6 +143,9 @@ export const usePlayerStore = create()((s export function resetPlayerBackendState(): void { groupFetchGeneration++; + pendingGroup = null; + groupRequestController?.abort(); + groupRequestController = null; usePlayerStore.setState({ status: 'idle', currentChannel: null, diff --git a/src/utils/__tests__/pwa-cache.test.ts b/src/utils/__tests__/pwa-cache.test.ts index 55d969c..47f970b 100644 --- a/src/utils/__tests__/pwa-cache.test.ts +++ b/src/utils/__tests__/pwa-cache.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest'; import { CACHEABLE_API_PATTERN } from '../pwa-cache'; describe('CACHEABLE_API_PATTERN', () => { + it.each([ + '/api/live-compatible', + '/api/live-compatible?probe=1', + '/api/live-compatible/live_1/authorize', + '/api/live-compatible/live_1/index.m3u8?ticket=synthetic', + '/api/live-compatible/live_1/segment/123.ts?ticket=synthetic', + 'https://streamvault.test/api/LIVE-COMPATIBLE/live_1/index.m3u8?ticket=synthetic', + ])('never caches native compatibility delivery: %s', path => { + expect(CACHEABLE_API_PATTERN.test(path)).toBe(false); + }); + it('excludes transient media delivery endpoints from the service-worker cache', () => { expect(CACHEABLE_API_PATTERN.test('/api/channels?group=News')).toBe(true); for (const path of [ diff --git a/src/utils/epg-batch.test.ts b/src/utils/epg-batch.test.ts new file mode 100644 index 0000000..48957c2 --- /dev/null +++ b/src/utils/epg-batch.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fetchBatchEpg } from './epg-batch'; +import { rotateBackendRequestScope, setStoredApiToken, StaleBackendRequestError } from '../services/api'; + +afterEach(() => { vi.restoreAllMocks(); setStoredApiToken(''); }); + +it('bounds bulk guide reads to two concurrent chunks and deduplicates channel IDs', async () => { + const releases: Array<() => void> = []; + let active = 0; + let maximum = 0; + const requests: string[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + requests.push(String(input)); active++; maximum = Math.max(maximum, active); + await new Promise(resolve => releases.push(resolve)); active--; + return new Response(JSON.stringify({ programs: {} }), { status: 200 }); + }); + const ids = Array.from({ length: 420 }, (_, index) => `live_${index}`); + const result = fetchBatchEpg([...ids, ...ids]); + await vi.waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(2)); + expect(maximum).toBe(2); + while (requests.length < 5 || active) { + releases.splice(0).forEach(release => release()); + await new Promise(resolve => setTimeout(resolve, 0)); + } + await result; + expect(requests).toHaveLength(5); + expect(maximum).toBe(2); +}); + +it('rejects an obsolete backend batch instead of returning its guide', async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise(resolve => { release = resolve; })); + const result = fetchBatchEpg(['live_a']); + const rejected = expect(result).rejects.toBeInstanceOf(StaleBackendRequestError); + rotateBackendRequestScope(); + release(new Response(JSON.stringify({ programs: { live_a: [{ title: 'Old guide' }] } }), { status: 200 })); + await rejected; +}); + +it('cancels a replaced viewport read and does not schedule remaining chunks', async () => { + const controller = new AbortController(); + const signals: (AbortSignal | null | undefined)[] = []; + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, options) => { + signals.push(options?.signal); + return new Promise((_resolve, reject) => options?.signal?.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))); + }); + const pending = fetchBatchEpg(Array.from({ length: 320 }, (_, index) => `live_${index}`), undefined, undefined, { signal: controller.signal }); + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + controller.abort(); + await rejected; + expect(fetch).toHaveBeenCalledTimes(2); + expect(signals.every(signal => signal?.aborted)).toBe(true); +}); + +it('bounds a stalled guide read instead of holding its refresh forever', async () => { + vi.useFakeTimers(); + try { + vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise(() => {})); + const pending = fetchBatchEpg(['live_a']); + const rejected = expect(pending).rejects.toMatchObject({ name: 'ApiTimeoutError' }); + await vi.advanceTimersByTimeAsync(10_000); + await rejected; + } finally { vi.useRealTimers(); } +}); + +it('uses configured API authorization for protected guide requests', async () => { + setStoredApiToken('synthetic-epg-fixture'); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ programs: {} }), { status: 200 })); + await fetchBatchEpg(['live_a']); + expect(new Headers(fetch.mock.calls[0][1]?.headers).get('x-streamvault-token')).toBe('synthetic-epg-fixture'); +}); diff --git a/src/utils/epg-batch.ts b/src/utils/epg-batch.ts index 7d3a37d..2f341d8 100644 --- a/src/utils/epg-batch.ts +++ b/src/utils/epg-batch.ts @@ -1,4 +1,5 @@ import { useChannelStore, SAME_ORIGIN } from '../stores/channelStore'; +import { apiFetch, getBackendRequestScope, StaleBackendRequestError } from '../services/api'; export interface EpgProgram { title: string; @@ -15,25 +16,32 @@ function getApiBase(): string { return SAME_ORIGIN ? '' : useChannelStore.getState().apiBaseUrl; } -export async function fetchBatchEpg(channelIds: string[], from?: number, to?: number): Promise { +export async function fetchBatchEpg(channelIds: string[], from?: number, to?: number, + options: { signal?: AbortSignal } = {}): Promise { if (channelIds.length === 0) return {}; const base = getApiBase(); + const scope = getBackendRequestScope(); + const ids = [...new Set(channelIds)]; const chunks: string[][] = []; - for (let i = 0; i < channelIds.length; i += BATCH_SIZE) { - chunks.push(channelIds.slice(i, i + BATCH_SIZE)); - } - const results = await Promise.all( - chunks.map(async (chunk) => { + for (let i = 0; i < ids.length; i += BATCH_SIZE) chunks.push(ids.slice(i, i + BATCH_SIZE)); + const results: EpgMap = {}; + let nextChunk = 0; + const read = async () => { + while (nextChunk < chunks.length) { + if (scope.generation !== getBackendRequestScope().generation) throw new StaleBackendRequestError(); + if (options.signal?.aborted) throw new DOMException('Guide request cancelled', 'AbortError'); + const chunk = chunks[nextChunk++]; const params = new URLSearchParams({ ids: chunk.join(',') }); if (from !== undefined) params.set('from', String(from)); if (to !== undefined) params.set('to', String(to)); - const resp = await fetch(`${base}/api/epg/batch?${params}`); - if (!resp.ok) return {} as EpgMap; - const data = await resp.json(); - return (data.programs || {}) as EpgMap; - }) - ); - return Object.assign({}, ...results); + const data = await apiFetch<{ programs?: EpgMap }>(base, `/api/epg/batch?${params}`, { signal: options.signal, timeoutMs: 10_000 }); + if (scope.generation !== getBackendRequestScope().generation) throw new StaleBackendRequestError(); + if (options.signal?.aborted) throw new DOMException('Guide request cancelled', 'AbortError'); + Object.assign(results, data.programs || {}); + } + }; + await Promise.all(Array.from({ length: Math.min(2, chunks.length) }, read)); + return results; } export function getCurrentEpg(programs: EpgProgram[] | undefined): { current: EpgProgram | null; progress: number } { diff --git a/src/utils/pwa-cache.ts b/src/utils/pwa-cache.ts index 49a1e12..4ef8ffb 100644 --- a/src/utils/pwa-cache.ts +++ b/src/utils/pwa-cache.ts @@ -1,2 +1,2 @@ /** API responses that are safe for the PWA runtime cache. */ -export const CACHEABLE_API_PATTERN = /\/api\/(?!(?:status|config|programs|stream|live|proxy|remux|transcode|subtitles|recordings|recording-rules|recording-status|ios-hls|ios-hls-authorize|ios-hls-assets)(?:\/|\?|$))/i; +export const CACHEABLE_API_PATTERN = /\/api\/(?!(?:status|config|programs|stream|live|live-compatible|proxy|remux|transcode|subtitles|recordings|recording-rules|recording-status|ios-hls|ios-hls-authorize|ios-hls-assets)(?:\/|\?|$))/i;