mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -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>
This commit is contained in:
1 parent
a9f4ae38ea
commit
df1667c866
16 files changed
+840
-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,187 @@
|
||||
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: proof that the episode resumed rather than restarted. */
|
||||
const RESUMED_POSITION_SECONDS = 25;
|
||||
|
||||
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. */
|
||||
function outcomeAfterDrop(
|
||||
sessions: SessionSample[],
|
||||
[first, second]: number[]
|
||||
): 'pending' | 'resumed' | 'skipped' {
|
||||
const dropped = sessions.findIndex(
|
||||
(session) => session.episodeId === first && session.status === 'ended'
|
||||
);
|
||||
if (dropped < 0) {
|
||||
return 'pending';
|
||||
}
|
||||
for (const session of sessions.slice(dropped)) {
|
||||
if (session.episodeId === second) {
|
||||
return 'skipped';
|
||||
}
|
||||
if (
|
||||
session.episodeId === first &&
|
||||
session.status === 'playing' &&
|
||||
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);
|
||||
}
|
||||
});
|
||||
+36
-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,39 @@ 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('treats EOF on a live stream as a drop', () => {
|
||||
startPlayingLive();
|
||||
|
||||
|
||||
@@ -1333,7 +1333,8 @@ export class EmbeddedMpvNativeService {
|
||||
payload.status,
|
||||
previousStatus,
|
||||
payload.positionSeconds,
|
||||
snapshot.errorOrigin
|
||||
snapshot.errorOrigin,
|
||||
payload.durationSeconds
|
||||
);
|
||||
if (reconnect) {
|
||||
payload.reconnect = reconnect;
|
||||
|
||||
@@ -197,6 +197,220 @@ 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('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,11 @@ 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. 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 +119,7 @@ export function createEmbeddedMpvReconnectState(
|
||||
attemptInFlight: false,
|
||||
lastPositionSeconds: null,
|
||||
positionReported: false,
|
||||
resumedAtSeconds: null,
|
||||
timer: null,
|
||||
pending: null,
|
||||
};
|
||||
@@ -131,6 +151,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,6 +197,7 @@ export class EmbeddedMpvReconnectCoordinator {
|
||||
? playback.startTime
|
||||
: null;
|
||||
state.positionReported = false;
|
||||
state.resumedAtSeconds = null;
|
||||
state.pending = null;
|
||||
}
|
||||
|
||||
@@ -188,7 +228,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 +252,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 +289,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.
|
||||
@@ -297,6 +350,7 @@ export class EmbeddedMpvReconnectCoordinator {
|
||||
state.timer = null;
|
||||
state.attempts = attempt;
|
||||
state.attemptInFlight = true;
|
||||
state.resumedAtSeconds = playback.startTime ?? null;
|
||||
try {
|
||||
this.hooks.reload(sessionId, playback);
|
||||
} catch (error) {
|
||||
@@ -325,6 +379,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,14 @@ 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. The reload records where it resumed; an early EOF before
|
||||
playback gets 5 s past that point 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.
|
||||
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