/* * iptvnator-mpv-helper — frame-copy embedded MPV helper process * (macOS + Linux + Windows; platform GL context in frame_helper_gl.h). * * One process = one playback session. Owns libmpv end to end: decodes, * renders offscreen at viewport size, publishes BGRA frames into a shared * memory ring (frame_shm.h), and plays audio directly through the OS. * * Control protocol: tab-separated commands on stdin, JSON events on stdout * (see frame_helper_io.h). The `snapshot` event mirrors the TypeScript * NativeEmbeddedMpvSessionSnapshot shape so the Electron-side adapter can * cache it verbatim. Status semantics are ported from embedded_mpv.mm: * END_FILE reason mapping, eof-reached => ended (keep-open), pause flag * gated on a loaded path, only fatal/load errors flip the status. * * Usage: * iptvnator_mpv_helper --shm-base /impv- --width 1280 --height 720 * [--volume 0..1] [--hwdec auto] * [--mpv-options-stdin] */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "frame_helper_io.h" #include "frame_helper_render.h" namespace { using frame_helper::Command; using frame_helper::emitLine; using frame_helper::GlContext; using frame_helper::JsonWriter; using frame_helper::RenderPipeline; struct TrackInfo { int64_t id = -1; std::string title; std::string language; bool defaultTrack = false; bool forced = false; }; struct SnapshotState { std::string status = "idle"; double positionSeconds = 0; double durationSeconds = -1; /* <0 => null */ double volume = 1; /* 0..1 */ int64_t videoWidth = 0; /* mpv dwidth/dheight; 0 = unknown */ int64_t videoHeight = 0; /* Stream diagnostics for the player's info popover. Sentinels mean * "mpv has not answered yet" so a row is omitted rather than shown as 0. */ double fps = 0; /* estimated-vf-fps; <=0 = unknown */ double videoBitrate = 0; /* bits/s; <=0 = unknown */ double audioBitrate = 0; std::string videoCodec; /* video-format, e.g. "h264" */ std::string audioCodec; /* audio-codec-name, e.g. "aac" */ std::string audioChannels; /* audio-params/channels, e.g. "5.1" */ int64_t audioSampleRate = 0; /* audio-params/samplerate; 0 unknown */ std::string container; /* file-format, e.g. "mpegts" */ double cacheDuration = -1; /* demuxer-cache-duration; <0 = unknown */ int64_t droppedFrames = -1; /* frame-drop-count; <0 = unknown */ int64_t decoderDroppedFrames = -1; // MPV_FORMAT_NONE revokes just this observation, not unrelated values. bool clearUnavailableStreamProperty(const std::string& name) { if (name == "estimated-vf-fps") { fps = 0; } else if (name == "video-bitrate") { videoBitrate = 0; } else if (name == "audio-bitrate") { audioBitrate = 0; } else if (name == "video-format") { videoCodec.clear(); } else if (name == "audio-codec-name") { audioCodec.clear(); } else if (name == "audio-params/channels") { audioChannels.clear(); } else if (name == "audio-params/samplerate") { audioSampleRate = 0; } else if (name == "file-format") { container.clear(); } else if (name == "demuxer-cache-duration") { cacheDuration = -1; } else if (name == "frame-drop-count") { droppedFrames = -1; } else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; } else if (name == "dwidth") { videoWidth = 0; } else if (name == "dheight") { videoHeight = 0; } else { return false; } return true; } /* Every new file starts from "nothing reported yet": the popover must * never show the previous stream's codec or bitrate. Kept next to the * fields so adding one cannot forget the reset. */ void clearStreamStats() { videoWidth = 0; videoHeight = 0; fps = 0; videoBitrate = 0; audioBitrate = 0; videoCodec.clear(); audioCodec.clear(); audioChannels.clear(); audioSampleRate = 0; container.clear(); cacheDuration = -1; droppedFrames = -1; decoderDroppedFrames = -1; } std::string streamUrl; std::vector audioTracks; int64_t selectedAudioTrackId = -1; std::vector subtitleTracks; int64_t selectedSubtitleTrackId = -1; double playbackSpeed = 1; std::string aspectOverride = "no"; bool recordingActive = false; std::string recordingTargetPath; 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 { mpv_handle* mpv = nullptr; RenderPipeline pipeline; std::mutex mutex; SnapshotState snapshot; bool paused = false; bool loadedPath = false; bool dirty = true; std::string lastEmittedStatus; uint64_t lastEmitNs = 0; /* Requested viewport in device pixels; the render target is the * aspect-fit of the video inside it so letterbox bars are never * rendered (and frames stay as small as possible). */ int viewportWidth = 0; int viewportHeight = 0; std::atomic running{true}; }; HelperState g_state; constexpr uint64_t SNAPSHOT_EMIT_INTERVAL_NS = 250ull * 1000 * 1000; std::string isoTimestampNow() { char buffer[32]; const time_t now = time(nullptr); struct tm utc; #if defined(_WIN32) gmtime_s(&utc, &now); #else gmtime_r(&now, &utc); #endif strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc); return buffer; } std::string tracksJson(const std::vector& tracks, int64_t selectedId) { std::string out = "["; bool first = true; for (const TrackInfo& track : tracks) { JsonWriter writer; writer.num("id", (double)track.id); if (!track.title.empty()) writer.str("title", track.title); if (!track.language.empty()) writer.str("language", track.language); writer.boolean("selected", track.id == selectedId); writer.boolean("defaultTrack", track.defaultTrack); writer.boolean("forced", track.forced); if (!first) out += ','; first = false; out += writer.finish(); } out += ']'; return out; } /* Compose the `stats` object of the snapshot event. Always emitted, even when * empty: the adapter merges snapshot events field by field, so an omitted key * would leave the previous stream's numbers in place across a channel switch. * Caller holds g_state.mutex. */ std::string composeStatsJsonLocked() { const SnapshotState& s = g_state.snapshot; JsonWriter stats; if (s.fps > 0) { stats.num("fps", s.fps); } if (s.videoBitrate > 0) { stats.num("videoBitrateBps", s.videoBitrate); } if (s.audioBitrate > 0) { stats.num("audioBitrateBps", s.audioBitrate); } if (!s.videoCodec.empty()) { stats.str("videoCodec", s.videoCodec); } if (!s.audioCodec.empty()) { stats.str("audioCodec", s.audioCodec); } if (!s.audioChannels.empty()) { stats.str("audioChannels", s.audioChannels); } if (s.audioSampleRate > 0) { stats.num("audioSampleRateHz", (double)s.audioSampleRate); } if (!s.container.empty()) { stats.str("container", s.container); } if (s.cacheDuration >= 0) { stats.num("bufferedAheadSeconds", s.cacheDuration); } if (s.droppedFrames >= 0 || s.decoderDroppedFrames >= 0) { /* mpv counts render-time and decoder drops separately; the popover * shows the one number a viewer cares about. */ const int64_t dropped = std::max(0, s.droppedFrames) + std::max(0, s.decoderDroppedFrames); stats.num("droppedFrames", (double)dropped); } return stats.finish(); } /* Compose the snapshot event. Caller holds g_state.mutex. */ std::string composeSnapshotLocked() { const SnapshotState& s = g_state.snapshot; JsonWriter writer; writer.str("event", "snapshot"); writer.str("status", s.status); writer.num("positionSeconds", std::max(0.0, s.positionSeconds)); // Snapshots merge in main; publish zero so an old size cannot survive. writer.num("videoWidth", (double)s.videoWidth); writer.num("videoHeight", (double)s.videoHeight); if (s.durationSeconds >= 0) { writer.num("durationSeconds", s.durationSeconds); } else { writer.nullValue("durationSeconds"); } writer.num("volume", std::clamp(s.volume, 0.0, 1.5)); writer.str("streamUrl", s.streamUrl); writer.raw("audioTracks", tracksJson(s.audioTracks, s.selectedAudioTrackId)); if (s.selectedAudioTrackId >= 0) { writer.num("selectedAudioTrackId", (double)s.selectedAudioTrackId); } else { writer.nullValue("selectedAudioTrackId"); } writer.raw("subtitleTracks", tracksJson(s.subtitleTracks, s.selectedSubtitleTrackId)); if (s.selectedSubtitleTrackId >= 0) { writer.num("selectedSubtitleTrackId", (double)s.selectedSubtitleTrackId); } else { writer.nullValue("selectedSubtitleTrackId"); } writer.num("playbackSpeed", s.playbackSpeed); writer.str("aspectOverride", s.aspectOverride); writer.raw("stats", composeStatsJsonLocked()); JsonWriter recording; recording.boolean("active", s.recordingActive); if (!s.recordingTargetPath.empty()) recording.str("targetPath", s.recordingTargetPath); if (!s.recordingStartedAt.empty()) 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.engineError) writer.str("errorOrigin", "engine"); } return writer.finish(); } /* Emit the snapshot when dirty: immediately on status changes, otherwise at * most every SNAPSHOT_EMIT_INTERVAL_NS. Called from the mpv event thread. */ void maybeEmitSnapshot(bool force = false) { std::string line; { std::lock_guard lock(g_state.mutex); if (!g_state.dirty && !force) return; const uint64_t now = frame_helper::nowNs(); const bool statusChanged = g_state.snapshot.status != g_state.lastEmittedStatus; if (!force && !statusChanged && now - g_state.lastEmitNs < SNAPSHOT_EMIT_INTERVAL_NS) { return; } g_state.dirty = false; g_state.lastEmitNs = now; g_state.lastEmittedStatus = g_state.snapshot.status; line = composeSnapshotLocked(); } emitLine(line); } void markDirtyLocked() { g_state.dirty = true; } /* Aspect-fit of the current video inside the requested viewport. Caller * holds g_state.mutex. Letterbox bars are handled by the renderer layout, * not baked into frames. */ std::pair computeRenderSizeLocked() { const int vpW = g_state.viewportWidth; const int vpH = g_state.viewportHeight; const int64_t vw = g_state.snapshot.videoWidth; const int64_t vh = g_state.snapshot.videoHeight; if (vw < 16 || vh < 16 || vpW < 16 || vpH < 16) { return {vpW, vpH}; } const double scale = std::min(vpW / (double)vw, vpH / (double)vh); const int width = std::max(16, (int)llround((double)vw * scale)) & ~1; const int height = std::max(16, (int)llround((double)vh * scale)) & ~1; return {width, height}; } void applyRenderSizeLocked() { const auto [width, height] = computeRenderSizeLocked(); if (width >= 16 && height >= 16) { g_state.pipeline.requestResize(width, height); } } /* ---- track-list parsing (ported from embedded_mpv.mm) ------------------- */ void parseTrackNode(const mpv_node& trackNode, const char* wantedType, std::vector& out) { if (trackNode.format != MPV_FORMAT_NODE_MAP || !trackNode.u.list) return; TrackInfo track; bool matchesType = false; const mpv_node_list& map = *trackNode.u.list; for (int i = 0; i < map.num; i++) { const char* key = map.keys[i]; const mpv_node& value = map.values[i]; if (!key) continue; if (std::strcmp(key, "type") == 0 && value.format == MPV_FORMAT_STRING && value.u.string) { matchesType = std::strcmp(value.u.string, wantedType) == 0; } else if (std::strcmp(key, "id") == 0 && value.format == MPV_FORMAT_INT64) { track.id = value.u.int64; } else if (std::strcmp(key, "title") == 0 && value.format == MPV_FORMAT_STRING && value.u.string) { track.title = value.u.string; } else if (std::strcmp(key, "lang") == 0 && value.format == MPV_FORMAT_STRING && value.u.string) { track.language = value.u.string; } else if (std::strcmp(key, "default") == 0 && value.format == MPV_FORMAT_FLAG) { track.defaultTrack = value.u.flag != 0; } else if (std::strcmp(key, "forced") == 0 && value.format == MPV_FORMAT_FLAG) { track.forced = value.u.flag != 0; } } if (matchesType && track.id >= 0) { out.push_back(std::move(track)); } } void updateTracksFromNode(const mpv_node& trackListNode) { if (trackListNode.format != MPV_FORMAT_NODE_ARRAY || !trackListNode.u.list) { return; } std::vector audio; std::vector subs; for (int i = 0; i < trackListNode.u.list->num; i++) { parseTrackNode(trackListNode.u.list->values[i], "audio", audio); parseTrackNode(trackListNode.u.list->values[i], "sub", subs); } g_state.snapshot.audioTracks = std::move(audio); g_state.snapshot.subtitleTracks = std::move(subs); } bool parseTrackSelection(const char* value, int64_t& out) { if (!value) return false; char* end = nullptr; const long long parsed = strtoll(value, &end, 10); if (end == value || (end && *end != '\0')) return false; out = parsed; return true; } /* ---- mpv event loop (status semantics ported from embedded_mpv.mm) ------ */ void handlePropertyChange(const mpv_event_property& property) { const std::string name = property.name ? property.name : ""; SnapshotState& s = g_state.snapshot; if (property.format == MPV_FORMAT_NONE) { if (s.clearUnavailableStreamProperty(name)) { if (name == "dwidth" || name == "dheight") { applyRenderSizeLocked(); } markDirtyLocked(); } return; } if (name == "time-pos" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.positionSeconds = *static_cast(property.data); } else if (name == "duration" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.durationSeconds = *static_cast(property.data); } else if (name == "pause" && property.format == MPV_FORMAT_FLAG && property.data) { g_state.paused = *static_cast(property.data) != 0; if (s.status != "loading" && s.status != "ended" && s.status != "error" && g_state.loadedPath) { s.status = g_state.paused ? "paused" : "playing"; } } else if (name == "eof-reached" && property.format == MPV_FORMAT_FLAG && property.data) { if (*static_cast(property.data) != 0 && g_state.loadedPath) { s.status = "ended"; } } else if (name == "volume" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.volume = *static_cast(property.data) / 100.0; } else if (name == "path" && property.format == MPV_FORMAT_STRING && property.data) { const char* value = *static_cast(property.data); s.streamUrl = value ? value : ""; } else if (name == "track-list" && property.format == MPV_FORMAT_NODE && property.data) { updateTracksFromNode(*static_cast(property.data)); } else if (name == "aid" && property.format == MPV_FORMAT_STRING && property.data) { int64_t selected = -1; if (parseTrackSelection(*static_cast(property.data), selected)) { s.selectedAudioTrackId = selected; } else { s.selectedAudioTrackId = -1; } } else if (name == "sid" && property.format == MPV_FORMAT_STRING && property.data) { int64_t selected = -1; if (parseTrackSelection(*static_cast(property.data), selected)) { s.selectedSubtitleTrackId = selected; } else { s.selectedSubtitleTrackId = -1; } } else if (name == "speed" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.playbackSpeed = *static_cast(property.data); } else if ((name == "dwidth" || name == "dheight") && property.format == MPV_FORMAT_INT64 && property.data) { const int64_t value = *static_cast(property.data); if (name == "dwidth") { s.videoWidth = value; } else { s.videoHeight = value; } applyRenderSizeLocked(); } else if (name == "estimated-vf-fps" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.fps = *static_cast(property.data); } else if (name == "video-bitrate" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.videoBitrate = *static_cast(property.data); } else if (name == "audio-bitrate" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.audioBitrate = *static_cast(property.data); } else if (name == "demuxer-cache-duration" && property.format == MPV_FORMAT_DOUBLE && property.data) { s.cacheDuration = *static_cast(property.data); } else if (name == "audio-params/samplerate" && property.format == MPV_FORMAT_INT64 && property.data) { s.audioSampleRate = *static_cast(property.data); } else if (name == "frame-drop-count" && property.format == MPV_FORMAT_INT64 && property.data) { s.droppedFrames = *static_cast(property.data); } else if (name == "decoder-frame-drop-count" && property.format == MPV_FORMAT_INT64 && property.data) { s.decoderDroppedFrames = *static_cast(property.data); } else if ((name == "video-format" || name == "audio-codec-name" || name == "audio-params/channels" || name == "file-format") && property.format == MPV_FORMAT_STRING && property.data) { const char* value = *static_cast(property.data); const std::string text = value ? value : ""; if (name == "video-format") { s.videoCodec = text; } else if (name == "audio-codec-name") { s.audioCodec = text; } else if (name == "audio-params/channels") { s.audioChannels = text; } else if (name == "file-format") { s.container = text; } } else if (name == "video-aspect-override" && property.format == MPV_FORMAT_STRING && property.data) { const char* value = *static_cast(property.data); /* mpv reports the unset override as "-1.000000"; the renderer * contract uses "no" for that state. */ s.aspectOverride = value && *value && std::atof(value) > 0 ? value : "no"; } else { return; } markDirtyLocked(); } void runMpvEventLoop() { while (g_state.running.load()) { mpv_event* event = mpv_wait_event(g_state.mpv, 0.1); if (!event) continue; if (event->event_id == MPV_EVENT_NONE) { maybeEmitSnapshot(); continue; } { std::lock_guard lock(g_state.mutex); SnapshotState& s = g_state.snapshot; switch (event->event_id) { case MPV_EVENT_START_FILE: s.status = "loading"; s.error.clear(); s.engineError = false; s.clearStreamStats(); s.audioTracks.clear(); s.selectedAudioTrackId = -1; s.subtitleTracks.clear(); s.selectedSubtitleTrackId = -1; g_state.loadedPath = false; markDirtyLocked(); break; case MPV_EVENT_FILE_LOADED: g_state.loadedPath = true; s.status = g_state.paused ? "paused" : "playing"; markDirtyLocked(); break; case MPV_EVENT_END_FILE: { const auto* endFile = static_cast(event->data); if (endFile && endFile->reason == MPV_END_FILE_REASON_ERROR) { s.status = "error"; s.error = endFile->error < 0 ? mpv_error_string(endFile->error) : "Playback failed."; } else if (endFile && endFile->reason == MPV_END_FILE_REASON_EOF && g_state.running.load()) { s.status = "ended"; } else if (endFile && endFile->reason == MPV_END_FILE_REASON_REDIRECT && 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"; } markDirtyLocked(); break; } case MPV_EVENT_PROPERTY_CHANGE: { const auto* property = static_cast(event->data); if (property) handlePropertyChange(*property); break; } case MPV_EVENT_LOG_MESSAGE: { const auto* logMessage = static_cast(event->data); if (!logMessage || !logMessage->level || !logMessage->text) { break; } const std::string level = logMessage->level; if (level == "error" || level == "fatal") { s.error = logMessage->text; markDirtyLocked(); } if (level == "fatal") { s.status = "error"; s.engineError = true; } emitLine(JsonWriter() .str("event", "log") .str("level", level) .str("prefix", logMessage->prefix ? logMessage->prefix : "mpv") .str("text", logMessage->text) .finish()); break; } case MPV_EVENT_SHUTDOWN: g_state.running.store(false); s.status = "closed"; markDirtyLocked(); break; default: break; } } maybeEmitSnapshot(); } maybeEmitSnapshot(true); } /* ---- command handling ---------------------------------------------------- */ void setPropertyString(const char* name, const std::string& value) { mpv_set_property_string(g_state.mpv, name, value.c_str()); } void handleLoadCommand(const Command& command) { const std::string url = command.get("url"); if (url.empty()) return; /* Loading a replacement stream stops any active recording first, * mirroring the .mm behavior. */ { std::lock_guard lock(g_state.mutex); if (g_state.snapshot.recordingActive) { setPropertyString("stream-record", ""); g_state.snapshot.recordingActive = false; g_state.snapshot.recordingStartedAt.clear(); } g_state.snapshot.streamUrl = url; g_state.snapshot.error.clear(); g_state.snapshot.engineError = false; g_state.snapshot.status = "loading"; g_state.dirty = true; } /* Generic pass-through: every `opt.` argument becomes a loadfile * option, so the adapter controls start/user-agent/referrer/headers * without protocol changes. */ std::string optionString; for (const auto& [key, value] : command.args) { if (key.rfind("opt.", 0) != 0 || value.empty()) continue; if (!optionString.empty()) optionString += ','; std::string escaped = value; /* loadfile options string uses %len%value quoting to stay safe */ optionString += key.substr(4) + "=%" + std::to_string(escaped.size()) + "%" + escaped; } const char* args[] = {"loadfile", url.c_str(), "replace", optionString.empty() ? nullptr : optionString.c_str(), nullptr}; /* mpv >= 0.38 expects an index argument before options; use the * options-map-free form: loadfile replace [options] */ int result; if (optionString.empty()) { const char* plain[] = {"loadfile", url.c_str(), "replace", nullptr}; result = mpv_command(g_state.mpv, plain); } else { const char* withOpts[] = {"loadfile", url.c_str(), "replace", "-1", optionString.c_str(), nullptr}; result = mpv_command(g_state.mpv, withOpts); if (result == MPV_ERROR_INVALID_PARAMETER) { /* Older mpv without the index argument. */ result = mpv_command(g_state.mpv, args); } } if (result < 0) { std::lock_guard lock(g_state.mutex); g_state.snapshot.status = "error"; g_state.snapshot.error = mpv_error_string(result); g_state.dirty = true; } } void handleRecordCommand(const Command& command) { const std::string path = command.get("path"); const int result = mpv_set_property_string(g_state.mpv, "stream-record", path.c_str()); std::lock_guard lock(g_state.mutex); if (result < 0) { g_state.snapshot.recordingError = mpv_error_string(result); } else if (path.empty()) { g_state.snapshot.recordingActive = false; g_state.snapshot.recordingStartedAt.clear(); g_state.snapshot.recordingError.clear(); } else { g_state.snapshot.recordingActive = true; g_state.snapshot.recordingTargetPath = path; g_state.snapshot.recordingStartedAt = isoTimestampNow(); g_state.snapshot.recordingError.clear(); } g_state.dirty = true; } void handleCommand(const Command& command) { if (command.name == "load") { handleLoadCommand(command); } else if (command.name == "pause") { const int flag = command.get("value") == "1" ? 1 : 0; mpv_set_property(g_state.mpv, "pause", MPV_FORMAT_FLAG, const_cast(&flag)); } else if (command.name == "seek") { const double seconds = command.getDouble("seconds", 0); const std::string value = std::to_string(seconds); const char* args[] = {"seek", value.c_str(), "absolute", nullptr}; mpv_command(g_state.mpv, args); } else if (command.name == "seek-by") { /* Relative step (arrow keys, ±10 s buttons). mpv resolves the delta * against its own playback position and merges relative seeks that * are still queued, so a burst of presses accumulates instead of * collapsing onto one target computed from the renderer's stale * snapshot. */ const double delta = command.getDouble("seconds", 0); const std::string value = std::to_string(delta); const char* args[] = {"seek", value.c_str(), "relative+exact", nullptr}; mpv_command(g_state.mpv, args); } else if (command.name == "volume") { double percent = std::clamp(command.getDouble("value", 1) * 100.0, 0.0, 100.0); mpv_set_property(g_state.mpv, "volume", MPV_FORMAT_DOUBLE, &percent); } else if (command.name == "aid" || command.name == "sid") { const std::string value = command.get("value"); setPropertyString(command.name.c_str(), value == "-1" ? "no" : value); } else if (command.name == "sub-add") { /* External subtitle file (.srt/.ass/.vtt/…): mpv parses and renders * it natively; "select" makes it the active subtitle track. */ const std::string path = command.get("path"); if (!path.empty()) { const char* args[] = {"sub-add", path.c_str(), "select", nullptr}; mpv_command(g_state.mpv, args); } } else if (command.name == "sub-delay") { double delay = std::clamp(command.getDouble("value", 0), -60.0, 60.0); mpv_set_property(g_state.mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delay); } else if (command.name == "sub-scale") { double scale = std::clamp(command.getDouble("value", 1), 0.25, 4.0); mpv_set_property(g_state.mpv, "sub-scale", MPV_FORMAT_DOUBLE, &scale); } else if (command.name == "sub-color") { /* Affects text subtitles rendered by mpv's OSD path; ASS files keep * their own embedded styling. */ setPropertyString("sub-color", command.get("value", "#FFFFFF")); } else if (command.name == "speed") { double speed = std::clamp(command.getDouble("value", 1), 0.25, 4.0); mpv_set_property(g_state.mpv, "speed", MPV_FORMAT_DOUBLE, &speed); } else if (command.name == "aspect") { setPropertyString("video-aspect-override", command.get("value", "no")); } else if (command.name == "record") { handleRecordCommand(command); } else if (command.name == "size") { const int width = (int)command.getDouble("width", 0); const int height = (int)command.getDouble("height", 0); if (width >= 16 && height >= 16) { std::lock_guard lock(g_state.mutex); g_state.viewportWidth = width; g_state.viewportHeight = height; applyRenderSizeLocked(); } } else if (command.name == "quit") { g_state.running.store(false); mpv_wakeup(g_state.mpv); } } void runStdinLoop() { std::string line; while (g_state.running.load() && std::getline(std::cin, line)) { if (line.empty()) continue; handleCommand(frame_helper::parseCommandLine(line)); } /* stdin EOF => the parent process died or closed us: shut down. */ g_state.running.store(false); mpv_wakeup(g_state.mpv); } void onRenderUpdate(void* pipeline) { static_cast(pipeline)->notifyUpdate(); } /* mpv's LUT scalers (the default lanczos/spline family) upload their weight * texture with uninitialized row padding (reinit_scaler in * video/out/gpu/video.c, still present in mpv 0.41 and master). Mesa's CPU * rasterizers carry NaN/Inf from that padding through zero-weight linear * filtering, so a session randomly renders pure black depending on heap * contents. Software GL gets mpv's LUT-free bilinear scalers instead, which * is also what it can afford; a session option for the same key wins. */ void applySoftwareRendererOptions(const std::set& sessionKeys) { static const std::pair kOptions[] = { {"scale", "bilinear"}, {"cscale", "bilinear"}, {"dscale", "bilinear"}, {"sigmoid-upscaling", "no"}, }; std::string applied; std::string failed; for (const auto& [key, value] : kOptions) { if (sessionKeys.count(key) != 0) continue; const int result = mpv_set_property_string(g_state.mpv, key, value); std::string& list = result < 0 ? failed : applied; list += (list.empty() ? "" : ", ") + std::string(key) + "=" + value; if (result < 0) { list += std::string(" (") + mpv_error_string(result) + ")"; } } std::fprintf(stderr, "software renderer: applied [%s]; failed [%s]\n", applied.c_str(), failed.c_str()); } 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//cmdline. */ bool mpvOptionsOnStdin = false; int width = 1280; int height = 720; double volume = 1; bool runtimeProbe = false; }; HelperArgs parseArgs(int argc, char** argv) { HelperArgs args; for (int i = 1; i < argc; i++) { const std::string arg = argv[i]; auto next = [&]() -> std::string { return i + 1 < argc ? argv[++i] : ""; }; if (arg == "--shm-base") args.shmBase = next(); else if (arg == "--width") args.width = std::atoi(next().c_str()); else if (arg == "--height") args.height = std::atoi(next().c_str()); 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); args.height = std::max(16, args.height); return args; } int runtimeProbeFailure(const std::string& reason, const std::string& detail = "") { JsonWriter writer; writer.num("protocol", 1) .boolean("usable", false) .str("reason", reason); if (!detail.empty()) writer.str("detail", detail); emitLine(writer.finish()); return 1; } std::string libmpvClientApiVersion() { const unsigned long version = mpv_client_api_version(); return std::to_string(version >> 16) + "." + std::to_string(version & 0xffff); } std::string runtimeProbeShmName() { #if defined(_WIN32) const uint64_t processId = (uint64_t)GetCurrentProcessId(); #else const uint64_t processId = (uint64_t)getpid(); #endif return "/impv-fc-runtime-probe-" + std::to_string(processId); } /* * Bounded startup capability probe: initialize an idle libmpv client and * create the platform GL + mpv OpenGL render contexts, then create, validate, * and destroy a minimal shared-memory ring. It deliberately does not create * an FBO, open media, or enter either command loop. */ int runRuntimeProbe() { mpv_handle* mpv = mpv_create(); if (!mpv) { return runtimeProbeFailure("mpv-create-failed"); } mpv_set_option_string(mpv, "vo", "libmpv"); mpv_set_option_string(mpv, "idle", "yes"); mpv_set_option_string(mpv, "input-default-bindings", "no"); mpv_set_option_string(mpv, "osc", "no"); const int initializeResult = mpv_initialize(mpv); if (initializeResult < 0) { const std::string error = mpv_error_string(initializeResult); mpv_destroy(mpv); return runtimeProbeFailure("mpv-initialize-failed", error); } GlContext gl; std::string error; if (!gl.create(error)) { gl.destroy(); mpv_terminate_destroy(mpv); return runtimeProbeFailure("gl-context-create-failed", error); } if (!gl.makeCurrent(error)) { gl.destroy(); mpv_terminate_destroy(mpv); return runtimeProbeFailure("gl-context-bind-failed", error); } mpv_opengl_init_params glInit = { gl.procLoader(), gl.procLoaderCtx(), }; mpv_render_param renderParams[] = { {MPV_RENDER_PARAM_API_TYPE, const_cast(MPV_RENDER_API_TYPE_OPENGL)}, {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit}, {MPV_RENDER_PARAM_INVALID, nullptr}, }; mpv_render_context* renderContext = nullptr; const int renderResult = mpv_render_context_create(&renderContext, mpv, renderParams); if (renderResult < 0 || !renderContext) { const std::string renderError = renderResult < 0 ? mpv_error_string(renderResult) : "render context unavailable"; if (renderContext) mpv_render_context_free(renderContext); gl.destroy(); mpv_terminate_destroy(mpv); return runtimeProbeFailure("mpv-render-context-failed", renderError); } frame_helper::ShmRing runtimeProbeRing; const std::string shmName = runtimeProbeShmName(); if (!runtimeProbeRing.create(shmName, 16, 16, 1)) { runtimeProbeRing.destroy(); mpv_render_context_free(renderContext); gl.destroy(); mpv_terminate_destroy(mpv); return runtimeProbeFailure("shared-memory-create-failed"); } const bool sharedMemoryInitialized = runtimeProbeRing.base != nullptr && runtimeProbeRing.header != nullptr && runtimeProbeRing.header->magic == FRAME_SHM_MAGIC && runtimeProbeRing.header->version == FRAME_SHM_VERSION && runtimeProbeRing.header->width == 16 && runtimeProbeRing.header->height == 16 && runtimeProbeRing.header->generation == 1; runtimeProbeRing.destroy(); if (!sharedMemoryInitialized) { mpv_render_context_free(renderContext); gl.destroy(); mpv_terminate_destroy(mpv); return runtimeProbeFailure("shared-memory-initialize-failed"); } const std::string libmpvVersion = libmpvClientApiVersion(); mpv_render_context_free(renderContext); gl.destroy(); mpv_terminate_destroy(mpv); emitLine(JsonWriter() .num("protocol", 1) .boolean("usable", true) .str("libmpv", libmpvVersion) .str("renderApi", gl.renderApiName()) .finish()); return 0; } } // namespace int main(int argc, char** argv) { std::setlocale(LC_NUMERIC, "C"); #if !defined(_WIN32) signal(SIGPIPE, SIG_IGN); #endif const HelperArgs args = parseArgs(argc, argv); if (args.runtimeProbe) { return runRuntimeProbe(); } g_state.mpv = mpv_create(); if (!g_state.mpv) { emitLine(JsonWriter() .str("event", "fatal") .str("error", "mpv_create failed") .finish()); return 1; } mpv_set_option_string(g_state.mpv, "vo", "libmpv"); mpv_set_option_string(g_state.mpv, "hwdec", args.hwdec.c_str()); mpv_set_option_string(g_state.mpv, "keep-open", "yes"); mpv_set_option_string(g_state.mpv, "idle", "yes"); mpv_set_option_string(g_state.mpv, "input-default-bindings", "no"); mpv_set_option_string(g_state.mpv, "osc", "no"); /* Keep parity with the native-view addons and the external MPV launch * path: with mpv's youtube-dl hook enabled, a refused HTTP open falls * through to yt-dlp and the session error reads "youtube-dl failed" * instead of the load error. A user `ytdl=yes` session option * (applied below, before mpv_initialize) still overrides it. */ mpv_set_option_string(g_state.mpv, "ytdl", "no"); if (!args.audioDelay.empty()) { /* Compensates the frame-copy video-path latency to restore * lip-sync; calibrated per platform, see the architecture doc. */ mpv_set_option_string(g_state.mpv, "audio-delay", args.audioDelay.c_str()); } std::set sessionOptionKeys; if (args.mpvOptionsOnStdin) { /* `mpv-options\to000=\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> 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) { sessionOptionKeys.insert(key); } else { 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") .str("error", "mpv_initialize failed") .finish()); return 1; } mpv_request_log_messages(g_state.mpv, "warn"); double initialVolumePercent = std::clamp(args.volume, 0.0, 1.0) * 100.0; mpv_set_property(g_state.mpv, "volume", MPV_FORMAT_DOUBLE, &initialVolumePercent); mpv_observe_property(g_state.mpv, 1, "time-pos", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 2, "duration", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 3, "pause", MPV_FORMAT_FLAG); mpv_observe_property(g_state.mpv, 4, "volume", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 5, "path", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 6, "track-list", MPV_FORMAT_NODE); mpv_observe_property(g_state.mpv, 7, "aid", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 8, "sid", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 9, "speed", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 10, "video-aspect-override", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 11, "eof-reached", MPV_FORMAT_FLAG); mpv_observe_property(g_state.mpv, 12, "dwidth", MPV_FORMAT_INT64); mpv_observe_property(g_state.mpv, 13, "dheight", MPV_FORMAT_INT64); /* Stream diagnostics behind the player's info popover. They change often, * but the snapshot emit stays throttled, so this adds no event traffic. */ mpv_observe_property(g_state.mpv, 14, "estimated-vf-fps", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 15, "video-bitrate", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 16, "audio-bitrate", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 17, "video-format", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 18, "audio-codec-name", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 19, "file-format", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 20, "demuxer-cache-duration", MPV_FORMAT_DOUBLE); mpv_observe_property(g_state.mpv, 21, "frame-drop-count", MPV_FORMAT_INT64); mpv_observe_property(g_state.mpv, 22, "decoder-frame-drop-count", MPV_FORMAT_INT64); mpv_observe_property(g_state.mpv, 23, "audio-params/channels", MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate", MPV_FORMAT_INT64); g_state.pipeline.onGenerationChanged = [](const std::string& name, int width, int height, uint32_t generation) { emitLine(JsonWriter() .str("event", "shm") .str("name", name) .num("width", width) .num("height", height) .num("generation", generation) .finish()); }; g_state.pipeline.onSoftwareRenderer = [sessionOptionKeys] { applySoftwareRendererOptions(sessionOptionKeys); }; g_state.viewportWidth = args.width; g_state.viewportHeight = args.height; std::string startError; if (!g_state.pipeline.start(g_state.mpv, args.shmBase, args.width, args.height, startError)) { emitLine(JsonWriter() .str("event", "fatal") .str("error", startError) .finish()); return 1; } std::thread renderThread([] { g_state.pipeline.runLoop(); }); /* The render context is created inside the render thread; hook the * update callback once it exists (or bail if GL init failed). */ while (g_state.pipeline.initState() == 0) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); } if (g_state.pipeline.initState() < 0) { renderThread.join(); mpv_terminate_destroy(g_state.mpv); return 1; } mpv_render_context_set_update_callback(g_state.pipeline.renderContext(), onRenderUpdate, &g_state.pipeline); #if defined(_WIN32) const double helperPid = (double)GetCurrentProcessId(); #else const double helperPid = (double)getpid(); #endif emitLine(JsonWriter() .str("event", "hello") .num("pid", helperPid) .num("protocolVersion", 1) .finish()); std::thread stdinThread(runStdinLoop); runMpvEventLoop(); g_state.pipeline.stop(); renderThread.join(); mpv_terminate_destroy(g_state.mpv); if (stdinThread.joinable()) { /* stdin thread exits on EOF; detach if the pipe is still open. */ stdinThread.detach(); } emitLine(JsonWriter().str("event", "bye").finish()); return 0; }