diff --git a/.changes/playback-embedded-mpv-mid-episode-drop.md b/.changes/playback-embedded-mpv-mid-episode-drop.md new file mode 100644 index 000000000..3175454e5 --- /dev/null +++ b/.changes/playback-embedded-mpv-mid-episode-drop.md @@ -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. diff --git a/apps/electron-backend-e2e/src/embedded-mpv-series-stream-drop.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-series-stream-drop.e2e.ts new file mode 100644 index 000000000..e04fbdc8a --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-series-stream-drop.e2e.ts @@ -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 { + 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; + }; + 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 { + return page.evaluate(() => { + const episodeBySession = new Map(); + 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); + } +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts index d682ff2ce..e621b58ce 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts @@ -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(() => ({ 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(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index bd95e81eb..1b539e4a6 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts index 249d65a11..dbb5283aa 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts index 2dc888792..d4940d450 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts @@ -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; @@ -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 diff --git a/apps/xtream-mock-server/src/app/dropping-episode.ts b/apps/xtream-mock-server/src/app/dropping-episode.ts new file mode 100644 index 000000000..7ab708600 --- /dev/null +++ b/apps/xtream-mock-server/src/app/dropping-episode.ts @@ -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(); + +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)); +} diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 7878733c0..c9fa0f731 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -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 = { 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: diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 9ce71d931..bdffbe350 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -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('Aurora News'); 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 }) diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index 4e24f7341..41f73eb86 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -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); diff --git a/apps/xtream-mock-server/src/fixtures/README.md b/apps/xtream-mock-server/src/fixtures/README.md index 330cf7f64..bacea0682 100644 --- a/apps/xtream-mock-server/src/fixtures/README.md +++ b/apps/xtream-mock-server/src/fixtures/README.md @@ -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. diff --git a/apps/xtream-mock-server/src/fixtures/episode.mkv b/apps/xtream-mock-server/src/fixtures/episode.mkv new file mode 100644 index 000000000..ea412ad81 Binary files /dev/null and b/apps/xtream-mock-server/src/fixtures/episode.mkv differ diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 2e20629c8..13a853f92 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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 diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 28f9ffbe9..e11a404af 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -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 | | `` | 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: diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts index 6562853e0..17e3bb54a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index d22c51931..175a00bae 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -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) {