mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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>
This commit is contained in:
1 parent
6cfdaf5900
commit
5a11b82eaf
66 files changed
+3740
-101
No files matched your search
@@ -14,6 +14,7 @@ export * from './lib/epg-item.interface';
|
||||
export * from './lib/epg-mapping-key.util';
|
||||
export * from './lib/epg-program.model';
|
||||
export * from './lib/external-player-arguments.utils';
|
||||
export * from './lib/embedded-mpv-extra-options.util';
|
||||
export * from './lib/external-player-session.interface';
|
||||
export * from './lib/global-search-result.interface';
|
||||
export * from './lib/host-connectivity.util';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS,
|
||||
normalizeEmbeddedMpvExtraOptions,
|
||||
parseEmbeddedMpvExtraOptions,
|
||||
resolveEmbeddedMpvSessionOptionArguments,
|
||||
validateEmbeddedMpvExtraOptions,
|
||||
} from './embedded-mpv-extra-options.util';
|
||||
|
||||
describe('embedded MPV extra options', () => {
|
||||
it('parses one key=value pair per line and tolerates CRLF, spaces and a -- prefix', () => {
|
||||
expect(
|
||||
parseEmbeddedMpvExtraOptions(
|
||||
'hwdec = auto-safe\r\n\n --cache-secs=30 \ndemuxer-lavf-o=reconnect=1,reconnect_delay_max=5'
|
||||
)
|
||||
).toEqual([
|
||||
{ key: 'hwdec', value: 'auto-safe' },
|
||||
{ key: 'cache-secs', value: '30' },
|
||||
{
|
||||
key: 'demuxer-lavf-o',
|
||||
value: 'reconnect=1,reconnect_delay_max=5',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips lines without a value or with a malformed key', () => {
|
||||
expect(
|
||||
parseEmbeddedMpvExtraOptions(
|
||||
'hwdec=\nno-border\n=auto\n bad key=1\nvolume=50'
|
||||
)
|
||||
).toEqual([{ key: 'volume', value: '50' }]);
|
||||
});
|
||||
|
||||
it('normalizes to canonical lines but keeps malformed lines for the user to fix', () => {
|
||||
expect(
|
||||
normalizeEmbeddedMpvExtraOptions(
|
||||
' --hwdec = auto \n\nnot an option\n'
|
||||
)
|
||||
).toBe('hwdec=auto\nnot an option');
|
||||
expect(normalizeEmbeddedMpvExtraOptions(undefined)).toBe('');
|
||||
expect(normalizeEmbeddedMpvExtraOptions(['a=1', '', 'b=2'])).toBe(
|
||||
'a=1\nb=2'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports malformed lines and forbidden keys separately', () => {
|
||||
expect(validateEmbeddedMpvExtraOptions('')).toBeNull();
|
||||
expect(validateEmbeddedMpvExtraOptions(undefined)).toBeNull();
|
||||
expect(validateEmbeddedMpvExtraOptions('hwdec=auto')).toBeNull();
|
||||
expect(
|
||||
validateEmbeddedMpvExtraOptions(
|
||||
'hwdec=auto\noops\nvo=null\nwid=1\nvo=gpu'
|
||||
)
|
||||
).toEqual({ invalidLines: ['oops'], forbiddenKeys: ['vo', 'wid'] });
|
||||
expect(
|
||||
validateEmbeddedMpvExtraOptions('input-ipc-server=/tmp/s')
|
||||
).toEqual({
|
||||
forbiddenKeys: ['input-ipc-server'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prepends the network defaults and drops forbidden keys for the addon', () => {
|
||||
expect(resolveEmbeddedMpvSessionOptionArguments('')).toEqual([
|
||||
...EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS,
|
||||
]);
|
||||
expect(
|
||||
resolveEmbeddedMpvSessionOptionArguments(
|
||||
'wid=42\nhwdec=no\nnetwork-timeout=3\nbroken line'
|
||||
)
|
||||
).toEqual([
|
||||
...EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS,
|
||||
'hwdec=no',
|
||||
'network-timeout=3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the user override after the default it replaces', () => {
|
||||
const options =
|
||||
resolveEmbeddedMpvSessionOptionArguments('network-timeout=3');
|
||||
expect(options.indexOf('network-timeout=10')).toBeLessThan(
|
||||
options.indexOf('network-timeout=3')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
ExternalPlayerArgumentsInput,
|
||||
parseExternalPlayerArguments,
|
||||
} from './external-player-arguments.utils';
|
||||
|
||||
/**
|
||||
* One libmpv option destined for an embedded MPV session, as entered in
|
||||
* Settings > Playback: `key=value`, one per line, without the leading `--`.
|
||||
*/
|
||||
export interface EmbeddedMpvExtraOption {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options the embed itself depends on. A user value for any of these would
|
||||
* detach the video from the app window (`wid`, `vo`, `force-window`), break
|
||||
* the Linux IPC control channel (`input-ipc-server`), or defeat the session
|
||||
* lifecycle the addon relies on (`idle`, `keep-open`). `config`, `include`
|
||||
* and `terminal` are refused because the embedded player deliberately runs
|
||||
* with an isolated configuration and no terminal.
|
||||
*/
|
||||
export const EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS: readonly string[] = [
|
||||
'wid',
|
||||
'vo',
|
||||
'force-window',
|
||||
'input-ipc-server',
|
||||
'idle',
|
||||
'keep-open',
|
||||
'config',
|
||||
'include',
|
||||
'terminal',
|
||||
];
|
||||
|
||||
/**
|
||||
* Applied to every embedded session ahead of the user's options, on every
|
||||
* engine: a stalled IPTV connection then surfaces as an mpv error within
|
||||
* seconds instead of the 60 s libmpv default, and ffmpeg re-requests a
|
||||
* dropped HTTP stream on its own before the app-level reconnect has to. A
|
||||
* user line with the same key wins because it is applied later.
|
||||
*/
|
||||
export const EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS: readonly string[] = [
|
||||
'network-timeout=10',
|
||||
'demuxer-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_delay_max=5',
|
||||
];
|
||||
|
||||
export interface EmbeddedMpvExtraOptionsValidationErrors {
|
||||
/** Lines that are not `key=value` with a non-empty value. */
|
||||
invalidLines?: string[];
|
||||
/** Keys from {@link EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS} present in the text. */
|
||||
forbiddenKeys?: string[];
|
||||
}
|
||||
|
||||
const OPTION_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
|
||||
|
||||
interface ParsedOptionLine {
|
||||
line: string;
|
||||
option: EmbeddedMpvExtraOption | null;
|
||||
}
|
||||
|
||||
function parseOptionLine(line: string): ParsedOptionLine {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator === -1) {
|
||||
return { line, option: null };
|
||||
}
|
||||
const key = line.slice(0, separator).trim().replace(/^--/, '');
|
||||
const value = line.slice(separator + 1).trim();
|
||||
if (!OPTION_KEY_PATTERN.test(key) || value.length === 0) {
|
||||
return { line, option: null };
|
||||
}
|
||||
return { line, option: { key, value } };
|
||||
}
|
||||
|
||||
function parseOptionLines(
|
||||
value: ExternalPlayerArgumentsInput
|
||||
): ParsedOptionLine[] {
|
||||
return parseExternalPlayerArguments(value).map(parseOptionLine);
|
||||
}
|
||||
|
||||
/** The well-formed `key=value` pairs in the text; malformed lines are skipped. */
|
||||
export function parseEmbeddedMpvExtraOptions(
|
||||
value: ExternalPlayerArgumentsInput
|
||||
): EmbeddedMpvExtraOption[] {
|
||||
return parseOptionLines(value)
|
||||
.map((parsed) => parsed.option)
|
||||
.filter((option): option is EmbeddedMpvExtraOption => option !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical text for storage: one trimmed line per option, `key=value`
|
||||
* without the `--` prefix. Malformed lines are kept verbatim rather than
|
||||
* dropped, so the user can still see and fix what the validator flagged.
|
||||
*/
|
||||
export function normalizeEmbeddedMpvExtraOptions(
|
||||
value: ExternalPlayerArgumentsInput
|
||||
): string {
|
||||
return parseOptionLines(value)
|
||||
.map((parsed) =>
|
||||
parsed.option
|
||||
? `${parsed.option.key}=${parsed.option.value}`
|
||||
: parsed.line
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation for the Settings textarea: malformed lines and forbidden keys.
|
||||
* Returns `null` when the text is acceptable, so it plugs into an Angular
|
||||
* validator without this library depending on Angular.
|
||||
*/
|
||||
export function validateEmbeddedMpvExtraOptions(
|
||||
value: unknown
|
||||
): EmbeddedMpvExtraOptionsValidationErrors | null {
|
||||
const parsedLines = parseOptionLines(
|
||||
typeof value === 'string' ? value : undefined
|
||||
);
|
||||
const invalidLines = parsedLines
|
||||
.filter((parsed) => parsed.option === null)
|
||||
.map((parsed) => parsed.line);
|
||||
const forbiddenKeys = [
|
||||
...new Set(
|
||||
parsedLines
|
||||
.map((parsed) => parsed.option?.key)
|
||||
.filter(
|
||||
(key): key is string =>
|
||||
key !== undefined &&
|
||||
EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS.includes(key)
|
||||
)
|
||||
),
|
||||
];
|
||||
if (invalidLines.length === 0 && forbiddenKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(invalidLines.length > 0 ? { invalidLines } : {}),
|
||||
...(forbiddenKeys.length > 0 ? { forbiddenKeys } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `key=value` lines a session addon receives: the network defaults
|
||||
* first, then the user's options with forbidden keys removed. Every engine
|
||||
* (Windows/Linux/macOS native-view and the frame-copy helper) applies them
|
||||
* in this order after its own built-in options.
|
||||
*/
|
||||
export function resolveEmbeddedMpvSessionOptionArguments(
|
||||
value: ExternalPlayerArgumentsInput
|
||||
): string[] {
|
||||
const userOptions = parseEmbeddedMpvExtraOptions(value)
|
||||
.filter(
|
||||
(option) => !EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS.includes(option.key)
|
||||
)
|
||||
.map((option) => `${option.key}=${option.value}`);
|
||||
return [...EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS, ...userOptions];
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import type { PlayerSubtitleStyle } from './subtitle-style.util';
|
||||
import type { RecordingStartMetadata } from './recording-metadata.interface';
|
||||
|
||||
export type EmbeddedMpvSessionStatus =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'playing'
|
||||
| 'paused'
|
||||
| 'ended'
|
||||
| 'error'
|
||||
| 'closed';
|
||||
'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error' | 'closed';
|
||||
|
||||
export interface EmbeddedMpvBounds {
|
||||
x: number;
|
||||
@@ -129,4 +123,19 @@ export interface EmbeddedMpvSession {
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
error?: string;
|
||||
/**
|
||||
* Present while the main process is waiting to reload a dropped stream
|
||||
* or has such a reload in flight; absent once playback is back or the
|
||||
* attempts are exhausted. Display only — the renderer never schedules.
|
||||
*/
|
||||
reconnect?: EmbeddedMpvReconnectInfo;
|
||||
}
|
||||
|
||||
/** Progress of the main-process automatic reconnect for one session. */
|
||||
export interface EmbeddedMpvReconnectInfo {
|
||||
/** 1-based number of the attempt that is scheduled or in flight. */
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
/** ISO timestamp at which the scheduled attempt fires. */
|
||||
nextAttemptAt: string;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
export type ExternalPlayerArgumentsInput =
|
||||
| string
|
||||
| readonly unknown[]
|
||||
| null
|
||||
| undefined;
|
||||
string | readonly unknown[] | null | undefined;
|
||||
|
||||
export function parseExternalPlayerArguments(
|
||||
value: ExternalPlayerArgumentsInput
|
||||
|
||||
@@ -212,6 +212,23 @@ export interface Settings {
|
||||
* for its preload frame pump, which is fixed at window creation.
|
||||
*/
|
||||
embeddedMpvFrameCopy?: boolean;
|
||||
/**
|
||||
* Extra libmpv options for the embedded engine, one `key=value` per line
|
||||
* (no leading `--`), applied on every engine after the built-in options
|
||||
* and the network defaults, so a line here overrides them. Keys the
|
||||
* embed depends on (`EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS`) are refused by
|
||||
* the form and skipped at session creation. Mirrored into the
|
||||
* main-process config like `embeddedMpvFrameCopy`; a change applies to
|
||||
* the next embedded session.
|
||||
*/
|
||||
embeddedMpvExtraOptions?: string;
|
||||
/**
|
||||
* Reload a dropped embedded MPV stream automatically (mpv error, or EOF
|
||||
* on a live stream) with capped backoff. Default on; only a stream that
|
||||
* already played is retried, so a dead URL stays on the manual Retry.
|
||||
* Captured per session, so a change applies to the next session.
|
||||
*/
|
||||
embeddedMpvAutoReconnect?: boolean;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
|
||||
Reference in new issue
Block a user