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 228d1ffbb..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 @@ -631,6 +631,30 @@ describe('EmbeddedMpvNativeService reconnect', () => { 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 0e71c63bd..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); 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 cb0f5ecc2..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 @@ -411,6 +411,114 @@ describe('EmbeddedMpvReconnectCoordinator', () => { }); }); + 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 1bf7bd1a1..d4940d450 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts @@ -89,8 +89,9 @@ export interface EmbeddedMpvReconnectState { */ 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. + * 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; @@ -201,6 +202,14 @@ export class EmbeddedMpvReconnectCoordinator { 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; @@ -350,9 +359,11 @@ export class EmbeddedMpvReconnectCoordinator { state.timer = null; state.attempts = attempt; state.attemptInFlight = true; - state.resumedAtSeconds = playback.startTime ?? null; 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)) { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 7aac74aa1..13a853f92 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -727,10 +727,12 @@ mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow: - 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. + 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