mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
* fix(playback): resume an episode whose connection drops instead of skipping it (#1902) Embedded MPV sessions run with keep-open, so a provider that closes the connection mid-episode (and refuses ffmpeg's own reconnect) reaches the app as eof-reached: status `ended` at the drop position. The reconnect policy counted `ended` as a loss only for live playback, so on VOD it stood for the end of the file and series autoplay started the next episode. - The reconnect policy now treats a VOD `ended` with more than max(10 s, 1 % of the duration) left as a dropped connection and reloads it at the last position. A reload that ends again before getting 5 s past where it resumed proves the file ends there (bitrate-estimated duration), so it is left as the end and autoplay continues after one attempt. - The service passes the snapshot duration to the policy. - The player emits playbackEnded only for an `ended` session without a pending reconnect. - Xtream mock: `streamdrop` scenario whose episodes drop a third of the way into a local 60 s clip and refuse the retry for 4 s; Electron E2E covers the native-view engine (skips without an Embedded MPV runtime). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep the early-EOF retry on a refused reload or after a seek The resume point that tells a reload ending where it resumed from a new drop was recorded before the reload ran, so a reload the addon refused made the original EOF look like the end and the next poll cancelled the retry. Record it only once the reload started, and forget it when the user seeks: an EOF after a seek is judged on its position alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): fail the stream-drop resume check when the reload restarts the episode A reload that started the episode over would also reach the 25 s mark within the timeout, since the mock drops each episode only once. Every position played after the drop must now stay within 3 s of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.1 KiB
TypeScript
201 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
});
|