mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(playback): keep the early-EOF retry on a refused reload or after a seek
The resume point that tells a reload ending where it resumed from a new drop was recorded before the reload ran, so a reload the addon refused made the original EOF look like the end and the next poll cancelled the retry. Record it only once the reload started, and forget it when the user seeks: an EOF after a seek is judged on its position alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df1667c866
commit
0a574a717a
5 files changed
+162
-7
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user