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_DEV_MODE=true
|
||||||
# LUMI_HOST=127.0.0.1
|
# LUMI_HOST=127.0.0.1
|
||||||
|
|
||||||
# Private OBS stream testing (optional; see docs/stream-testing.md)
|
# Private OBS stream testing (optional; see docs/stream-testing.md).
|
||||||
# LUMI_STREAM_TEST_INGEST_HOST=stream-test.example.com
|
# 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_INGEST_PORT=19350
|
||||||
# LUMI_STREAM_TEST_PUBLIC_PORT=443
|
# LUMI_STREAM_TEST_PUBLIC_PORT=19350
|
||||||
# LUMI_STREAM_TEST_RTMPS=true
|
# LUMI_STREAM_TEST_TRANSPORT=rtmps
|
||||||
# LUMI_FFMPEG_PATH=/absolute/path/to/ffmpeg
|
# 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
|
# 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
|
## 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.
|
- 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,
|
Lumi Companion provides the streaming-computer boundary for transcription,
|
||||||
media capture, and private stream testing. Its trust boundaries and setup are documented in
|
media capture, and private stream testing. Its trust boundaries and setup are documented in
|
||||||
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
|
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
|
||||||
Private OBS output testing, receiver setup, recovery behavior, and the
|
Private OBS output testing, Lumi-managed MediaMTX setup, recovery behavior, and
|
||||||
deterministic media pattern are documented in
|
network safety are documented in
|
||||||
[`docs/stream-testing.md`](docs/stream-testing.md).
|
[`docs/stream-testing.md`](docs/stream-testing.md).
|
||||||
|
|
||||||
## Updates and recovery
|
## Updates and recovery
|
||||||
|
|||||||
@ -52,6 +52,27 @@ Localhost development builds can also receive checksum-addressed same-version
|
|||||||
updates without publishing a release. See
|
updates without publishing a release. See
|
||||||
[Localhost development updates](../docs/local-development-updates.md).
|
[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
|
## Build and verify
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "0.2.2"
|
#define AppVersion "0.2.3"
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceRoot
|
#ifndef SourceRoot
|
||||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
LUMI COMPANION PRIVACY NOTICE
|
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.
|
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;
|
- the computer or device name, installation identifier, Companion version, protocol version, capabilities, and component status;
|
||||||
- OBS state and selected-source information;
|
- 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;
|
- 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
|
- readiness, test, benchmark, connection, and diagnostic information; and
|
||||||
- settings or control messages required for enabled Companion features.
|
- 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.
|
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.
|
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
|
4. NETWORK CONNECTIONS
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# Copyright (c) 2026 OokamiKunTV
|
# Copyright (c) 2026 OokamiKunTV
|
||||||
|
|
||||||
cmake_minimum_required(VERSION 3.28)
|
cmake_minimum_required(VERSION 3.28)
|
||||||
project(lumi-obs-bridge VERSION 0.2.1 LANGUAGES CXX)
|
project(lumi-obs-bridge VERSION 0.2.4 LANGUAGES CXX)
|
||||||
set(LUMI_BRIDGE_VERSION "0.2.1-development" CACHE STRING "Lumi Companion bridge release version")
|
set(LUMI_BRIDGE_VERSION "0.2.4-development" CACHE STRING "Lumi Companion bridge release version")
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <obs-module.h>
|
#include <obs-module.h>
|
||||||
#include <obs-frontend-api.h>
|
#include <obs-frontend-api.h>
|
||||||
|
#include <util/config-file.h>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include <bcrypt.h>
|
#include <bcrypt.h>
|
||||||
@ -69,6 +70,7 @@ static obs_source_t *captured_source = nullptr;
|
|||||||
static std::mutex captured_source_mutex;
|
static std::mutex captured_source_mutex;
|
||||||
static std::mutex stream_test_mutex;
|
static std::mutex stream_test_mutex;
|
||||||
static std::string stream_test_session;
|
static std::string stream_test_session;
|
||||||
|
static obs_output_t *stream_test_output = nullptr;
|
||||||
|
|
||||||
static std::string wide_to_utf8(const std::wstring &value)
|
static std::string wide_to_utf8(const std::wstring &value)
|
||||||
{
|
{
|
||||||
@ -347,6 +349,114 @@ struct service_command_context {
|
|||||||
std::string snapshot_settings;
|
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)
|
static void apply_stream_test_service(void *opaque)
|
||||||
{
|
{
|
||||||
auto &context = *static_cast<service_command_context *>(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.";
|
context.error = "OBS could not create the private test service.";
|
||||||
return;
|
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_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);
|
obs_service_release(test);
|
||||||
{
|
{
|
||||||
std::scoped_lock lock(stream_test_mutex);
|
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;
|
stream_test_session = context.session_id;
|
||||||
}
|
}
|
||||||
obs_frontend_streaming_start();
|
|
||||||
context.ok = true;
|
context.ok = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (obs_frontend_streaming_active()) obs_frontend_streaming_stop();
|
obs_output_t *output = nullptr;
|
||||||
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);
|
|
||||||
{
|
{
|
||||||
std::scoped_lock lock(stream_test_mutex);
|
std::scoped_lock lock(stream_test_mutex);
|
||||||
|
output = stream_test_output;
|
||||||
|
stream_test_output = nullptr;
|
||||||
stream_test_session.clear();
|
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()
|
static json stream_test_metrics_message()
|
||||||
{
|
{
|
||||||
std::string session_id;
|
std::string session_id;
|
||||||
|
obs_output_t *output = nullptr;
|
||||||
{
|
{
|
||||||
std::scoped_lock lock(stream_test_mutex);
|
std::scoped_lock lock(stream_test_mutex);
|
||||||
session_id = stream_test_session;
|
session_id = stream_test_session;
|
||||||
|
if (stream_test_output) output = obs_output_get_ref(stream_test_output);
|
||||||
}
|
}
|
||||||
if (session_id.empty()) return json();
|
if (session_id.empty()) return json();
|
||||||
obs_video_info video{};
|
obs_video_info video{};
|
||||||
obs_get_video_info(&video);
|
obs_get_video_info(&video);
|
||||||
json value{{"type", "stream_test_metrics"}, {"protocol_version", protocol_version}, {"session_id", session_id},
|
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}};
|
{"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) {
|
if (output) {
|
||||||
value["total_bytes"] = obs_output_get_total_bytes(output);
|
value["total_bytes"] = obs_output_get_total_bytes(output);
|
||||||
value["dropped_frames"] = obs_output_get_frames_dropped(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);
|
value["congestion"] = obs_output_get_congestion(output);
|
||||||
obs_encoder_t *encoder = obs_output_get_video_encoder(output);
|
obs_encoder_t *encoder = obs_output_get_video_encoder(output);
|
||||||
value["encoder"] = encoder && obs_encoder_get_id(encoder) ? obs_encoder_get_id(encoder) : "";
|
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);
|
obs_output_release(output);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
@ -446,9 +575,18 @@ static json stream_test_metrics_message()
|
|||||||
|
|
||||||
static bool output_caption(const std::string &text, double display_seconds)
|
static bool output_caption(const std::string &text, double display_seconds)
|
||||||
{
|
{
|
||||||
if (!obs_frontend_streaming_active() || text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
obs_output_t *output = obs_frontend_get_streaming_output();
|
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 (!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_output_caption_text2(output, text.c_str(), std::clamp(display_seconds, 0.5, 10.0));
|
||||||
obs_output_release(output);
|
obs_output_release(output);
|
||||||
return true;
|
return true;
|
||||||
@ -460,7 +598,6 @@ static std::optional<json> handle_command(const json &message)
|
|||||||
if (type == "select_sources") {
|
if (type == "select_sources") {
|
||||||
const auto uuid = message.value("primary_source_uuid", "");
|
const auto uuid = message.value("primary_source_uuid", "");
|
||||||
const auto installed = select_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",
|
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] %s selected OBS audio source %s",
|
||||||
installed ? "Attached" : "Could not attach", uuid.c_str());
|
installed ? "Attached" : "Could not attach", uuid.c_str());
|
||||||
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
|
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
|
||||||
@ -567,7 +704,9 @@ static void run_pipe_worker()
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
blog(LOG_INFO, "[Lumi Companion] connected to the same-user Companion IPC endpoint");
|
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);
|
source_list_dirty.store(true, std::memory_order_release);
|
||||||
obs_state_dirty.store(true, std::memory_order_release);
|
obs_state_dirty.store(true, std::memory_order_release);
|
||||||
auto health_sent = std::chrono::steady_clock::now();
|
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);
|
obs_frontend_remove_event_callback(frontend_event, nullptr);
|
||||||
detach_source();
|
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);
|
stopping.store(true, std::memory_order_release);
|
||||||
worker_signal.notify_all();
|
worker_signal.notify_all();
|
||||||
if (worker_thread.joinable()) worker_thread.join();
|
if (worker_thread.joinable()) worker_thread.join();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
param(
|
param(
|
||||||
[string]$ObsVersion = "31.1.1",
|
[string]$ObsVersion = "31.1.1",
|
||||||
[string]$BridgeVersion = "0.2.1",
|
[string]$BridgeVersion = "0.2.4",
|
||||||
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
|
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Version = "0.2.2",
|
[string]$Version = "0.2.3",
|
||||||
[string]$BridgeVersion = "0.2.1",
|
[string]$BridgeVersion = "0.2.4",
|
||||||
[string]$ObsVersion = "31.1.1"
|
[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 RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
private const string PathValidationContract = "voice-free-path-v1";
|
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 CompanionPaths _paths;
|
||||||
private readonly CompanionSettingsStore _settings;
|
private readonly CompanionSettingsStore _settings;
|
||||||
private readonly SecureCredentialStore _credentials;
|
private readonly SecureCredentialStore _credentials;
|
||||||
@ -43,6 +44,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private readonly SingleFlightOperation _updateChecks = new();
|
private readonly SingleFlightOperation _updateChecks = new();
|
||||||
private int _streamTestRestoreRunning;
|
private int _streamTestRestoreRunning;
|
||||||
|
private int _streamTestStopRequested;
|
||||||
|
private int _obsProcessId;
|
||||||
private int _obsOutputWidth = 1920;
|
private int _obsOutputWidth = 1920;
|
||||||
private int _obsOutputHeight = 1080;
|
private int _obsOutputHeight = 1080;
|
||||||
private double _obsOutputFps = 30;
|
private double _obsOutputFps = 30;
|
||||||
@ -96,6 +99,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
private async Task InitializeCoreAsync()
|
private async Task InitializeCoreAsync()
|
||||||
{
|
{
|
||||||
await _settings.LoadAsync();
|
await _settings.LoadAsync();
|
||||||
|
_obsProcessId = _settings.Current.LastObsProcessId;
|
||||||
|
if (_settings.Current.StreamTestObsRestartRequired)
|
||||||
|
SetState(State with
|
||||||
|
{
|
||||||
|
StreamTestObsRestartRequired = true,
|
||||||
|
Health = TrayHealth.Degraded,
|
||||||
|
StreamTestDetail = RestartObsAfterFailedStreamTestDetail
|
||||||
|
});
|
||||||
StartObsBridgeBoundary();
|
StartObsBridgeBoundary();
|
||||||
if (_streamTestRecovery.Exists)
|
if (_streamTestRecovery.Exists)
|
||||||
SetState(State with { StreamTestRecoveryRequired = true, StreamTestDetail = "A previous private test needs OBS restoration. Open OBS; Lumi Companion will repair it automatically." });
|
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 (State.StreamTestRunning) return;
|
||||||
if (_streamTestRecovery.Exists) throw new InvalidOperationException("Restore the previous OBS stream service before starting another test.");
|
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.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.ObsConnected || _obsBridge is null) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect.");
|
||||||
if (State.ObsBridgeUpdateAvailable)
|
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.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.");
|
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 });
|
SetState(State with { StreamTestDetail = "Requesting an expiring private receiver from Lumi…", Health = TrayHealth.Operating });
|
||||||
_streamTestSessionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
_streamTestSessionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
JsonElement created;
|
JsonElement created;
|
||||||
@ -346,11 +359,12 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
|
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.");
|
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
|
||||||
SetState(State with
|
SetState(State with
|
||||||
{
|
{
|
||||||
Health = TrayHealth.Degraded,
|
Health = TrayHealth.Degraded,
|
||||||
StreamTestDetail = $"The private test could not start. {Friendly(error)}"
|
StreamTestDetail = $"{RestartObsAfterFailedStreamTestDetail} {Friendly(error)}"
|
||||||
});
|
});
|
||||||
await WriteLogAsync("stream_test_start_failed", error.Message);
|
await WriteLogAsync("stream_test_start_failed", error.Message);
|
||||||
throw;
|
throw;
|
||||||
@ -359,6 +373,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task StopStreamTestAsync(string reason = "requested", CancellationToken cancellationToken = default)
|
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;
|
var sessionId = State.StreamTestSessionId ?? _streamTestRecovery.Load()?.SessionId;
|
||||||
SetState(State with { StreamTestDetail = "Ending the private receiver and restoring the exact OBS service…" });
|
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)
|
if (_socket is not null && State.Connected && sessionId is not null)
|
||||||
@ -380,7 +398,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
if (recovery is null)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (_obsBridge is null || !State.ObsConnected)
|
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." });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var reply = await _obsBridge.RequestAsync("stream_test_restore", new
|
var restorePayload = new
|
||||||
{
|
{
|
||||||
session_id = recovery.SessionId,
|
session_id = recovery.SessionId,
|
||||||
service_type = recovery.ServiceType,
|
service_type = recovery.ServiceType,
|
||||||
settings_json = recovery.ServiceSettingsJson
|
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")
|
if (ReadString(reply, "state") != "restored")
|
||||||
throw new InvalidOperationException(ReadString(reply, "error") ?? "OBS did not confirm restoration.");
|
throw new InvalidOperationException(ReadString(reply, "error") ?? "OBS did not confirm restoration.");
|
||||||
_streamTestRecovery.Remove();
|
_streamTestRecovery.Remove();
|
||||||
@ -406,8 +437,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
StreamTestDroppedFrames = 0,
|
StreamTestDroppedFrames = 0,
|
||||||
StreamTestTotalFrames = 0,
|
StreamTestTotalFrames = 0,
|
||||||
StreamTestCongestion = 0,
|
StreamTestCongestion = 0,
|
||||||
StreamTestDetail = detail,
|
StreamTestDetail = State.StreamTestObsRestartRequired ? RestartObsAfterFailedStreamTestDetail : detail,
|
||||||
Health = State.Connected ? TrayHealth.Ready : TrayHealth.Degraded
|
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.");
|
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")
|
if (message.Type == "stream_test_ended")
|
||||||
{
|
{
|
||||||
var reason = ReadString(message.Payload, "reason") ?? "The private receiver 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.");
|
_ = RestoreObsAfterStreamTestAsync($"{reason} OBS was restored to its previous streaming service.");
|
||||||
}
|
}
|
||||||
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
|
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")
|
if (type == "hello")
|
||||||
{
|
{
|
||||||
var loadedVersion = ReadString(message, "bridge_version");
|
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 });
|
var next = WithBridgeState(State with { ObsConnected = true, ObsBridgeLoadedVersion = loadedVersion });
|
||||||
SetState(next with
|
SetState(next with
|
||||||
{
|
{
|
||||||
Detail = next.ObsBridgeUpdateAvailable
|
Detail = next.ObsBridgeUpdateAvailable
|
||||||
? $"OBS Bridge {loadedVersion ?? "unknown"} is loaded, but Companion requires {next.ObsBridgeBundledVersion ?? "the bundled version"}. Close OBS and update the bridge."
|
? $"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
|
: State.Detail
|
||||||
});
|
});
|
||||||
RefreshPathReadiness();
|
RefreshPathReadiness();
|
||||||
@ -818,7 +859,20 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
var fpsNum = ReadInt(message, "fps_num");
|
var fpsNum = ReadInt(message, "fps_num");
|
||||||
if (fpsDen > 0 && fpsNum > 0) _obsOutputFps = (double)fpsNum / fpsDen;
|
if (fpsDen > 0 && fpsNum > 0) _obsOutputFps = (double)fpsNum / fpsDen;
|
||||||
var privateTest = State.StreamTestRunning || _streamTestRecovery.Exists;
|
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();
|
RefreshPathReadiness();
|
||||||
await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version"));
|
await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version"));
|
||||||
}
|
}
|
||||||
@ -892,12 +946,37 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
}, _socket.SessionId, _lifetimeToken());
|
}, _socket.SessionId, _lifetimeToken());
|
||||||
}
|
}
|
||||||
catch when (!State.Connected) { }
|
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");
|
_ = 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)
|
private Task OnObsAudioAsync(ReadOnlyMemory<byte> frame)
|
||||||
{
|
{
|
||||||
var level = MeasureDbfs(frame.Span);
|
var level = MeasureDbfs(frame.Span);
|
||||||
|
|||||||
@ -10,7 +10,9 @@ public sealed record CompanionSettings(
|
|||||||
string? PrimarySourceUuid = null,
|
string? PrimarySourceUuid = null,
|
||||||
string? PrimarySourceName = null,
|
string? PrimarySourceName = null,
|
||||||
DateTimeOffset? PathTestPassedAt = null,
|
DateTimeOffset? PathTestPassedAt = null,
|
||||||
string? PathTestFingerprint = null);
|
string? PathTestFingerprint = null,
|
||||||
|
int LastObsProcessId = 0,
|
||||||
|
bool StreamTestObsRestartRequired = false);
|
||||||
|
|
||||||
public sealed class CompanionSettingsStore
|
public sealed class CompanionSettingsStore
|
||||||
{
|
{
|
||||||
|
|||||||
@ -15,6 +15,7 @@ public sealed record CompanionState(
|
|||||||
bool BenchmarkRunning = false,
|
bool BenchmarkRunning = false,
|
||||||
bool StreamTestRunning = false,
|
bool StreamTestRunning = false,
|
||||||
bool StreamTestRecoveryRequired = false,
|
bool StreamTestRecoveryRequired = false,
|
||||||
|
bool StreamTestObsRestartRequired = false,
|
||||||
TrayHealth Health = TrayHealth.Degraded,
|
TrayHealth Health = TrayHealth.Degraded,
|
||||||
string Detail = "Pair Lumi Companion to get started.",
|
string Detail = "Pair Lumi Companion to get started.",
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||||
<Version>0.2.2</Version>
|
<Version>0.2.3</Version>
|
||||||
<AssemblyVersion>0.2.2.0</AssemblyVersion>
|
<AssemblyVersion>0.2.3.0</AssemblyVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||||
|
|||||||
@ -313,12 +313,18 @@ public partial class MainWindow : Window
|
|||||||
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
||||||
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
||||||
BenchmarkStatusText.Text = state.BenchmarkDetail;
|
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;
|
StreamTestStatusDetail.Text = state.StreamTestDetail;
|
||||||
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
|
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
|
||||||
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
|
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
|
||||||
StreamTestCongestionText.Text = state.StreamTestRunning ? $"{state.StreamTestCongestion:P0}" : "—";
|
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;
|
StopStreamTestButton.IsEnabled = state.StreamTestRunning;
|
||||||
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
|
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
|
||||||
UpdatePanel.IsVisible = state.UpdateAvailable;
|
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
|
Detailed benchmark records remain inspectable for one hour and include
|
||||||
word/phrase latency and confidence views.
|
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
|
## Verification
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@ -8,59 +8,92 @@ Lumi Companion, then watch it at **Admin > Stream testing**.
|
|||||||
|
|
||||||
- The existing paired-device WebSocket authenticates session creation, OBS
|
- The existing paired-device WebSocket authenticates session creation, OBS
|
||||||
metrics, caption cues, stop requests, and server-ended notifications.
|
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,
|
temporarily installs an expiring custom service, reports real output facts,
|
||||||
and restores the snapshot.
|
and restores the snapshot.
|
||||||
- Companion protects the minimum recovery record with Windows DPAPI and writes
|
- Companion protects the minimum recovery record with Windows DPAPI and writes
|
||||||
it atomically before OBS is redirected. A normal stop, server failure, expiry,
|
it atomically before OBS is redirected. Normal stop, server failure, expiry,
|
||||||
Companion exit, WebSocket loss, or OBS restart all use the same restore path.
|
Companion exit, WebSocket loss, and OBS restart all use that restore path.
|
||||||
- Core supervises one FFmpeg receiver process and creates a short rolling HLS
|
- Core lazily starts a Lumi-managed MediaMTX process. MediaMTX accepts the
|
||||||
window. HLS.js provides authenticated Chromium playback; native HLS remains
|
private RTMP(S) publisher and repackages the original source into low-latency
|
||||||
available where the browser supports it.
|
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.
|
- Stable transcription captions are reused as a private WebVTT side channel.
|
||||||
No second transcription pipeline and no local speech inference are added.
|
No second transcription pipeline and no local speech inference are added.
|
||||||
|
|
||||||
Temporary HLS fragments exist only to serve the active admin player. They are
|
Media segments stay in bounded MediaMTX memory and roll out of the short live
|
||||||
bounded, deleted as they roll out, and removed when the session ends. Lumi does
|
window. Lumi does not retain a raw stream recording or create temporary HLS
|
||||||
not retain a raw stream recording.
|
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
|
No operating-system package installation, FFmpeg installation, or executable
|
||||||
it. Set:
|
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
|
```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
|
```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_MAX_MS=1800000
|
||||||
LUMI_STREAM_TEST_INACTIVITY_MS=45000
|
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
|
## Safety and recovery
|
||||||
|
|
||||||
Companion refuses to start while OBS is streaming or recording. During a test
|
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
|
The **Repair restoration** action retries the same operation and does not create
|
||||||
a new session.
|
a new session.
|
||||||
|
|
||||||
The receiver stops on maximum duration, inactivity, temporary-storage limit,
|
The receiver stops on maximum duration, lack of inbound media, process failure,
|
||||||
process failure, server shutdown, Companion disconnect, or an administrator
|
server shutdown, Companion disconnect, or administrator stop. OBS status
|
||||||
stop. One active session is allowed today; UUID session identities and scoped
|
messages do not reset the media-inactivity clock. Only one session is allowed.
|
||||||
paths are already used throughout so the model can be expanded later.
|
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
|
The Admin page reports real OBS bitrate, dropped frames, congestion, MediaMTX
|
||||||
pattern with a stable 880 Hz audio tone. It exercises receiver startup,
|
path readiness, media tracks, inbound/outbound bytes, HLS viewers, discarded
|
||||||
transcoding, adaptive HLS, audio, quality switching, player cleanup, and
|
frames, player latency, buffer health, stalls, errors, and caption delay.
|
||||||
diagnostics without OBS. It uses the same limits and temporary-media cleanup as
|
Only an allowlist of MediaMTX API and metrics fields is exposed. Publisher
|
||||||
a Companion session.
|
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
|
```text
|
||||||
npm run verify:stream-testing
|
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.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.3.2
|
Version: 0.3.3
|
||||||
## Routes
|
## Routes
|
||||||
- POST /api/diagnostics/v1/run
|
- POST /api/diagnostics/v1/run
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
@ -96,9 +96,12 @@ Version: 0.3.2
|
|||||||
- GET /admin/stream-testing
|
- GET /admin/stream-testing
|
||||||
- GET /admin/stream-testing/status
|
- GET /admin/stream-testing/status
|
||||||
- POST /admin/stream-testing/stop
|
- 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/captions.vtt
|
||||||
- GET /admin/stream-testing/media/:id/:name
|
- GET /admin/stream-testing/media/:id/*
|
||||||
- GET /admin/stream-testing/hls.js
|
- GET /admin/stream-testing/hls.js
|
||||||
- POST /admin/diagnostics/run
|
- POST /admin/diagnostics/run
|
||||||
- POST /admin/diagnostics/access/renew
|
- POST /admin/diagnostics/access/renew
|
||||||
@ -942,15 +945,42 @@ Version: 0.3.2
|
|||||||
- Side effects: Action route; side effects were not detected statically.
|
- 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.
|
- 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.
|
- Purpose: Provides admin stream testing runtime install data as JSON.
|
||||||
- Inputs: No request parameters detected by static analysis.
|
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||||
- Response format: Form/action response; exact format was not detected statically.
|
- Response format: JSON response
|
||||||
- Access: admin access expected
|
- Access: admin access expected
|
||||||
- Side effects: writes or mutates server-side state
|
- 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.
|
- 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
|
### GET /admin/stream-testing/media/:id/captions.vtt
|
||||||
|
|
||||||
- Purpose: Handles 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.
|
- Side effects: Usually read-only.
|
||||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
- 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.
|
- Purpose: Handles admin stream testing media id *.
|
||||||
- Inputs: path params: `id`, `name`
|
- Inputs: path params: `id`
|
||||||
- Response format: static file response
|
- Response format: HTML or data response; exact format was not detected statically.
|
||||||
- Access: admin access expected
|
- Access: admin access expected
|
||||||
- Side effects: Usually read-only.
|
- Side effects: Usually read-only.
|
||||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
- 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.
|
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_transcription
|
Plugin ID: lumi_transcription
|
||||||
Version: 0.2.2
|
Version: 0.2.3
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/lumi_transcription
|
- /plugins/lumi_transcription
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# Lumi Transcription changelog
|
# 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
|
## 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.
|
- 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": {
|
case "stream_test_create": {
|
||||||
const service = global.lumiFrameworks?.streamTesting;
|
const service = global.lumiFrameworks?.streamTesting;
|
||||||
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
|
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);
|
send("stream_test_session", created);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,90 +1 @@
|
|||||||
const crypto = require("crypto");
|
module.exports = require("../../../../src/services/artifact-manager");
|
||||||
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 };
|
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"signed": false,
|
"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": {
|
"installer": {
|
||||||
"id": "windows-x64-installer",
|
"id": "windows-x64-installer",
|
||||||
"platform": "win32",
|
"platform": "win32",
|
||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 per-user installer",
|
"label": "Windows x64 per-user installer",
|
||||||
"filename": "Lumi.Companion-Setup.exe",
|
"filename": "Lumi.Companion-Setup.exe",
|
||||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.2/Lumi.Companion-Setup.exe",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.3/Lumi.Companion-Setup.exe",
|
||||||
"sha256": "67a1dc04e3bd75649ba4191142a88ead406acf4338ec07e564e502215e82f636",
|
"sha256": "fddb7f25b866de0645b6e20875145170c95666000db18ed75dfc1f6af41dbd72",
|
||||||
"bytes": 51864724
|
"bytes": 51870710
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@ -20,9 +20,9 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 self-contained",
|
"label": "Windows x64 self-contained",
|
||||||
"filename": "Lumi.Companion-win-x64.zip",
|
"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",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.3/Lumi.Companion-win-x64.zip",
|
||||||
"sha256": "5cd1f6e0d8a507db8e25b2d1db9608d4c5fcf20a9140d04bcb7150fb83a08a2f",
|
"sha256": "ff7d5b2c2d2928c4db4cb8c4e33702859a0739312294c32c1d95f16d50ee185a",
|
||||||
"bytes": 66773904,
|
"bytes": 66774831,
|
||||||
"entrypoint": "Lumi.Companion.App.exe"
|
"entrypoint": "Lumi.Companion.App.exe"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_transcription",
|
"id": "lumi_transcription",
|
||||||
"name": "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.",
|
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -2,6 +2,38 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"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",
|
"version": "0.3.2",
|
||||||
"ref": "refs/tags/v0.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 { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.3.2";
|
const releaseVersion = "0.3.3";
|
||||||
const previousStableVersion = "0.3.1";
|
const previousStableVersion = "0.3.2";
|
||||||
const priorStableVersion = "0.3.0";
|
const priorStableVersion = "0.3.1";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const introducedPlugins = {
|
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" }
|
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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
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 assert = require("assert");
|
||||||
|
const crypto = require("crypto");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { EventEmitter } = require("events");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const AdmZip = require("adm-zip");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
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");
|
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
|
||||||
|
|
||||||
async function main() {
|
class FakeRuntime extends EventEmitter {
|
||||||
assert.equal(typeof resolveFfmpegExecutable(), "string");
|
constructor() {
|
||||||
const fullHd = ladderFor({ width: 1920, height: 1080, fps: 60 });
|
super();
|
||||||
assert.deepStrictEqual(fullHd.variants.map((item) => item.name), ["source", "720p", "480p"]);
|
this.session = null;
|
||||||
assert(fullHd.variants.every((item) => item.width <= 1920 && item.height <= 1080), "adaptive ladder must never upscale");
|
this.running = false;
|
||||||
const hd = ladderFor({ width: 1280, height: 720, fps: 30 });
|
this.cleared = 0;
|
||||||
assert.deepStrictEqual(hd.variants.map((item) => item.name), ["source", "480p"]);
|
this.pathReady = true;
|
||||||
const sd = ladderFor({ width: 854, height: 480, fps: 30 });
|
this.inboundBytes = 4096;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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");
|
async function verifyArtifactManager(tempRoot) {
|
||||||
const service = fs.readFileSync(path.join(root, "src/services/stream-testing.js"), "utf8");
|
assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/);
|
||||||
const gateway = fs.readFileSync(path.join(root, "plugins/lumi_transcription/backend/companion/gateway.js"), "utf8");
|
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/);
|
||||||
const nativeBridge = fs.readFileSync(path.join(root, "companion/native/obs-bridge/src/plugin.cpp"), "utf8");
|
const zip = new AdmZip();
|
||||||
const companion = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionRuntime.cs"), "utf8");
|
zip.addFile("LICENSE", Buffer.from("MIT"));
|
||||||
const companionUi = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
|
zip.addFile("mediamtx.exe", Buffer.from("fake executable"));
|
||||||
const companionView = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8");
|
zip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
|
||||||
const bridgeManager = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/ObsBridgeManager.cs"), "utf8");
|
const archive = zip.toBuffer();
|
||||||
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
|
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"\)/);
|
async function verifyManagedRuntime(tempRoot) {
|
||||||
assert.match(server, /admin\/stream-testing\/media\/:id\/:name", requireRole\("admin"\)/);
|
const runtimeRoot = path.join(tempRoot, "runtime");
|
||||||
assert.match(service, /MAX_SESSION_MS/);
|
const executable = path.join(runtimeRoot, "mediamtx", "mediamtx.exe");
|
||||||
assert.match(service, /INACTIVITY_MS/);
|
fs.mkdirSync(path.dirname(executable), { recursive: true });
|
||||||
assert.match(service, /MAX_OUTPUT_BYTES/);
|
fs.writeFileSync(executable, "fake");
|
||||||
assert.match(service, /redactDiagnostic/);
|
const fixture = path.join(root, "scripts", "fixtures", "fake-mediamtx.js");
|
||||||
assert.match(service, /caption_delay/);
|
const runtime = new MediaMtxRuntime({
|
||||||
assert.match(service, /receiver_slow/);
|
runtimeRoot,
|
||||||
assert.match(service, /PUBLIC_INGEST_PORT/);
|
configRoot: path.join(tempRoot, "config"),
|
||||||
assert.match(service, /Microsoft", "WinGet", "Packages/);
|
platform: "win32",
|
||||||
assert.strictEqual(/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test("master.m3u8"), true);
|
arch: "x64",
|
||||||
assert.match(service, /spawn\(FFMPEG, args, \{[^}]*stdio:/s);
|
spawn: (_executable, args, options) => spawn(process.execPath, [fixture, args[0]], options),
|
||||||
assert.match(service, /testsrc2=size=1280x720:rate=30/);
|
spawnSync: () => ({ status: 0, stdout: "v1.18.2\n", stderr: "" })
|
||||||
assert.match(service, /sine=frequency=880:sample_rate=48000/);
|
});
|
||||||
assert(!service.includes("shell: true"), "receiver commands must not use a shell");
|
await assert.rejects(() => runtime.install(), /administrator confirmation/);
|
||||||
assert.match(gateway, /stream_test_create/);
|
await assert.rejects(() => runtime.repair(), /administrator confirmation/);
|
||||||
assert.match(gateway, /Companion disconnected; the private receiver was stopped/);
|
assert.equal(runtime.probeExecutable(executable).version, "v1.18.2", "manual executable probes must remain bounded and versioned");
|
||||||
assert.match(nativeBridge, /stream_test_snapshot/);
|
const health = await runtime.healthCheck();
|
||||||
assert.match(nativeBridge, /obs_frontend_save_streaming_service/);
|
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");
|
||||||
assert.match(nativeBridge, /stream_test_restore/);
|
const session = {
|
||||||
assert(!nativeBridge.includes("obs_service_release(existing)"), "OBS frontend service getters return borrowed references that must not be released");
|
path: "lumi-test/00000000-0000-4000-8000-000000000001",
|
||||||
assert.match(nativeBridge, /obs_frontend_set_streaming_service\(test\)[\s\S]*obs_service_release\(test\)/);
|
username: "private-user",
|
||||||
assert.match(nativeBridge, /obs_frontend_set_streaming_service\(restored\)[\s\S]*obs_service_release\(restored\)/);
|
password: "private-password",
|
||||||
assert.match(companion, /StreamTestRecoveryStore/);
|
transport: "rtmp",
|
||||||
assert.match(companion, /StartStreamTestAsync/);
|
ingestPort: 29350
|
||||||
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
|
};
|
||||||
assert.match(companion, /finally\s*\{\s*Interlocked\.Exchange\(ref _streamTestRestoreRunning, 0\)/s);
|
await runtime.configureSession(session);
|
||||||
assert.match(companion, /StreamTestDetail = streamTestPending/);
|
const generated = fs.readFileSync(runtime.configPath, "utf8");
|
||||||
assert.match(companion, /stream_test_start_failed/);
|
assert(!generated.includes(session.username) && !generated.includes(session.password), "generated MediaMTX config must not persist plaintext publisher credentials");
|
||||||
assert.match(companionUi, /CheckUpdateButton\.IsEnabled = !state\.UpdateCheckRunning/);
|
assert(generated.includes(sha256Credential(session.username)) && generated.includes(sha256Credential(session.password)));
|
||||||
assert.match(companionUi, /CheckUpdateButton\.Content = state\.UpdateCheckRunning \? "Checking…" : "Check now"/);
|
assert.match(generated, /rtsp: false/);
|
||||||
assert.match(companionUi, /ObsBridgeUpdateAvailable/);
|
assert.match(generated, /webrtc: false/);
|
||||||
assert.match(companionUi, /Update OBS Bridge/);
|
assert.match(generated, /srt: false/);
|
||||||
assert.match(companionView, /OverviewBridgeUpdatePanel/);
|
assert.match(generated, /hlsVariant: lowLatency/);
|
||||||
assert.match(companionView, /ConnectionBridgeUpdatePanel/);
|
assert.match(generated, /maxReaders: 4/);
|
||||||
assert.match(bridgeManager, /InstalledVersion/);
|
assert.match(generated, /rtmpAddress: "127\.0\.0\.1:29350"/);
|
||||||
assert.match(bridgeManager, /UpdateAvailable/);
|
const diagnostic = await runtime.diagnostics(session.path);
|
||||||
assert.match(webUi, /data-stream-player/);
|
assert.equal(diagnostic.path.name, session.path);
|
||||||
assert.match(webUi, /data-stream-quality/);
|
const playlist = await runtime.fetchHls(session.path, "index.m3u8");
|
||||||
assert.match(webUi, /data-stream-captions/);
|
assert.equal(playlist.status, 200);
|
||||||
assert.match(webUi, /data-stream-timeline/);
|
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();
|
async function verifyStreamService() {
|
||||||
console.log("Stream testing and Companion lifecycle verification passed.");
|
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) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
|||||||
const coreManifest = readJson("update-manifest.json");
|
const coreManifest = readJson("update-manifest.json");
|
||||||
assert.equal(packageVersion, coreManifest.version);
|
assert.equal(packageVersion, coreManifest.version);
|
||||||
assert.equal(coreManifest.channel, "stable");
|
assert.equal(coreManifest.channel, "stable");
|
||||||
assert.equal(packageVersion, "0.3.2");
|
assert.equal(packageVersion, "0.3.3");
|
||||||
assert.equal(currentRelease.version, "0.3.2");
|
assert.equal(currentRelease.version, "0.3.3");
|
||||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
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 = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
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.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.1", ref: "refs/tags/v0.3.1", rollback_safe: true },
|
||||||
{ version: "0.3.0", ref: "refs/tags/v0.3.0", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ const { registerCorePlaceholders } = require("./services/placeholders");
|
|||||||
const { overlayConnectorManager } = require("./services/overlay-connectors");
|
const { overlayConnectorManager } = require("./services/overlay-connectors");
|
||||||
const { cleanupSnapshots } = require("./services/update-manager");
|
const { cleanupSnapshots } = require("./services/update-manager");
|
||||||
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
||||||
|
const { streamTestingService } = require("./services/stream-testing");
|
||||||
const {
|
const {
|
||||||
isSafeModeRequested,
|
isSafeModeRequested,
|
||||||
markStartupVerification
|
markStartupVerification
|
||||||
@ -181,6 +182,7 @@ async function main() {
|
|||||||
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
|
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
|
||||||
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
||||||
for (const stop of [
|
for (const stop of [
|
||||||
|
() => streamTestingService.close(),
|
||||||
() => overlayConnectorManager.stop(),
|
() => overlayConnectorManager.stop(),
|
||||||
() => twitchEventSubManager.stop(),
|
() => twitchEventSubManager.stop(),
|
||||||
() => stopPlugins(),
|
() => 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 crypto = require("crypto");
|
||||||
|
const dns = require("dns");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const net = require("net");
|
||||||
const path = require("path");
|
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 { publishWebEvent } = require("./web-events");
|
||||||
|
|
||||||
const DATA_ROOT = path.join(
|
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 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 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 = port(process.env.LUMI_STREAM_TEST_INGEST_PORT, 19350);
|
||||||
const INGEST_PORT = Math.min(65535, Math.max(1024, Number(process.env.LUMI_STREAM_TEST_INGEST_PORT) || 19350));
|
const PUBLIC_INGEST_PORT = port(process.env.LUMI_STREAM_TEST_PUBLIC_PORT, INGEST_PORT);
|
||||||
const PUBLIC_INGEST_PORT = Math.min(65535, Math.max(1, Number(process.env.LUMI_STREAM_TEST_PUBLIC_PORT) || INGEST_PORT));
|
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
function finite(value, fallback, min, max) {
|
function finite(value, fallback, min, max) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redactUrl(value) {
|
function sourceFor(source = {}) {
|
||||||
try {
|
const width = Math.round(finite(source.width, 1920, 160, 7680));
|
||||||
const parsed = new URL(value);
|
const height = Math.round(finite(source.height, 1080, 90, 4320));
|
||||||
parsed.pathname = "/lumi-test/[session]";
|
const fps = finite(source.fps, 30, 1, 120);
|
||||||
parsed.search = "";
|
return {
|
||||||
return parsed.toString();
|
width,
|
||||||
} catch {
|
height,
|
||||||
return "configured ingest endpoint";
|
fps,
|
||||||
}
|
variants: [{
|
||||||
}
|
name: "source",
|
||||||
|
label: `Source (${width}×${height} at ${formatFps(fps)} fps)`,
|
||||||
function redactDiagnostic(value, session) {
|
width,
|
||||||
let result = String(value || "");
|
height,
|
||||||
if (session?.listenerUrl) result = result.split(session.listenerUrl).join("rtmp://[private-stream-test]");
|
fps
|
||||||
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 refreshWarnings(session) {
|
function refreshWarnings(session) {
|
||||||
@ -80,300 +46,162 @@ function refreshWarnings(session) {
|
|||||||
...session.baseWarnings,
|
...session.baseWarnings,
|
||||||
...(session.obsWarnings || []),
|
...(session.obsWarnings || []),
|
||||||
...(session.receiverWarnings || []),
|
...(session.receiverWarnings || []),
|
||||||
...(session.captionWarnings || [])
|
...(session.captionWarnings || []),
|
||||||
];
|
...(session.playerWarnings || [])
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
class StreamTestingService {
|
class StreamTestingService {
|
||||||
constructor() {
|
constructor(options = {}) {
|
||||||
|
this.runtime = options.runtime || new MediaMtxRuntime();
|
||||||
this.active = null;
|
this.active = null;
|
||||||
this.timeline = [];
|
this.timeline = [];
|
||||||
this.lastCreateAt = new Map();
|
this.lastCreateAt = new Map();
|
||||||
this.timer = setInterval(() => this.sweep(), 2000);
|
this.sweepRunning = false;
|
||||||
this.timer.unref?.();
|
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 = () => {
|
this.exitHandler = () => {
|
||||||
for (const child of [this.active?.patternProcess, this.active?.process]) {
|
|
||||||
try {
|
|
||||||
if (child && child.exitCode === null) child.kill("SIGKILL");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
try {
|
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 {}
|
} catch {}
|
||||||
};
|
};
|
||||||
process.once("exit", this.exitHandler);
|
process.once("exit", this.exitHandler);
|
||||||
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
|
||||||
this.cleanupStale();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
receiverStatus() {
|
receiverStatus(options) {
|
||||||
return executableStatus();
|
return this.runtime.status(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
publicStatus() {
|
publicStatus() {
|
||||||
const session = this.active;
|
const session = this.active;
|
||||||
const receiver = session?.receiver || executableStatus();
|
const receiver = publicRuntimeStatus(this.runtime.status());
|
||||||
return {
|
return {
|
||||||
available: receiver.available,
|
available: receiver.available,
|
||||||
receiver: { available: receiver.available, encoder: receiver.encoder || null, hardware: Boolean(receiver.hardware), detail: receiver.detail },
|
receiver,
|
||||||
active: Boolean(session),
|
active: Boolean(session),
|
||||||
session: session ? {
|
session: session ? {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
state: session.state,
|
state: session.state,
|
||||||
started_at: session.startedAt,
|
started_at: session.startedAt,
|
||||||
expires_at: session.expiresAt,
|
expires_at: session.expiresAt,
|
||||||
last_activity_at: session.lastActivityAt,
|
last_activity_at: session.lastMediaAt,
|
||||||
device: session.deviceName,
|
device: session.deviceName,
|
||||||
source: session.source,
|
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`,
|
captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
|
||||||
metrics: session.metrics,
|
metrics: session.metrics,
|
||||||
warnings: session.warnings,
|
warnings: session.warnings,
|
||||||
ingest: redactUrl(session.listenerUrl)
|
ingest: `${session.transport}://${session.ingestHost}:${session.publicPort}/lumi-test/[session]`
|
||||||
} : null,
|
} : null,
|
||||||
timeline: this.timeline.slice(-100)
|
timeline: this.timeline.slice(-100)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
create(device, input = {}, send = null) {
|
async create(device, input = {}, send = null) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.active) {
|
if (this.active) throw coded("STREAM_TEST_ACTIVE", "A stream test is already active. End it before starting another.");
|
||||||
const error = new Error("A stream test is already active. End it before starting another.");
|
|
||||||
error.code = "STREAM_TEST_ACTIVE";
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (now - (this.lastCreateAt.get(device.id) || 0) < 5000) {
|
if (now - (this.lastCreateAt.get(device.id) || 0) < 5000) {
|
||||||
const error = new Error("Wait a few seconds before creating another stream test.");
|
throw coded("STREAM_TEST_RATE_LIMIT", "Wait a few seconds before creating another stream test.");
|
||||||
error.code = "STREAM_TEST_RATE_LIMIT";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
const receiver = executableStatus({ refresh: true });
|
const receiver = this.runtime.status({ refresh: true });
|
||||||
if (!receiver.available) {
|
if (!receiver.available) {
|
||||||
const error = new Error(receiver.detail);
|
throw coded(
|
||||||
error.code = "STREAM_TEST_RECEIVER_UNAVAILABLE";
|
"STREAM_TEST_RECEIVER_UNAVAILABLE",
|
||||||
throw error;
|
"The Lumi private media router needs setup. Ask an administrator to open Admin > Stream testing and choose Install MediaMTX."
|
||||||
}
|
);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
const network = await resolveIngestConfiguration(device);
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const secret = crypto.randomBytes(32).toString("base64url");
|
const streamPath = `lumi-test/${id}`;
|
||||||
const directory = path.join(DATA_ROOT, id);
|
const username = `lumi_${crypto.randomBytes(18).toString("base64url")}`;
|
||||||
fs.mkdirSync(directory, { recursive: true });
|
const password = crypto.randomBytes(32).toString("base64url");
|
||||||
const configuredHost = String(process.env.LUMI_STREAM_TEST_INGEST_HOST || (input.pattern === true ? "127.0.0.1" : "")).trim();
|
const source = sourceFor(input.source || {});
|
||||||
if (!configuredHost) {
|
const baseWarnings = network.transport === "rtmp"
|
||||||
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.");
|
severity: "warning",
|
||||||
error.code = "STREAM_TEST_INGEST_UNCONFIGURED";
|
code: "unencrypted_ingest",
|
||||||
throw error;
|
message: "This private test uses unencrypted RTMP only because the paired streaming computer is on a loopback or private network."
|
||||||
}
|
}]
|
||||||
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 session = {
|
const session = {
|
||||||
id,
|
id,
|
||||||
|
path: streamPath,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
transport: network.transport,
|
||||||
|
ingestHost: network.host,
|
||||||
|
ingestPort: network.ingestPort,
|
||||||
|
publicPort: network.publicPort,
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
|
deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
|
||||||
state: "starting",
|
state: "starting",
|
||||||
source,
|
source,
|
||||||
receiver,
|
|
||||||
directory,
|
|
||||||
listenerUrl,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
expiresAt: now + MAX_SESSION_MS,
|
expiresAt: now + MAX_SESSION_MS,
|
||||||
lastActivityAt: now,
|
lastMediaAt: now,
|
||||||
metrics: { obs: {}, receiver: {}, bytes: 0 },
|
lastInboundBytes: 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." }] : [],
|
lastMetricAt: now,
|
||||||
|
metrics: {
|
||||||
|
obs: {},
|
||||||
|
receiver: {},
|
||||||
|
player: { latency_seconds: null, buffer_seconds: null, stalls: 0, errors: 0 },
|
||||||
|
captions: {}
|
||||||
|
},
|
||||||
|
baseWarnings,
|
||||||
obsWarnings: [],
|
obsWarnings: [],
|
||||||
receiverWarnings: [],
|
receiverWarnings: [],
|
||||||
captionWarnings: [],
|
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: [],
|
captions: [],
|
||||||
process: null,
|
|
||||||
patternProcess: null,
|
|
||||||
pattern: input.pattern === true,
|
|
||||||
send: typeof send === "function" ? send : null,
|
send: typeof send === "function" ? send : null,
|
||||||
stopping: false
|
stopping: false
|
||||||
};
|
};
|
||||||
this.active = session;
|
this.active = session;
|
||||||
this.lastCreateAt.set(device.id, now);
|
this.lastCreateAt.set(device.id, now);
|
||||||
this.event("created", "Test session created.", session);
|
this.event("created", "Private Stream Test session created.", session);
|
||||||
this.startReceiver(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 {
|
return {
|
||||||
id,
|
id,
|
||||||
state: session.state,
|
state: session.state,
|
||||||
expires_at: session.expiresAt,
|
expires_at: session.expiresAt,
|
||||||
ingest: {
|
ingest: {
|
||||||
server: `${protocol}://${configuredHost}:${PUBLIC_INGEST_PORT}/lumi-test`,
|
server: `${network.transport}://${network.host}:${network.publicPort}/lumi-test`,
|
||||||
key,
|
key: `${id}?user=${encodeURIComponent(username)}&pass=${encodeURIComponent(password)}`,
|
||||||
encrypted: protocol === "rtmps"
|
encrypted: network.transport === "rtmps"
|
||||||
},
|
},
|
||||||
source,
|
source,
|
||||||
warning: session.warnings[0]?.message || null
|
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 = {}) {
|
updateObs(deviceId, input = {}) {
|
||||||
const session = this.requireDeviceSession(deviceId, input.session_id);
|
const session = this.requireDeviceSession(deviceId, input.session_id);
|
||||||
session.lastActivityAt = Date.now();
|
|
||||||
session.metrics.obs = {
|
session.metrics.obs = {
|
||||||
bitrate_kbps: finite(input.bitrate_kbps, 0, 0, 100000),
|
bitrate_kbps: finite(input.bitrate_kbps, 0, 0, 100000),
|
||||||
dropped_frames: Math.round(finite(input.dropped_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
|
dropped_frames: Math.round(finite(input.dropped_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
|
||||||
@ -400,6 +228,30 @@ class StreamTestingService {
|
|||||||
return this.publicStatus().session;
|
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 = {}) {
|
addCaption(deviceId, input = {}) {
|
||||||
const session = this.requireDeviceSession(deviceId, input.session_id);
|
const session = this.requireDeviceSession(deviceId, input.session_id);
|
||||||
const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
|
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.` }]
|
? [{ severity: "warning", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
|
||||||
: [];
|
: [];
|
||||||
refreshWarnings(session);
|
refreshWarnings(session);
|
||||||
session.lastActivityAt = Date.now();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
captionFile(id) {
|
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")}`;
|
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;
|
const session = this.active?.id === id ? this.active : null;
|
||||||
if (!session || !/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test(name)) return null;
|
if (!session) return res.status(404).end();
|
||||||
const resolved = path.join(session.directory, name);
|
const controller = new AbortController();
|
||||||
return fs.existsSync(resolved) && fs.statSync(resolved).isFile() ? resolved : null;
|
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) {
|
requireDeviceSession(deviceId, id) {
|
||||||
const session = this.active;
|
const session = this.active;
|
||||||
if (!session || session.id !== id || session.deviceId !== deviceId) {
|
if (!session || session.id !== id || session.deviceId !== deviceId) {
|
||||||
const error = new Error("The stream test session is not active for this Companion.");
|
throw coded("STREAM_TEST_NOT_ACTIVE", "The stream test session is not active for this Companion.");
|
||||||
error.code = "STREAM_TEST_NOT_ACTIVE";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return session;
|
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 = {}) {
|
async stop(input = {}) {
|
||||||
const session = this.active;
|
const session = this.active;
|
||||||
if (!session || (input.session_id && session.id !== input.session_id)) return { stopped: false };
|
if (!session || (input.session_id && session.id !== input.session_id)) return { stopped: false };
|
||||||
@ -454,53 +365,126 @@ class StreamTestingService {
|
|||||||
session.stopping = true;
|
session.stopping = true;
|
||||||
session.state = input.failed ? "failed" : "stopping";
|
session.state = input.failed ? "failed" : "stopping";
|
||||||
this.event(input.failed ? "failed" : "stopping", input.reason || (input.failed ? "The test failed." : "The test is ending."), session);
|
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;
|
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 {
|
try {
|
||||||
session.send?.("stream_test_ended", {
|
session.send?.("stream_test_ended", {
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
reason: input.reason || "The private stream test ended.",
|
reason: input.reason || "The private stream test ended.",
|
||||||
failed: Boolean(input.failed)
|
failed: Boolean(input.failed || runtimeError)
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
this.event(input.failed ? "failed" : "ended", input.reason || "Test session ended and temporary media was removed.", session);
|
session.username = null;
|
||||||
try { await fs.promises.rm(session.directory, { recursive: true, force: true }); } catch {}
|
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();
|
this.notify();
|
||||||
return { stopped: true, id: session.id };
|
return { stopped: true, id: session.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(session, detail) {
|
fail(session, detail) {
|
||||||
if (this.active?.id !== session.id || session.stopping) return;
|
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);
|
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;
|
const session = this.active;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
session.metrics.bytes = directoryBytes(session.directory);
|
this.sweepRunning = true;
|
||||||
const receiverSpeed = Number.parseFloat(String(session.metrics.receiver.speed || "").replace(/x$/, ""));
|
try {
|
||||||
session.receiverWarnings = Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.75
|
let diagnostic;
|
||||||
? [{ severity: "critical", code: "receiver_slow", message: `Receiver transcoding is only ${receiverSpeed.toFixed(2)}× real time.` }]
|
try {
|
||||||
: Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.95
|
diagnostic = await this.runtime.diagnostics(session.path);
|
||||||
? [{ severity: "warning", code: "receiver_slow", message: `Receiver transcoding is ${receiverSpeed.toFixed(2)}× real time.` }]
|
} catch (error) {
|
||||||
: [];
|
session.receiverWarnings = [{ severity: "warning", code: "receiver_diagnostics", message: `Media diagnostics are temporarily unavailable: ${error.message}` }];
|
||||||
refreshWarnings(session);
|
refreshWarnings(session);
|
||||||
const now = Date.now();
|
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) {
|
||||||
if (now >= session.expiresAt) return void this.stop({ session_id: session.id, reason: "The maximum test duration expired." });
|
await 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." });
|
return;
|
||||||
this.notify();
|
}
|
||||||
|
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) {
|
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" });
|
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 })) {
|
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 });
|
fs.rmSync(path.join(DATA_ROOT, entry.name), { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
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.stop({ reason: "Lumi is shutting down; OBS recovery was requested." });
|
||||||
|
await this.runtime.close();
|
||||||
process.removeListener("exit", this.exitHandler);
|
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();
|
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 { display: grid; gap: var(--lumi-space-2); padding-left: 1.4rem; }
|
||||||
.stream-test-timeline li::marker { color: var(--lumi-primary); }
|
.stream-test-timeline li::marker { color: var(--lumi-primary); }
|
||||||
.stream-test-warning { margin-top: var(--lumi-space-3); }
|
.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; } }
|
@media (max-width: 900px) { .stream-test-layout { grid-template-columns: 1fr; } }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,16 @@
|
|||||||
const quality = root.querySelector("[data-stream-quality]");
|
const quality = root.querySelector("[data-stream-quality]");
|
||||||
const fullscreen = root.querySelector("[data-stream-fullscreen]");
|
const fullscreen = root.querySelector("[data-stream-fullscreen]");
|
||||||
const stop = root.querySelector("[data-stream-stop]");
|
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 hls = null;
|
||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
|
let playerStalls = 0;
|
||||||
|
let playerErrors = 0;
|
||||||
|
|
||||||
const text = (value) => String(value ?? "");
|
const text = (value) => String(value ?? "");
|
||||||
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
||||||
@ -31,19 +38,29 @@
|
|||||||
function attach(session) {
|
function attach(session) {
|
||||||
if (!session || sessionId === session.id) return;
|
if (!session || sessionId === session.id) return;
|
||||||
sessionId = session.id;
|
sessionId = session.id;
|
||||||
|
playerStalls = 0;
|
||||||
|
playerErrors = 0;
|
||||||
hls?.destroy?.();
|
hls?.destroy?.();
|
||||||
hls = null;
|
hls = null;
|
||||||
player.removeAttribute("src");
|
player.removeAttribute("src");
|
||||||
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
|
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
|
||||||
captions.src = `${session.captions_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?.()) {
|
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.loadSource(url);
|
||||||
hls.attachMedia(player);
|
hls.attachMedia(player);
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => player.play().catch(() => {}));
|
||||||
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))));
|
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||||
quality.disabled = false;
|
if (data?.fatal) playerErrors += 1;
|
||||||
player.play().catch(() => {});
|
if (String(data?.details || "").toLowerCase().includes("stall")) playerStalls += 1;
|
||||||
});
|
});
|
||||||
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
player.src = url;
|
player.src = url;
|
||||||
@ -58,7 +75,7 @@
|
|||||||
player.removeAttribute("src");
|
player.removeAttribute("src");
|
||||||
captions.removeAttribute("src");
|
captions.removeAttribute("src");
|
||||||
player.load();
|
player.load();
|
||||||
quality.replaceChildren(new Option("Automatic", "-1"));
|
quality.replaceChildren(new Option("Source output", "-1"));
|
||||||
quality.disabled = true;
|
quality.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,9 +88,18 @@
|
|||||||
empty.hidden = Boolean(session);
|
empty.hidden = Boolean(session);
|
||||||
fullscreen.disabled = !session;
|
fullscreen.disabled = !session;
|
||||||
stop.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 = [
|
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("Session", session ? session.state : "No active test"),
|
||||||
metric("Streaming computer", session?.device || "Not connected"),
|
metric("Streaming computer", session?.device || "Not connected"),
|
||||||
metric("Expires", session ? new Date(session.expires_at).toLocaleTimeString() : "—")
|
metric("Expires", session ? new Date(session.expires_at).toLocaleTimeString() : "—")
|
||||||
@ -81,18 +107,22 @@
|
|||||||
const obs = session?.metrics?.obs || {};
|
const obs = session?.metrics?.obs || {};
|
||||||
const receiver = session?.metrics?.receiver || {};
|
const receiver = session?.metrics?.receiver || {};
|
||||||
const captionMetrics = session?.metrics?.captions || {};
|
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 = [
|
metrics.innerHTML = [
|
||||||
metric("OBS bitrate", obs.bitrate_kbps ? `${Math.round(obs.bitrate_kbps)} kbps` : "Waiting"),
|
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("Dropped frames", `${obs.dropped_frames || 0} / ${obs.total_frames || 0}`),
|
||||||
metric("OBS congestion", obs.congestion != null ? `${Math.round(obs.congestion * 100)}%` : "Waiting"),
|
metric("OBS congestion", obs.congestion != null ? `${Math.round(obs.congestion * 100)}%` : "Waiting"),
|
||||||
metric("Receiver speed", receiver.speed || "Waiting"),
|
metric("Ingest", receiver.path_ready ? `${Math.round(receiver.inbound_bitrate_kbps || 0)} kbps · ${bytes(receiver.inbound_bytes)} received` : "Waiting"),
|
||||||
metric("Receiver FPS", receiver.fps || "Waiting"),
|
metric("Media tracks", receiver.tracks?.length ? receiver.tracks.join(", ") : "Waiting"),
|
||||||
|
metric("Preview viewers", receiver.hls_sessions ?? 0),
|
||||||
metric("Player latency", playerLatency),
|
metric("Player latency", playerLatency),
|
||||||
metric("Caption delivery", captionMetrics.delivered ? `${captionMetrics.delivered} cues · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay` : "Waiting"),
|
metric("Playback stalls", playerMetrics.stalls ?? playerStalls),
|
||||||
metric("Temporary media", bytes(session?.metrics?.bytes))
|
metric("Caption delivery", captionMetrics.delivered ? `${captionMetrics.delivered} cues · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay` : "Waiting")
|
||||||
].join("");
|
].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");
|
const item = document.createElement("div");
|
||||||
item.className = `callout ${warning.severity === "critical" ? "danger" : "warning"} stream-test-warning`;
|
item.className = `callout ${warning.severity === "critical" ? "danger" : "warning"} stream-test-warning`;
|
||||||
item.textContent = warning.message;
|
item.textContent = warning.message;
|
||||||
@ -103,32 +133,94 @@
|
|||||||
item.textContent = `${new Date(event.at).toLocaleTimeString()} — ${event.message}`;
|
item.textContent = `${new Date(event.at).toLocaleTimeString()} — ${event.message}`;
|
||||||
return item;
|
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() {
|
async function refresh() {
|
||||||
const response = await fetch("/admin/stream-testing/status", { cache: "no-store" });
|
const response = await fetch("/admin/stream-testing/status", { cache: "no-store" });
|
||||||
if (response.ok) render(await response.json());
|
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?.());
|
fullscreen.addEventListener("click", () => player.requestFullscreen?.());
|
||||||
stop.addEventListener("click", async () => {
|
stop.addEventListener("click", async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
stop.disabled = true;
|
stop.disabled = true;
|
||||||
await fetch("/admin/stream-testing/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId }) });
|
await fetch("/admin/stream-testing/stop", {
|
||||||
await refresh();
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
pattern.addEventListener("click", async () => {
|
body: JSON.stringify({ session_id: sessionId })
|
||||||
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 refresh();
|
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");
|
const events = new EventSource("/api/events");
|
||||||
events.addEventListener("stream-test:changed", refresh);
|
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();
|
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 {
|
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) {
|
} catch (error) {
|
||||||
res.status(409).json({ ok: false, error: error.message });
|
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);
|
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) => {
|
app.get("/admin/stream-testing/media/:id/*", requireRole("admin"), async (req, res) => {
|
||||||
const file = streamTestingService.mediaFile(req.params.id, req.params.name);
|
await streamTestingService.proxyMedia(req.params.id, req.params[0], req, res);
|
||||||
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/hls.js", requireRole("admin"), (_req, res) => {
|
app.get("/admin/stream-testing/hls.js", requireRole("admin"), (_req, res) => {
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
title,
|
title,
|
||||||
pageWidth: "wide",
|
pageWidth: "wide",
|
||||||
pageId: "stream-testing",
|
pageId: "stream-testing",
|
||||||
extraStyles: ["/stream-testing.css"],
|
extraStyles: ["/stream-testing.css"]
|
||||||
extraScripts: ["/admin/stream-testing/hls.js", "/stream-testing.js"]
|
|
||||||
}) %>
|
}) %>
|
||||||
|
|
||||||
<main data-stream-testing>
|
<main data-stream-testing>
|
||||||
@ -17,6 +16,20 @@
|
|||||||
<div class="stream-test-summary" data-stream-summary aria-live="polite"></div>
|
<div class="stream-test-summary" data-stream-summary aria-live="polite"></div>
|
||||||
</section>
|
</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">
|
<section class="stream-test-layout">
|
||||||
<div class="card stream-test-player-card">
|
<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>
|
<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 class="empty-state" data-stream-empty>Start Stream testing from Lumi Companion on the streaming computer.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-actions">
|
<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-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>
|
<button class="button danger" type="button" data-stream-stop disabled>End test</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -42,8 +54,10 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-heading"><div><span class="eyebrow">Recovery trail</span><h2>Timeline</h2></div></div>
|
<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>
|
<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>
|
</section>
|
||||||
</main>
|
</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",
|
"name": "Lumi Core",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-24",
|
"released_at": "2026-07-25",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
"migration_kind": "patch",
|
"migration_kind": "patch",
|
||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer",
|
"Node.js 18 or newer"
|
||||||
"FFmpeg for the optional private Stream Testing receiver"
|
|
||||||
],
|
],
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
@ -374,6 +373,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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