mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16: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>
This commit is contained in:
16 files changed
+1008
-16
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [1902]
|
||||
---
|
||||
|
||||
Embedded MPV no longer skips to the next episode when the provider drops the
|
||||
connection in the middle of an episode. It now reconnects and continues the
|
||||
episode where it stopped; a movie interrupted the same way resumes as well.
|
||||
@@ -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
-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
|
||||
|
||||
@@ -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.
@@ -636,6 +636,13 @@ button disabled state at current-season boundaries.
|
||||
|
||||
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
|
||||
|
||||
`ended` alone does not prove the episode is over: mpv reports a connection the
|
||||
server closed (and ffmpeg could not reopen) as EOF too, once its cache runs
|
||||
dry. `EmbeddedMpvPlayerComponent` therefore emits `playbackEnded` only for an
|
||||
`ended` session that carries no `reconnect`; while the main process reloads a
|
||||
VOD that ended well before its duration (see Network Auto-Reconnect), the
|
||||
episode continues instead of autoplay skipping its rest (issue #1902).
|
||||
|
||||
## Session Options (Extra libmpv Options)
|
||||
|
||||
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
|
||||
@@ -701,8 +708,9 @@ included.
|
||||
`EmbeddedMpvReconnectCoordinator`
|
||||
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
|
||||
the last user-requested playback when a session reports a stream loss: an
|
||||
`error` status, or `ended` while the playback is live (`isLive`, else "no
|
||||
`contentInfo`", the same rule the renderer uses). It is driven by
|
||||
`error` status, `ended` while the playback is live (`isLive`, else "no
|
||||
`contentInfo`", the same rule the renderer uses), or `ended` on VOD well
|
||||
before the known duration. It is driven by
|
||||
`refreshSession()` status transitions, so it covers every engine, and it is
|
||||
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
|
||||
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
|
||||
@@ -716,6 +724,16 @@ mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
|
||||
the IPC socket so a stream that ended reports `ended` rather than the
|
||||
`paused` that keep-open would otherwise look like, and an mpv process that
|
||||
exits abnormally reports `error`.
|
||||
- A VOD `ended` counts only when more than `max(10 s, 1 % of the duration)`
|
||||
remained (`isEmbeddedMpvEarlyEof`): the margin absorbs container rounding
|
||||
and the whole-second snapshot values, and with no known duration an EOF is
|
||||
always the end. A reload the engine accepted records where it resumed; an
|
||||
early EOF before playback gets 5 s past that point (and with no user seek
|
||||
in between) ended where the file does (a duration mpv estimated from the
|
||||
bitrate can overshoot) and is left as the end, so autoplay still continues
|
||||
after one attempt instead of a reload loop. A refused reload records
|
||||
nothing, so the original EOF keeps its backoff.
|
||||
With the setting off, an early EOF stays a plain `ended`.
|
||||
- Backoff is 2 s, 4 s, 8 s, 16 s, 30 s, 30 s, at most six attempts per
|
||||
outage. The budget resets only after 30 s of uninterrupted `playing`, so a
|
||||
stream that flaps every few seconds runs out of attempts instead of being
|
||||
|
||||
@@ -405,6 +405,7 @@ sometimes only respond to that misspelled action.
|
||||
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
|
||||
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
|
||||
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
|
||||
| `streamdrop:streamdrop` | 8181 | series:1 | 1 | active |
|
||||
| `expired:expired` | 4004 | 4 each | 10 | Expired |
|
||||
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
|
||||
| `<any other>` | hash | 6 each | 30 | active |
|
||||
@@ -428,6 +429,17 @@ Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip
|
||||
the catch-up seek bar's label bounds; `epg:epg` keeps its short titles,
|
||||
which other tests assert verbatim.
|
||||
|
||||
### `streamdrop:streamdrop` fixture details
|
||||
|
||||
Its three episodes play the local 60 s Matroska clip
|
||||
(`src/fixtures/episode.mkv`, generation command in the fixtures README) with
|
||||
byte ranges, but `dropping-episode.ts` cuts the first response that reaches
|
||||
35 % of the file and answers 403 for the next 4 s, so ffmpeg's own HTTP
|
||||
reconnect fails and mpv reports EOF mid-episode. `POST /reset` re-arms the
|
||||
drop. The Electron E2E `embedded-mpv-series-stream-drop.e2e.ts` uses it to
|
||||
check that Embedded MPV resumes the episode instead of autoplaying the next
|
||||
one (issue #1902).
|
||||
|
||||
### `marketing:marketing` fixture details
|
||||
|
||||
This scenario is reserved for release screenshots and marketing materials:
|
||||
|
||||
@@ -166,6 +166,57 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
|
||||
expect(player.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not end the episode while the main process reloads a dropped stream', () => {
|
||||
// A connection lost mid-episode reaches mpv as EOF (#1902); the
|
||||
// reconnect owns it, and autoplay would skip the rest of the episode.
|
||||
const reconnect = {
|
||||
attempt: 1,
|
||||
maxAttempts: 6,
|
||||
nextAttemptAt: '2026-06-06T12:00:52Z',
|
||||
};
|
||||
controller.session.update((session) =>
|
||||
session
|
||||
? {
|
||||
...session,
|
||||
status: 'ended',
|
||||
positionSeconds: 50,
|
||||
reconnect,
|
||||
updatedAt: '2026-06-06T12:00:50Z',
|
||||
}
|
||||
: session
|
||||
);
|
||||
fixture.detectChanges();
|
||||
controller.session.update((session) =>
|
||||
session
|
||||
? {
|
||||
...session,
|
||||
status: 'loading',
|
||||
updatedAt: '2026-06-06T12:00:52Z',
|
||||
}
|
||||
: session
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.endedCount).toBe(0);
|
||||
expect(player.statusLabel()).toContain('RECONNECTING');
|
||||
|
||||
// The reloaded episode later finishes for real.
|
||||
controller.session.update((session) =>
|
||||
session
|
||||
? {
|
||||
...session,
|
||||
status: 'ended',
|
||||
positionSeconds: 120,
|
||||
reconnect: undefined,
|
||||
updatedAt: '2026-06-06T12:02:00Z',
|
||||
}
|
||||
: session
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.endedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('hides episode navigation for live playback', () => {
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
|
||||
|
||||
@@ -578,7 +578,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
|
||||
effect(() => {
|
||||
const session = this.session();
|
||||
if (!session || session.status !== 'ended') {
|
||||
// An `ended` the main process is reloading is a dropped
|
||||
// connection, not the end: autoplay would skip the rest of the
|
||||
// episode (#1902).
|
||||
if (!session || session.status !== 'ended' || session.reconnect) {
|
||||
return;
|
||||
}
|
||||
if (this.lastEndedSessionId === session.id) {
|
||||
|
||||
Reference in new issue
Block a user