release: replace stream testing with MediaMTX
This commit is contained in:
parent
c321d2b1ff
commit
ba049d682b
15
.env.example
15
.env.example
@ -50,9 +50,14 @@ LUMI_OPERATOR_PRIVACY_URL=
|
||||
# LUMI_DEV_MODE=true
|
||||
# LUMI_HOST=127.0.0.1
|
||||
|
||||
# Private OBS stream testing (optional; see docs/stream-testing.md)
|
||||
# LUMI_STREAM_TEST_INGEST_HOST=stream-test.example.com
|
||||
# Private OBS stream testing (optional; see docs/stream-testing.md).
|
||||
# Lumi uses the paired Companion hostname by default and manages MediaMTX itself.
|
||||
# LUMI_STREAM_TEST_INGEST_HOST=lumi.example.com
|
||||
# LUMI_STREAM_TEST_INGEST_PORT=19350
|
||||
# LUMI_STREAM_TEST_PUBLIC_PORT=443
|
||||
# LUMI_STREAM_TEST_RTMPS=true
|
||||
# LUMI_FFMPEG_PATH=/absolute/path/to/ffmpeg
|
||||
# LUMI_STREAM_TEST_PUBLIC_PORT=19350
|
||||
# LUMI_STREAM_TEST_TRANSPORT=rtmps
|
||||
# LUMI_STREAM_TEST_TLS_CERT=/absolute/path/to/fullchain.pem
|
||||
# LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.pem
|
||||
# LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE=false
|
||||
# Advanced/manual runtime override only; normal installations do not need this.
|
||||
# LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# Lumi changelog
|
||||
|
||||
## 0.3.3
|
||||
|
||||
- Isolated private tests in a dedicated libobs output, bypassed account OAuth stream-key mutation, retained crash-safe exact service restoration, and corrected OBS encoder ownership so failed outputs cannot corrupt a later retry.
|
||||
- Replaced the Stream Testing FFmpeg listener and rendition transcoder with a checksum-pinned, Lumi-managed MediaMTX runtime that receives OBS RTMP/RTMPS and remuxes the genuine source representation to low-latency HLS without recording.
|
||||
- Added explicit MediaMTX install, repair, and receiver-health actions; loopback-only API, metrics, and HLS; exact per-session hashed publisher authorization; bounded supervision and diagnostics; and authenticated same-origin streaming playback.
|
||||
- Kept OBS on the existing paired Lumi hostname, made unencrypted RTMP private-network-only by default, removed the extra FFmpeg/package requirement, and retained crash-safe exact OBS destination restoration and private caption delivery.
|
||||
- Updated Stream Testing to show only real source quality, factual OBS/MediaMTX/player diagnostics, and receiver warnings while preserving overlay, update, Companion plugin, transcription, and Song Overlay behavior.
|
||||
- Released Companion 0.2.3 with OBS Bridge 0.2.4 so the new private-output and recovery behavior reaches installed streaming computers through the normal updater.
|
||||
|
||||
## 0.3.2
|
||||
|
||||
- Fixed an OBS crash at private Stream Test startup by respecting the OBS Frontend API's borrowed streaming-service reference instead of releasing OBS-owned memory.
|
||||
|
||||
@ -58,8 +58,8 @@ You can also create a local plugin from the WebUI.
|
||||
Lumi Companion provides the streaming-computer boundary for transcription,
|
||||
media capture, and private stream testing. Its trust boundaries and setup are documented in
|
||||
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
|
||||
Private OBS output testing, receiver setup, recovery behavior, and the
|
||||
deterministic media pattern are documented in
|
||||
Private OBS output testing, Lumi-managed MediaMTX setup, recovery behavior, and
|
||||
network safety are documented in
|
||||
[`docs/stream-testing.md`](docs/stream-testing.md).
|
||||
|
||||
## Updates and recovery
|
||||
|
||||
@ -52,6 +52,27 @@ Localhost development builds can also receive checksum-addressed same-version
|
||||
updates without publishing a release. See
|
||||
[Localhost development updates](../docs/local-development-updates.md).
|
||||
|
||||
## Private Stream Testing
|
||||
|
||||
The core **Stream Testing** page asks the paired Lumi host for an expiring
|
||||
destination, then uses the managed OBS Bridge to snapshot the complete current
|
||||
OBS streaming service before installing that destination. OBS sends its ordinary
|
||||
encoded video and audio to the existing paired Lumi hostname on the configured
|
||||
RTMP/RTMPS port; Companion does not need a second login, destination editor, or
|
||||
local media runtime.
|
||||
|
||||
Lumi owns and verifies the MediaMTX receiver. MediaMTX remuxes the source stream
|
||||
for same-origin Admin playback without transcoding or recording it. In v0.3.3,
|
||||
`Source` is therefore the normal and only quality choice unless a future
|
||||
provider supplies genuine additional representations.
|
||||
|
||||
Stopping the test, losing the Lumi connection, receiver failure, Companion
|
||||
exit, expiry, or an OBS restart all enter the same recovery path. Companion
|
||||
keeps the DPAPI-protected recovery record until the OBS Bridge confirms that the
|
||||
exact saved service type and settings were restored and saved. If automatic
|
||||
restoration cannot finish while OBS is unavailable, the recovery remains
|
||||
visible and can be retried after OBS and the bridge reconnect.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Requirements:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.2.2"
|
||||
#define AppVersion "0.2.3"
|
||||
#endif
|
||||
#ifndef SourceRoot
|
||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
LUMI COMPANION PRIVACY NOTICE
|
||||
Effective date: 23 July 2026
|
||||
Effective date: 25 July 2026
|
||||
|
||||
This notice describes the default data behaviour of the Lumi Companion Windows application. It does not replace the privacy notice of the person or organisation operating the Lumi host to which Companion is paired.
|
||||
|
||||
@ -35,11 +35,14 @@ Depending on the features you enable, Companion may send:
|
||||
- the computer or device name, installation identifier, Companion version, protocol version, capabilities, and component status;
|
||||
- OBS state and selected-source information;
|
||||
- audio from the OBS source you select, converted to 16 kHz mono signed 16-bit PCM and transmitted in bounded frames;
|
||||
- when you explicitly start a private Stream Test, the real OBS audio/video output, temporary Stream Test destination facts, and output diagnostics required to preview and troubleshoot that output;
|
||||
- readiness, test, benchmark, connection, and diagnostic information; and
|
||||
- settings or control messages required for enabled Companion features.
|
||||
|
||||
Raw audio is held in bounded memory and is not written to disk by Companion by default. Audio is transmitted to the paired Lumi host for server-side processing. The Host Operator controls the server-side speech model, storage, logs, caption delivery, integrations, and retention. Review the Host Operator’s notice before enabling capture.
|
||||
|
||||
Private Stream Test media is sent only after you start a test. The default Lumi host repackages it into a short in-memory live window for authenticated administrators and does not retain a recording. A modified or independently operated host may behave differently; review the Host Operator’s notice and configuration.
|
||||
|
||||
You are responsible for having authority to capture and transmit audio or other information, including informing participants and obtaining consent where required.
|
||||
|
||||
4. NETWORK CONNECTIONS
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
# Copyright (c) 2026 OokamiKunTV
|
||||
|
||||
cmake_minimum_required(VERSION 3.28)
|
||||
project(lumi-obs-bridge VERSION 0.2.1 LANGUAGES CXX)
|
||||
set(LUMI_BRIDGE_VERSION "0.2.1-development" CACHE STRING "Lumi Companion bridge release version")
|
||||
project(lumi-obs-bridge VERSION 0.2.4 LANGUAGES CXX)
|
||||
set(LUMI_BRIDGE_VERSION "0.2.4-development" CACHE STRING "Lumi Companion bridge release version")
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <obs-frontend-api.h>
|
||||
#include <util/config-file.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <windows.h>
|
||||
#include <bcrypt.h>
|
||||
@ -69,6 +70,7 @@ static obs_source_t *captured_source = nullptr;
|
||||
static std::mutex captured_source_mutex;
|
||||
static std::mutex stream_test_mutex;
|
||||
static std::string stream_test_session;
|
||||
static obs_output_t *stream_test_output = nullptr;
|
||||
|
||||
static std::string wide_to_utf8(const std::wstring &value)
|
||||
{
|
||||
@ -347,6 +349,114 @@ struct service_command_context {
|
||||
std::string snapshot_settings;
|
||||
};
|
||||
|
||||
static bool restore_frontend_service(const std::string &type, const std::string &settings_json, std::string &error)
|
||||
{
|
||||
obs_data_t *settings = obs_data_create_from_json(settings_json.c_str());
|
||||
if (!settings) {
|
||||
error = "The encrypted OBS recovery snapshot is invalid.";
|
||||
return false;
|
||||
}
|
||||
obs_service_t *restored = obs_service_create(type.c_str(), "Restored streaming service", settings, nullptr);
|
||||
obs_data_release(settings);
|
||||
if (!restored) {
|
||||
error = "OBS could not restore the saved streaming service.";
|
||||
return false;
|
||||
}
|
||||
obs_frontend_set_streaming_service(restored);
|
||||
obs_frontend_save_streaming_service();
|
||||
obs_service_release(restored);
|
||||
return true;
|
||||
}
|
||||
|
||||
static obs_output_t *create_stream_test_output(obs_service_t *service, std::string &error)
|
||||
{
|
||||
config_t *profile = obs_frontend_get_profile_config();
|
||||
const char *mode = profile ? config_get_string(profile, "Output", "Mode") : nullptr;
|
||||
const bool advanced = mode && std::strcmp(mode, "Advanced") == 0;
|
||||
const char *configured_video = profile
|
||||
? config_get_string(profile, advanced ? "AdvOut" : "SimpleOutput", advanced ? "Encoder" : "StreamEncoder")
|
||||
: nullptr;
|
||||
const char *configured_audio = profile && advanced
|
||||
? config_get_string(profile, "AdvOut", "AudioEncoder")
|
||||
: "ffmpeg_aac";
|
||||
const char *video_id = configured_video && *configured_video ? configured_video : "obs_x264";
|
||||
const char *audio_id = configured_audio && *configured_audio ? configured_audio : "ffmpeg_aac";
|
||||
|
||||
obs_data_t *video_settings = obs_encoder_defaults(video_id);
|
||||
if (!video_settings) video_settings = obs_data_create();
|
||||
char *profile_path = obs_frontend_get_current_profile_path();
|
||||
if (profile_path) {
|
||||
const std::string encoder_path = std::string(profile_path) + "/streamEncoder.json";
|
||||
obs_data_t *saved = obs_data_create_from_json_file(encoder_path.c_str());
|
||||
if (saved) {
|
||||
obs_data_apply(video_settings, saved);
|
||||
obs_data_release(saved);
|
||||
}
|
||||
bfree(profile_path);
|
||||
}
|
||||
if (!obs_data_has_user_value(video_settings, "bitrate"))
|
||||
obs_data_set_int(video_settings, "bitrate", profile ? config_get_uint(profile, "SimpleOutput", "VBitrate") : 6000);
|
||||
obs_data_set_int(video_settings, "keyint_sec", 2);
|
||||
obs_encoder_t *video = obs_video_encoder_create(video_id, "Lumi private stream video", video_settings, nullptr);
|
||||
obs_data_release(video_settings);
|
||||
if (!video && std::strcmp(video_id, "obs_x264") != 0) {
|
||||
obs_data_t *fallback = obs_encoder_defaults("obs_x264");
|
||||
video = obs_video_encoder_create("obs_x264", "Lumi private stream video", fallback, nullptr);
|
||||
if (fallback) obs_data_release(fallback);
|
||||
}
|
||||
if (!video) {
|
||||
error = "OBS could not create a video encoder for the private output.";
|
||||
return nullptr;
|
||||
}
|
||||
obs_encoder_set_video(video, obs_get_video());
|
||||
if (advanced && profile && config_get_bool(profile, "AdvOut", "Rescale")) {
|
||||
const char *resolution = config_get_string(profile, "AdvOut", "RescaleRes");
|
||||
unsigned int width = 0, height = 0;
|
||||
if (resolution && std::sscanf(resolution, "%ux%u", &width, &height) == 2 && width && height)
|
||||
obs_encoder_set_scaled_size(video, width, height);
|
||||
}
|
||||
|
||||
obs_data_t *audio_settings = obs_encoder_defaults(audio_id);
|
||||
if (!audio_settings) audio_settings = obs_data_create();
|
||||
const uint64_t audio_bitrate = profile
|
||||
? config_get_uint(profile, advanced ? "AdvOut" : "SimpleOutput", advanced ? "Track1Bitrate" : "ABitrate")
|
||||
: 160;
|
||||
obs_data_set_int(audio_settings, "bitrate", audio_bitrate ? static_cast<long long>(audio_bitrate) : 160);
|
||||
obs_encoder_t *audio = obs_audio_encoder_create(audio_id, "Lumi private stream audio", audio_settings, 0, nullptr);
|
||||
obs_data_release(audio_settings);
|
||||
if (!audio && std::strcmp(audio_id, "ffmpeg_aac") != 0) {
|
||||
obs_data_t *fallback = obs_encoder_defaults("ffmpeg_aac");
|
||||
audio = obs_audio_encoder_create("ffmpeg_aac", "Lumi private stream audio", fallback, 0, nullptr);
|
||||
if (fallback) obs_data_release(fallback);
|
||||
}
|
||||
if (!audio) {
|
||||
obs_encoder_release(video);
|
||||
error = "OBS could not create an audio encoder for the private output.";
|
||||
return nullptr;
|
||||
}
|
||||
obs_encoder_set_audio(audio, obs_get_audio());
|
||||
|
||||
obs_data_t *output_settings = obs_output_defaults("rtmp_output");
|
||||
if (!output_settings) output_settings = obs_data_create();
|
||||
obs_data_set_string(output_settings, "bind_ip", "default");
|
||||
obs_data_set_bool(output_settings, "dyn_bitrate", false);
|
||||
obs_output_t *output = obs_output_create("rtmp_output", "Lumi private stream output", output_settings, nullptr);
|
||||
obs_data_release(output_settings);
|
||||
if (!output) {
|
||||
obs_encoder_release(audio);
|
||||
obs_encoder_release(video);
|
||||
error = "OBS could not create the private RTMP output.";
|
||||
return nullptr;
|
||||
}
|
||||
obs_output_set_video_encoder(output, video);
|
||||
obs_output_set_audio_encoder(output, audio, 0);
|
||||
obs_encoder_release(audio);
|
||||
obs_encoder_release(video);
|
||||
obs_output_set_service(output, service);
|
||||
obs_output_set_reconnect_settings(output, 0, 1);
|
||||
return output;
|
||||
}
|
||||
|
||||
static void apply_stream_test_service(void *opaque)
|
||||
{
|
||||
auto &context = *static_cast<service_command_context *>(opaque);
|
||||
@ -383,54 +493,73 @@ static void apply_stream_test_service(void *opaque)
|
||||
context.error = "OBS could not create the private test service.";
|
||||
return;
|
||||
}
|
||||
// Use a dedicated libobs output so platform OAuth cannot replace the
|
||||
// expiring private stream key. Point the frontend at the same private
|
||||
// service only as a fail-safe: pressing OBS's normal Start button
|
||||
// cannot accidentally publish to the saved public destination.
|
||||
obs_frontend_set_streaming_service(test);
|
||||
obs_frontend_save_streaming_service();
|
||||
obs_output_t *output = create_stream_test_output(test, context.error);
|
||||
if (!output) {
|
||||
restore_frontend_service(context.snapshot_type, context.snapshot_settings, context.error);
|
||||
obs_service_release(test);
|
||||
return;
|
||||
}
|
||||
if (!obs_output_start(output)) {
|
||||
const char *error = obs_output_get_last_error(output);
|
||||
const std::string start_error = error && *error ? error : "OBS could not start the private output.";
|
||||
obs_output_release(output);
|
||||
restore_frontend_service(context.snapshot_type, context.snapshot_settings, context.error);
|
||||
obs_service_release(test);
|
||||
context.error = start_error;
|
||||
return;
|
||||
}
|
||||
obs_service_release(test);
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
if (stream_test_output) obs_output_release(stream_test_output);
|
||||
stream_test_output = output;
|
||||
stream_test_session = context.session_id;
|
||||
}
|
||||
obs_frontend_streaming_start();
|
||||
context.ok = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (obs_frontend_streaming_active()) obs_frontend_streaming_stop();
|
||||
obs_data_t *settings = obs_data_create_from_json(context.restore_settings.c_str());
|
||||
if (!settings) {
|
||||
context.error = "The encrypted OBS recovery snapshot is invalid.";
|
||||
return;
|
||||
}
|
||||
obs_service_t *restored = obs_service_create(context.restore_type.c_str(), "Restored streaming service", settings, nullptr);
|
||||
obs_data_release(settings);
|
||||
if (!restored) {
|
||||
context.error = "OBS could not restore the saved streaming service.";
|
||||
return;
|
||||
}
|
||||
obs_frontend_set_streaming_service(restored);
|
||||
obs_frontend_save_streaming_service();
|
||||
obs_service_release(restored);
|
||||
obs_output_t *output = nullptr;
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
output = stream_test_output;
|
||||
stream_test_output = nullptr;
|
||||
stream_test_session.clear();
|
||||
}
|
||||
context.ok = true;
|
||||
if (output) {
|
||||
if (obs_output_active(output)) obs_output_force_stop(output);
|
||||
if (obs_output_active(output)) {
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
stream_test_output = output;
|
||||
context.error = "The private OBS output is still stopping. Try restoration again.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (output) obs_output_release(output);
|
||||
context.ok = restore_frontend_service(context.restore_type, context.restore_settings, context.error);
|
||||
}
|
||||
|
||||
static json stream_test_metrics_message()
|
||||
{
|
||||
std::string session_id;
|
||||
obs_output_t *output = nullptr;
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
session_id = stream_test_session;
|
||||
if (stream_test_output) output = obs_output_get_ref(stream_test_output);
|
||||
}
|
||||
if (session_id.empty()) return json();
|
||||
obs_video_info video{};
|
||||
obs_get_video_info(&video);
|
||||
json value{{"type", "stream_test_metrics"}, {"protocol_version", protocol_version}, {"session_id", session_id},
|
||||
{"active", obs_frontend_streaming_active()}, {"width", video.output_width}, {"height", video.output_height},
|
||||
{"active", output && obs_output_active(output)}, {"width", video.output_width}, {"height", video.output_height},
|
||||
{"fps", video.fps_den ? static_cast<double>(video.fps_num) / video.fps_den : 0.0}};
|
||||
obs_output_t *output = obs_frontend_get_streaming_output();
|
||||
if (output) {
|
||||
value["total_bytes"] = obs_output_get_total_bytes(output);
|
||||
value["dropped_frames"] = obs_output_get_frames_dropped(output);
|
||||
@ -438,7 +567,7 @@ static json stream_test_metrics_message()
|
||||
value["congestion"] = obs_output_get_congestion(output);
|
||||
obs_encoder_t *encoder = obs_output_get_video_encoder(output);
|
||||
value["encoder"] = encoder && obs_encoder_get_id(encoder) ? obs_encoder_get_id(encoder) : "";
|
||||
if (encoder) obs_encoder_release(encoder);
|
||||
// obs_output_get_video_encoder returns a borrowed reference.
|
||||
obs_output_release(output);
|
||||
}
|
||||
return value;
|
||||
@ -446,9 +575,18 @@ static json stream_test_metrics_message()
|
||||
|
||||
static bool output_caption(const std::string &text, double display_seconds)
|
||||
{
|
||||
if (!obs_frontend_streaming_active() || text.empty()) return false;
|
||||
obs_output_t *output = obs_frontend_get_streaming_output();
|
||||
if (text.empty()) return false;
|
||||
obs_output_t *output = nullptr;
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
if (stream_test_output) output = obs_output_get_ref(stream_test_output);
|
||||
}
|
||||
if (!output) output = obs_frontend_get_streaming_output();
|
||||
if (!output) return false;
|
||||
if (!obs_output_active(output)) {
|
||||
obs_output_release(output);
|
||||
return false;
|
||||
}
|
||||
obs_output_output_caption_text2(output, text.c_str(), std::clamp(display_seconds, 0.5, 10.0));
|
||||
obs_output_release(output);
|
||||
return true;
|
||||
@ -460,7 +598,6 @@ static std::optional<json> handle_command(const json &message)
|
||||
if (type == "select_sources") {
|
||||
const auto uuid = message.value("primary_source_uuid", "");
|
||||
const auto installed = select_source(uuid);
|
||||
source_list_dirty.store(true, std::memory_order_release);
|
||||
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] %s selected OBS audio source %s",
|
||||
installed ? "Attached" : "Could not attach", uuid.c_str());
|
||||
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
|
||||
@ -567,7 +704,9 @@ static void run_pipe_worker()
|
||||
continue;
|
||||
}
|
||||
blog(LOG_INFO, "[Lumi Companion] connected to the same-user Companion IPC endpoint");
|
||||
bool connected = write_json(pipe, {{"type", "hello"}, {"protocol_version", protocol_version}, {"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version}});
|
||||
bool connected = write_json(pipe, {{"type", "hello"}, {"protocol_version", protocol_version},
|
||||
{"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version},
|
||||
{"obs_process_id", static_cast<uint32_t>(GetCurrentProcessId())}});
|
||||
source_list_dirty.store(true, std::memory_order_release);
|
||||
obs_state_dirty.store(true, std::memory_order_release);
|
||||
auto health_sent = std::chrono::steady_clock::now();
|
||||
@ -647,6 +786,15 @@ void obs_module_unload(void)
|
||||
{
|
||||
obs_frontend_remove_event_callback(frontend_event, nullptr);
|
||||
detach_source();
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
if (stream_test_output) {
|
||||
if (obs_output_active(stream_test_output)) obs_output_force_stop(stream_test_output);
|
||||
obs_output_release(stream_test_output);
|
||||
stream_test_output = nullptr;
|
||||
}
|
||||
stream_test_session.clear();
|
||||
}
|
||||
stopping.store(true, std::memory_order_release);
|
||||
worker_signal.notify_all();
|
||||
if (worker_thread.joinable()) worker_thread.join();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
param(
|
||||
[string]$ObsVersion = "31.1.1",
|
||||
[string]$BridgeVersion = "0.2.1",
|
||||
[string]$BridgeVersion = "0.2.4",
|
||||
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
|
||||
)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
param(
|
||||
[string]$Version = "0.2.2",
|
||||
[string]$BridgeVersion = "0.2.1",
|
||||
[string]$Version = "0.2.3",
|
||||
[string]$BridgeVersion = "0.2.4",
|
||||
[string]$ObsVersion = "31.1.1"
|
||||
)
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
{
|
||||
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
private const string PathValidationContract = "voice-free-path-v1";
|
||||
private const string RestartObsAfterFailedStreamTestDetail = "OBS could not establish the private output. The normal destination was restored. Restart OBS before trying again.";
|
||||
private readonly CompanionPaths _paths;
|
||||
private readonly CompanionSettingsStore _settings;
|
||||
private readonly SecureCredentialStore _credentials;
|
||||
@ -43,6 +44,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
private bool _disposed;
|
||||
private readonly SingleFlightOperation _updateChecks = new();
|
||||
private int _streamTestRestoreRunning;
|
||||
private int _streamTestStopRequested;
|
||||
private int _obsProcessId;
|
||||
private int _obsOutputWidth = 1920;
|
||||
private int _obsOutputHeight = 1080;
|
||||
private double _obsOutputFps = 30;
|
||||
@ -96,6 +99,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
private async Task InitializeCoreAsync()
|
||||
{
|
||||
await _settings.LoadAsync();
|
||||
_obsProcessId = _settings.Current.LastObsProcessId;
|
||||
if (_settings.Current.StreamTestObsRestartRequired)
|
||||
SetState(State with
|
||||
{
|
||||
StreamTestObsRestartRequired = true,
|
||||
Health = TrayHealth.Degraded,
|
||||
StreamTestDetail = RestartObsAfterFailedStreamTestDetail
|
||||
});
|
||||
StartObsBridgeBoundary();
|
||||
if (_streamTestRecovery.Exists)
|
||||
SetState(State with { StreamTestRecoveryRequired = true, StreamTestDetail = "A previous private test needs OBS restoration. Open OBS; Lumi Companion will repair it automatically." });
|
||||
@ -276,6 +287,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
{
|
||||
if (State.StreamTestRunning) return;
|
||||
if (_streamTestRecovery.Exists) throw new InvalidOperationException("Restore the previous OBS stream service before starting another test.");
|
||||
if (State.StreamTestObsRestartRequired) throw new InvalidOperationException(RestartObsAfterFailedStreamTestDetail);
|
||||
if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect Lumi Companion before starting a private stream test.");
|
||||
if (!State.ObsConnected || _obsBridge is null) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect.");
|
||||
if (State.ObsBridgeUpdateAvailable)
|
||||
@ -283,6 +295,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
if (State.ObsBridgeRepairNeeded) throw new InvalidOperationException("Repair the managed OBS integration before starting Stream Testing.");
|
||||
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording in OBS before starting a private test.");
|
||||
|
||||
Interlocked.Exchange(ref _streamTestStopRequested, 0);
|
||||
SetState(State with { StreamTestDetail = "Requesting an expiring private receiver from Lumi…", Health = TrayHealth.Operating });
|
||||
_streamTestSessionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
JsonElement created;
|
||||
@ -346,11 +359,12 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
catch (Exception error)
|
||||
{
|
||||
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
|
||||
await SetObsRestartGuardAsync(true);
|
||||
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
|
||||
SetState(State with
|
||||
{
|
||||
Health = TrayHealth.Degraded,
|
||||
StreamTestDetail = $"The private test could not start. {Friendly(error)}"
|
||||
StreamTestDetail = $"{RestartObsAfterFailedStreamTestDetail} {Friendly(error)}"
|
||||
});
|
||||
await WriteLogAsync("stream_test_start_failed", error.Message);
|
||||
throw;
|
||||
@ -359,6 +373,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
|
||||
public async Task StopStreamTestAsync(string reason = "requested", CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(reason, "obs_stopped", StringComparison.Ordinal))
|
||||
Interlocked.Exchange(ref _streamTestStopRequested, 1);
|
||||
else
|
||||
await SetObsRestartGuardAsync(true);
|
||||
var sessionId = State.StreamTestSessionId ?? _streamTestRecovery.Load()?.SessionId;
|
||||
SetState(State with { StreamTestDetail = "Ending the private receiver and restoring the exact OBS service…" });
|
||||
if (_socket is not null && State.Connected && sessionId is not null)
|
||||
@ -380,7 +398,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
}
|
||||
if (recovery is null)
|
||||
{
|
||||
SetState(State with { StreamTestRunning = false, StreamTestRecoveryRequired = false, StreamTestSessionId = null, StreamTestDetail = detail });
|
||||
SetState(State with
|
||||
{
|
||||
StreamTestRunning = false,
|
||||
StreamTestRecoveryRequired = false,
|
||||
StreamTestSessionId = null,
|
||||
StreamTestDetail = State.StreamTestObsRestartRequired ? RestartObsAfterFailedStreamTestDetail : detail
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_obsBridge is null || !State.ObsConnected)
|
||||
@ -388,12 +412,19 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
SetState(State with { StreamTestRunning = false, StreamTestRecoveryRequired = true, StreamTestSessionId = recovery.SessionId, Health = TrayHealth.Degraded, StreamTestDetail = "OBS restoration is pending. Open OBS and keep Companion running; restoration will resume automatically." });
|
||||
return;
|
||||
}
|
||||
var reply = await _obsBridge.RequestAsync("stream_test_restore", new
|
||||
var restorePayload = new
|
||||
{
|
||||
session_id = recovery.SessionId,
|
||||
service_type = recovery.ServiceType,
|
||||
settings_json = recovery.ServiceSettingsJson
|
||||
}, TimeSpan.FromSeconds(12), cancellationToken);
|
||||
};
|
||||
var reply = await _obsBridge.RequestAsync("stream_test_restore", restorePayload, TimeSpan.FromSeconds(12), cancellationToken);
|
||||
if (ReadString(reply, "state") != "restored" &&
|
||||
(ReadString(reply, "error") ?? "").Contains("still stopping", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await Task.Delay(300, cancellationToken);
|
||||
reply = await _obsBridge.RequestAsync("stream_test_restore", restorePayload, TimeSpan.FromSeconds(12), cancellationToken);
|
||||
}
|
||||
if (ReadString(reply, "state") != "restored")
|
||||
throw new InvalidOperationException(ReadString(reply, "error") ?? "OBS did not confirm restoration.");
|
||||
_streamTestRecovery.Remove();
|
||||
@ -406,8 +437,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
StreamTestDroppedFrames = 0,
|
||||
StreamTestTotalFrames = 0,
|
||||
StreamTestCongestion = 0,
|
||||
StreamTestDetail = detail,
|
||||
Health = State.Connected ? TrayHealth.Ready : TrayHealth.Degraded
|
||||
StreamTestDetail = State.StreamTestObsRestartRequired ? RestartObsAfterFailedStreamTestDetail : detail,
|
||||
Health = State.StreamTestObsRestartRequired ? TrayHealth.Degraded : State.Connected ? TrayHealth.Ready : TrayHealth.Degraded
|
||||
});
|
||||
await WriteLogAsync("stream_test_restored", "OBS streaming service restored from the encrypted recovery snapshot.");
|
||||
}
|
||||
@ -684,6 +715,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
if (message.Type == "stream_test_ended")
|
||||
{
|
||||
var reason = ReadString(message.Payload, "reason") ?? "The private receiver ended.";
|
||||
Interlocked.Exchange(ref _streamTestStopRequested, 1);
|
||||
_ = RestoreObsAfterStreamTestAsync($"{reason} OBS was restored to its previous streaming service.");
|
||||
}
|
||||
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
|
||||
@ -797,11 +829,20 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
if (type == "hello")
|
||||
{
|
||||
var loadedVersion = ReadString(message, "bridge_version");
|
||||
var processId = ReadInt(message, "obs_process_id");
|
||||
var obsRestarted = processId > 0 && _obsProcessId > 0 && processId != _obsProcessId;
|
||||
if (processId > 0 && (processId != _obsProcessId || obsRestarted))
|
||||
{
|
||||
_obsProcessId = processId;
|
||||
await SetObsRestartGuardAsync(obsRestarted ? false : State.StreamTestObsRestartRequired, processId);
|
||||
}
|
||||
var next = WithBridgeState(State with { ObsConnected = true, ObsBridgeLoadedVersion = loadedVersion });
|
||||
SetState(next with
|
||||
{
|
||||
Detail = next.ObsBridgeUpdateAvailable
|
||||
? $"OBS Bridge {loadedVersion ?? "unknown"} is loaded, but Companion requires {next.ObsBridgeBundledVersion ?? "the bundled version"}. Close OBS and update the bridge."
|
||||
: next.StreamTestObsRestartRequired
|
||||
? RestartObsAfterFailedStreamTestDetail
|
||||
: State.Detail
|
||||
});
|
||||
RefreshPathReadiness();
|
||||
@ -818,7 +859,20 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
var fpsNum = ReadInt(message, "fps_num");
|
||||
if (fpsDen > 0 && fpsNum > 0) _obsOutputFps = (double)fpsNum / fpsDen;
|
||||
var privateTest = State.StreamTestRunning || _streamTestRecovery.Exists;
|
||||
SetState(State with { ObsConnected = true, ObsStreaming = streaming, ObsRecording = recording, Health = streaming ? TrayHealth.Operating : TrayHealth.Ready, Detail = privateTest ? "PRIVATE STREAM TEST — the OBS output is redirected only to Lumi." : streaming ? "OBS is live and companion services are active." : "OBS is connected and ready." });
|
||||
SetState(State with
|
||||
{
|
||||
ObsConnected = true,
|
||||
ObsStreaming = streaming,
|
||||
ObsRecording = recording,
|
||||
Health = State.StreamTestObsRestartRequired ? TrayHealth.Degraded : streaming ? TrayHealth.Operating : TrayHealth.Ready,
|
||||
Detail = State.StreamTestObsRestartRequired
|
||||
? RestartObsAfterFailedStreamTestDetail
|
||||
: privateTest
|
||||
? "PRIVATE STREAM TEST — the OBS output is redirected only to Lumi."
|
||||
: streaming
|
||||
? "OBS is live and companion services are active."
|
||||
: "OBS is connected and ready."
|
||||
});
|
||||
RefreshPathReadiness();
|
||||
await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version"));
|
||||
}
|
||||
@ -892,12 +946,37 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
||||
}, _socket.SessionId, _lifetimeToken());
|
||||
}
|
||||
catch when (!State.Connected) { }
|
||||
if (!ReadBoolean(message, "active") && DateTimeOffset.UtcNow - _streamTestBeganAt > TimeSpan.FromSeconds(5))
|
||||
if (!ReadBoolean(message, "active") &&
|
||||
Volatile.Read(ref _streamTestStopRequested) == 0 &&
|
||||
DateTimeOffset.UtcNow - _streamTestBeganAt > TimeSpan.FromSeconds(5))
|
||||
_ = StopStreamTestAsync("obs_stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetObsRestartGuardAsync(bool required, int? processId = null)
|
||||
{
|
||||
var nextProcessId = processId ?? _obsProcessId;
|
||||
SetState(State with
|
||||
{
|
||||
StreamTestObsRestartRequired = required,
|
||||
Health = required ? TrayHealth.Degraded : State.Health,
|
||||
StreamTestDetail = required ? RestartObsAfterFailedStreamTestDetail : State.StreamTestDetail
|
||||
});
|
||||
try
|
||||
{
|
||||
await _settings.SaveAsync(_settings.Current with
|
||||
{
|
||||
LastObsProcessId = nextProcessId,
|
||||
StreamTestObsRestartRequired = required
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await WriteLogAsync("stream_test_restart_guard_save_failed", error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnObsAudioAsync(ReadOnlyMemory<byte> frame)
|
||||
{
|
||||
var level = MeasureDbfs(frame.Span);
|
||||
|
||||
@ -10,7 +10,9 @@ public sealed record CompanionSettings(
|
||||
string? PrimarySourceUuid = null,
|
||||
string? PrimarySourceName = null,
|
||||
DateTimeOffset? PathTestPassedAt = null,
|
||||
string? PathTestFingerprint = null);
|
||||
string? PathTestFingerprint = null,
|
||||
int LastObsProcessId = 0,
|
||||
bool StreamTestObsRestartRequired = false);
|
||||
|
||||
public sealed class CompanionSettingsStore
|
||||
{
|
||||
|
||||
@ -15,6 +15,7 @@ public sealed record CompanionState(
|
||||
bool BenchmarkRunning = false,
|
||||
bool StreamTestRunning = false,
|
||||
bool StreamTestRecoveryRequired = false,
|
||||
bool StreamTestObsRestartRequired = false,
|
||||
TrayHealth Health = TrayHealth.Degraded,
|
||||
string Detail = "Pair Lumi Companion to get started.",
|
||||
string? DeviceName = null,
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||
<Version>0.2.2</Version>
|
||||
<AssemblyVersion>0.2.2.0</AssemblyVersion>
|
||||
<Version>0.2.3</Version>
|
||||
<AssemblyVersion>0.2.3.0</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||
|
||||
@ -313,12 +313,18 @@ public partial class MainWindow : Window
|
||||
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
||||
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
||||
BenchmarkStatusText.Text = state.BenchmarkDetail;
|
||||
StreamTestStatusTitle.Text = state.StreamTestRunning ? "PRIVATE TEST ACTIVE" : state.StreamTestRecoveryRequired ? "OBS restoration required" : "Ready to check";
|
||||
StreamTestStatusTitle.Text = state.StreamTestRunning
|
||||
? "PRIVATE TEST ACTIVE"
|
||||
: state.StreamTestRecoveryRequired
|
||||
? "OBS restoration required"
|
||||
: state.StreamTestObsRestartRequired
|
||||
? "Restart OBS before retrying"
|
||||
: "Ready to check";
|
||||
StreamTestStatusDetail.Text = state.StreamTestDetail;
|
||||
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
|
||||
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
|
||||
StreamTestCongestionText.Text = state.StreamTestRunning ? $"{state.StreamTestCongestion:P0}" : "—";
|
||||
StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsBridgeUpdateAvailable && !state.ObsBridgeRepairNeeded && !state.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired;
|
||||
StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsBridgeUpdateAvailable && !state.ObsBridgeRepairNeeded && !state.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired && !state.StreamTestObsRestartRequired;
|
||||
StopStreamTestButton.IsEnabled = state.StreamTestRunning;
|
||||
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
|
||||
UpdatePanel.IsVisible = state.UpdateAvailable;
|
||||
|
||||
@ -66,6 +66,20 @@ The Admin summary reports Companion, device, inference, and session health.
|
||||
Detailed benchmark records remain inspectable for one hour and include
|
||||
word/phrase latency and confidence views.
|
||||
|
||||
## OBS Bridge and Stream Testing
|
||||
|
||||
The same paired Companion identity and same-user OBS Bridge are also used for
|
||||
private Stream Testing. Lumi issues the expiring RTMP/RTMPS destination from the
|
||||
validated pairing hostname; plugins do not re-authenticate and Companion does
|
||||
not invent or accept another host. Before changing OBS, Companion stores the
|
||||
complete service snapshot in its DPAPI-protected recovery record. Every terminal
|
||||
path uses that record until OBS confirms the exact service has been restored.
|
||||
|
||||
MediaMTX is installed, verified, configured, and supervised by Lumi Core on the
|
||||
server. It is not shipped in Companion and does not run inference or transcode
|
||||
media. See [`stream-testing.md`](stream-testing.md) for transport, firewall,
|
||||
managed-runtime, playback, and recovery details.
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
|
||||
@ -8,59 +8,92 @@ Lumi Companion, then watch it at **Admin > Stream testing**.
|
||||
|
||||
- The existing paired-device WebSocket authenticates session creation, OBS
|
||||
metrics, caption cues, stop requests, and server-ended notifications.
|
||||
- The existing same-user OBS bridge snapshots the complete current OBS service,
|
||||
- The existing same-user OBS Bridge snapshots the complete current OBS service,
|
||||
temporarily installs an expiring custom service, reports real output facts,
|
||||
and restores the snapshot.
|
||||
- Companion protects the minimum recovery record with Windows DPAPI and writes
|
||||
it atomically before OBS is redirected. A normal stop, server failure, expiry,
|
||||
Companion exit, WebSocket loss, or OBS restart all use the same restore path.
|
||||
- Core supervises one FFmpeg receiver process and creates a short rolling HLS
|
||||
window. HLS.js provides authenticated Chromium playback; native HLS remains
|
||||
available where the browser supports it.
|
||||
it atomically before OBS is redirected. Normal stop, server failure, expiry,
|
||||
Companion exit, WebSocket loss, and OBS restart all use that restore path.
|
||||
- Core lazily starts a Lumi-managed MediaMTX process. MediaMTX accepts the
|
||||
private RTMP(S) publisher and repackages the original source into low-latency
|
||||
HLS without transcoding, scaling, a GPU requirement, or an adaptive ladder.
|
||||
- The MediaMTX API, metrics, and HLS listeners bind only to loopback. The Admin
|
||||
WebUI accesses HLS through an authenticated, no-store, same-origin streaming
|
||||
proxy. RTSP, WebRTC, SRT, playback, pprof, and recording are disabled.
|
||||
- Stable transcription captions are reused as a private WebVTT side channel.
|
||||
No second transcription pipeline and no local speech inference are added.
|
||||
|
||||
Temporary HLS fragments exist only to serve the active admin player. They are
|
||||
bounded, deleted as they roll out, and removed when the session ends. Lumi does
|
||||
not retain a raw stream recording.
|
||||
Media segments stay in bounded MediaMTX memory and roll out of the short live
|
||||
window. Lumi does not retain a raw stream recording or create temporary HLS
|
||||
files. Session state, captions, and diagnostic facts disappear when the test
|
||||
ends or Lumi restarts.
|
||||
|
||||
## Server setup
|
||||
## Managed setup
|
||||
|
||||
Install a current FFmpeg build and ensure the Lumi service account can execute
|
||||
it. Set:
|
||||
No operating-system package installation, FFmpeg installation, or executable
|
||||
path is required. Open **Admin > Stream testing** and choose **Install
|
||||
MediaMTX**. Lumi then:
|
||||
|
||||
1. downloads the pinned official Windows x64 archive only after confirmation;
|
||||
2. enforces download and extraction size limits;
|
||||
3. verifies the exact SHA-256 checksum and archive layout;
|
||||
4. probes the executable and exact expected version; and
|
||||
5. atomically installs it while retaining the previous installation until the
|
||||
replacement is known to be valid.
|
||||
|
||||
**Repair installation** repeats the same verified workflow. **Run health
|
||||
check** starts MediaMTX with deny-by-default paths and tests its loopback API,
|
||||
metrics, and HLS listeners; opening the page alone never downloads or starts a
|
||||
runtime. `LUMI_MEDIAMTX_PATH` remains an advanced manual override for another
|
||||
platform or operator-managed binary, but normal Windows installations do not
|
||||
need it.
|
||||
|
||||
The managed release and checksum source are recorded in
|
||||
`src/services/mediamtx-runtime-manifest.json`. MediaMTX's licence is retained in
|
||||
the downloaded runtime and documented in
|
||||
[`third-party/MediaMTX-MIT.txt`](third-party/MediaMTX-MIT.txt).
|
||||
|
||||
## Ingest network configuration
|
||||
|
||||
Lumi reuses the validated hostname from the Companion pairing record unless
|
||||
`LUMI_STREAM_TEST_INGEST_HOST` overrides it. A session receives an exact
|
||||
`lumi-test/<uuid>` path and high-entropy publisher credentials. Only that path
|
||||
can be published, and the generated MediaMTX configuration contains SHA-256
|
||||
credential hashes rather than plaintext credentials.
|
||||
|
||||
RTMPS is recommended for any public or routed network:
|
||||
|
||||
```text
|
||||
LUMI_STREAM_TEST_INGEST_HOST=the-hostname-reachable-from-the-streaming-computer
|
||||
LUMI_STREAM_TEST_TRANSPORT=rtmps
|
||||
LUMI_STREAM_TEST_INGEST_PORT=19350
|
||||
LUMI_STREAM_TEST_PUBLIC_PORT=19350
|
||||
LUMI_STREAM_TEST_TLS_CERT=/absolute/path/to/fullchain.pem
|
||||
LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.pem
|
||||
```
|
||||
|
||||
Optional controls:
|
||||
The public port is the value given to OBS and can differ when a firewall or
|
||||
port-forward maps it to the MediaMTX listener. Use the existing paired Lumi
|
||||
hostname; no extra Stream Testing domain is required.
|
||||
|
||||
Unencrypted RTMP is allowed automatically only when the paired hostname
|
||||
resolves entirely to loopback or private/LAN addresses:
|
||||
|
||||
```text
|
||||
LUMI_STREAM_TEST_TRANSPORT=rtmp
|
||||
```
|
||||
|
||||
`LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE=true` is an explicit dangerous override
|
||||
for operators who understand that publisher credentials and media can cross
|
||||
the network without transport encryption. It defaults to false. Restrict the
|
||||
ingest port at the host firewall to the streaming network in either mode.
|
||||
|
||||
Optional session limits remain:
|
||||
|
||||
```text
|
||||
LUMI_FFMPEG_PATH=/absolute/path/to/ffmpeg
|
||||
LUMI_STREAM_TEST_INGEST_PORT=19350
|
||||
LUMI_STREAM_TEST_PUBLIC_PORT=443
|
||||
LUMI_STREAM_TEST_ENCODER=h264_nvenc
|
||||
LUMI_STREAM_TEST_MAX_MS=1800000
|
||||
LUMI_STREAM_TEST_INACTIVITY_MS=45000
|
||||
LUMI_STREAM_TEST_MAX_BYTES=1073741824
|
||||
```
|
||||
|
||||
Allow the configured ingest port only from trusted streaming networks. The
|
||||
default RTMP hop uses a 256-bit, session-scoped credential and never exposes the
|
||||
normal stream key, but RTMP itself is not encrypted. Put RTMPS at the network
|
||||
boundary, set `LUMI_STREAM_TEST_RTMPS=true`, and set
|
||||
`LUMI_STREAM_TEST_PUBLIC_PORT` to the proxy's external port. FFmpeg continues
|
||||
listening on the private `LUMI_STREAM_TEST_INGEST_PORT`.
|
||||
The player, playlists, segments, status, and stop endpoints always require an
|
||||
active Lumi administrator session.
|
||||
|
||||
Lumi automatically selects `h264_nvenc` when the installed FFmpeg advertises
|
||||
it, otherwise it uses bounded `libx264`. NVIDIA acceleration has no artificial
|
||||
model lock: an RTX 3060 12 GB is a supported production baseline, faster cards
|
||||
such as a 3080 Ti work without configuration changes, and CPU fallback remains
|
||||
available. The ladder contains the source plus 720p and 480p only when the
|
||||
source is larger, so it never upscales.
|
||||
|
||||
## Safety and recovery
|
||||
|
||||
Companion refuses to start while OBS is streaming or recording. During a test
|
||||
@ -73,20 +106,23 @@ running. Automatic restoration resumes when the same-user bridge reconnects.
|
||||
The **Repair restoration** action retries the same operation and does not create
|
||||
a new session.
|
||||
|
||||
The receiver stops on maximum duration, inactivity, temporary-storage limit,
|
||||
process failure, server shutdown, Companion disconnect, or an administrator
|
||||
stop. One active session is allowed today; UUID session identities and scoped
|
||||
paths are already used throughout so the model can be expanded later.
|
||||
The receiver stops on maximum duration, lack of inbound media, process failure,
|
||||
server shutdown, Companion disconnect, or administrator stop. OBS status
|
||||
messages do not reset the media-inactivity clock. Only one session is allowed.
|
||||
MediaMTX is reconfigured to a deny-by-default path set after every session.
|
||||
|
||||
## Deterministic verification
|
||||
## Diagnostics and verification
|
||||
|
||||
**Run test pattern** on the Admin page creates a 1280×720 moving color/timing
|
||||
pattern with a stable 880 Hz audio tone. It exercises receiver startup,
|
||||
transcoding, adaptive HLS, audio, quality switching, player cleanup, and
|
||||
diagnostics without OBS. It uses the same limits and temporary-media cleanup as
|
||||
a Companion session.
|
||||
The Admin page reports real OBS bitrate, dropped frames, congestion, MediaMTX
|
||||
path readiness, media tracks, inbound/outbound bytes, HLS viewers, discarded
|
||||
frames, player latency, buffer health, stalls, errors, and caption delay.
|
||||
Only an allowlist of MediaMTX API and metrics fields is exposed. Publisher
|
||||
credentials are redacted from status, timeline entries, process logs, and
|
||||
errors.
|
||||
|
||||
The focused checks are:
|
||||
The runtime health check replaces the former transcoded synthetic test pattern.
|
||||
Focused verification uses a fake MediaMTX process with real loopback API,
|
||||
metrics, and HLS listeners:
|
||||
|
||||
```text
|
||||
npm run verify:stream-testing
|
||||
|
||||
24
docs/third-party/MediaMTX-MIT.txt
vendored
Normal file
24
docs/third-party/MediaMTX-MIT.txt
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
MediaMTX v1.18.2
|
||||
Project: https://github.com/bluenviron/mediamtx
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 aler9
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Lumi is the core web UI and bot runtime.
|
||||
## Runtime
|
||||
Package: lumi-bot
|
||||
Version: 0.3.2
|
||||
Version: 0.3.3
|
||||
## Routes
|
||||
- POST /api/diagnostics/v1/run
|
||||
- GET /api/events
|
||||
@ -96,9 +96,12 @@ Version: 0.3.2
|
||||
- GET /admin/stream-testing
|
||||
- GET /admin/stream-testing/status
|
||||
- POST /admin/stream-testing/stop
|
||||
- POST /admin/stream-testing/pattern/start
|
||||
- POST /admin/stream-testing/runtime/install
|
||||
- POST /admin/stream-testing/runtime/repair
|
||||
- POST /admin/stream-testing/runtime/health
|
||||
- POST /admin/stream-testing/player-metrics
|
||||
- GET /admin/stream-testing/media/:id/captions.vtt
|
||||
- GET /admin/stream-testing/media/:id/:name
|
||||
- GET /admin/stream-testing/media/:id/*
|
||||
- GET /admin/stream-testing/hls.js
|
||||
- POST /admin/diagnostics/run
|
||||
- POST /admin/diagnostics/access/renew
|
||||
@ -942,15 +945,42 @@ Version: 0.3.2
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/stream-testing/pattern/start
|
||||
### POST /admin/stream-testing/runtime/install
|
||||
|
||||
- Purpose: Processes the admin stream testing pattern start action and stores or applies submitted form data.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Purpose: Provides admin stream testing runtime install data as JSON.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/stream-testing/runtime/repair
|
||||
|
||||
- Purpose: Provides admin stream testing runtime repair data as JSON.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/stream-testing/runtime/health
|
||||
|
||||
- Purpose: Returns runtime health information.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/stream-testing/player-metrics
|
||||
|
||||
- Purpose: Provides admin stream testing player metrics data as JSON.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /admin/stream-testing/media/:id/captions.vtt
|
||||
|
||||
- Purpose: Handles admin stream testing media id captions vtt.
|
||||
@ -960,11 +990,11 @@ Version: 0.3.2
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /admin/stream-testing/media/:id/:name
|
||||
### GET /admin/stream-testing/media/:id/*
|
||||
|
||||
- Purpose: Handles admin stream testing media id name.
|
||||
- Inputs: path params: `id`, `name`
|
||||
- Response format: static file response
|
||||
- Purpose: Handles admin stream testing media id *.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||
## Metadata
|
||||
Plugin ID: lumi_transcription
|
||||
Version: 0.2.2
|
||||
Version: 0.2.3
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/lumi_transcription
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi-bot",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.6.0",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# Lumi Transcription changelog
|
||||
|
||||
## 0.2.3
|
||||
|
||||
- Added the MediaMTX private-test destination contract and OBS Bridge 0.2.4.
|
||||
- Isolated private tests in a dedicated libobs output so Twitch OAuth cannot replace the temporary destination, while preserving exact crash-safe service restoration.
|
||||
- Added process-aware failed-start recovery and a bounded retry when OBS is still stopping the private output.
|
||||
|
||||
## 0.2.2
|
||||
|
||||
- Fixed the managed OBS Bridge's streaming-service ownership so starting a private Stream Test no longer releases OBS's current service and crashes OBS.
|
||||
|
||||
@ -141,7 +141,7 @@ class CompanionGateway {
|
||||
case "stream_test_create": {
|
||||
const service = global.lumiFrameworks?.streamTesting;
|
||||
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
|
||||
const created = service.create(device, message.payload || {}, send);
|
||||
const created = await service.create(device, message.payload || {}, send);
|
||||
send("stream_test_session", created);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1,90 +1 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const AdmZip = require("adm-zip");
|
||||
|
||||
class ArtifactManager {
|
||||
constructor(root, options = {}) { this.root = root; this.fetch = options.fetch || global.fetch; fs.mkdirSync(root, { recursive: true }); }
|
||||
status(entry) {
|
||||
const target = path.join(this.root, entry.filename || entry.id);
|
||||
if (!fs.existsSync(target)) return { installed: false, valid: false, path: target };
|
||||
return { installed: true, valid: sha256File(target) === entry.sha256, path: target, bytes: fs.statSync(target).size };
|
||||
}
|
||||
async download(entry, options = {}) {
|
||||
validateManifestEntry(entry);
|
||||
if (options.confirmed !== true) throw new Error("Download requires explicit setup confirmation.");
|
||||
const filename = entry.filename || path.basename(new URL(entry.url).pathname);
|
||||
const target = path.join(this.root, filename);
|
||||
const partial = `${target}.${process.pid}.partial`;
|
||||
fs.rmSync(partial, { force: true });
|
||||
try {
|
||||
const response = await this.fetch(entry.url, { redirect: "follow" });
|
||||
if (!response.ok || !response.body) throw new Error(`Download failed with HTTP ${response.status}.`);
|
||||
const handle = fs.createWriteStream(partial, { flags: "wx", mode: 0o600 });
|
||||
for await (const chunk of response.body) {
|
||||
if (!handle.write(chunk)) await new Promise((resolve) => handle.once("drain", resolve));
|
||||
}
|
||||
await new Promise((resolve, reject) => handle.end((error) => error ? reject(error) : resolve()));
|
||||
if (Number.isSafeInteger(entry.bytes) && fs.statSync(partial).size !== entry.bytes) throw new Error(`Downloaded size mismatch for ${entry.id}.`);
|
||||
const actual = sha256File(partial);
|
||||
if (actual !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
|
||||
fs.renameSync(partial, target);
|
||||
return this.status({ ...entry, filename });
|
||||
} finally { fs.rmSync(partial, { force: true }); }
|
||||
}
|
||||
installZip(entry, archivePath) {
|
||||
validateManifestEntry(entry);
|
||||
if (sha256File(archivePath) !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
|
||||
const target = path.join(this.root, entry.id);
|
||||
const staged = `${target}.${process.pid}.staged`;
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
fs.mkdirSync(staged, { recursive: true });
|
||||
const zip = new AdmZip(archivePath);
|
||||
for (const item of zip.getEntries()) {
|
||||
const portable = String(item.entryName).replace(/\\/g, "/");
|
||||
const relative = path.posix.normalize(portable).replace(/^\/+/, "");
|
||||
if (!relative || relative === ".." || relative.startsWith("../") || /^[A-Za-z]:/.test(relative)) throw new Error("Runtime archive contains an unsafe path.");
|
||||
}
|
||||
zip.extractAllTo(staged, true);
|
||||
for (const expected of entry.expected_paths || []) if (!findBasename(staged, expected)) throw new Error(`Runtime is missing ${expected}.`);
|
||||
replaceDirectory(staged, target);
|
||||
return { installed: true, path: target, backend: entry.backend, version: entry.id };
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifestEntry(entry) { if (!entry?.id || !/^https:\/\//.test(entry.url || "") || !/^[a-f0-9]{64}$/.test(entry.sha256 || "")) throw new Error("Artifact manifest entry is invalid."); }
|
||||
function sha256File(target) { const hash = crypto.createHash("sha256"); const fd = fs.openSync(target, "r"); const buffer = Buffer.alloc(1024 * 1024); try { let read; while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read)); } finally { fs.closeSync(fd); } return hash.digest("hex"); }
|
||||
function findBasename(root, basename) { const pending = [root]; while (pending.length) { const current = pending.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const target = path.join(current, entry.name); if (entry.isDirectory()) pending.push(target); else if (entry.name === basename) return target; } } return null; }
|
||||
function replaceDirectory(staged, target) {
|
||||
const backup = `${target}.${process.pid}.backup`;
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
const hadTarget = fs.existsSync(target);
|
||||
let backupDisposable = false;
|
||||
try {
|
||||
if (hadTarget) moveDirectory(target, backup);
|
||||
moveDirectory(staged, target);
|
||||
backupDisposable = true;
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
if (hadTarget && fs.existsSync(backup)) {
|
||||
moveDirectory(backup, target);
|
||||
backupDisposable = true;
|
||||
} else if (!hadTarget) backupDisposable = true;
|
||||
throw error;
|
||||
} finally {
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
if (backupDisposable) fs.rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
function moveDirectory(source, target) {
|
||||
try { fs.renameSync(source, target); }
|
||||
catch (error) {
|
||||
if (!["EPERM", "EACCES", "EXDEV"].includes(error.code)) throw error;
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.cpSync(source, target, { recursive: true, force: true });
|
||||
fs.rmSync(source, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ArtifactManager, sha256File, validateManifestEntry };
|
||||
module.exports = require("../../../../src/services/artifact-manager");
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"signed": false,
|
||||
"release_notes": "Fixes the OBS Bridge streaming-service ownership crash and requires the corrected bridge before another private Stream Test.",
|
||||
"release_notes": "Adds the MediaMTX private-test handoff, a dedicated OBS output immune to account OAuth destination changes, exact recovery retry handling, and OBS Bridge 0.2.4.",
|
||||
"installer": {
|
||||
"id": "windows-x64-installer",
|
||||
"platform": "win32",
|
||||
"architecture": "x64",
|
||||
"label": "Windows x64 per-user installer",
|
||||
"filename": "Lumi.Companion-Setup.exe",
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.2/Lumi.Companion-Setup.exe",
|
||||
"sha256": "67a1dc04e3bd75649ba4191142a88ead406acf4338ec07e564e502215e82f636",
|
||||
"bytes": 51864724
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.3/Lumi.Companion-Setup.exe",
|
||||
"sha256": "fddb7f25b866de0645b6e20875145170c95666000db18ed75dfc1f6af41dbd72",
|
||||
"bytes": 51870710
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
@ -20,9 +20,9 @@
|
||||
"architecture": "x64",
|
||||
"label": "Windows x64 self-contained",
|
||||
"filename": "Lumi.Companion-win-x64.zip",
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.2/Lumi.Companion-win-x64.zip",
|
||||
"sha256": "5cd1f6e0d8a507db8e25b2d1db9608d4c5fcf20a9140d04bcb7150fb83a08a2f",
|
||||
"bytes": 66773904,
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.3/Lumi.Companion-win-x64.zip",
|
||||
"sha256": "ff7d5b2c2d2928c4db4cb8c4e33702859a0739312294c32c1d95f16d50ee185a",
|
||||
"bytes": 66774831,
|
||||
"entrypoint": "Lumi.Companion.App.exe"
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "lumi_transcription",
|
||||
"name": "Lumi Transcription",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
||||
"main": "index.js",
|
||||
"channel": "stable",
|
||||
|
||||
@ -2,6 +2,38 @@
|
||||
"schema_version": 1,
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.3.3",
|
||||
"ref": "refs/tags/v0.3.3",
|
||||
"released_at": "2026-07-25",
|
||||
"installable": true,
|
||||
"rollback_safe": true,
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"data_policy": "preserve",
|
||||
"dependency_policy": "sync_on_restart",
|
||||
"migration_notes": "Replaces the FFmpeg Stream Testing receiver with Lumi-managed checksum-pinned MediaMTX source remuxing, authenticated same-origin playback, and factual receiver diagnostics. Existing local and plugin data remains preserved.",
|
||||
"plugins": {
|
||||
"auto-vc": "0.1.6",
|
||||
"birthday": "0.1.3",
|
||||
"economy-framework": "0.2.10",
|
||||
"economy-games": "0.1.7",
|
||||
"expression-interaction": "0.2.1",
|
||||
"lumi_ai": "0.8.5",
|
||||
"lumi_transcription": "0.2.3",
|
||||
"moderation": "0.1.5",
|
||||
"now_playing": "0.1.2",
|
||||
"okf": "0.1.2",
|
||||
"quotes": "0.1.2",
|
||||
"sample-plugin": "0.1.0",
|
||||
"throne_wishlist": "0.1.2",
|
||||
"welcome_messages": "0.1.1"
|
||||
},
|
||||
"tools": {
|
||||
"lumi_ai_web_search": "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.3.2",
|
||||
"ref": "refs/tags/v0.3.2",
|
||||
|
||||
63
scripts/fixtures/fake-mediamtx.js
Normal file
63
scripts/fixtures/fake-mediamtx.js
Normal file
@ -0,0 +1,63 @@
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const config = fs.readFileSync(process.argv[2], "utf8");
|
||||
const portFor = (key) => Number(new RegExp(`^${key}:\\s*"127\\.0\\.0\\.1:(\\d+)"`, "m").exec(config)?.[1]);
|
||||
const pathName = /^paths:\s*\n\s+"([^"]+)":/m.exec(config)?.[1] || null;
|
||||
const apiPort = portFor("apiAddress");
|
||||
const metricsPort = portFor("metricsAddress");
|
||||
const hlsPort = portFor("hlsAddress");
|
||||
const servers = [];
|
||||
|
||||
function listen(port, handler) {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(port, "127.0.0.1");
|
||||
servers.push(server);
|
||||
}
|
||||
|
||||
listen(apiPort, (req, res) => {
|
||||
if (req.url !== "/v3/paths/list") return res.writeHead(404).end();
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({
|
||||
itemCount: pathName ? 1 : 0,
|
||||
pageCount: 1,
|
||||
items: pathName ? [{
|
||||
name: pathName,
|
||||
ready: false,
|
||||
tracks: [],
|
||||
bytesReceived: 0,
|
||||
bytesSent: 0,
|
||||
readers: []
|
||||
}] : []
|
||||
}));
|
||||
});
|
||||
|
||||
listen(metricsPort, (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(pathName
|
||||
? `paths{name=${JSON.stringify(pathName)}} 1\npaths_readers{name=${JSON.stringify(pathName)}} 0\npaths_inbound_bytes{name=${JSON.stringify(pathName)}} 0\npaths_outbound_bytes{name=${JSON.stringify(pathName)}} 0\nhls_sessions 0\n`
|
||||
: "paths 0\nhls_sessions 0\n");
|
||||
});
|
||||
|
||||
listen(hlsPort, (req, res) => {
|
||||
if (!pathName || !req.url.startsWith(`/${pathName}/`)) return res.writeHead(404).end();
|
||||
if (req.url.includes("index.m3u8")) {
|
||||
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
||||
return res.end("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:1,\nsegment0.mp4\n");
|
||||
}
|
||||
res.setHeader("Content-Type", "video/mp4");
|
||||
res.end(Buffer.from("fake-media"));
|
||||
});
|
||||
|
||||
function close() {
|
||||
let pending = servers.length;
|
||||
if (!pending) return process.exit(0);
|
||||
for (const server of servers) server.close(() => {
|
||||
pending -= 1;
|
||||
if (!pending) process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(0), 500).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", close);
|
||||
process.on("SIGTERM", close);
|
||||
@ -4,12 +4,12 @@ const path = require("path");
|
||||
const { findSafeTarget } = require("../src/services/versioning");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const releaseVersion = "0.3.2";
|
||||
const previousStableVersion = "0.3.1";
|
||||
const priorStableVersion = "0.3.0";
|
||||
const releaseVersion = "0.3.3";
|
||||
const previousStableVersion = "0.3.2";
|
||||
const priorStableVersion = "0.3.1";
|
||||
const earliestCompatibleCoreVersion = "0.1.9";
|
||||
const introducedPlugins = {
|
||||
lumi_transcription: { version: "0.2.2", knowledge: "lumi-transcription" },
|
||||
lumi_transcription: { version: "0.2.3", knowledge: "lumi-transcription" },
|
||||
now_playing: { version: "0.1.2", knowledge: "now-playing" }
|
||||
};
|
||||
|
||||
@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||
|
||||
console.log("Release metadata verification passed: stable core 0.3.2 after 0.3.1 with synchronized Companion plugin metadata.");
|
||||
console.log("Release metadata verification passed: stable core 0.3.3 after 0.3.2 with synchronized Companion plugin metadata.");
|
||||
|
||||
@ -1,92 +1,400 @@
|
||||
const assert = require("assert");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { EventEmitter } = require("events");
|
||||
const { spawn } = require("child_process");
|
||||
const AdmZip = require("adm-zip");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const { ladderFor, ffmpegArgs, streamTestingService, resolveFfmpegExecutable } = require("../src/services/stream-testing");
|
||||
const {
|
||||
ArtifactManager,
|
||||
safeArchivePath,
|
||||
validateManifestEntry
|
||||
} = require("../src/services/artifact-manager");
|
||||
const {
|
||||
MediaMtxRuntime,
|
||||
parseMetrics,
|
||||
readResponseText,
|
||||
renderConfig,
|
||||
sha256Credential,
|
||||
validateHlsAsset
|
||||
} = require("../src/services/mediamtx-runtime");
|
||||
const {
|
||||
StreamTestingService,
|
||||
INACTIVITY_MS,
|
||||
isPrivateAddress,
|
||||
resolveIngestConfiguration,
|
||||
rewriteManifest,
|
||||
sourceFor,
|
||||
validateHostname
|
||||
} = require("../src/services/stream-testing");
|
||||
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
|
||||
|
||||
async function main() {
|
||||
assert.equal(typeof resolveFfmpegExecutable(), "string");
|
||||
const fullHd = ladderFor({ width: 1920, height: 1080, fps: 60 });
|
||||
assert.deepStrictEqual(fullHd.variants.map((item) => item.name), ["source", "720p", "480p"]);
|
||||
assert(fullHd.variants.every((item) => item.width <= 1920 && item.height <= 1080), "adaptive ladder must never upscale");
|
||||
const hd = ladderFor({ width: 1280, height: 720, fps: 30 });
|
||||
assert.deepStrictEqual(hd.variants.map((item) => item.name), ["source", "480p"]);
|
||||
const sd = ladderFor({ width: 854, height: 480, fps: 30 });
|
||||
assert.deepStrictEqual(sd.variants.map((item) => item.name), ["source"]);
|
||||
|
||||
const fakeSession = {
|
||||
source: fullHd,
|
||||
receiver: { encoder: "h264_nvenc" },
|
||||
listenerUrl: "rtmp://0.0.0.0:19350/lumi-test/private-secret",
|
||||
directory: path.join(root, ".tmp-stream-test"),
|
||||
};
|
||||
const args = ffmpegArgs(fakeSession);
|
||||
assert(args.includes("h264_nvenc") && args.includes("master.m3u8"));
|
||||
assert(args.includes("v:0,a:0,name:source v:1,a:1,name:720p v:2,a:2,name:480p"));
|
||||
assert(args.includes("delete_segments+independent_segments+program_date_time+temp_file"));
|
||||
assert(args.includes("512") && args.includes("1024"), "receiver queues must be bounded");
|
||||
|
||||
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]) {
|
||||
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
|
||||
assert.strictEqual(parsed.type, type);
|
||||
class FakeRuntime extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.session = null;
|
||||
this.running = false;
|
||||
this.cleared = 0;
|
||||
this.pathReady = true;
|
||||
this.inboundBytes = 4096;
|
||||
}
|
||||
status() {
|
||||
return {
|
||||
available: true,
|
||||
installed: true,
|
||||
valid: true,
|
||||
version: "v1.18.2",
|
||||
tested_version: "v1.18.2",
|
||||
source: "managed by Lumi",
|
||||
managed_install_supported: true,
|
||||
detail: "MediaMTX v1.18.2 is ready.",
|
||||
process: { state: this.running ? "ready" : "stopped", running: this.running, restart_count: 0, log_tail: "" }
|
||||
};
|
||||
}
|
||||
async configureSession(session) { this.session = session; this.running = true; }
|
||||
async clearSession() { this.session = null; this.cleared += 1; }
|
||||
async diagnostics(sessionPath) {
|
||||
return {
|
||||
path: { name: sessionPath, ready: this.pathReady, tracks: ["H264", "MPEG4Audio"], bytes_received: this.inboundBytes, bytes_sent: 2048, readers: 1 },
|
||||
metrics: { paths_inbound_bytes: this.inboundBytes, paths_outbound_bytes: 2048, hls_sessions: 1 },
|
||||
process: { state: "ready", running: true }
|
||||
};
|
||||
}
|
||||
async install() { return this.status(); }
|
||||
async repair() { return this.status(); }
|
||||
async healthCheck() { return { ok: true, version: "v1.18.2", api_status: 200, metrics_status: 200, hls_status: 404 }; }
|
||||
async close() { this.running = false; }
|
||||
}
|
||||
|
||||
const server = fs.readFileSync(path.join(root, "src/web/server.js"), "utf8");
|
||||
const service = fs.readFileSync(path.join(root, "src/services/stream-testing.js"), "utf8");
|
||||
const gateway = fs.readFileSync(path.join(root, "plugins/lumi_transcription/backend/companion/gateway.js"), "utf8");
|
||||
const nativeBridge = fs.readFileSync(path.join(root, "companion/native/obs-bridge/src/plugin.cpp"), "utf8");
|
||||
const companion = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionRuntime.cs"), "utf8");
|
||||
const companionUi = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
|
||||
const companionView = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8");
|
||||
const bridgeManager = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/ObsBridgeManager.cs"), "utf8");
|
||||
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
|
||||
async function verifyArtifactManager(tempRoot) {
|
||||
assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/);
|
||||
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("LICENSE", Buffer.from("MIT"));
|
||||
zip.addFile("mediamtx.exe", Buffer.from("fake executable"));
|
||||
zip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
|
||||
const archive = zip.toBuffer();
|
||||
const entry = {
|
||||
id: "fake-mediamtx",
|
||||
install_id: "mediamtx",
|
||||
backend: "mediamtx",
|
||||
version: "v1.18.2",
|
||||
url: "https://example.invalid/mediamtx.zip",
|
||||
filename: "mediamtx.zip",
|
||||
sha256: crypto.createHash("sha256").update(archive).digest("hex"),
|
||||
bytes: archive.length,
|
||||
max_bytes: archive.length + 1,
|
||||
max_extracted_bytes: 1024,
|
||||
expected_entries: ["LICENSE", "mediamtx.exe", "mediamtx.yml"]
|
||||
};
|
||||
const manager = new ArtifactManager(path.join(tempRoot, "artifacts"), {
|
||||
fetch: async () => new Response(archive, {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(archive.length) }
|
||||
})
|
||||
});
|
||||
await assert.rejects(() => manager.download(entry), /explicit setup confirmation/);
|
||||
const downloaded = await manager.download(entry, { confirmed: true });
|
||||
assert(downloaded.valid, "downloaded artifact must match its pinned SHA-256");
|
||||
const installed = manager.installZip(entry, downloaded.path, {
|
||||
verify: (staged) => assert(fs.existsSync(path.join(staged, "mediamtx.exe")))
|
||||
});
|
||||
assert(fs.existsSync(path.join(installed.path, "mediamtx.exe")), "validated archive must install atomically");
|
||||
const originalExecutable = fs.readFileSync(path.join(installed.path, "mediamtx.exe"), "utf8");
|
||||
const replacementZip = new AdmZip();
|
||||
replacementZip.addFile("LICENSE", Buffer.from("MIT replacement"));
|
||||
replacementZip.addFile("mediamtx.exe", Buffer.from("broken replacement"));
|
||||
replacementZip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
|
||||
const replacementArchive = replacementZip.toBuffer();
|
||||
const replacementPath = path.join(tempRoot, "replacement.zip");
|
||||
fs.writeFileSync(replacementPath, replacementArchive);
|
||||
const replacementEntry = {
|
||||
...entry,
|
||||
sha256: crypto.createHash("sha256").update(replacementArchive).digest("hex"),
|
||||
bytes: replacementArchive.length
|
||||
};
|
||||
assert.throws(
|
||||
() => manager.installZip(replacementEntry, replacementPath, {
|
||||
verifyInstalled: () => { throw new Error("replacement probe failed"); }
|
||||
}),
|
||||
/replacement probe failed/
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(installed.path, "mediamtx.exe"), "utf8"),
|
||||
originalExecutable,
|
||||
"a failed post-install probe must restore the last known-good runtime"
|
||||
);
|
||||
assert.throws(
|
||||
() => manager.installZip({ ...entry, sha256: "0".repeat(64) }, downloaded.path),
|
||||
/Checksum mismatch/
|
||||
);
|
||||
}
|
||||
|
||||
assert.match(server, /admin\/stream-testing", requireRole\("admin"\)/);
|
||||
assert.match(server, /admin\/stream-testing\/media\/:id\/:name", requireRole\("admin"\)/);
|
||||
assert.match(service, /MAX_SESSION_MS/);
|
||||
assert.match(service, /INACTIVITY_MS/);
|
||||
assert.match(service, /MAX_OUTPUT_BYTES/);
|
||||
assert.match(service, /redactDiagnostic/);
|
||||
assert.match(service, /caption_delay/);
|
||||
assert.match(service, /receiver_slow/);
|
||||
assert.match(service, /PUBLIC_INGEST_PORT/);
|
||||
assert.match(service, /Microsoft", "WinGet", "Packages/);
|
||||
assert.strictEqual(/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test("master.m3u8"), true);
|
||||
assert.match(service, /spawn\(FFMPEG, args, \{[^}]*stdio:/s);
|
||||
assert.match(service, /testsrc2=size=1280x720:rate=30/);
|
||||
assert.match(service, /sine=frequency=880:sample_rate=48000/);
|
||||
assert(!service.includes("shell: true"), "receiver commands must not use a shell");
|
||||
assert.match(gateway, /stream_test_create/);
|
||||
assert.match(gateway, /Companion disconnected; the private receiver was stopped/);
|
||||
assert.match(nativeBridge, /stream_test_snapshot/);
|
||||
assert.match(nativeBridge, /obs_frontend_save_streaming_service/);
|
||||
assert.match(nativeBridge, /stream_test_restore/);
|
||||
assert(!nativeBridge.includes("obs_service_release(existing)"), "OBS frontend service getters return borrowed references that must not be released");
|
||||
assert.match(nativeBridge, /obs_frontend_set_streaming_service\(test\)[\s\S]*obs_service_release\(test\)/);
|
||||
assert.match(nativeBridge, /obs_frontend_set_streaming_service\(restored\)[\s\S]*obs_service_release\(restored\)/);
|
||||
assert.match(companion, /StreamTestRecoveryStore/);
|
||||
assert.match(companion, /StartStreamTestAsync/);
|
||||
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
|
||||
assert.match(companion, /finally\s*\{\s*Interlocked\.Exchange\(ref _streamTestRestoreRunning, 0\)/s);
|
||||
assert.match(companion, /StreamTestDetail = streamTestPending/);
|
||||
assert.match(companion, /stream_test_start_failed/);
|
||||
assert.match(companionUi, /CheckUpdateButton\.IsEnabled = !state\.UpdateCheckRunning/);
|
||||
assert.match(companionUi, /CheckUpdateButton\.Content = state\.UpdateCheckRunning \? "Checking…" : "Check now"/);
|
||||
assert.match(companionUi, /ObsBridgeUpdateAvailable/);
|
||||
assert.match(companionUi, /Update OBS Bridge/);
|
||||
assert.match(companionView, /OverviewBridgeUpdatePanel/);
|
||||
assert.match(companionView, /ConnectionBridgeUpdatePanel/);
|
||||
assert.match(bridgeManager, /InstalledVersion/);
|
||||
assert.match(bridgeManager, /UpdateAvailable/);
|
||||
assert.match(webUi, /data-stream-player/);
|
||||
assert.match(webUi, /data-stream-quality/);
|
||||
assert.match(webUi, /data-stream-captions/);
|
||||
assert.match(webUi, /data-stream-timeline/);
|
||||
async function verifyManagedRuntime(tempRoot) {
|
||||
const runtimeRoot = path.join(tempRoot, "runtime");
|
||||
const executable = path.join(runtimeRoot, "mediamtx", "mediamtx.exe");
|
||||
fs.mkdirSync(path.dirname(executable), { recursive: true });
|
||||
fs.writeFileSync(executable, "fake");
|
||||
const fixture = path.join(root, "scripts", "fixtures", "fake-mediamtx.js");
|
||||
const runtime = new MediaMtxRuntime({
|
||||
runtimeRoot,
|
||||
configRoot: path.join(tempRoot, "config"),
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
spawn: (_executable, args, options) => spawn(process.execPath, [fixture, args[0]], options),
|
||||
spawnSync: () => ({ status: 0, stdout: "v1.18.2\n", stderr: "" })
|
||||
});
|
||||
await assert.rejects(() => runtime.install(), /administrator confirmation/);
|
||||
await assert.rejects(() => runtime.repair(), /administrator confirmation/);
|
||||
assert.equal(runtime.probeExecutable(executable).version, "v1.18.2", "manual executable probes must remain bounded and versioned");
|
||||
const health = await runtime.healthCheck();
|
||||
assert(health.ok && health.restart_verified && health.api_status === 200 && health.metrics_status === 200, "fake MediaMTX listeners and supervised restart must pass the runtime health check");
|
||||
const session = {
|
||||
path: "lumi-test/00000000-0000-4000-8000-000000000001",
|
||||
username: "private-user",
|
||||
password: "private-password",
|
||||
transport: "rtmp",
|
||||
ingestPort: 29350
|
||||
};
|
||||
await runtime.configureSession(session);
|
||||
const generated = fs.readFileSync(runtime.configPath, "utf8");
|
||||
assert(!generated.includes(session.username) && !generated.includes(session.password), "generated MediaMTX config must not persist plaintext publisher credentials");
|
||||
assert(generated.includes(sha256Credential(session.username)) && generated.includes(sha256Credential(session.password)));
|
||||
assert.match(generated, /rtsp: false/);
|
||||
assert.match(generated, /webrtc: false/);
|
||||
assert.match(generated, /srt: false/);
|
||||
assert.match(generated, /hlsVariant: lowLatency/);
|
||||
assert.match(generated, /maxReaders: 4/);
|
||||
assert.match(generated, /rtmpAddress: "127\.0\.0\.1:29350"/);
|
||||
const diagnostic = await runtime.diagnostics(session.path);
|
||||
assert.equal(diagnostic.path.name, session.path);
|
||||
const playlist = await runtime.fetchHls(session.path, "index.m3u8");
|
||||
assert.equal(playlist.status, 200);
|
||||
assert.match(await playlist.text(), /segment0\.mp4/);
|
||||
assert.throws(() => validateHlsAsset("../secret"), /Invalid media path/);
|
||||
await runtime.clearSession();
|
||||
const denyConfig = fs.readFileSync(runtime.configPath, "utf8");
|
||||
assert.match(denyConfig, /paths: \{\}/);
|
||||
assert(!denyConfig.includes("private-user"));
|
||||
await runtime.close();
|
||||
}
|
||||
|
||||
await streamTestingService.close();
|
||||
console.log("Stream testing and Companion lifecycle verification passed.");
|
||||
async function verifyStreamService() {
|
||||
const old = {
|
||||
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
|
||||
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
|
||||
insecure: process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE
|
||||
};
|
||||
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
|
||||
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
|
||||
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
|
||||
const runtime = new FakeRuntime();
|
||||
const service = new StreamTestingService({ runtime, timer: false });
|
||||
const sent = [];
|
||||
try {
|
||||
const created = await service.create(
|
||||
{ id: "device-1", name: "Studio PC", pairing_host: "http://localhost:3000" },
|
||||
{ source: { width: 2560, height: 1440, fps: 60 } },
|
||||
(type, payload) => sent.push({ type, payload })
|
||||
);
|
||||
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/);
|
||||
assert.match(created.ingest.key, /^[0-9a-f-]{36}\?user=/);
|
||||
assert.equal(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder");
|
||||
assert.equal(runtime.session.path, `lumi-test/${created.id}`);
|
||||
assert(runtime.session.username && runtime.session.password);
|
||||
assert.equal(runtime.session.bindHost, "127.0.0.1", "LAN-only RTMP must bind to the resolved private interface");
|
||||
await assert.rejects(
|
||||
() => service.create({ id: "device-2", pairing_host: "http://localhost:3000" }, {}),
|
||||
(error) => error.code === "STREAM_TEST_ACTIVE"
|
||||
);
|
||||
await service.sweep();
|
||||
const status = service.publicStatus();
|
||||
assert.equal(status.session.state, "receiving");
|
||||
assert.equal(status.session.metrics.receiver.path_ready, true);
|
||||
assert(Object.hasOwn(status.receiver.process, "log_tail"), "admin diagnostics must include a bounded sanitized MediaMTX log tail");
|
||||
assert(!JSON.stringify(status).includes("127.0.0.1:"), "browser status must not expose loopback MediaMTX origins");
|
||||
service.updateObs("device-1", {
|
||||
session_id: created.id,
|
||||
bitrate_kbps: 6000,
|
||||
dropped_frames: 2,
|
||||
total_frames: 10000,
|
||||
congestion: 0
|
||||
});
|
||||
service.addCaption("device-1", { session_id: created.id, text: "Private caption", delay_ms: 120 });
|
||||
assert.throws(
|
||||
() => service.addCaption("device-2", { session_id: created.id, text: "Wrong device" }),
|
||||
/not active for this Companion/
|
||||
);
|
||||
service.reportPlayer({ session_id: created.id, latency_seconds: 1.2, buffer_seconds: 1.5, stalls: 0, errors: 0 });
|
||||
assert.match(service.captionFile(created.id), /Private caption/);
|
||||
const endedSession = service.active;
|
||||
await service.stop({ session_id: created.id, reason: "Verification complete." });
|
||||
assert.equal(runtime.cleared, 1);
|
||||
assert.equal(endedSession.username, null);
|
||||
assert.equal(endedSession.password, null);
|
||||
assert.equal(sent.at(-1).type, "stream_test_ended");
|
||||
await assert.rejects(
|
||||
() => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}),
|
||||
(error) => error.code === "STREAM_TEST_RATE_LIMIT"
|
||||
);
|
||||
|
||||
const expiring = await service.create(
|
||||
{ id: "device-2", pairing_host: "http://localhost:3000" },
|
||||
{}
|
||||
);
|
||||
service.active.expiresAt = Date.now() - 1;
|
||||
await service.sweep();
|
||||
assert.equal(service.active, null, "session expiry must revoke the MediaMTX path and end the session");
|
||||
|
||||
const inactive = await service.create(
|
||||
{ id: "device-3", pairing_host: "http://localhost:3000" },
|
||||
{}
|
||||
);
|
||||
service.active.lastInboundBytes = runtime.inboundBytes;
|
||||
service.active.lastMediaAt = Date.now() - INACTIVITY_MS - 1;
|
||||
await service.sweep();
|
||||
assert.equal(service.active, null, "stalled inbound bytes must trigger the inactivity recovery path");
|
||||
|
||||
const failed = await service.create(
|
||||
{ id: "device-4", pairing_host: "http://localhost:3000" },
|
||||
{}
|
||||
);
|
||||
runtime.emit("failed", new Error("fixture process exited"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(service.active, null, "an unexpected MediaMTX exit must end the session and request OBS recovery");
|
||||
assert([created.id, expiring.id, inactive.id, failed.id].every(Boolean));
|
||||
} finally {
|
||||
await service.close();
|
||||
if (old.host === undefined) delete process.env.LUMI_STREAM_TEST_INGEST_HOST; else process.env.LUMI_STREAM_TEST_INGEST_HOST = old.host;
|
||||
if (old.transport === undefined) delete process.env.LUMI_STREAM_TEST_TRANSPORT; else process.env.LUMI_STREAM_TEST_TRANSPORT = old.transport;
|
||||
if (old.insecure === undefined) delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE; else process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE = old.insecure;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-stream-testing-"));
|
||||
try {
|
||||
assert.equal(validateHostname("stream.example.com"), "stream.example.com");
|
||||
assert.equal(validateHostname("https://stream.example.com"), "");
|
||||
assert.equal(validateHostname("[::1]"), "[::1]");
|
||||
assert(isPrivateAddress("127.0.0.1") && isPrivateAddress("192.168.1.20") && !isPrivateAddress("8.8.8.8"));
|
||||
assert.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]);
|
||||
assert.match(rewriteManifest(
|
||||
"#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\nsegment0.mp4\n",
|
||||
"/admin/stream-testing/media/test/",
|
||||
"lumi-test/test"
|
||||
), /URI="\/admin\/stream-testing\/media\/test\/init\.mp4"[\s\S]*\/admin\/stream-testing\/media\/test\/segment0\.mp4/);
|
||||
assert.throws(() => rewriteManifest("../secret", "/proxy/", "lumi-test/test"), /unsafe playlist URI/);
|
||||
assert.throws(() => rewriteManifest("https://foreign.invalid/segment.mp4", "/proxy/", "lumi-test/test"), /foreign playlist URI/);
|
||||
assert.deepStrictEqual(
|
||||
parseMetrics('paths_inbound_bytes{name="lumi-test/id"} 42\nunapproved_metric 9000\n', "lumi-test/id"),
|
||||
{ paths_inbound_bytes: 42 }
|
||||
);
|
||||
await assert.rejects(
|
||||
() => readResponseText(new Response("x".repeat(32), { headers: { "Content-Length": "32" } }), 16),
|
||||
/oversized diagnostic response/
|
||||
);
|
||||
const config = renderConfig({
|
||||
apiPort: 10001,
|
||||
metricsPort: 10002,
|
||||
hlsPort: 10003,
|
||||
session: { path: "lumi-test/id", username: "user", password: "pass", transport: "rtmp", ingestPort: 19350 }
|
||||
});
|
||||
assert(!config.includes('\n pass: "pass"') && config.includes("sha256:"));
|
||||
assert(!config.includes("ffmpeg"));
|
||||
assert.match(config, /record: false/);
|
||||
assert.match(config, /maxReaders: 4/);
|
||||
assert.match(config, /paths:\n "lumi-test\/id":/);
|
||||
assert(!/action: publish[\s\S]*path: all/.test(config));
|
||||
|
||||
const oldNetwork = {
|
||||
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
|
||||
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
|
||||
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
|
||||
key: process.env.LUMI_STREAM_TEST_TLS_KEY,
|
||||
insecure: process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE
|
||||
};
|
||||
try {
|
||||
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
|
||||
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
|
||||
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
|
||||
await assert.rejects(
|
||||
() => resolveIngestConfiguration({ pairing_host: "https://8.8.8.8" }),
|
||||
(error) => error.code === "STREAM_TEST_INSECURE_REMOTE"
|
||||
);
|
||||
const cert = path.join(tempRoot, "cert.pem");
|
||||
const key = path.join(tempRoot, "key.pem");
|
||||
fs.writeFileSync(cert, "test certificate");
|
||||
fs.writeFileSync(key, "test key");
|
||||
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps";
|
||||
process.env.LUMI_STREAM_TEST_TLS_CERT = cert;
|
||||
process.env.LUMI_STREAM_TEST_TLS_KEY = key;
|
||||
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" });
|
||||
assert.equal(secure.transport, "rtmps");
|
||||
assert.equal(secure.host, "lumi.example.test");
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries({
|
||||
LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host,
|
||||
LUMI_STREAM_TEST_TRANSPORT: oldNetwork.transport,
|
||||
LUMI_STREAM_TEST_TLS_CERT: oldNetwork.cert,
|
||||
LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key,
|
||||
LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE: oldNetwork.insecure
|
||||
})) {
|
||||
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]) {
|
||||
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
|
||||
assert.strictEqual(parsed.type, type);
|
||||
}
|
||||
|
||||
await verifyArtifactManager(tempRoot);
|
||||
await verifyManagedRuntime(tempRoot);
|
||||
await verifyStreamService();
|
||||
|
||||
const server = fs.readFileSync(path.join(root, "src/web/server.js"), "utf8");
|
||||
const service = fs.readFileSync(path.join(root, "src/services/stream-testing.js"), "utf8");
|
||||
const runtime = fs.readFileSync(path.join(root, "src/services/mediamtx-runtime.js"), "utf8");
|
||||
const gateway = fs.readFileSync(path.join(root, "plugins/lumi_transcription/backend/companion/gateway.js"), "utf8");
|
||||
const nativeBridge = fs.readFileSync(path.join(root, "companion/native/obs-bridge/src/plugin.cpp"), "utf8");
|
||||
const companion = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionRuntime.cs"), "utf8");
|
||||
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
|
||||
assert.match(server, /admin\/stream-testing\/runtime\/install/);
|
||||
assert.match(server, /admin\/stream-testing\/media\/:id\/\*/);
|
||||
assert.match(server, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/);
|
||||
assert.match(server, /streamTestingService\.proxyMedia/);
|
||||
assert.match(service, /INACTIVITY_MS/);
|
||||
assert.match(service, /caption_delay/);
|
||||
assert.match(service, /LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE/);
|
||||
assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
|
||||
assert(!/shell:\s*true/.test(runtime));
|
||||
assert.match(gateway, /await service\.create/);
|
||||
assert.match(gateway, /Companion disconnected; the private receiver was stopped/);
|
||||
assert.match(nativeBridge, /stream_test_snapshot/);
|
||||
assert.match(nativeBridge, /stream_test_restore/);
|
||||
assert.match(nativeBridge, /obs_process_id/);
|
||||
assert.match(nativeBridge, /obs_output_start\(output\)/);
|
||||
assert(!nativeBridge.includes("obs_frontend_streaming_start();"));
|
||||
assert(!nativeBridge.includes("obs_encoder_release(encoder);"));
|
||||
assert.match(companion, /StreamTestRecoveryStore/);
|
||||
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
|
||||
assert.match(companion, /StreamTestObsRestartRequired/);
|
||||
assert.match(companion, /LastObsProcessId/);
|
||||
assert.match(companion, /Restart OBS before trying again/);
|
||||
assert.match(webUi, /data-runtime-install/);
|
||||
assert.match(webUi, /data-runtime-health/);
|
||||
assert.match(webUi, /layout-bottom[\s\S]*admin\/stream-testing\/hls\.js[\s\S]*stream-testing\.js/);
|
||||
assert(!webUi.includes("Run test pattern"));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
const { streamTestingService } = require("../src/services/stream-testing");
|
||||
await streamTestingService.close();
|
||||
}
|
||||
console.log("Managed MediaMTX Stream Testing verification passed.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
||||
|
||||
const releaseIndex = readJson("release-index.json");
|
||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||
assert.deepEqual(releaseVersions, ["0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||
assert.deepEqual(releaseVersions, ["0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||
for (const release of releaseIndex.releases) {
|
||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
||||
const coreManifest = readJson("update-manifest.json");
|
||||
assert.equal(packageVersion, coreManifest.version);
|
||||
assert.equal(coreManifest.channel, "stable");
|
||||
assert.equal(packageVersion, "0.3.2");
|
||||
assert.equal(currentRelease.version, "0.3.2");
|
||||
assert.equal(packageVersion, "0.3.3");
|
||||
assert.equal(currentRelease.version, "0.3.3");
|
||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
||||
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
||||
const baseTarget = {
|
||||
current_version: "0.2.4",
|
||||
available_versions: [
|
||||
{ version: "0.3.3", ref: "refs/tags/v0.3.3", rollback_safe: true },
|
||||
{ version: "0.3.2", ref: "refs/tags/v0.3.2", rollback_safe: true },
|
||||
{ version: "0.3.1", ref: "refs/tags/v0.3.1", rollback_safe: true },
|
||||
{ version: "0.3.0", ref: "refs/tags/v0.3.0", rollback_safe: true },
|
||||
@ -151,7 +152,7 @@ const corrected = buildStatus({
|
||||
channel: "stable"
|
||||
});
|
||||
assert.equal(corrected.version_correction, true);
|
||||
assert.equal(corrected.safe_target_version, "0.3.2");
|
||||
assert.equal(corrected.safe_target_version, "0.3.3");
|
||||
assert.equal(corrected.update_available, true);
|
||||
assert.equal(corrected.blocked, false);
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ const { registerCorePlaceholders } = require("./services/placeholders");
|
||||
const { overlayConnectorManager } = require("./services/overlay-connectors");
|
||||
const { cleanupSnapshots } = require("./services/update-manager");
|
||||
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
||||
const { streamTestingService } = require("./services/stream-testing");
|
||||
const {
|
||||
isSafeModeRequested,
|
||||
markStartupVerification
|
||||
@ -181,6 +182,7 @@ async function main() {
|
||||
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
|
||||
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
||||
for (const stop of [
|
||||
() => streamTestingService.close(),
|
||||
() => overlayConnectorManager.stop(),
|
||||
() => twitchEventSubManager.stop(),
|
||||
() => stopPlugins(),
|
||||
|
||||
220
src/services/artifact-manager.js
Normal file
220
src/services/artifact-manager.js
Normal file
@ -0,0 +1,220 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const AdmZip = require("adm-zip");
|
||||
|
||||
const DEFAULT_MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
class ArtifactManager {
|
||||
constructor(root, options = {}) {
|
||||
this.root = root;
|
||||
this.fetch = options.fetch || global.fetch;
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
}
|
||||
|
||||
status(entry) {
|
||||
const target = path.join(this.root, entry.filename || entry.id);
|
||||
if (!fs.existsSync(target)) return { installed: false, valid: false, path: target };
|
||||
const stat = fs.statSync(target);
|
||||
if (!stat.isFile()) return { installed: true, valid: false, path: target };
|
||||
return {
|
||||
installed: true,
|
||||
valid: sha256File(target) === entry.sha256,
|
||||
path: target,
|
||||
bytes: stat.size
|
||||
};
|
||||
}
|
||||
|
||||
async download(entry, options = {}) {
|
||||
validateManifestEntry(entry);
|
||||
if (options.confirmed !== true) throw new Error("Download requires explicit setup confirmation.");
|
||||
if (typeof this.fetch !== "function") throw new Error("This Lumi runtime cannot download managed artifacts.");
|
||||
const filename = entry.filename || path.basename(new URL(entry.url).pathname);
|
||||
const target = path.join(this.root, filename);
|
||||
const partial = `${target}.${process.pid}.${crypto.randomUUID()}.partial`;
|
||||
const maxBytes = positiveInteger(entry.max_bytes, DEFAULT_MAX_ARCHIVE_BYTES);
|
||||
fs.rmSync(partial, { force: true });
|
||||
try {
|
||||
const response = await this.fetch(entry.url, {
|
||||
redirect: "follow",
|
||||
signal: options.signal
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error(`Download failed with HTTP ${response.status}.`);
|
||||
const contentLength = Number(response.headers?.get?.("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
throw new Error(`Artifact exceeds the ${maxBytes}-byte download safety limit.`);
|
||||
}
|
||||
const handle = fs.createWriteStream(partial, { flags: "wx", mode: 0o600 });
|
||||
let written = 0;
|
||||
try {
|
||||
for await (const chunk of response.body) {
|
||||
written += chunk.length;
|
||||
if (written > maxBytes) throw new Error(`Artifact exceeds the ${maxBytes}-byte download safety limit.`);
|
||||
if (!handle.write(chunk)) await new Promise((resolve) => handle.once("drain", resolve));
|
||||
}
|
||||
await new Promise((resolve, reject) => handle.end((error) => error ? reject(error) : resolve()));
|
||||
} catch (error) {
|
||||
handle.destroy();
|
||||
throw error;
|
||||
}
|
||||
if (Number.isSafeInteger(entry.bytes) && written !== entry.bytes) {
|
||||
throw new Error(`Downloaded size mismatch for ${entry.id}.`);
|
||||
}
|
||||
const actual = sha256File(partial);
|
||||
if (actual !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
|
||||
replaceFile(partial, target);
|
||||
return this.status({ ...entry, filename });
|
||||
} finally {
|
||||
fs.rmSync(partial, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
installZip(entry, archivePath, options = {}) {
|
||||
validateManifestEntry(entry);
|
||||
if (sha256File(archivePath) !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
|
||||
const target = path.join(this.root, entry.install_id || entry.id);
|
||||
const staged = `${target}.${process.pid}.${crypto.randomUUID()}.staged`;
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
fs.mkdirSync(staged, { recursive: true });
|
||||
try {
|
||||
const zip = new AdmZip(archivePath);
|
||||
const entries = zip.getEntries();
|
||||
const maxExtractedBytes = positiveInteger(entry.max_extracted_bytes, DEFAULT_MAX_ARCHIVE_BYTES * 4);
|
||||
let extractedBytes = 0;
|
||||
for (const item of entries) {
|
||||
const relative = safeArchivePath(item.entryName);
|
||||
extractedBytes += Number(item.header?.size || 0);
|
||||
if (extractedBytes > maxExtractedBytes) {
|
||||
throw new Error(`Runtime archive exceeds the ${maxExtractedBytes}-byte extraction safety limit.`);
|
||||
}
|
||||
if (!item.isDirectory && isSymlink(item)) throw new Error(`Runtime archive contains a symbolic link: ${relative}.`);
|
||||
}
|
||||
const exactEntries = Array.isArray(entry.expected_entries) ? entry.expected_entries.map(safeArchivePath) : null;
|
||||
if (exactEntries) {
|
||||
const actualFiles = entries.filter((item) => !item.isDirectory).map((item) => safeArchivePath(item.entryName)).sort();
|
||||
const expectedFiles = exactEntries.slice().sort();
|
||||
if (actualFiles.length !== expectedFiles.length || actualFiles.some((value, index) => value !== expectedFiles[index])) {
|
||||
throw new Error("Runtime archive layout does not match the pinned manifest.");
|
||||
}
|
||||
}
|
||||
zip.extractAllTo(staged, true);
|
||||
for (const expected of entry.expected_paths || []) {
|
||||
if (!findBasename(staged, expected)) throw new Error(`Runtime is missing ${expected}.`);
|
||||
}
|
||||
for (const expected of exactEntries || []) {
|
||||
const candidate = path.resolve(staged, ...expected.split("/"));
|
||||
if (!candidate.startsWith(`${path.resolve(staged)}${path.sep}`) || !fs.statSync(candidate).isFile()) {
|
||||
throw new Error(`Runtime is missing ${expected}.`);
|
||||
}
|
||||
}
|
||||
if (typeof options.verify === "function") options.verify(staged);
|
||||
replaceDirectory(staged, target, { verifyTarget: options.verifyInstalled });
|
||||
return { installed: true, path: target, backend: entry.backend, version: entry.version || entry.id };
|
||||
} finally {
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifestEntry(entry) {
|
||||
if (!entry?.id || !/^https:\/\//.test(entry.url || "") || !/^[a-f0-9]{64}$/.test(entry.sha256 || "")) {
|
||||
throw new Error("Artifact manifest entry is invalid.");
|
||||
}
|
||||
if (entry.bytes !== undefined && !Number.isSafeInteger(entry.bytes)) throw new Error("Artifact size is invalid.");
|
||||
}
|
||||
|
||||
function sha256File(target) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const fd = fs.openSync(target, "r");
|
||||
const buffer = Buffer.alloc(1024 * 1024);
|
||||
try {
|
||||
let read;
|
||||
while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read));
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function safeArchivePath(value) {
|
||||
const portable = String(value || "").replace(/\\/g, "/");
|
||||
const relative = path.posix.normalize(portable).replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (!relative || relative === ".." || relative.startsWith("../") || /^[A-Za-z]:/.test(relative) || relative.includes("\0")) {
|
||||
throw new Error("Runtime archive contains an unsafe path.");
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
function isSymlink(entry) {
|
||||
const mode = Number(entry.attr || 0) >>> 16;
|
||||
return (mode & 0o170000) === 0o120000;
|
||||
}
|
||||
|
||||
function findBasename(root, basename) {
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const target = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) pending.push(target);
|
||||
else if (entry.name === basename) return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceFile(staged, target) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
fs.renameSync(staged, target);
|
||||
} catch (error) {
|
||||
if (!["EPERM", "EACCES", "EXDEV"].includes(error.code)) throw error;
|
||||
fs.copyFileSync(staged, target);
|
||||
fs.rmSync(staged, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function replaceDirectory(staged, target, options = {}) {
|
||||
const backup = `${target}.${process.pid}.${crypto.randomUUID()}.last-good`;
|
||||
const hadTarget = fs.existsSync(target);
|
||||
let replacementComplete = false;
|
||||
try {
|
||||
if (hadTarget) moveDirectory(target, backup);
|
||||
moveDirectory(staged, target);
|
||||
if (typeof options.verifyTarget === "function") options.verifyTarget(target);
|
||||
replacementComplete = true;
|
||||
} catch (error) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
if (hadTarget && fs.existsSync(backup)) moveDirectory(backup, target);
|
||||
throw error;
|
||||
} finally {
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
if (replacementComplete) fs.rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function moveDirectory(source, target) {
|
||||
try {
|
||||
fs.renameSync(source, target);
|
||||
} catch (error) {
|
||||
if (!["EPERM", "EACCES", "EXDEV"].includes(error.code)) throw error;
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.cpSync(source, target, { recursive: true, force: true });
|
||||
fs.rmSync(source, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ArtifactManager,
|
||||
DEFAULT_MAX_ARCHIVE_BYTES,
|
||||
findBasename,
|
||||
replaceDirectory,
|
||||
safeArchivePath,
|
||||
sha256File,
|
||||
validateManifestEntry
|
||||
};
|
||||
23
src/services/mediamtx-runtime-manifest.json
Normal file
23
src/services/mediamtx-runtime-manifest.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "mediamtx-v1.18.2-windows-x64",
|
||||
"backend": "mediamtx",
|
||||
"version": "v1.18.2",
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"url": "https://github.com/bluenviron/mediamtx/releases/download/v1.18.2/mediamtx_v1.18.2_windows_amd64.zip",
|
||||
"checksum_url": "https://github.com/bluenviron/mediamtx/releases/download/v1.18.2/checksums.sha256",
|
||||
"sha256": "945ab46c5fc6d2802ad18e2f1d7e49245ca5609657d85e310aa6eda4cdd72eec",
|
||||
"bytes": 26482303,
|
||||
"max_bytes": 41943040,
|
||||
"max_extracted_bytes": 67108864,
|
||||
"filename": "mediamtx_v1.18.2_windows_amd64.zip",
|
||||
"install_id": "mediamtx",
|
||||
"executable": "mediamtx.exe",
|
||||
"expected_entries": [
|
||||
"LICENSE",
|
||||
"mediamtx.exe",
|
||||
"mediamtx.yml"
|
||||
],
|
||||
"license": "MIT",
|
||||
"license_url": "https://github.com/bluenviron/mediamtx/blob/v1.18.2/LICENSE"
|
||||
}
|
||||
705
src/services/mediamtx-runtime.js
Normal file
705
src/services/mediamtx-runtime.js
Normal file
@ -0,0 +1,705 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const { EventEmitter } = require("events");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
const { ArtifactManager } = require("./artifact-manager");
|
||||
const manifest = require("./mediamtx-runtime-manifest.json");
|
||||
|
||||
const DATA_DIR = process.env.LUMI_DATA_DIR
|
||||
? path.resolve(process.env.LUMI_DATA_DIR)
|
||||
: path.join(__dirname, "..", "..", "data");
|
||||
const RUNTIME_ROOT = path.join(DATA_DIR, "runtimes");
|
||||
const CONFIG_ROOT = path.join(DATA_DIR, "stream-testing");
|
||||
const CONFIG_PATH = path.join(CONFIG_ROOT, "mediamtx.yml");
|
||||
const MAX_LOG_BYTES = 16 * 1024;
|
||||
const MAX_DIAGNOSTIC_BYTES = 1024 * 1024;
|
||||
const MAX_RESTARTS = 3;
|
||||
const RESTART_WINDOW_MS = 30_000;
|
||||
|
||||
class MediaMtxRuntime extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
this.spawn = options.spawn || spawn;
|
||||
this.spawnSync = options.spawnSync || spawnSync;
|
||||
this.fetch = options.fetch || global.fetch;
|
||||
this.platform = options.platform || process.platform;
|
||||
this.arch = options.arch || process.arch;
|
||||
this.runtimeRoot = options.runtimeRoot || RUNTIME_ROOT;
|
||||
this.configRoot = options.configRoot || CONFIG_ROOT;
|
||||
this.configPath = options.configPath || path.join(this.configRoot, "mediamtx.yml");
|
||||
this.manifest = options.manifest || manifest;
|
||||
this.artifacts = options.artifacts || new ArtifactManager(this.runtimeRoot, { fetch: this.fetch });
|
||||
this.child = null;
|
||||
this.state = "stopped";
|
||||
this.startedAt = null;
|
||||
this.lastExit = null;
|
||||
this.lastError = null;
|
||||
this.logTail = "";
|
||||
this.ports = options.ports || null;
|
||||
this.currentSession = null;
|
||||
this.restartHistory = [];
|
||||
this.intentionalStop = false;
|
||||
this.restartTimer = null;
|
||||
this.operation = Promise.resolve();
|
||||
this.statusCache = null;
|
||||
fs.mkdirSync(this.runtimeRoot, { recursive: true });
|
||||
fs.mkdirSync(this.configRoot, { recursive: true });
|
||||
}
|
||||
|
||||
executablePath() {
|
||||
const manual = String(process.env.LUMI_MEDIAMTX_PATH || "").trim();
|
||||
return manual || path.join(this.runtimeRoot, this.manifest.install_id || "mediamtx", this.manifest.executable);
|
||||
}
|
||||
|
||||
probeExecutable(executable = this.executablePath()) {
|
||||
try {
|
||||
const stat = fs.statSync(executable);
|
||||
if (!stat.isFile()) throw new Error("not a file");
|
||||
} catch {
|
||||
return { available: false, installed: false, valid: false, version: null, executable };
|
||||
}
|
||||
const result = this.spawnSync(executable, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
shell: false
|
||||
});
|
||||
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
|
||||
const version = /\bv?(\d+\.\d+\.\d+)\b/.exec(output)?.[1] || null;
|
||||
return {
|
||||
available: !result.error && result.status === 0 && Boolean(version),
|
||||
installed: true,
|
||||
valid: !result.error && result.status === 0 && Boolean(version),
|
||||
version: version ? `v${version}` : null,
|
||||
executable,
|
||||
error: result.error?.message || (result.status === 0 ? null : `Version probe exited with ${result.status}.`)
|
||||
};
|
||||
}
|
||||
|
||||
status({ refresh = false } = {}) {
|
||||
if (!refresh && this.statusCache && Date.now() - this.statusCache.at < 30_000) {
|
||||
return { ...this.statusCache.value, process: this.processStatus() };
|
||||
}
|
||||
const probe = this.probeExecutable();
|
||||
const manual = Boolean(String(process.env.LUMI_MEDIAMTX_PATH || "").trim());
|
||||
const supported = this.platform === this.manifest.platform && this.arch === this.manifest.arch;
|
||||
const available = probe.available;
|
||||
const value = {
|
||||
available,
|
||||
installed: probe.installed,
|
||||
valid: probe.valid,
|
||||
version: probe.version,
|
||||
tested_version: this.manifest.version,
|
||||
source: manual ? "manual override" : "managed by Lumi",
|
||||
managed_install_supported: supported,
|
||||
detail: available
|
||||
? `MediaMTX ${probe.version} is ready (${manual ? "manual override" : "managed by Lumi"}).`
|
||||
: manual
|
||||
? `The MediaMTX executable configured by LUMI_MEDIAMTX_PATH did not pass its version probe${probe.error ? `: ${probe.error}` : "."}`
|
||||
: supported
|
||||
? "The Lumi-managed MediaMTX runtime is not installed or did not pass validation."
|
||||
: `Automatic MediaMTX installation is available for ${this.manifest.platform}-${this.manifest.arch}; configure LUMI_MEDIAMTX_PATH on this ${this.platform}-${this.arch} host.`,
|
||||
process: this.processStatus()
|
||||
};
|
||||
this.statusCache = { at: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
processStatus() {
|
||||
return {
|
||||
state: this.state,
|
||||
running: Boolean(this.child && this.child.exitCode === null),
|
||||
pid: this.child?.pid || null,
|
||||
started_at: this.startedAt,
|
||||
restart_count: this.restartHistory.length,
|
||||
last_exit: this.lastExit,
|
||||
last_error: this.lastError,
|
||||
log_tail: redactRuntimeLog(this.logTail, this.currentSession)
|
||||
};
|
||||
}
|
||||
|
||||
async install({ confirmed = false } = {}) {
|
||||
return this.queue(async () => {
|
||||
if (confirmed !== true) throw new Error("MediaMTX installation requires administrator confirmation.");
|
||||
if (String(process.env.LUMI_MEDIAMTX_PATH || "").trim()) {
|
||||
throw new Error("LUMI_MEDIAMTX_PATH is active. Remove the override before installing Lumi's managed runtime.");
|
||||
}
|
||||
if (this.platform !== this.manifest.platform || this.arch !== this.manifest.arch) {
|
||||
throw new Error(`This managed package supports ${this.manifest.platform}-${this.manifest.arch}, not ${this.platform}-${this.arch}.`);
|
||||
}
|
||||
await this.stopProcess();
|
||||
this.state = "installing";
|
||||
this.emit("changed");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5 * 60_000);
|
||||
timeout.unref?.();
|
||||
let archivePath = null;
|
||||
try {
|
||||
const downloaded = await this.artifacts.download(this.manifest, {
|
||||
confirmed: true,
|
||||
signal: controller.signal
|
||||
});
|
||||
archivePath = downloaded.path;
|
||||
const verifyDirectory = (directory) => {
|
||||
const executable = path.join(directory, this.manifest.executable);
|
||||
const probe = this.probeExecutable(executable);
|
||||
if (!probe.available || probe.version !== this.manifest.version) {
|
||||
throw new Error(`MediaMTX version probe failed; expected ${this.manifest.version}, received ${probe.version || "no version"}.`);
|
||||
}
|
||||
};
|
||||
const installed = this.artifacts.installZip(this.manifest, downloaded.path, {
|
||||
verify: verifyDirectory,
|
||||
verifyInstalled: verifyDirectory
|
||||
});
|
||||
const finalProbe = this.probeExecutable(path.join(installed.path, this.manifest.executable));
|
||||
if (!finalProbe.available || finalProbe.version !== this.manifest.version) {
|
||||
throw new Error("Installed MediaMTX did not pass its final version probe.");
|
||||
}
|
||||
this.state = "stopped";
|
||||
this.lastError = null;
|
||||
this.statusCache = null;
|
||||
this.emit("changed");
|
||||
return this.status({ refresh: true });
|
||||
} catch (error) {
|
||||
this.state = "failed";
|
||||
this.lastError = friendlyRuntimeError(error);
|
||||
this.emit("changed");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (archivePath) fs.rmSync(archivePath, { force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async repair(options = {}) {
|
||||
return this.install(options);
|
||||
}
|
||||
|
||||
async healthCheck() {
|
||||
return this.queue(async () => {
|
||||
await this.startWithConfig(null);
|
||||
const [api, metrics, hls] = await Promise.all([
|
||||
this.fetchText(`http://127.0.0.1:${this.ports.api}/v3/paths/list`, 5000),
|
||||
this.fetchText(`http://127.0.0.1:${this.ports.metrics}/metrics`, 5000),
|
||||
this.fetchText(`http://127.0.0.1:${this.ports.hls}/`, 5000, true)
|
||||
]);
|
||||
const listenerHealth = {
|
||||
api: api.response.status,
|
||||
metrics: metrics.response.status,
|
||||
hls: hls.response.status
|
||||
};
|
||||
await this.stopProcess();
|
||||
await this.startWithConfig(null);
|
||||
return {
|
||||
ok: api.response.ok && metrics.response.ok && Boolean(hls.response),
|
||||
version: this.status({ refresh: true }).version,
|
||||
api_status: listenerHealth.api,
|
||||
metrics_status: listenerHealth.metrics,
|
||||
hls_status: listenerHealth.hls,
|
||||
restart_verified: this.state === "ready",
|
||||
process: this.processStatus()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async configureSession(session) {
|
||||
return this.queue(async () => {
|
||||
this.currentSession = {
|
||||
path: session.path,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
transport: session.transport,
|
||||
ingestPort: session.ingestPort,
|
||||
tlsCert: session.tlsCert,
|
||||
tlsKey: session.tlsKey,
|
||||
bindHost: session.bindHost
|
||||
};
|
||||
await this.startWithConfig(this.currentSession);
|
||||
return this.processStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async clearSession() {
|
||||
return this.queue(async () => {
|
||||
this.logTail = redactRuntimeLog(this.logTail, this.currentSession);
|
||||
this.currentSession = null;
|
||||
if (this.child && this.child.exitCode === null) await this.startWithConfig(null);
|
||||
else this.writeConfig(null);
|
||||
return this.processStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async startWithConfig(session) {
|
||||
const status = this.status({ refresh: true });
|
||||
if (!status.available) {
|
||||
const error = new Error(status.detail);
|
||||
error.code = "STREAM_TEST_RUNTIME_UNAVAILABLE";
|
||||
throw error;
|
||||
}
|
||||
if (!this.ports) this.ports = await allocateLoopbackPorts(3);
|
||||
await this.stopProcess();
|
||||
this.writeConfig(session);
|
||||
await this.startProcess();
|
||||
await this.waitUntilReady();
|
||||
}
|
||||
|
||||
writeConfig(session) {
|
||||
const content = renderConfig({
|
||||
apiPort: this.ports?.api || 19997,
|
||||
metricsPort: this.ports?.metrics || 19998,
|
||||
hlsPort: this.ports?.hls || 18888,
|
||||
session
|
||||
});
|
||||
atomicWriteFile(this.configPath, content);
|
||||
}
|
||||
|
||||
async startProcess() {
|
||||
this.intentionalStop = false;
|
||||
this.state = "starting";
|
||||
this.lastError = null;
|
||||
if (this.logTail) this.logTail = `${this.logTail}\n--- MediaMTX restart ---\n`.slice(-MAX_LOG_BYTES);
|
||||
const executable = this.executablePath();
|
||||
const child = this.spawn(executable, [this.configPath], {
|
||||
cwd: path.dirname(executable),
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
this.child = child;
|
||||
this.startedAt = Date.now();
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream?.setEncoding?.("utf8");
|
||||
stream?.on?.("data", (chunk) => {
|
||||
this.logTail = `${this.logTail}${chunk}`.slice(-MAX_LOG_BYTES);
|
||||
this.emit("changed");
|
||||
});
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
this.lastError = friendlyRuntimeError(error);
|
||||
this.state = "failed";
|
||||
this.emit("changed");
|
||||
});
|
||||
child.on("exit", (code, signal) => this.onExit(child, code, signal));
|
||||
this.emit("changed");
|
||||
}
|
||||
|
||||
onExit(child, code, signal) {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
this.lastExit = { at: Date.now(), code, signal: signal || null };
|
||||
const unexpected = !this.intentionalStop;
|
||||
this.state = unexpected ? "failed" : "stopped";
|
||||
this.emit("changed");
|
||||
if (!unexpected) return;
|
||||
this.lastError = `MediaMTX stopped unexpectedly (${signal || `exit ${code}`}).`;
|
||||
this.emit("failed", new Error(this.lastError));
|
||||
const now = Date.now();
|
||||
this.restartHistory = this.restartHistory.filter((at) => now - at < RESTART_WINDOW_MS);
|
||||
if (this.restartHistory.length >= MAX_RESTARTS) return;
|
||||
this.restartHistory.push(now);
|
||||
this.restartTimer = setTimeout(() => {
|
||||
this.restartTimer = null;
|
||||
void this.queue(async () => {
|
||||
try {
|
||||
await this.startWithConfig(this.currentSession);
|
||||
} catch (error) {
|
||||
this.lastError = friendlyRuntimeError(error);
|
||||
this.state = "failed";
|
||||
this.emit("changed");
|
||||
}
|
||||
});
|
||||
}, 750);
|
||||
this.restartTimer.unref?.();
|
||||
}
|
||||
|
||||
async waitUntilReady() {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (!this.child || this.child.exitCode !== null) break;
|
||||
try {
|
||||
const result = await this.fetchText(`http://127.0.0.1:${this.ports.api}/v3/paths/list`, 1200);
|
||||
if (result.response.ok) {
|
||||
this.state = "ready";
|
||||
this.lastError = null;
|
||||
this.emit("changed");
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`Control API returned HTTP ${result.response.status}.`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await delay(150);
|
||||
}
|
||||
await this.stopProcess();
|
||||
const detail = redactRuntimeLog(this.logTail.slice(-1200), this.currentSession);
|
||||
const error = new Error(`MediaMTX did not become ready${lastError ? `: ${lastError.message}` : ""}${detail ? ` (${detail})` : ""}`);
|
||||
this.lastError = friendlyRuntimeError(error);
|
||||
this.state = "failed";
|
||||
this.emit("changed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
async diagnostics(sessionPath) {
|
||||
if (!this.child || this.state !== "ready" || !this.ports) return { path: null, metrics: {}, process: this.processStatus() };
|
||||
const [api, metrics] = await Promise.all([
|
||||
this.fetchJson(`http://127.0.0.1:${this.ports.api}/v3/paths/list`, 2500, MAX_DIAGNOSTIC_BYTES),
|
||||
this.fetchText(`http://127.0.0.1:${this.ports.metrics}/metrics`, 2500, false, MAX_DIAGNOSTIC_BYTES)
|
||||
]);
|
||||
const items = Array.isArray(api.items) ? api.items : [];
|
||||
const pathInfo = items.find((item) => item.name === sessionPath) || null;
|
||||
return {
|
||||
path: pathInfo ? sanitizePathInfo(pathInfo) : null,
|
||||
metrics: parseMetrics(metrics.text, sessionPath),
|
||||
process: this.processStatus()
|
||||
};
|
||||
}
|
||||
|
||||
async fetchHls(sessionPath, asset, options = {}) {
|
||||
if (!this.currentSession || this.currentSession.path !== sessionPath || !this.ports) {
|
||||
const error = new Error("The requested private stream is no longer active.");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
const safeAsset = validateHlsAsset(asset);
|
||||
const query = validateHlsQuery(options.query);
|
||||
const url = new URL(`http://127.0.0.1:${this.ports.hls}/${sessionPath}/${safeAsset}${query}`);
|
||||
const request = {
|
||||
signal: options.signal,
|
||||
headers: options.headers,
|
||||
redirect: "manual",
|
||||
cache: "no-store"
|
||||
};
|
||||
const response = await fetchWithTimeout(this.fetch, url, options.timeout || 15_000, request);
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
||||
const location = response.headers.get("location");
|
||||
if (!location) return response;
|
||||
const redirected = new URL(location, url);
|
||||
const expectedPrefix = `/${sessionPath}/`;
|
||||
if (redirected.origin !== url.origin || !decodeURIComponent(redirected.pathname).startsWith(expectedPrefix)) {
|
||||
await response.body?.cancel?.().catch(() => {});
|
||||
throw Object.assign(new Error("MediaMTX returned an unsafe HLS redirect."), { status: 502 });
|
||||
}
|
||||
validateHlsAsset(decodeURIComponent(redirected.pathname).slice(expectedPrefix.length));
|
||||
validateHlsQuery(redirected.search);
|
||||
await response.body?.cancel?.().catch(() => {});
|
||||
return fetchWithTimeout(this.fetch, redirected, options.timeout || 15_000, request);
|
||||
}
|
||||
|
||||
async fetchJson(url, timeout, maxBytes = MAX_DIAGNOSTIC_BYTES) {
|
||||
const result = await this.fetchText(url, timeout, false, maxBytes);
|
||||
if (!result.response.ok) throw new Error(`MediaMTX returned HTTP ${result.response.status}.`);
|
||||
return JSON.parse(result.text);
|
||||
}
|
||||
|
||||
async fetchText(url, timeout, allowErrorStatus = false, maxBytes = MAX_DIAGNOSTIC_BYTES) {
|
||||
const response = await fetchWithTimeout(this.fetch, url, timeout, { cache: "no-store" });
|
||||
const text = await readResponseText(response, maxBytes);
|
||||
if (!allowErrorStatus && !response.ok) throw new Error(`MediaMTX returned HTTP ${response.status}.`);
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
async stopProcess() {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
const child = this.child;
|
||||
if (!child || child.exitCode !== null) {
|
||||
this.child = null;
|
||||
if (this.state !== "installing") this.state = "stopped";
|
||||
return;
|
||||
}
|
||||
this.intentionalStop = true;
|
||||
child.kill("SIGTERM");
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
delay(3000)
|
||||
]);
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
if (this.child === child) this.child = null;
|
||||
if (this.state !== "installing") this.state = "stopped";
|
||||
}
|
||||
|
||||
async close() {
|
||||
return this.queue(async () => {
|
||||
this.currentSession = null;
|
||||
await this.stopProcess();
|
||||
});
|
||||
}
|
||||
|
||||
queue(task) {
|
||||
const next = this.operation.then(task, task);
|
||||
this.operation = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConfig({ apiPort, metricsPort, hlsPort, session }) {
|
||||
const transport = session?.transport === "rtmps" ? "rtmps" : "rtmp";
|
||||
const publisher = session ? [
|
||||
"- user: " + yamlString(sha256Credential(session.username)),
|
||||
" pass: " + yamlString(sha256Credential(session.password)),
|
||||
" ips: []",
|
||||
" permissions:",
|
||||
" - action: publish",
|
||||
" path: " + yamlString(session.path)
|
||||
] : [];
|
||||
const rtmp = transport === "rtmps" ? [
|
||||
"rtmpEncryption: strict",
|
||||
`rtmpsAddress: "0.0.0.0:${session.ingestPort}"`,
|
||||
`rtmpServerKey: ${yamlString(session.tlsKey)}`,
|
||||
`rtmpServerCert: ${yamlString(session.tlsCert)}`
|
||||
] : [
|
||||
"rtmpEncryption: \"no\"",
|
||||
`rtmpAddress: ${yamlString(formatSocketAddress(session?.bindHost || "127.0.0.1", session?.ingestPort || 19350))}`
|
||||
];
|
||||
const paths = session ? [
|
||||
"paths:",
|
||||
` ${yamlString(session.path)}:`,
|
||||
" source: publisher",
|
||||
" overridePublisher: false",
|
||||
" record: false"
|
||||
] : ["paths: {}"];
|
||||
return [
|
||||
"# Generated by Lumi. Manual edits are replaced.",
|
||||
"logLevel: info",
|
||||
"logDestinations: [stdout]",
|
||||
"readTimeout: 10s",
|
||||
"writeTimeout: 10s",
|
||||
"authMethod: internal",
|
||||
"authInternalUsers:",
|
||||
...publisher,
|
||||
"- user: any",
|
||||
" pass:",
|
||||
" ips: [127.0.0.1, '::1']",
|
||||
" permissions:",
|
||||
" - action: read",
|
||||
...(session ? [` path: ${yamlString(session.path)}`] : []),
|
||||
" - action: api",
|
||||
" - action: metrics",
|
||||
"api: true",
|
||||
`apiAddress: "127.0.0.1:${apiPort}"`,
|
||||
"apiEncryption: false",
|
||||
"apiAllowOrigins: []",
|
||||
"metrics: true",
|
||||
`metricsAddress: "127.0.0.1:${metricsPort}"`,
|
||||
"metricsEncryption: false",
|
||||
"metricsAllowOrigins: []",
|
||||
"pprof: false",
|
||||
"playback: false",
|
||||
"rtsp: false",
|
||||
"rtmp: true",
|
||||
...rtmp,
|
||||
"hls: true",
|
||||
`hlsAddress: "127.0.0.1:${hlsPort}"`,
|
||||
"hlsEncryption: false",
|
||||
"hlsAllowOrigins: []",
|
||||
"hlsAlwaysRemux: false",
|
||||
"hlsVariant: lowLatency",
|
||||
"hlsSegmentCount: 7",
|
||||
"hlsSegmentDuration: 1s",
|
||||
"hlsPartDuration: 200ms",
|
||||
"hlsSegmentMaxSize: 50M",
|
||||
"hlsDirectory: ''",
|
||||
"webrtc: false",
|
||||
"srt: false",
|
||||
"pathDefaults:",
|
||||
" source: publisher",
|
||||
" overridePublisher: false",
|
||||
" maxReaders: 4",
|
||||
" record: false",
|
||||
...paths,
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sha256Credential(value) {
|
||||
return `sha256:${crypto.createHash("sha256").update(String(value), "utf8").digest("base64")}`;
|
||||
}
|
||||
|
||||
function yamlString(value) {
|
||||
return JSON.stringify(String(value || ""));
|
||||
}
|
||||
|
||||
function atomicWriteFile(target, content) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const staged = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
const previous = `${target}.${process.pid}.previous`;
|
||||
fs.writeFileSync(staged, content, { encoding: "utf8", mode: 0o600 });
|
||||
fs.rmSync(previous, { force: true });
|
||||
try {
|
||||
if (fs.existsSync(target)) fs.renameSync(target, previous);
|
||||
fs.renameSync(staged, target);
|
||||
fs.rmSync(previous, { force: true });
|
||||
} catch (error) {
|
||||
fs.rmSync(target, { force: true });
|
||||
if (fs.existsSync(previous)) fs.renameSync(previous, target);
|
||||
throw error;
|
||||
} finally {
|
||||
fs.rmSync(staged, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function allocateLoopbackPorts(count) {
|
||||
const ports = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = server.address().port;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
ports.push(port);
|
||||
}
|
||||
return { api: ports[0], metrics: ports[1], hls: ports[2] };
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(fetchImpl, url, timeout, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
options.signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
timer.unref?.();
|
||||
try {
|
||||
return await fetchImpl(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener?.("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHlsAsset(value) {
|
||||
const decoded = decodeURIComponent(String(value || ""));
|
||||
if (!decoded || decoded.length > 240 || decoded.includes("\\") || decoded.includes("\0")) throw Object.assign(new Error("Invalid media path."), { status: 400 });
|
||||
const segments = decoded.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === ".." || !/^[A-Za-z0-9._-]+$/.test(segment))) {
|
||||
throw Object.assign(new Error("Invalid media path."), { status: 400 });
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function validateHlsQuery(value) {
|
||||
const query = String(value || "");
|
||||
if (!query) return "";
|
||||
if (query.length > 1024 || !query.startsWith("?") || /[\r\n\0]/.test(query)) {
|
||||
throw Object.assign(new Error("Invalid media query."), { status: 400 });
|
||||
}
|
||||
const parsed = new URLSearchParams(query.slice(1));
|
||||
for (const [key, item] of parsed) {
|
||||
if (!key || key.length > 128 || item.length > 512) {
|
||||
throw Object.assign(new Error("Invalid media query."), { status: 400 });
|
||||
}
|
||||
}
|
||||
return `?${parsed.toString()}`;
|
||||
}
|
||||
|
||||
async function readResponseText(response, maxBytes) {
|
||||
const contentLength = Number(response.headers?.get?.("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
throw new Error("MediaMTX returned an oversized diagnostic response.");
|
||||
}
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("MediaMTX returned an oversized diagnostic response.");
|
||||
return text;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error("MediaMTX returned an oversized diagnostic response.");
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function formatSocketAddress(host, port) {
|
||||
const value = String(host || "");
|
||||
return value.includes(":") && !value.startsWith("[") ? `[${value}]:${port}` : `${value}:${port}`;
|
||||
}
|
||||
|
||||
function sanitizePathInfo(value) {
|
||||
return {
|
||||
name: String(value.name || ""),
|
||||
ready: value.ready === true,
|
||||
ready_time: value.readyTime || null,
|
||||
tracks: Array.isArray(value.tracks) ? value.tracks.slice(0, 16).map(String) : [],
|
||||
bytes_received: Number(value.bytesReceived || 0),
|
||||
bytes_sent: Number(value.bytesSent || 0),
|
||||
readers: Array.isArray(value.readers) ? value.readers.length : Number(value.readerCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetrics(text, sessionPath) {
|
||||
const result = {};
|
||||
const allowed = new Set([
|
||||
"paths",
|
||||
"paths_readers",
|
||||
"paths_bytes_received",
|
||||
"paths_bytes_sent",
|
||||
"paths_inbound_bytes",
|
||||
"paths_outbound_bytes",
|
||||
"paths_inbound_frames_in_error",
|
||||
"hls_muxers",
|
||||
"hls_muxers_bytes_sent",
|
||||
"hls_muxers_outbound_bytes",
|
||||
"hls_muxers_outbound_frames_discarded",
|
||||
"hls_sessions",
|
||||
"hls_sessions_bytes_sent",
|
||||
"rtmp_conns",
|
||||
"rtmp_conns_bytes_received",
|
||||
"rtmps_conns",
|
||||
"rtmps_conns_bytes_received"
|
||||
]);
|
||||
for (const line of String(text || "").split(/\r?\n/)) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const match = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+(?:\.\d+)?|Inf|NaN))$/.exec(line);
|
||||
if (!match || !allowed.has(match[1])) continue;
|
||||
const labels = match[2] || "";
|
||||
if (labels.includes("name=") && !labels.includes(JSON.stringify(sessionPath))) continue;
|
||||
const value = Number(match[3]);
|
||||
if (Number.isFinite(value)) result[match[1]] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function redactRuntimeLog(value, session) {
|
||||
let text = String(value || "");
|
||||
for (const secret of [session?.username, session?.password]) {
|
||||
if (secret) text = text.split(secret).join("[redacted]");
|
||||
}
|
||||
return text.replace(/([?&](?:user|pass)=)[^&\s]+/gi, "$1[redacted]").slice(-MAX_LOG_BYTES);
|
||||
}
|
||||
|
||||
function friendlyRuntimeError(error) {
|
||||
if (error?.name === "AbortError") return "The MediaMTX operation timed out.";
|
||||
return String(error?.message || error || "MediaMTX operation failed.").slice(0, 2000);
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_PATH,
|
||||
MediaMtxRuntime,
|
||||
RUNTIME_ROOT,
|
||||
allocateLoopbackPorts,
|
||||
manifest,
|
||||
parseMetrics,
|
||||
readResponseText,
|
||||
redactRuntimeLog,
|
||||
renderConfig,
|
||||
sha256Credential,
|
||||
validateHlsAsset,
|
||||
validateHlsQuery
|
||||
};
|
||||
@ -1,7 +1,11 @@
|
||||
const crypto = require("crypto");
|
||||
const dns = require("dns");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
const { Readable } = require("stream");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { MediaMtxRuntime } = require("./mediamtx-runtime");
|
||||
const { publishWebEvent } = require("./web-events");
|
||||
|
||||
const DATA_ROOT = path.join(
|
||||
@ -10,69 +14,31 @@ const DATA_ROOT = path.join(
|
||||
);
|
||||
const MAX_SESSION_MS = Math.min(60 * 60 * 1000, Math.max(5 * 60 * 1000, Number(process.env.LUMI_STREAM_TEST_MAX_MS) || 30 * 60 * 1000));
|
||||
const INACTIVITY_MS = Math.min(5 * 60 * 1000, Math.max(20 * 1000, Number(process.env.LUMI_STREAM_TEST_INACTIVITY_MS) || 45 * 1000));
|
||||
const MAX_OUTPUT_BYTES = Math.min(4 * 1024 ** 3, Math.max(128 * 1024 ** 2, Number(process.env.LUMI_STREAM_TEST_MAX_BYTES) || 1024 ** 3));
|
||||
const INGEST_PORT = Math.min(65535, Math.max(1024, Number(process.env.LUMI_STREAM_TEST_INGEST_PORT) || 19350));
|
||||
const PUBLIC_INGEST_PORT = Math.min(65535, Math.max(1, Number(process.env.LUMI_STREAM_TEST_PUBLIC_PORT) || INGEST_PORT));
|
||||
|
||||
function resolveFfmpegExecutable() {
|
||||
if (process.env.LUMI_FFMPEG_PATH) return String(process.env.LUMI_FFMPEG_PATH);
|
||||
if (process.platform !== "win32") return "ffmpeg";
|
||||
const local = process.env.LOCALAPPDATA;
|
||||
const programFiles = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean);
|
||||
const direct = [
|
||||
local && path.join(local, "Microsoft", "WinGet", "Links", "ffmpeg.exe"),
|
||||
local && path.join(local, "Programs", "ffmpeg", "bin", "ffmpeg.exe"),
|
||||
...programFiles.map((root) => path.join(root, "ffmpeg", "bin", "ffmpeg.exe"))
|
||||
].filter(Boolean);
|
||||
for (const candidate of direct) {
|
||||
try { if (fs.statSync(candidate).isFile()) return candidate; } catch {}
|
||||
}
|
||||
const packages = local && path.join(local, "Microsoft", "WinGet", "Packages");
|
||||
try {
|
||||
for (const packageName of fs.readdirSync(packages).filter((name) => /ffmpeg/i.test(name)).slice(0, 32)) {
|
||||
const packageRoot = path.join(packages, packageName);
|
||||
for (const versionName of fs.readdirSync(packageRoot).slice(0, 32)) {
|
||||
const candidate = path.join(packageRoot, versionName, "bin", "ffmpeg.exe");
|
||||
try { if (fs.statSync(candidate).isFile()) return candidate; } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
const FFMPEG = resolveFfmpegExecutable();
|
||||
const INGEST_PORT = port(process.env.LUMI_STREAM_TEST_INGEST_PORT, 19350);
|
||||
const PUBLIC_INGEST_PORT = port(process.env.LUMI_STREAM_TEST_PUBLIC_PORT, INGEST_PORT);
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
function finite(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function redactUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.pathname = "/lumi-test/[session]";
|
||||
parsed.search = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "configured ingest endpoint";
|
||||
}
|
||||
}
|
||||
|
||||
function redactDiagnostic(value, session) {
|
||||
let result = String(value || "");
|
||||
if (session?.listenerUrl) result = result.split(session.listenerUrl).join("rtmp://[private-stream-test]");
|
||||
return result.replace(/rtmps?:\/\/[^\s'"]+\/lumi-test\/[^\s'"]+/gi, "rtmp://[private-stream-test]");
|
||||
}
|
||||
|
||||
function directoryBytes(root) {
|
||||
try {
|
||||
return fs.readdirSync(root, { withFileTypes: true }).reduce((total, entry) => {
|
||||
if (!entry.isFile()) return total;
|
||||
try { return total + fs.statSync(path.join(root, entry.name)).size; } catch { return total; }
|
||||
}, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
function sourceFor(source = {}) {
|
||||
const width = Math.round(finite(source.width, 1920, 160, 7680));
|
||||
const height = Math.round(finite(source.height, 1080, 90, 4320));
|
||||
const fps = finite(source.fps, 30, 1, 120);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
variants: [{
|
||||
name: "source",
|
||||
label: `Source (${width}×${height} at ${formatFps(fps)} fps)`,
|
||||
width,
|
||||
height,
|
||||
fps
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function refreshWarnings(session) {
|
||||
@ -80,300 +46,162 @@ function refreshWarnings(session) {
|
||||
...session.baseWarnings,
|
||||
...(session.obsWarnings || []),
|
||||
...(session.receiverWarnings || []),
|
||||
...(session.captionWarnings || [])
|
||||
];
|
||||
}
|
||||
|
||||
let receiverStatusCache = null;
|
||||
function executableStatus({ refresh = false } = {}) {
|
||||
if (!refresh && receiverStatusCache && Date.now() - receiverStatusCache.checkedAt < 60_000) return receiverStatusCache.value;
|
||||
const result = spawnSync(FFMPEG, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
||||
if (result.error || result.status !== 0) {
|
||||
const value = { available: false, detail: "FFmpeg is not available. Install a current FFmpeg build or set LUMI_FFMPEG_PATH." };
|
||||
receiverStatusCache = { checkedAt: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
const encoders = spawnSync(FFMPEG, ["-hide_banner", "-encoders"], { encoding: "utf8", timeout: 8000, windowsHide: true });
|
||||
const hardwareAdvertised = /\bh264_nvenc\b/.test(encoders.stdout || "");
|
||||
const hardwareProbe = hardwareAdvertised
|
||||
? spawnSync(FFMPEG, ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=size=64x64:rate=1", "-frames:v", "1", "-c:v", "h264_nvenc", "-f", "null", "-"], { encoding: "utf8", timeout: 8000, windowsHide: true })
|
||||
: null;
|
||||
const hardware = hardwareProbe?.status === 0;
|
||||
const value = {
|
||||
available: true,
|
||||
encoder: process.env.LUMI_STREAM_TEST_ENCODER || (hardware ? "h264_nvenc" : "libx264"),
|
||||
hardware,
|
||||
detail: hardware
|
||||
? "FFmpeg with NVIDIA NVENC is ready."
|
||||
: hardwareAdvertised
|
||||
? "FFmpeg is ready. NVENC was advertised but failed its encode probe, so Lumi will use CPU H.264."
|
||||
: "FFmpeg is ready with CPU H.264 encoding."
|
||||
};
|
||||
receiverStatusCache = { checkedAt: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
function ladderFor(source = {}) {
|
||||
const width = Math.round(finite(source.width, 1920, 160, 7680));
|
||||
const height = Math.round(finite(source.height, 1080, 90, 4320));
|
||||
const fps = finite(source.fps, 30, 1, 120);
|
||||
const ladder = [{ name: "source", label: `Source (${width}×${height})`, width, height, bitrate: Math.min(12000, Math.max(2500, Math.round(width * height * fps / 9000))) }];
|
||||
if (height > 720) ladder.push({ name: "720p", label: "720p", height: 720, width: Math.max(2, Math.round((width * 720 / height) / 2) * 2), bitrate: 4500 });
|
||||
if (height > 480) ladder.push({ name: "480p", label: "480p", height: 480, width: Math.max(2, Math.round((width * 480 / height) / 2) * 2), bitrate: 2200 });
|
||||
return { width, height, fps, variants: ladder };
|
||||
}
|
||||
|
||||
function ffmpegArgs(session) {
|
||||
const variants = session.source.variants;
|
||||
const splitNames = variants.map((_, index) => `[split${index}]`).join("");
|
||||
const filters = variants.length > 1 ? [`[0:v:0]split=${variants.length}${splitNames}`] : [];
|
||||
const maps = [];
|
||||
const streamMap = [];
|
||||
variants.forEach((variant, index) => {
|
||||
if (variant.name === "source") {
|
||||
if (variants.length > 1) {
|
||||
filters.push(`[split${index}]scale=w='min(iw,${variant.width})':h='min(ih,${variant.height})':force_original_aspect_ratio=decrease:force_divisible_by=2[v${index}]`);
|
||||
}
|
||||
maps.push("-map", variants.length > 1 ? `[v${index}]` : "0:v:0");
|
||||
} else {
|
||||
filters.push(`[split${index}]scale=w=${variant.width}:h=${variant.height}:flags=lanczos[v${index}]`);
|
||||
maps.push("-map", `[v${index}]`);
|
||||
}
|
||||
maps.push("-map", "0:a:0");
|
||||
streamMap.push(`v:${index},a:${index},name:${variant.name}`);
|
||||
});
|
||||
const encoderArgs = session.receiver.encoder === "h264_nvenc"
|
||||
? ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-rc", "vbr"]
|
||||
: ["-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency"];
|
||||
const bitrateArgs = variants.flatMap((variant, index) => [
|
||||
`-b:v:${index}`, `${variant.bitrate}k`,
|
||||
`-maxrate:v:${index}`, `${Math.round(variant.bitrate * 1.15)}k`,
|
||||
`-bufsize:v:${index}`, `${Math.round(variant.bitrate * 2)}k`
|
||||
]);
|
||||
return [
|
||||
"-hide_banner", "-nostdin", "-loglevel", "warning",
|
||||
"-listen", "1", "-rw_timeout", `${INACTIVITY_MS * 1000}`, "-thread_queue_size", "512",
|
||||
"-i", session.listenerUrl,
|
||||
...(filters.length ? ["-filter_complex", filters.join(";")] : []),
|
||||
...maps,
|
||||
...encoderArgs,
|
||||
...bitrateArgs,
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000",
|
||||
"-max_muxing_queue_size", "1024",
|
||||
"-g", `${Math.max(30, Math.round(session.source.fps * 2))}`, "-keyint_min", `${Math.max(30, Math.round(session.source.fps * 2))}`, "-sc_threshold", "0",
|
||||
"-f", "hls", "-hls_time", "2", "-hls_list_size", "8",
|
||||
"-hls_flags", "delete_segments+independent_segments+program_date_time+temp_file",
|
||||
"-master_pl_name", "master.m3u8",
|
||||
"-var_stream_map", streamMap.join(" "),
|
||||
"-hls_segment_filename", path.join(session.directory, "stream_%v_%06d.ts"),
|
||||
path.join(session.directory, "stream_%v.m3u8"),
|
||||
"-progress", "pipe:1"
|
||||
...(session.captionWarnings || []),
|
||||
...(session.playerWarnings || [])
|
||||
];
|
||||
}
|
||||
|
||||
class StreamTestingService {
|
||||
constructor() {
|
||||
constructor(options = {}) {
|
||||
this.runtime = options.runtime || new MediaMtxRuntime();
|
||||
this.active = null;
|
||||
this.timeline = [];
|
||||
this.lastCreateAt = new Map();
|
||||
this.timer = setInterval(() => this.sweep(), 2000);
|
||||
this.timer.unref?.();
|
||||
this.sweepRunning = false;
|
||||
this.runtime.on?.("changed", () => this.notify());
|
||||
this.runtime.on?.("failed", (error) => {
|
||||
const session = this.active;
|
||||
if (session && !session.stopping) this.fail(session, `The private media router stopped: ${error.message}`);
|
||||
});
|
||||
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
||||
this.cleanupLegacyMedia();
|
||||
this.timer = options.timer === false ? null : setInterval(() => void this.sweep(), 2000);
|
||||
this.timer?.unref?.();
|
||||
this.exitHandler = () => {
|
||||
for (const child of [this.active?.patternProcess, this.active?.process]) {
|
||||
try {
|
||||
if (child && child.exitCode === null) child.kill("SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
if (this.active?.directory) fs.rmSync(this.active.directory, { recursive: true, force: true });
|
||||
if (this.runtime.child && this.runtime.child.exitCode === null) this.runtime.child.kill("SIGKILL");
|
||||
} catch {}
|
||||
};
|
||||
process.once("exit", this.exitHandler);
|
||||
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
||||
this.cleanupStale();
|
||||
}
|
||||
|
||||
receiverStatus() {
|
||||
return executableStatus();
|
||||
receiverStatus(options) {
|
||||
return this.runtime.status(options);
|
||||
}
|
||||
|
||||
publicStatus() {
|
||||
const session = this.active;
|
||||
const receiver = session?.receiver || executableStatus();
|
||||
const receiver = publicRuntimeStatus(this.runtime.status());
|
||||
return {
|
||||
available: receiver.available,
|
||||
receiver: { available: receiver.available, encoder: receiver.encoder || null, hardware: Boolean(receiver.hardware), detail: receiver.detail },
|
||||
receiver,
|
||||
active: Boolean(session),
|
||||
session: session ? {
|
||||
id: session.id,
|
||||
state: session.state,
|
||||
started_at: session.startedAt,
|
||||
expires_at: session.expiresAt,
|
||||
last_activity_at: session.lastActivityAt,
|
||||
last_activity_at: session.lastMediaAt,
|
||||
device: session.deviceName,
|
||||
source: session.source,
|
||||
playback_url: `/admin/stream-testing/media/${session.id}/master.m3u8`,
|
||||
playback_url: `/admin/stream-testing/media/${session.id}/index.m3u8`,
|
||||
captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
|
||||
metrics: session.metrics,
|
||||
warnings: session.warnings,
|
||||
ingest: redactUrl(session.listenerUrl)
|
||||
ingest: `${session.transport}://${session.ingestHost}:${session.publicPort}/lumi-test/[session]`
|
||||
} : null,
|
||||
timeline: this.timeline.slice(-100)
|
||||
};
|
||||
}
|
||||
|
||||
create(device, input = {}, send = null) {
|
||||
async create(device, input = {}, send = null) {
|
||||
const now = Date.now();
|
||||
if (this.active) {
|
||||
const error = new Error("A stream test is already active. End it before starting another.");
|
||||
error.code = "STREAM_TEST_ACTIVE";
|
||||
throw error;
|
||||
}
|
||||
if (this.active) throw coded("STREAM_TEST_ACTIVE", "A stream test is already active. End it before starting another.");
|
||||
if (now - (this.lastCreateAt.get(device.id) || 0) < 5000) {
|
||||
const error = new Error("Wait a few seconds before creating another stream test.");
|
||||
error.code = "STREAM_TEST_RATE_LIMIT";
|
||||
throw error;
|
||||
throw coded("STREAM_TEST_RATE_LIMIT", "Wait a few seconds before creating another stream test.");
|
||||
}
|
||||
const receiver = executableStatus({ refresh: true });
|
||||
const receiver = this.runtime.status({ refresh: true });
|
||||
if (!receiver.available) {
|
||||
const error = new Error(receiver.detail);
|
||||
error.code = "STREAM_TEST_RECEIVER_UNAVAILABLE";
|
||||
throw error;
|
||||
}
|
||||
const source = ladderFor(input.source || {});
|
||||
if (source.width > 3840 || source.height > 2160 || source.fps > 60) {
|
||||
const error = new Error("Stream testing is bounded to 3840×2160 at 60 fps.");
|
||||
error.code = "STREAM_TEST_SOURCE_LIMIT";
|
||||
throw error;
|
||||
throw coded(
|
||||
"STREAM_TEST_RECEIVER_UNAVAILABLE",
|
||||
"The Lumi private media router needs setup. Ask an administrator to open Admin > Stream testing and choose Install MediaMTX."
|
||||
);
|
||||
}
|
||||
const network = await resolveIngestConfiguration(device);
|
||||
const id = crypto.randomUUID();
|
||||
const secret = crypto.randomBytes(32).toString("base64url");
|
||||
const directory = path.join(DATA_ROOT, id);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const configuredHost = String(process.env.LUMI_STREAM_TEST_INGEST_HOST || (input.pattern === true ? "127.0.0.1" : "")).trim();
|
||||
if (!configuredHost) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
const error = new Error("Set LUMI_STREAM_TEST_INGEST_HOST to the hostname reachable from the streaming computer.");
|
||||
error.code = "STREAM_TEST_INGEST_UNCONFIGURED";
|
||||
throw error;
|
||||
}
|
||||
const protocol = process.env.LUMI_STREAM_TEST_RTMPS === "true" ? "rtmps" : "rtmp";
|
||||
const key = `${id}/${secret}`;
|
||||
const listenerUrl = `rtmp://0.0.0.0:${INGEST_PORT}/lumi-test/${key}`;
|
||||
const streamPath = `lumi-test/${id}`;
|
||||
const username = `lumi_${crypto.randomBytes(18).toString("base64url")}`;
|
||||
const password = crypto.randomBytes(32).toString("base64url");
|
||||
const source = sourceFor(input.source || {});
|
||||
const baseWarnings = network.transport === "rtmp"
|
||||
? [{
|
||||
severity: "warning",
|
||||
code: "unencrypted_ingest",
|
||||
message: "This private test uses unencrypted RTMP only because the paired streaming computer is on a loopback or private network."
|
||||
}]
|
||||
: [];
|
||||
const session = {
|
||||
id,
|
||||
path: streamPath,
|
||||
username,
|
||||
password,
|
||||
transport: network.transport,
|
||||
ingestHost: network.host,
|
||||
ingestPort: network.ingestPort,
|
||||
publicPort: network.publicPort,
|
||||
deviceId: device.id,
|
||||
deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
|
||||
state: "starting",
|
||||
source,
|
||||
receiver,
|
||||
directory,
|
||||
listenerUrl,
|
||||
createdAt: now,
|
||||
startedAt: null,
|
||||
expiresAt: now + MAX_SESSION_MS,
|
||||
lastActivityAt: now,
|
||||
metrics: { obs: {}, receiver: {}, bytes: 0 },
|
||||
baseWarnings: protocol === "rtmp" ? [{ severity: "warning", code: "unencrypted_ingest", message: "The ingest hop uses an ephemeral credential but is not transport-encrypted. Keep the ingest port private or enable RTMPS at the reverse-proxy boundary." }] : [],
|
||||
lastMediaAt: now,
|
||||
lastInboundBytes: 0,
|
||||
lastMetricAt: now,
|
||||
metrics: {
|
||||
obs: {},
|
||||
receiver: {},
|
||||
player: { latency_seconds: null, buffer_seconds: null, stalls: 0, errors: 0 },
|
||||
captions: {}
|
||||
},
|
||||
baseWarnings,
|
||||
obsWarnings: [],
|
||||
receiverWarnings: [],
|
||||
captionWarnings: [],
|
||||
warnings: protocol === "rtmp" ? [{ severity: "warning", code: "unencrypted_ingest", message: "The ingest hop uses an ephemeral credential but is not transport-encrypted. Keep the ingest port private or enable RTMPS at the reverse-proxy boundary." }] : [],
|
||||
playerWarnings: [],
|
||||
warnings: baseWarnings.slice(),
|
||||
captions: [],
|
||||
process: null,
|
||||
patternProcess: null,
|
||||
pattern: input.pattern === true,
|
||||
send: typeof send === "function" ? send : null,
|
||||
stopping: false
|
||||
};
|
||||
this.active = session;
|
||||
this.lastCreateAt.set(device.id, now);
|
||||
this.event("created", "Test session created.", session);
|
||||
this.startReceiver(session);
|
||||
this.event("created", "Private Stream Test session created.", session);
|
||||
try {
|
||||
await this.runtime.configureSession({
|
||||
path: streamPath,
|
||||
username,
|
||||
password,
|
||||
transport: network.transport,
|
||||
ingestPort: network.ingestPort,
|
||||
tlsCert: network.tlsCert,
|
||||
tlsKey: network.tlsKey,
|
||||
bindHost: network.bindHost
|
||||
});
|
||||
this.event("receiver_ready", "Lumi's private media router is ready for OBS.", session);
|
||||
} catch (error) {
|
||||
if (this.active?.id === session.id) this.active = null;
|
||||
try { await this.runtime.clearSession(); } catch {}
|
||||
this.event("receiver_error", `Private media router startup failed: ${error.message}`, session);
|
||||
throw coded("STREAM_TEST_RECEIVER_UNAVAILABLE", `The private media router could not start: ${error.message}`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
state: session.state,
|
||||
expires_at: session.expiresAt,
|
||||
ingest: {
|
||||
server: `${protocol}://${configuredHost}:${PUBLIC_INGEST_PORT}/lumi-test`,
|
||||
key,
|
||||
encrypted: protocol === "rtmps"
|
||||
server: `${network.transport}://${network.host}:${network.publicPort}/lumi-test`,
|
||||
key: `${id}?user=${encodeURIComponent(username)}&pass=${encodeURIComponent(password)}`,
|
||||
encrypted: network.transport === "rtmps"
|
||||
},
|
||||
source,
|
||||
warning: session.warnings[0]?.message || null
|
||||
};
|
||||
}
|
||||
|
||||
createPattern() {
|
||||
const created = this.create(
|
||||
{ id: "admin-test-pattern", name: "Deterministic test pattern" },
|
||||
{ pattern: true, source: { width: 1280, height: 720, fps: 30 } }
|
||||
);
|
||||
const session = this.active;
|
||||
if (!session) throw new Error("The deterministic receiver session was not created.");
|
||||
const publishUrl = session.listenerUrl.replace("0.0.0.0", "127.0.0.1");
|
||||
setTimeout(() => {
|
||||
if (this.active?.id !== session.id || session.stopping) return;
|
||||
const args = [
|
||||
"-hide_banner", "-nostdin", "-loglevel", "warning", "-re",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30",
|
||||
"-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||||
"-b:v", "3500k", "-g", "60", "-c:a", "aac", "-b:a", "128k",
|
||||
"-f", "flv", publishUrl
|
||||
];
|
||||
const feeder = spawn(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
|
||||
session.patternProcess = feeder;
|
||||
feeder.stderr.resume();
|
||||
feeder.on("error", (error) => this.fail(session, `Test pattern could not start: ${error.message}`));
|
||||
feeder.on("exit", (code, signal) => {
|
||||
if (!session.stopping && this.active?.id === session.id)
|
||||
this.fail(session, `Test pattern stopped unexpectedly (${signal || `exit ${code}`}).`);
|
||||
});
|
||||
this.event("pattern_started", "Deterministic color, motion, timing, and audio pattern started.", session);
|
||||
}, 750).unref?.();
|
||||
return created;
|
||||
}
|
||||
|
||||
startReceiver(session) {
|
||||
const args = ffmpegArgs(session);
|
||||
const child = spawn(FFMPEG, args, { cwd: session.directory, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||
session.process = child;
|
||||
let progress = "";
|
||||
let diagnostic = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
progress += chunk;
|
||||
const records = progress.split(/\r?\n/);
|
||||
progress = records.pop() || "";
|
||||
for (const line of records) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) continue;
|
||||
const key = line.slice(0, separator);
|
||||
const value = line.slice(separator + 1);
|
||||
if (["fps", "bitrate", "speed", "drop_frames", "dup_frames", "out_time_ms"].includes(key)) session.metrics.receiver[key] = value;
|
||||
if (key === "progress") {
|
||||
session.lastActivityAt = Date.now();
|
||||
if (session.state === "starting") {
|
||||
session.state = "receiving";
|
||||
session.startedAt = Date.now();
|
||||
this.event("receiving", "OBS stream reached Lumi.", session);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => { diagnostic = `${diagnostic}${chunk}`.slice(-4000); });
|
||||
child.on("error", (error) => this.fail(session, `Receiver could not start: ${error.message}`));
|
||||
child.on("exit", (code, signal) => {
|
||||
if (session.stopping || this.active?.id !== session.id) return;
|
||||
this.fail(session, `Receiver stopped unexpectedly (${signal || `exit ${code}`}). ${redactDiagnostic(diagnostic.trim().slice(-600), session)}`);
|
||||
});
|
||||
}
|
||||
|
||||
updateObs(deviceId, input = {}) {
|
||||
const session = this.requireDeviceSession(deviceId, input.session_id);
|
||||
session.lastActivityAt = Date.now();
|
||||
session.metrics.obs = {
|
||||
bitrate_kbps: finite(input.bitrate_kbps, 0, 0, 100000),
|
||||
dropped_frames: Math.round(finite(input.dropped_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
|
||||
@ -400,6 +228,30 @@ class StreamTestingService {
|
||||
return this.publicStatus().session;
|
||||
}
|
||||
|
||||
reportPlayer(input = {}) {
|
||||
const session = this.active;
|
||||
if (!session || session.id !== input.session_id) throw coded("STREAM_TEST_NOT_ACTIVE", "The stream test session is no longer active.");
|
||||
session.metrics.player = {
|
||||
latency_seconds: nullableFinite(input.latency_seconds, 0, 120),
|
||||
buffer_seconds: nullableFinite(input.buffer_seconds, 0, 120),
|
||||
stalls: Math.round(finite(input.stalls, session.metrics.player.stalls || 0, 0, Number.MAX_SAFE_INTEGER)),
|
||||
errors: Math.round(finite(input.errors, session.metrics.player.errors || 0, 0, Number.MAX_SAFE_INTEGER)),
|
||||
at: Date.now()
|
||||
};
|
||||
session.playerWarnings = [];
|
||||
if (session.metrics.player.errors > 0) {
|
||||
session.playerWarnings.push({ severity: "warning", code: "player_errors", message: `The admin preview reported ${session.metrics.player.errors} playback error${session.metrics.player.errors === 1 ? "" : "s"}.` });
|
||||
}
|
||||
if (session.metrics.player.stalls >= 3) {
|
||||
session.playerWarnings.push({ severity: "warning", code: "player_stalls", message: `The admin preview stalled ${session.metrics.player.stalls} times.` });
|
||||
}
|
||||
if (session.metrics.player.latency_seconds !== null && session.metrics.player.latency_seconds >= 10) {
|
||||
session.playerWarnings.push({ severity: "warning", code: "player_latency", message: `The admin preview is ${session.metrics.player.latency_seconds.toFixed(1)} seconds behind live input.` });
|
||||
}
|
||||
refreshWarnings(session);
|
||||
return session.metrics.player;
|
||||
}
|
||||
|
||||
addCaption(deviceId, input = {}) {
|
||||
const session = this.requireDeviceSession(deviceId, input.session_id);
|
||||
const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
|
||||
@ -416,7 +268,7 @@ class StreamTestingService {
|
||||
? [{ severity: "warning", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
|
||||
: [];
|
||||
refreshWarnings(session);
|
||||
session.lastActivityAt = Date.now();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
captionFile(id) {
|
||||
@ -430,23 +282,82 @@ class StreamTestingService {
|
||||
return `WEBVTT\n\n${session.captions.map((cue, index) => `${index + 1}\n${timestamp(cue.start)} --> ${timestamp(cue.end)}\n${cue.text}\n`).join("\n")}`;
|
||||
}
|
||||
|
||||
mediaFile(id, name) {
|
||||
async proxyMedia(id, asset, req, res) {
|
||||
const session = this.active?.id === id ? this.active : null;
|
||||
if (!session || !/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test(name)) return null;
|
||||
const resolved = path.join(session.directory, name);
|
||||
return fs.existsSync(resolved) && fs.statSync(resolved).isFile() ? resolved : null;
|
||||
if (!session) return res.status(404).end();
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
req.once("aborted", abort);
|
||||
res.once("close", abort);
|
||||
try {
|
||||
const upstream = await this.runtime.fetchHls(session.path, asset, {
|
||||
signal: controller.signal,
|
||||
query: new URL(req.originalUrl, "http://lumi.invalid").search,
|
||||
headers: req.headers.range ? { Range: String(req.headers.range).slice(0, 160) } : undefined
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
if (upstream.body) await upstream.body.cancel().catch(() => {});
|
||||
res.set("X-Lumi-Upstream-Status", String(upstream.status));
|
||||
return res.status(upstream.status === 404 ? 404 : 502).end();
|
||||
}
|
||||
const contentType = String(upstream.headers.get("content-type") || "");
|
||||
res.status(upstream.status);
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
if (/mpegurl|application\/vnd\.apple\.mpegurl/i.test(contentType) || String(asset).endsWith(".m3u8")) {
|
||||
const text = await readBoundedText(upstream, MAX_MANIFEST_BYTES);
|
||||
return res.type("application/vnd.apple.mpegurl").send(
|
||||
rewriteManifest(text, `/admin/stream-testing/media/${session.id}/`, session.path)
|
||||
);
|
||||
}
|
||||
const allowedType = /^(?:video\/mp2t|video\/mp4|audio\/mp4|application\/octet-stream)(?:;|$)/i.test(contentType)
|
||||
? contentType
|
||||
: "application/octet-stream";
|
||||
res.set("Content-Type", allowedType);
|
||||
const contentLength = upstream.headers.get("content-length");
|
||||
const contentRange = upstream.headers.get("content-range");
|
||||
if (contentLength) res.set("Content-Length", contentLength);
|
||||
if (contentRange) res.set("Content-Range", contentRange);
|
||||
if (upstream.body) await pipeline(Readable.fromWeb(upstream.body), res);
|
||||
else res.end();
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError" || res.headersSent) return res.end();
|
||||
res.status(Number(error.status) || 502).json({ ok: false, error: "The private media preview is temporarily unavailable." });
|
||||
} finally {
|
||||
req.removeListener("aborted", abort);
|
||||
res.removeListener("close", abort);
|
||||
}
|
||||
}
|
||||
|
||||
requireDeviceSession(deviceId, id) {
|
||||
const session = this.active;
|
||||
if (!session || session.id !== id || session.deviceId !== deviceId) {
|
||||
const error = new Error("The stream test session is not active for this Companion.");
|
||||
error.code = "STREAM_TEST_NOT_ACTIVE";
|
||||
throw error;
|
||||
throw coded("STREAM_TEST_NOT_ACTIVE", "The stream test session is not active for this Companion.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async installRuntime(options) {
|
||||
if (this.active) throw new Error("End the active Stream Test before installing or repairing MediaMTX.");
|
||||
const result = await this.runtime.install(options);
|
||||
this.event("runtime_installed", `MediaMTX ${result.version || result.tested_version || ""} was installed and verified.`.trim(), null);
|
||||
return result;
|
||||
}
|
||||
|
||||
async repairRuntime(options) {
|
||||
if (this.active) throw new Error("End the active Stream Test before installing or repairing MediaMTX.");
|
||||
const result = await this.runtime.repair(options);
|
||||
this.event("runtime_repaired", `MediaMTX ${result.version || result.tested_version || ""} was repaired and verified.`.trim(), null);
|
||||
return result;
|
||||
}
|
||||
|
||||
async healthCheck() {
|
||||
if (this.active) throw new Error("The active Stream Test is already exercising MediaMTX.");
|
||||
const result = await this.runtime.healthCheck();
|
||||
this.event("runtime_health", `MediaMTX ${result.version || ""} passed its listener and API health check.`.trim(), null);
|
||||
return result;
|
||||
}
|
||||
|
||||
async stop(input = {}) {
|
||||
const session = this.active;
|
||||
if (!session || (input.session_id && session.id !== input.session_id)) return { stopped: false };
|
||||
@ -454,53 +365,126 @@ class StreamTestingService {
|
||||
session.stopping = true;
|
||||
session.state = input.failed ? "failed" : "stopping";
|
||||
this.event(input.failed ? "failed" : "stopping", input.reason || (input.failed ? "The test failed." : "The test is ending."), session);
|
||||
const child = session.process;
|
||||
if (session.patternProcess && session.patternProcess.exitCode === null) session.patternProcess.kill("SIGTERM");
|
||||
if (child && child.exitCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
]);
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
if (this.active?.id === session.id) this.active = null;
|
||||
let runtimeError = null;
|
||||
try {
|
||||
await this.runtime.clearSession();
|
||||
} catch (error) {
|
||||
runtimeError = error;
|
||||
try { await this.runtime.close(); } catch {}
|
||||
}
|
||||
try {
|
||||
session.send?.("stream_test_ended", {
|
||||
session_id: session.id,
|
||||
reason: input.reason || "The private stream test ended.",
|
||||
failed: Boolean(input.failed)
|
||||
failed: Boolean(input.failed || runtimeError)
|
||||
});
|
||||
} catch {}
|
||||
this.event(input.failed ? "failed" : "ended", input.reason || "Test session ended and temporary media was removed.", session);
|
||||
try { await fs.promises.rm(session.directory, { recursive: true, force: true }); } catch {}
|
||||
session.username = null;
|
||||
session.password = null;
|
||||
this.event(
|
||||
input.failed || runtimeError ? "failed" : "ended",
|
||||
runtimeError ? `The session ended, but the private media router needed a forced stop: ${runtimeError.message}` : (input.reason || "Private Stream Test ended; no recording was retained."),
|
||||
session
|
||||
);
|
||||
this.notify();
|
||||
return { stopped: true, id: session.id };
|
||||
}
|
||||
|
||||
fail(session, detail) {
|
||||
if (this.active?.id !== session.id || session.stopping) return;
|
||||
session.warnings.push({ severity: "critical", code: "receiver_stopped", message: String(detail).slice(0, 1000) });
|
||||
session.receiverWarnings = [{ severity: "critical", code: "receiver_stopped", message: String(detail).slice(0, 1000) }];
|
||||
refreshWarnings(session);
|
||||
this.event("receiver_error", String(detail).slice(0, 1000), session);
|
||||
void this.stop({ session_id: session.id, failed: true, reason: "The Lumi receiver stopped. OBS recovery was requested." });
|
||||
void this.stop({ session_id: session.id, failed: true, reason: "Lumi's private media router stopped. OBS recovery was requested." });
|
||||
}
|
||||
|
||||
sweep() {
|
||||
async sweep() {
|
||||
if (this.sweepRunning) return;
|
||||
const session = this.active;
|
||||
if (!session) return;
|
||||
session.metrics.bytes = directoryBytes(session.directory);
|
||||
const receiverSpeed = Number.parseFloat(String(session.metrics.receiver.speed || "").replace(/x$/, ""));
|
||||
session.receiverWarnings = Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.75
|
||||
? [{ severity: "critical", code: "receiver_slow", message: `Receiver transcoding is only ${receiverSpeed.toFixed(2)}× real time.` }]
|
||||
: Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.95
|
||||
? [{ severity: "warning", code: "receiver_slow", message: `Receiver transcoding is ${receiverSpeed.toFixed(2)}× real time.` }]
|
||||
: [];
|
||||
refreshWarnings(session);
|
||||
const now = Date.now();
|
||||
if (session.metrics.bytes > MAX_OUTPUT_BYTES) return void this.stop({ session_id: session.id, failed: true, reason: "The test reached its temporary media safety limit." });
|
||||
if (now >= session.expiresAt) return void this.stop({ session_id: session.id, reason: "The maximum test duration expired." });
|
||||
if (now - session.lastActivityAt >= INACTIVITY_MS) return void this.stop({ session_id: session.id, failed: true, reason: "No stream activity reached Lumi before the inactivity limit." });
|
||||
this.notify();
|
||||
this.sweepRunning = true;
|
||||
try {
|
||||
let diagnostic;
|
||||
try {
|
||||
diagnostic = await this.runtime.diagnostics(session.path);
|
||||
} catch (error) {
|
||||
session.receiverWarnings = [{ severity: "warning", code: "receiver_diagnostics", message: `Media diagnostics are temporarily unavailable: ${error.message}` }];
|
||||
refreshWarnings(session);
|
||||
const now = Date.now();
|
||||
if (now >= session.expiresAt) {
|
||||
await this.stop({ session_id: session.id, reason: "The maximum test duration expired." });
|
||||
return;
|
||||
}
|
||||
if (now - session.lastMediaAt >= INACTIVITY_MS) {
|
||||
await this.stop({ session_id: session.id, failed: true, reason: "No OBS audio or video reached Lumi before the inactivity limit." });
|
||||
return;
|
||||
}
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
if (this.active?.id !== session.id) return;
|
||||
const inboundBytes = Number(
|
||||
diagnostic.metrics.paths_inbound_bytes
|
||||
?? diagnostic.metrics.paths_bytes_received
|
||||
?? diagnostic.path?.bytes_received
|
||||
?? 0
|
||||
);
|
||||
const metricAt = Date.now();
|
||||
const elapsedSeconds = Math.max(0.001, (metricAt - session.lastMetricAt) / 1000);
|
||||
const inboundDelta = Math.max(0, inboundBytes - session.lastInboundBytes);
|
||||
const inboundBitrateKbps = inboundDelta * 8 / elapsedSeconds / 1000;
|
||||
if (inboundBytes > session.lastInboundBytes) {
|
||||
session.lastInboundBytes = inboundBytes;
|
||||
session.lastMediaAt = metricAt;
|
||||
}
|
||||
session.lastMetricAt = metricAt;
|
||||
if (diagnostic.path?.ready) {
|
||||
if (session.state === "starting") {
|
||||
session.state = "receiving";
|
||||
session.startedAt = metricAt;
|
||||
this.event("receiving", "OBS audio and video reached Lumi.", session);
|
||||
}
|
||||
}
|
||||
session.metrics.receiver = {
|
||||
state: diagnostic.process.state,
|
||||
path_ready: Boolean(diagnostic.path?.ready),
|
||||
tracks: diagnostic.path?.tracks || [],
|
||||
readers: diagnostic.path?.readers || 0,
|
||||
inbound_bytes: inboundBytes,
|
||||
inbound_bitrate_kbps: Math.round(inboundBitrateKbps),
|
||||
outbound_bytes: Number(diagnostic.metrics.paths_outbound_bytes ?? diagnostic.path?.bytes_sent ?? 0),
|
||||
inbound_frame_errors: Number(diagnostic.metrics.paths_inbound_frames_in_error || 0),
|
||||
hls_sessions: Number(diagnostic.metrics.hls_sessions || 0),
|
||||
hls_discarded_frames: Number(diagnostic.metrics.hls_muxers_outbound_frames_discarded || 0)
|
||||
};
|
||||
session.receiverWarnings = [];
|
||||
if (session.metrics.receiver.inbound_frame_errors > 0) {
|
||||
session.receiverWarnings.push({ severity: "warning", code: "receiver_frame_errors", message: `The media router reported ${session.metrics.receiver.inbound_frame_errors} inbound frame errors.` });
|
||||
}
|
||||
if (session.metrics.receiver.hls_discarded_frames > 0) {
|
||||
session.receiverWarnings.push({ severity: "warning", code: "receiver_hls_discard", message: `The private preview discarded ${session.metrics.receiver.hls_discarded_frames} frames while packaging playback.` });
|
||||
}
|
||||
if (session.state === "receiving" && !session.metrics.receiver.path_ready) {
|
||||
session.receiverWarnings.push({ severity: "critical", code: "receiver_disconnected", message: "The OBS publisher disconnected from Lumi's private media router." });
|
||||
}
|
||||
if (session.metrics.obs.active && metricAt - session.lastMediaAt >= Math.min(15_000, Math.floor(INACTIVITY_MS / 2))) {
|
||||
session.receiverWarnings.push({ severity: "critical", code: "receiver_ingest_stalled", message: "OBS reports active output, but Lumi is not receiving new media bytes." });
|
||||
}
|
||||
refreshWarnings(session);
|
||||
const now = Date.now();
|
||||
if (now >= session.expiresAt) {
|
||||
await this.stop({ session_id: session.id, reason: "The maximum test duration expired." });
|
||||
return;
|
||||
}
|
||||
if (now - session.lastMediaAt >= INACTIVITY_MS) {
|
||||
await this.stop({ session_id: session.id, failed: true, reason: "No OBS audio or video reached Lumi before the inactivity limit." });
|
||||
return;
|
||||
}
|
||||
this.notify();
|
||||
} finally {
|
||||
this.sweepRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
event(kind, message, session = this.active) {
|
||||
@ -513,20 +497,211 @@ class StreamTestingService {
|
||||
publishWebEvent("stream-test:changed", { active: Boolean(this.active), state: this.active?.state || "idle" }, { role: "admin" });
|
||||
}
|
||||
|
||||
cleanupStale() {
|
||||
cleanupLegacyMedia() {
|
||||
for (const entry of fs.readdirSync(DATA_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) continue;
|
||||
fs.rmSync(path.join(DATA_ROOT, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
clearInterval(this.timer);
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
await this.stop({ reason: "Lumi is shutting down; OBS recovery was requested." });
|
||||
await this.runtime.close();
|
||||
process.removeListener("exit", this.exitHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveIngestConfiguration(device) {
|
||||
const pairedOrigin = String(device?.pairing_host || "").trim();
|
||||
let pairedHost = "";
|
||||
try {
|
||||
pairedHost = new URL(pairedOrigin).hostname;
|
||||
} catch {}
|
||||
const host = validateHostname(String(process.env.LUMI_STREAM_TEST_INGEST_HOST || pairedHost).trim());
|
||||
if (!host) throw coded("STREAM_TEST_INGEST_UNCONFIGURED", "The paired Lumi hostname could not be used for private Stream Testing.");
|
||||
const requested = String(process.env.LUMI_STREAM_TEST_TRANSPORT || "").trim().toLowerCase();
|
||||
const tlsCert = String(process.env.LUMI_STREAM_TEST_TLS_CERT || "").trim();
|
||||
const tlsKey = String(process.env.LUMI_STREAM_TEST_TLS_KEY || "").trim();
|
||||
const resolved = await resolveHostAddresses(host);
|
||||
const privateHost = resolved.length > 0 && resolved.every(isPrivateAddress);
|
||||
const transport = requested || (tlsCert && tlsKey ? "rtmps" : "rtmp");
|
||||
if (!["rtmps", "rtmp"].includes(transport)) throw coded("STREAM_TEST_TRANSPORT_INVALID", "LUMI_STREAM_TEST_TRANSPORT must be rtmps or rtmp.");
|
||||
if (transport === "rtmps") {
|
||||
for (const [label, target] of [["certificate", tlsCert], ["private key", tlsKey]]) {
|
||||
if (!target || !fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||
throw coded("STREAM_TEST_TLS_UNCONFIGURED", `RTMPS requires a readable ${label}. Configure LUMI_STREAM_TEST_TLS_CERT and LUMI_STREAM_TEST_TLS_KEY.`);
|
||||
}
|
||||
}
|
||||
} else if (!privateHost && String(process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE || "").toLowerCase() !== "true") {
|
||||
throw coded(
|
||||
"STREAM_TEST_INSECURE_REMOTE",
|
||||
"Lumi will not expose unencrypted RTMP to a public address. Configure RTMPS, or use a private/LAN pairing hostname."
|
||||
);
|
||||
}
|
||||
return {
|
||||
host,
|
||||
transport,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
bindHost: transport === "rtmp" && privateHost ? resolved[0] : "0.0.0.0",
|
||||
ingestPort: INGEST_PORT,
|
||||
publicPort: PUBLIC_INGEST_PORT
|
||||
};
|
||||
}
|
||||
|
||||
function validateHostname(value) {
|
||||
if (!value || value.length > 253) return "";
|
||||
const host = value.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(host)) return net.isIP(host) === 6 ? `[${host}]` : host;
|
||||
if (/[/:?#@\s]/.test(host)) return "";
|
||||
return /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(host)
|
||||
? host.toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
async function isPrivateHost(host) {
|
||||
const addresses = await resolveHostAddresses(host);
|
||||
return addresses.length > 0 && addresses.every(isPrivateAddress);
|
||||
}
|
||||
|
||||
async function resolveHostAddresses(host) {
|
||||
const bare = host.replace(/^\[|\]$/g, "");
|
||||
if (bare === "localhost" || bare.endsWith(".localhost")) return ["127.0.0.1"];
|
||||
if (net.isIP(bare)) return [bare];
|
||||
try {
|
||||
return (await dns.promises.lookup(bare, { all: true, verbatim: true })).map((item) => item.address);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateAddress(address) {
|
||||
if (net.isIPv4(address)) {
|
||||
const parts = address.split(".").map(Number);
|
||||
return parts[0] === 10
|
||||
|| parts[0] === 127
|
||||
|| (parts[0] === 169 && parts[1] === 254)
|
||||
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
||||
|| (parts[0] === 192 && parts[1] === 168);
|
||||
}
|
||||
const normalized = String(address).toLowerCase();
|
||||
return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
||||
}
|
||||
|
||||
function rewriteManifest(content, proxyBase, sessionPath) {
|
||||
const rewrite = (uri) => {
|
||||
if (!uri || uri.startsWith("data:")) return uri;
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(uri) || String(uri).startsWith("//")) {
|
||||
throw new Error("MediaMTX returned a foreign playlist URI.");
|
||||
}
|
||||
const rawPath = decodeURIComponent(String(uri).split(/[?#]/, 1)[0]).replace(/\\/g, "/");
|
||||
if (rawPath.split("/").some((segment) => segment === "." || segment === "..")) {
|
||||
throw new Error("MediaMTX returned an unsafe playlist URI.");
|
||||
}
|
||||
let relative = uri;
|
||||
try {
|
||||
const parsed = new URL(uri, "http://mediamtx.invalid/");
|
||||
relative = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
|
||||
if (relative.startsWith(`${sessionPath}/`)) relative = relative.slice(sessionPath.length + 1);
|
||||
relative += parsed.search;
|
||||
} catch {
|
||||
throw new Error("MediaMTX returned an invalid playlist URI.");
|
||||
}
|
||||
const [asset, query = ""] = relative.split("?", 2);
|
||||
if (!asset || asset.split("/").some((segment) => !segment || segment === "." || segment === ".." || !/^[A-Za-z0-9._-]+$/.test(segment))) {
|
||||
throw new Error("MediaMTX returned an unsafe playlist URI.");
|
||||
}
|
||||
return `${proxyBase}${asset.split("/").map(encodeURIComponent).join("/")}${query ? `?${query}` : ""}`;
|
||||
};
|
||||
return String(content || "").split(/\r?\n/).map((line) => {
|
||||
if (!line) return line;
|
||||
if (!line.startsWith("#")) return rewrite(line.trim());
|
||||
return line.replace(/URI="([^"]+)"/g, (_match, uri) => `URI="${rewrite(uri)}"`);
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
async function readBoundedText(response, maxBytes) {
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) return response.text();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error("MediaMTX returned an oversized playlist.");
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function coded(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function nullableFinite(value, min, max) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return finite(value, null, min, max);
|
||||
}
|
||||
|
||||
function formatFps(value) {
|
||||
return Number.isInteger(value) ? String(value) : Number(value).toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function port(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function publicRuntimeStatus(status) {
|
||||
return {
|
||||
available: Boolean(status.available),
|
||||
installed: Boolean(status.installed),
|
||||
valid: Boolean(status.valid),
|
||||
version: status.version || null,
|
||||
tested_version: status.tested_version || null,
|
||||
source: status.source || null,
|
||||
managed_install_supported: Boolean(status.managed_install_supported),
|
||||
detail: status.detail || "MediaMTX status is unavailable.",
|
||||
process: status.process ? {
|
||||
state: status.process.state,
|
||||
running: Boolean(status.process.running),
|
||||
started_at: status.process.started_at || null,
|
||||
restart_count: Number(status.process.restart_count || 0),
|
||||
last_exit: status.process.last_exit || null,
|
||||
last_error: sanitizePublicRuntimeText(status.process.last_error),
|
||||
log_tail: sanitizePublicRuntimeText(status.process.log_tail, 8000)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePublicRuntimeText(value, maxLength = 2000) {
|
||||
if (!value) return null;
|
||||
return String(value)
|
||||
.replace(/(?:127\.0\.0\.1|\[?::1\]?):\d{1,5}/gi, "[loopback listener]")
|
||||
.replace(/([?&](?:user|pass)=)[^&\s]+/gi, "$1[redacted]")
|
||||
.slice(-maxLength);
|
||||
}
|
||||
|
||||
const streamTestingService = new StreamTestingService();
|
||||
|
||||
module.exports = { StreamTestingService, streamTestingService, ladderFor, executableStatus, ffmpegArgs, resolveFfmpegExecutable, DATA_ROOT };
|
||||
module.exports = {
|
||||
DATA_ROOT,
|
||||
INACTIVITY_MS,
|
||||
INGEST_PORT,
|
||||
MAX_SESSION_MS,
|
||||
PUBLIC_INGEST_PORT,
|
||||
StreamTestingService,
|
||||
isPrivateAddress,
|
||||
isPrivateHost,
|
||||
publicRuntimeStatus,
|
||||
resolveIngestConfiguration,
|
||||
rewriteManifest,
|
||||
sourceFor,
|
||||
streamTestingService,
|
||||
validateHostname
|
||||
};
|
||||
|
||||
@ -11,5 +11,6 @@
|
||||
.stream-test-timeline { display: grid; gap: var(--lumi-space-2); padding-left: 1.4rem; }
|
||||
.stream-test-timeline li::marker { color: var(--lumi-primary); }
|
||||
.stream-test-warning { margin-top: var(--lumi-space-3); }
|
||||
.stream-runtime-actions { margin-top: var(--lumi-space-3); }
|
||||
@media (max-width: 900px) { .stream-test-layout { grid-template-columns: 1fr; } }
|
||||
}
|
||||
|
||||
@ -13,9 +13,16 @@
|
||||
const quality = root.querySelector("[data-stream-quality]");
|
||||
const fullscreen = root.querySelector("[data-stream-fullscreen]");
|
||||
const stop = root.querySelector("[data-stream-stop]");
|
||||
const pattern = root.querySelector("[data-stream-pattern]");
|
||||
const runtimeState = root.querySelector("[data-runtime-state]");
|
||||
const runtimeDetail = root.querySelector("[data-runtime-detail]");
|
||||
const runtimeInstall = root.querySelector("[data-runtime-install]");
|
||||
const runtimeRepair = root.querySelector("[data-runtime-repair]");
|
||||
const runtimeHealth = root.querySelector("[data-runtime-health]");
|
||||
const runtimeResult = root.querySelector("[data-runtime-result]");
|
||||
let hls = null;
|
||||
let sessionId = null;
|
||||
let playerStalls = 0;
|
||||
let playerErrors = 0;
|
||||
|
||||
const text = (value) => String(value ?? "");
|
||||
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
||||
@ -31,19 +38,29 @@
|
||||
function attach(session) {
|
||||
if (!session || sessionId === session.id) return;
|
||||
sessionId = session.id;
|
||||
playerStalls = 0;
|
||||
playerErrors = 0;
|
||||
hls?.destroy?.();
|
||||
hls = null;
|
||||
player.removeAttribute("src");
|
||||
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
|
||||
captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}`;
|
||||
quality.replaceChildren(new Option(session.source?.variants?.[0]?.label || "Source output", "-1"));
|
||||
if (window.Hls?.isSupported?.()) {
|
||||
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: false, xhrSetup: (xhr) => { xhr.withCredentials = true; } });
|
||||
hls = new window.Hls({
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 2,
|
||||
liveMaxLatencyDurationCount: 5,
|
||||
maxLiveSyncPlaybackRate: 1.35,
|
||||
backBufferLength: 10,
|
||||
xhrSetup: (xhr) => { xhr.withCredentials = true; }
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(player);
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||
quality.replaceChildren(new Option("Automatic", "-1"), ...hls.levels.map((level, index) => new Option(`${level.height || "Source"}p · ${Math.round((level.bitrate || 0) / 1000)} kbps`, String(index))));
|
||||
quality.disabled = false;
|
||||
player.play().catch(() => {});
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => player.play().catch(() => {}));
|
||||
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||
if (data?.fatal) playerErrors += 1;
|
||||
if (String(data?.details || "").toLowerCase().includes("stall")) playerStalls += 1;
|
||||
});
|
||||
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
player.src = url;
|
||||
@ -58,7 +75,7 @@
|
||||
player.removeAttribute("src");
|
||||
captions.removeAttribute("src");
|
||||
player.load();
|
||||
quality.replaceChildren(new Option("Automatic", "-1"));
|
||||
quality.replaceChildren(new Option("Source output", "-1"));
|
||||
quality.disabled = true;
|
||||
}
|
||||
|
||||
@ -71,9 +88,18 @@
|
||||
empty.hidden = Boolean(session);
|
||||
fullscreen.disabled = !session;
|
||||
stop.disabled = !session;
|
||||
pattern.disabled = Boolean(session);
|
||||
const runtime = status.receiver || {};
|
||||
const process = runtime.process || {};
|
||||
runtimeState.textContent = process.running ? "Running" : runtime.available ? "Ready" : "Needs setup";
|
||||
runtimeState.className = `status-pill ${process.running || runtime.available ? "success" : "warning"}`;
|
||||
runtimeDetail.textContent = `${runtime.detail || "MediaMTX status is unavailable"} Tested by Lumi: ${runtime.tested_version || "unknown"}.`;
|
||||
runtimeInstall.hidden = Boolean(runtime.installed) || !runtime.managed_install_supported || runtime.source === "manual override";
|
||||
runtimeRepair.hidden = !runtime.installed || !runtime.managed_install_supported || runtime.source === "manual override";
|
||||
runtimeInstall.disabled = Boolean(session);
|
||||
runtimeRepair.disabled = Boolean(session);
|
||||
runtimeHealth.disabled = Boolean(session) || !runtime.available;
|
||||
summary.innerHTML = [
|
||||
metric("Receiver", status.receiver.available ? "Ready" : "Needs setup"),
|
||||
metric("Media router", runtime.available ? `${runtime.version || "Ready"} · ${runtime.source || "managed"}` : "Needs setup"),
|
||||
metric("Session", session ? session.state : "No active test"),
|
||||
metric("Streaming computer", session?.device || "Not connected"),
|
||||
metric("Expires", session ? new Date(session.expires_at).toLocaleTimeString() : "—")
|
||||
@ -81,18 +107,22 @@
|
||||
const obs = session?.metrics?.obs || {};
|
||||
const receiver = session?.metrics?.receiver || {};
|
||||
const captionMetrics = session?.metrics?.captions || {};
|
||||
const playerLatency = Number.isFinite(hls?.latency) ? `${hls.latency.toFixed(1)} s` : "Waiting";
|
||||
const playerMetrics = session?.metrics?.player || {};
|
||||
const playerLatency = Number.isFinite(hls?.latency)
|
||||
? `${hls.latency.toFixed(1)} s`
|
||||
: Number.isFinite(playerMetrics.latency_seconds) ? `${playerMetrics.latency_seconds.toFixed(1)} s` : "Waiting";
|
||||
metrics.innerHTML = [
|
||||
metric("OBS bitrate", obs.bitrate_kbps ? `${Math.round(obs.bitrate_kbps)} kbps` : "Waiting"),
|
||||
metric("Dropped frames", `${obs.dropped_frames || 0} / ${obs.total_frames || 0}`),
|
||||
metric("OBS congestion", obs.congestion != null ? `${Math.round(obs.congestion * 100)}%` : "Waiting"),
|
||||
metric("Receiver speed", receiver.speed || "Waiting"),
|
||||
metric("Receiver FPS", receiver.fps || "Waiting"),
|
||||
metric("Ingest", receiver.path_ready ? `${Math.round(receiver.inbound_bitrate_kbps || 0)} kbps · ${bytes(receiver.inbound_bytes)} received` : "Waiting"),
|
||||
metric("Media tracks", receiver.tracks?.length ? receiver.tracks.join(", ") : "Waiting"),
|
||||
metric("Preview viewers", receiver.hls_sessions ?? 0),
|
||||
metric("Player latency", playerLatency),
|
||||
metric("Caption delivery", captionMetrics.delivered ? `${captionMetrics.delivered} cues · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay` : "Waiting"),
|
||||
metric("Temporary media", bytes(session?.metrics?.bytes))
|
||||
metric("Playback stalls", playerMetrics.stalls ?? playerStalls),
|
||||
metric("Caption delivery", captionMetrics.delivered ? `${captionMetrics.delivered} cues · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay` : "Waiting")
|
||||
].join("");
|
||||
warnings.replaceChildren(...(session?.warnings || (!status.receiver.available ? [{ severity: "critical", message: status.receiver.detail }] : [])).map((warning) => {
|
||||
warnings.replaceChildren(...(session?.warnings || (!runtime.available ? [{ severity: "critical", message: runtime.detail }] : [])).map((warning) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = `callout ${warning.severity === "critical" ? "danger" : "warning"} stream-test-warning`;
|
||||
item.textContent = warning.message;
|
||||
@ -103,32 +133,94 @@
|
||||
item.textContent = `${new Date(event.at).toLocaleTimeString()} — ${event.message}`;
|
||||
return item;
|
||||
}));
|
||||
advanced.textContent = JSON.stringify({ receiver: status.receiver, source: session?.source, metrics: session?.metrics, ingest: session?.ingest }, null, 2);
|
||||
advanced.textContent = JSON.stringify({ runtime, source: session?.source, metrics: session?.metrics, ingest: session?.ingest }, null, 2);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const response = await fetch("/admin/stream-testing/status", { cache: "no-store" });
|
||||
if (response.ok) render(await response.json());
|
||||
}
|
||||
quality.addEventListener("change", () => { if (hls) hls.currentLevel = Number(quality.value); });
|
||||
|
||||
function showRuntimeResult(message, danger = false) {
|
||||
runtimeResult.hidden = false;
|
||||
runtimeResult.className = `callout ${danger ? "danger" : "success"}`;
|
||||
runtimeResult.textContent = message;
|
||||
}
|
||||
|
||||
async function runtimeAction(kind) {
|
||||
const installing = kind === "install" || kind === "repair";
|
||||
if (installing) {
|
||||
const confirmed = await (window.LumiConfirm?.destructive?.({
|
||||
title: kind === "repair" ? "Repair MediaMTX" : "Install MediaMTX",
|
||||
text: kind === "repair"
|
||||
? "Download Lumi's pinned MediaMTX package, verify its checksum, and atomically replace the managed runtime?"
|
||||
: "Download Lumi's pinned MediaMTX package from its official release, verify its checksum, and install it for private Stream Testing?",
|
||||
label: kind === "repair" ? "Repair installation" : "Install MediaMTX",
|
||||
danger: false
|
||||
}) ?? Promise.resolve(window.confirm("Download, verify, and install Lumi's pinned MediaMTX package?")));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
for (const button of [runtimeInstall, runtimeRepair, runtimeHealth]) button.disabled = true;
|
||||
runtimeResult.hidden = true;
|
||||
try {
|
||||
const response = await fetch(`/admin/stream-testing/runtime/${kind}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify({ confirmed: installing })
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "The MediaMTX operation failed.");
|
||||
showRuntimeResult(kind === "health"
|
||||
? `Health check passed. API ${payload.health.api_status}, metrics ${payload.health.metrics_status}, HLS listener ${payload.health.hls_status}.`
|
||||
: `MediaMTX ${payload.runtime.version || payload.runtime.tested_version || ""} is installed and verified.`.trim());
|
||||
} catch (error) {
|
||||
showRuntimeResult(error.message, true);
|
||||
} finally {
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
player.addEventListener("error", () => { playerErrors += 1; });
|
||||
fullscreen.addEventListener("click", () => player.requestFullscreen?.());
|
||||
stop.addEventListener("click", async () => {
|
||||
if (!sessionId) return;
|
||||
stop.disabled = true;
|
||||
await fetch("/admin/stream-testing/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId }) });
|
||||
await refresh();
|
||||
});
|
||||
pattern.addEventListener("click", async () => {
|
||||
pattern.disabled = true;
|
||||
const response = await fetch("/admin/stream-testing/pattern/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload.error || "The deterministic test pattern could not start.");
|
||||
}
|
||||
await fetch("/admin/stream-testing/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
});
|
||||
await refresh();
|
||||
});
|
||||
runtimeInstall.addEventListener("click", () => runtimeAction("install"));
|
||||
runtimeRepair.addEventListener("click", () => runtimeAction("repair"));
|
||||
runtimeHealth.addEventListener("click", () => runtimeAction("health"));
|
||||
|
||||
const metricTimer = window.setInterval(() => {
|
||||
if (!sessionId) return;
|
||||
let bufferSeconds = null;
|
||||
try {
|
||||
const range = player.buffered;
|
||||
if (range.length) bufferSeconds = Math.max(0, range.end(range.length - 1) - player.currentTime);
|
||||
} catch {}
|
||||
fetch("/admin/stream-testing/player-metrics", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
latency_seconds: Number.isFinite(hls?.latency) ? hls.latency : null,
|
||||
buffer_seconds: bufferSeconds,
|
||||
stalls: playerStalls,
|
||||
errors: playerErrors
|
||||
})
|
||||
}).catch(() => {});
|
||||
}, 5000);
|
||||
const events = new EventSource("/api/events");
|
||||
events.addEventListener("stream-test:changed", refresh);
|
||||
window.addEventListener("pagehide", () => { events.close(); hls?.destroy?.(); }, { once: true });
|
||||
window.addEventListener("pagehide", () => {
|
||||
events.close();
|
||||
window.clearInterval(metricTimer);
|
||||
hls?.destroy?.();
|
||||
}, { once: true });
|
||||
refresh();
|
||||
})();
|
||||
|
||||
@ -6214,9 +6214,36 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/stream-testing/pattern/start", requireRole("admin"), (req, res) => {
|
||||
app.post("/admin/stream-testing/runtime/install", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
res.status(201).json({ ok: true, session: streamTestingService.createPattern() });
|
||||
const runtime = await streamTestingService.installRuntime({ confirmed: req.body?.confirmed === true });
|
||||
res.json({ ok: true, runtime });
|
||||
} catch (error) {
|
||||
res.status(409).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/stream-testing/runtime/repair", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const runtime = await streamTestingService.repairRuntime({ confirmed: req.body?.confirmed === true });
|
||||
res.json({ ok: true, runtime });
|
||||
} catch (error) {
|
||||
res.status(409).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/stream-testing/runtime/health", requireRole("admin"), async (_req, res) => {
|
||||
try {
|
||||
const health = await streamTestingService.healthCheck();
|
||||
res.json({ ok: true, health });
|
||||
} catch (error) {
|
||||
res.status(409).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/stream-testing/player-metrics", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, metrics: streamTestingService.reportPlayer(req.body || {}) });
|
||||
} catch (error) {
|
||||
res.status(409).json({ ok: false, error: error.message });
|
||||
}
|
||||
@ -6228,12 +6255,8 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
res.set({ "Cache-Control": "no-store", "Content-Type": "text/vtt; charset=utf-8" }).send(content);
|
||||
});
|
||||
|
||||
app.get("/admin/stream-testing/media/:id/:name", requireRole("admin"), (req, res) => {
|
||||
const file = streamTestingService.mediaFile(req.params.id, req.params.name);
|
||||
if (!file) return res.status(404).end();
|
||||
res.set("Cache-Control", req.params.name.endsWith(".m3u8") ? "no-store" : "private, max-age=30");
|
||||
res.type(req.params.name.endsWith(".m3u8") ? "application/vnd.apple.mpegurl" : "video/mp2t");
|
||||
res.sendFile(file);
|
||||
app.get("/admin/stream-testing/media/:id/*", requireRole("admin"), async (req, res) => {
|
||||
await streamTestingService.proxyMedia(req.params.id, req.params[0], req, res);
|
||||
});
|
||||
|
||||
app.get("/admin/stream-testing/hls.js", requireRole("admin"), (_req, res) => {
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
title,
|
||||
pageWidth: "wide",
|
||||
pageId: "stream-testing",
|
||||
extraStyles: ["/stream-testing.css"],
|
||||
extraScripts: ["/admin/stream-testing/hls.js", "/stream-testing.js"]
|
||||
extraStyles: ["/stream-testing.css"]
|
||||
}) %>
|
||||
|
||||
<main data-stream-testing>
|
||||
@ -17,6 +16,20 @@
|
||||
<div class="stream-test-summary" data-stream-summary aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div><span class="eyebrow">Managed runtime</span><h2>MediaMTX</h2></div>
|
||||
<span class="status-pill neutral" data-runtime-state>Checking</span>
|
||||
</div>
|
||||
<p class="hint" data-runtime-detail>Lumi checks the private media router without downloading anything.</p>
|
||||
<div class="inline-actions stream-runtime-actions">
|
||||
<button class="button" type="button" data-runtime-install>Install MediaMTX</button>
|
||||
<button class="button subtle" type="button" data-runtime-repair>Repair installation</button>
|
||||
<button class="button subtle" type="button" data-runtime-health>Run health check</button>
|
||||
</div>
|
||||
<div class="callout" data-runtime-result hidden aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="stream-test-layout">
|
||||
<div class="card stream-test-player-card">
|
||||
<div class="section-heading"><div><span class="eyebrow">Private receiver</span><h2>Live output</h2></div><span class="status-pill neutral" data-stream-state>Idle</span></div>
|
||||
@ -25,9 +38,8 @@
|
||||
<div class="empty-state" data-stream-empty>Start Stream testing from Lumi Companion on the streaming computer.</div>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<label class="field compact"><span>Quality</span><select data-stream-quality disabled><option value="-1">Automatic</option></select></label>
|
||||
<label class="field compact"><span>Quality</span><select data-stream-quality disabled><option value="-1">Source output</option></select></label>
|
||||
<button class="button subtle" type="button" data-stream-fullscreen disabled>Fullscreen</button>
|
||||
<button class="button subtle" type="button" data-stream-pattern>Run test pattern</button>
|
||||
<button class="button danger" type="button" data-stream-stop disabled>End test</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -42,8 +54,10 @@
|
||||
<section class="card">
|
||||
<div class="section-heading"><div><span class="eyebrow">Recovery trail</span><h2>Timeline</h2></div></div>
|
||||
<ol class="stream-test-timeline" data-stream-timeline></ol>
|
||||
<details class="lumi-expandable-settings"><summary><span><strong>Advanced receiver details</strong><span class="hint">Encoder, ingest safety, and raw metric values</span></span></summary><pre class="log-details" data-stream-advanced></pre></details>
|
||||
<details class="lumi-expandable-settings"><summary><span><strong>Advanced receiver details</strong><span class="hint">MediaMTX health, ingest safety, and allowlisted metrics</span></span></summary><pre class="log-details" data-stream-advanced></pre></details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include("partials/layout-bottom") %>
|
||||
<%- include("partials/layout-bottom", {
|
||||
extraScripts: ["/admin/stream-testing/hls.js", "/stream-testing.js"]
|
||||
}) %>
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-24",
|
||||
"released_at": "2026-07-25",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"migration_notes": "Fixes the managed OBS Bridge streaming-service ownership crash and requires the corrected bridge before Stream Testing. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.",
|
||||
"migration_notes": "Replaces the FFmpeg Stream Testing receiver and transcoder with Lumi-managed checksum-pinned MediaMTX source remuxing, authenticated same-origin playback, and factual receiver diagnostics. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.",
|
||||
"rollback_safe": true,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer",
|
||||
"FFmpeg for the optional private Stream Testing receiver"
|
||||
"Node.js 18 or newer"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
@ -374,6 +373,18 @@
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Fixes Stream Testing startup feedback and Windows FFmpeg discovery, and adds explicit loaded OBS Bridge version checks with update actions while preserving local data."
|
||||
},
|
||||
{
|
||||
"version": "0.3.2",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-24",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Fixes the managed OBS Bridge streaming-service ownership crash and requires the corrected bridge before Stream Testing. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user