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:
ZoultandClaude Fable 5.1 authored and GitHub committed 2026-09-04 16:36:14 +02:00
1 parent 6cfdaf5900
commit 5a11b82eaf
66 files changed
+3740 -101

No files matched your search

@@ -0,0 +1,11 @@
---
type: feature
area: embedded-mpv
highlight: Embedded MPV reconnects dropped streams
---
The embedded MPV player now reloads a live stream that drops mid-playback on
its own, with increasing delays and an "attempt N of 6" line instead of a
dead error screen; a new Settings > Playback toggle turns this off. The same
section gains an advanced field for extra libmpv options (one key=value per
line) that applies on every engine, with a short network timeout on by default.
+2 -2
View File
@@ -187,7 +187,7 @@ Useful narrower flags:
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity, bounded Embedded MPV runtime-probe stderr, and embedded MPV session status transitions (the input of the reconnect policy; never the stream URL)
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
@@ -997,7 +997,7 @@ app as a real argument, so it is not an option.
API. Radio's `<audio>` deliberately never blocks display sleep. Embedded
MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
inhibit the screensaver themselves.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. Two per-session knobs are captured at session creation from the main-process settings mirror (`readEmbeddedMpvSessionOptions()`, read in the `EMBEDDED_MPV_CREATE_SESSION` handler — never inside the service, whose specs would otherwise construct electron-conf): `Settings.embeddedMpvExtraOptions` (free-form `key=value` libmpv lines, forbidden embed-critical keys refused by the form, network defaults `network-timeout=10` + ffmpeg `reconnect` prepended, applied on every engine after its built-ins — Windows/macOS via `mpv_set_option_string`, Linux through a user-only `--include` config file, frame-copy as the helper's first stdin line — never on a command line, where `ps` could read a credential-bearing header option) and `Settings.embeddedMpvAutoReconnect` (default on: `EmbeddedMpvReconnectCoordinator` in `embedded-mpv-reconnect.ts` reloads the last playback on `error`, or `ended` for live, only if it had played, with 2 s→30 s backoff, six attempts per outage, budget reset after 30 s stable playing, cancelled by user loads/pause/dispose; a recording running at the drop is finalized as an interrupted partial when the reload actually replaces the stream (a stream that recovers on its own keeps recording) and restarted into a new file once that reload plays, and an external subtitle file added through `sub-add` is re-added once the reload plays unless the user picked another track or loaded something else since; the renderer only displays `EmbeddedMpvSession.reconnect`). Contract: `docs/architecture/embedded-mpv-native.md` ("Session Options", "Network Auto-Reconnect"). macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
engine` (restart required) or
@@ -16,6 +16,7 @@
* Usage:
* iptvnator_mpv_helper --shm-base /impv-<id> --width 1280 --height 720
* [--volume 0..1] [--hwdec auto]
* [--mpv-options-stdin]
*/
#include <mpv/client.h>
@@ -72,6 +73,9 @@ struct SnapshotState {
std::string recordingStartedAt;
std::string recordingError;
std::string error;
/* True when `error` is the engine's own failure (a fatal libmpv log)
* rather than the stream's: the app must not reload media for it. */
bool engineError = false;
};
struct HelperState {
@@ -173,7 +177,10 @@ std::string composeSnapshotLocked() {
recording.str("startedAt", s.recordingStartedAt);
if (!s.recordingError.empty()) recording.str("error", s.recordingError);
writer.raw("recording", recording.finish());
if (!s.error.empty()) writer.str("error", s.error);
if (!s.error.empty()) {
writer.str("error", s.error);
if (s.engineError) writer.str("errorOrigin", "engine");
}
return writer.finish();
}
@@ -382,6 +389,7 @@ void runMpvEventLoop() {
case MPV_EVENT_START_FILE:
s.status = "loading";
s.error.clear();
s.engineError = false;
s.audioTracks.clear();
s.selectedAudioTrackId = -1;
s.subtitleTracks.clear();
@@ -413,6 +421,7 @@ void runMpvEventLoop() {
g_state.running.load()) {
s.status = "loading";
s.error.clear();
s.engineError = false;
g_state.loadedPath = false;
} else if (g_state.running.load()) {
s.status = "idle";
@@ -440,6 +449,7 @@ void runMpvEventLoop() {
}
if (level == "fatal") {
s.status = "error";
s.engineError = true;
}
emitLine(JsonWriter()
.str("event", "log")
@@ -486,6 +496,7 @@ void handleLoadCommand(const Command& command) {
}
g_state.snapshot.streamUrl = url;
g_state.snapshot.error.clear();
g_state.snapshot.engineError = false;
g_state.snapshot.status = "loading";
g_state.dirty = true;
}
@@ -629,6 +640,12 @@ struct HelperArgs {
std::string shmBase = "/impv";
std::string hwdec = "auto";
std::string audioDelay; /* seconds, mpv audio-delay passthrough */
/* Read the session's "key=value" libmpv options (network defaults plus
* the user's Settings > Playback lines) from the first stdin line and
* apply them after the built-in block so they override it. They never
* travel on argv: a credential-bearing option must not be readable
* through `ps` or /proc/<pid>/cmdline. */
bool mpvOptionsOnStdin = false;
int width = 1280;
int height = 720;
double volume = 1;
@@ -648,6 +665,7 @@ HelperArgs parseArgs(int argc, char** argv) {
else if (arg == "--volume") args.volume = std::atof(next().c_str());
else if (arg == "--hwdec") args.hwdec = next();
else if (arg == "--audio-delay") args.audioDelay = next();
else if (arg == "--mpv-options-stdin") args.mpvOptionsOnStdin = true;
else if (arg == "--runtime-probe") args.runtimeProbe = true;
}
args.width = std::max(16, args.width);
@@ -811,6 +829,53 @@ int main(int argc, char** argv) {
mpv_set_option_string(g_state.mpv, "audio-delay",
args.audioDelay.c_str());
}
if (args.mpvOptionsOnStdin) {
/* `mpv-options\to000=<key=value>\to001=...` in the regular command
* encoding; zero-padded keys keep the application order (network
* defaults first, then the user's lines) through the field map. */
std::string preamble;
if (!std::getline(std::cin, preamble)) {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", "mpv-options preamble missing on stdin")
.finish());
return 1;
}
const frame_helper::Command preambleCommand =
frame_helper::parseCommandLine(preamble);
if (preambleCommand.name != "mpv-options") {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", "expected the mpv-options preamble on stdin")
.finish());
return 1;
}
std::vector<std::pair<std::string, std::string>> entries(
preambleCommand.args.begin(), preambleCommand.args.end());
std::sort(entries.begin(), entries.end());
for (const auto& entry : entries) {
const std::string& option = entry.second;
const size_t separator = option.find('=');
if (separator == std::string::npos || separator == 0 ||
separator + 1 >= option.size()) {
continue;
}
const std::string key = option.substr(0, separator);
const std::string value = option.substr(separator + 1);
const int optionResult = mpv_set_option_string(
g_state.mpv, key.c_str(), value.c_str());
if (optionResult < 0) {
emitLine(JsonWriter()
.str("event", "log")
.str("level", "warn")
.str("prefix", "iptvnator")
.str("text", "rejected session option " + key +
": " +
mpv_error_string(optionResult))
.finish());
}
}
}
if (mpv_initialize(g_state.mpv) < 0) {
emitLine(JsonWriter()
.str("event", "fatal")
@@ -13,6 +13,8 @@
#include <mpv/render.h>
#include <mpv/render_gl.h>
#include "embedded_mpv_extra_options.h"
#include <algorithm>
#include <atomic>
#include <chrono>
@@ -96,6 +98,12 @@ struct SessionSnapshot {
double volumePercent = 100.0;
std::string streamUrl;
std::string error;
// True when `error` is the engine's own failure (render context, fatal
// libmpv log) rather than the stream's: the app must not reload for it.
bool engineError = false;
// Keys of session options libmpv refused at creation (names only, never
// values: a value may carry credentials). Reported once by the service.
std::vector<std::string> rejectedOptionKeys;
std::vector<AudioTrack> audioTracks;
int64_t selectedAudioTrackId = -1;
std::vector<AudioTrack> subtitleTracks;
@@ -203,7 +211,11 @@ std::shared_ptr<Session> getSessionOrThrow(
void scheduleRender(const std::shared_ptr<Session>& session);
void requestRender(const std::shared_ptr<Session>& session);
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error);
void updateSessionError(
const std::shared_ptr<Session>& session,
const std::string& error,
bool engineError = false
);
bool isEmbeddedMpvTraceEnabled()
{
@@ -550,7 +562,8 @@ void renderSoftwareFrame(const std::shared_ptr<Session>& session)
if (result < 0) {
updateSessionError(
session,
std::string("Failed to render frame: ") + mpv_error_string(result)
std::string("Failed to render frame: ") + mpv_error_string(result),
true
);
return;
}
@@ -670,7 +683,8 @@ void renderOpenGLFrame(const std::shared_ptr<Session>& session)
updateSessionError(
session,
std::string("Failed to render OpenGL frame: ") +
mpv_error_string(result)
mpv_error_string(result),
true
);
return;
}
@@ -1035,11 +1049,16 @@ void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& nod
);
}
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error)
void updateSessionError(
const std::shared_ptr<Session>& session,
const std::string& error,
bool engineError
)
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.status = SessionStatus::Error;
session->snapshot.error = error;
session->snapshot.engineError = engineError;
}
uint64_t nextAsyncRequestId()
@@ -1154,6 +1173,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
case MPV_EVENT_START_FILE:
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.engineError = false;
session->snapshot.audioTracks.clear();
session->snapshot.selectedAudioTrackId = -1;
session->snapshot.subtitleTracks.clear();
@@ -1186,6 +1206,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
session->running.load()) {
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.engineError = false;
session->loadedPath = false;
} else if (session->running.load()) {
session->snapshot.status = SessionStatus::Idle;
@@ -1353,6 +1374,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
}
if (level == "fatal") {
session->snapshot.status = SessionStatus::Error;
session->snapshot.engineError = true;
}
break;
}
@@ -1657,6 +1679,25 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
std::to_string(session->snapshot.volumePercent);
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
// Session options from Settings (network defaults first, then the
// user's lines). Applied last so a user value overrides a built-in one,
// and before mpv_initialize because most of them are init-only.
for (const auto& option : iptvnator::readEmbeddedMpvExtraOptions(info, 4)) {
const int optionResult = mpv_set_option_string(
session->handle,
option.first.c_str(),
option.second.c_str()
);
if (optionResult < 0) {
traceEmbeddedMpv(
session,
"rejected session option " + option.first + ": " +
mpv_error_string(optionResult)
);
session->snapshot.rejectedOptionKeys.push_back(option.first);
}
}
mpv_request_log_messages(session->handle, "warn");
const int initializeResult = mpv_initialize(session->handle);
@@ -1920,6 +1961,7 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.streamUrl = streamUrl;
session->snapshot.error.clear();
session->snapshot.engineError = false;
session->snapshot.status = SessionStatus::Loading;
session->snapshot.recordingActive = false;
session->snapshot.recordingTargetPath.clear();
@@ -2000,9 +2042,13 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
{
std::lock_guard<std::mutex> lock(session->mutex);
session->paused = paused != 0;
// Optimistic only while playing/paused: a load in flight keeps
// `loading` until MPV_EVENT_FILE_LOADED, otherwise a reconnect's
// unpause right after loadfile would report the replacement stream
// as playing before it opened (and clear the attempt indicator).
if (session->loadedPath &&
session->snapshot.status != SessionStatus::Ended &&
session->snapshot.status != SessionStatus::Error) {
(session->snapshot.status == SessionStatus::Playing ||
session->snapshot.status == SessionStatus::Paused)) {
session->snapshot.status = session->paused
? SessionStatus::Paused
: SessionStatus::Playing;
@@ -2503,6 +2549,20 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
if (!snapshot.error.empty()) {
result.Set("error", Napi::String::New(env, snapshot.error));
if (snapshot.engineError) {
result.Set("errorOrigin", Napi::String::New(env, "engine"));
}
}
if (!snapshot.rejectedOptionKeys.empty()) {
Napi::Array keys =
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
keys.Set(
static_cast<uint32_t>(i),
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
);
}
result.Set("rejectedOptionKeys", keys);
}
return result;
@@ -0,0 +1,58 @@
#pragma once
#include <napi.h>
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace iptvnator {
/** One libmpv option name/value pair handed to a session at creation. */
using EmbeddedMpvExtraOption = std::pair<std::string, std::string>;
/**
* Reads the optional `key=value` string array `createSession()` receives at
* `index`. The Electron side already validated and ordered the lines
* (network defaults first, then the user's options minus the keys the embed
* depends on — see `resolveEmbeddedMpvSessionOptionArguments`), so this only
* splits on the first `=` and skips anything that is not a non-empty pair.
* No libmpv call lives here: each engine applies the pairs itself, because
* on Linux the libmpv symbols are macros over dynamically resolved pointers.
*/
inline std::vector<EmbeddedMpvExtraOption> readEmbeddedMpvExtraOptions(
const Napi::CallbackInfo& info,
size_t index
)
{
std::vector<EmbeddedMpvExtraOption> options;
if (info.Length() <= index || !info[index].IsArray()) {
return options;
}
const auto entries = info[index].As<Napi::Array>();
for (uint32_t i = 0; i < entries.Length(); ++i) {
const Napi::Value entry = entries.Get(i);
if (!entry.IsString()) {
continue;
}
const std::string line = entry.As<Napi::String>().Utf8Value();
const auto separator = line.find('=');
if (
separator == std::string::npos ||
separator == 0 ||
separator + 1 >= line.size()
) {
continue;
}
options.emplace_back(
line.substr(0, separator),
line.substr(separator + 1)
);
}
return options;
}
} // namespace iptvnator
@@ -5,6 +5,9 @@
#include <napi.h>
#include <mpv/client.h>
#include <string>
#include "embedded_mpv_extra_options.h"
#ifdef IPTVNATOR_DYNAMIC_LIBMPV
#ifndef IPTVNATOR_MPV_SELECTANY
@@ -116,6 +119,8 @@ struct SessionSnapshot {
double volumePercent = 100.0;
std::string streamUrl;
std::string error;
/** Keys of session options libmpv refused at creation (names only). */
std::vector<std::string> rejectedOptionKeys;
std::vector<AudioTrack> audioTracks;
int64_t selectedAudioTrackId = -1;
std::vector<AudioTrack> subtitleTracks;
@@ -142,6 +147,8 @@ struct Session {
std::string pendingRecordingStartedAt;
std::string pendingRecordingStopStartedAt;
uint64_t pendingPlaybackLoadRequestId = 0;
/** Session options from Settings (network defaults + user lines). */
std::vector<iptvnator::EmbeddedMpvExtraOption> extraOptions;
#ifdef __linux__
pid_t mpvProcessId = -1;
std::string mpvIpcSocketPath;
@@ -607,6 +614,12 @@ std::vector<std::string> buildLinuxMpvArguments(
arguments.push_back("--really-quiet");
}
// Session options come before the per-playback ones below, so a
// playlist's own user-agent/headers still win over a global line.
for (const auto& option : session->extraOptions) {
arguments.push_back("--" + option.first + "=" + option.second);
}
appendLinuxMpvOption(arguments, "force-media-title", title);
appendLinuxMpvOption(arguments, "user-agent", userAgent);
appendLinuxMpvOption(arguments, "referrer", referer);
@@ -983,6 +996,7 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
const auto duration = queryLinuxMpvNumber(socketPath, "duration");
const auto volume = queryLinuxMpvNumber(socketPath, "volume");
const auto paused = queryLinuxMpvBoolean(socketPath, "pause");
const auto eofReached = queryLinuxMpvBoolean(socketPath, "eof-reached");
const auto path = queryLinuxMpvString(socketPath, "path");
const auto trackCount =
queryLinuxMpvInteger(socketPath, "track-list/count");
@@ -1018,10 +1032,24 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
std::max(0.0, std::min(100.0, *volume));
}
if (paused) {
session->snapshot.status =
*paused ? SessionStatus::Paused : SessionStatus::Playing;
if (*paused) {
session->snapshot.status = SessionStatus::Paused;
} else if (
position ||
session->snapshot.status != SessionStatus::Loading
) {
// `time-pos` only answers once the media is decoding; until then
// an unpaused freshly spawned process is still loading.
session->snapshot.status = SessionStatus::Playing;
}
session->snapshot.error.clear();
}
if (eofReached && *eofReached) {
// keep-open=yes parks mpv paused at the end of the stream. Report
// that as `ended` like the in-process engines do, so the reconnect
// policy can tell a finished live stream from a user pause.
session->snapshot.status = SessionStatus::Ended;
}
if (path && !path->empty()) {
session->snapshot.streamUrl = *path;
}
@@ -1149,7 +1177,10 @@ void loadLinuxProcessPlayback(
session->snapshot.durationSeconds = -1.0;
session->snapshot.streamUrl = streamUrl;
session->snapshot.error.clear();
session->snapshot.status = SessionStatus::Playing;
// A spawned process has not opened the URL yet: stay `loading` until
// the poller sees a decoded position, so a URL that never plays is
// never mistaken for one that did (the reconnect policy keys on it).
session->snapshot.status = SessionStatus::Loading;
session->snapshot.audioTracks.clear();
session->snapshot.selectedAudioTrackId = -1;
session->snapshot.subtitleTracks.clear();
@@ -1654,6 +1685,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
throw Napi::Error::New(env, NativeVideoHost::lastError());
}
traceMpvCommon("native video host created");
session->extraOptions = iptvnator::readEmbeddedMpvExtraOptions(info, 4);
#ifdef __linux__
session->host.setBounds(bounds);
@@ -1705,6 +1737,24 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
session->snapshot.volumePercent
);
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
// Session options from Settings (network defaults first, then the
// user's lines). Applied last so a user value overrides a built-in one,
// and before mpv_initialize because most of them are init-only.
for (const auto& option : session->extraOptions) {
const int optionResult = mpv_set_option_string(
session->handle,
option.first.c_str(),
option.second.c_str()
);
if (optionResult < 0) {
traceMpvCommon(
"rejected session option " + option.first + ": " +
mpv_error_string(optionResult)
);
session->snapshot.rejectedOptionKeys.push_back(option.first);
}
}
mpv_request_log_messages(session->handle, "warn");
traceMpvCommon("initializing libmpv");
@@ -1936,8 +1986,16 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
{
std::lock_guard<std::mutex> lock(session->mutex);
socketPath = session->mpvIpcSocketPath;
session->snapshot.status =
paused ? SessionStatus::Paused : SessionStatus::Playing;
// Optimistic only while playing/paused: a load in flight or a
// finished/failed stream keeps its status until the poller says
// otherwise, like the in-process engines' pause handling.
if (
session->snapshot.status == SessionStatus::Playing ||
session->snapshot.status == SessionStatus::Paused
) {
session->snapshot.status =
paused ? SessionStatus::Paused : SessionStatus::Playing;
}
}
if (!socketPath.empty()) {
sendLinuxMpvCommand(
@@ -2326,6 +2384,17 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
if (!snapshot.error.empty()) {
result.Set("error", Napi::String::New(env, snapshot.error));
}
if (!snapshot.rejectedOptionKeys.empty()) {
Napi::Array keys =
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
keys.Set(
static_cast<uint32_t>(i),
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
);
}
result.Set("rejectedOptionKeys", keys);
}
return result;
}
@@ -5,17 +5,26 @@ jest.mock('electron', () => ({
}));
const mockEmbeddedMpvService = {
createSession: jest.fn(),
getSupport: jest.fn(),
setPaused: jest.fn(),
};
const mockSessionOptions = {
extraOptions: ['network-timeout=10', 'hwdec=no'],
autoReconnect: false,
};
jest.mock('../services/embedded-mpv-native.service', () => ({
EmbeddedMpvNativeService: class {},
embeddedMpvNativeService: mockEmbeddedMpvService,
}));
jest.mock('../services/embedded-mpv-session-options', () => ({
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
}));
import { ipcMain } from 'electron';
import {
EMBEDDED_MPV_CREATE_SESSION,
EMBEDDED_MPV_SET_PAUSED,
EMBEDDED_MPV_SUPPORT,
} from '@iptvnator/shared/interfaces';
@@ -41,10 +50,29 @@ function getIpcMainHandler(
describe('EmbeddedMpvEvents IPC handlers', () => {
beforeEach(() => {
mockEmbeddedMpvService.createSession.mockReset();
mockEmbeddedMpvService.getSupport.mockReset();
mockEmbeddedMpvService.setPaused.mockReset();
});
it('creates a session with the options read from the settings mirror', async () => {
const session = { id: 'session-1', status: 'idle' };
mockEmbeddedMpvService.createSession.mockReturnValue(session);
const bounds = { x: 0, y: 0, width: 640, height: 360 };
const handler = getIpcMainHandler(EMBEDDED_MPV_CREATE_SESSION);
await expect(handler({}, bounds, 'Title', 0.5)).resolves.toEqual(
session
);
expect(mockEmbeddedMpvService.createSession).toHaveBeenCalledWith(
bounds,
'Title',
0.5,
mockSessionOptions
);
});
it('forwards arguments to the native service and returns its result', async () => {
const session = { id: 'session-1', status: 'paused' };
mockEmbeddedMpvService.setPaused.mockReturnValue(session);
@@ -31,6 +31,7 @@ import {
EmbeddedMpvNativeService,
embeddedMpvNativeService,
} from '../services/embedded-mpv-native.service';
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
export default class EmbeddedMpvEvents {
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
@@ -56,10 +57,7 @@ function handleEmbeddedMpv<Args extends unknown[]>(
try {
return await handler(...(args as Args));
} catch (error) {
console.error(
`[Embedded MPV] ${channel} handler failed:`,
error
);
console.error(`[Embedded MPV] ${channel} handler failed:`, error);
throw error;
}
});
@@ -72,7 +70,14 @@ handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
handleEmbeddedMpv(
EMBEDDED_MPV_CREATE_SESSION,
(bounds: EmbeddedMpvBounds, title?: string, initialVolume?: number) =>
getService().createSession(bounds, title, initialVolume)
// Settings are read here, per session, so the service itself never
// touches the config store (constructed at module load).
getService().createSession(
bounds,
title,
initialVolume,
readEmbeddedMpvSessionOptions()
)
);
handleEmbeddedMpv(
@@ -137,9 +142,8 @@ handleEmbeddedMpv(EMBEDDED_MPV_SELECT_SUBTITLE_FILE, () =>
getService().selectSubtitleFile()
);
handleEmbeddedMpv(
EMBEDDED_MPV_SET_SPEED,
(sessionId: string, speed: number) => getService().setSpeed(sessionId, speed)
handleEmbeddedMpv(EMBEDDED_MPV_SET_SPEED, (sessionId: string, speed: number) =>
getService().setSpeed(sessionId, speed)
);
handleEmbeddedMpv(
@@ -22,6 +22,8 @@ type SettingsUpdateHandler = (
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
const STORE_KEYS = {
EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT',
EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS',
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
@@ -47,6 +49,8 @@ jest.mock('electron', () => ({
}));
jest.mock('../services/store.service', () => ({
EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT,
EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
@@ -140,6 +144,31 @@ describe('SETTINGS_UPDATE', () => {
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it('mirrors the embedded MPV extra options in canonical form', () => {
settingsUpdateHandler(
{},
{ embeddedMpvExtraOptions: ' --hwdec = auto \r\n\nvo=null\n' }
);
expect(mockStoreSet.mock.calls).toEqual([
[STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS, 'hwdec=auto\nvo=null'],
]);
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it('mirrors the embedded MPV auto-reconnect toggle as a boolean', () => {
settingsUpdateHandler({}, { embeddedMpvAutoReconnect: false });
expect(mockStoreSet.mock.calls).toEqual([
[STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, false],
]);
mockStoreSet.mockClear();
settingsUpdateHandler({}, { embeddedMpvAutoReconnect: true });
expect(mockStoreSet.mock.calls).toEqual([
[STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, true],
]);
});
it('mirrors the startup window mode into the main-process store', () => {
settingsUpdateHandler({}, { startupWindowMode: 'fullscreen' });
@@ -218,6 +247,26 @@ describe('SETTINGS_UPDATE', () => {
expect(output).toContain('enabled');
});
it('does not print credentials embedded in embedded MPV extra options', () => {
const headerSecret = 'embedded-mpv-header-secret';
settingsUpdateHandler(
{},
{
language: 'de',
embeddedMpvExtraOptions: `cache-secs=5\nhttp-header-fields=X-Playback-Key: ${headerSecret}`,
}
);
const output = JSON.stringify(consoleLogSpy.mock.calls);
expect(output).not.toContain(headerSecret);
expect(output).toContain('language');
expect(mockStoreSet).toHaveBeenCalledWith(
STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
`cache-secs=5\nhttp-header-fields=X-Playback-Key: ${headerSecret}`
);
});
it('does not print credentials embedded in external player arguments', () => {
const authorizationSecret = 'player-authorization-secret';
const cookieSecret = 'player-cookie-secret';
@@ -1,10 +1,13 @@
import { ipcMain } from 'electron';
import {
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeStartupWindowMode,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import {
EMBEDDED_MPV_AUTO_RECONNECT,
EMBEDDED_MPV_EXTRA_OPTIONS,
EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE,
@@ -50,6 +53,18 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
if (arg.embeddedMpvFrameCopy !== undefined) {
store.set(EMBEDDED_MPV_FRAME_COPY, !!arg.embeddedMpvFrameCopy);
}
if (arg.embeddedMpvExtraOptions !== undefined) {
store.set(
EMBEDDED_MPV_EXTRA_OPTIONS,
normalizeEmbeddedMpvExtraOptions(arg.embeddedMpvExtraOptions)
);
}
if (arg.embeddedMpvAutoReconnect !== undefined) {
store.set(
EMBEDDED_MPV_AUTO_RECONNECT,
arg.embeddedMpvAutoReconnect !== false
);
}
// Read by initMainWindow before any renderer exists, so it applies on
// the next launch. Normalized here so a junk value never reaches the
@@ -25,6 +25,23 @@ export function encodeProtocolValue(value: string): string {
.replace(/\r/g, '%0D');
}
/**
* The first line the adapter writes to a helper started with
* `--mpv-options-stdin`: the session's libmpv options in application order.
* They travel over stdin rather than argv so a credential-bearing option is
* never visible to `ps`; zero-padded keys keep the order through the
* helper's unordered field map.
*/
export function buildMpvOptionsPreamble(options: readonly string[]): string {
return [
'mpv-options',
...options.map(
(option, index) =>
`o${String(index).padStart(3, '0')}=${encodeProtocolValue(option)}`
),
].join('\t');
}
export function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot {
return {
status: 'loading',
@@ -53,7 +70,9 @@ export function buildLoadPlaybackCommand(
);
}
if (playback.userAgent) {
fields.push(`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`);
fields.push(
`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`
);
}
if (playback.referer) {
fields.push(`opt.referrer=${encodeProtocolValue(playback.referer)}`);
@@ -128,6 +147,7 @@ export function applyHelperEvent(
session.snapshot.error = String(
event.error ?? 'Embedded MPV helper failed.'
);
session.snapshot.errorOrigin = 'engine';
break;
case 'log':
if (event.level === 'error' || event.level === 'fatal') {
@@ -136,6 +156,16 @@ export function applyHelperEvent(
event.prefix ?? ''
)}] ${String(event.text ?? '').trim()}`
);
} else if (event.level === 'warn' && event.prefix === 'iptvnator') {
// The helper's own warnings (a session option libmpv
// rejected) must reach the main-process log like the
// native-view trace does; mpv's warn-level chatter stays
// filtered.
console.warn(
`[embedded-mpv-fc][${session.id}] ${String(
event.text ?? ''
).trim()}`
);
}
break;
default:
@@ -61,6 +61,9 @@ export function resolveFrameCopyHelperSpawn(
...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY
? ['--audio-delay', process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY]
: []),
// Session options follow as the first stdin line (see
// buildMpvOptionsPreamble), never as arguments `ps` could read.
'--mpv-options-stdin',
];
if (process.platform !== 'linux') {
@@ -81,7 +84,9 @@ export function resolveFrameCopyHelperSpawn(
fileSystem: helperLaunchFileSystem,
});
if (!launch.usable) {
throw new Error('The connected Snap graphics provider is not available.');
throw new Error(
'The connected Snap graphics provider is not available.'
);
}
return {
@@ -1,6 +1,17 @@
import type { Stats } from 'fs';
import path from 'path';
// The adapter reaches `electron` through the platform util for packaged
// paths only; mocking it keeps this suite independent of the Electron binary
// being present (a CI runner can restore node_modules without it).
jest.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/mock/app',
getPath: () => '/mock/user-data',
},
}));
const spawnMock = jest.fn();
jest.mock('child_process', () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
@@ -98,7 +109,8 @@ describe('EmbeddedMpvFrameCopyAdapter Linux loader environment', () => {
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
@@ -1,11 +1,23 @@
import path from 'path';
// The adapter reaches `electron` through the platform util for packaged
// paths only; mocking it keeps this suite independent of the Electron binary
// being present (a CI runner can restore node_modules without it).
jest.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/mock/app',
getPath: () => '/mock/user-data',
},
}));
const spawnMock = jest.fn();
jest.mock('child_process', () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
}));
import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import { EmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
import {
createFrameCopyAdapter,
createFrameCopySession,
@@ -48,7 +60,9 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
'720',
'--volume',
'0.8',
'--mpv-options-stdin',
]);
expect(child.stdin.written[0]).toBe('mpv-options\n');
});
it('caches helper snapshot events for getSessionSnapshot', () => {
@@ -93,6 +107,116 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
);
});
it('sends the session options as the first stdin line, never on argv', () => {
adapter.createSession(
Buffer.alloc(0),
{ x: 0, y: 0, width: 640, height: 360 },
'Title',
0.8,
['network-timeout=10', 'http-header-fields=X-Key: s%cr\tet']
);
const [, args] = spawnMock.mock.calls[0];
expect(args).toContain('--mpv-options-stdin');
expect(args.join(' ')).not.toContain('network-timeout');
expect(args.join(' ')).not.toContain('X-Key');
expect(child.stdin.written[0]).toBe(
'mpv-options\to000=network-timeout=10\to001=http-header-fields=X-Key: s%25cr%09et\n'
);
});
it('flips the cached snapshot to loading as soon as a load is sent', () => {
const sessionId = createSession();
child.emitStdout({
event: 'snapshot',
status: 'error',
error: 'connection reset',
positionSeconds: 0,
durationSeconds: null,
volume: 0.8,
streamUrl: 'http://stream',
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
});
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
adapter.loadPlayback(sessionId, {
streamUrl: 'http://stream',
title: 'Live',
});
const snapshot = adapter.getSessionSnapshot(sessionId);
expect(snapshot?.status).toBe('loading');
expect(snapshot?.error).toBeUndefined();
expect(snapshot?.streamUrl).toBe('http://stream');
});
it('refuses a load once the helper process has exited', () => {
const sessionId = createSession();
child.exitCode = 1;
child.emit('exit', 1, null);
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
expect(() =>
adapter.loadPlayback(sessionId, {
streamUrl: 'http://stream',
title: 'Live',
})
).toThrow(EmbeddedMpvSessionGoneError);
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
expect(
child.stdin.written.some((line) => line.startsWith('load\t'))
).toBe(false);
});
it('refuses a load once the helper died from a signal', () => {
const sessionId = createSession();
child.signalCode = 'SIGKILL';
child.emit('exit', null, 'SIGKILL');
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
expect(() =>
adapter.loadPlayback(sessionId, {
streamUrl: 'http://stream',
title: 'Live',
})
).toThrow(EmbeddedMpvSessionGoneError);
expect(adapter.getSessionSnapshot(sessionId)?.status).toBe('error');
});
it('surfaces the helper warning for a session option libmpv rejected', () => {
const sessionId = createSession();
const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
child.emitStdout({
event: 'log',
level: 'warn',
prefix: 'iptvnator',
text: 'rejected session option nonexistent-option: option not found',
});
child.emitStdout({
event: 'log',
level: 'warn',
prefix: 'ffmpeg/demuxer',
text: 'mpegts: PES packet size mismatch',
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain(sessionId);
expect(warn.mock.calls[0][0]).toContain(
'rejected session option nonexistent-option'
);
} finally {
warn.mockRestore();
}
});
it('encodes loadfile options with percent-escaping', () => {
const sessionId = createSession();
adapter.loadPlayback(sessionId, {
@@ -65,6 +65,7 @@ export function fakeStat(
export class FakeHelperProcess extends EventEmitter {
exitCode: number | null = null;
signalCode: string | null = null;
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
readonly stdin = {
@@ -9,11 +9,13 @@ import {
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util';
import {
applyHelperEvent,
buildMpvOptionsPreamble,
buildLoadPlaybackCommand,
createInitialSnapshot,
encodeProtocolValue,
} from './embedded-mpv-frame-copy-protocol';
import { resolveFrameCopyHelperSpawn } from './embedded-mpv-frame-copy-spawn';
import { EmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
import type {
EmbeddedMpvFrameCopyRuntimeMode,
LinuxFrameCopyHelperLaunchFileSystem,
@@ -83,7 +85,8 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
_windowHandle: Buffer,
bounds: EmbeddedMpvBounds,
_title?: string,
initialVolume?: number
initialVolume?: number,
extraOptions?: string[]
): string {
const helperPath = this.options.resolveHelperPath();
if (!helperPath) {
@@ -122,6 +125,13 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
killTimers: [],
};
this.sessions.set(sessionId, session);
// The helper blocks on this line before initialising libmpv; it is
// the private channel for options that may carry credentials.
if (child.stdin.writable) {
child.stdin.write(
`${buildMpvOptionsPreamble(extraOptions ?? [])}\n`
);
}
child.stdout.on('data', (chunk: Buffer) =>
this.consumeStdout(session, chunk)
@@ -134,6 +144,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
child.on('error', (error) => {
session.snapshot.status = 'error';
session.snapshot.error = `Helper process failed: ${error.message}`;
session.snapshot.errorOrigin = 'engine';
});
child.on('exit', (code, signal) => {
console.log(
@@ -151,12 +162,41 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
session.snapshot.error = `The embedded MPV helper exited unexpectedly (${
signal ?? code ?? 'unknown'
}).`;
session.snapshot.errorOrigin = 'engine';
});
return sessionId;
}
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
const session = this.sessions.get(sessionId);
if (session && !session.disposed) {
if (
session.child.exitCode !== null ||
session.child.signalCode !== null ||
!session.child.stdin.writable
) {
// A dead helper (exit code, signal death, or a closed stdin)
// cannot take a load: `send()` would drop the command
// silently and a reconnect would wait forever on a
// `loading` that nothing can ever advance.
throw new EmbeddedMpvSessionGoneError(
sessionId,
'the frame-copy helper process has exited'
);
}
// The native addons flip to `loading` synchronously; mirror that
// so the reconnect coordinator always observes loading → loss
// for a failed attempt, even when the helper folds START_FILE
// and the END_FILE error into one snapshot.
const snapshot = {
...session.snapshot,
status: 'loading' as const,
};
delete snapshot.error;
delete snapshot.errorOrigin;
session.snapshot = snapshot;
}
this.send(sessionId, buildLoadPlaybackCommand(playback));
}
@@ -193,10 +233,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
}
addSubtitle(sessionId: string, filePath: string): void {
this.send(
sessionId,
`sub-add\tpath=${encodeProtocolValue(filePath)}`
);
this.send(sessionId, `sub-add\tpath=${encodeProtocolValue(filePath)}`);
}
setSubtitleDelay(sessionId: string, seconds: number): void {
@@ -277,7 +314,11 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
`Embedded MPV frame-copy session "${sessionId}" was not found.`
);
}
if (session.child.exitCode !== null || !session.child.stdin.writable) {
if (
session.child.exitCode !== null ||
session.child.signalCode !== null ||
!session.child.stdin.writable
) {
return;
}
session.child.stdin.write(`${line}\n`);
@@ -0,0 +1,711 @@
/**
* Reconnect and session-option behaviour of EmbeddedMpvNativeService. Kept
* apart from embedded-mpv-native.service.spec.ts, which is close to the
* 1200-line spec limit; the mocks mirror that file.
*/
import type {
EmbeddedMpvBounds,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
const mockSpawnSync = jest.fn();
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
const mockGetFrameCopyRuntimeAvailability = jest.fn();
jest.mock('child_process', () => ({
spawnSync: mockSpawnSync,
}));
const recordingTrackerMock = {
onRecordingStarted: jest.fn(),
onRecordingStopped: jest.fn(),
onRecordingInterrupted: jest.fn(),
observeSnapshot: jest.fn(),
};
jest.mock('./embedded-mpv-recording-tracker', () => ({
embeddedMpvRecordingTracker: recordingTrackerMock,
}));
jest.mock('./embedded-mpv-frame-copy-platform.util', () => {
const actual = jest.requireActual(
'./embedded-mpv-frame-copy-platform.util'
);
return {
...actual,
getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability,
isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable,
};
});
const powerSaveBlockerMock = {
start: jest.fn<number, [string]>(() => 1),
stop: jest.fn<void, [number]>(),
isStarted: jest.fn<boolean, [number]>(() => true),
};
let userDataDir = '';
const appMock = {
isPackaged: true,
getAppPath: () => '/mock/app.asar',
getPath: () => userDataDir,
commandLine: { getSwitchValue: jest.fn<string, [string]>(() => '') },
};
jest.mock('electron', () => ({
app: appMock,
powerSaveBlocker: powerSaveBlockerMock,
screen: { getDisplayMatching: jest.fn(() => ({ scaleFactor: 1 })) },
}));
const mainWindowSendMock = jest.fn();
const mainWindowMock = {
isDestroyed: () => false,
getNativeWindowHandle: () => Buffer.alloc(8),
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
webContents: {
send: mainWindowSendMock,
on: jest.fn(),
getZoomFactor: () => 1,
},
};
jest.mock('../app', () => ({
__esModule: true,
default: {
get mainWindow() {
return mainWindowMock;
},
},
}));
interface MockSnapshot {
status: EmbeddedMpvSessionStatus;
positionSeconds: number;
durationSeconds: number | null;
volume: number;
streamUrl: string;
recording?: { active: boolean; targetPath?: string };
error?: string;
errorOrigin?: 'playback' | 'engine';
rejectedOptionKeys?: string[];
}
const BOUNDS: EmbeddedMpvBounds = { x: 0, y: 0, width: 100, height: 100 };
const LIVE: ResolvedPortalPlayback = {
streamUrl: 'http://host/live.ts',
title: 'Live channel',
isLive: true,
};
const VOD: ResolvedPortalPlayback = {
streamUrl: 'http://host/movie.mkv',
title: 'Movie',
isLive: false,
startTime: 30,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
};
describe('EmbeddedMpvNativeService reconnect', () => {
let EmbeddedMpvNativeService: typeof EmbeddedMpvNativeServiceType;
let service: EmbeddedMpvNativeServiceType;
let addon: {
isSupported: jest.Mock<boolean, []>;
createSession: jest.Mock<string, unknown[]>;
loadPlayback: jest.Mock<void, [string, ResolvedPortalPlayback]>;
getSessionSnapshot: jest.Mock<MockSnapshot | null, [string]>;
disposeSession: jest.Mock<void, [string]>;
setBounds: jest.Mock;
setPaused: jest.Mock;
seek: jest.Mock;
setVolume: jest.Mock;
setAudioTrack: jest.Mock;
startRecording: jest.Mock;
stopRecording: jest.Mock;
setSubtitleTrack: jest.Mock;
addSubtitle: jest.Mock;
};
let status: EmbeddedMpvSessionStatus;
let positionSeconds: number;
let recordingActive: boolean;
let errorOrigin: 'playback' | 'engine' | undefined;
let rejectedOptionKeys: string[] | undefined;
let originalPlatform: NodeJS.Platform;
let originalArch: string;
let originalExperiment: string | undefined;
beforeEach(async () => {
jest.resetModules();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-09-03T10:00:00Z'));
mainWindowSendMock.mockReset();
mockSpawnSync.mockReset();
mockSpawnSync.mockReturnValue({ status: 0 });
mockIsFrameCopyRuntimeUsable.mockReset();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
mockGetFrameCopyRuntimeAvailability.mockReset();
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: false,
reason: 'helper-probe-failed',
});
originalPlatform = process.platform;
originalArch = process.arch;
Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'arm64' });
originalExperiment =
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = '1';
userDataDir = mkdtempSync(
path.join(tmpdir(), 'iptvnator-mpv-options-')
);
({ EmbeddedMpvNativeService } =
await import('./embedded-mpv-native.service'));
service = new EmbeddedMpvNativeService();
status = 'idle';
positionSeconds = 0;
recordingActive = false;
errorOrigin = undefined;
rejectedOptionKeys = undefined;
recordingTrackerMock.onRecordingStarted.mockReset();
recordingTrackerMock.onRecordingStopped.mockReset();
recordingTrackerMock.onRecordingInterrupted.mockReset();
recordingTrackerMock.observeSnapshot.mockReset();
addon = {
isSupported: jest.fn<boolean, []>(() => true),
createSession: jest.fn<string, unknown[]>(() => 'session-1'),
loadPlayback: jest.fn(),
getSessionSnapshot: jest.fn<MockSnapshot | null, [string]>(() => ({
status,
positionSeconds,
durationSeconds: null,
volume: 1,
streamUrl: LIVE.streamUrl,
recording: { active: recordingActive },
...(errorOrigin ? { errorOrigin } : {}),
...(rejectedOptionKeys ? { rejectedOptionKeys } : {}),
})),
disposeSession: jest.fn(),
setBounds: jest.fn(),
setPaused: jest.fn(),
seek: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
startRecording: jest.fn(),
stopRecording: jest.fn(),
setSubtitleTrack: jest.fn(),
addSubtitle: jest.fn(),
};
(service as unknown as { addon: typeof addon }).addon = addon;
});
afterEach(() => {
service.shutdown();
jest.useRealTimers();
rmSync(userDataDir, { recursive: true, force: true });
Object.defineProperty(process, 'platform', { value: originalPlatform });
Object.defineProperty(process, 'arch', { value: originalArch });
if (originalExperiment === undefined) {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
} else {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT =
originalExperiment;
}
});
/** Lets the 500 ms poll observe the addon status once. */
const poll = (next: EmbeddedMpvSessionStatus) => {
status = next;
jest.advanceTimersByTime(500);
};
const lastUpdate = (): EmbeddedMpvSession | undefined =>
mainWindowSendMock.mock.calls.at(-1)?.[1] as
EmbeddedMpvSession | undefined;
const startPlayingLive = (autoReconnect = true) => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: ['network-timeout=10'],
autoReconnect,
});
service.loadPlayback('session-1', LIVE);
poll('loading');
poll('playing');
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
};
it('hands the session options to the addon', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: ['network-timeout=10', 'hwdec=no'],
autoReconnect: true,
});
expect(addon.createSession).toHaveBeenCalledWith(
expect.any(Buffer),
expect.objectContaining({ width: 100, height: 100 }),
'Title',
0.5,
['network-timeout=10', 'hwdec=no']
);
});
it('hands Linux native-view its options through a user-only include file', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [
'network-timeout=10',
'http-header-fields=X-Key: secret',
],
autoReconnect: true,
});
const addonOptions = addon.createSession.mock.calls[0][4] as string[];
expect(addonOptions).toHaveLength(1);
expect(addonOptions[0]).toMatch(/^include=.*session-options-.*\.conf$/);
const file = addonOptions[0].slice('include='.length);
expect(path.dirname(file)).toBe(
path.join(userDataDir, 'embedded-mpv', `options-${process.pid}`)
);
expect(readFileSync(file, 'utf8')).toBe(
'network-timeout=10\nhttp-header-fields=X-Key: secret\n'
);
expect(statSync(file).mode & 0o777).toBe(0o600);
service.disposeSession('session-1');
expect(existsSync(file)).toBe(false);
});
it('sweeps only option directories whose owning process is gone', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const root = path.join(userDataDir, 'embedded-mpv');
const staleDir = path.join(root, 'options-2147483646');
const liveDir = path.join(root, `options-${process.ppid}`);
mkdirSync(staleDir, { recursive: true });
mkdirSync(liveDir, { recursive: true });
writeFileSync(path.join(staleDir, 'session-options-old.conf'), 'x=1\n');
writeFileSync(path.join(liveDir, 'session-options-live.conf'), 'y=1\n');
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: ['network-timeout=10'],
autoReconnect: true,
});
expect(existsSync(staleDir)).toBe(false);
expect(
existsSync(path.join(liveDir, 'session-options-live.conf'))
).toBe(true);
const ownDir = path.join(root, `options-${process.pid}`);
expect(existsSync(ownDir)).toBe(true);
service.shutdown();
expect(existsSync(ownDir)).toBe(false);
});
it('restarts a recording that was running when the stream dropped', () => {
startPlayingLive();
service.startRecording('session-1', { title: 'Live channel' });
expect(addon.startRecording).toHaveBeenCalledTimes(1);
const firstTarget = addon.startRecording.mock.calls[0][1] as string;
recordingActive = true;
poll('playing');
recordingActive = false;
poll('error');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
expect(
recordingTrackerMock.onRecordingInterrupted
).not.toHaveBeenCalled();
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(
recordingTrackerMock.onRecordingInterrupted
).toHaveBeenCalledWith('session-1');
poll('playing');
// The restart is deferred with a 0 ms timer scheduled from inside
// the poll tick, which the fake clock parks one millisecond out.
jest.advanceTimersByTime(1);
expect(addon.startRecording).toHaveBeenCalledTimes(2);
const secondTarget = addon.startRecording.mock.calls[1][1] as string;
expect(secondTarget).not.toBe(firstTarget);
expect(path.dirname(secondTarget)).toBe(path.dirname(firstTarget));
expect(recordingTrackerMock.onRecordingStarted).toHaveBeenCalledTimes(
2
);
});
it('sees a recording that became active only in the loss snapshot', () => {
startPlayingLive();
// Recording starts asynchronously: the immediate refresh after
// startRecording() still reports it inactive.
service.startRecording('session-1', { title: 'Live channel' });
expect(lastUpdate()?.recording?.active).toBeFalsy();
// The next poll carries the activated recording and the drop together.
recordingActive = true;
poll('error');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
status = 'loading';
recordingActive = false;
jest.advanceTimersByTime(2_000);
expect(
recordingTrackerMock.onRecordingInterrupted
).toHaveBeenCalledWith('session-1');
poll('playing');
jest.advanceTimersByTime(1);
expect(addon.startRecording).toHaveBeenCalledTimes(2);
});
it('captures a recording acknowledged one poll after the drop', () => {
startPlayingLive();
service.startRecording('session-1', { title: 'Live channel' });
// First loss poll: the drop arrives before the recording is
// acknowledged; the next poll still reports the error, now with
// the recording active.
poll('error');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
recordingActive = true;
poll('error');
status = 'loading';
recordingActive = false;
jest.advanceTimersByTime(2_000);
expect(
recordingTrackerMock.onRecordingInterrupted
).toHaveBeenCalledWith('session-1');
poll('playing');
jest.advanceTimersByTime(1);
expect(addon.startRecording).toHaveBeenCalledTimes(2);
});
it('warns once, by key only, when libmpv rejected a session option', () => {
const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
rejectedOptionKeys = ['http-header-fields', 'no-such-option'];
startPlayingLive();
poll('playing');
poll('playing');
const rejectedWarnings = warn.mock.calls.filter((call) =>
String(call[0]).includes('rejected extra option')
);
expect(rejectedWarnings).toHaveLength(1);
expect(rejectedWarnings[0][0]).toContain(
'http-header-fields, no-such-option'
);
expect(rejectedWarnings[0][0]).toContain('session-1');
} finally {
warn.mockRestore();
}
});
describe('external subtitles', () => {
let subtitleFile: string;
beforeEach(() => {
subtitleFile = path.join(userDataDir, 'movie.srt');
writeFileSync(
subtitleFile,
'1\n00:00:01,000 --> 00:00:02,000\nHi\n'
);
});
function dropAndReload(): void {
poll('error');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('playing');
jest.advanceTimersByTime(1);
}
it('re-adds the external subtitle file once the automatic reload plays', () => {
startPlayingLive();
service.addSubtitle('session-1', subtitleFile);
poll('playing');
dropAndReload();
expect(addon.addSubtitle).toHaveBeenCalledTimes(2);
expect(addon.addSubtitle).toHaveBeenLastCalledWith(
'session-1',
subtitleFile
);
});
it('does not re-select a file the user had switched away from', () => {
startPlayingLive();
service.addSubtitle('session-1', subtitleFile);
service.setSubtitleTrack('session-1', 1);
poll('playing');
dropAndReload();
expect(addon.addSubtitle).toHaveBeenCalledTimes(1);
});
it('forgets the file after a user-driven load', () => {
startPlayingLive();
service.addSubtitle('session-1', subtitleFile);
service.loadPlayback('session-1', LIVE);
poll('playing');
dropAndReload();
expect(addon.addSubtitle).toHaveBeenCalledTimes(1);
});
});
it('files the interruption once even when the first reload attempt fails', () => {
startPlayingLive();
service.startRecording('session-1', { title: 'Live channel' });
recordingActive = true;
poll('playing');
recordingActive = false;
poll('error');
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('error');
jest.advanceTimersByTime(4_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(3);
expect(
recordingTrackerMock.onRecordingInterrupted
).toHaveBeenCalledTimes(1);
});
it('leaves a recording alone when the stream recovers before the reload fires', () => {
startPlayingLive();
service.startRecording('session-1', { title: 'Live channel' });
recordingActive = true;
poll('playing');
poll('error');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
// ffmpeg's own reconnect brings the stream back within the backoff;
// mpv never stopped recording.
poll('playing');
jest.advanceTimersByTime(60_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
expect(addon.startRecording).toHaveBeenCalledTimes(1);
expect(
recordingTrackerMock.onRecordingInterrupted
).not.toHaveBeenCalled();
});
it('does not restart a recording the user had stopped or replaced', () => {
startPlayingLive();
service.startRecording('session-1', { title: 'Live channel' });
recordingActive = true;
poll('playing');
service.stopRecording('session-1');
recordingActive = false;
poll('playing');
poll('error');
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('playing');
// The restart is deferred with a 0 ms timer scheduled from inside
// the poll tick, which the fake clock parks one millisecond out.
jest.advanceTimersByTime(1);
expect(addon.startRecording).toHaveBeenCalledTimes(1);
});
it('sends an empty option list when no options were captured', () => {
service.createSession(BOUNDS);
expect(addon.createSession.mock.calls[0][4]).toEqual([]);
});
it('reloads a dropped live stream, tells the renderer, and clears once it plays again', () => {
startPlayingLive();
poll('error');
expect(lastUpdate()?.status).toBe('error');
expect(lastUpdate()?.reconnect).toEqual({
attempt: 1,
maxAttempts: 6,
nextAttemptAt: new Date(Date.now() + 2_000).toISOString(),
});
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', LIVE);
// keep-open leaves mpv paused after EOF; the reload must clear it.
expect(addon.setPaused).toHaveBeenCalledWith('session-1', false);
expect(lastUpdate()?.status).toBe('loading');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
poll('playing');
expect(lastUpdate()?.status).toBe('playing');
expect(lastUpdate()?.reconnect).toBeUndefined();
});
it('reloads a dropped movie from the position it reached', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 640;
poll('playing');
poll('playing');
poll('error');
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', {
...VOD,
startTime: 640,
});
});
it('treats EOF on a live stream as a drop', () => {
startPlayingLive();
poll('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
});
it('leaves an error the engine attributes to itself alone', () => {
startPlayingLive();
errorOrigin = 'engine';
poll('error');
jest.advanceTimersByTime(120_000);
expect(lastUpdate()?.status).toBe('error');
expect(lastUpdate()?.reconnect).toBeUndefined();
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
});
it('stays quiet when the setting is off', () => {
startPlayingLive(false);
poll('error');
jest.advanceTimersByTime(120_000);
expect(lastUpdate()?.reconnect).toBeUndefined();
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
});
it('drops the pending reload when the user loads something else', () => {
startPlayingLive();
poll('error');
const next: ResolvedPortalPlayback = {
...LIVE,
streamUrl: 'http://host/other.ts',
};
status = 'loading';
service.loadPlayback('session-1', next);
jest.advanceTimersByTime(60_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', next);
});
it('drops the pending reload when the user pauses from the error state', () => {
startPlayingLive();
poll('error');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
service.setPaused('session-1', true);
expect(addon.setPaused).toHaveBeenCalledWith('session-1', true);
expect(lastUpdate()?.reconnect).toBeUndefined();
jest.advanceTimersByTime(60_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
});
it('drops the pending reload when the session is disposed', () => {
startPlayingLive();
poll('error');
service.disposeSession('session-1');
jest.advanceTimersByTime(60_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(1);
});
it('still counts the attempt when only the unpause fails', () => {
startPlayingLive();
poll('error');
addon.setPaused.mockImplementationOnce(() => {
throw new Error('socket not ready');
});
const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
poll('playing');
expect(lastUpdate()?.reconnect).toBeUndefined();
} finally {
warn.mockRestore();
}
});
it('keeps the main process alive when a reload attempt throws', () => {
startPlayingLive();
poll('error');
addon.loadPlayback.mockImplementationOnce(() => {
throw new Error('addon exploded');
});
const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
expect(() => jest.advanceTimersByTime(2_000)).not.toThrow();
expect(lastUpdate()?.reconnect?.attempt).toBe(2);
} finally {
warn.mockRestore();
}
});
});
@@ -442,7 +442,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Buffer.alloc(0),
BOUNDS,
'',
1
1,
[]
);
// Dispose while the frame-copy env is still set so teardown
@@ -476,7 +477,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect.any(Buffer),
physicalBounds,
'',
1
1,
[]
);
expect(addon.setBounds).toHaveBeenCalledWith(
's-scaled',
@@ -493,13 +495,18 @@ describe('EmbeddedMpvNativeService power blocker', () => {
addon.createSession.mockReturnValueOnce('s-zoom');
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
service.createSession({ x: 0, y: 0, width: 100, height: 100 }, '', 1);
service.createSession(
{ x: 0, y: 0, width: 100, height: 100 },
'',
1
);
expect(addon.createSession).toHaveBeenCalledWith(
expect.any(Buffer),
{ x: 0, y: 0, width: 125, height: 125 },
'',
1
1,
[]
);
});
@@ -530,7 +537,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Buffer.alloc(0),
BOUNDS,
'',
1
1,
[]
);
expect(frameCopyAddon.setBounds).toHaveBeenCalledWith(
's-fc-bounds',
@@ -30,8 +30,23 @@ import {
clampSubtitleDelay,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
import { isExternalPlayerTraceEnabled, trace } from './debug-trace';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { embeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker';
import {
createEmbeddedMpvReconnectState,
EmbeddedMpvReconnectCoordinator,
EmbeddedMpvReconnectState,
} from './embedded-mpv-reconnect';
import type { EmbeddedMpvSessionOptions } from './embedded-mpv-session-options';
import {
removeSessionOptionsDirectory,
removeSessionOptionsFile,
resolveSessionOptionsDirectory,
resolveSessionOptionsRoot,
sweepSessionOptionsFiles,
writeSessionOptionsFile,
} from './embedded-mpv-session-options-file';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
@@ -61,6 +76,17 @@ export interface NativeEmbeddedMpvSessionSnapshot {
videoHeight?: number;
recording?: EmbeddedMpvRecordingState;
error?: string;
/**
* `engine` when the error is the engine's own failure (a render context
* or a fatal libmpv log) rather than the stream's; absent means the
* stream failed and a reload can help.
*/
errorOrigin?: 'playback' | 'engine';
/**
* Session option keys libmpv refused at creation (macOS/Windows
* native-view). Names only; the service warns once per session.
*/
rejectedOptionKeys?: string[];
}
export interface NativeEmbeddedMpvAddon {
@@ -69,7 +95,9 @@ export interface NativeEmbeddedMpvAddon {
windowHandle: Buffer,
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
initialVolume?: number,
/** `key=value` libmpv options applied after the engine's built-ins. */
extraOptions?: string[]
): string;
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
@@ -99,6 +127,27 @@ interface EmbeddedMpvRuntimeSession {
updatedAt: string;
lastPayloadKey: string;
lastStatus: EmbeddedMpvSessionStatus | null;
reconnect: EmbeddedMpvReconnectState;
/** Linux native-view only: the `--include` file carrying the options. */
optionsFile: string | null;
/** What `startRecording` was last asked for, until an explicit stop. */
lastRecordingStart: EmbeddedMpvRecordingStartOptions | null;
/** Whether the previous refresh reported an active recording. */
lastRecordingActive: boolean;
/** A recording was running when the outage began. */
recordingRunningAtLoss: boolean;
/** The reload replaced the recorded stream; restart the recording once it plays. */
restartRecordingAfterReconnect: boolean;
/** The rejected-options warning was logged for this session. */
rejectedOptionsReported: boolean;
/**
* External subtitle file added through `addSubtitle` and still the
* track that add selected: an explicit track pick or a user-driven load
* hands selection back to the user and clears it.
*/
externalSubtitlePath: string | null;
/** The automatic reload dropped the external subtitle; re-add it once the reload plays. */
restoreSubtitleAfterReconnect: boolean;
}
const EMBEDDED_MPV_FRAME_COPY_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
@@ -118,6 +167,59 @@ export class EmbeddedMpvNativeService {
private readonly loadAddonModule = createRequire(__filename);
private cachedLinuxMpvExecutableReason: string | null | undefined;
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
private sessionOptionsDirectory: string | null = null;
/**
* Reloads a dropped stream (see embedded-mpv-reconnect.ts). `publish`
* runs from a timer, outside the polling try/catch, so it must swallow
* addon failures itself instead of surfacing an uncaughtException.
*/
private readonly reconnect = new EmbeddedMpvReconnectCoordinator({
reload: (sessionId, playback) => {
const addon = this.getAddon();
const session = this.sessions.get(sessionId);
if (session?.recordingRunningAtLoss && session.lastRecordingStart) {
// This reload replaces the recorded stream: the file mpv was
// writing is over (file it as interrupted, not completed),
// and the recording is started again once the reload plays.
// A stream that recovers by itself before this fires keeps
// its recording running and never reaches this branch.
embeddedMpvRecordingTracker.onRecordingInterrupted(sessionId);
session.restartRecordingAfterReconnect = true;
// Consumed: a failed attempt's next reload must not re-arm
// the tracker's flush window and postpone the verdict.
session.recordingRunningAtLoss = false;
}
if (session?.externalSubtitlePath) {
// The engines drop external subtitle tracks with the file
// they were added to (START_FILE clears them); re-add the
// user's file once the reload plays.
session.restoreSubtitleAfterReconnect = true;
}
addon.loadPlayback(sessionId, playback);
// Sessions run with keep-open=yes, so EOF on a live stream leaves
// mpv paused at the end of the old file and a plain loadfile
// inherits that pause: the reload then buffers, reports
// `paused` and never plays. Unpausing is part of the reload.
try {
addon.setPaused(sessionId, false);
} catch (error) {
console.warn(
`[Embedded MPV][reconnect] session ${sessionId}: could not clear the keep-open pause after the reload:`,
error
);
}
},
publish: (sessionId) => {
try {
this.refreshSession(sessionId);
} catch (error) {
console.error(
`[Embedded MPV][reconnect] Refreshing session "${sessionId}" failed:`,
error
);
}
},
});
/**
* Frame-copy engine: helper process + shm ring + renderer canvas.
@@ -453,7 +555,8 @@ export class EmbeddedMpvNativeService {
createSession(
bounds: EmbeddedMpvBounds,
title = '',
initialVolume = 1
initialVolume = 1,
options?: EmbeddedMpvSessionOptions
): EmbeddedMpvSession {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
@@ -469,12 +572,25 @@ export class EmbeddedMpvNativeService {
? Buffer.alloc(0)
: this.getMainWindowHandle();
const startedAt = new Date().toISOString();
const sessionId = addon.createSession(
windowHandle,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
title,
initialVolume
const { addonOptions, optionsFile } = this.prepareSessionOptions(
options?.extraOptions ?? [],
usesFrameCopyAddon
);
let sessionId: string;
try {
sessionId = addon.createSession(
windowHandle,
usesFrameCopyAddon
? bounds
: this.scaleBoundsForNativeView(bounds),
title,
initialVolume,
addonOptions
);
} catch (error) {
removeSessionOptionsFile(optionsFile);
throw error;
}
this.sessions.set(sessionId, {
id: sessionId,
@@ -484,6 +600,17 @@ export class EmbeddedMpvNativeService {
updatedAt: startedAt,
lastPayloadKey: '',
lastStatus: null,
reconnect: createEmbeddedMpvReconnectState(
options?.autoReconnect ?? true
),
optionsFile,
lastRecordingStart: null,
lastRecordingActive: false,
recordingRunningAtLoss: false,
restartRecordingAfterReconnect: false,
rejectedOptionsReported: false,
externalSubtitlePath: null,
restoreSubtitleAfterReconnect: false,
});
this.ensurePolling();
@@ -517,6 +644,15 @@ export class EmbeddedMpvNativeService {
session.title = playback.title ?? session.title;
session.streamUrl = playback.streamUrl ?? session.streamUrl;
session.updatedAt = new Date().toISOString();
this.reconnect.onUserLoad(session.reconnect, playback);
// A user-driven replacement stops an active recording by design
// (see "Live Stream Recording"); only an automatic reload restarts it.
session.lastRecordingStart = null;
session.recordingRunningAtLoss = false;
session.restartRecordingAfterReconnect = false;
// External subtitles are source-scoped: a user-driven load drops them.
session.externalSubtitlePath = null;
session.restoreSubtitleAfterReconnect = false;
addon.loadPlayback(sessionId, playback);
this.refreshSession(sessionId);
}
@@ -534,6 +670,15 @@ export class EmbeddedMpvNativeService {
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
if (paused) {
// A pause sent while the session sits in a loss state never
// surfaces as a `paused` status, so the command itself has to
// call off a scheduled reload (see embedded-mpv-reconnect.ts).
const session = this.sessions.get(sessionId);
if (session) {
this.reconnect.onUserPause(session.reconnect);
}
}
this.getAddon().setPaused(sessionId, paused);
return this.refreshSession(sessionId);
}
@@ -571,10 +716,20 @@ export class EmbeddedMpvNativeService {
);
}
addon.setSubtitleTrack(sessionId, trackId);
// An explicit pick after an external file was added hands the
// selection back to the user: nothing is re-selected for them later.
const session = this.sessions.get(sessionId);
if (session) {
session.externalSubtitlePath = null;
session.restoreSubtitleAfterReconnect = false;
}
return this.refreshSession(sessionId);
}
addSubtitle(sessionId: string, filePath: string): EmbeddedMpvSession | null {
addSubtitle(
sessionId: string,
filePath: string
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.addSubtitle !== 'function') {
@@ -587,6 +742,10 @@ export class EmbeddedMpvNativeService {
throw new Error('The selected subtitle file was not found.');
}
addon.addSubtitle(sessionId, normalized);
const session = this.sessions.get(sessionId);
if (session) {
session.externalSubtitlePath = normalized;
}
return this.refreshSession(sessionId);
}
@@ -707,6 +866,7 @@ export class EmbeddedMpvNativeService {
fallbackChannelName,
metadata: options.metadata,
});
session.lastRecordingStart = { ...options };
return this.refreshSession(sessionId);
}
@@ -720,9 +880,39 @@ export class EmbeddedMpvNativeService {
}
addon.stopRecording(sessionId);
embeddedMpvRecordingTracker.onRecordingStopped(sessionId);
const session = this.sessions.get(sessionId);
if (session) {
session.lastRecordingStart = null;
session.recordingRunningAtLoss = false;
session.restartRecordingAfterReconnect = false;
}
return this.refreshSession(sessionId);
}
/**
* An automatic reload replaces the stream, which stops mpv's
* `stream-record` and lets the tracker finalize that file as an
* interrupted partial. Once the reload plays, the recording the user had
* running is started again into a fresh file with the same folder,
* title and metadata, so a transient outage costs seconds, not the rest
* of the programme. Deferred out of `refreshSession`, which must not
* re-enter itself.
*/
private restartRecordingAfterReconnect(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session?.lastRecordingStart) {
return;
}
try {
this.startRecording(sessionId, session.lastRecordingStart);
} catch (error) {
console.warn(
`[Embedded MPV][reconnect] session ${sessionId}: could not restart the recording after the reload:`,
error
);
}
}
getDefaultRecordingFolder(): string {
return app.getPath('downloads');
}
@@ -741,11 +931,49 @@ export class EmbeddedMpvNativeService {
return result.filePaths[0];
}
/**
* How the session options reach the engine. In-process engines take the
* list directly; the frame-copy adapter sends it over the helper's
* stdin; Linux native-view runs a separate `mpv --wid` process whose
* argv is world-readable, so there the list goes into a user-only
* config file referenced with `--include`.
*/
private prepareSessionOptions(
extraOptions: string[],
usesFrameCopyAddon: boolean
): { addonOptions: string[]; optionsFile: string | null } {
if (
process.platform !== 'linux' ||
usesFrameCopyAddon ||
extraOptions.length === 0
) {
return { addonOptions: extraOptions, optionsFile: null };
}
if (this.sessionOptionsDirectory === null) {
const root = resolveSessionOptionsRoot(app.getPath('userData'));
sweepSessionOptionsFiles(root);
this.sessionOptionsDirectory = resolveSessionOptionsDirectory(root);
}
const optionsFile = writeSessionOptionsFile(
this.sessionOptionsDirectory,
extraOptions
);
return { addonOptions: [`include=${optionsFile}`], optionsFile };
}
disposeSession(sessionId: string): EmbeddedMpvSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
this.reconnect.cancel(session.reconnect);
session.lastRecordingStart = null;
session.recordingRunningAtLoss = false;
session.restartRecordingAfterReconnect = false;
session.externalSubtitlePath = null;
session.restoreSubtitleAfterReconnect = false;
removeSessionOptionsFile(session.optionsFile);
session.optionsFile = null;
let lastRecording: EmbeddedMpvRecordingState | undefined;
try {
@@ -790,6 +1018,8 @@ export class EmbeddedMpvNativeService {
shutdown(): void {
const sessionIds = [...this.sessions.keys()];
sessionIds.forEach((sessionId) => this.disposeSession(sessionId));
removeSessionOptionsDirectory(this.sessionOptionsDirectory);
this.sessionOptionsDirectory = null;
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
this.pollingTimer = null;
@@ -880,6 +1110,62 @@ export class EmbeddedMpvNativeService {
this.pollingTimer = null;
}
/**
* Re-adds the external subtitle file an automatic reload dropped. The
* helper's `sub-add` selects the added track, which restores what the
* user saw before the drop; `sub-delay` is an mpv-global property and
* survives the reload on its own.
*/
private restoreSubtitleAfterReconnect(sessionId: string): void {
const session = this.sessions.get(sessionId);
const filePath = session?.externalSubtitlePath;
if (!session || !filePath) {
return;
}
try {
const addon = this.getAddon();
if (typeof addon.addSubtitle !== 'function') {
return;
}
if (!existsSync(filePath)) {
session.externalSubtitlePath = null;
console.warn(
`[Embedded MPV][reconnect] session ${sessionId}: the external subtitle file is gone; not restoring it after the reload`
);
return;
}
addon.addSubtitle(sessionId, filePath);
} catch (error) {
console.warn(
`[Embedded MPV][reconnect] session ${sessionId}: could not restore the external subtitle after the reload:`,
error
);
}
}
/**
* A syntactically valid option libmpv still refused (unknown name, bad
* value) is otherwise invisible outside a trace run; log the key once so
* the user learns the line did not apply. Keys only — a value may carry
* credentials (`http-header-fields`, proxies).
*/
private reportRejectedOptions(
session: EmbeddedMpvRuntimeSession,
snapshot: NativeEmbeddedMpvSessionSnapshot
): void {
if (
session.rejectedOptionsReported ||
!Array.isArray(snapshot.rejectedOptionKeys) ||
snapshot.rejectedOptionKeys.length === 0
) {
return;
}
session.rejectedOptionsReported = true;
console.warn(
`[Embedded MPV] session ${session.id}: libmpv rejected extra option(s) ${snapshot.rejectedOptionKeys.join(', ')} (Settings > Playback > Embedded MPV)`
);
}
private refreshSession(sessionId: string): EmbeddedMpvSession | null {
const addon = this.getAddon();
const session = this.sessions.get(sessionId);
@@ -891,6 +1177,7 @@ export class EmbeddedMpvNativeService {
if (!snapshot) {
return null;
}
this.reportRejectedOptions(session, snapshot);
const payload: EmbeddedMpvSession = {
id: session.id,
@@ -938,9 +1225,78 @@ export class EmbeddedMpvNativeService {
...(snapshot.error ? { error: snapshot.error } : {}),
};
const previousStatus = session.lastStatus;
session.streamUrl = payload.streamUrl;
session.updatedAt = payload.updatedAt;
session.lastStatus = payload.status;
if (
previousStatus !== payload.status &&
isExternalPlayerTraceEnabled()
) {
// Status transitions are the whole input of the reconnect policy;
// tracing them (never the URL) is what makes a "why did it not
// reconnect" report answerable.
trace(
'embedded-mpv',
`session ${sessionId} status ${previousStatus ?? 'none'} -> ${payload.status}`,
payload.error ? { error: payload.error } : undefined
);
}
const reconnect = this.reconnect.observe(
sessionId,
session.reconnect,
payload.status,
previousStatus,
payload.positionSeconds,
snapshot.errorOrigin
);
if (reconnect) {
payload.reconnect = reconnect;
}
if (
reconnect &&
session.lastRecordingStart &&
!session.restartRecordingAfterReconnect &&
(payload.recording?.active === true ||
(session.lastRecordingActive &&
(previousStatus === 'playing' ||
previousStatus === 'paused')))
) {
// The outage began while a recording was running. Whether that
// recording is over is decided by the reload hook: only an
// actual reload replaces the stream and stops it. Recording
// starts asynchronously on Windows native-view and frame-copy,
// so a recording started shortly before the drop is acknowledged
// as active only in a later poll of the pending outage — accept
// it whenever the engine reports it, not just on the transition.
session.recordingRunningAtLoss = true;
} else if (
reconnect === null &&
payload.status === 'playing' &&
previousStatus !== 'playing'
) {
// Back to playing: either our reload succeeded (restart the
// recording it stopped) or the stream recovered on its own
// (the recording never stopped; nothing to do).
const restart = session.restartRecordingAfterReconnect;
session.recordingRunningAtLoss = false;
session.restartRecordingAfterReconnect = false;
if (restart) {
setTimeout(
() => this.restartRecordingAfterReconnect(sessionId),
0
);
}
const restoreSubtitle = session.restoreSubtitleAfterReconnect;
session.restoreSubtitleAfterReconnect = false;
if (restoreSubtitle) {
setTimeout(
() => this.restoreSubtitleAfterReconnect(sessionId),
0
);
}
}
session.lastRecordingActive = payload.recording?.active === true;
// The refresh timestamp must not participate in the diff key,
// otherwise every poll tick looks like a change and the renderer
// receives an IPC update every 500 ms even while paused.
@@ -0,0 +1,414 @@
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
import {
createEmbeddedMpvReconnectState,
EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS,
EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS,
EmbeddedMpvReconnectCoordinator,
EmbeddedMpvReconnectState,
isLiveEmbeddedMpvPlayback,
resolveEmbeddedMpvReconnectDelayMs,
} from './embedded-mpv-reconnect';
const LIVE: ResolvedPortalPlayback = {
streamUrl: 'http://host/live.ts',
title: 'Live channel',
isLive: true,
};
const VOD: ResolvedPortalPlayback = {
streamUrl: 'http://host/movie.mkv',
title: 'Movie',
isLive: false,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
};
const SESSION = 'session-1';
describe('embedded MPV reconnect policy helpers', () => {
it('backs off 2 s → 30 s and caps there', () => {
expect(
[0, 1, 2, 3, 4, 5].map(resolveEmbeddedMpvReconnectDelayMs)
).toEqual([2_000, 4_000, 8_000, 16_000, 30_000, 30_000]);
});
it('treats an explicit flag, else missing content info, as live', () => {
expect(isLiveEmbeddedMpvPlayback(LIVE)).toBe(true);
expect(isLiveEmbeddedMpvPlayback(VOD)).toBe(false);
expect(
isLiveEmbeddedMpvPlayback({ streamUrl: 'http://h/x', title: 'x' })
).toBe(true);
expect(isLiveEmbeddedMpvPlayback({ ...VOD, isLive: undefined })).toBe(
false
);
});
});
describe('EmbeddedMpvReconnectCoordinator', () => {
let reload: jest.Mock<void, [string, ResolvedPortalPlayback]>;
let publish: jest.Mock<void, [string]>;
let log: { log: jest.Mock; warn: jest.Mock };
let coordinator: EmbeddedMpvReconnectCoordinator;
let state: EmbeddedMpvReconnectState;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-09-03T10:00:00Z'));
reload = jest.fn();
publish = jest.fn();
log = { log: jest.fn(), warn: jest.fn() };
coordinator = new EmbeddedMpvReconnectCoordinator({
reload,
publish,
log,
});
state = createEmbeddedMpvReconnectState(true);
});
afterEach(() => {
coordinator.cancel(state);
jest.useRealTimers();
});
/** A user load that reached `playing`. */
const startPlaying = (playback: ResolvedPortalPlayback = LIVE) => {
coordinator.onUserLoad(state, playback);
expect(coordinator.observe(SESSION, state, 'loading', null)).toBeNull();
expect(
coordinator.observe(SESSION, state, 'playing', 'loading')
).toBeNull();
};
it('never retries a load that has not played yet', () => {
coordinator.onUserLoad(state, LIVE);
coordinator.observe(SESSION, state, 'loading', null);
expect(
coordinator.observe(SESSION, state, 'error', 'loading')
).toBeNull();
jest.advanceTimersByTime(120_000);
expect(reload).not.toHaveBeenCalled();
expect(state.timer).toBeNull();
});
it('reloads the last playback after a drop and reports the attempt', () => {
startPlaying();
const info = coordinator.observe(SESSION, state, 'error', 'playing');
expect(info).toEqual({
attempt: 1,
maxAttempts: EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS,
nextAttemptAt: new Date(Date.now() + 2_000).toISOString(),
});
expect(reload).not.toHaveBeenCalled();
// The poll sees the same error again: no second schedule.
expect(coordinator.observe(SESSION, state, 'error', 'error')).toBe(
info
);
jest.advanceTimersByTime(1_999);
expect(reload).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(reload).toHaveBeenCalledWith(SESSION, LIVE);
expect(publish).toHaveBeenCalledWith(SESSION);
// Still pending while the attempt is loading.
expect(coordinator.observe(SESSION, state, 'loading', 'error')).toEqual(
info
);
});
it('doubles the delay per failed attempt and gives up after the sixth', () => {
startPlaying();
const delays: number[] = [];
let previous: 'playing' | 'loading' = 'playing';
for (
let attempt = 1;
attempt <= EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS;
attempt++
) {
const before = Date.now();
const info = coordinator.observe(SESSION, state, 'error', previous);
expect(info?.attempt).toBe(attempt);
delays.push(Date.parse(info?.nextAttemptAt ?? '') - before);
jest.advanceTimersByTime(delays[delays.length - 1]);
expect(reload).toHaveBeenCalledTimes(attempt);
coordinator.observe(SESSION, state, 'loading', 'error');
previous = 'loading';
}
expect(delays).toEqual([2_000, 4_000, 8_000, 16_000, 30_000, 30_000]);
expect(
coordinator.observe(SESSION, state, 'error', 'loading')
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).toHaveBeenCalledTimes(
EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS
);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining('giving up')
);
});
it('spends the budget across short-lived recoveries and resets it after stable playback', () => {
startPlaying();
coordinator.observe(SESSION, state, 'error', 'playing');
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'error');
coordinator.observe(SESSION, state, 'playing', 'loading');
// Drops again after 10 s of playing: still the same outage.
jest.advanceTimersByTime(10_000);
coordinator.observe(SESSION, state, 'playing', 'playing');
expect(
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
coordinator.observe(SESSION, state, 'loading', 'error');
coordinator.observe(SESSION, state, 'playing', 'loading');
// Plays for the stable window: the next drop starts a fresh outage.
jest.advanceTimersByTime(EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS);
coordinator.observe(SESSION, state, 'playing', 'playing');
expect(
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
).toBe(1);
});
it('treats EOF as a loss for live playback only', () => {
startPlaying(LIVE);
expect(
coordinator.observe(SESSION, state, 'ended', 'playing')?.attempt
).toBe(1);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledWith(SESSION, LIVE);
reload.mockClear();
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(SESSION, state, 'ended', 'playing')
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('resumes a VOD reload where it dropped but keeps live at the live edge', () => {
startPlaying(VOD);
coordinator.observe(SESSION, state, 'playing', 'playing', 95);
coordinator.observe(SESSION, state, 'playing', 'playing', 120);
coordinator.observe(SESSION, state, 'error', 'playing', 0);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 120,
});
// A seek back to the very start is a valid resume point as well.
reload.mockClear();
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
coordinator.observe(SESSION, state, 'playing', 'playing', 120);
coordinator.observe(SESSION, state, 'playing', 'playing', 0);
coordinator.observe(SESSION, state, 'error', 'playing');
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 0,
});
// The snapshot's placeholder zero before the first time-pos must not
// beat the playback's own resume offset.
reload.mockClear();
state = createEmbeddedMpvReconnectState(true);
startPlaying({ ...VOD, startTime: 30 });
coordinator.observe(SESSION, state, 'playing', 'playing', 0);
coordinator.observe(SESSION, state, 'error', 'playing');
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 30,
});
reload.mockClear();
state = createEmbeddedMpvReconnectState(true);
startPlaying(LIVE);
coordinator.observe(SESSION, state, 'playing', 'playing', 3_600);
coordinator.observe(SESSION, state, 'error', 'playing', 3_600);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, LIVE);
expect(reload.mock.calls[0][1]).not.toHaveProperty('startTime');
});
it('counts a reload that fails before the poll ever sees it loading', () => {
startPlaying();
coordinator.observe(SESSION, state, 'error', 'playing');
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledTimes(1);
// The engine failed the reload so fast that the poll still reads
// `error` on both sides of the transition.
const info = coordinator.observe(SESSION, state, 'error', 'error');
expect(info?.attempt).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenCalledTimes(2);
});
it('cancels and disarms on an explicit pause command sent from a loss state', () => {
startPlaying();
expect(
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
).toBe(1);
coordinator.onUserPause(state);
expect(state.pending).toBeNull();
expect(state.timer).toBeNull();
expect(
coordinator.observe(SESSION, state, 'error', 'error')
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('leaves an error the engine attributes to itself alone', () => {
startPlaying();
expect(
coordinator.observe(
SESSION,
state,
'error',
'playing',
42,
'engine'
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
expect(state.pending).toBeNull();
});
it('does not resume a stream the user paused when it drops', () => {
startPlaying();
expect(
coordinator.observe(SESSION, state, 'paused', 'playing')
).toBeNull();
expect(
coordinator.observe(SESSION, state, 'error', 'paused')
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
// Playing again re-arms the policy.
coordinator.observe(SESSION, state, 'loading', 'error');
coordinator.observe(SESSION, state, 'playing', 'loading');
expect(
coordinator.observe(SESSION, state, 'error', 'playing')?.attempt
).toBe(1);
});
it('drops a pending attempt when the stream recovers on its own', () => {
startPlaying();
coordinator.observe(SESSION, state, 'error', 'playing');
expect(
coordinator.observe(SESSION, state, 'playing', 'error')
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
expect(state.pending).toBeNull();
});
it('is cancelled by a new user load, a pause, and an explicit cancel', () => {
startPlaying();
coordinator.observe(SESSION, state, 'error', 'playing');
coordinator.onUserLoad(state, VOD);
expect(state.pending).toBeNull();
expect(state.hadPlayed).toBe(false);
startPlaying();
coordinator.observe(SESSION, state, 'error', 'playing');
expect(
coordinator.observe(SESSION, state, 'paused', 'error')
).toBeNull();
expect(state.timer).toBeNull();
coordinator.observe(SESSION, state, 'playing', 'paused');
coordinator.observe(SESSION, state, 'error', 'playing');
coordinator.cancel(state);
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('does nothing when the setting is off', () => {
state = createEmbeddedMpvReconnectState(false);
startPlaying();
expect(
coordinator.observe(SESSION, state, 'error', 'playing')
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('gives up at once when the engine behind the session is gone', () => {
startPlaying();
reload.mockImplementationOnce(() => {
throw new EmbeddedMpvSessionGoneError(SESSION, 'helper exited');
});
coordinator.observe(SESSION, state, 'error', 'playing');
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledTimes(1);
expect(state.pending).toBeNull();
expect(state.timer).toBeNull();
expect(
coordinator.observe(SESSION, state, 'error', 'error')
).toBeNull();
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining('engine gone'),
expect.any(Error)
);
jest.advanceTimersByTime(120_000);
expect(reload).toHaveBeenCalledTimes(1);
});
it('continues the backoff when the addon refuses the reload outright', () => {
startPlaying();
reload.mockImplementationOnce(() => {
throw new Error('addon exploded');
});
coordinator.observe(SESSION, state, 'error', 'playing');
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledTimes(1);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining('could not start'),
expect.any(Error)
);
expect(state.pending?.attempt).toBe(2);
// No status transition ever arrives for a refused load.
expect(
coordinator.observe(SESSION, state, 'error', 'error')?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,352 @@
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;
}
}
}
@@ -511,6 +511,65 @@ describe('EmbeddedMpvRecordingTracker', () => {
expect(db.updateSet.mock.calls[0][0].status).toBe('completed');
});
it('finalizes a recording as interrupted when the stream drops and a reload replaces it', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
jest.useFakeTimers();
try {
tracker.onRecordingInterrupted('session-1');
tracker.onRecordingStarted({
sessionId: 'session-1',
targetPath: '/rec/News-20260815-213500.ts',
fallbackChannelName: 'Channel One',
});
await jest.advanceTimersByTimeAsync(1_600);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('keeps the interruption when the engine still shows the old recording active for a tick', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
jest.useFakeTimers();
try {
tracker.onRecordingInterrupted('session-1');
// A clean live EOF can carry the old recording as active once more.
tracker.observeSnapshot(activeSnapshot());
tracker.observeSnapshot(inactiveSnapshot());
await jest.advanceTimersByTimeAsync(2_600);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('finalizes an interrupted recording on its own when no reload follows', async () => {
started(tracker);
tracker.observeSnapshot(activeSnapshot());
jest.useFakeTimers();
try {
tracker.onRecordingInterrupted('session-1');
await jest.advanceTimersByTimeAsync(2_600);
} finally {
jest.useRealTimers();
}
await flush();
expect(db.updateSet).toHaveBeenCalledTimes(1);
expect(db.updateSet.mock.calls[0][0].status).toBe('interrupted');
});
it('keeps a finalizing row in the ledger until its update commits', async () => {
// finalize() removes the entry from the open map immediately, but
// the row stays persisted as 'recording' until the queued terminal
@@ -31,6 +31,8 @@ interface OpenRecordingEntry {
stopFallbackTimer?: ReturnType<typeof setTimeout>;
/** Armed by an inactive snapshot; finalizes once the stop looks settled. */
settleTimer?: ReturnType<typeof setTimeout>;
/** The stream dropped under this recording; it must not end as `completed`. */
interrupted: boolean;
/**
* Set by the first finalize. Timers hold the entry itself, so an entry
* replaced in the map (stop → immediate restart on the same session) can
@@ -86,9 +88,7 @@ const TEARDOWN_FLUSH_WINDOW_MS = 2_500;
const FINALIZE_STAT_TIMEOUT_MS = 3_000;
type FinalizeFileProbe =
| { kind: 'size'; size: number }
| { kind: 'missing' }
| { kind: 'unknown' };
{ kind: 'size'; size: number } | { kind: 'missing' } | { kind: 'unknown' };
/**
* Persists the lifecycle of embedded-MPV live recordings into the
@@ -129,7 +129,10 @@ export class EmbeddedMpvRecordingTracker {
clearTimeout(replaced.settleTimer);
}
replaced.settleTimer = setTimeout(() => {
this.finalize(replaced, 'completed');
this.finalize(
replaced,
replaced.interrupted ? 'interrupted' : 'completed'
);
}, STOP_SETTLE_WINDOW_MS);
}
@@ -165,6 +168,7 @@ export class EmbeddedMpvRecordingTracker {
targetPath: event.targetPath,
startedAt,
sawActive: false,
interrupted: false,
stopRequested: false,
finalized: false,
rowId,
@@ -182,6 +186,27 @@ export class EmbeddedMpvRecordingTracker {
* statting (or unlinking) the file before mpv flushed it would report a
* short recording as failed and could delete bytes still being written.
*/
/**
* The stream carrying this recording dropped and an automatic reload is
* about to replace it (see embedded-mpv-reconnect.ts). The file mpv was
* writing is over: finalize it as `interrupted`. Without this, a clean
* EOF never reaches the error path above and the reload's own
* `onRecordingStarted` would file the partial as `completed`.
*/
onRecordingInterrupted(sessionId: string): void {
const entry = this.open.get(sessionId);
if (!entry || entry.finalized) {
return;
}
entry.interrupted = true;
if (entry.settleTimer !== undefined) {
clearTimeout(entry.settleTimer);
}
entry.settleTimer = setTimeout(() => {
this.finalize(entry, 'interrupted');
}, TEARDOWN_FLUSH_WINDOW_MS);
}
onRecordingStopped(sessionId: string): void {
const entry = this.open.get(sessionId);
if (!entry || entry.stopRequested) {
@@ -228,7 +253,10 @@ export class EmbeddedMpvRecordingTracker {
entry.sawActive = true;
// A rejected stop puts the recording back: abandon the pending
// finalization instead of ending a row that is still recording.
if (entry.settleTimer !== undefined) {
// Not after an interruption, though: the engine may still show
// the old recording as active for a tick after a clean live EOF,
// and the reload replacing it is already on its way.
if (entry.settleTimer !== undefined && !entry.interrupted) {
clearTimeout(entry.settleTimer);
entry.settleTimer = undefined;
}
@@ -244,7 +272,10 @@ export class EmbeddedMpvRecordingTracker {
// Finalize only once it survived the settle window.
if (entry.settleTimer === undefined) {
entry.settleTimer = setTimeout(() => {
this.finalize(entry, 'completed');
this.finalize(
entry,
entry.interrupted ? 'interrupted' : 'completed'
);
}, STOP_SETTLE_WINDOW_MS);
}
} else if (recording.error) {
@@ -0,0 +1,20 @@
/**
* Thrown by an engine adapter when a session can no longer take commands
* because the process behind it is gone (a crashed frame-copy helper). The
* reconnect coordinator treats it as terminal: only a new session can
* recover, so retrying the same session would just spin.
*/
export class EmbeddedMpvSessionGoneError extends Error {
constructor(sessionId: string, reason: string) {
super(
`Embedded MPV session "${sessionId}" cannot be reused: ${reason}.`
);
this.name = 'EmbeddedMpvSessionGoneError';
}
}
export function isEmbeddedMpvSessionGoneError(error: unknown): boolean {
return (
error instanceof Error && error.name === 'EmbeddedMpvSessionGoneError'
);
}
@@ -0,0 +1,96 @@
import { randomUUID } from 'crypto';
import { mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import path from 'path';
const OPTIONS_ROOT = 'embedded-mpv';
const INSTANCE_DIRECTORY_PREFIX = 'options-';
const OPTIONS_FILE_PREFIX = 'session-options-';
/**
* Linux native-view runs mpv as a separate process whose command line is
* readable by every local user through /proc/<pid>/cmdline, so the session
* options (which may carry request headers with credentials) are handed
* over as an mpv config file readable by the owner only and referenced with
* `--include=<path>`. Files live under a per-instance directory named by the
* owning process id: a session removes its file on dispose, the instance
* removes its directory on shutdown, and a crashed instance's leftovers are
* swept by the next one — but only once its process is gone, because two
* instances may share one `userData` (IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1).
*/
export function resolveSessionOptionsRoot(userDataPath: string): string {
return path.join(userDataPath, OPTIONS_ROOT);
}
export function resolveSessionOptionsDirectory(
root: string,
pid: number = process.pid
): string {
return path.join(root, `${INSTANCE_DIRECTORY_PREFIX}${pid}`);
}
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM: the process exists but belongs to someone else.
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
export function sweepSessionOptionsFiles(
root: string,
options: { ownPid?: number; isAlive?: (pid: number) => boolean } = {}
): void {
const ownPid = options.ownPid ?? process.pid;
const isAlive = options.isAlive ?? isProcessAlive;
let names: string[];
try {
names = readdirSync(root);
} catch {
return;
}
for (const name of names) {
if (!name.startsWith(INSTANCE_DIRECTORY_PREFIX)) {
continue;
}
const pid = Number(name.slice(INSTANCE_DIRECTORY_PREFIX.length));
if (!Number.isInteger(pid) || pid === ownPid || isAlive(pid)) {
continue;
}
rmSync(path.join(root, name), { recursive: true, force: true });
}
}
export function writeSessionOptionsFile(
directory: string,
options: readonly string[]
): string {
mkdirSync(directory, { recursive: true, mode: 0o700 });
const file = path.join(
directory,
`${OPTIONS_FILE_PREFIX}${randomUUID()}.conf`
);
writeFileSync(file, options.map((option) => `${option}\n`).join(''), {
mode: 0o600,
});
return file;
}
export function removeSessionOptionsFile(file: string | null): void {
if (!file) {
return;
}
try {
unlinkSync(file);
} catch {
// Already gone (a swept or never-written file); nothing to keep.
}
}
export function removeSessionOptionsDirectory(directory: string | null): void {
if (!directory) {
return;
}
rmSync(directory, { recursive: true, force: true });
}
@@ -0,0 +1,34 @@
import { resolveEmbeddedMpvSessionOptionArguments } from '@iptvnator/shared/interfaces';
import {
EMBEDDED_MPV_AUTO_RECONNECT,
EMBEDDED_MPV_EXTRA_OPTIONS,
store,
} from './store.service';
/**
* Per-session knobs captured when an embedded MPV session is created. They
* come from the main-process settings mirror (see `store.service.ts`), so a
* settings change applies to the next session, never to a running one.
*
* Read here rather than inside `EmbeddedMpvNativeService`: the config store
* is constructed at module load and would drag electron-conf into every
* consumer of the service, including its unit tests.
*/
export interface EmbeddedMpvSessionOptions {
/**
* `key=value` lines the addon applies after its built-in options: the
* network defaults first, then the user's allowed lines.
*/
extraOptions: string[];
/** Reload a dropped stream automatically (see `embedded-mpv-reconnect.ts`). */
autoReconnect: boolean;
}
export function readEmbeddedMpvSessionOptions(): EmbeddedMpvSessionOptions {
return {
extraOptions: resolveEmbeddedMpvSessionOptionArguments(
store.get(EMBEDDED_MPV_EXTRA_OPTIONS, '')
),
autoReconnect: store.get(EMBEDDED_MPV_AUTO_RECONNECT, true) !== false,
};
}
@@ -9,6 +9,7 @@ export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH';
export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS';
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
/**
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the
* main process config file because it must be readable synchronously before
@@ -24,6 +25,21 @@ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
*/
export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/**
* Extra libmpv options for embedded sessions, one "key=value" per line, as
* typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler
* because sessions are created in the main process, where the renderer's
* IndexedDB settings are unreachable. Turned into addon arguments by
* `readEmbeddedMpvSessionOptions()`.
*/
export const EMBEDDED_MPV_EXTRA_OPTIONS = 'EMBEDDED_MPV_EXTRA_OPTIONS';
/**
* Whether a dropped embedded MPV stream is reloaded automatically. Same
* mirror as above; absent means enabled.
*/
export const EMBEDDED_MPV_AUTO_RECONNECT = 'EMBEDDED_MPV_AUTO_RECONNECT';
export type StoreType = {
[WINDOW_BOUNDS]: Electron.Rectangle;
[MPV_PLAYER_PATH]: string;
@@ -33,6 +49,8 @@ export type StoreType = {
[MPV_REUSE_INSTANCE]: boolean;
[VLC_REUSE_INSTANCE]: boolean;
[EMBEDDED_MPV_FRAME_COPY]: boolean;
[EMBEDDED_MPV_EXTRA_OPTIONS]: string;
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
};
@@ -1,5 +1,5 @@
import { FormBuilder } from '@angular/forms';
import { Settings } from '@iptvnator/shared/interfaces';
import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
createSettingsForm,
createSettingsFromFormValue,
@@ -70,3 +70,77 @@ describe('settings form utils — startup window mode', () => {
).toBe('normal');
});
});
describe('settings form utils — embedded MPV session options', () => {
const formBuilder = new FormBuilder();
it('defaults to no extra options and auto-reconnect on', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().embeddedMpvExtraOptions).toBe('');
expect(form.getRawValue().embeddedMpvAutoReconnect).toBe(true);
expect(form.valid).toBe(true);
});
it('rejects forbidden keys and malformed lines in the options field', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
const control = form.get('embeddedMpvExtraOptions');
control?.setValue('hwdec=auto\nvo=null\nnot an option');
expect(control?.errors).toEqual({
invalidLines: ['not an option'],
forbiddenKeys: ['vo'],
});
expect(form.valid).toBe(false);
control?.setValue('hwdec=auto\ncache-secs=30');
expect(control?.errors).toBeNull();
});
it('ignores extra-option errors while another player is selected', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
const control = form.get('embeddedMpvExtraOptions');
control?.setValue('vo=null');
expect(form.valid).toBe(false);
form.patchValue({ player: VideoPlayer.VideoJs });
expect(control?.errors).toBeNull();
expect(form.valid).toBe(true);
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
expect(control?.errors).toEqual({ forbiddenKeys: ['vo'] });
expect(form.valid).toBe(false);
});
it('carries canonical options and the reconnect opt-out into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
embeddedMpvExtraOptions: ' --hwdec = auto \r\n\ncache-secs=30 ',
embeddedMpvAutoReconnect: false,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.embeddedMpvExtraOptions).toBe(
'hwdec=auto\ncache-secs=30'
);
expect(settings.embeddedMpvAutoReconnect).toBe(false);
});
it('falls back to auto-reconnect on when the form value is missing', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
embeddedMpvAutoReconnect: null as unknown as boolean,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.embeddedMpvAutoReconnect).toBe(true);
});
});
@@ -1,7 +1,9 @@
import {
AbstractControl,
FormArray,
FormBuilder,
FormControl,
ValidationErrors,
Validators,
} from '@angular/forms';
import {
@@ -11,6 +13,7 @@ import {
EpgViewMode,
Language,
normalizeDashboardRailsSettings,
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeStartupWindowMode,
Settings,
@@ -18,11 +21,28 @@ import {
StartupWindowMode,
StreamFormat,
Theme,
validateEmbeddedMpvExtraOptions,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
/**
* Rejects malformed lines and the option keys the embed depends on, so a
* value that the main process would have to drop never gets saved.
*/
export function embeddedMpvExtraOptionsValidator(
control: AbstractControl
): ValidationErrors | null {
// Only the embedded MPV player consumes these lines. While another player
// is selected the field is hidden, and a stale error must not keep the
// whole settings form unsaveable.
if (control.parent?.get('player')?.value !== VideoPlayer.EmbeddedMpv) {
return null;
}
return validateEmbeddedMpvExtraOptions(control.value);
}
export function createEpgUrlControl(value = ''): FormControl<string | null> {
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
}
@@ -31,7 +51,7 @@ export function createSettingsForm(
formBuilder: FormBuilder,
supportsEpg: boolean
) {
return formBuilder.group({
const form = formBuilder.group({
player: [VideoPlayer.VideoJs],
webPlayerSharedControls: true,
playerAmbientMode: false,
@@ -84,6 +104,8 @@ export function createSettingsForm(
],
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: ['', [embeddedMpvExtraOptionsValidator]],
embeddedMpvAutoReconnect: true,
coverSize: 'medium' as CoverSize,
...(supportsEpg
? {
@@ -96,6 +118,13 @@ export function createSettingsForm(
apiKey: DEFAULT_TMDB_SETTINGS.apiKey ?? '',
}),
});
// The extra-options validator reads the sibling `player` control, which
// Angular does not track as a dependency: re-validate on player changes
// so a hidden field's stale error cannot block Save.
form.get('player')?.valueChanges.subscribe(() =>
form.get('embeddedMpvExtraOptions')?.updateValueAndValidity()
);
return form;
}
export type SettingsForm = ReturnType<typeof createSettingsForm>;
@@ -155,6 +184,10 @@ export function createSettingsFromFormValue(
remoteControlPort: Number(value.remoteControlPort ?? 8765),
recordingFolder: value.recordingFolder ?? '',
embeddedMpvFrameCopy: value.embeddedMpvFrameCopy ?? false,
embeddedMpvExtraOptions: normalizeEmbeddedMpvExtraOptions(
value.embeddedMpvExtraOptions
),
embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true,
coverSize: value.coverSize ?? 'medium',
epgUrl,
preferUploadedEpgOverXtream:
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="playback"
[formGroup]="form()"
>
<section class="settings-group" id="playback" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>play_circle</mat-icon>
@@ -235,6 +231,101 @@
</div>
}
@if (form().value.player === 'embedded-mpv') {
<div
class="setting-item"
data-test-id="embedded-mpv-extra-options-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' | translate }}
</h4>
<p>
{{
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label for="embeddedMpvExtraOptions">{{
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' | translate
}}</mat-label>
<textarea
matInput
id="embeddedMpvExtraOptions"
formControlName="embeddedMpvExtraOptions"
placeholder="hwdec=auto-safe"
rows="3"
spellcheck="false"
autocomplete="off"
></textarea>
<mat-hint>{{
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_HINT'
| translate
}}</mat-hint>
@if (
form().get('embeddedMpvExtraOptions')?.errors;
as errors
) {
<mat-error
data-test-id="embedded-mpv-extra-options-error"
>
@if (errors['forbiddenKeys']) {
{{
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS'
| translate
: {
keys: errors[
'forbiddenKeys'
].join(', '),
}
}}
} @else if (errors['invalidLines']) {
{{
'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES'
| translate
: {
lines: errors[
'invalidLines'
].join(', '),
}
}}
}
</mat-error>
}
</mat-form-field>
</div>
</div>
<div
class="setting-item"
data-test-id="embedded-mpv-auto-reconnect-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT' | translate }}
</h4>
<p>
{{
'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="embeddedMpvAutoReconnect"
data-test-id="embedded-mpv-auto-reconnect-toggle"
></mat-checkbox>
</div>
</div>
}
<div class="setting-item" data-test-id="recording-folder-setting">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
@@ -110,6 +110,8 @@ export const DEFAULT_SETTINGS = {
epgUrl: [],
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
coverSize: 'medium',
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "خيارات إضافية لـ MPV المدمج",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "خيارات libmpv متقدمة للمشغّل المدمج، زوج key=value واحد في كل سطر (بدون -- في البداية). تُطبَّق في كل محرك بعد الخيارات المضمّنة، وتسري عند بدء جلسة MPV المدمج التالية.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "مثال: hwdec=auto-safe. تُرفض الخيارات التي يعتمد عليها التضمين نفسه (wid، vo، input-ipc-server، …).",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "ليس سطرًا بصيغة key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "لا يمكن تغيير هذه الخيارات للمشغّل المدمج: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "إعادة الاتصال بالبث المنقطع تلقائيًا",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "عند انقطاع بث قيد التشغيل، يعيد المشغّل المدمج تحميله بفواصل زمنية متزايدة، حتى 6 محاولات، قبل أن يتوقف. أما البث الذي لم يبدأ أصلًا فلا يزال يحتاج إلى إعادة المحاولة يدويًا.",
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
},
@@ -520,6 +527,8 @@
"RETRY": "إعادة المحاولة",
"STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.",
"PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.",
"RECONNECTING": "انقطع الاتصال. جارٍ إعادة الاتصال… المحاولة {{attempt}} من {{maxAttempts}}",
"STREAM_ENDED": "انتهى البث المباشر.",
"CHECKING_SUPPORT": "جارٍ التحقق من دعم MPV المضمّن...",
"NOT_AVAILABLE": "MPV المضمّن غير متاح في هذه البيئة.",
"LOADING_STREAM": "جارٍ تحميل البث…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "خيارات زايدة لـ MPV المدمج",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "خيارات libmpv متقدمة للمشغل المدمج، زوج key=value واحد فكل سطر (بلا -- فالبداية). كيتطبقو فكل محرك من بعد الخيارات المدمجة، وكيبداو يخدمو ملي تبدا جلسة MPV المدمج الجاية.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "مثال: hwdec=auto-safe. الخيارات اللي كيعتمد عليها التضمين بنفسو (wid، vo، input-ipc-server، …) كيتّرفضو.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "ماشي سطر بصيغة key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "هاد الخيارات ما يمكنش تتبدل فالمشغل المدمج: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "عاود الاتصال أوتوماتيكياً بالبث اللي تقطع",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "ملي شي بث خدام كيتقطع، المشغل المدمج كيعاود يشرجو بفواصل كتزيد شوية بشوية، حتى 6 محاولات، قبل ما يوقف. البث اللي عمرو ما بدا كيبقى محتاج تحاول مرة أخرى يدوياً.",
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
},
@@ -520,6 +527,8 @@
"RETRY": "حاول مرة أخرى",
"STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.",
"PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.",
"RECONNECTING": "تقطع الاتصال. كنعاودو الاتصال… المحاولة {{attempt}} من {{maxAttempts}}",
"STREAM_ENDED": "البث المباشر سالا.",
"CHECKING_SUPPORT": "جاري التحقق من دعم MPV المدمج...",
"NOT_AVAILABLE": "MPV المدمج ما متوفرش فهاد البيئة.",
"LOADING_STREAM": "جاري تشريج البث…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Дадатковыя параметры ўбудаванага MPV",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Пашыраныя параметры libmpv для ўбудаванага прайгравальніка, адна пара key=value на радок (без -- у пачатку). Яны прымяняюцца ў кожным рухавіку пасля ўбудаваных параметраў і пачынаюць дзейнічаць пры запуску наступнага сеанса ўбудаванага MPV.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Прыклад: hwdec=auto-safe. Параметры, ад якіх залежыць само ўбудаванне (wid, vo, input-ipc-server, …), адхіляюцца.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Не радок выгляду key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Гэтыя параметры нельга змяніць для ўбудаванага прайгравальніка: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Аўтаматычна перападключацца да абарваных патокаў",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Калі паток, які прайграецца, абрываецца, убудаваны прайгравальнік перазагружае яго з усё большымі паўзамі, да 6 спроб, перш чым спыніцца. Паток, які так і не запусціўся, па-ранейшаму патрабуе ручнога «Паўтарыць».",
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
},
@@ -520,6 +527,8 @@
"RETRY": "Паўтарыць",
"STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.",
"PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.",
"RECONNECTING": "Злучэнне страчана. Перападключэнне… спроба {{attempt}} з {{maxAttempts}}",
"STREAM_ENDED": "Прамая трансляцыя скончылася.",
"CHECKING_SUPPORT": "Праверка падтрымкі ўбудаванага MPV...",
"NOT_AVAILABLE": "Убудаваны MPV недаступны ў гэтым асяроддзі.",
"LOADING_STREAM": "Загрузка патоку…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Zusätzliche Optionen für eingebettetes MPV",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Erweiterte libmpv-Optionen für den eingebetteten Player, ein key=value-Paar pro Zeile (ohne vorangestelltes --). Sie gelten für jede Engine, werden nach den integrierten Optionen angewendet und werden wirksam, sobald die nächste Sitzung mit eingebettetem MPV startet.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Beispiel: hwdec=auto-safe. Optionen, auf die der eingebettete Player selbst angewiesen ist (wid, vo, input-ipc-server, …), werden abgelehnt.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Keine key=value-Zeile: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Diese Optionen können für den eingebetteten Player nicht geändert werden: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Unterbrochene Streams automatisch neu verbinden",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wenn ein laufender Stream unterbrochen wird, lädt der eingebettete Player ihn in bis zu 6 Versuchen mit immer längeren Wartezeiten neu, bevor er aufgibt. Ein Stream, der gar nicht erst gestartet ist, erfordert weiterhin ein manuelles „Erneut versuchen“.",
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
},
@@ -520,6 +527,8 @@
"RETRY": "Erneut versuchen",
"STALLED": "Der Start dieses Streams dauert länger als erwartet.",
"PLAYBACK_FAILED": "Die Wiedergabe mit eingebettetem MPV ist fehlgeschlagen.",
"RECONNECTING": "Verbindung unterbrochen. Verbindung wird wiederhergestellt… Versuch {{attempt}} von {{maxAttempts}}",
"STREAM_ENDED": "Der Live-Stream ist beendet.",
"CHECKING_SUPPORT": "Unterstützung für eingebettetes MPV wird geprüft...",
"NOT_AVAILABLE": "Eingebettetes MPV ist in dieser Umgebung nicht verfügbar.",
"LOADING_STREAM": "Stream wird geladen…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Πρόσθετες επιλογές ενσωματωμένου MPV",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Προηγμένες επιλογές libmpv για το ενσωματωμένο πρόγραμμα αναπαραγωγής, ένα ζεύγος key=value ανά γραμμή (χωρίς -- στην αρχή). Εφαρμόζονται σε κάθε μηχανή μετά τις ενσωματωμένες επιλογές και ισχύουν από την επόμενη συνεδρία του ενσωματωμένου MPV.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Παράδειγμα: hwdec=auto-safe. Οι επιλογές από τις οποίες εξαρτάται η ίδια η ενσωμάτωση (wid, vo, input-ipc-server, …) απορρίπτονται.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Δεν είναι γραμμή key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Αυτές οι επιλογές δεν μπορούν να αλλάξουν για το ενσωματωμένο πρόγραμμα αναπαραγωγής: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Αυτόματη επανασύνδεση σε ροές που διακόπηκαν",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Όταν μια ροή που αναπαράγεται διακοπεί, το ενσωματωμένο πρόγραμμα αναπαραγωγής τη φορτώνει ξανά με αυξανόμενες καθυστερήσεις, έως 6 προσπάθειες, πριν τα παρατήσει. Μια ροή που δεν ξεκίνησε ποτέ εξακολουθεί να χρειάζεται χειροκίνητη Επανάληψη.",
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
},
@@ -520,6 +527,8 @@
"RETRY": "Επανάληψη",
"STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.",
"PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.",
"RECONNECTING": "Η σύνδεση χάθηκε. Επανασύνδεση… προσπάθεια {{attempt}} από {{maxAttempts}}",
"STREAM_ENDED": "Η ζωντανή ροή τερματίστηκε.",
"CHECKING_SUPPORT": "Έλεγχος υποστήριξης ενσωματωμένου MPV...",
"NOT_AVAILABLE": "Το ενσωματωμένο MPV δεν είναι διαθέσιμο σε αυτό το περιβάλλον.",
"LOADING_STREAM": "Φόρτωση ροής…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Extra embedded MPV options",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Advanced libmpv options for the embedded player, one key=value pair per line (no leading --). They apply on every engine after the built-in options and take effect when the next embedded MPV session starts.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Example: hwdec=auto-safe. Options the embed itself depends on (wid, vo, input-ipc-server, …) are refused.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Not a key=value line: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "These options cannot be changed for the embedded player: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnect dropped streams automatically",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "When a playing stream drops, the embedded player reloads it with increasing delays, up to 6 attempts, before giving up. A stream that never started still needs a manual Retry.",
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
},
@@ -520,6 +527,8 @@
"RETRY": "Retry",
"STALLED": "This stream is taking longer than expected to start.",
"PLAYBACK_FAILED": "Embedded MPV playback failed.",
"RECONNECTING": "Connection lost. Reconnecting… attempt {{attempt}} of {{maxAttempts}}",
"STREAM_ENDED": "The live stream ended.",
"CHECKING_SUPPORT": "Checking embedded MPV support...",
"NOT_AVAILABLE": "Embedded MPV is not available in this environment.",
"LOADING_STREAM": "Loading stream…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Opciones adicionales de MPV integrado",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Opciones avanzadas de libmpv para el reproductor integrado, un par key=value por línea (sin -- al inicio). Se aplican en todos los motores después de las opciones integradas y surten efecto al iniciarse la siguiente sesión de MPV integrado.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Ejemplo: hwdec=auto-safe. Las opciones de las que depende el propio reproductor integrado (wid, vo, input-ipc-server, …) se rechazan.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Sin formato key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Estas opciones no se pueden cambiar para el reproductor integrado: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar automáticamente los streams interrumpidos",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Cuando un stream en reproducción se interrumpe, el reproductor integrado lo vuelve a cargar con intervalos cada vez más largos, hasta 6 intentos, antes de desistir. Un stream que nunca llegó a iniciarse sigue necesitando un Reintentar manual.",
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
},
@@ -520,6 +527,8 @@
"RETRY": "Reintentar",
"STALLED": "Este stream está tardando más de lo esperado en iniciarse.",
"PLAYBACK_FAILED": "Falló la reproducción de MPV integrado.",
"RECONNECTING": "Conexión perdida. Reconectando… intento {{attempt}} de {{maxAttempts}}",
"STREAM_ENDED": "La transmisión en vivo terminó.",
"CHECKING_SUPPORT": "Comprobando compatibilidad con MPV integrado...",
"NOT_AVAILABLE": "MPV integrado no está disponible en este entorno.",
"LOADING_STREAM": "Cargando stream…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Options MPV intégré supplémentaires",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Options libmpv avancées pour le lecteur intégré, une paire key=value par ligne (sans -- au début). Elles s'appliquent sur tous les moteurs après les options intégrées et prennent effet au démarrage de la prochaine session MPV intégrée.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Exemple : hwdec=auto-safe. Les options dont dépend l'intégration (wid, vo, input-ipc-server, …) sont refusées.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Format key=value non respecté : {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Ces options ne peuvent pas être modifiées pour le lecteur intégré : {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnecter automatiquement les flux interrompus",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Lorsqu'un flux en cours de lecture est interrompu, le lecteur intégré le recharge avec des délais croissants, jusqu'à 6 tentatives, avant d'abandonner. Un flux qui n'a jamais démarré doit toujours être relancé manuellement avec « Réessayer ».",
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
},
@@ -520,6 +527,8 @@
"RETRY": "Réessayer",
"STALLED": "Ce flux met plus de temps que prévu à démarrer.",
"PLAYBACK_FAILED": "Échec de la lecture avec MPV intégré.",
"RECONNECTING": "Connexion perdue. Reconnexion… tentative {{attempt}} sur {{maxAttempts}}",
"STREAM_ENDED": "La diffusion en direct est terminée.",
"CHECKING_SUPPORT": "Vérification de la prise en charge de MPV intégré...",
"NOT_AVAILABLE": "MPV intégré n'est pas disponible dans cet environnement.",
"LOADING_STREAM": "Chargement du flux…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "További beágyazott MPV-beállítások",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Speciális libmpv-beállítások a beágyazott lejátszóhoz, soronként egy key=value pár (kezdő -- nélkül). Minden megjelenítőmotorra érvényesek, a beépített beállítások után kerülnek alkalmazásra, és a következő beágyazott MPV-munkamenet indításakor lépnek érvénybe.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Példa: hwdec=auto-safe. A beágyazás működéséhez szükséges beállításokat (wid, vo, input-ipc-server, …) az alkalmazás elutasítja.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Nem key=value formátumú sor: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Ezek a beállítások nem módosíthatók a beágyazott lejátszónál: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Megszakadt adatfolyamok automatikus újracsatlakoztatása",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Ha egy lejátszás alatt álló adatfolyam megszakad, a beágyazott lejátszó egyre hosszabb várakozással, legfeljebb 6 próbálkozással tölti be újra, mielőtt feladná. Az el sem indult adatfolyamhoz továbbra is kézi újrapróbálkozás szükséges.",
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
},
@@ -520,6 +527,8 @@
"RETRY": "Újrapróbálkozás",
"STALLED": "Az adatfolyam indítása a vártnál hosszabb ideig tart.",
"PLAYBACK_FAILED": "Nem sikerült lejátszani a tartalmat a beágyazott MPV-vel.",
"RECONNECTING": "A kapcsolat megszakadt. Újracsatlakozás… próbálkozás: {{attempt}}/{{maxAttempts}}",
"STREAM_ENDED": "Az élő adás véget ért.",
"CHECKING_SUPPORT": "A beágyazott MPV támogatásának ellenőrzése…",
"NOT_AVAILABLE": "A beágyazott MPV ebben a környezetben nem érhető el.",
"LOADING_STREAM": "Adatfolyam betöltése…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Opzioni aggiuntive per MPV integrato",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Opzioni avanzate di libmpv per il lettore integrato, una coppia key=value per riga (senza -- iniziale). Si applicano a tutti i motori dopo le opzioni integrate dell'app e hanno effetto all'avvio della prossima sessione di MPV integrato.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Esempio: hwdec=auto-safe. Le opzioni da cui dipende l'integrazione stessa (wid, vo, input-ipc-server, …) vengono rifiutate.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Non in formato key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Queste opzioni non possono essere modificate per il lettore integrato: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Riconnetti automaticamente gli stream interrotti",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando uno stream in riproduzione si interrompe, il lettore integrato lo ricarica a intervalli crescenti, fino a 6 tentativi, prima di rinunciare. Per uno stream che non è mai partito è comunque necessario premere Riprova manualmente.",
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
},
@@ -520,6 +527,8 @@
"RETRY": "Riprova",
"STALLED": "L'avvio di questo stream sta richiedendo più tempo del previsto.",
"PLAYBACK_FAILED": "Riproduzione con MPV integrato non riuscita.",
"RECONNECTING": "Connessione persa. Riconnessione in corso… tentativo {{attempt}} di {{maxAttempts}}",
"STREAM_ENDED": "Lo stream in diretta è terminato.",
"CHECKING_SUPPORT": "Verifica del supporto per MPV integrato...",
"NOT_AVAILABLE": "MPV integrato non è disponibile in questo ambiente.",
"LOADING_STREAM": "Caricamento stream…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。",
"EMBEDDED_MPV_EXTRA_OPTIONS": "埋め込みMPVの追加オプション",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "埋め込みプレーヤー向けの高度な libmpv オプションです。1 行に 1 つの key=value ペアを入力します(先頭の -- は不要)。すべてのエンジンで組み込みオプションの後に適用され、次に埋め込みMPVのセッションを開始したときに有効になります。",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "例: hwdec=auto-safe。埋め込み自体が依存するオプション(wid、vo、input-ipc-server など)は拒否されます。",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "key=value 形式の行ではありません: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "埋め込みプレーヤーでは次のオプションを変更できません: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "切断されたストリームに自動的に再接続",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "再生中のストリームが切断されると、埋め込みプレーヤーは間隔を延ばしながら最大 6 回まで再読み込みを試み、その後あきらめます。一度も開始しなかったストリームには、引き続き手動の再試行が必要です。",
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
},
@@ -520,6 +527,8 @@
"RETRY": "再試行",
"STALLED": "このストリームの開始に予想より時間がかかっています。",
"PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。",
"RECONNECTING": "接続が失われました。再接続中… 試行 {{attempt}} / {{maxAttempts}}",
"STREAM_ENDED": "ライブストリームが終了しました。",
"CHECKING_SUPPORT": "組み込みMPVのサポートを確認中...",
"NOT_AVAILABLE": "この環境では組み込みMPVは利用できません。",
"LOADING_STREAM": "ストリームを読み込み中…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "내장 MPV 추가 옵션",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "내장 플레이어용 고급 libmpv 옵션입니다. 한 줄에 key=value 쌍 하나씩 입력하세요(앞의 -- 없이). 모든 엔진에서 기본 제공 옵션 다음에 적용되며, 다음 내장 MPV 세션이 시작될 때 반영됩니다.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "예: hwdec=auto-safe. 내장 기능 자체가 의존하는 옵션(wid, vo, input-ipc-server 등)은 거부됩니다.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "key=value 형식의 줄이 아닙니다: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "내장 플레이어에서는 다음 옵션을 변경할 수 없습니다: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "끊긴 스트림 자동 재연결",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "재생 중인 스트림이 끊기면 내장 플레이어가 점점 길어지는 간격으로 최대 6회까지 다시 불러온 뒤 포기합니다. 한 번도 시작되지 않은 스트림은 여전히 수동으로 다시 시도해야 합니다.",
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
},
@@ -520,6 +527,8 @@
"RETRY": "다시 시도",
"STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.",
"PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.",
"RECONNECTING": "연결이 끊어졌습니다. 다시 연결하는 중… 시도 {{attempt}}/{{maxAttempts}}",
"STREAM_ENDED": "라이브 스트림이 종료되었습니다.",
"CHECKING_SUPPORT": "내장 MPV 지원을 확인하는 중...",
"NOT_AVAILABLE": "이 환경에서는 내장 MPV를 사용할 수 없습니다.",
"LOADING_STREAM": "스트림을 불러오는 중…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Extra opties voor Embedded MPV",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Geavanceerde libmpv-opties voor de ingebedde speler, één key=value-paar per regel (zonder -- ervoor). Ze worden bij elke engine toegepast na de ingebouwde opties en worden van kracht wanneer de volgende Embedded MPV-sessie start.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Voorbeeld: hwdec=auto-safe. Opties waarvan de ingebedde speler zelf afhankelijk is (wid, vo, input-ipc-server, …) worden geweigerd.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Geen key=value-regel: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Deze opties kunnen niet worden gewijzigd voor de ingebedde speler: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Weggevallen streams automatisch opnieuw verbinden",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wanneer een stream tijdens het afspelen wegvalt, laadt de ingebedde speler deze opnieuw met steeds langere tussenpozen, tot maximaal 6 pogingen, voordat hij het opgeeft. Voor een stream die nooit is gestart, blijft handmatig Opnieuw proberen nodig.",
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
},
@@ -520,6 +527,8 @@
"RETRY": "Opnieuw proberen",
"STALLED": "Deze stream doet er langer over dan verwacht om te starten.",
"PLAYBACK_FAILED": "Afspelen via Embedded MPV is mislukt.",
"RECONNECTING": "Verbinding verbroken. Opnieuw verbinden… poging {{attempt}} van {{maxAttempts}}",
"STREAM_ENDED": "De live stream is beëindigd.",
"CHECKING_SUPPORT": "Ondersteuning voor Embedded MPV controleren...",
"NOT_AVAILABLE": "Embedded MPV is niet beschikbaar in deze omgeving.",
"LOADING_STREAM": "Stream laden…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Dodatkowe opcje wbudowanego MPV",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Zaawansowane opcje libmpv dla wbudowanego odtwarzacza, jedna para key=value w każdym wierszu (bez początkowego --). Są stosowane w każdym silniku po opcjach wbudowanych i zaczynają działać przy uruchomieniu następnej sesji wbudowanego MPV.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Przykład: hwdec=auto-safe. Opcje, od których zależy samo osadzenie (wid, vo, input-ipc-server, …), są odrzucane.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "To nie jest wiersz key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Tych opcji nie można zmienić dla wbudowanego odtwarzacza: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Automatycznie wznawiaj przerwane strumienie",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Gdy odtwarzany strumień zostanie przerwany, wbudowany odtwarzacz wczytuje go ponownie z rosnącymi odstępami, maksymalnie 6 prób, zanim się podda. Strumień, który w ogóle się nie uruchomił, nadal wymaga ręcznego użycia „Spróbuj ponownie”.",
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
},
@@ -520,6 +527,8 @@
"RETRY": "Spróbuj ponownie",
"STALLED": "Uruchamianie tego strumienia trwa dłużej niż zwykle.",
"PLAYBACK_FAILED": "Odtwarzanie we wbudowanym MPV nie powiodło się.",
"RECONNECTING": "Utracono połączenie. Ponowne łączenie… próba {{attempt}} z {{maxAttempts}}",
"STREAM_ENDED": "Transmisja na żywo zakończyła się.",
"CHECKING_SUPPORT": "Sprawdzanie obsługi wbudowanego MPV...",
"NOT_AVAILABLE": "Wbudowane MPV nie jest dostępne w tym środowisku.",
"LOADING_STREAM": "Ładowanie strumienia…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Opções extras do MPV integrado",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Opções avançadas do libmpv para o reprodutor integrado, um par key=value por linha (sem -- no início). São aplicadas em todos os motores após as opções internas e entram em vigor quando a próxima sessão do MPV integrado iniciar.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Exemplo: hwdec=auto-safe. Opções das quais o próprio reprodutor integrado depende (wid, vo, input-ipc-server, …) são recusadas.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Não está no formato key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Estas opções não podem ser alteradas para o reprodutor integrado: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar streams interrompidos automaticamente",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando um stream em reprodução é interrompido, o reprodutor integrado o recarrega com intervalos crescentes, em até 6 tentativas, antes de desistir. Um stream que nunca chegou a iniciar ainda precisa de um \"Tentar novamente\" manual.",
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
},
@@ -520,6 +527,8 @@
"RETRY": "Tentar novamente",
"STALLED": "Este stream está demorando mais do que o esperado para iniciar.",
"PLAYBACK_FAILED": "Falha na reprodução do MPV integrado.",
"RECONNECTING": "Conexão perdida. Reconectando… tentativa {{attempt}} de {{maxAttempts}}",
"STREAM_ENDED": "A transmissão ao vivo terminou.",
"CHECKING_SUPPORT": "Verificando suporte ao MPV integrado...",
"NOT_AVAILABLE": "O MPV integrado não está disponível neste ambiente.",
"LOADING_STREAM": "Carregando stream…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Дополнительные параметры встроенного MPV",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Расширенные параметры libmpv для встроенного плеера, по одной паре key=value на строку (без -- в начале). Они применяются на любом движке после встроенных параметров и вступают в силу при следующем запуске воспроизведения во встроенном MPV.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Пример: hwdec=auto-safe. Параметры, от которых зависит сам встроенный плеер (wid, vo, input-ipc-server, …), отклоняются.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "Не в формате key=value: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Эти параметры нельзя изменять для встроенного плеера: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Автоматически переподключаться при обрыве потока",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Если поток обрывается во время воспроизведения, встроенный плеер перезагружает его с возрастающими задержками (до 6 попыток), после чего прекращает переподключение. Для потока, который так и не запустился, по-прежнему нужно нажать «Повторить» вручную.",
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
},
@@ -520,6 +527,8 @@
"RETRY": "Повторить",
"STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.",
"PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.",
"RECONNECTING": "Подключение потеряно. Переподключение… попытка {{attempt}} из {{maxAttempts}}",
"STREAM_ENDED": "Прямой эфир завершился.",
"CHECKING_SUPPORT": "Проверка поддержки встроенного MPV...",
"NOT_AVAILABLE": "Встроенный MPV недоступен в этой среде.",
"LOADING_STREAM": "Загрузка потока…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.",
"EMBEDDED_MPV_EXTRA_OPTIONS": "Gömülü MPV ek seçenekleri",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "Gömülü oynatıcı için gelişmiş libmpv seçenekleri; her satıra bir key=value çifti girin (başında -- olmadan). Tüm motorlarda yerleşik seçeneklerden sonra uygulanır ve bir sonraki gömülü MPV oturumu başladığında geçerli olur.",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "Örnek: hwdec=auto-safe. Gömülü oynatıcının kendisinin bağlı olduğu seçenekler (wid, vo, input-ipc-server, …) reddedilir.",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "key=value biçiminde olmayan satır: {{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "Bu seçenekler gömülü oynatıcı için değiştirilemez: {{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "Kesilen yayınlara otomatik yeniden bağlan",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Oynatılan bir yayın kesildiğinde gömülü oynatıcı, vazgeçmeden önce yayını giderek artan gecikmelerle en fazla 6 kez yeniden yüklemeyi dener. Hiç başlamamış bir yayın için yine de Tekrar dene’ye el ile basmanız gerekir.",
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
},
@@ -520,6 +527,8 @@
"RETRY": "Tekrar dene",
"STALLED": "Bu yayının başlaması beklenenden uzun sürüyor.",
"PLAYBACK_FAILED": "Gömülü MPV oynatması başarısız oldu.",
"RECONNECTING": "Bağlantı kesildi. Yeniden bağlanılıyor… deneme {{attempt}}/{{maxAttempts}}",
"STREAM_ENDED": "Canlı yayın sona erdi.",
"CHECKING_SUPPORT": "Gömülü MPV desteği denetleniyor...",
"NOT_AVAILABLE": "Gömülü MPV bu ortamda kullanılamıyor.",
"LOADING_STREAM": "Yayın yükleniyor…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。",
"EMBEDDED_MPV_EXTRA_OPTIONS": "嵌入式 MPV 附加选项",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "嵌入式播放器的高级 libmpv 选项,每行一个 key=value 键值对(开头不加 --)。这些选项会在所有引擎中于内置选项之后应用,并在下一次嵌入式 MPV 会话启动时生效。",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "示例:hwdec=auto-safe。嵌入本身依赖的选项(wid、vo、input-ipc-server 等)会被拒绝。",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "不是 key=value 格式的行:{{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "嵌入式播放器无法更改这些选项:{{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "自动重连中断的流",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "正在播放的流中断时,嵌入式播放器会以逐渐增加的间隔重新加载,最多尝试 6 次后放弃。从未成功启动的流仍需手动重试。",
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
},
@@ -520,6 +527,8 @@
"RETRY": "重试",
"STALLED": "该流的启动时间超出预期。",
"PLAYBACK_FAILED": "嵌入式 MPV 播放失败。",
"RECONNECTING": "连接已丢失。正在重新连接… 第 {{attempt}} 次尝试,共 {{maxAttempts}} 次",
"STREAM_ENDED": "直播已结束。",
"CHECKING_SUPPORT": "正在检查嵌入式 MPV 支持…",
"NOT_AVAILABLE": "嵌入式 MPV 在当前环境中不可用。",
"LOADING_STREAM": "正在加载流…",
+9
View File
@@ -497,6 +497,13 @@
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。",
"EMBEDDED_MPV_EXTRA_OPTIONS": "內嵌 MPV 額外選項",
"EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION": "內嵌播放器的進階 libmpv 選項,每行一組 key=value(開頭不加 --)。這些選項會在所有引擎中於內建選項之後套用,並在下一次內嵌 MPV 工作階段啟動時生效。",
"EMBEDDED_MPV_EXTRA_OPTIONS_HINT": "範例:hwdec=auto-safe。內嵌本身所依賴的選項(wid、vo、input-ipc-server 等)會被拒絕。",
"EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES": "不是 key=value 格式的行:{{lines}}",
"EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS": "內嵌播放器無法變更這些選項:{{keys}}",
"EMBEDDED_MPV_AUTO_RECONNECT": "自動重新連線中斷的串流",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "播放中的串流中斷時,內嵌播放器會以逐漸拉長的間隔重新載入,最多嘗試 6 次後放棄。從未成功啟動的串流仍需手動重試。",
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
},
@@ -520,6 +527,8 @@
"RETRY": "重試",
"STALLED": "此串流的啟動時間比預期長。",
"PLAYBACK_FAILED": "內嵌 MPV 播放失敗。",
"RECONNECTING": "連線中斷。正在重新連線… 第 {{attempt}} 次嘗試,共 {{maxAttempts}} 次",
"STREAM_ENDED": "現場直播已結束。",
"CHECKING_SUPPORT": "正在檢查內嵌 MPV 支援…",
"NOT_AVAILABLE": "內嵌 MPV 在此環境中無法使用。",
"LOADING_STREAM": "正在載入串流…",
+137
View File
@@ -511,6 +511,143 @@ button disabled state at current-season boundaries.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
## Session Options (Extra libmpv Options)
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
one `key=value` per line without the leading `--`. The renderer keeps the
canonical text (`normalizeEmbeddedMpvExtraOptions`) and refuses to save
malformed lines or the keys the embed depends on
(`EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS`: `wid`, `vo`, `force-window`,
`input-ipc-server`, `idle`, `keep-open`, `config`, `include`, `terminal`);
the `SETTINGS_UPDATE` handler mirrors the text into the main-process config
(`EMBEDDED_MPV_EXTRA_OPTIONS`), because sessions are created there.
At session creation the IPC handler reads the mirror through
`readEmbeddedMpvSessionOptions()` and passes
`resolveEmbeddedMpvSessionOptionArguments()` to
`EmbeddedMpvNativeService.createSession(..., options)`. That list is the
network defaults followed by the user's allowed lines, and every engine
applies it after its own built-in options and before `mpv_initialize`, so a
user line overrides both:
| Engine | Transport |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Windows native-view | `createSession(..., string[])` → `mpv_set_option_string` |
| macOS native-view | same, in `embedded_mpv.mm` |
| Linux native-view | written to a user-only (0600) config file under `userData/embedded-mpv/options-<pid>/` and referenced as `--include=<path>` on the `mpv --wid` command line (before the per-playback options, so a playlist's user-agent/headers still win); the file is removed on dispose, the instance directory on shutdown, and another instance's leftovers only once its process is gone (two instances may share one `userData`) |
| Frame-copy helper | the first stdin line (`mpv-options`, helper started with `--mpv-options-stdin`), applied after the helper's built-in block |
The list never appears on a command line: an option such as
`http-header-fields=Authorization: …` would otherwise be readable by every
local user through `ps` / `/proc/<pid>/cmdline`.
`EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS` (`network-timeout=10`,
`demuxer-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_delay_max=5`) are
part of that list on purpose: a stalled IPTV connection then surfaces as an
mpv error within seconds instead of libmpv's 60 s default, and ffmpeg
re-requests a dropped HTTP stream on its own before the app-level reconnect
has to. Options mpv rejects never fail session creation, and a rejection is
reported without any trace flag, by key only (a value may carry credentials):
the Windows and macOS native-view engines collect the refused keys into the
snapshot's `rejectedOptionKeys`, which the service logs once per session as a
main-process warning; the frame-copy helper emits a `log` warn event with
prefix `iptvnator` that the adapter forwards unconditionally. The Linux
native-view path is the exception: mpv skips a bad `--include` line and
continues, but reports it only on its own stderr, which the addon discards
(`--really-quiet`; `IPTVNATOR_TRACE_EMBEDDED_MPV` writes it to
`/tmp/iptvnator-embedded-mpv.log`). The service itself never
touches the config store — it is constructed at module load, and importing it
would drag electron-conf into every consumer of the service, its unit tests
included.
## Network Auto-Reconnect
`EmbeddedMpvReconnectCoordinator`
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
the last user-requested playback when a session reports a stream loss: an
`error` status, or `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses). It is driven by
`refreshSession()` status transitions, so it covers every engine, and it is
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). 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 instead of hammering the panel six times with the same request. The
Linux `mpv --wid` path keeps a freshly spawned process at `loading` until
its poller sees a decoded `time-pos`, so a URL that never opens is never
counted as played there either; the poller also reads `eof-reached` over
the IPC socket so a stream that ended reports `ended` rather than the
`paused` that keep-open would otherwise look like, and an mpv process that
exits abnormally reports `error`.
- 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
stream that flaps every few seconds runs out of attempts instead of being
retried forever.
- A user-driven `loadPlayback`, `paused`/`idle`, dispose, and shutdown cancel
a pending attempt; a stream that recovers on its own (ffmpeg-level
reconnect) while a retry is pending drops the retry. A pause also disarms
the policy until the stream plays again, so a drop while the user has the
stream paused shows the error instead of resuming playback unasked.
- Each attempt is tracked from the moment its reload is issued, so a reload
that fails before the 500 ms poll ever observes it `loading` still counts
as a failed attempt and schedules the next one.
- An `error` the engine attributes to itself — `errorOrigin: 'engine'` in
the native snapshot: a fatal libmpv log in the frame-copy helper, a dead
helper, a macOS render-context failure — is not a stream loss. Reloading
media cannot repair a broken engine, so it stays the terminal error with
Retry; only stream-side errors (load and `END_FILE` failures, the Linux
process exiting) are retried.
- A reload the engine cannot take at all — a frame-copy helper that has
exited (exit code, signal death, or a closed stdin), reported by the
adapter as `EmbeddedMpvSessionGoneError` — ends the
reconnect immediately: only a new session can recover, and the renderer's
Retry creates one, so the actionable error is shown instead of a reconnect
spinner that nothing could ever advance.
- Any other refused reload (the addon throws) continues the backoff itself,
because no status transition will ever arrive for it.
- A non-live reload carries the last position observed while playing as
`startTime` (seeded from the playback's own `startTime`), so a movie or
episode resumes where the connection dropped instead of at the offset the
user originally resumed from; a reported zero counts only after a positive
position was seen, because engine snapshots start at zero before the first
`time-pos`. Live reloads go back to the live edge.
- Every reload is followed by `setPaused(false)`: sessions run with
`keep-open=yes`, so EOF leaves mpv paused at the end of the old file and a
plain `loadfile` inherits that pause — the reloaded live stream would
buffer, report `paused` and never play. The engines apply that pause
toggle optimistically only while already `playing`/`paused`, so the
reload stays `loading` until the media actually opens and the attempt
indicator is not cleared early.
Every engine flips to `loading` synchronously on a load (the frame-copy
adapter does so optimistically before the helper confirms), which is what
lets a failed attempt always be observed as `loading` → loss and never as
loss → loss. While an attempt is scheduled or in flight the session carries
`EmbeddedMpvSession.reconnect` (`attempt`, `maxAttempts`, `nextAttemptAt`);
the renderer shows "Reconnecting… attempt N of M" in the error overlay and,
for shared controls, as a `loading` status message, keeps Retry available
(it disposes the session, which cancels the timer), and never schedules
anything itself. A live stream that sits at `ended` with no reconnect in
progress (the budget is spent, or the setting is off) is shown as "The live
stream ended." with Retry — for shared controls as an `error` status — since
the plain `ended` state otherwise looks like a paused player. A recording that
was running when the stream dropped is finalized by the recording tracker as
an interrupted partial at the moment the reload replaces the stream (which
stops `stream-record`; the tracker's `onRecordingInterrupted` keeps that
verdict even if the engine shows the old recording as active for one more
tick), and once the reload plays the service starts it again into a fresh
file with the same folder, title and metadata, so the manager lists both
parts. A stream that recovers by itself before the reload fires keeps its
recording running untouched; an explicit stop or a user-driven load never
restarts anything. An external subtitle file added through `sub-add` is
dropped by the reload as well (the engines clear their tracks on
`START_FILE`), so the service remembers the last added file and re-adds it
once the reload plays — the helper's `sub-add` selects the added track, and
`sub-delay` is an mpv-global property that survives on its own. An explicit
subtitle-track pick after the add, a user-driven load or a missing file hand
the selection back to the user and restore nothing.
## Live Stream Recording
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for playback classified as live (`ResolvedPortalPlayback.isLive` when present, otherwise no `contentInfo`); VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
@@ -137,6 +137,24 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().webPlayerSharedControls).toBe(false);
});
it('defaults embedded MPV auto-reconnect to true when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().embeddedMpvAutoReconnect).toBe(true);
});
it('restores a persisted false embedded MPV auto-reconnect opt-out', async () => {
storedSettings = { embeddedMpvAutoReconnect: false };
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().embeddedMpvAutoReconnect).toBe(false);
});
it('defaults strip country prefix to false when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
@@ -54,6 +54,8 @@ const DEFAULT_SETTINGS: Settings = {
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
coverSize: 'medium',
epgViewMode: 'timeline',
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
@@ -158,6 +160,9 @@ export const SettingsStore = signalStore(
webPlayerSharedControls:
storedSettings.webPlayerSharedControls !==
false,
embeddedMpvAutoReconnect:
storedSettings.embeddedMpvAutoReconnect !==
false,
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
@@ -192,6 +197,12 @@ export const SettingsStore = signalStore(
settings.webPlayerSharedControls !== false,
}
: {}),
...(settings.embeddedMpvAutoReconnect !== undefined
? {
embeddedMpvAutoReconnect:
settings.embeddedMpvAutoReconnect !== false,
}
: {}),
...(settings.dashboardRails !== undefined
? {
dashboardRails: normalizeDashboardRailsSettings(
@@ -269,6 +280,10 @@ export const SettingsStore = signalStore(
DEFAULT_SETTINGS.recordingFolder,
embeddedMpvFrameCopy:
store.embeddedMpvFrameCopy?.() ?? false,
embeddedMpvExtraOptions:
store.embeddedMpvExtraOptions?.() ?? '',
embeddedMpvAutoReconnect:
store.embeddedMpvAutoReconnect?.() !== false,
coverSize:
store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
epgViewMode:
+1
View File
@@ -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 */
@@ -355,3 +355,17 @@ describe('redactSensitiveData', () => {
});
});
});
describe('redactSensitiveData embedded MPV options', () => {
it('redacts the whole extra-options text, which can carry request headers', () => {
const secret = 'embedded-mpv-header-secret';
const redacted = redactSensitiveData({
player: 'embedded-mpv',
embeddedMpvExtraOptions: `cache-secs=5\nhttp-header-fields=X-Playback-Key: ${secret}`,
}) as Record<string, unknown>;
expect(JSON.stringify(redacted)).not.toContain(secret);
expect(redacted['player']).toBe('embedded-mpv');
});
});
@@ -15,6 +15,8 @@ const SENSITIVE_KEY_NAMES = new Set([
'credentials',
'deviceid',
'deviceid2',
// Free-form libmpv lines can carry request headers with credentials.
'embeddedmpvextraoptions',
'login',
'mac',
'macaddress',
@@ -33,11 +35,23 @@ const SENSITIVE_KEY_NAMES = new Set([
]);
const SENSITIVE_KEY_SUFFIXES = [
'apikey', 'authorization', 'cookie',
'deviceid', 'deviceid1', 'deviceid2',
'macaddress', 'passwd', 'password', 'prehash',
'serialnumber', 'signature', 'signature1', 'signature2',
'secret', 'token', 'username',
'apikey',
'authorization',
'cookie',
'deviceid',
'deviceid1',
'deviceid2',
'macaddress',
'passwd',
'password',
'prehash',
'serialnumber',
'signature',
'signature1',
'signature2',
'secret',
'token',
'username',
];
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
@@ -160,25 +174,20 @@ function redactUrlStrings(
value: string,
sanitizeValue: (value: string) => string
): string {
return value.replace(
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
(candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
return value.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu, (candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
);
});
}
function redactEmbeddedSensitivePairs(value: string): string {
const redactedAssignments = value.replace(
/\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu,
(match, key: string, separator: string) =>
isSensitiveKey(key)
? `${key}${separator}${REDACTED_VALUE}`
: match
isSensitiveKey(key) ? `${key}${separator}${REDACTED_VALUE}` : match
);
return redactedAssignments.replace(
/\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu,
@@ -286,9 +295,10 @@ export function redactSensitiveData(
if (error.stack) {
const [, ...stackFrames] = error.stack.split('\n');
output['stack'] = visitString(
[`${output['name']}: ${output['message']}`, ...stackFrames].join(
'\n'
),
[
`${output['name']}: ${output['message']}`,
...stackFrames,
].join('\n'),
depth + 1
);
}
@@ -370,8 +380,7 @@ export function redactSensitiveData(
const stringKey = String(key);
const redactedKey = visitString(stringKey, depth + 1);
entries[redactedKey] =
isSensitiveKey(stringKey) &&
!/[/:?=&]/u.test(stringKey)
isSensitiveKey(stringKey) && !/[/:?=&]/u.test(stringKey)
? REDACTED_VALUE
: visit(entry, depth + 1);
}
@@ -127,6 +127,8 @@ function translations(prefix = ''): object {
EMBEDDED_MPV: {
PLAYER: {
PLAYBACK_FAILED: `${prefix}Playback failed`,
RECONNECTING: `${prefix}Reconnecting {{attempt}}/{{maxAttempts}}`,
STREAM_ENDED: `${prefix}Live stream ended`,
CHECKING_SUPPORT: `${prefix}Checking support`,
NOT_AVAILABLE: `${prefix}Not available`,
LOADING_STREAM: `${prefix}Loading stream`,
@@ -384,6 +386,30 @@ describe('EmbeddedMpvControlsAdapter', () => {
controller.session.set(session({ status: 'error', error: undefined }));
expect(adapter.state().statusMessage).toBe('Playback failed');
playback.set({ ...LIVE_PLAYBACK, isLive: true });
controller.session.set(session({ status: 'ended' }));
expect(adapter.state()).toMatchObject({
status: 'error',
statusMessage: 'Live stream ended',
});
playback.set({ ...VOD_PLAYBACK, isLive: false });
controller.session.set(
session({
status: 'error',
error: 'Decoder exploded',
reconnect: {
attempt: 3,
maxAttempts: 6,
nextAttemptAt: '2026-09-03T10:00:08Z',
},
})
);
expect(adapter.state()).toMatchObject({
status: 'loading',
statusMessage: 'Reconnecting 3/6',
});
});
it('delegates every non-recording shared-controls command', () => {
@@ -119,8 +119,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
volume: true,
audioTracks: true,
subtitles: optionalCapabilities?.subtitles ?? false,
externalSubtitles:
optionalCapabilities?.externalSubtitles ?? false,
externalSubtitles: optionalCapabilities?.externalSubtitles ?? false,
subtitleDelay: optionalCapabilities?.subtitleDelay ?? false,
subtitleStyle: optionalCapabilities?.subtitleStyle ?? false,
playbackSpeed: optionalCapabilities?.playbackSpeed ?? false,
@@ -307,6 +306,21 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
};
}
if (session?.reconnect) {
// A dropped stream the main process is reloading: a spinner with
// the attempt count reads better than the underlying mpv error.
return {
status: 'loading',
statusMessage: this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECONNECTING',
{
attempt: session.reconnect.attempt,
maxAttempts: session.reconnect.maxAttempts,
}
),
};
}
if (!session || session.status === 'loading') {
return {
status: 'loading',
@@ -316,6 +330,12 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
};
}
const statusPlayback = this.configuredContext()?.playback();
const liveEnded =
session.status === 'ended' &&
statusPlayback !== undefined &&
this.isLivePlayback(statusPlayback);
switch (session.status) {
case 'playing':
case 'paused':
@@ -323,6 +343,16 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
return { status: session.status, statusMessage: '' };
case 'ended':
case 'closed':
if (liveEnded) {
// A broadcast never ends on its own: surface it like an
// error so the shared controls offer a way back.
return {
status: 'error',
statusMessage: this.translate.instant(
'EMBEDDED_MPV.PLAYER.STREAM_ENDED'
),
};
}
return { status: 'ended', statusMessage: '' };
case 'error':
return {
@@ -31,13 +31,16 @@
data-embedded-mpv-frame
></canvas>
}
@if (isErrored()) {
@if (isErrored() || isReconnecting() || isLiveEnded()) {
<div
class="embedded-mpv-player__stalled"
role="alert"
aria-live="assertive"
[attr.data-reconnecting]="isReconnecting() ? '' : null"
>
<mat-icon>error_outline</mat-icon>
<mat-icon>{{
isReconnecting() ? 'sync' : 'error_outline'
}}</mat-icon>
<p>{{ statusLabel() }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
@@ -215,12 +218,10 @@
[disabled]="!canSeek()"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
matTooltipPosition="above"
>
@@ -330,9 +331,7 @@
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="
volume() * 100
"
[style.--slider-progress.%]="volume() * 100"
type="range"
min="0"
max="1"
@@ -367,4 +367,67 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
).toBeNull();
expect(fixture.nativeElement.textContent).toContain('--:--');
});
it('offers Retry when a live stream ends without a reconnect in progress', () => {
fixture.componentInstance.playback = {
...fixture.componentInstance.playback,
contentInfo: undefined,
isLive: true,
};
fixture.detectChanges();
controller.session.update((session) =>
session ? { ...session, status: 'ended' } : session
);
fixture.detectChanges();
const overlay = fixture.debugElement.query(
By.css('.embedded-mpv-player__stalled')
);
expect(overlay).not.toBeNull();
expect(overlay.nativeElement.textContent).toContain(
'EMBEDDED_MPV.PLAYER.STREAM_ENDED'
);
expect(overlay.query(By.css('button'))).not.toBeNull();
});
it('shows the reconnect attempt instead of the mpv error while the main process retries', () => {
controller.session.update((session) =>
session
? {
...session,
status: 'error',
error: 'Connection reset by peer',
reconnect: {
attempt: 2,
maxAttempts: 6,
nextAttemptAt: '2026-06-06T12:00:04Z',
},
}
: session
);
fixture.detectChanges();
const overlay = fixture.debugElement.query(
By.css('.embedded-mpv-player__stalled[data-reconnecting]')
);
expect(overlay).not.toBeNull();
expect(overlay.nativeElement.textContent).toContain(
'EMBEDDED_MPV.PLAYER.RECONNECTING'
);
expect(overlay.nativeElement.textContent).not.toContain(
'Connection reset by peer'
);
expect(overlay.query(By.css('button'))).not.toBeNull();
controller.session.update((session) =>
session
? { ...session, status: 'playing', reconnect: undefined }
: session
);
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__stalled'))
).toBeNull();
});
});
@@ -173,6 +173,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
);
readonly isPlaying = computed(() => this.session()?.status === 'playing');
readonly isErrored = computed(() => this.session()?.status === 'error');
/**
* The main process is waiting to reload a dropped stream or has the
* reload in flight (see embedded-mpv-reconnect.ts); shown like an error
* with a countdown-free "attempt N of M" line, Retry still available.
*/
readonly reconnectInfo = computed(() => this.session()?.reconnect ?? null);
readonly isReconnecting = computed(() => this.reconnectInfo() !== null);
readonly isLivePlayback = computed(() => {
const playback = this.playback();
if (typeof playback.isLive === 'boolean') {
@@ -181,6 +188,14 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
return !playback.contentInfo;
});
/**
* A live stream that ended and is not being reconnected: a broadcast
* never ends on its own, so this is a loss the viewer must be able to
* act on — the `ended` state otherwise looks like a paused player.
*/
readonly isLiveEnded = computed(
() => this.isLivePlayback() && this.session()?.status === 'ended'
);
readonly canSeek = computed(
() =>
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
@@ -194,12 +209,21 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly statusLabel = computed(() => {
this.translationsTick();
const session = this.session();
if (session?.reconnect) {
return this.translate.instant('EMBEDDED_MPV.PLAYER.RECONNECTING', {
attempt: session.reconnect.attempt,
maxAttempts: session.reconnect.maxAttempts,
});
}
if (session?.status === 'error') {
return (
session.error ??
this.translate.instant('EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED')
);
}
if (session?.status === 'ended' && this.isLivePlayback()) {
return this.translate.instant('EMBEDDED_MPV.PLAYER.STREAM_ENDED');
}
if (!this.support()) {
return this.translate.instant(
'EMBEDDED_MPV.PLAYER.CHECKING_SUPPORT'