Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa

# Conflicts:
#	docs/architecture/pwa-self-hosted.md
This commit is contained in:
4gray committed 2026-10-13 09:14:50 +02:00
commit de87c1e4d3
78 files changed
+4367 -276

No files matched your search

@@ -0,0 +1,200 @@
import type { APIRequestContext, Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamImportToFinish,
xtreamMockServer,
} from './electron-test-fixtures';
import { installEmbeddedMpvSessionCapture } from './embedded-mpv-frame-copy-packaged-fixtures';
import {
fetchXtreamSeriesFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
/**
* Issue #1902: an episode whose connection drops reaches mpv as EOF in the
* middle of the episode, and series autoplay used to take that for the end
* and start the next episode. The `streamdrop` mock cuts every episode a
* third of the way into its 60 s clip and refuses it for a few seconds, so
* ffmpeg's own reconnect fails and only the app-level reconnect can resume.
* Needs the native-view Embedded MPV runtime; skips where it is missing.
*/
const credentials = { password: 'streamdrop', username: 'streamdrop' };
/** Past the ~21 s cut: the resumed episode has played on. */
const RESUMED_POSITION_SECONDS = 25;
/** Keyframe rounding and the 500 ms poll before the drop. */
const RESUME_TOLERANCE_SECONDS = 3;
type SessionSample = {
episodeId: number | null;
positionSeconds: number;
reconnecting: boolean;
status: string;
};
async function fetchSeasonOneEpisodeIds(
request: APIRequestContext,
seriesId: number
): Promise<number[]> {
const params = new URLSearchParams({
action: 'get_series_info',
series_id: String(seriesId),
...credentials,
});
const response = await request.get(
`${xtreamMockServer}/player_api.php?${params}`
);
expect(response.ok()).toBeTruthy();
const info = (await response.json()) as {
episodes?: Record<string, { id: string; episode_num: number }[]>;
};
return [...(info.episodes?.['1'] ?? [])]
.sort((a, b) => a.episode_num - b.episode_num)
.map((episode) => Number(episode.id));
}
/**
* Session updates in arrival order, each tagged with the episode its session
* last loaded: the `loading` update carries the requested URL, while later
* snapshots report mpv's `path`, which not every engine fills in reliably.
*/
function readSessions(page: Page): Promise<SessionSample[]> {
return page.evaluate(() => {
const episodeBySession = new Map<string, number>();
return (window.__packagedEmbeddedMpvSessions ?? []).map((session) => {
const match = /\/series\/[^/]+\/[^/]+\/(\d+)\.\w+$/.exec(
session.streamUrl
);
if (match) {
episodeBySession.set(session.id, Number(match[1]));
}
return {
episodeId: episodeBySession.get(session.id) ?? null,
positionSeconds: session.positionSeconds,
reconnecting: !!session.reconnect,
status: session.status,
};
});
});
}
/**
* What followed the first episode's mid-episode EOF, in update order. Every
* position played after the drop must stay near the drop: a reload that
* restarted the episode would otherwise reach the resumed position as well.
* A zero is the engine's placeholder before the reload's first `time-pos`.
*/
function outcomeAfterDrop(
sessions: SessionSample[],
[first, second]: number[]
): 'pending' | 'resumed' | 'restarted' | 'skipped' {
const dropped = sessions.findIndex(
(session) => session.episodeId === first && session.status === 'ended'
);
if (dropped < 0) {
return 'pending';
}
const dropPosition = sessions[dropped].positionSeconds;
for (const session of sessions.slice(dropped)) {
if (session.episodeId === second) {
return 'skipped';
}
if (session.episodeId !== first || session.status !== 'playing') {
continue;
}
if (
session.positionSeconds > 0 &&
session.positionSeconds < dropPosition - RESUME_TOLERANCE_SECONDS
) {
return 'restarted';
}
if (session.positionSeconds >= RESUMED_POSITION_SECONDS) {
return 'resumed';
}
}
return 'pending';
}
test('@playback @xtream @electron @embedded-mpv resumes an episode whose connection drops instead of skipping to the next one', async ({
dataDir,
request,
}) => {
test.setTimeout(180_000);
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamSeriesFixture(request, credentials);
const series = fixture.items[0];
if (!series) {
throw new Error('The stream-drop scenario has no series.');
}
const episodeIds = await fetchSeasonOneEpisodeIds(
request,
Number(series.series_id)
);
expect(episodeIds).toHaveLength(3);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '0',
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
},
});
try {
const support = await app.mainWindow.evaluate(() =>
window.electron.getEmbeddedMpvSupport()
);
test.skip(
!support.supported || support.engine !== 'native',
`Native-view Embedded MPV unavailable: ${JSON.stringify(support)}`
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await app.mainWindow.getByTestId('select-video-player').click();
await app.mainWindow.getByTestId('embedded-mpv').click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: 'Stream Drop',
});
await waitForXtreamImportToFinish(app.mainWindow);
await installEmbeddedMpvSessionCapture(app);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, getXtreamTitle(series));
const episode = app.mainWindow
.locator('.episode-card, .episode-list-item')
.first();
await expect(episode).toBeVisible({ timeout: 20_000 });
await episode.click();
await expect
.poll(
async () =>
outcomeAfterDrop(
await readSessions(app.mainWindow),
episodeIds
),
{ timeout: 90_000 }
)
.not.toBe('pending');
const sessions = await readSessions(app.mainWindow);
expect(outcomeAfterDrop(sessions, episodeIds)).toBe('resumed');
// The drop was shown as a reconnect, not as the episode's end.
expect(sessions.some((session) => session.reconnecting)).toBe(true);
} finally {
await closeElectronApp(app);
}
});
@@ -60,7 +60,7 @@ export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
* matches 170 series titles, more than the first page of 100.
*/
export const SEARCH_JOURNEY_QUERY = 'system';
/** Well below the shell's 350 ms input debounce, like steady typing. */
/** Well below the shell's 300 ms input debounce, like steady typing. */
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
const QUIET_MS = 1_000;
const QUIET_POLL_MS = 100;
@@ -0,0 +1,301 @@
import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import {
closeElectronApp,
electronMainPath,
expect,
type LaunchedElectronApp,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSources,
openWorkspaceSection,
restartElectronApp,
sourceRowByTitle,
test,
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
const SOURCE_TITLE = 'group-management-source';
const HIDDEN_GROUPS = ['Movies', 'Sports'];
const VISIBLE_HIDDEN_GROUPS_KEY = '__e2eVisibleHiddenGroups';
const RECONCILE_CHANNEL = 'EPG_RECONCILE_SOURCES';
interface ReconcileScript {
calls: number;
gate: Promise<void>;
release: () => void;
}
type ReconcileGlobal = typeof globalThis & {
__e2eReconcileScript?: ReconcileScript;
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installReconcileScript(
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
channel: string
): void {
const register = ipcMain.handle;
const scope = globalThis as ReconcileGlobal;
ipcMain.handle = function (name, listener) {
if (name !== channel) {
return register.call(this, name, listener);
}
return register.call(this, name, async (...args) => {
const script = scope.__e2eReconcileScript;
if (script) {
script.calls += 1;
if (script.calls === 1) return { success: false };
await script.gate;
}
return listener(...args);
});
};
}
function launchWithReconcileScript(
dataDir: string
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'reconcile-script.cjs');
writeFileSync(
entryPoint,
[
`(${installReconcileScript.toString()})(require('electron'), ${JSON.stringify(RECONCILE_CHANNEL)});`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/**
* The next renderer's startup reconciliation fails, and the retry the
* playlist inventory awaits is parked until `release`: the M3U route then
* activates (its settings resolver has finished) while the store still has no
* playlists.
*/
async function delayPlaylistInventory(app: LaunchedElectronApp) {
await app.electronApp.evaluate(() => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
(globalThis as ReconcileGlobal).__e2eReconcileScript = {
calls: 0,
gate,
release,
};
});
return {
waitUntilParked: () =>
expect
.poll(() =>
app.electronApp.evaluate(
() =>
(globalThis as ReconcileGlobal).__e2eReconcileScript
?.calls ?? 0
)
)
.toBeGreaterThan(1),
release: () =>
app.electronApp.evaluate(() => {
const scope = globalThis as ReconcileGlobal;
const script = scope.__e2eReconcileScript;
delete scope.__e2eReconcileScript;
script?.release();
}),
};
}
const CHANNELS = ['News', 'Sports', 'Movies'].flatMap((group) =>
[1, 2].map((index) => ({
groupTitle: group,
name: `${group} Channel ${index}`,
url: `https://streams.example.test/${group.toLowerCase()}-${index}.m3u8`,
}))
);
function groupNavLabels(page: Page): Locator {
return page.locator('app-groups-view .group-nav-item .nav-item-label');
}
async function openGroupManagement(page: Page): Promise<Locator> {
await page
.getByRole('button', { name: 'Manage groups', exact: true })
.click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog.locator('.group-item')).toHaveCount(3);
return dialog;
}
function groupCheckbox(dialog: Locator, group: string): Locator {
return dialog
.locator('.group-item')
.filter({ hasText: group })
.getByRole('checkbox');
}
async function storedHiddenGroupTitles(page: Page): Promise<unknown> {
return page.evaluate(async () => {
const playlistId = location.pathname.match(
/\/workspace\/playlists\/([^/]+)/
)?.[1];
if (!playlistId) throw new Error('M3U playlist route is missing');
const api = (window as unknown as { electron: ElectronBridgeApi })
.electron;
const playlist = (await api.dbGetAppPlaylist(playlistId)) as {
hiddenGroupTitles?: unknown;
} | null;
const hidden = playlist?.hiddenGroupTitles;
return Array.isArray(hidden) ? [...hidden].sort() : hidden;
});
}
/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */
async function importAndHideGroups(
app: LaunchedElectronApp,
dataDir: string
): Promise<void> {
const filePath = writeTemporaryM3uFile(
dataDir,
`${SOURCE_TITLE}.m3u`,
CHANNELS
);
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await expect(groupNavLabels(app.mainWindow)).toHaveText([
'Movies',
'News',
'Sports',
]);
const dialog = await openGroupManagement(app.mainWindow);
for (const group of HIDDEN_GROUPS) {
await groupCheckbox(dialog, group).uncheck();
}
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
await expect
.poll(() => storedHiddenGroupTitles(app.mainWindow))
.toEqual(HIDDEN_GROUPS);
}
/**
* Records, from the start of every new document, each hidden group the
* groups rail ever renders visibly — including before the startup inventory
* publishes the playlist.
*/
async function recordVisibleHiddenGroups(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.context().addInitScript(
({ hiddenGroups, key }) => {
const seen: string[] = [];
Reflect.set(window, key, seen);
const check = () => {
for (const item of document.querySelectorAll(
'app-groups-view .group-nav-item'
)) {
const group = item.getAttribute('data-group-key') ?? '';
if (
hiddenGroups.includes(group) &&
!seen.includes(group) &&
item.checkVisibility()
) {
seen.push(group);
}
}
};
new MutationObserver(check).observe(document, {
attributes: true,
attributeFilter: ['class', 'style'],
childList: true,
subtree: true,
});
},
{ hiddenGroups: HIDDEN_GROUPS, key: VISIBLE_HIDDEN_GROUPS_KEY }
);
}
test.describe('Electron M3U group management', () => {
test('keeps groups hidden in Manage groups after a restart (#1894)', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importAndHideGroups(app, dataDir);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, SOURCE_TITLE)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
// The dialog opens from the restored state, so saving it again
// cannot silently unhide the groups.
const reopened = await openGroupManagement(app.mainWindow);
await expect(groupCheckbox(reopened, 'News')).toBeChecked();
await expect(groupCheckbox(reopened, 'Movies')).not.toBeChecked();
await expect(groupCheckbox(reopened, 'Sports')).not.toBeChecked();
await reopened
.getByRole('button', { name: 'Close', exact: true })
.click();
await expect(reopened).toBeHidden();
} finally {
await closeElectronApp(app);
}
});
test('never shows hidden groups when the Groups route opens before the playlist inventory', async ({
dataDir,
}) => {
const app = await launchWithReconcileScript(dataDir);
try {
await importAndHideGroups(app, dataDir);
await recordVisibleHiddenGroups(app);
const inventory = await delayPlaylistInventory(app);
// A reload boots a fresh store straight on this route.
await reloadFromMainProcess(app);
await inventory.waitUntilParked();
// The route session has read the playlist and published its
// channels, still behind the startup screen, before the store
// has the playlist the hidden groups belong to.
const rail = app.mainWindow.locator(
'app-groups-view .group-nav-item'
);
await expect(rail).toHaveCount(3);
await expect(rail.first()).toBeHidden();
await inventory.release();
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/groups$/
);
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
expect(
await app.mainWindow.evaluate(
(key) => Reflect.get(window, key),
VISIBLE_HIDDEN_GROUPS_KEY
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -34,7 +34,7 @@ export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch';
/**
* Longest accepted gap between two keydowns. Below the shell's 350 ms input
* Longest accepted gap between two keydowns. Below the shell's 300 ms input
* debounce, so a late key on a busy machine cannot let an intermediate term
* run a query that steady typing would not.
*/
@@ -46,7 +46,7 @@ function runPlaylistUpdateScenario(scenario: string): unknown {
const { default: Database } = await import('better-sqlite3');
const { drizzle } = await import('drizzle-orm/better-sqlite3');
const schema = await import('@iptvnator/shared/database/schema');
const { updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
const { getAppPlaylist, getAppPlaylistMetas, updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
const { setPlaylistServerTimezone } = await import(${JSON.stringify(timezoneOperationsUrl)});
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
const sqlite = new Database(':memory:');
@@ -317,6 +317,91 @@ describe('playlist.operations', () => {
expect(result['moved'].payload).not.toHaveProperty('serverTimezone');
});
it('clears the optional columns a full upsert leaves empty, so a cleared field stays cleared', () => {
// The codec maps an empty value to `undefined`, which ON CONFLICT DO
// UPDATE skips; reads prefer the column, so the old value would come
// back. Covers the single and batch writers and both conflict-update
// shapes (a clockless document and one that carries its own clock).
const ids = ['single', 'batch', 'with-clock'];
const result = runPlaylistUpdateScenario(`
const source = (id, extra = {}) => ({
_id: id,
title: id,
type: 'm3u-url',
url: 'http://example.com/list.m3u',
userAgent: 'Agent/1',
referrer: 'http://ref.example',
origin: 'http://origin.example',
username: 'demo',
password: 'secret',
filePath: '/playlists/list.m3u',
manualEpgUrls: ['http://epg.example/manual.xml'],
favorites: ['1'],
...(id === 'with-clock' ? { serverTimezone: 'UTC' } : {}),
...extra,
});
const cleared = {
userAgent: '',
referrer: ' ',
origin: '',
username: '',
password: '',
filePath: undefined,
manualEpgUrls: undefined,
};
for (const id of ${JSON.stringify(ids)}) {
await upsertAppPlaylist(db, source(id));
}
await upsertAppPlaylist(db, source('single', cleared));
await upsertAppPlaylists(db, [source('batch', cleared)]);
await upsertAppPlaylist(db, source('with-clock', cleared));
const docs = {};
for (const id of ${JSON.stringify(ids)}) {
docs[id] = await getAppPlaylist(db, id);
}
const metas = Object.fromEntries(
(await getAppPlaylistMetas(db)).map((meta) => [meta._id, meta])
);
const rows = Object.fromEntries(
sqlite.prepare('SELECT id, date_created FROM playlists').all()
.map((row) => [row.id, row.date_created])
);
process.stdout.write(JSON.stringify({ docs, metas, rows }));
`) as {
docs: Record<string, Record<string, unknown>>;
metas: Record<string, Record<string, unknown>>;
rows: Record<string, string | null>;
};
for (const id of ids) {
// Metadata reads see only the columns; the full read merges the
// payload in. Both must lose every cleared field.
for (const playlist of [result.docs[id], result.metas[id]]) {
expect(playlist).toEqual(
expect.objectContaining({
_id: id,
title: id,
url: 'http://example.com/list.m3u',
favorites: ['1'],
manualEpgUrls: [],
})
);
for (const field of [
'userAgent',
'referrer',
'origin',
'username',
'password',
'filePath',
]) {
expect(playlist).not.toHaveProperty(field);
}
}
expect(result.rows[id]).toEqual(expect.any(String));
}
expect(result.docs['with-clock']['serverTimezone']).toBe('UTC');
});
it('loads app playlist metadata without selecting the large payload column', async () => {
const from = jest.fn().mockResolvedValue([
{
@@ -142,6 +142,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
};
let status: EmbeddedMpvSessionStatus;
let positionSeconds: number;
let durationSeconds: number | null;
let recordingActive: boolean;
let errorOrigin: 'playback' | 'engine' | undefined;
let rejectedOptionKeys: string[] | undefined;
@@ -179,6 +180,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
service = new EmbeddedMpvNativeService();
status = 'idle';
positionSeconds = 0;
durationSeconds = null;
recordingActive = false;
errorOrigin = undefined;
rejectedOptionKeys = undefined;
@@ -193,7 +195,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
getSessionSnapshot: jest.fn<MockSnapshot | null, [string]>(() => ({
status,
positionSeconds,
durationSeconds: null,
durationSeconds,
volume: 1,
streamUrl: LIVE.streamUrl,
recording: { active: recordingActive },
@@ -596,6 +598,63 @@ describe('EmbeddedMpvNativeService reconnect', () => {
});
});
it('treats EOF in the middle of an episode as a drop, not as its end (#1902)', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
// mpv drained its cache after the server closed the connection.
poll('ended');
expect(lastUpdate()?.status).toBe('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', {
...VOD,
startTime: 1_260,
});
// The genuine end of the episode stays the end.
positionSeconds = 2_700;
poll('playing');
poll('ended');
jest.advanceTimersByTime(60_000);
expect(lastUpdate()?.status).toBe('ended');
expect(lastUpdate()?.reconnect).toBeUndefined();
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
});
it('forgets where the reload resumed once the user seeks', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
poll('ended');
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('playing');
// A seek away and back: the next early EOF near the old resume
// point is a new drop, not the reload ending where it resumed.
service.seek('session-1', 600);
service.seekBy('session-1', 662);
poll('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(2);
});
it('treats EOF on a live stream as a drop', () => {
startPlayingLive();
@@ -741,6 +741,7 @@ export class EmbeddedMpvNativeService {
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.onUserSeek(sessionId);
this.getAddon().seek(sessionId, seconds);
return this.refreshSession(sessionId);
}
@@ -760,6 +761,7 @@ export class EmbeddedMpvNativeService {
if (!Number.isFinite(deltaSeconds)) {
return this.refreshSession(sessionId);
}
this.onUserSeek(sessionId);
if (typeof addon.seekBy === 'function') {
addon.seekBy(sessionId, deltaSeconds);
} else {
@@ -770,6 +772,14 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
/** A seek invalidates the reconnect's resume point (embedded-mpv-reconnect.ts). */
private onUserSeek(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
this.reconnect.onUserSeek(session.reconnect);
}
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
@@ -1333,7 +1343,8 @@ export class EmbeddedMpvNativeService {
payload.status,
previousStatus,
payload.positionSeconds,
snapshot.errorOrigin
snapshot.errorOrigin,
payload.durationSeconds
);
if (reconnect) {
payload.reconnect = reconnect;
@@ -197,6 +197,328 @@ describe('EmbeddedMpvReconnectCoordinator', () => {
expect(reload).not.toHaveBeenCalled();
});
it('treats a VOD EOF well before the end as a drop and resumes there (#1902)', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_260,
undefined,
2_700
);
// The server closed the connection 21 min into a 45 min episode.
const info = coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_261,
undefined,
2_700
);
expect(info?.attempt).toBe(1);
// The poll keeps reading the same EOF until the attempt fires.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_261,
undefined,
2_700
)
).toBe(info);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 1_260,
});
});
it('keeps a VOD EOF at the end, or with no known duration, as the end', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
2_670,
undefined,
2_700
);
// 4 s short of a duration rounded down to whole seconds.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
2_696,
undefined,
2_700
)
).toBeNull();
// A long film's closing seconds fall inside the 1 % margin.
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
7_150,
undefined,
7_200
)
).toBeNull();
// Without a duration nothing says the EOF came early.
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
600,
undefined,
null
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('accepts the end when the reload cannot get past where it resumed', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
2_400,
undefined,
2_700
);
// The duration was estimated from the bitrate; the file ends at 2 401 s.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
2_401,
undefined,
2_700
)?.attempt
).toBe(1);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 2_400,
});
coordinator.observe(SESSION, state, 'loading', 'ended');
expect(
coordinator.observe(
SESSION,
state,
'ended',
'loading',
2_401,
undefined,
2_700
)
).toBeNull();
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
2_401,
undefined,
2_700
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).toHaveBeenCalledTimes(1);
});
it('reconnects again when the resumed episode drops further on', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
600,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
601,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
900,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
901,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 900,
});
});
it('keeps retrying an early EOF when the addon refuses the reload', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
reload.mockImplementationOnce(() => {
throw new Error('addon exploded');
});
jest.advanceTimersByTime(2_000);
// The refused reload left the original EOF in the snapshot: it is
// still the drop, not a reload that ended where it resumed.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_200,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenCalledTimes(2);
});
it('judges an EOF after a user seek on its position alone', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
1_201,
undefined,
2_700
);
// Back to 10 min; the connection drops again a few minutes later.
coordinator.onUserSeek(state);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_202,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_203,
undefined,
2_700
)?.attempt
).toBe(2);
});
it('resumes a VOD reload where it dropped but keeps live at the live edge', () => {
startPlaying(VOD);
coordinator.observe(SESSION, state, 'playing', 'playing', 95);
@@ -16,8 +16,13 @@ import { isEmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
* - Only a load that already reached `playing` is retried. A URL that never
* worked (404, refused credentials, unsupported container) keeps the manual
* Retry; hammering a panel six times with the same bad request helps nobody.
* - EOF counts as a loss only for live playback — a broadcast never ends on
* its own, a movie does.
* - EOF counts as a loss for live playback — a broadcast never ends on its
* own. A movie or episode does, so on VOD only an EOF well before the
* known duration counts: mpv reports a connection that the server closed
* and ffmpeg could not reopen as EOF, and taking it for the end made
* series autoplay skip the rest of the episode (#1902). A reload that
* ends again where it resumed proves the file ends there (a duration
* estimated from the bitrate can overshoot), and is left as the end.
* - The attempt budget belongs to one outage: it resets after
* {@link EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS} of uninterrupted playing,
* not on the first `playing`, so a stream that flaps every few seconds runs
@@ -48,6 +53,15 @@ export const EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS = 6;
export const EMBEDDED_MPV_RECONNECT_BASE_DELAY_MS = 2_000;
export const EMBEDDED_MPV_RECONNECT_MAX_DELAY_MS = 30_000;
export const EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS = 30_000;
/**
* A VOD EOF is the end of the file when less than this much remained
* (seconds, or the ratio of the duration when that is longer): the margin
* absorbs frame and container rounding and duration estimates.
*/
export const EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_SECONDS = 10;
export const EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_RATIO = 0.01;
/** Playback a resumed reload must make before another early EOF counts. */
export const EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS = 5;
export interface EmbeddedMpvReconnectState {
/** `Settings.embeddedMpvAutoReconnect`, captured at session creation. */
@@ -74,6 +88,12 @@ export interface EmbeddedMpvReconnectState {
* is trusted as a real seek-to-start only once that has happened.
*/
positionReported: boolean;
/**
* Where the last VOD reload resumed, until playback gets past it or the
* user seeks. An early EOF before then ended where the file does, not
* where it dropped.
*/
resumedAtSeconds: number | null;
timer: NodeJS.Timeout | null;
/** What the renderer is shown while an attempt is scheduled or in flight. */
pending: EmbeddedMpvReconnectInfo | null;
@@ -100,6 +120,7 @@ export function createEmbeddedMpvReconnectState(
attemptInFlight: false,
lastPositionSeconds: null,
positionReported: false,
resumedAtSeconds: null,
timer: null,
pending: null,
};
@@ -131,6 +152,25 @@ export function isEmbeddedMpvStreamLoss(
return status === 'error' || (status === 'ended' && isLive);
}
/** Whether a VOD EOF at `positionSeconds` came well before the end. */
export function isEmbeddedMpvEarlyEof(
positionSeconds: number,
durationSeconds: number | null | undefined
): boolean {
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return false;
}
const margin = Math.max(
EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_SECONDS,
durationSeconds * EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_RATIO
);
return durationSeconds - positionSeconds > margin;
}
export class EmbeddedMpvReconnectCoordinator {
private readonly now: () => number;
private readonly log: Pick<Console, 'log' | 'warn'>;
@@ -158,9 +198,18 @@ export class EmbeddedMpvReconnectCoordinator {
? playback.startTime
: null;
state.positionReported = false;
state.resumedAtSeconds = null;
state.pending = null;
}
/**
* The user moved the playhead: an EOF after that is not the reload
* ending where it resumed, so it is judged on its position alone.
*/
onUserSeek(state: EmbeddedMpvReconnectState): void {
state.resumedAtSeconds = null;
}
cancel(state: EmbeddedMpvReconnectState): void {
this.clearTimer(state);
state.attemptInFlight = false;
@@ -188,7 +237,8 @@ export class EmbeddedMpvReconnectCoordinator {
status: EmbeddedMpvSessionStatus,
previousStatus: EmbeddedMpvSessionStatus | null,
positionSeconds?: number,
errorOrigin?: 'playback' | 'engine'
errorOrigin?: 'playback' | 'engine',
durationSeconds?: number | null
): EmbeddedMpvReconnectInfo | null {
const now = this.now();
@@ -211,6 +261,14 @@ export class EmbeddedMpvReconnectCoordinator {
// the seeded `startTime` stays the better resume point.
state.lastPositionSeconds = 0;
}
if (
state.resumedAtSeconds !== null &&
positionSeconds >
state.resumedAtSeconds +
EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS
) {
state.resumedAtSeconds = null;
}
}
if (status === 'playing') {
@@ -240,9 +298,13 @@ export class EmbeddedMpvReconnectCoordinator {
const isLive =
state.playback !== null &&
isLiveEmbeddedMpvPlayback(state.playback);
if (!isEmbeddedMpvStreamLoss(status, isLive)) {
if (
!isEmbeddedMpvStreamLoss(status, isLive) &&
!this.isDroppedVod(state, status, positionSeconds, durationSeconds)
) {
// paused/idle are user actions, closed is teardown, ended on
// VOD is the movie finishing — none of them is an outage.
// VOD near its end is the movie finishing — none of them is an
// outage.
if (status === 'paused') {
// Disarmed until the stream plays again: reloading a stream
// the user paused would resume it without them asking.
@@ -299,6 +361,9 @@ export class EmbeddedMpvReconnectCoordinator {
state.attemptInFlight = true;
try {
this.hooks.reload(sessionId, playback);
// Only a reload that started can end where it resumed; a
// refused one leaves the original EOF, still a drop.
state.resumedAtSeconds = playback.startTime ?? null;
} catch (error) {
state.attemptInFlight = false;
if (isEmbeddedMpvSessionGoneError(error)) {
@@ -325,6 +390,39 @@ export class EmbeddedMpvReconnectCoordinator {
return state.pending;
}
/**
* A VOD `ended` well before the duration is a dropped connection, unless
* it is the reload that resumed there ending again: then the file itself
* ends there and the duration was only an estimate.
*/
private isDroppedVod(
state: EmbeddedMpvReconnectState,
status: EmbeddedMpvSessionStatus,
positionSeconds: number | undefined,
durationSeconds: number | null | undefined
): boolean {
if (status !== 'ended' || state.playback === null) {
return false;
}
const position =
typeof positionSeconds === 'number' &&
Number.isFinite(positionSeconds) &&
positionSeconds > 0
? positionSeconds
: state.lastPositionSeconds;
if (
position === null ||
!isEmbeddedMpvEarlyEof(position, durationSeconds)
) {
return false;
}
return (
state.resumedAtSeconds === null ||
position >
state.resumedAtSeconds + EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS
);
}
/**
* Live streams reload at the live edge. Anything else resumes where the
* connection dropped: the stored playback still carries the offset the
@@ -6,10 +6,7 @@ import {
playlistConflictUpdate,
} from '@iptvnator/shared/database/app-playlist';
import { appState, playlists } from '@iptvnator/shared/database/schema';
import type {
NewPlaylist,
Playlist as PlaylistRow,
} from '@iptvnator/shared/database/schema';
import type { Playlist as PlaylistRow } from '@iptvnator/shared/database/schema';
import { SERVER_PAYLOAD_FIELDS } from '@iptvnator/shared/interfaces';
import {
metadataDocument,
@@ -42,46 +39,6 @@ function metadataColumns() {
const metaColumns = metadataColumns();
/**
* Columns a whole-document upsert must rewrite even when the document no
* longer has a value: the codec maps empty values to `undefined`, which an
* `ON CONFLICT DO UPDATE` skips, so a cleared field would keep its old
* column value and `parseAppPlaylist` would bring it back.
*/
const REPLACEABLE_COLUMNS = [
'serverUrl',
'username',
'password',
'lastUpdated',
'userAgent',
'origin',
'referrer',
'filePath',
'epgUrls',
'detectedEpgUrls',
'manualEpgUrls',
'disabledEpgUrls',
'macAddress',
'url',
'portalUrl',
'count',
'importDate',
'updateDate',
'position',
'favorites',
'recentlyViewed',
] as const satisfies readonly (keyof NewPlaylist)[];
function clearedColumns(row: NewPlaylist): Partial<NewPlaylist> {
const cleared: Partial<Record<keyof NewPlaylist, null>> = {};
for (const column of REPLACEABLE_COLUMNS) {
if (row[column] === undefined) {
cleared[column] = null;
}
}
return cleared as Partial<NewPlaylist>;
}
/** Playlist documents in the shared `playlists` table, same rows as desktop. */
export class ServerPlaylistRepository {
constructor(private readonly db: DatabaseInstance) {}
@@ -146,10 +103,7 @@ export class ServerPlaylistRepository {
throw new InvalidPlaylistDocumentError();
}
const update = {
...playlistConflictUpdate(row, next),
...clearedColumns(row),
};
const update = playlistConflictUpdate(row, next);
this.db.transaction((tx) => {
tx.insert(playlists)
.values(row)
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Request, Response } from 'express';
/** Share of the clip after which the provider loses the first connection. */
export const DROPPING_EPISODE_CUT_RATIO = 0.35;
/**
* How long the stream is refused after the cut: long enough to defeat
* ffmpeg's own HTTP reconnect, short enough for an app-level retry.
*/
export const DROPPING_EPISODE_REFUSE_MS = 4_000;
let fixture: Buffer | null = null;
/** Stream id → when its connection was cut. Every episode drops once. */
const droppedAt = new Map<string, number>();
export function resetDroppingEpisodes(): void {
droppedAt.clear();
}
/**
* `dropping-episode` fixture: every episode plays a local 60 s clip with
* byte ranges, but the first response that reaches a third of the file is
* cut there, and the stream answers 403 for a few seconds — a panel still
* holding the dropped connection's slot. mpv reports such a drop as EOF in
* the middle of the episode (issue #1902).
*/
export function streamDroppingEpisode(
request: Request,
response: Response
): void {
const body = (fixture ??= readFileSync(
join(process.cwd(), 'apps/xtream-mock-server/src/fixtures/episode.mkv')
));
const streamId = String(request.params['streamId']);
const cutAt = droppedAt.get(streamId);
response.set('Cache-Control', 'no-store');
if (
cutAt !== undefined &&
Date.now() - cutAt < DROPPING_EPISODE_REFUSE_MS
) {
response.status(403).end();
return;
}
const range = /^bytes=(\d+)-(\d*)$/.exec(request.get('range') ?? '');
const start = range ? Number(range[1]) : 0;
const end = range?.[2]
? Math.min(Number(range[2]), body.length - 1)
: body.length - 1;
if (start > end) {
response
.status(416)
.set('Content-Range', `bytes */${body.length}`)
.end();
return;
}
response
.status(range ? 206 : 200)
.type('video/x-matroska')
.set('Accept-Ranges', 'bytes')
.set('Content-Length', String(end - start + 1));
if (range) {
response.set('Content-Range', `bytes ${start}-${end}/${body.length}`);
}
const cut = Math.floor(body.length * DROPPING_EPISODE_CUT_RATIO);
if (cutAt === undefined && start < cut && end >= cut) {
response.write(body.subarray(start, cut), () => {
droppedAt.set(streamId, Date.now());
response.socket?.destroy();
});
return;
}
response.end(body.subarray(start, end + 1));
}
+16 -1
View File
@@ -43,8 +43,10 @@ export interface ScenarioConfig {
* episodes slowly from generated bytes so download screenshots complete
* without any request leaving the machine (the release capture's network
* gate rejects the public HLS stub every other scenario redirects to).
* `dropping-episode` plays a local clip whose connection is lost
* mid-episode (see dropping-episode.ts).
*/
downloadStreamFixture?: 'slow-series' | 'local-media';
downloadStreamFixture?: 'slow-series' | 'local-media' | 'dropping-episode';
/**
* Optional `player_api.php` actions the portal accepts and then never
* answers: the connection stays open until the client gives up. Models a
@@ -172,6 +174,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
expiryDate: '2099-12-31',
downloadStreamFixture: 'slow-series',
},
'streamdrop:streamdrop': {
name: 'stream-drop',
description:
'One series whose episodes lose their connection mid-playback — embedded MPV reconnect tests',
seed: 8181,
categoryCount: { live: 0, vod: 0, series: 1 },
itemsPerCategory: 1,
seasonsPerSeries: 1,
episodesPerSeason: 3,
accountStatus: 'Active',
expiryDate: '2099-12-31',
downloadStreamFixture: 'dropping-episode',
},
'epg:epg': {
name: 'epg-fixture',
description:
+106 -4
View File
@@ -1,6 +1,13 @@
import { statSync } from 'node:fs';
import { connect } from 'node:net';
import { join } from 'node:path';
import express from 'express';
import { resetAll } from './data-store.js';
import {
DROPPING_EPISODE_CUT_RATIO,
DROPPING_EPISODE_REFUSE_MS,
resetDroppingEpisodes,
} from './dropping-episode.js';
import { LONG_PROGRAMME_TITLE } from './scenarios.js';
import {
createXtreamMockApp,
@@ -46,9 +53,37 @@ jest.mock('@faker-js/faker', () => {
jest.setTimeout(60_000);
const DROPPING_EPISODE_URL = '/series/streamdrop/streamdrop/80000.mkv';
const droppingFixtureSize = statSync(
join(process.cwd(), 'apps/xtream-mock-server/src/fixtures/episode.mkv')
).size;
const droppingCut = Math.floor(
droppingFixtureSize * DROPPING_EPISODE_CUT_RATIO
);
/** Bytes received before the body ended, and whether it ended cleanly. */
async function readDroppingBody(
response: Response
): Promise<{ bytes: number; complete: boolean }> {
const reader = response.body?.getReader();
let bytes = 0;
try {
for (;;) {
const chunk = await reader?.read();
if (!chunk || chunk.done) {
return { bytes, complete: true };
}
bytes += chunk.value.length;
}
} catch {
return { bytes, complete: false };
}
}
describe('Xtream mock server factory', () => {
afterEach(() => {
resetAll();
resetDroppingEpisodes();
jest.restoreAllMocks();
});
@@ -192,7 +227,9 @@ describe('Xtream mock server factory', () => {
expect(xml).toContain('<tv ');
expect(xml).toContain('<display-name>Aurora News</display-name>');
expect(xml).toContain('channel="aurora-news.fictional"');
expect(xml).toContain(`${running.origin}/assets/marketing/logo/aurora-news.svg`);
expect(xml).toContain(
`${running.origin}/assets/marketing/logo/aurora-news.svg`
);
expect(xml).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/);
} finally {
await running.close();
@@ -259,9 +296,9 @@ describe('Xtream mock server factory', () => {
}
// Movies must finish quickly while episodes stay slow enough to
// keep a progress row in frame.
expect(
Number(movie.headers.get('content-length'))
).toBeGreaterThan(Number(episode.headers.get('content-length')));
expect(Number(movie.headers.get('content-length'))).toBeGreaterThan(
Number(episode.headers.get('content-length'))
);
expect(ordinaryMovie.status).toBe(302);
} finally {
@@ -301,6 +338,71 @@ describe('Xtream mock server factory', () => {
}
});
it('drops stream-drop episodes a third of the way in, refuses the retry, then serves ranges again', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
);
try {
const first = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: 'bytes=0-' },
}
);
expect(first.status).toBe(206);
expect(Number(first.headers.get('content-length'))).toBe(
droppingFixtureSize
);
expect(await readDroppingBody(first)).toEqual({
bytes: droppingCut,
complete: false,
});
// ffmpeg's own reconnect right after the drop.
const retry = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: `bytes=${droppingCut}-` },
}
);
expect(retry.status).toBe(403);
await retry.body?.cancel();
const later = Date.now() + DROPPING_EPISODE_REFUSE_MS + 1;
jest.spyOn(Date, 'now').mockReturnValue(later);
const resumed = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: `bytes=${droppingCut}-` },
}
);
expect(resumed.status).toBe(206);
expect(resumed.headers.get('content-range')).toBe(
`bytes ${droppingCut}-${droppingFixtureSize - 1}/${droppingFixtureSize}`
);
expect(await readDroppingBody(resumed)).toEqual({
bytes: droppingFixtureSize - droppingCut,
complete: true,
});
// A reset arms the drop again for the next test.
expect(
(await fetch(`${running.origin}/reset`, { method: 'POST' }))
.status
).toBe(200);
const afterReset = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`
);
expect(afterReset.status).toBe(200);
expect(await readDroppingBody(afterReset)).toEqual({
bytes: droppingCut,
complete: false,
});
} finally {
await running.close();
}
});
it('never answers the silent scenario detail actions while the rest of the portal works', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
@@ -9,6 +9,10 @@ import express, {
import { installLiveFormatFixture } from './live-format-fixture.js';
import { resolveXtreamMockPortString } from './mock-port.js';
import { resetAll } from './data-store.js';
import {
resetDroppingEpisodes,
streamDroppingEpisode,
} from './dropping-episode.js';
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
import { installPerformanceControlRoutes } from './performance-control-routes.js';
import {
@@ -97,6 +101,7 @@ export function createXtreamMockApp(
return;
}
resetAll();
resetDroppingEpisodes();
response.json({ status: 'reset' });
});
app.get('/playlist.m3u', (_request, response) => {
@@ -316,6 +321,10 @@ function installStreamRoutes(
);
return;
}
if (fixture === 'dropping-episode') {
streamDroppingEpisode(request, response);
return;
}
response.redirect(HLS_STUB);
};
app.get('/live/:username/:password/:streamId.m3u8', streamResponse);
@@ -10,6 +10,13 @@ Regenerate from the repository root with FFmpeg:
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=25 -f lavfi -i sine=frequency=440:sample_rate=48000 -t 6 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 25 -c:a aac -b:a 48k -f mpegts apps/xtream-mock-server/src/fixtures/live.mpegts
```
The mock returns the fixture only for synthetic live-format account media;
`episode.mkv` is sixty seconds of the same pattern, H.264 only, keyframes every
second, played by the `streamdrop` account's episodes:
```sh
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=10 -t 60 -c:v libx264 -preset veryslow -crf 40 -pix_fmt yuv420p -g 10 -fflags +bitexact -flags:v +bitexact -map_metadata -1 apps/xtream-mock-server/src/fixtures/episode.mkv
```
The mock returns `live.mpegts` only for synthetic live-format account media;
manifest and denied-segment routes return controlled 200/403 responses. Web and
Electron E2E tests check advancing playback, not HTTP status alone.
Binary file not shown.