Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions server/src/directory-status-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
36 changes: 36 additions & 0 deletions server/src/directory-status-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export function createDirectoryStatusCache<T>(read: () => Promise<T>, 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<T> } | 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<T> {
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;
},
};
}
8 changes: 7 additions & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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); }
});

Expand Down
26 changes: 26 additions & 0 deletions src/components/PlaybackLoading.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>) | 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(<PlaybackLoading live onRetry={retry} />));
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);
});
23 changes: 23 additions & 0 deletions src/components/PlaybackLoading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div data-playback-loading className="absolute z-[4] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-3 text-center text-[#aaa] animate-fade-in w-[min(88%,360px)] p-4 rounded-xl bg-black/70">
<div aria-hidden="true" className="w-10 h-10 border-[3px] border-[#333] border-t-accent rounded-full animate-spin-fast" />
<span role="status" className="text-base lg:text-20">{live ? 'Starting live stream…' : 'Loading playback…'}</span>
<span className="text-sm text-[#888] tabular-nums">{elapsed}s</span>
{slow && <>
<p className="text-sm">Taking longer than usual.{live ? ' Retry or choose another channel.' : ' You can retry.'}</p>
<button type="button" onClick={(event) => { event.stopPropagation(); onRetry(); }}
className="py-3 px-7 rounded-lg bg-accent text-black font-semibold tap-none active:opacity-80">Retry now</button>
</>}
</div>
);
}
43 changes: 43 additions & 0 deletions src/components/Player.epg.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Player />));
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<Response>(() => {});
}
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(<Player />));
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();
Expand Down
41 changes: 31 additions & 10 deletions src/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -111,6 +112,27 @@ function LiveChannelList({ channels, currentId, onSelect }: {
const listRef = useRef<HTMLDivElement>(null);
const [epgMap, setEpgMap] = useState<EpgMap>({});
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(() => {
Expand All @@ -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<typeof setTimeout> | 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;
Expand All @@ -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 (
<div
ref={listRef}
data-live-channel-list
onScroll={updateGuideWindow}
className="flex-1 min-h-0 overflow-y-auto bg-[#111] [-webkit-overflow-scrolling:touch]"
>
{channels.map((ch) => (
Expand Down Expand Up @@ -797,10 +821,7 @@ export default function Player() {

{/* Loading spinner */}
{playerState.status === 'loading' && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-4 text-[#888] text-20 animate-fade-in">
<div className="w-12 h-12 border-[3px] border-[#222] border-t-accent rounded-full animate-spin-fast" />
<span>Loading...</span>
</div>
<PlaybackLoading key={currentChannel?.id} live={currentChannel?.contentType === 'livetv'} onRetry={retry} />
)}

{/* Error display */}
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/usePlayer.liveHls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/usePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading