mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
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([
|
||||
{
|
||||
|
||||
+60
-1
@@ -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));
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
Reference in new issue
Block a user