From 0a574a717adae6172da5ca702dee4fc0d00a20f0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 08:55:48 +0200 Subject: [PATCH] fix(playback): keep the early-EOF retry on a refused reload or after a seek The resume point that tells a reload ending where it resumed from a new drop was recorded before the reload ran, so a reload the addon refused made the original EOF look like the end and the next poll cancelled the retry. Record it only once the reload started, and forget it when the user seeks: an EOF after a seek is judged on its position alone. Co-Authored-By: Claude Opus 5.5 --- ...edded-mpv-native.service.reconnect.spec.ts | 24 ++++ .../services/embedded-mpv-native.service.ts | 10 ++ .../services/embedded-mpv-reconnect.spec.ts | 108 ++++++++++++++++++ .../app/services/embedded-mpv-reconnect.ts | 17 ++- docs/architecture/embedded-mpv-native.md | 10 +- 5 files changed, 162 insertions(+), 7 deletions(-) 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