Files
iptvnator/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts
T
ZoultandClaude Fable 5.1 5a11b82eaf feat(embedded-mpv): configurable extra libmpv options and network auto-reconnect (#1515)
Extra libmpv options (Settings > Playback) reach every embedded engine off the command line (createSession array on Windows/macOS, a 0600 --include file on Linux native-view, a stdin preamble for the frame-copy helper); the keys the embed depends on are refused, and keys libmpv rejects are reported once per session. Dropped streams reload automatically (error, or ended on live) with 2 s -> 30 s backoff, six attempts per outage and a 30 s stability reset, only for a load that already played; engine failures stay terminal, a running recording is filed as interrupted and restarted after the reload, and an external subtitle file is re-added. Settings.embeddedMpvAutoReconnect (default on) opts out; the player shows 'Reconnecting... attempt N of M'.

Started by Bpl5966 in #1515 and finished by the maintainers in the same PR.

Co-authored-by: Bpl5966 <amine.b1959@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:36:14 +02:00

353 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
EmbeddedMpvReconnectInfo,
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { isEmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
/**
* Automatic reconnect for embedded MPV sessions.
*
* The native engines report a dropped IPTV connection as an mpv error, or as
* EOF when the server simply closed the socket. Either leaves the viewer on a
* dead player, so the main process reloads the last user-requested playback
* with capped exponential backoff. The policy is deliberately narrow:
*
* - 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.
* - 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
* out of attempts instead of being retried forever.
* - A user-driven load, a pause, or teardown cancels everything. A pause
* also disarms the policy until the stream plays again: a drop while the
* user has the stream paused must not resume playback behind their back.
* - An attempt is tracked explicitly from the moment its reload is issued,
* so a reload that fails before the poll ever sees it `loading` is still
* counted as a failed attempt rather than mistaken for the original loss.
* - A VOD reload resumes at the last position observed while playing; a live
* reload goes back to the live edge.
* - An `error` the engine attributes to itself (a macOS render failure, a
* fatal libmpv log in the helper) is not a stream loss: reloading media
* cannot repair a broken render context, so it stays a terminal error.
* - A reload the engine cannot take at all (a crashed frame-copy helper,
* reported as `EmbeddedMpvSessionGoneError`) ends the reconnect: only a
* new session can recover, and the renderer's Retry creates one.
*
* Status transitions arrive from `EmbeddedMpvNativeService.refreshSession()`
* (polled every 500 ms); every engine flips to `loading` synchronously on a
* load, so a failed attempt is always observed as `loading` → loss and never
* as loss → loss. The coordinator publishes {@link EmbeddedMpvReconnectInfo}
* for the renderer, which displays it and never schedules anything itself.
*/
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;
export interface EmbeddedMpvReconnectState {
/** `Settings.embeddedMpvAutoReconnect`, captured at session creation. */
enabled: boolean;
/** The last playback the user asked for; what every attempt reloads. */
playback: ResolvedPortalPlayback | null;
/** Whether that playback reached `playing` at least once. */
hadPlayed: boolean;
/** Attempts spent on the current outage. */
attempts: number;
/** Start of the current uninterrupted `playing` stretch. */
playingSince: number | null;
/** A reload was issued and neither `playing` nor a loss has answered it yet. */
attemptInFlight: boolean;
/**
* Last position reported while the stream played, seeded from the
* playback's own `startTime`. Non-live reloads carry it as `startTime`,
* otherwise mpv would restart at the playback's original resume offset.
*/
lastPositionSeconds: number | null;
/**
* Whether a positive position has been reported for this load. Engine
* snapshots start at zero before the first `time-pos` update, so a zero
* is trusted as a real seek-to-start only once that has happened.
*/
positionReported: boolean;
timer: NodeJS.Timeout | null;
/** What the renderer is shown while an attempt is scheduled or in flight. */
pending: EmbeddedMpvReconnectInfo | null;
}
export interface EmbeddedMpvReconnectHooks {
/** Issue the reload against the addon; may throw. */
reload(sessionId: string, playback: ResolvedPortalPlayback): void;
/** Re-read the session so the renderer sees the attempt; must not throw. */
publish(sessionId: string): void;
now?(): number;
log?: Pick<Console, 'log' | 'warn'>;
}
export function createEmbeddedMpvReconnectState(
enabled: boolean
): EmbeddedMpvReconnectState {
return {
enabled,
playback: null,
hadPlayed: false,
attempts: 0,
playingSince: null,
attemptInFlight: false,
lastPositionSeconds: null,
positionReported: false,
timer: null,
pending: null,
};
}
/** Same rule as the renderer's `isLivePlayback`: explicit flag, else "no content info". */
export function isLiveEmbeddedMpvPlayback(
playback: ResolvedPortalPlayback
): boolean {
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
}
/** 2 s, 4 s, 8 s, 16 s, 30 s, 30 s for attempts 1–6. */
export function resolveEmbeddedMpvReconnectDelayMs(
completedAttempts: number
): number {
return Math.min(
EMBEDDED_MPV_RECONNECT_BASE_DELAY_MS * 2 ** completedAttempts,
EMBEDDED_MPV_RECONNECT_MAX_DELAY_MS
);
}
export function isEmbeddedMpvStreamLoss(
status: EmbeddedMpvSessionStatus,
isLive: boolean
): boolean {
return status === 'error' || (status === 'ended' && isLive);
}
export class EmbeddedMpvReconnectCoordinator {
private readonly now: () => number;
private readonly log: Pick<Console, 'log' | 'warn'>;
constructor(private readonly hooks: EmbeddedMpvReconnectHooks) {
this.now = hooks.now ?? (() => Date.now());
this.log = hooks.log ?? console;
}
/** The user (or the renderer on their behalf) asked for a playback. */
onUserLoad(
state: EmbeddedMpvReconnectState,
playback: ResolvedPortalPlayback
): void {
this.clearTimer(state);
state.playback = playback;
state.hadPlayed = false;
state.attempts = 0;
state.playingSince = null;
state.attemptInFlight = false;
state.lastPositionSeconds =
typeof playback.startTime === 'number' &&
Number.isFinite(playback.startTime) &&
playback.startTime >= 0
? playback.startTime
: null;
state.positionReported = false;
state.pending = null;
}
cancel(state: EmbeddedMpvReconnectState): void {
this.clearTimer(state);
state.attemptInFlight = false;
state.pending = null;
}
/**
* The user asked to pause. Observing `paused` cannot cover a pause sent
* while the session already sits in a loss state (the engines keep
* `error`/`ended` rather than flipping to `paused`), so the command
* itself cancels and disarms: a reload would resume playback unasked.
*/
onUserPause(state: EmbeddedMpvReconnectState): void {
this.cancel(state);
state.hadPlayed = false;
}
/**
* Feed one observed status (and the position that came with it). Returns
* what the renderer should be shown (`null` when nothing is pending).
*/
observe(
sessionId: string,
state: EmbeddedMpvReconnectState,
status: EmbeddedMpvSessionStatus,
previousStatus: EmbeddedMpvSessionStatus | null,
positionSeconds?: number,
errorOrigin?: 'playback' | 'engine'
): EmbeddedMpvReconnectInfo | null {
const now = this.now();
if (status === 'error' && errorOrigin === 'engine') {
this.cancel(state);
return null;
}
if (
(status === 'playing' || status === 'paused') &&
typeof positionSeconds === 'number' &&
Number.isFinite(positionSeconds)
) {
if (positionSeconds > 0) {
state.lastPositionSeconds = positionSeconds;
state.positionReported = true;
} else if (positionSeconds === 0 && state.positionReported) {
// A real seek back to the start. Before any positive
// position the zero is only the snapshot's placeholder and
// the seeded `startTime` stays the better resume point.
state.lastPositionSeconds = 0;
}
}
if (status === 'playing') {
state.hadPlayed = true;
if (state.playingSince === null) {
state.playingSince = now;
} else if (
state.attempts > 0 &&
now - state.playingSince >=
EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS
) {
state.attempts = 0;
}
// Either our attempt succeeded or the stream recovered on its
// own (ffmpeg-level reconnect) while a retry was still pending.
this.cancel(state);
return null;
}
state.playingSince = null;
if (status === 'loading') {
// A fresh user load, or our own attempt in flight.
return state.pending;
}
const isLive =
state.playback !== null &&
isLiveEmbeddedMpvPlayback(state.playback);
if (!isEmbeddedMpvStreamLoss(status, isLive)) {
// paused/idle are user actions, closed is teardown, ended on
// VOD 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.
state.hadPlayed = false;
}
this.cancel(state);
return null;
}
const answersAttempt = state.attemptInFlight;
if (
(status === previousStatus && !answersAttempt) ||
state.timer !== null
) {
// Same loss observed again by the poll, or already scheduled.
return state.pending;
}
state.attemptInFlight = false;
return this.schedule(sessionId, state, now);
}
private schedule(
sessionId: string,
state: EmbeddedMpvReconnectState,
now: number
): EmbeddedMpvReconnectInfo | null {
if (!state.enabled || state.playback === null || !state.hadPlayed) {
state.pending = null;
return null;
}
if (state.attempts >= EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS) {
this.log.warn(
`[Embedded MPV][reconnect] session ${sessionId}: giving up after ${state.attempts} attempts`
);
state.pending = null;
return null;
}
const delay = resolveEmbeddedMpvReconnectDelayMs(state.attempts);
const attempt = state.attempts + 1;
const playback = this.playbackForReload(state.playback, state);
state.pending = {
attempt,
maxAttempts: EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS,
nextAttemptAt: new Date(now + delay).toISOString(),
};
this.log.log(
`[Embedded MPV][reconnect] session ${sessionId}: stream lost, attempt ${attempt}/${EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS} in ${delay} ms`
);
state.timer = setTimeout(() => {
state.timer = null;
state.attempts = attempt;
state.attemptInFlight = true;
try {
this.hooks.reload(sessionId, playback);
} catch (error) {
state.attemptInFlight = false;
if (isEmbeddedMpvSessionGoneError(error)) {
// Nothing behind the session can take a load any more;
// stop so the renderer shows the actionable error
// instead of a reconnect spinner that never resolves.
this.log.warn(
`[Embedded MPV][reconnect] session ${sessionId}: engine gone, giving up`,
error
);
state.pending = null;
} else {
this.log.warn(
`[Embedded MPV][reconnect] session ${sessionId}: attempt ${attempt} could not start`,
error
);
// The addon refused the load outright, so no loading→loss
// transition will ever arrive: continue the backoff here.
this.schedule(sessionId, state, this.now());
}
}
this.hooks.publish(sessionId);
}, delay);
return state.pending;
}
/**
* Live streams reload at the live edge. Anything else resumes where the
* connection dropped: the stored playback still carries the offset the
* user originally resumed from, and reloading it would rewind the movie.
*/
private playbackForReload(
playback: ResolvedPortalPlayback,
state: EmbeddedMpvReconnectState
): ResolvedPortalPlayback {
if (
isLiveEmbeddedMpvPlayback(playback) ||
state.lastPositionSeconds === null
) {
return playback;
}
return { ...playback, startTime: state.lastPositionSeconds };
}
private clearTimer(state: EmbeddedMpvReconnectState): void {
if (state.timer !== null) {
clearTimeout(state.timer);
state.timer = null;
}
}
}