feat: complete companion streaming and logging upgrades

This commit is contained in:
Franz Rolfsvaag 2026-07-26 17:22:10 +02:00
parent 2368c01eb0
commit 2d8dc86b20
50 changed files with 2449 additions and 388 deletions

View File

@ -55,9 +55,11 @@ LUMI_OPERATOR_PRIVACY_URL=
# LUMI_STREAM_TEST_INGEST_HOST=lumi.example.com
# LUMI_STREAM_TEST_INGEST_PORT=19350
# LUMI_STREAM_TEST_PUBLIC_PORT=19350
# LUMI_STREAM_TEST_TRANSPORT=rtmps
# Production always uses RTMPS and Lumi manages its certificate automatically.
# Advanced certificate override only; set both or neither.
# 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 ACME directory override only.
# LUMI_STREAM_TEST_ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org/directory
# Advanced/manual runtime override only; normal installations do not need this.
# LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx

View File

@ -48,10 +48,25 @@ recording. Applying an update replaces the installed executable, bundled
components, and legal bundle, then restarts Companion. Pairing credentials,
settings, and plugin state remain in the per-user data directory.
If the paired Lumi host is temporarily unavailable, Companion remains paired and
reconnects quietly in the background with bounded backoff. The Overview and
Connection pages show the offline state without repeated dialogs; **Retry now**
remains available for an immediate manual attempt.
Localhost development builds can also receive checksum-addressed same-version
updates without publishing a release. See
[Localhost development updates](../docs/local-development-updates.md).
## Transcription control
The Transcription page has a persistent **Generate and include captions** switch,
also available from the tray menu. Turning it off stops the active transcription
session, excludes captions from live and private-test output, and stops sending
microphone audio to Lumi for speech recognition. Private Stream Testing remains
available as a video-only test. Turning the switch back on restores automatic
caption startup with OBS and can start captions while a private test is already
running.
## Private Stream Testing
The core **Stream Testing** page asks the paired Lumi host for an expiring
@ -61,6 +76,9 @@ 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.
While a private test is active, Companion exposes **Open stream viewer**, which
opens the paired Lumi host directly at **Admin > Stream testing**.
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

View File

@ -598,7 +598,7 @@ static std::optional<json> handle_command(const json &message)
if (type == "select_sources") {
const auto uuid = message.value("primary_source_uuid", "");
const auto installed = select_source(uuid);
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] %s selected OBS audio source %s",
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] event=audio_source_selection_changed %s selected OBS audio source %s",
installed ? "Attached" : "Could not attach", uuid.c_str());
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
} else if (type == "caption" && message.contains("payload")) {
@ -682,7 +682,7 @@ static bool read_available_command(HANDLE pipe)
const auto response = handle_command(json::parse(body.begin(), body.end()));
if (response && !write_json(pipe, *response)) return false;
}
catch (const std::exception &error) { blog(LOG_WARNING, "[Lumi Companion] Ignored invalid IPC command: %s", error.what()); }
catch (const std::exception &error) { blog(LOG_WARNING, "[Lumi Companion] event=ipc_command_rejected Ignored invalid IPC command: %s", error.what()); }
return true;
}
@ -703,7 +703,7 @@ static void run_pipe_worker()
worker_signal.wait_for(lock, std::chrono::seconds(2));
continue;
}
blog(LOG_INFO, "[Lumi Companion] connected to the same-user Companion IPC endpoint");
blog(LOG_INFO, "[Lumi Companion] event=ipc_connected 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},
{"obs_process_id", static_cast<uint32_t>(GetCurrentProcessId())}});
@ -736,7 +736,7 @@ static void run_pipe_worker()
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
}
CloseHandle(pipe);
blog(LOG_INFO, "[Lumi Companion] disconnected from Companion IPC; retrying safely");
blog(LOG_INFO, "[Lumi Companion] event=ipc_disconnected disconnected from Companion IPC; retrying safely");
}
}
@ -772,13 +772,13 @@ bool obs_module_load(void)
const char *version = obs_get_version_string();
const int major = version ? std::atoi(version) : 0;
if (major < 31) {
blog(LOG_ERROR, "[Lumi Companion] OBS %s is unsupported; version 31 or newer is required", version ? version : "unknown");
blog(LOG_ERROR, "[Lumi Companion] event=obs_version_unsupported OBS %s is unsupported; version 31 or newer is required", version ? version : "unknown");
return false;
}
stopping.store(false, std::memory_order_release);
obs_frontend_add_event_callback(frontend_event, nullptr);
worker_thread = std::thread(run_pipe_worker);
blog(LOG_INFO, "[Lumi Companion] bridge %s loaded", bridge_version);
blog(LOG_INFO, "[Lumi Companion] event=bridge_loaded bridge %s loaded", bridge_version);
return true;
}
@ -798,5 +798,5 @@ void obs_module_unload(void)
stopping.store(true, std::memory_order_release);
worker_signal.notify_all();
if (worker_thread.joinable()) worker_thread.join();
blog(LOG_INFO, "[Lumi Companion] bridge unloaded");
blog(LOG_INFO, "[Lumi Companion] event=bridge_unloaded bridge unloaded");
}

View File

@ -13,7 +13,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private const int MaxCoverOutputBytes = 500 * 1024;
private readonly Func<SongOverlaySettings> _settings;
private readonly Func<MediaTrack, CancellationToken, Task<MediaTrack>> _enrich;
private readonly Action<string, Exception?> _log;
private readonly Action<string, string, Exception?> _log;
private GlobalSystemMediaTransportControlsSessionManager? _manager;
private GlobalSystemMediaTransportControlsSession? _session;
private MediaSnapshot? _last;
@ -21,7 +21,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private bool _started;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public SpotifyWindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, Exception?> log)
public SpotifyWindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
{
_settings = settings;
_enrich = enrich;
@ -62,7 +62,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
{
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Could not refresh Spotify media sessions", error); }
catch (Exception error) { _log("spotify_session_refresh_failed", "Could not refresh Spotify media sessions", error); }
}
private async Task SelectSessionAsync(CancellationToken cancellationToken)
@ -105,19 +105,19 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
{
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Spotify media-property update failed", error); }
catch (Exception error) { _log("spotify_media_property_update_failed", "Spotify media-property update failed", error); }
}
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
{
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Spotify playback update failed", error); }
catch (Exception error) { _log("spotify_playback_update_failed", "Spotify playback update failed", error); }
}
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
{
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Spotify timeline update failed", error); }
catch (Exception error) { _log("spotify_timeline_update_failed", "Spotify timeline update failed", error); }
}
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
@ -206,7 +206,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
finally { _refreshLock.Release(); }
await RefreshAndPublishAsync("metadata", false, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception error) { _log("Spotify metadata enrichment update failed", error); }
catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); }
}
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session)

View File

@ -1,6 +1,7 @@
using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay.Providers;
using Lumi.Companion.SongOverlay.Spotify;
using System.Text.Json;
namespace Lumi.Companion.SongOverlay;
@ -192,7 +193,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
}
}
catch (OperationCanceledException) { }
catch (Exception error) { Log("Song Overlay heartbeat failed", error); }
catch (Exception error) { Log("heartbeat_failed", "Song Overlay heartbeat failed", error); }
}, token);
}
@ -274,7 +275,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
catch (OperationCanceledException) { }
catch (Exception error)
{
Log("Could not process a Song Overlay event", error);
Log("event_processing_failed", "Could not process a Song Overlay event", error);
SetStatus(CompanionPluginHealth.Warning, "Delivery delayed", "Playback was detected, but Lumi could not be updated: " + error.Message);
}
finally { _eventLock.Release(); }
@ -358,17 +359,49 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
}
private void RaiseChanged() => Changed?.Invoke();
private void Log(string message, Exception? error = null)
private void Log(string eventId, string message, Exception? error = null)
{
try
{
Directory.CreateDirectory(_logDirectory);
File.AppendAllText(Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyyMMdd}.log"),
$"{DateTimeOffset.Now:O}\t{message}{(error is null ? "" : "\t" + error)}{Environment.NewLine}");
PruneLogs();
var normalizedEvent = CompanionLogSanitizer.NormalizeEvent(eventId);
var line = JsonSerializer.Serialize(new
{
timestamp = DateTimeOffset.UtcNow,
level = CompanionLogSanitizer.LevelForEvent(normalizedEvent),
source = $"plugin:{PluginId}",
category = "plugin",
@event = normalizedEvent,
message = CompanionLogSanitizer.Sanitize(message),
error = error is null ? null : CompanionLogSanitizer.Sanitize(error.ToString())
});
File.AppendAllText(
Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyy-MM-dd}.jsonl"),
line + Environment.NewLine
);
}
catch { }
}
private void PruneLogs()
{
var files = new DirectoryInfo(_logDirectory)
.GetFiles("song-overlay-*.*")
.Where(file => file.Extension is ".jsonl" or ".log")
.OrderBy(file => file.CreationTimeUtc)
.ToList();
foreach (var file in files.Where(file => file.CreationTimeUtc < DateTime.UtcNow.AddDays(-7))) file.Delete();
const long cap = 32L * 1024 * 1024;
var total = files.Where(file => file.Exists).Sum(file => file.Length);
foreach (var file in files.Where(file => file.Exists))
{
if (total <= cap) break;
total -= file.Length;
file.Delete();
}
}
private static string Describe(MediaSnapshot snapshot) => snapshot.Track is null
? "Connected; no active song."
: $"{snapshot.Status}: {snapshot.Track.Title} — {snapshot.Track.Artist}";

View File

@ -16,12 +16,12 @@ internal sealed class SpotifyWebApiEnricher : IDisposable
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly SongOverlaySecretProtector _secrets;
private readonly Action _save;
private readonly Action<string, Exception?> _log;
private readonly Action<string, string, Exception?> _log;
private readonly SongOverlaySettings _settings;
private string _accessToken = "";
private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue;
public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action<string, Exception?> log)
public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action<string, string, Exception?> log)
{
_settings = settings;
_secrets = secrets;
@ -130,7 +130,7 @@ internal sealed class SpotifyWebApiEnricher : IDisposable
}
catch (Exception error)
{
_log("Spotify metadata enrichment failed", error);
_log("spotify_metadata_enrichment_failed", "Spotify metadata enrichment failed", error);
return null;
}
}

View File

@ -0,0 +1,53 @@
using System.Text.RegularExpressions;
namespace Lumi.Companion.Abstractions;
public static partial class CompanionLogSanitizer
{
public static string Sanitize(string? value, int maxLength = 4000)
{
var safeMaxLength = Math.Clamp(maxLength, 64, 65536);
var text = value ?? "";
text = AuthorizationPattern().Replace(text, "$1 [redacted]");
text = CookiePattern().Replace(text, "$1[redacted]");
text = QuerySecretPattern().Replace(text, "$1[redacted]");
text = AssignedSecretPattern().Replace(text, "$1[redacted]");
return text.Length <= safeMaxLength ? text : text[..safeMaxLength] + "\n[truncated]";
}
public static string NormalizeEvent(string? value)
{
var normalized = InvalidEventCharacters().Replace((value ?? "").Trim().ToLowerInvariant(), "_");
normalized = RepeatedUnderscores().Replace(normalized, "_").Trim('_');
return string.IsNullOrWhiteSpace(normalized) ? "unknown_event" : normalized[..Math.Min(80, normalized.Length)];
}
public static string LevelForEvent(string eventId)
{
var normalized = NormalizeEvent(eventId);
if (normalized.EndsWith("_failed", StringComparison.Ordinal) ||
normalized.EndsWith("_error", StringComparison.Ordinal))
{
return "error";
}
return normalized is "disconnected" or "audio_dropped" ? "warn" : "info";
}
[GeneratedRegex(@"\b(Bearer|Basic|LumiDevice)\s+[A-Za-z0-9._~+/=-]+", RegexOptions.IgnoreCase)]
private static partial Regex AuthorizationPattern();
[GeneratedRegex(@"\b((?:set-)?cookie\s*:\s*)[^\r\n]+", RegexOptions.IgnoreCase)]
private static partial Regex CookiePattern();
[GeneratedRegex(@"([?&](?:token|key|secret|password|authorization)=)[^&#\s]+", RegexOptions.IgnoreCase)]
private static partial Regex QuerySecretPattern();
[GeneratedRegex(@"\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|signature|authorization)\s*[:=]\s*)[^\s,;}]+", RegexOptions.IgnoreCase)]
private static partial Regex AssignedSecretPattern();
[GeneratedRegex(@"[^a-z0-9]+")]
private static partial Regex InvalidEventCharacters();
[GeneratedRegex(@"_+")]
private static partial Regex RepeatedUnderscores();
}

View File

@ -73,10 +73,6 @@
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Padding" Value="16,9" />
</Style>
<Style Selector="Expander.pluginRoot">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,1" />
</Style>
<Style Selector="Button.nav">
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Background" Value="Transparent" />
@ -85,6 +81,9 @@
<Setter Property="Padding" Value="13,10" />
<Setter Property="Margin" Value="0,2" />
</Style>
<Style Selector="Button.navRoot">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.nav.selected">
<Setter Property="Background" Value="#DDEEF0" />
<Setter Property="Foreground" Value="{DynamicResource LumiPrimaryBrush}" />

View File

@ -17,6 +17,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string PathValidationContract = "voice-free-path-v1";
private const string RestartObsAfterFailedStreamTestDetail = "OBS could not establish the private output. The normal destination was restored. Restart OBS before trying again.";
private static readonly TimeSpan UpdateCheckTimeout = TimeSpan.FromMinutes(11);
private readonly CompanionPaths _paths;
private readonly CompanionSettingsStore _settings;
private readonly SecureCredentialStore _credentials;
@ -43,8 +44,11 @@ public sealed class CompanionRuntime : IAsyncDisposable
private long _lastVoiceMeterAt;
private bool _disposed;
private readonly SingleFlightOperation _updateChecks = new();
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private int _reconnectLoopRunning;
private int _streamTestRestoreRunning;
private int _streamTestStopRequested;
private bool _streamTestTranscriptionRunning;
private int _obsProcessId;
private int _obsOutputWidth = 1920;
private int _obsOutputHeight = 1080;
@ -82,6 +86,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
public event Action<string, bool>? CaptionReceived;
public event Action<string>? LogAdded;
public event Func<Task>? UpdateRestartRequested;
public bool TranscriptionEnabled => _settings.Current.TranscriptionEnabled;
public ICompanionPluginTransport CreatePluginTransport(string pluginId) =>
new CompanionPluginTransport(_http, () => _credentials.Load(), pluginId);
@ -172,54 +177,126 @@ public sealed class CompanionRuntime : IAsyncDisposable
await ConnectAsync(credential, cancellationToken);
}
private async Task ConnectAsync(DeviceCredential credential, CancellationToken cancellationToken = default)
private async Task ConnectAsync(DeviceCredential credential, CancellationToken cancellationToken = default, bool background = false)
{
if (_socket is not null) await _socket.DisposeAsync();
_serverReady = false;
_serverReadinessFingerprint = null;
_serverReadinessDetail = "Waiting for Lumi to report speech recognition readiness.";
_socket = new CompanionSocket();
_socket.MessageReceived += OnServerMessageAsync;
_socket.Disconnected += error =>
{
if (_disposed) return;
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
_benchmarkLifetime?.Cancel();
if (State.StreamTestRunning || _streamTestRecovery.Exists) _ = RestoreObsAfterStreamTestAsync("The Lumi connection closed; OBS recovery started.");
SetState(State with { Connected = false, BenchmarkRunning = false, Health = TrayHealth.Degraded, Detail = "The secure Lumi connection closed. Retry when the host is available.", BenchmarkDetail = State.BenchmarkRunning ? "The benchmark was aborted because the Lumi connection closed." : State.BenchmarkDetail });
_ = WriteLogAsync("disconnected", error?.Message ?? "Connection closed.");
};
SetState(State with { Connected = false, Detail = "Connecting securely to Lumi…" });
await _connectionGate.WaitAsync(cancellationToken);
try
{
await _socket.ConnectAsync(credential, Version, "0.1.0", null, cancellationToken);
var bridgeInstalled = State.ObsBridgeInstalled || DetectBridgeInstallation();
SetState(State with
var previous = _socket;
_socket = null;
if (previous is not null) await previous.DisposeAsync();
_serverReady = false;
_serverReadinessFingerprint = null;
_serverReadinessDetail = "Waiting for Lumi to report speech recognition readiness.";
var socket = new CompanionSocket();
_socket = socket;
socket.MessageReceived += OnServerMessageAsync;
socket.Disconnected += error =>
{
Paired = true,
Connected = true,
ObsBridgeInstalled = bridgeInstalled,
Health = bridgeInstalled ? TrayHealth.Ready : TrayHealth.Degraded,
Detail = bridgeInstalled ? "Lumi is connected. Waiting for OBS." : "Lumi is connected. Install the managed OBS integration to continue setup.",
Host = credential.Host,
LastConnectedAt = DateTimeOffset.Now
});
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
await SendRuntimeStateAsync(cancellationToken);
await _socket.SendAsync("readiness", new { }, _socket.SessionId, cancellationToken);
await WriteLogAsync("connected", $"Connected to {credential.Host}.");
await CheckForUpdatesAsync(cancellationToken);
if (_disposed || !ReferenceEquals(_socket, socket)) return;
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
_benchmarkLifetime?.Cancel();
_streamTestTranscriptionRunning = false;
if (State.StreamTestRunning || _streamTestRecovery.Exists) _ = RestoreObsAfterStreamTestAsync("The Lumi connection closed; OBS recovery started.");
SetState(State with
{
Connected = false,
BenchmarkRunning = false,
Health = TrayHealth.Degraded,
Detail = "Lumi is temporarily unavailable. Companion will reconnect automatically.",
BenchmarkDetail = State.BenchmarkRunning ? "The benchmark was aborted because the Lumi connection closed." : State.BenchmarkDetail
});
_ = WriteLogAsync("disconnected", error?.Message ?? "Connection closed.");
ScheduleReconnect();
};
if (!background) SetState(State with { Connected = false, Detail = "Connecting to Lumi…" });
try
{
using var connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectTimeout.CancelAfter(TimeSpan.FromSeconds(15));
await socket.ConnectAsync(credential, Version, "0.1.0", null, connectTimeout.Token);
var bridgeInstalled = State.ObsBridgeInstalled || DetectBridgeInstallation();
SetState(State with
{
Paired = true,
Connected = true,
ObsBridgeInstalled = bridgeInstalled,
Health = bridgeInstalled ? TrayHealth.Ready : TrayHealth.Degraded,
Detail = bridgeInstalled ? "Lumi is connected. Waiting for OBS." : "Lumi is connected. Install the managed OBS integration to continue setup.",
Host = credential.Host,
LastConnectedAt = DateTimeOffset.Now
});
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
await SendRuntimeStateAsync(cancellationToken);
await socket.SendAsync("readiness", new { }, socket.SessionId, cancellationToken);
await WriteLogAsync("connected", $"Connected to {credential.Host}.");
_ = CheckForUpdatesAsync(_maintenanceLifetime.Token);
}
catch (OperationCanceledException) when (_disposed || _maintenanceLifetime.IsCancellationRequested)
{
return;
}
catch (Exception error)
{
if (!background) SetState(State with
{
Paired = true,
Connected = false,
Health = TrayHealth.Degraded,
Detail = "Lumi is unavailable. Companion will keep trying in the background."
});
await WriteLogAsync("connection_failed", error.Message);
}
}
finally
{
_connectionGate.Release();
}
if (!State.Connected) ScheduleReconnect();
}
private void ScheduleReconnect()
{
if (_disposed || State.Connected || _maintenanceLifetime.IsCancellationRequested) return;
try { if (_credentials.Load() is null) return; }
catch { return; }
if (Interlocked.CompareExchange(ref _reconnectLoopRunning, 1, 0) != 0) return;
_ = RunReconnectLoopAsync(_maintenanceLifetime.Token);
}
private async Task RunReconnectLoopAsync(CancellationToken cancellationToken)
{
var attempt = 0;
try
{
while (!cancellationToken.IsCancellationRequested && !_disposed && !State.Connected)
{
var credential = _credentials.Load();
if (credential is null) return;
var seconds = Math.Min(60, attempt switch { 0 => 2, 1 => 5, 2 => 10, 3 => 20, _ => 30 });
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 750));
await Task.Delay(TimeSpan.FromSeconds(seconds) + jitter, cancellationToken);
if (State.Connected || _disposed) return;
await ConnectAsync(credential, cancellationToken, background: true);
attempt += 1;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception error)
{
SetState(State with { Paired = true, Connected = false, Health = TrayHealth.Degraded, Detail = $"Lumi could not be reached. {Friendly(error)}" });
await WriteLogAsync("connection_failed", error.Message);
await WriteLogAsync("reconnect_failed", error.Message);
}
finally
{
Interlocked.Exchange(ref _reconnectLoopRunning, 0);
if (!State.Connected && !_disposed && !cancellationToken.IsCancellationRequested) ScheduleReconnect();
}
}
public async Task RunTestAsync(CancellationToken cancellationToken = default)
{
if (State.TestRunning || State.BenchmarkRunning) return;
if (!_settings.Current.TranscriptionEnabled) throw new InvalidOperationException("Turn on Generate and include captions before running the transcription check.");
SetState(State with { TestRunning = true, Detail = "Running the transcription path check…" });
var stages = CreateInitialTestStages().ToArray();
TestStages = stages;
@ -286,6 +363,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
public async Task StartStreamTestAsync(CancellationToken cancellationToken = default)
{
if (State.StreamTestRunning) return;
if (State.TestRunning || State.BenchmarkRunning) throw new InvalidOperationException("End the active transcription check before starting Stream Testing.");
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.");
@ -294,6 +372,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
throw new InvalidOperationException($"Update OBS Bridge before starting Stream Testing. Loaded: {State.ObsBridgeLoadedVersion ?? State.ObsBridgeInstalledVersion ?? "unknown"}; required: {State.ObsBridgeBundledVersion ?? "current bundled version"}.");
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.");
var captionsEnabled = _settings.Current.TranscriptionEnabled;
if (captionsEnabled)
{
var selected = ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
if (selected is not { Missing: false, Active: true })
throw new InvalidOperationException("Choose an available microphone that is active in the OBS Program scene before starting Stream Testing.");
if (!await SyncBridgeSelectionAsync(cancellationToken))
throw new InvalidOperationException("Companion could not attach the selected OBS source. Keep OBS open and retry.");
}
Interlocked.Exchange(ref _streamTestStopRequested, 0);
SetState(State with { StreamTestDetail = "Requesting an expiring private receiver from Lumi…", Health = TrayHealth.Operating });
@ -305,7 +392,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
source = new { width = _obsOutputWidth, height = _obsOutputHeight, fps = _obsOutputFps }
}, _socket.SessionId, cancellationToken);
created = await _streamTestSessionSignal.Task.WaitAsync(TimeSpan.FromSeconds(12), cancellationToken);
// The first production test can include Lumi-managed ACME
// certificate provisioning. Subsequent tests use the cached
// certificate and return immediately.
created = await _streamTestSessionSignal.Task.WaitAsync(TimeSpan.FromMinutes(2), cancellationToken);
}
catch (Exception error)
{
@ -329,8 +419,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
var server = ReadString(ingest, "server") ?? throw new InvalidDataException("The private OBS server is missing.");
var key = ReadString(ingest, "key") ?? throw new InvalidDataException("The private OBS stream credential is missing.");
var obsRedirectAttempted = false;
try
{
if (captionsEnabled)
await StartStreamTestTranscriptionAsync(sessionId, cancellationToken);
else
await ReportStreamTestCaptionStatusAsync(sessionId, "disabled", "Captions are turned off in Lumi Companion.", cancellationToken);
var snapshotReply = await _obsBridge.RequestAsync("stream_test_snapshot", new { }, TimeSpan.FromSeconds(8), cancellationToken);
if (ReadString(snapshotReply, "state") != "snapshotted")
throw new InvalidOperationException(ReadString(snapshotReply, "error") ?? "OBS could not snapshot the current streaming service.");
@ -340,6 +436,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
_streamTestRecovery.Save(new StreamTestRecoveryState(sessionId, serviceType, settingsJson, DateTimeOffset.UtcNow));
SetState(State with { StreamTestRecoveryRequired = true, StreamTestDetail = "The original OBS service is protected. Redirecting OBS to the private receiver…" });
obsRedirectAttempted = true;
var beginReply = await _obsBridge.RequestAsync("stream_test_begin", new { server, key, session_id = sessionId }, TimeSpan.FromSeconds(12), cancellationToken);
if (ReadString(beginReply, "state") != "started")
throw new InvalidOperationException(ReadString(beginReply, "error") ?? "OBS could not start the private output.");
@ -351,20 +448,28 @@ public sealed class CompanionRuntime : IAsyncDisposable
StreamTestRunning = true,
StreamTestRecoveryRequired = true,
StreamTestSessionId = sessionId,
StreamTestDetail = "PRIVATE TEST ACTIVE — OBS is sending only to Lumi. End the test here or in the Lumi Admin page.",
StreamTestDetail = captionsEnabled
? "PRIVATE TEST ACTIVE — OBS and private-test captions are being sent only to Lumi. End the test here or in the Lumi Admin page."
: "PRIVATE TEST ACTIVE — OBS is being sent only to Lumi; caption generation is off.",
Health = TrayHealth.Operating
});
await WriteLogAsync("stream_test_started", "Private stream test started; an encrypted OBS recovery snapshot is active.");
}
catch (Exception error)
{
await StopStreamTestTranscriptionAsync("stream_test_start_failed", CancellationToken.None);
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.");
if (obsRedirectAttempted)
{
await SetObsRestartGuardAsync(true);
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
}
SetState(State with
{
Health = TrayHealth.Degraded,
StreamTestDetail = $"{RestartObsAfterFailedStreamTestDetail} {Friendly(error)}"
StreamTestDetail = obsRedirectAttempted
? $"{RestartObsAfterFailedStreamTestDetail} {Friendly(error)}"
: $"The private test could not start. {Friendly(error)}"
});
await WriteLogAsync("stream_test_start_failed", error.Message);
throw;
@ -379,11 +484,72 @@ public sealed class CompanionRuntime : IAsyncDisposable
await SetObsRestartGuardAsync(true);
var sessionId = State.StreamTestSessionId ?? _streamTestRecovery.Load()?.SessionId;
SetState(State with { StreamTestDetail = "Ending the private receiver and restoring the exact OBS service…" });
await StopStreamTestTranscriptionAsync(reason, cancellationToken);
if (_socket is not null && State.Connected && sessionId is not null)
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason }, _socket.SessionId, cancellationToken); } catch { }
await RestoreObsAfterStreamTestAsync("Private test ended. OBS was restored to its previous streaming service.", cancellationToken);
}
private async Task StopStreamTestTranscriptionAsync(string reason, CancellationToken cancellationToken)
{
if (!_streamTestTranscriptionRunning) return;
_streamTestTranscriptionRunning = false;
if (_socket is null || !State.Connected) return;
try
{
await _socket.SendAsync("stop", new { reason }, _socket.SessionId, cancellationToken);
}
catch (Exception error)
{
await WriteLogAsync("stream_test_caption_stop_failed", error.Message);
}
}
private async Task ReportStreamTestCaptionStatusAsync(string sessionId, string state, string detail, CancellationToken cancellationToken)
{
if (_socket is null || !State.Connected) return;
try
{
await _socket.SendAsync("stream_test_caption_status", new { session_id = sessionId, state, detail }, _socket.SessionId, cancellationToken);
}
catch (Exception error)
{
await WriteLogAsync("stream_test_caption_status_failed", error.Message);
}
}
private async Task StartStreamTestTranscriptionAsync(string sessionId, CancellationToken cancellationToken)
{
if (_socket is null || !State.Connected) throw new InvalidOperationException("Reconnect to Lumi before enabling private-test captions.");
var selected = ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
if (selected is not { Missing: false, Active: true })
throw new InvalidOperationException("Choose an available microphone that is active in the OBS Program scene before enabling captions.");
if (!await SyncBridgeSelectionAsync(cancellationToken))
throw new InvalidOperationException("Companion could not attach the selected OBS source. Keep OBS open and retry.");
_sessionStartSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_testFailure = new(TaskCreationOptions.RunContinuationsAsynchronously);
_streamTestTranscriptionRunning = true;
try
{
await _socket.SendAsync("start", new { mode = "stream_test" }, _socket.SessionId, cancellationToken);
var transcriptionStarted = await WaitForTestSignalAsync(_sessionStartSignal.Task, TimeSpan.FromSeconds(8), cancellationToken);
if (!transcriptionStarted.Completed)
throw new InvalidOperationException(transcriptionStarted.Failure ?? "Lumi did not start private-test captioning within 8 seconds.");
await ReportStreamTestCaptionStatusAsync(sessionId, "ready", "Speech recognition is ready and listening.", cancellationToken);
}
catch
{
_streamTestTranscriptionRunning = false;
await ReportStreamTestCaptionStatusAsync(sessionId, "failed", "Caption generation could not start.", CancellationToken.None);
throw;
}
finally
{
_sessionStartSignal = null;
_testFailure = null;
}
}
private async Task RestoreObsAfterStreamTestAsync(string detail, CancellationToken cancellationToken = default)
{
if (Interlocked.CompareExchange(ref _streamTestRestoreRunning, 1, 0) != 0) return;
@ -459,6 +625,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
public async Task StartBenchmarkAsync(CancellationToken cancellationToken = default)
{
if (State.BenchmarkRunning || State.TestRunning) return;
if (!_settings.Current.TranscriptionEnabled) throw new InvalidOperationException("Turn on Generate and include captions before starting the transcription test.");
if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect to Lumi before starting the transcription test.");
if (!State.ObsConnected) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect.");
var selected = ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
@ -571,12 +738,67 @@ public sealed class CompanionRuntime : IAsyncDisposable
await WriteLogAsync("settings_saved", "Local companion preferences updated.");
}
public async Task SetTranscriptionEnabledAsync(bool enabled, CancellationToken cancellationToken = default)
{
if (_settings.Current.TranscriptionEnabled == enabled) return;
if (!enabled && State.BenchmarkRunning) await StopBenchmarkAsync("captions_disabled", cancellationToken);
await _settings.SaveAsync(_settings.Current with { TranscriptionEnabled = enabled }, cancellationToken);
if (_socket is not null && State.Connected)
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
if (!enabled)
{
if (State.StreamTestRunning && State.StreamTestSessionId is { } streamTestSessionId)
{
await StopStreamTestTranscriptionAsync("captions_disabled", cancellationToken);
await ReportStreamTestCaptionStatusAsync(streamTestSessionId, "disabled", "Captions were turned off in Lumi Companion.", cancellationToken);
SetState(State with { StreamTestDetail = "PRIVATE TEST ACTIVE — OBS is being sent only to Lumi; caption generation is off." });
}
else if (_socket is not null && State.Connected)
{
try { await _socket.SendAsync("stop", new { reason = "captions_disabled" }, _socket.SessionId, cancellationToken); }
catch (Exception error) { await WriteLogAsync("caption_stop_failed", error.Message); }
}
}
else if (State.StreamTestRunning && State.StreamTestSessionId is { } streamTestSessionId)
{
try
{
await StartStreamTestTranscriptionAsync(streamTestSessionId, cancellationToken);
}
catch
{
await StopStreamTestTranscriptionAsync("caption_enable_failed", CancellationToken.None);
await _settings.SaveAsync(_settings.Current with { TranscriptionEnabled = false }, CancellationToken.None);
if (_socket is not null && State.Connected)
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
await ReportStreamTestCaptionStatusAsync(streamTestSessionId, "disabled", "Captions could not be enabled in Lumi Companion.", CancellationToken.None);
SetState(State with { StreamTestDetail = "PRIVATE TEST ACTIVE — OBS is being sent only to Lumi; caption generation could not be enabled." });
throw;
}
SetState(State with { StreamTestDetail = "PRIVATE TEST ACTIVE — OBS and private-test captions are being sent only to Lumi. End the test here or in the Lumi Admin page." });
}
else if (State.ObsStreaming && _settings.Current.StartWithObs && _socket is not null && State.Connected)
{
try { await _socket.SendAsync("start", new { mode = "live" }, _socket.SessionId, cancellationToken); }
catch (Exception error) { await WriteLogAsync("caption_start_deferred", error.Message); }
}
await SendRuntimeStateAsync(cancellationToken);
SetState(State);
await WriteLogAsync("captions_toggled", enabled
? "Caption generation and inclusion enabled."
: "Caption generation and inclusion disabled.");
}
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
{
await _updateChecks.RunAsync(async operationToken =>
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(operationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
// Local development checks may need to publish the Companion before
// an artifact can be returned. UpdateService allows that build ten
// minutes, so this guard must not cancel a healthy build first.
timeout.CancelAfter(UpdateCheckTimeout);
SetState(State with { UpdateCheckRunning = true, UpdateDetail = "Checking for Companion updates…" });
try
{
@ -694,6 +916,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true });
}
public void OpenStreamTestingWebUi()
{
if (!Uri.TryCreate(State.Host, UriKind.Absolute, out var host)) return;
var target = new Uri(host, "/admin/stream-testing");
Process.Start(new ProcessStartInfo(target.ToString()) { UseShellExecute = true });
}
public void OpenLogsDirectory()
{
Directory.CreateDirectory(_paths.LogsDirectory);
@ -702,10 +931,20 @@ public sealed class CompanionRuntime : IAsyncDisposable
public async Task ForgetDeviceAsync()
{
if (_socket is not null) { await _socket.DisposeAsync(); _socket = null; }
_credentials.Remove();
SetState(WithBridgeState(new CompanionState(Detail: "Device removed. Pair this computer to reconnect.")));
await WriteLogAsync("device_removed", "Saved device credential removed locally.");
await _connectionGate.WaitAsync();
try
{
var socket = _socket;
_socket = null;
if (socket is not null) await socket.DisposeAsync();
_credentials.Remove();
SetState(WithBridgeState(new CompanionState(Detail: "Device removed. Pair this computer to reconnect.")));
await WriteLogAsync("device_removed", "Saved device credential removed locally.");
}
finally
{
_connectionGate.Release();
}
}
private Task OnServerMessageAsync(ServerEnvelope message)
@ -716,6 +955,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
var reason = ReadString(message.Payload, "reason") ?? "The private receiver ended.";
Interlocked.Exchange(ref _streamTestStopRequested, 1);
_ = StopStreamTestTranscriptionAsync("stream_test_ended", CancellationToken.None);
_ = RestoreObsAfterStreamTestAsync($"{reason} OBS was restored to its previous streaming service.");
}
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
@ -740,7 +980,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
}
else if (state.GetString() == "idle") _sessionStopSignal?.TrySetResult(true);
}
if (message.Type == "caption")
if (message.Type == "caption" && _settings.Current.TranscriptionEnabled)
{
var stableText = message.Payload.TryGetProperty("stable_text", out var stable) ? stable.GetString() : null;
var uncertainText = message.Payload.TryGetProperty("uncertain_text", out var uncertain) ? uncertain.GetString() : null;
@ -753,18 +993,31 @@ public sealed class CompanionRuntime : IAsyncDisposable
CaptionReceived?.Invoke(text, simulated);
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
if (State.BenchmarkRunning) UpdateBenchmarkCaption(message.Payload, text, final);
if (State.StreamTestRunning && final && _socket is not null)
if (State.StreamTestRunning && _streamTestTranscriptionRunning && _socket is not null)
{
var elapsed = Math.Max(0, (DateTimeOffset.UtcNow - _streamTestBeganAt).TotalSeconds);
var captionDelay = message.Payload.TryGetProperty("latency", out var latency)
? ReadDouble(latency, "total_ms")
: 0;
var captionId = ReadString(message.Payload, "caption_id") ?? Guid.NewGuid().ToString();
var revision = message.Payload.TryGetProperty("revision", out var revisionValue) && revisionValue.TryGetInt32(out var parsedRevision)
? parsedRevision
: 1;
var audioStart = StreamTestAudioSeconds(message.Payload, "start_us", Math.Max(0, elapsed - Math.Max(0.5, captionDelay / 1000)));
var audioEnd = StreamTestAudioSeconds(message.Payload, "end_us", elapsed);
var displaySeconds = final ? Math.Clamp(2.5 + text.Length / 18.0, 4, 8) : 3.5;
_ = _socket.SendAsync("stream_test_caption", new
{
session_id = State.StreamTestSessionId,
caption_id = captionId,
revision,
final,
text,
start_seconds = Math.Max(0, elapsed - 3),
end_seconds = elapsed + 1,
stable_text = stableText,
uncertain_text = uncertainText,
start_seconds = audioStart,
end_seconds = Math.Max(audioStart + 0.5, Math.Max(audioEnd + displaySeconds, elapsed + (final ? 2 : 1.5))),
display_seconds = displaySeconds,
delay_ms = captionDelay
}, _socket.SessionId, _lifetimeToken());
}
@ -783,6 +1036,11 @@ public sealed class CompanionRuntime : IAsyncDisposable
_streamTestSessionSignal.TrySetException(new InvalidOperationException(serverMessage ?? "Lumi could not create the private stream test."));
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
_benchmarkLifetime?.Cancel();
if (State.StreamTestRunning && _streamTestTranscriptionRunning && State.StreamTestSessionId is { } streamTestSessionId)
{
_streamTestTranscriptionRunning = false;
_ = ReportStreamTestCaptionStatusAsync(streamTestSessionId, "failed", serverMessage ?? "Speech recognition stopped.", CancellationToken.None);
}
SetState(State with
{
Health = TrayHealth.Degraded,
@ -989,9 +1247,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow;
}
var audioNeeded = State.TestRunning || (State.BenchmarkRunning
? Volatile.Read(ref _benchmarkStopping) == 0
: State.ObsStreaming);
var audioNeeded = _settings.Current.TranscriptionEnabled &&
(State.TestRunning || _streamTestTranscriptionRunning || (State.BenchmarkRunning
? Volatile.Read(ref _benchmarkStopping) == 0
: State.ObsStreaming));
if (audioNeeded && _socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame))
_ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery.");
return Task.CompletedTask;
@ -1009,7 +1268,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
program_active = source.Active,
source_missing = source.Missing,
single_speaker = true,
delivery_enabled = true
delivery_enabled = _settings.Current.TranscriptionEnabled
}, _socket.SessionId, _lifetimeToken());
}
@ -1146,7 +1405,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
streaming = State.ObsStreaming,
recording = State.ObsRecording,
version = obsVersion,
auto_start = _settings.Current.StartWithObs,
auto_start = _settings.Current.TranscriptionEnabled && _settings.Current.StartWithObs && !_streamTestTranscriptionRunning,
bridge_installed = bridge.Valid,
bridge_connected = State.ObsConnected,
bridge_version = State.ObsBridgeLoadedVersion ?? bridge.InstalledVersion ?? bridge.Version,
@ -1210,6 +1469,22 @@ public sealed class CompanionRuntime : IAsyncDisposable
private static int ReadInt(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : 0;
private static long ReadLong(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.TryGetInt64(out var result) ? result : 0;
private double StreamTestAudioSeconds(JsonElement payload, string name, double fallback)
{
if (!payload.TryGetProperty("audio", out var audio)) return fallback;
var timestampUs = ReadLong(audio, name);
if (timestampUs <= 0) return fallback;
try
{
var capturedAt = DateTimeOffset.FromUnixTimeMilliseconds(timestampUs / 1000);
return Math.Max(0, (capturedAt - _streamTestBeganAt).TotalSeconds);
}
catch (ArgumentOutOfRangeException)
{
return fallback;
}
}
private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True;
private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null;
private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None;
@ -1275,13 +1550,43 @@ public sealed class CompanionRuntime : IAsyncDisposable
Directory.CreateDirectory(_paths.LogsDirectory);
PruneLogs();
var path = Path.Combine(_paths.LogsDirectory, $"companion-{DateTime.UtcNow:yyyy-MM-dd}.jsonl");
var line = JsonSerializer.Serialize(new { timestamp = DateTimeOffset.UtcNow, kind, message }, ProtocolV1.JsonOptions);
var eventId = CompanionLogSanitizer.NormalizeEvent(kind);
var line = JsonSerializer.Serialize(new
{
timestamp = DateTimeOffset.UtcNow,
level = CompanionLogSanitizer.LevelForEvent(eventId),
source = "companion:core",
category = CompanionLogCategory(eventId),
@event = eventId,
message = CompanionLogSanitizer.Sanitize(message)
}, ProtocolV1.JsonOptions);
await File.AppendAllTextAsync(path, line + Environment.NewLine);
LogAdded?.Invoke($"{DateTime.Now:HH:mm:ss} {message}");
LogAdded?.Invoke($"{DateTime.Now:HH:mm:ss} {CompanionLogSanitizer.Sanitize(message, 500)}");
}
catch { }
}
private static string CompanionLogCategory(string eventId)
{
if (eventId.StartsWith("update_", StringComparison.Ordinal)) return "updates";
if (eventId.StartsWith("stream_test_", StringComparison.Ordinal) ||
eventId.StartsWith("obs_", StringComparison.Ordinal))
{
return "integration";
}
if (eventId.StartsWith("benchmark_", StringComparison.Ordinal) ||
eventId.StartsWith("audio_", StringComparison.Ordinal) ||
eventId == "source_selected")
{
return "transcription";
}
if (eventId is "paired" or "pairing_failed" or "connected" or "disconnected" or "connection_failed" or "device_removed")
{
return "integration";
}
return "lifecycle";
}
private void PruneLogs()
{
var files = new DirectoryInfo(_paths.LogsDirectory).GetFiles("*.jsonl").OrderBy(file => file.CreationTimeUtc).ToList();
@ -1327,7 +1632,17 @@ public sealed class CompanionRuntime : IAsyncDisposable
_disposed = true;
_maintenanceLifetime.Cancel();
_benchmarkLifetime?.Cancel();
if (_socket is not null) await _socket.DisposeAsync();
await _connectionGate.WaitAsync();
try
{
var socket = _socket;
_socket = null;
if (socket is not null) await socket.DisposeAsync();
}
finally
{
_connectionGate.Release();
}
if (_obsBridge is not null) await _obsBridge.DisposeAsync();
_benchmarkLifetime?.Dispose(); _bridgeSelectionGate.Dispose();
_http.Dispose(); _maintenanceLifetime.Dispose();

View File

@ -6,6 +6,7 @@ namespace Lumi.Companion.App;
public sealed record CompanionSettings(
bool AutoStartWithWindows = false,
bool StartWithObs = true,
bool TranscriptionEnabled = true,
bool AdvancedMode = false,
string? PrimarySourceUuid = null,
string? PrimarySourceName = null,

View File

@ -152,6 +152,7 @@
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="StartStreamTestButton" Classes="primary" Content="Start private stream test" />
<Button x:Name="StopStreamTestButton" Classes="secondary" Content="End test and restore OBS" IsEnabled="False" />
<Button x:Name="OpenStreamTestWebButton" Classes="secondary" Content="Open stream viewer" IsVisible="False" />
<Button x:Name="RepairStreamTestButton" Classes="secondary" Content="Repair restoration" IsVisible="False" />
</StackPanel>
</StackPanel>
@ -172,6 +173,15 @@
<TextBlock Text="Transcription" Classes="pageTitle" />
<TextBlock Text="Choose the OBS microphone Lumi should caption. Source identity remains stable if you rename it." Classes="muted" FontSize="15" />
</StackPanel>
<Border Classes="card">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
<StackPanel Spacing="4">
<TextBlock Text="Generate and include captions" FontWeight="SemiBold" />
<TextBlock x:Name="TranscriptionEnabledDetail" Text="Speech recognition and caption inclusion are enabled." Classes="muted" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="TranscriptionEnabledToggle" OffContent="Off" OnContent="On" VerticalAlignment="Center" />
</Grid>
</Border>
<StackPanel Spacing="8">
<TextBlock Text="Primary microphone" FontWeight="SemiBold" />
<ComboBox x:Name="SourcePicker" IsEnabled="False" PlaceholderText="Open OBS after installing the managed integration" MinWidth="420" HorizontalAlignment="Left" />

View File

@ -16,9 +16,11 @@ public partial class MainWindow : Window
private readonly SongOverlayRuntime _songOverlay;
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
private readonly Dictionary<CompanionPage, PluginNavigationGroup> _pluginNavigationGroups = [];
private bool _allowExit;
private bool _renderingSources;
private bool _renderingSongOverlay;
private bool _renderingSettings;
public MainWindow() : this(CreateDefaultServices()) { }
@ -49,7 +51,7 @@ public partial class MainWindow : Window
if (simulated) SimulatedCaption.Text = text;
});
runtime.LogAdded += line => Dispatcher.UIThread.Post(() => AddLog(line));
settings.Changed += value => Dispatcher.UIThread.Post(() => RenderSettings(value));
settings.Changed += value => Dispatcher.UIThread.Post(() => { RenderSettings(value); RenderState(runtime.State); });
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
}
@ -58,7 +60,35 @@ public partial class MainWindow : Window
PluginNavigation.Children.Clear();
foreach (var plugin in _plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
{
var children = new StackPanel { Spacing = 2, Margin = new Thickness(8, 3, 0, 6) };
var children = new StackPanel
{
Spacing = 2,
Margin = new Thickness(8, 1, 0, 6),
IsVisible = false
};
var arrow = new TextBlock
{
Text = "",
FontSize = 17,
Foreground = new SolidColorBrush(Color.Parse("#5A6872")),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
var header = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("*,Auto") };
header.Children.Add(new TextBlock
{
Text = plugin.Descriptor.Name,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
});
Grid.SetColumn(arrow, 1);
header.Children.Add(arrow);
var root = new Button
{
Content = header,
HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Stretch
};
root.Classes.Add("nav");
root.Classes.Add("navRoot");
root.Click += (_, _) => SetPluginNavigationExpanded(children, arrow, !children.IsVisible);
foreach (var page in plugin.Pages.OrderBy(item => item.Order))
{
if (!Enum.TryParse<CompanionPage>(page.Key, out var parsed)) continue;
@ -67,16 +97,12 @@ public partial class MainWindow : Window
button.Click += OnNavigate;
children.Children.Add(button);
_navigationButtons[parsed] = button;
_pluginNavigationGroups[parsed] = new PluginNavigationGroup(children, arrow);
}
var expander = new Expander
{
Header = new TextBlock { Text = plugin.Descriptor.Name, FontWeight = FontWeight.SemiBold },
IsExpanded = true,
Content = children,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch
};
expander.Classes.Add("pluginRoot");
PluginNavigation.Children.Add(expander);
var group = new StackPanel { Spacing = 1 };
group.Children.Add(root);
group.Children.Add(children);
PluginNavigation.Children.Add(group);
}
_navigationButtons[CompanionPage.Overview] = OverviewNav;
_navigationButtons[CompanionPage.StreamTesting] = StreamTestingNav;
@ -98,6 +124,7 @@ public partial class MainWindow : Window
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
StartStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartStreamTestAsync(), StartStreamTestButton);
StopStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopStreamTestAsync(), StopStreamTestButton);
OpenStreamTestWebButton.Click += (_, _) => _runtime.OpenStreamTestingWebUi();
RepairStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RepairStreamTestRecoveryAsync(), RepairStreamTestButton);
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
@ -110,6 +137,7 @@ public partial class MainWindow : Window
ConnectionUpdateBridgeButton.Click += async (_, _) => await InstallBridgeAsync(ConnectionUpdateBridgeButton);
RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync();
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
@ -145,6 +173,14 @@ public partial class MainWindow : Window
foreach (var item in pages) item.Value.IsVisible = item.Key == page;
foreach (var item in _navigationButtons)
item.Value.Classes.Set("selected", item.Key == page);
if (_pluginNavigationGroups.TryGetValue(page, out var pluginGroup))
SetPluginNavigationExpanded(pluginGroup.Children, pluginGroup.Arrow, true);
}
private static void SetPluginNavigationExpanded(StackPanel children, TextBlock arrow, bool expanded)
{
children.IsVisible = expanded;
arrow.Text = expanded ? "⌄" : "";
}
private async void OnNextAction(object? sender, RoutedEventArgs args)
@ -261,6 +297,30 @@ public partial class MainWindow : Window
finally { SaveSettingsButton.IsEnabled = true; }
}
private async Task SaveTranscriptionEnabledAsync()
{
if (_renderingSettings) return;
var enabled = TranscriptionEnabledToggle.IsChecked == true;
TranscriptionEnabledToggle.IsEnabled = false;
TranscriptionEnabledDetail.Text = enabled ? "Enabling speech recognition and caption inclusion…" : "Stopping speech recognition and removing caption inclusion…";
try
{
await _runtime.SetTranscriptionEnabledAsync(enabled);
}
catch (Exception error)
{
TranscriptionEnabledDetail.Text = $"The caption preference could not be changed: {error.Message}";
AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}");
_renderingSettings = true;
TranscriptionEnabledToggle.IsChecked = _settings.Current.TranscriptionEnabled;
_renderingSettings = false;
}
finally
{
TranscriptionEnabledToggle.IsEnabled = !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning;
}
}
private async Task ForgetDeviceAsync()
{
var dialog = new DecisionWindow("Forget this device?", "This removes the protected credential from this computer. Pairing can be restored with a new package.", "Forget device");
@ -295,7 +355,7 @@ public partial class MainWindow : Window
StatusMark.Background = new SolidColorBrush(Color.Parse(state.Health switch { TrayHealth.Ready => "#176B75", TrayHealth.Operating => "#23845B", TrayHealth.Failed => "#BD4D4D", _ => "#A96612" }));
OverviewDetail.Text = state.Detail;
PairingStepSymbol.Text = state.Paired ? "✓" : "○";
PairingStepDetail.Text = state.Connected ? "Securely connected" : state.Paired ? "Paired, currently offline" : "Not paired";
PairingStepDetail.Text = state.Connected ? "Securely connected" : state.Paired ? "Paired; reconnecting automatically" : "Not paired";
ObsStepSymbol.Text = state.ObsBridgeUpdateAvailable ? "!" : state.ObsConnected ? "✓" : state.ObsBridgeInstalled ? "◐" : "○";
ObsStepDetail.Text = state.ObsBridgeUpdateAvailable
? $"Update required ({state.ObsBridgeLoadedVersion ?? state.ObsBridgeInstalledVersion ?? "unknown"} → {state.ObsBridgeBundledVersion ?? "current"})"
@ -309,9 +369,10 @@ public partial class MainWindow : Window
ReconnectButton.IsEnabled = state.Paired && !state.Connected;
ForgetButton.IsEnabled = state.Paired;
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
RunTestButton.IsEnabled = _settings.Current.TranscriptionEnabled && !state.TestRunning && !state.BenchmarkRunning && !state.StreamTestRunning;
StartBenchmarkButton.IsEnabled = _settings.Current.TranscriptionEnabled && !state.TestRunning && !state.BenchmarkRunning && !state.StreamTestRunning;
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
TranscriptionEnabledToggle.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
BenchmarkStatusText.Text = state.BenchmarkDetail;
StreamTestStatusTitle.Text = state.StreamTestRunning
? "PRIVATE TEST ACTIVE"
@ -324,8 +385,10 @@ public partial class MainWindow : Window
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
StreamTestCongestionText.Text = state.StreamTestRunning ? $"{state.StreamTestCongestion:P0}" : "—";
StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsBridgeUpdateAvailable && !state.ObsBridgeRepairNeeded && !state.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired && !state.StreamTestObsRestartRequired;
StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsBridgeUpdateAvailable && !state.ObsBridgeRepairNeeded && !state.ObsStreaming && !state.ObsRecording && !state.TestRunning && !state.BenchmarkRunning && !state.StreamTestRunning && !state.StreamTestRecoveryRequired && !state.StreamTestObsRestartRequired;
StopStreamTestButton.IsEnabled = state.StreamTestRunning;
OpenStreamTestWebButton.IsVisible = state.StreamTestRunning;
OpenStreamTestWebButton.IsEnabled = state.StreamTestRunning && state.Host is not null;
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
UpdatePanel.IsVisible = state.UpdateAvailable;
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
@ -357,9 +420,9 @@ public partial class MainWindow : Window
}
else if (!state.Connected)
{
NextActionTitle.Text = "Reconnect to Lumi";
NextActionDetail.Text = "Your device is paired, but Lumi cannot currently be reached.";
NextActionButton.Content = "Retry connection";
NextActionTitle.Text = "Lumi is temporarily unavailable";
NextActionDetail.Text = "Companion is reconnecting automatically in the background.";
NextActionButton.Content = "Retry now";
}
else if (state.ObsBridgeUpdateAvailable)
{
@ -383,12 +446,24 @@ public partial class MainWindow : Window
private void RenderSettings(CompanionSettings settings)
{
AutoStartToggle.IsChecked = settings.AutoStartWithWindows;
StartWithObsToggle.IsChecked = settings.StartWithObs;
AdvancedToggle.IsChecked = settings.AdvancedMode;
AdvancedPanel.IsVisible = settings.AdvancedMode;
if (settings.PrimarySourceUuid is not null)
SourcePicker.SelectedItem = _runtime.ObsSources.FirstOrDefault(source => source.Uuid == settings.PrimarySourceUuid);
_renderingSettings = true;
try
{
AutoStartToggle.IsChecked = settings.AutoStartWithWindows;
StartWithObsToggle.IsChecked = settings.StartWithObs;
TranscriptionEnabledToggle.IsChecked = settings.TranscriptionEnabled;
TranscriptionEnabledDetail.Text = settings.TranscriptionEnabled
? "Speech recognition and caption inclusion are enabled."
: "Captions are off. Companion will not send microphone audio for speech recognition.";
AdvancedToggle.IsChecked = settings.AdvancedMode;
AdvancedPanel.IsVisible = settings.AdvancedMode;
if (settings.PrimarySourceUuid is not null)
SourcePicker.SelectedItem = _runtime.ObsSources.FirstOrDefault(source => source.Uuid == settings.PrimarySourceUuid);
}
finally
{
_renderingSettings = false;
}
}
@ -448,7 +523,10 @@ public partial class MainWindow : Window
TestStepSymbol.Text = valid ? "✓" : "○";
TestStepDetail.Text = currentPass ? "Passed just now; this result will be reused while the relevant setup stays unchanged." : _runtime.State.PathTestDetail;
RunTestButton.Content = _runtime.State.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning;
RunTestButton.IsEnabled = _settings.Current.TranscriptionEnabled &&
!_runtime.State.TestRunning &&
!_runtime.State.BenchmarkRunning &&
!_runtime.State.StreamTestRunning;
}
private void RenderBenchmark(BenchmarkSnapshot benchmark)
@ -468,6 +546,8 @@ public partial class MainWindow : Window
VoiceLevelMeter.Foreground = new SolidColorBrush(Color.Parse(color));
}
private sealed record PluginNavigationGroup(StackPanel Children, TextBlock Arrow);
private static void RenderMetric(Panel panel, MetricStatistics metric, Func<double, string> formatter)
{
panel.Children.Clear();

View File

@ -11,11 +11,21 @@ internal sealed class TranscriptionPluginContribution : ICompanionPluginContribu
_runtime = runtime;
Actions =
[
new CompanionPluginAction(
"toggle",
() => _runtime.TranscriptionEnabled ? "Turn captions off" : "Turn captions on",
async cancellationToken =>
{
await _runtime.SetTranscriptionEnabledAsync(!_runtime.TranscriptionEnabled, cancellationToken);
Changed?.Invoke();
},
() => !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
5),
new CompanionPluginAction(
"test",
() => _runtime.State.TestRunning || _runtime.State.BenchmarkRunning ? "Transcription test running…" : "Run transcription test",
cancellationToken => _runtime.RunTestAsync(cancellationToken),
() => !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
() => _runtime.TranscriptionEnabled && !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
10)
];
_runtime.StateChanged += _ => Changed?.Invoke();
@ -37,15 +47,17 @@ internal sealed class TranscriptionPluginContribution : ICompanionPluginContribu
public IReadOnlyList<CompanionPluginAction> Actions { get; }
public CompanionPluginStatus Status => new(
_runtime.State.Health switch
!_runtime.TranscriptionEnabled
? CompanionPluginHealth.Healthy
: _runtime.State.Health switch
{
TrayHealth.Ready => CompanionPluginHealth.Healthy,
TrayHealth.Operating => CompanionPluginHealth.Healthy,
TrayHealth.Failed => CompanionPluginHealth.Error,
_ => CompanionPluginHealth.Warning
},
_runtime.State.Summary,
_runtime.State.Detail);
_runtime.TranscriptionEnabled ? _runtime.State.Summary : "Captions off",
_runtime.TranscriptionEnabled ? _runtime.State.Detail : "Microphone audio is not sent for speech recognition, and captions are not included.");
public event Action? Changed;
}

View File

@ -1,3 +1,4 @@
using Lumi.Companion.Abstractions;
using Lumi.Companion.Core;
static void Assert(bool condition, string message)
@ -39,4 +40,14 @@ release.SetResult();
await Task.WhenAll(first, second);
Assert(concurrentCalls == 1 && !operation.IsRunning, "Rapid update checks did not share and reset the active operation.");
Console.WriteLine("Companion core single-flight regression checks passed.");
var sanitized = CompanionLogSanitizer.Sanitize(
"LumiDevice device-id.private-device-secret Authorization=private-value https://lumi.test/?token=private-token Cookie: session=private-cookie"
);
Assert(!sanitized.Contains("private-value"), "Companion logs retained an authorization value.");
Assert(!sanitized.Contains("private-cookie"), "Companion logs retained a cookie.");
Assert(!sanitized.Contains("private-token"), "Companion logs retained a query token.");
Assert(!sanitized.Contains("private-device-secret"), "Companion logs retained a paired-device credential.");
Assert(CompanionLogSanitizer.NormalizeEvent("Update Failed!") == "update_failed", "Companion event normalization drifted.");
Assert(CompanionLogSanitizer.LevelForEvent("update_failed") == "error", "Companion failed events must use the error level.");
Console.WriteLine("Companion core single-flight and logging regression checks passed.");

View File

@ -10,10 +10,9 @@ Core services create a named logger:
```js
const { createLogger } = require("./logger");
const logger = createLogger("updater");
const logger = createLogger("core:updater", { category: "updates" });
logger.info("Update check completed", { version }, {
category: "updates",
event: "update_check_completed"
});
```
@ -22,6 +21,19 @@ Plugins receive a plugin-scoped `logger` in their `init()` dependencies. Use
that injected logger when practical. A plugin module that must log outside
`init()` may create a logger named `plugin:<plugin-id>`.
Use these source prefixes consistently:
- `core:<component>` for Lumi core and WebUI services.
- `platform:<provider>` for Discord, Twitch, YouTube, and similar integrations.
- `plugin:<plugin-id>` for bundled and locally installed plugins.
- `companion:<component>` for the desktop Companion host.
Give each logger a stable default category such as `lifecycle`, `http`,
`integration`, `command`, `automation`, `security`, `updates`, or `plugin`.
Every operational call must provide a stable, lowercase `snake_case` event ID.
Messages are for people and may improve over time; event IDs are for filtering
and automation and should remain stable.
Use:
- `debug` for detailed, low-value troubleshooting information.
@ -38,9 +50,24 @@ logger.error("Plugin refresh failed", error, {
});
```
The metadata argument only accepts `source`, `category`, `event`, and
`requestId`. Operational fields such as `plugin_id`, `user_id`, `status`, or
`duration_ms` belong in the details argument:
```js
logger.warn("Plugin health check degraded", {
plugin_id: plugin.id,
status: health.status
}, {
event: "plugin_health_degraded"
});
```
Do not include passwords, tokens, cookies, pairing secrets, authorization
headers, or full request bodies in messages or metadata. Redaction is a safety
net, not a reason to collect secrets.
headers, signature fragments, full request bodies, or full third-party payloads
in messages or details. Record a bounded summary with IDs, status, counts, and
safe error text instead. Redaction is a safety net, not a reason to collect
secrets.
## Metrics and high-frequency events
@ -53,12 +80,29 @@ retention, and inspection UI. Lumi AI is one example:
work history. Operational failures in such a feature still belong in the core
logger.
Feature-owned diagnostic logs follow the same credential and payload rules.
They must have explicit size/age retention, sanitize recursively before writing,
and remain admin-only. Transcription worker diagnostics are kept separately
because they are high-volume troubleshooting data; they are not a substitute
for operational warnings and errors in the core logger.
The desktop Companion cannot write directly to the server database. Its local
JSON Lines logs therefore carry the same `level`, `source`, `category`, `event`,
and human-readable `message` shape, use the shared
`CompanionLogSanitizer`, and enforce local retention.
The native OBS Bridge must use OBS's `blog()` facility so its messages remain in
the operator's OBS log. Prefix each message with `[Lumi Companion]` and a stable
`event=<snake_case>` field, and never include IPC payloads or credentials.
## Console output
Direct `console.*` calls are captured after `hookConsole()` starts, but they lose
the useful source and event metadata of a named logger. Prefer a named logger in
runtime code. Console output remains reasonable in standalone verification and
build scripts where the terminal is the intended consumer.
runtime code, including startup and shutdown paths. Console output remains
reasonable in standalone verification and build scripts where the terminal is
the intended consumer. Browser-side console diagnostics and isolated worker
sandboxes are outside the server operational-log boundary.
## Retention and context

View File

@ -32,6 +32,13 @@ portable fallback.
7. Use the dedicated transcription test when measuring real speech accuracy,
confidence, latency, and input level.
The Companion Transcription page and tray menu expose a persistent **Generate
and include captions** switch. When it is off, Companion does not queue or send
microphone audio for recognition, does not start a live transcription session,
and removes caption inclusion from a private stream test. The video-only private
test remains available. Path and benchmark tests remain disabled until captions
are turned back on.
Production device HTTP and WebSocket traffic requires HTTPS/WSS. Plain
HTTP/WebSocket is allowed only for a device paired from the exact matching
loopback Lumi origin and only while both sides remain on loopback.
@ -58,7 +65,9 @@ workers are built with `plugins/lumi_transcription/scripts/build-worker.ps1`.
- Live sessions require an active OBS stream. Tests use simulated delivery and
never publish captions to Twitch.
- Disconnects pause delivery and retain the session/model for a bounded grace
period so a short reconnect can resume safely.
period so a short reconnect can resume safely. Companion retries a paired Lumi
host silently with bounded backoff and resumes its authenticated runtime state
after reconnecting.
- Manual benchmark completion waits for the final confidence-bearing caption;
silence ends a test after ten seconds.
- Device revocation takes effect on the next authenticated request or

View File

@ -20,8 +20,15 @@ Lumi Companion, then watch it at **Admin > Stream testing**.
- The MediaMTX API, metrics, and HLS listeners bind only to loopback. The Admin
WebUI accesses HLS through an authenticated, no-store, same-origin streaming
proxy. RTSP, WebRTC, SRT, playback, pprof, and recording are disabled.
- Stable transcription captions are reused as a private WebVTT side channel.
No second transcription pipeline and no local speech inference are added.
- Companion explicitly starts a server-hosted `stream_test` transcription
session before redirecting OBS. It reuses the selected source and model while
keeping normal live-caption delivery disabled. Interim revisions progressively
reveal each caption in the private player; a finalized caption remains for a
length-aware readability window, then ends when stale or when the next
utterance replaces it. The player's Captions control toggles a wrapping,
multiline Lumi caption layer immediately as revisions arrive instead of
delaying display against the HLS media timeline. No second transcription
pipeline and no local speech inference are added.
Media segments stay in bounded MediaMTX memory and roll out of the short live
window. Lumi does not retain a raw stream recording or create temporary HLS
@ -55,37 +62,51 @@ the downloaded runtime and documented in
## 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.
Lumi derives the network policy from the authenticated Companion pairing
origin. A Companion paired through `localhost`, `127.0.0.1`, or `::1` always
receives loopback RTMP; production ingest overrides are deliberately ignored
for that local development session. A non-local Companion must have paired
through HTTPS and always receives RTMPS. `LUMI_STREAM_TEST_INGEST_HOST` may
override the advertised hostname only for those non-local sessions.
RTMPS is recommended for any public or routed network:
For the normal production path, Lumi automatically provisions and renews a
publicly trusted certificate for the paired hostname through ACME HTTP-01. The
temporary `/.well-known/acme-challenge/` response is public, narrowly scoped,
and available before WebUI authentication; all certificate keys stay under
Lumi's ignored data directory. The reverse proxy must forward that challenge
path to Lumi. The first production test may take up to two minutes while the
certificate is issued; later tests reuse it.
Every 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 required for every non-local pairing. A stale
`LUMI_STREAM_TEST_TRANSPORT=rtmp` value is ignored for production rather than
weakening transport or preventing the test from starting. Normal installations
need no certificate environment variables:
```text
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
```
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:
Operators that already manage a matching certificate can override Lumi's
managed certificate by setting both paths:
```text
LUMI_STREAM_TEST_TRANSPORT=rtmp
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=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.
Unencrypted RTMP is selected automatically only for an authenticated loopback
pairing. It cannot be enabled for a non-local pairing through an environment
override. Restrict the ingest port at the host firewall in either mode.
Optional session limits remain:

374
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "lumi-bot",
"version": "0.3.4",
"dependencies": {
"acme-client": "^5.4.0",
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",
"better-sqlite3-session-store": "^0.1.0",
@ -87,6 +88,163 @@
"node": ">= 10"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
@ -167,6 +325,45 @@
"node": ">= 0.6"
}
},
"node_modules/acme-client": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz",
"integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==",
"license": "MIT",
"dependencies": {
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.7.2",
"debug": "^4.3.5",
"node-forge": "^1.3.1"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/acme-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/acme-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/adm-zip": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
@ -176,6 +373,41 @@
"node": ">=14.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agent-base/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/agent-base/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@ -188,6 +420,20 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -210,6 +456,18 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -808,6 +1066,26 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@ -992,6 +1270,42 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -1258,6 +1572,15 @@
}
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1437,6 +1760,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@ -1447,6 +1779,24 @@
"once": "^1.3.1"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@ -1525,6 +1875,12 @@
"node": ">= 6"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1834,6 +2190,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@ -30,6 +30,7 @@
"node": ">=18"
},
"dependencies": {
"acme-client": "^5.4.0",
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",
"better-sqlite3-session-store": "^0.1.0",

View File

@ -5,7 +5,7 @@ const { Permissions } = require("discord.js");
const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("auto-vc");
const logger = createLogger("plugin:auto-vc", { category: "plugin" });
const PLUGIN_ID = "auto-vc";
const DEFAULT_TEMPLATE = "[username]'s room";
@ -130,7 +130,7 @@ module.exports = {
const attach = () => {
bootstrapRooms(discordClient, db, state, settings).catch((error) => {
logger.error("Auto VC bootstrap failed", error);
logger.error("Auto VC bootstrap failed", error, { event: "bootstrap_failed" });
});
discordClient.on("voiceStateUpdate", (oldState, newState) => {
handleVoiceStateUpdate(oldState, newState, db, settings, state);
@ -583,7 +583,7 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
if (lobby && newState.channelId !== oldState.channelId) {
createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => {
logger.error("Auto VC creation failed", error);
logger.error("Auto VC creation failed", error, { event: "room_creation_failed" });
});
}
@ -926,7 +926,7 @@ async function deleteChannel(channel) {
try {
await channel.delete("Auto VC cleanup");
} catch (error) {
logger.error("Failed to delete Auto VC channel", error);
logger.error("Failed to delete Auto VC channel", error, { event: "channel_delete_failed" });
}
}
@ -1364,12 +1364,12 @@ async function moveMemberToChannel(member, channel) {
await member.voice.setChannel(channel);
return true;
} catch (error) {
logger.error("Failed to move member", error);
logger.error("Failed to move member", error, { event: "member_move_failed" });
try {
await member.edit({ channel: channel.id });
return true;
} catch (fallbackError) {
logger.error("Fallback move failed", fallbackError);
logger.error("Fallback move failed", fallbackError, { event: "member_move_fallback_failed" });
return false;
}
}
@ -1381,7 +1381,7 @@ function startNameRefreshTimer(discordClient, db, settings, state) {
}
const runSweep = () => {
refreshRoomNames(discordClient, db, settings, state).catch((error) => {
logger.error("Auto VC name refresh failed", error);
logger.error("Auto VC name refresh failed", error, { event: "name_refresh_failed" });
});
};
runSweep();
@ -1411,7 +1411,7 @@ async function refreshRoomNames(discordClient, db, settings, state) {
const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName);
if (desiredName && desiredName !== channel.name) {
await channel.setName(desiredName).catch((error) => {
logger.error("Failed to update Auto VC name", error);
logger.error("Failed to update Auto VC name", error, { event: "channel_rename_failed" });
});
}
}
@ -1423,7 +1423,7 @@ function startSweepTimer(discordClient, db, settings, state) {
}
const runSweep = () => {
sweepRooms(discordClient, db, state).catch((error) => {
logger.error("Auto VC sweep failed", error);
logger.error("Auto VC sweep failed", error, { event: "room_sweep_failed" });
});
};
runSweep();

View File

@ -2,7 +2,7 @@ const crypto = require("crypto");
const path = require("path");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("birthday");
const logger = createLogger("plugin:birthday", { category: "plugin" });
const PLUGIN_ID = "birthday";
const TIMER_KEY = Symbol.for("lumi.birthday.interval");
@ -542,9 +542,13 @@ function restartScheduler({ db, discordClient }) {
if (!config.enabled) {
return;
}
checkBirthdays({ db, discordClient }).catch((error) => logger.error("Birthday check failed", error));
checkBirthdays({ db, discordClient }).catch((error) =>
logger.error("Birthday check failed", error, { event: "birthday_check_failed" })
);
global[TIMER_KEY] = setInterval(() => {
checkBirthdays({ db, discordClient }).catch((error) => logger.error("Birthday check failed", error));
checkBirthdays({ db, discordClient }).catch((error) =>
logger.error("Birthday check failed", error, { event: "birthday_check_failed" })
);
}, config.birthday_check_interval_minutes * 60 * 1000);
}

View File

@ -12,7 +12,7 @@ const {
validateUploadedFile
} = require("../../src/services/upload-security");
const logger = createLogger("economy-framework");
const logger = createLogger("plugin:economy-framework", { category: "plugin" });
const PLUGIN_ID = "economy-framework";
const LEGACY_STEM = ["echo", "nomy"].join("");
@ -1426,7 +1426,9 @@ function startActivityRewardFlusher(db) {
try {
flushActivityRewards(db);
} catch (error) {
logger.error("Activity reward flush failed", error);
logger.error("Activity reward flush failed", error, {
event: "activity_reward_flush_failed"
});
}
}, 60 * 1000);
}
@ -1492,7 +1494,9 @@ function flushActivityRewards(db) {
"DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?"
).run(group.userId, group.hourStart);
} catch (error) {
logger.error("Failed to apply queued activity reward", error);
logger.error("Failed to apply queued activity reward", error, {
event: "activity_reward_apply_failed"
});
}
}
}

View File

@ -2,7 +2,7 @@ const fs = require("fs");
const path = require("path");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("expression-interaction");
const logger = createLogger("plugin:expression-interaction", { category: "plugin" });
const DEFAULT_ACTIONS = [
{ id: "hug", verb: "hugs", past: "hugged" },
@ -675,7 +675,9 @@ function writeCommandsManifest(config) {
const target = path.join(pluginMeta.dir, "cmds.json");
fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8");
} catch (error) {
logger.error("Failed to write expression command manifest", error);
logger.error("Failed to write expression command manifest", error, {
event: "command_manifest_write_failed"
});
}
}

View File

@ -65,7 +65,11 @@ module.exports = {
ensureDataDirs();
if (!repoIndexer.loadIndex()) {
try { repoIndexer.refreshIndex(); }
catch (error) { logger.warn("Lumi AI repository index initialization failed", error); }
catch (error) {
logger.warn("Lumi AI repository index initialization failed", error, {
event: "repository_index_initialization_failed"
});
}
}
let config = getConfig();
metrics.configureRetention(config.work_history_retention);
@ -376,7 +380,11 @@ module.exports = {
sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3),
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
].filter((check) => !check.valid);
for (const diagnostic of sizeDiagnostics) logger.warn(`Lumi AI size diagnostic: ${diagnostic.message}`);
for (const diagnostic of sizeDiagnostics) {
logger.warn("Lumi AI size diagnostic reported an invalid value", {
message: diagnostic.message
}, { event: "size_diagnostic_invalid" });
}
const models = modelManifest.models.map((model) => ({
...model,
downloaded: fs.existsSync(resolveData("models", model.filename)),
@ -1653,7 +1661,11 @@ module.exports = {
locals: { endpoint: `/plugins/${PLUGIN_ID}` }
});
} else {
logger.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible.");
logger.warn(
"Lumi AI assistant panel hook is unavailable; settings remain accessible",
null,
{ event: "assistant_panel_hook_unavailable" }
);
}
ensureSidebarNavItem(settings);
registerAssistantCommands({
@ -1668,17 +1680,25 @@ module.exports = {
});
writeCommandsManifest(plugin?.dir || __dirname, config);
setImmediate(() => toolManager.loadEnabled().catch((error) =>
logger.error("Lumi AI tool loader failed", error)
logger.error("Lumi AI tool loader failed", error, {
event: "tool_loader_failed"
})
));
if (config.enabled) {
setImmediate(() => ensureGateRuntime().catch((error) =>
logger.error("Lumi AI gate runtime start failed", error)
logger.error("Lumi AI gate runtime start failed", error, {
event: "gate_runtime_start_failed"
})
));
}
const state = getRuntimeState();
if (shouldAutoResume(config, state)) {
setImmediate(() => startRuntimes({ resume: true }).catch((error) => logger.error("Lumi AI runtime resume failed", error)));
setImmediate(() => startRuntimes({ resume: true }).catch((error) =>
logger.error("Lumi AI runtime resume failed", error, {
event: "runtime_resume_failed"
})
));
}
return async () => {
@ -1806,8 +1826,8 @@ function logImprovementActionError(req, error) {
action: cleanText(req.body?.action, 30),
user_id: cleanText(req.session?.user?.id, 100),
code: cleanText(error?.code, 40),
message: cleanText(error?.message || error, 500)
});
error
}, { event: "feedback_action_failed" });
}
function parseIdList(value) {
return [...new Set(String(value || "")

View File

@ -158,6 +158,12 @@ class CompanionGateway {
service.updateObs(device.id, message.payload || {});
break;
}
case "stream_test_caption_status": {
const service = global.lumiFrameworks?.streamTesting;
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
service.updateCaptionStatus(device.id, message.payload || {});
break;
}
case "stream_test_caption": {
const service = global.lumiFrameworks?.streamTesting;
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");

View File

@ -6,7 +6,7 @@ const AUDIO_HEADER_BYTES = 64;
const MAX_JSON_BYTES = 64 * 1024;
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
const CLIENT_TYPES = new Set(["hello", "ping", "source_update", "obs_state", "readiness", "start", "stop", "ack",
"stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]);
"stream_test_create", "stream_test_obs_metrics", "stream_test_caption_status", "stream_test_caption", "stream_test_stop"]);
function parseEnvelope(input) {
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");

View File

@ -42,14 +42,27 @@ class JsonlDiagnosticLog {
function sanitize(value, includeCaptionText) {
if (Buffer.isBuffer(value)) return "[binary omitted]";
if (Array.isArray(value)) return value.map((entry) => sanitize(entry, includeCaptionText));
if (typeof value === "string") return sanitizeText(value);
if (!value || typeof value !== "object") return value;
const result = {};
for (const [key, child] of Object.entries(value)) {
if (/audio|pcm|credential|secret|token/i.test(key)) result[key] = "[redacted]";
if (isSensitiveKey(key)) result[key] = "[redacted]";
else if (!includeCaptionText && /(?:stable|uncertain|caption)_text/i.test(key)) result[key] = "[caption text disabled]";
else result[key] = sanitize(child, includeCaptionText);
}
return result;
}
module.exports = { JsonlDiagnosticLog, sanitize };
function isSensitiveKey(key) {
return /(?:audio|pcm|credential|(?:^|[_-])(?:authorization|cookie|password|passwd|secret|signature|token|api[_-]?key|private[_-]?key)(?:$|[_-]))/i.test(String(key));
}
function sanitizeText(value) {
return String(value || "")
.replace(/\b(Bearer|Basic|LumiDevice)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [redacted]")
.replace(/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, "$1[redacted]")
.replace(/([?&](?:token|key|secret|password|authorization)=)[^&#\s]+/gi, "$1[redacted]")
.replace(/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|signature|authorization)\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]");
}
module.exports = { JsonlDiagnosticLog, sanitize, sanitizeText };

View File

@ -76,7 +76,13 @@ class SessionCoordinator {
async start(sessionId, options = {}) {
const session = this.require(sessionId);
if (session.state !== "idle") throw Object.assign(new Error("Another transcription activity is still finishing. Try again in a moment."), { code: "SESSION_BUSY" });
const mode = options.mode === "test" ? "test" : options.mode === "benchmark" ? "benchmark" : "live";
const mode = options.mode === "test"
? "test"
: options.mode === "benchmark"
? "benchmark"
: options.mode === "stream_test"
? "stream_test"
: "live";
if (mode === "live" && !session.obs.streaming) throw Object.assign(new Error("Live transcription requires an active OBS stream."), { code: "OBS_NOT_STREAMING" });
const tracks = Array.from(session.tracks.values()).filter((track) => track.enabled && !track.source_missing);
if (!tracks.length) throw Object.assign(new Error("Select an available OBS audio source first."), { code: "NO_TRACKS" });

View File

@ -352,7 +352,8 @@ async function verifySessionLifecycle() {
}
const provider = new Provider();
const delivered = [];
const deliveryFactory = () => ({ start: async () => {}, stop: async () => {}, pause: async () => {}, resume: async () => {}, deliver: async (event) => { delivered.push(event); return { disposition: "simulated" }; } });
const deliveryModes = [];
const deliveryFactory = () => ({ start: async (options) => { deliveryModes.push(options); }, stop: async () => {}, pause: async () => {}, resume: async () => {}, deliver: async (event) => { delivered.push(event); return { disposition: "simulated" }; } });
const coordinator = new SessionCoordinator({ provider, deliveryFactory, graceMs: 25 });
const { session } = coordinator.create({ id: "device" }, () => {});
const primary = crypto.randomUUID();
@ -370,6 +371,13 @@ async function verifySessionLifecycle() {
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(coordinator.status(session.id).state, "idle");
assert.equal(provider.stops, 1);
await coordinator.start(session.id, { mode: "stream_test" });
assert.equal(coordinator.status(session.id).mode, "stream_test");
assert.equal(deliveryModes.at(-1).testMode, true, "private Stream Testing must never use normal live caption delivery");
await coordinator.updateObsState(session.id, { streaming: true, auto_start: true });
assert.equal(coordinator.status(session.id).mode, "stream_test", "OBS startup must not replace the private caption session");
assert.equal((await coordinator.audio(session.id, { ...frame, sequence: 2 })).accepted, true);
await coordinator.stop(session.id, "stream_test_ended");
await coordinator.close();
}
@ -524,7 +532,18 @@ function verifyArtifactsAndLogs(temp) {
manager.installZip(runtimeEntry, runtimeZip);
manager.installZip(runtimeEntry, runtimeZip);
assert.equal(fs.readFileSync(path.join(runtimeRoot, runtimeEntry.id, "bin", "lumi-whisper-worker.exe"), "utf8"), "portable-worker");
assert.equal(sanitize({ pcm: Buffer.alloc(10), device_secret: "secret", stable_text: "hello" }, false).pcm, "[redacted]");
const sanitized = sanitize({
pcm: Buffer.alloc(10),
device_secret: "secret",
webhook_signature: "private-signature",
message: "LumiDevice device-id.private-device-secret Authorization=private-value Cookie: session=private-cookie",
stable_text: "hello"
}, false);
assert.equal(sanitized.pcm, "[redacted]");
assert.equal(sanitized.webhook_signature, "[redacted]");
assert.equal(sanitized.message.includes("private-value"), false);
assert.equal(sanitized.message.includes("private-cookie"), false);
assert.equal(sanitized.message.includes("private-device-secret"), false);
const logsRoot = path.join(temp, "logs");
const logs = new JsonlDiagnosticLog(logsRoot, { retentionDays: 1, maxBytes: 100 });
logs.append({ kind: "caption", stable_text: "hello", pcm: Buffer.alloc(10) });

View File

@ -1,6 +1,7 @@
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { createLogger } = require("../../src/services/logger");
let overlayService = null;
let encryptSecret = null;
let decryptSecret = null;
@ -60,7 +61,7 @@ const DEFAULTS = Object.freeze({
const renderClients = new Set();
const announcementTimers = new Map();
let pluginLogger = console;
let pluginLogger = createLogger("plugin:now_playing", { category: "plugin" });
let activeDb = null;
let activeSettings = null;
let activeClients = null;
@ -80,7 +81,7 @@ module.exports = {
activeClients = { twitchClient, youtubeClient };
placeholderApi = placeholders;
webApi = web;
pluginLogger = logger || console;
pluginLogger = logger || pluginLogger;
fs.mkdirSync(COVER_DIR, { recursive: true });
ensureTables(db);
@ -244,7 +245,9 @@ module.exports = {
if (changedTrack && next.playback_status === "playing") scheduleAnnouncement(db, next.track_key);
return res.json({ accepted: true, server_time: Date.now(), sequence: next.sequence });
} catch (error) {
pluginLogger.warn?.("Rejected Companion song update", { error: error?.message || String(error) });
pluginLogger.warn("Rejected Companion song update", error, {
event: "companion_song_update_rejected"
});
return res.status(error?.status || 400).json({ accepted: false, error: error?.message || "Invalid song update." });
}
});
@ -840,7 +843,9 @@ function scheduleAnnouncement(db, trackKey) {
const current = getStateRow(db);
if (!current || current.track_key !== trackKey || current.playback_status !== "playing") return;
announceTrack({ db, state: current }).catch((error) => {
pluginLogger.warn?.("Song Overlay chat announcement failed", { error: error?.message || String(error) });
pluginLogger.warn("Song Overlay chat announcement failed", error, {
event: "chat_announcement_failed"
});
});
}, 1800);
timer.unref?.();

View File

@ -2,11 +2,12 @@ const crypto = require("crypto");
const express = require("express");
const path = require("path");
const discord = require("discord.js");
const { log } = require("../../src/services/logger");
const { createLogger } = require("../../src/services/logger");
const { getPlatformStatus } = require("../../src/services/platforms");
const placeholderService = require("../../src/services/placeholders");
const PLUGIN_ID = "throne_wishlist";
let pluginLogger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" });
const NAMESPACE = "throne";
const REPLAY_WINDOW_SECONDS = 300;
const DEBUG_IDLE_MS = 300000;
@ -74,8 +75,10 @@ module.exports = {
settings,
discordClient,
twitchClient,
youtubeClient
youtubeClient,
logger
}) {
pluginLogger = logger || pluginLogger;
ensureTables(db);
ensureDefaults(db);
registerPlaceholderSupport(placeholders);
@ -446,17 +449,19 @@ function humanizePlaceholderLabel(value) {
function registerStoredEndpoints({ db, webhookApi, clients }) {
if (!webhookApi) {
log("warn", "Throne Wishlist webhook framework unavailable");
pluginLogger.warn("Throne Wishlist webhook framework unavailable", null, {
event: "webhook_framework_unavailable"
});
return;
}
for (const endpoint of listEndpoints(db).filter((entry) => entry.enabled)) {
try {
registerEndpoint({ db, webhookApi, endpoint, clients });
} catch (error) {
log("error", "Throne endpoint registration failed", {
endpointId: endpoint.id,
message: error?.message || String(error)
});
pluginLogger.error("Throne endpoint registration failed", {
endpoint_id: endpoint.id,
error
}, { event: "endpoint_registration_failed" });
}
}
}
@ -555,7 +560,7 @@ async function handleIncoming({
"SELECT event_id FROM throne_event_deliveries WHERE event_id = ?"
).get(eventId);
if (existing) {
log("info", `Webhook - Throne - ${EVENT_TYPES.includes(eventType) ? eventType : "unknown"}`, JSON.stringify({
pluginLogger.info("Duplicate Throne webhook ignored", {
provider: "throne",
authentic: true,
authenticity_status: "duplicate",
@ -564,7 +569,7 @@ async function handleIncoming({
event_id: eventId,
event_type: eventType,
received_at: new Date(context.receivedAt).toISOString()
}));
}, { event: "webhook_duplicate_ignored" });
return { status: 204 };
}
db.prepare(
@ -581,10 +586,10 @@ async function handleIncoming({
receivedAt: context.receivedAt,
clients
}).catch((error) => {
log("error", "Throne platform delivery failed", {
eventId,
message: error?.message || String(error)
});
pluginLogger.error("Throne platform delivery failed", {
event_id: eventId,
error
}, { event: "platform_delivery_failed" });
});
});
return { status: 202, body: { accepted: true } };
@ -669,11 +674,11 @@ async function sendPlatformMessages({ db, payload, endpoint, receivedAt, clients
eventId
);
for (const failure of failed) {
log("warn", "Throne destination send failed", {
eventId,
pluginLogger.warn("Throne destination send failed", {
event_id: eventId,
platform: failure.platform,
message: failure.error
});
}, { event: "destination_send_failed" });
}
}
@ -815,7 +820,7 @@ function writePayloadLog({
: EVENT_TYPES.includes(eventType)
? eventType
: "unknown";
log("info", `Webhook - Throne - ${logEventType}`, JSON.stringify({
pluginLogger.info("Throne webhook received", {
provider: "throne",
authentic: verification.authentic,
authenticity_status: authenticityStatus,
@ -824,10 +829,10 @@ function writePayloadLog({
event_id: eventId,
event_type: eventType,
received_at: new Date(context.receivedAt).toISOString(),
payload: payload || null,
raw_body_preview: context.jsonError ? context.rawBodyText.slice(0, 4000) : null,
signature_prefix: (headerValue(context.headers, "x-signature-ed25519") || "").slice(0, 12)
}));
payload_present: Boolean(payload),
payload_key_count: payload && typeof payload === "object" ? Object.keys(payload).length : 0,
raw_body_bytes: Buffer.byteLength(context.rawBody || "")
}, { event: `webhook_${logEventType}` });
}
function buildPayloadPreview({ receivedAt, eventType, eventId, authentic, status }) {

View File

@ -1,11 +1,12 @@
const crypto = require("crypto");
const path = require("path");
const discord = require("discord.js");
const { Permissions } = discord;
const { ensureUserForIdentity } = require("../../src/services/users");
const { log } = require("../../src/services/logger");
const PLUGIN_ID = "welcome_messages";
const discord = require("discord.js");
const { Permissions } = discord;
const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const PLUGIN_ID = "welcome_messages";
let pluginLogger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" });
const CONFIG_KEY = "config";
const ALLOWED_PLACEHOLDERS = new Set([
"username",
@ -44,10 +45,13 @@ const PRONOUN_SETS = [
module.exports = {
id: PLUGIN_ID,
init({ web, db, discordClient, settings }) {
ensureTables(db);
ensureConfig(db);
log("info", "Welcome Messages plugin initialized");
init({ web, db, discordClient, settings, logger }) {
pluginLogger = logger || pluginLogger;
ensureTables(db);
ensureConfig(db);
pluginLogger.info("Welcome Messages plugin initialized", null, {
event: "plugin_initialized"
});
const router = web.createRouter();
@ -382,40 +386,42 @@ function ensureSidebarNavItem(settings) {
function attachDiscordListener(discordClient, db) {
if (!discordClient || discordClient[LISTENER_KEY]) {
if (!discordClient) {
log("warn", "Welcome Messages listener not attached", {
reason: "Discord client is unavailable"
});
pluginLogger.warn("Welcome Messages listener not attached", {
reason: "Discord client is unavailable"
}, { event: "listener_not_attached" });
}
return;
}
discordClient[LISTENER_KEY] = true;
discordClient.on("guildMemberAdd", (member) => {
handleMemberJoin(member, db).catch((error) => {
log("error", "Welcome Messages join handler failed", error);
pluginLogger.error("Welcome Messages join handler failed", error, {
event: "join_handler_failed"
});
});
});
log("info", "Welcome Messages listener attached", {
event: "guildMemberAdd",
memberIntent: detectGuildMembersIntent(discordClient)
});
pluginLogger.info("Welcome Messages listener attached", {
discord_event: "guildMemberAdd",
member_intent: detectGuildMembersIntent(discordClient)
}, { event: "listener_attached" });
}
async function handleMemberJoin(member, db) {
const config = getConfig(db);
if (!config.enabled || !config.channelId) {
log("debug", "Welcome Messages skipped join", {
reason: !config.enabled ? "Plugin posting disabled" : "No welcome channel configured",
guildId: member?.guild?.id || null,
userId: member?.user?.id || member?.id || null
});
pluginLogger.debug("Welcome Messages skipped join", {
reason: !config.enabled ? "Plugin posting disabled" : "No welcome channel configured",
guild_id: member?.guild?.id || null,
user_id: member?.user?.id || member?.id || null
}, { event: "join_skipped" });
return;
}
const discordId = member?.user?.id || member?.id;
if (!discordId) {
log("warn", "Welcome Messages skipped join", {
reason: "Missing Discord user id",
guildId: member?.guild?.id || null
});
pluginLogger.warn("Welcome Messages skipped join", {
reason: "Missing Discord user id",
guild_id: member?.guild?.id || null
}, { event: "join_skipped" });
return;
}
const existingIdentity = db
@ -427,12 +433,12 @@ async function handleMemberJoin(member, db) {
const profile = ensureDiscordIdentity(member);
const channelResult = await validateTextChannel(member.client, config.channelId);
if (!channelResult.valid || !channelResult.channel) {
log("warn", "Welcome Messages skipped join", {
reason: channelResult.message,
guildId: member?.guild?.id || null,
userId: discordId,
channelId: config.channelId
});
pluginLogger.warn("Welcome Messages skipped join", {
reason: channelResult.message,
guild_id: member?.guild?.id || null,
user_id: discordId,
channel_id: config.channelId
}, { event: "join_skipped" });
return;
}
const pool =
@ -441,11 +447,11 @@ async function handleMemberJoin(member, db) {
: config.welcomeMessages;
const message = chooseMessage(pool) || chooseMessage(config.welcomeMessages);
if (!message) {
log("warn", "Welcome Messages skipped join", {
reason: "No enabled message templates",
guildId: member?.guild?.id || null,
userId: discordId
});
pluginLogger.warn("Welcome Messages skipped join", {
reason: "No enabled message templates",
guild_id: member?.guild?.id || null,
user_id: discordId
}, { event: "join_skipped" });
return;
}
const pronoun = getSubjectPronoun(db, profile?.id);
@ -455,21 +461,21 @@ async function handleMemberJoin(member, db) {
content: rendered,
allowedMentions: { parse: [] }
});
log("info", "Welcome Messages sent welcome message", {
guildId: member?.guild?.id || null,
userId: discordId,
channelId: config.channelId,
returning,
pool: returning && config.welcomeBackEnabled ? "welcomeBackMessages" : "welcomeMessages",
messageId: message.id
});
} catch (error) {
log("error", "Welcome Messages failed to send message", {
guildId: member?.guild?.id || null,
userId: discordId,
channelId: config.channelId,
error: error?.stack || error?.message || String(error)
});
pluginLogger.info("Welcome Messages sent welcome message", {
guild_id: member?.guild?.id || null,
user_id: discordId,
channel_id: config.channelId,
returning,
pool: returning && config.welcomeBackEnabled ? "welcomeBackMessages" : "welcomeMessages",
message_id: message.id
}, { event: "welcome_message_sent" });
} catch (error) {
pluginLogger.error("Welcome Messages failed to send message", {
guild_id: member?.guild?.id || null,
user_id: discordId,
channel_id: config.channelId,
error
}, { event: "welcome_message_send_failed" });
}
}
@ -638,21 +644,23 @@ async function buildDiagnostics(discordClient, channelId) {
function logStartupDiagnostics(discordClient) {
const memberIntent = detectGuildMembersIntent(discordClient);
const details = {
discordClientAvailable: Boolean(discordClient),
discordClientReady: Boolean(discordClient?.readyAt),
memberIntent
};
if (memberIntent === "configured") {
log("info", "Welcome Messages diagnostics passed", details);
return;
}
log("warn", "Welcome Messages diagnostics warning", {
...details,
message:
"Guild member join events require the runtime Discord client to start with GuildMembers/GUILD_MEMBERS and the Discord Developer Portal Server Members Intent enabled."
});
}
const details = {
discord_client_available: Boolean(discordClient),
discord_client_ready: Boolean(discordClient?.readyAt),
member_intent: memberIntent
};
if (memberIntent === "configured") {
pluginLogger.info("Welcome Messages diagnostics passed", details, {
event: "startup_diagnostics_passed"
});
return;
}
pluginLogger.warn("Welcome Messages diagnostics warning", {
...details,
message:
"Guild member join events require the runtime Discord client to start with GuildMembers/GUILD_MEMBERS and the Discord Developer Portal Server Members Intent enabled."
}, { event: "startup_diagnostics_warning" });
}
function detectGuildMembersIntent(discordClient) {
if (!discordClient) {

View File

@ -150,6 +150,8 @@ async function main() {
assert(updateSource.includes("SameOrigin"));
assert(updateSource.includes("SupportsPendingBuild"));
assert(updateSource.includes("BuildPending"));
const runtimeSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "CompanionRuntime.cs"), "utf8");
assert(runtimeSource.includes("UpdateCheckTimeout = TimeSpan.FromMinutes(11)"));
assert(transcriptionSource.includes("supports_pending_build"));
assert(transcriptionSource.includes("build_pending"));
const applierSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "UpdateApplier.cs"), "utf8");

View File

@ -33,6 +33,9 @@ try {
pluginLog.warn("Example warning", {
access_token: "must-not-be-stored",
nested: { password: "also-secret", safe: "visible" },
webhook_signature: "signature-must-not-be-stored",
cookie_header: "Cookie: session=also-hidden",
device_header: "LumiDevice device-id.device-secret",
url: "https://example.com/run?token=hidden&mode=safe"
}, { event: "example_warning", requestId: "request-123" });
logger.withLogContext({ source: "core:test", category: "verification", event: "context_entry" }, () => {
@ -48,6 +51,9 @@ try {
assert(warning.details.includes("[REDACTED]"));
assert.equal(warning.details.includes("must-not-be-stored"), false);
assert.equal(warning.details.includes("also-secret"), false);
assert.equal(warning.details.includes("signature-must-not-be-stored"), false);
assert.equal(warning.details.includes("also-hidden"), false);
assert.equal(warning.details.includes("device-secret"), false);
assert.equal(warning.details.includes("token=hidden"), false);
assert(warning.details.includes("visible"));
@ -78,8 +84,163 @@ try {
assert.match(serverSource, /summarizeLogs\(query\)/);
assert.match(serverSource, /"admin_action"/);
console.log("Logging verification passed: structured scope, redaction, search, summaries, retention, admin audit, and live UI wiring.");
verifyRuntimeLoggingConventions();
console.log("Logging verification passed: structured scope, redaction, search, summaries, retention, runtime conventions, admin audit, and live UI wiring.");
} finally {
try { database?.db?.close(); } catch {}
fs.rmSync(sandbox, { recursive: true, force: true });
}
function verifyRuntimeLoggingConventions() {
const files = [
...runtimeJavaScriptFiles(path.join(root, "src")),
...runtimeJavaScriptFiles(path.join(root, "plugins"))
];
const directConsoleExceptions = new Set([
path.join(root, "src", "services", "logger.js")
]);
const sourcePattern = /createLogger\(\s*["']([^"']+)["']/g;
const loggerCallPattern =
/\b(?:[A-Za-z_$][\w$]*(?:Log|Logger)|logger)\.(?:log|debug|info|warn|error)(?:\?\.)?\s*\(/g;
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
if (!directConsoleExceptions.has(file)) {
assert.doesNotMatch(
source,
/\bconsole\.(?:debug|info|log|warn|error)\s*\(/,
`${path.relative(root, file)} must use a named operational logger`
);
}
assert.doesNotMatch(
source,
/\{\s*log\s*\}\s*=\s*require\(["'][^"']*services\/logger["']\)/,
`${path.relative(root, file)} must not use the legacy global log function`
);
for (const match of source.matchAll(sourcePattern)) {
assert.match(
match[1],
/^(?:companion|core|platform|plugin):[a-z0-9_.:-]+$/,
`${path.relative(root, file)} has an unscoped logger source: ${match[1]}`
);
}
for (const match of source.matchAll(loggerCallPattern)) {
const openIndex = source.indexOf("(", match.index);
const closeIndex = matchingParenthesis(source, openIndex);
assert(closeIndex > openIndex, `${path.relative(root, file)} contains an unreadable logger call`);
const call = source.slice(match.index, closeIndex + 1);
assert.match(
call,
/\bevent\s*:/,
`${path.relative(root, file)} operational logger call is missing a stable event ID: ${call.split(/\r?\n/, 1)[0]}`
);
const eventLiteral = /\bevent\s*:\s*["'`]([^"'`]+)["'`]/.exec(call)?.[1];
if (eventLiteral && !eventLiteral.includes("${")) {
assert.match(
eventLiteral,
/^[a-z0-9]+(?:_[a-z0-9]+)*$/,
`${path.relative(root, file)} has a non-standard event ID: ${eventLiteral}`
);
}
}
}
const companionRuntime = fs.readFileSync(
path.join(root, "companion", "src", "Lumi.Companion.App", "CompanionRuntime.cs"),
"utf8"
);
assert.match(companionRuntime, /source = "companion:core"/);
assert.match(companionRuntime, /@event = eventId/);
assert.match(companionRuntime, /CompanionLogSanitizer\.Sanitize\(message\)/);
assert.match(companionRuntime, /PruneLogs\(\)/);
const songRuntime = fs.readFileSync(
path.join(root, "companion", "plugins", "Lumi.Companion.SongOverlay", "SongOverlayRuntime.cs"),
"utf8"
);
assert.match(songRuntime, /source = \$"plugin:\{PluginId\}"/);
assert.match(songRuntime, /CompanionLogSanitizer\.Sanitize\(error\.ToString\(\)\)/);
assert.match(songRuntime, /song-overlay-\{DateTime\.UtcNow:yyyy-MM-dd\}\.jsonl/);
assert.doesNotMatch(songRuntime, /song-overlay-\{DateTime\.UtcNow:yyyyMMdd\}\.log/);
const obsBridge = fs.readFileSync(
path.join(root, "companion", "native", "obs-bridge", "src", "plugin.cpp"),
"utf8"
);
const nativeLogCalls = [...obsBridge.matchAll(/\bblog\([\s\S]*?\);/g)].map((match) => match[0]);
assert(nativeLogCalls.length > 0, "the OBS Bridge logging boundary must remain covered");
for (const call of nativeLogCalls) {
assert.match(call, /\[Lumi Companion\] event=[a-z0-9]+(?:_[a-z0-9]+)*/);
}
}
function runtimeJavaScriptFiles(start) {
const skippedDirectories = new Set(["bin", "node_modules", "obj", "public", "scripts", "tests"]);
const files = [];
for (const entry of fs.readdirSync(start, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue;
const target = path.join(start, entry.name);
if (entry.isDirectory()) {
if (!skippedDirectories.has(entry.name)) files.push(...runtimeJavaScriptFiles(target));
} else if (entry.isFile() && entry.name.endsWith(".js")) {
files.push(target);
}
}
return files;
}
function matchingParenthesis(source, openIndex) {
let depth = 0;
let quote = "";
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let index = openIndex; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (lineComment) {
if (character === "\n") lineComment = false;
continue;
}
if (blockComment) {
if (character === "*" && next === "/") {
blockComment = false;
index += 1;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === quote) {
quote = "";
}
continue;
}
if (character === "/" && next === "/") {
lineComment = true;
index += 1;
continue;
}
if (character === "/" && next === "*") {
blockComment = true;
index += 1;
continue;
}
if (character === "'" || character === "\"" || character === "`") {
quote = character;
continue;
}
if (character === "(") depth += 1;
if (character === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}

View File

@ -30,6 +30,10 @@ const {
sourceFor,
validateHostname
} = require("../src/services/stream-testing");
const {
StreamTestCertificateManager,
normalizeCertificateHostname
} = require("../src/services/stream-test-certificates");
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
class FakeRuntime extends EventEmitter {
@ -186,11 +190,13 @@ async function verifyStreamService() {
const old = {
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
insecure: process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
key: process.env.LUMI_STREAM_TEST_TLS_KEY
};
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
process.env.LUMI_STREAM_TEST_INGEST_HOST = "public.example.test";
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps";
process.env.LUMI_STREAM_TEST_TLS_CERT = path.join(os.tmpdir(), "missing-local-cert.pem");
process.env.LUMI_STREAM_TEST_TLS_KEY = path.join(os.tmpdir(), "missing-local-key.pem");
const runtime = new FakeRuntime();
const service = new StreamTestingService({ runtime, timer: false });
const sent = [];
@ -201,6 +207,7 @@ async function verifyStreamService() {
(type, payload) => sent.push({ type, payload })
);
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/);
assert.equal(runtime.session.transport, "rtmp", "localhost pairing must ignore production RTMPS overrides");
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}`);
@ -223,13 +230,88 @@ async function verifyStreamService() {
total_frames: 10000,
congestion: 0
});
service.addCaption("device-1", { session_id: created.id, text: "Private caption", delay_ms: 120 });
service.updateCaptionStatus("device-1", {
session_id: created.id,
state: "ready",
detail: "Speech recognition is ready and listening."
});
assert.equal(service.publicStatus().session.metrics.captions.state, "ready");
const captionId = "4adf31ce-a9de-4e91-9797-0e8c25b6f133";
service.addCaption("device-1", {
session_id: created.id,
caption_id: captionId,
revision: 1,
final: false,
text: "Private",
start_seconds: 1,
end_seconds: 4.5,
delay_ms: 120
});
service.addCaption("device-1", {
session_id: created.id,
caption_id: captionId,
revision: 2,
final: false,
text: "Private caption",
stable_text: "Private",
uncertain_text: "caption",
start_seconds: 1,
end_seconds: 5.5,
delay_ms: 125
});
service.addCaption("device-1", {
session_id: created.id,
caption_id: captionId,
revision: 1,
final: false,
text: "Stale revision",
start_seconds: 1,
end_seconds: 20,
delay_ms: 125
});
service.addCaption("device-1", {
session_id: created.id,
caption_id: captionId,
revision: 3,
final: true,
text: "Private caption finalized",
stable_text: "Private caption finalized",
uncertain_text: "",
start_seconds: 1,
end_seconds: 8,
delay_ms: 130
});
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/);
assert.match(service.captionFile(created.id), /Private caption finalized/);
assert.deepStrictEqual(service.publicStatus().session.caption_cues.map((cue) => cue.text), ["Private caption finalized"]);
assert.equal(service.publicStatus().session.caption_cues[0].stable_text, "Private caption finalized");
assert(Number.isFinite(service.publicStatus().session.caption_cues[0].expires_at));
assert.equal(service.publicStatus().session.metrics.captions.revisions, 3, "stale progressive revisions must not inflate delivery metrics");
assert.equal(service.publicStatus().session.metrics.captions.delivered, 1);
service.addCaption("device-1", {
session_id: created.id,
caption_id: "56fa22c9-1f39-4e17-a217-2dfa38793547",
revision: 1,
final: false,
text: "Next utterance",
start_seconds: 6,
end_seconds: 10,
delay_ms: 110
});
const progressiveCues = service.publicStatus().session.caption_cues;
assert.deepStrictEqual(progressiveCues.map((cue) => cue.text), ["Private caption finalized", "Next utterance"]);
assert.equal(progressiveCues[0].end, progressiveCues[1].start, "a new utterance must retire the previous caption instead of stacking");
service.updateCaptionStatus("device-1", {
session_id: created.id,
state: "disabled",
detail: "Captions are turned off in Lumi Companion."
});
assert.equal(service.publicStatus().session.metrics.captions.state, "disabled");
assert.deepStrictEqual(service.publicStatus().session.caption_cues, [], "turning captions off must immediately clear private-player cues");
const endedSession = service.active;
await service.stop({ session_id: created.id, reason: "Verification complete." });
assert.equal(runtime.cleared, 1);
@ -270,7 +352,8 @@ async function verifyStreamService() {
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;
if (old.cert === undefined) delete process.env.LUMI_STREAM_TEST_TLS_CERT; else process.env.LUMI_STREAM_TEST_TLS_CERT = old.cert;
if (old.key === undefined) delete process.env.LUMI_STREAM_TEST_TLS_KEY; else process.env.LUMI_STREAM_TEST_TLS_KEY = old.key;
}
}
@ -280,6 +363,8 @@ async function main() {
assert.equal(validateHostname("stream.example.com"), "stream.example.com");
assert.equal(validateHostname("https://stream.example.com"), "");
assert.equal(validateHostname("[::1]"), "[::1]");
assert.equal(normalizeCertificateHostname("Stream.Example.com."), "stream.example.com");
assert.equal(normalizeCertificateHostname("127.0.0.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(
@ -314,17 +399,20 @@ async function main() {
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
key: process.env.LUMI_STREAM_TEST_TLS_KEY
};
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 forcedSecure = await resolveIngestConfiguration(
{ pairing_host: "https://stream.example.test" },
{ certificateManager: { resolve: async () => ({ certificate: "managed-cert.pem", privateKey: "managed-key.pem" }) } }
);
assert.equal(forcedSecure.transport, "rtmps", "a stale RTMP override must not weaken production transport");
assert.equal(forcedSecure.tlsCert, "managed-cert.pem");
const local = await resolveIngestConfiguration({ pairing_host: "http://localhost:3000" });
assert.equal(local.transport, "rtmp");
assert.equal(local.host, "localhost");
const cert = path.join(tempRoot, "cert.pem");
const key = path.join(tempRoot, "key.pem");
fs.writeFileSync(cert, "test certificate");
@ -335,19 +423,22 @@ async function main() {
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" });
assert.equal(secure.transport, "rtmps");
assert.equal(secure.host, "lumi.example.test");
delete process.env.LUMI_STREAM_TEST_TLS_CERT;
delete process.env.LUMI_STREAM_TEST_TLS_KEY;
await verifyManagedCertificateProvisioning(tempRoot);
} 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
LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key
})) {
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"]) {
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption_status", "stream_test_caption", "stream_test_stop"]) {
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
assert.strictEqual(parsed.type, type);
}
@ -362,14 +453,27 @@ async function main() {
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 companionSettings = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionSettingsStore.cs"), "utf8");
const companionWindow = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8");
const companionWindowCode = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
const companionStyles = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/App.axaml"), "utf8");
const certificates = fs.readFileSync(path.join(root, "src/services/stream-test-certificates.js"), "utf8");
const transcriptionContribution = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/TranscriptionPluginContribution.cs"), "utf8");
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
const webPlayer = fs.readFileSync(path.join(root, "src/web/public/stream-testing.js"), "utf8");
const webPlayerCss = fs.readFileSync(path.join(root, "src/web/public/stream-testing.css"), "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.match(service, /updateCaptionStatus/);
assert.match(service, /localDevelopment[\s\S]*transport: "rtmp"/);
assert.match(service, /const transport = "rtmps"/);
assert.match(service, /streamTestCertificateManager/);
assert.match(certificates, /challengePriority: \["http-01"\]/);
assert.match(server, /\.well-known\/acme-challenge\/:token/);
assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
assert(!/shell:\s*true/.test(runtime));
assert.match(gateway, /await service\.create/);
@ -383,11 +487,54 @@ async function main() {
assert.match(companion, /StreamTestRecoveryStore/);
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
assert.match(companion, /StreamTestObsRestartRequired/);
assert.match(companion, /mode = "stream_test"/);
assert.match(companion, /RunReconnectLoopAsync/);
assert.match(companion, /background:\s*true/);
assert.match(companion, /Math\.Min\(60,\s*attempt/);
assert.match(companion, /TranscriptionEnabled && _settings\.Current\.StartWithObs/);
assert.match(companion, /SetTranscriptionEnabledAsync/);
assert.match(companion, /ReportStreamTestCaptionStatusAsync\([^\n]+"disabled"/);
assert.match(companionSettings, /bool TranscriptionEnabled = true/);
assert.match(companionWindow, /TranscriptionEnabledToggle/);
assert.match(companionWindow, /Generate and include captions/);
assert.match(companionWindow, /OpenStreamTestWebButton/);
assert.match(companionWindowCode, /OpenStreamTestingWebUi/);
assert.match(companionWindowCode, /IsVisible = false/);
assert.match(companionWindowCode, /SetPluginNavigationExpanded/);
assert.match(companionStyles, /Button\.navRoot/);
assert.doesNotMatch(companionStyles, /Expander\.pluginRoot/);
assert.match(transcriptionContribution, /Turn captions off/);
assert.match(transcriptionContribution, /Turn captions on/);
assert.match(companion, /StopStreamTestTranscriptionAsync/);
assert.match(companion, /_streamTestTranscriptionRunning \|\| \(State\.BenchmarkRunning/);
assert.match(companion, /caption_id = captionId/);
assert.match(companion, /final \? Math\.Clamp\(2\.5 \+ text\.Length \/ 18\.0, 4, 8\) : 3\.5/);
assert.match(companion, /display_seconds = displaySeconds/);
assert.doesNotMatch(companion, /_streamTestTranscriptionRunning && final &&/);
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, /data-stream-player[^>]*autoplay[^>]*muted/);
assert.match(webUi, /data-stream-caption-overlay/);
assert.match(webUi, /layout-bottom[\s\S]*admin\/stream-testing\/hls\.js[\s\S]*stream-testing\.js/);
assert.match(webPlayer, /async function attemptAutoplay/);
assert.match(webPlayer, /track\.addCue\(textCue\)/);
assert.match(webPlayer, /cues\.at\(-1\)\?\.revision/);
assert.match(webPlayer, /captionCueState\.get\(id\)/);
assert.match(webPlayer, /textCue\.text = "\\u200B"/);
assert.match(webPlayer, /if \(desired\.has\(id\)\) continue/);
assert.match(webPlayer, /renderCaptionOverlay/);
assert.match(webPlayer, /captionTokens/);
assert.match(webPlayer, /captionMode !== "showing"/);
assert.match(webPlayer, /playerShell\.requestFullscreen/);
assert.match(webPlayerCss, /flex-wrap:\s*wrap/);
assert.match(webPlayerCss, /stream-caption-reveal/);
assert.match(webPlayerCss, /stream-test-caption-overlay\[hidden\]/);
assert.match(webPlayer, /Ready · listening for speech/);
assert.match(webPlayer, /player\.textTracks\?\..*"change"/);
assert.match(webPlayer, /captions\.src\s*=\s*`\$\{session\.captions_url\}/);
assert.doesNotMatch(webPlayer, /captions\.src\s*=.*Date\.now/);
assert(!webUi.includes("Run test pattern"));
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
@ -397,6 +544,55 @@ async function main() {
console.log("Managed MediaMTX Stream Testing verification passed.");
}
async function verifyManagedCertificateProvisioning(tempRoot) {
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const privateKey = keyPair.privateKey.export({ type: "pkcs8", format: "pem" });
let manager;
let issued = false;
let issueCalls = 0;
const fakeAcme = {
directory: { letsencrypt: { production: "https://acme.invalid/directory" } },
crypto: {
createPrivateRsaKey: async () => Buffer.from(privateKey),
createCsr: async () => [Buffer.from(privateKey), Buffer.from("test csr")]
},
Client: class {
async auto(options) {
issueCalls += 1;
await options.challengeCreateFn({}, { type: "http-01", token: "abcdefghijklmnopqrstuvwxyz012345" }, "key-authorization");
assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), "key-authorization");
await options.challengeRemoveFn({}, { type: "http-01", token: "abcdefghijklmnopqrstuvwxyz012345" });
assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), null);
issued = true;
return "test certificate";
}
}
};
manager = new StreamTestCertificateManager({
root: path.join(tempRoot, "managed-certificates"),
acme: fakeAcme,
directoryUrl: fakeAcme.directory.letsencrypt.production,
log: { info() {}, error() {} }
});
manager.inspect = (host) => {
const locations = manager.locations(host);
const ready = issued && fs.existsSync(locations.certificate) && fs.existsSync(locations.privateKey);
return {
ready,
source: "managed",
certificate: locations.certificate,
privateKey: locations.privateKey,
validUntil: ready ? Date.now() + 60 * 24 * 60 * 60 * 1000 : null
};
};
const first = manager.resolve("stream.example.test");
const second = manager.resolve("stream.example.test");
const [result] = await Promise.all([first, second]);
assert.equal(issueCalls, 1, "concurrent certificate requests must share one operation");
assert(result.ready && issued, "managed certificate provisioning did not complete");
assert.equal(fs.statSync(result.privateKey).mode & 0o777, process.platform === "win32" ? fs.statSync(result.privateKey).mode & 0o777 : 0o600);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;

View File

@ -37,24 +37,26 @@ async function main() {
const runtimeEnvironment = installGlobalRuntimeEnvironment();
migrate();
ensureDefaults();
logger.hookConsole();
const runtimeLog = logger.createLogger("core:runtime", { category: "lifecycle" });
const configuredRemote = getSetting("git_remote", "origin");
const configuredBranch = getSetting("git_branch", "main");
const completedDeployment = await completePendingBranchDeployment(configuredRemote, configuredBranch);
if (completedDeployment) {
console.log(`Completed deployment of ${completedDeployment.deployment.branch}; restarting with synchronized core and plugin code.`);
runtimeLog.info("Completed branch deployment; restarting with synchronized core and plugin code", {
branch: completedDeployment.deployment.branch
}, { event: "branch_deployment_completed" });
requestRestart({ delayMs: 0 });
return;
}
const completedPluginSync = await completePendingBundledPluginSync(configuredRemote);
if (completedPluginSync) {
console.log(
`Completed bundled plugin synchronization for Lumi ${completedPluginSync.version} ` +
`(${completedPluginSync.plugin_ids.length} plugins).`
);
runtimeLog.info("Completed bundled plugin synchronization", {
version: completedPluginSync.version,
plugin_count: completedPluginSync.plugin_ids.length
}, { event: "bundled_plugin_sync_completed" });
}
registerCorePlaceholders();
logger.hookConsole();
const runtimeLog = logger.createLogger("core:runtime", { category: "lifecycle" });
const logCleanup = logger.cleanupLogs({
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
@ -70,12 +72,18 @@ async function main() {
try {
cleanupSnapshots();
} catch (error) {
console.warn(`Snapshot cleanup could not complete: ${error.message}`);
runtimeLog.warn("Snapshot cleanup could not complete", error, {
event: "snapshot_cleanup_failed"
});
}
const safeModeRequested = isSafeModeRequested();
const startupMarker = markStartupVerification();
if (startupMarker?.status === "stale") {
console.warn("Recovery marker detected from incomplete update; start with LUMI_SAFE_MODE=1 for recovery tools.");
runtimeLog.warn(
"Recovery marker detected from incomplete update; start with LUMI_SAFE_MODE=1 for recovery tools",
{ marker_status: startupMarker.status },
{ event: "stale_recovery_marker_detected" }
);
}
const settingsApi = { getSetting, setSetting };
@ -89,7 +97,9 @@ async function main() {
try {
discordClient = await startBot({ commandRouter });
} catch (error) {
console.error("Discord bot failed to start", error);
runtimeLog.error("Discord bot failed to start", error, {
event: "discord_start_failed"
});
}
}
@ -97,12 +107,16 @@ async function main() {
try {
twitchClient = await startTwitchBot({ commandRouter });
} catch (error) {
console.error("Twitch bot failed to start", error);
runtimeLog.error("Twitch bot failed to start", error, {
event: "twitch_start_failed"
});
}
try {
await twitchEventSubManager.start();
} catch (error) {
console.error("Twitch event alerts failed to start", error);
runtimeLog.error("Twitch event alerts failed to start", error, {
event: "twitch_eventsub_start_failed"
});
}
}
@ -110,7 +124,9 @@ async function main() {
try {
youtubeClient = await startYouTubeBot({ commandRouter });
} catch (error) {
console.error("YouTube bot failed to start", error);
runtimeLog.error("YouTube bot failed to start", error, {
event: "youtube_start_failed"
});
}
}
@ -134,17 +150,21 @@ async function main() {
if (!safeModeRequested) {
overlayConnectorManager.start().catch((error) => {
console.error("OBS overlay connectors failed to start", error);
runtimeLog.error("OBS overlay connectors failed to start", error, {
event: "obs_connectors_start_failed"
});
});
}
const port = Number(process.env.PORT || 3000);
const listenHost = String(process.env.LUMI_HOST || "").trim();
const onListening = () => {
runtimeLog.run({ event: "web_ready" }, () => {
const displayHost = listenHost || "localhost";
console.log(`WebUI listening on http://${displayHost}:${port}`, { port, host: listenHost || null, runtime_mode: runtimeEnvironment.mode });
});
const displayHost = listenHost || "localhost";
runtimeLog.info(`WebUI listening on http://${displayHost}:${port}`, {
port,
host: listenHost || null,
runtime_mode: runtimeEnvironment.mode
}, { event: "web_ready" });
};
const webServer = listenHost ? app.listen(port, listenHost, onListening) : app.listen(port, onListening);
app.locals.lumiUpgradeRegistry?.attach(webServer);
@ -165,7 +185,9 @@ async function main() {
requestRestart();
}
} catch (error) {
console.error("Auto-update failed", error);
runtimeLog.error("Auto-update failed", error, {
event: "auto_update_failed"
});
} finally {
autoUpdateRunning = false;
}
@ -181,19 +203,22 @@ async function main() {
app.locals.lumiUpgradeRegistry?.close();
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
for (const stop of [
() => streamTestingService.close(),
() => overlayConnectorManager.stop(),
() => twitchEventSubManager.stop(),
() => stopPlugins(),
() => stopBot(),
() => stopTwitchBot(),
() => stopYouTubeBot()
for (const service of [
{ name: "stream_testing", stop: () => streamTestingService.close() },
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
{ name: "plugins", stop: () => stopPlugins() },
{ name: "discord", stop: () => stopBot() },
{ name: "twitch", stop: () => stopTwitchBot() },
{ name: "youtube", stop: () => stopYouTubeBot() }
]) {
try {
await stop();
await service.stop();
} catch (error) {
runtimeLog.warn("A service did not stop cleanly", { error: error.message }, { event: "shutdown_service_error" });
runtimeLog.warn("A service did not stop cleanly", {
service: service.name,
error
}, { event: "shutdown_service_error" });
}
}
await Promise.race([

View File

@ -271,14 +271,15 @@ function redactValue(value, seen = new WeakSet()) {
}
function isSensitiveKey(key) {
return /(?:^|[_-])(authorization|cookie|password|passwd|secret|token|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key)(?:$|[_-])/i.test(String(key));
return /(?:^|[_-])(authorization|cookie|password|passwd|secret|signature|token|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key)(?:$|[_-])/i.test(String(key));
}
function redactText(value) {
return String(value || "")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(/\b(Bearer|Basic|LumiDevice)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, "$1[REDACTED]")
.replace(/([?&](?:token|key|secret|password|authorization)=)[^&#\s]+/gi, "$1[REDACTED]")
.replace(/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|authorization)\s*[:=]\s*)[^\s,;}]+/gi, "$1[REDACTED]");
.replace(/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|signature|authorization)\s*[:=]\s*)[^\s,;}]+/gi, "$1[REDACTED]");
}
function truncate(value, maxLength) {

View File

@ -44,9 +44,17 @@ function emitLumiEvent(type, payload = {}, metadata = {}) {
});
for (const listener of listeners) {
try {
Promise.resolve(listener(event)).catch((error) => eventLog.error("Event listener failed", error, { event: "automation_listener_failed", event_type: eventType }));
Promise.resolve(listener(event)).catch((error) =>
eventLog.error("Event listener failed", {
event_type: eventType,
error
}, { event: "automation_listener_failed" })
);
} catch (error) {
eventLog.error("Event listener failed", error, { event: "automation_listener_failed", event_type: eventType });
eventLog.error("Event listener failed", {
event_type: eventType,
error
}, { event: "automation_listener_failed" });
}
}
return event;

View File

@ -182,7 +182,7 @@ function registerPublicOverlayRoutes(app) {
module_id: source.moduleId,
error_code: error?.code || null,
error: error?.message || "Unknown website loading error."
}, { event: "overlay.web_css_injection_failed" });
}, { event: "web_css_injection_failed" });
return res.status(error?.code === "PRIVATE_TARGET" ? 422 : 502).type("html").send(browserSourceErrorHtml(error.message));
}
});

View File

@ -4,7 +4,7 @@ const { db } = require("./db");
const { getPlugins } = require("./plugins");
const { createLogger } = require("./logger");
const logger = createLogger("plugin-stats");
const logger = createLogger("core:plugin-stats", { category: "plugin" });
function readJsonSafe(filePath) {
try {
@ -38,7 +38,10 @@ function loadStatProviders() {
try {
provider = require(providerPath);
} catch (error) {
logger.error("Failed to load plugin stats provider", error);
logger.error("Failed to load plugin stats provider", {
plugin_id: plugin.id,
error
}, { event: "stats_provider_load_failed" });
continue;
}
providers.push({ plugin, manifest, provider });
@ -54,7 +57,11 @@ function buildProfileSection({ plugin, manifest, provider, userId }) {
try {
result = provider.getProfileStats({ db, userId, plugin, manifest });
} catch (error) {
logger.error("Failed to load plugin profile stats", error);
logger.error("Failed to load plugin profile stats", {
plugin_id: plugin.id,
user_id: userId,
error
}, { event: "profile_stats_load_failed" });
return null;
}
const stats = Array.isArray(result?.stats) ? result.stats : [];
@ -75,7 +82,10 @@ function buildLeaderboardSection({ plugin, manifest, provider, limit }) {
try {
result = provider.getLeaderboards({ db, limit, plugin, manifest });
} catch (error) {
logger.error("Failed to load plugin leaderboards", error);
logger.error("Failed to load plugin leaderboards", {
plugin_id: plugin.id,
error
}, { event: "leaderboard_load_failed" });
return null;
}
const boards = Array.isArray(result?.boards) ? result.boards : [];
@ -115,7 +125,10 @@ async function getAdminDashboardSections() {
actions: normalizeDashboardActions(result.actions)
};
} catch (error) {
logger.error(`Failed to load ${plugin.id} admin dashboard stats`, error);
logger.error("Failed to load plugin admin dashboard stats", {
plugin_id: plugin.id,
error
}, { event: "admin_dashboard_stats_load_failed" });
return {
id: plugin.id,
eyebrow: "Companion service",

View File

@ -45,7 +45,9 @@ function issueDiagnosticsAccessKey() {
setSetting("production_diagnostics_key_prefix", `${key.slice(0, TOKEN_PREFIX.length + 8)}`);
setSetting("production_diagnostics_key_created_at", new Date().toISOString());
setSetting("production_diagnostics_enabled", true);
diagnosticsLog.warn("Production diagnostics access key rotated", { key_prefix: `${key.slice(0, TOKEN_PREFIX.length + 8)}` }, { event: "access_rotated" });
diagnosticsLog.warn("Production diagnostics access key rotated", {
configured: true
}, { event: "access_rotated" });
return key;
}

View File

@ -0,0 +1,223 @@
const acme = require("acme-client");
const crypto = require("crypto");
const fs = require("fs");
const net = require("net");
const path = require("path");
const { createLogger } = require("./logger");
const DATA_ROOT = path.join(
process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"),
"stream-testing",
"tls"
);
const RENEWAL_WINDOW_MS = 21 * 24 * 60 * 60 * 1000;
const CHALLENGE_TOKEN = /^[A-Za-z0-9_-]{20,300}$/;
class StreamTestCertificateManager {
constructor(options = {}) {
this.root = options.root || DATA_ROOT;
this.acme = options.acme || acme;
this.directoryUrl = options.directoryUrl || process.env.LUMI_STREAM_TEST_ACME_DIRECTORY || acme.directory.letsencrypt.production;
this.now = options.now || (() => Date.now());
this.challenges = new Map();
this.operations = new Map();
this.accountKeyOperation = null;
this.log = options.log || createLogger("core:stream-testing", { category: "integration" });
}
challenge(token) {
if (!CHALLENGE_TOKEN.test(String(token || ""))) return null;
return this.challenges.get(String(token)) || null;
}
async resolve(hostname) {
const host = normalizeCertificateHostname(hostname);
if (!host) {
throw certificateError("Automatic RTMPS certificates require a public DNS hostname; IP addresses and local hostnames cannot be issued a public certificate.");
}
const existing = this.inspect(host);
if (existing.ready && existing.validUntil - this.now() > RENEWAL_WINDOW_MS) return existing;
if (this.operations.has(host)) return this.operations.get(host);
const operation = this.issue(host, existing).finally(() => this.operations.delete(host));
this.operations.set(host, operation);
return operation;
}
inspect(hostname) {
const host = normalizeCertificateHostname(hostname);
if (!host) return { ready: false, source: "managed" };
const locations = this.locations(host);
try {
const certificatePem = fs.readFileSync(locations.certificate);
const privateKeyPem = fs.readFileSync(locations.privateKey);
const certificate = new crypto.X509Certificate(certificatePem);
const privateKey = crypto.createPrivateKey(privateKeyPem);
const certificatePublicKey = certificate.publicKey.export({ type: "spki", format: "der" });
const privatePublicKey = crypto.createPublicKey(privateKey).export({ type: "spki", format: "der" });
const validFrom = Date.parse(certificate.validFrom);
const validUntil = Date.parse(certificate.validTo);
const hostnameMatches = Boolean(certificate.checkHost(host));
const ready = hostnameMatches &&
certificatePublicKey.equals(privatePublicKey) &&
Number.isFinite(validFrom) &&
Number.isFinite(validUntil) &&
validFrom <= this.now() + 5 * 60 * 1000 &&
validUntil > this.now();
return {
ready,
source: "managed",
certificate: locations.certificate,
privateKey: locations.privateKey,
validUntil: ready ? validUntil : null
};
} catch {
return {
ready: false,
source: "managed",
certificate: locations.certificate,
privateKey: locations.privateKey,
validUntil: null
};
}
}
async issue(host, existing) {
const locations = this.locations(host);
const issuedTokens = new Set();
fs.mkdirSync(locations.directory, { recursive: true, mode: 0o700 });
fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
this.log.info("Provisioning managed RTMPS certificate", { hostname: host }, { event: "rtmps_certificate_provisioning" });
try {
const accountKey = await this.accountKey();
let certificateKey;
try {
certificateKey = fs.readFileSync(locations.privateKey);
crypto.createPrivateKey(certificateKey);
} catch {
certificateKey = await this.acme.crypto.createPrivateRsaKey(2048);
}
const [, csr] = await this.acme.crypto.createCsr({
commonName: host,
altNames: [host]
}, certificateKey);
const client = new this.acme.Client({
directoryUrl: this.directoryUrl,
accountKey
});
const certificate = await client.auto({
csr,
email: operatorEmail(),
termsOfServiceAgreed: true,
challengePriority: ["http-01"],
// Home-hosted Lumi installations frequently cannot hairpin through their
// public address. The ACME authority still performs the authoritative
// external HTTP-01 validation before issuing anything.
skipChallengeVerification: true,
challengeCreateFn: async (_authorization, challenge, keyAuthorization) => {
if (challenge.type !== "http-01" || !CHALLENGE_TOKEN.test(challenge.token)) {
throw new Error("The certificate authority did not provide a valid HTTP-01 challenge.");
}
this.challenges.set(challenge.token, keyAuthorization);
issuedTokens.add(challenge.token);
},
challengeRemoveFn: async (_authorization, challenge) => {
if (challenge?.token) {
this.challenges.delete(challenge.token);
issuedTokens.delete(challenge.token);
}
}
});
writeAtomic(locations.privateKey, certificateKey, 0o600);
writeAtomic(locations.certificate, certificate, 0o644);
const issued = this.inspect(host);
if (!issued.ready) throw new Error("The issued certificate did not match the Lumi hostname and private key.");
this.log.info("Managed RTMPS certificate is ready", {
hostname: host,
valid_until: new Date(issued.validUntil).toISOString(),
renewed: Boolean(existing?.ready)
}, { event: "rtmps_certificate_ready" });
return issued;
} catch (error) {
this.log.error("Managed RTMPS certificate provisioning failed", {
hostname: host,
error
}, { event: "rtmps_certificate_failed" });
throw certificateError(
`Lumi could not automatically prepare RTMPS for ${host}. Ensure the public hostname reaches this Lumi installation at /.well-known/acme-challenge/ and retry. ${error.message}`
);
} finally {
for (const token of issuedTokens) this.challenges.delete(token);
}
}
accountKey() {
if (this.accountKeyOperation) return this.accountKeyOperation;
this.accountKeyOperation = this.loadOrCreateAccountKey()
.finally(() => { this.accountKeyOperation = null; });
return this.accountKeyOperation;
}
async loadOrCreateAccountKey() {
const target = path.join(this.root, "acme-account.key");
try {
const current = fs.readFileSync(target);
crypto.createPrivateKey(current);
return current;
} catch {
const created = await this.acme.crypto.createPrivateRsaKey(2048);
writeAtomic(target, created, 0o600);
return created;
}
}
locations(host) {
const safe = host.replace(/[^a-z0-9.-]/gi, "_");
const suffix = crypto.createHash("sha256").update(host).digest("hex").slice(0, 12);
const directory = path.join(this.root, `${safe}-${suffix}`);
return {
directory,
certificate: path.join(directory, "fullchain.pem"),
privateKey: path.join(directory, "private-key.pem")
};
}
}
function normalizeCertificateHostname(value) {
const host = String(value || "").trim().replace(/\.$/, "").toLowerCase();
if (!host || net.isIP(host) || host === "localhost" || host.endsWith(".localhost")) return "";
if (host.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) return "";
return host;
}
function operatorEmail() {
const value = String(process.env.LUMI_OPERATOR_CONTACT || "").trim().replace(/^mailto:/i, "");
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value : undefined;
}
function writeAtomic(target, content, mode) {
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
const temporary = `${target}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
try {
fs.writeFileSync(temporary, content, { mode, flag: "wx" });
fs.renameSync(temporary, target);
try { fs.chmodSync(target, mode); } catch {}
} finally {
try { fs.rmSync(temporary, { force: true }); } catch {}
}
}
function certificateError(message) {
const error = new Error(message);
error.code = "STREAM_TEST_TLS_UNCONFIGURED";
return error;
}
const streamTestCertificateManager = new StreamTestCertificateManager();
module.exports = {
CHALLENGE_TOKEN,
RENEWAL_WINDOW_MS,
StreamTestCertificateManager,
normalizeCertificateHostname,
streamTestCertificateManager
};

View File

@ -6,6 +6,7 @@ const path = require("path");
const { Readable } = require("stream");
const { pipeline } = require("stream/promises");
const { MediaMtxRuntime } = require("./mediamtx-runtime");
const { streamTestCertificateManager } = require("./stream-test-certificates");
const { publishWebEvent } = require("./web-events");
const DATA_ROOT = path.join(
@ -51,6 +52,11 @@ function refreshWarnings(session) {
];
}
function visibleCaptionCues(session) {
const mediaPosition = session.startedAt ? Math.max(0, (Date.now() - session.startedAt) / 1000) : 0;
return session.captions.filter((cue) => cue.end >= mediaPosition - 15).slice(-50);
}
class StreamTestingService {
constructor(options = {}) {
this.runtime = options.runtime || new MediaMtxRuntime();
@ -96,6 +102,18 @@ class StreamTestingService {
source: session.source,
playback_url: `/admin/stream-testing/media/${session.id}/index.m3u8`,
captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
caption_cues: visibleCaptionCues(session).map((cue) => ({
id: cue.id,
revision: cue.revision,
final: cue.final,
start: cue.start,
end: cue.end,
text: cue.text,
stable_text: cue.stable_text,
uncertain_text: cue.uncertain_text,
updated_at: cue.updated_at,
expires_at: cue.expires_at
})),
metrics: session.metrics,
warnings: session.warnings,
ingest: `${session.transport}://${session.ingestHost}:${session.publicPort}/lumi-test/[session]`
@ -127,7 +145,7 @@ class StreamTestingService {
? [{
severity: "warning",
code: "unencrypted_ingest",
message: "This private test uses unencrypted RTMP only because the paired streaming computer is on a loopback or private network."
message: "This local development test uses RTMP only because Companion paired through a loopback Lumi URL."
}]
: [];
const session = {
@ -256,12 +274,49 @@ class StreamTestingService {
const session = this.requireDeviceSession(deviceId, input.session_id);
const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
if (!text) return;
const start = finite(input.start_seconds, Math.max(0, (Date.now() - (session.startedAt || Date.now())) / 1000), 0, MAX_SESSION_MS / 1000);
const end = Math.max(start + 0.3, finite(input.end_seconds, start + 3, start, MAX_SESSION_MS / 1000));
session.captions.push({ start, end, text });
if (session.captions.length > 1000) session.captions.shift();
const rawCaptionId = String(input.caption_id || "");
const captionId = /^[a-z0-9_.:-]{1,100}$/i.test(rawCaptionId) ? rawCaptionId : crypto.randomUUID();
const revision = Math.round(finite(input.revision, 1, 1, Number.MAX_SAFE_INTEGER));
const final = input.final === undefined ? true : Boolean(input.final);
const stableText = String(input.stable_text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
const uncertainText = String(input.uncertain_text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
const existingIndex = session.captions.findIndex((cue) => cue.id === captionId);
const existing = existingIndex >= 0 ? session.captions[existingIndex] : null;
if (existing && revision <= existing.revision) return existing;
const reportedStart = finite(input.start_seconds, Math.max(0, (Date.now() - (session.startedAt || Date.now())) / 1000), 0, MAX_SESSION_MS / 1000);
const previous = existing ? null : session.captions.at(-1);
const start = existing?.start ?? Math.max(reportedStart, previous ? previous.start + 0.05 : 0);
const reportedEnd = finite(input.end_seconds, start + (final ? 4 : 3.5), start, MAX_SESSION_MS / 1000);
const end = Math.max(existing?.end || 0, start + 0.5, reportedEnd);
if (previous) previous.end = Math.min(previous.end, Math.max(previous.start + 0.3, start));
const updatedAt = Date.now();
const displaySeconds = finite(input.display_seconds, final ? Math.min(8, Math.max(4, 2.5 + text.length / 18)) : 3.5, final ? 4 : 2, final ? 8 : 5);
const cue = {
id: captionId,
revision,
final,
start,
end,
text,
stable_text: stableText || (uncertainText ? "" : text),
uncertain_text: uncertainText,
updated_at: updatedAt,
expires_at: updatedAt + displaySeconds * 1000
};
if (existingIndex >= 0) session.captions[existingIndex] = cue;
else session.captions.push(cue);
session.captions = session.captions.filter((candidate) => candidate.end >= start - 30).slice(-200);
const delayMs = finite(input.delay_ms, 0, 0, MAX_SESSION_MS);
session.metrics.captions = { delivered: session.captions.length, last_delay_ms: delayMs, last_received_at: Date.now() };
session.metrics.captions = {
...session.metrics.captions,
state: "delivering",
detail: final ? "Progressive private captions are reaching Lumi." : "A private caption is updating progressively.",
delivered: session.captions.filter((candidate) => candidate.final).length,
revisions: (session.metrics.captions.revisions || 0) + 1,
progressive: true,
last_delay_ms: delayMs,
last_received_at: Date.now()
};
session.captionWarnings = delayMs >= 5000
? [{ severity: "critical", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
: delayMs >= 2500
@ -271,6 +326,33 @@ class StreamTestingService {
this.notify();
}
updateCaptionStatus(deviceId, input = {}) {
const session = this.requireDeviceSession(deviceId, input.session_id);
const state = input.state === "ready" ? "ready" : input.state === "failed" ? "failed" : input.state === "disabled" ? "disabled" : "";
if (!state) throw coded("STREAM_TEST_CAPTION_STATE_INVALID", "The private caption state is invalid.");
const detail = String(input.detail || (state === "ready"
? "Speech recognition is ready and listening."
: state === "disabled"
? "Captions are turned off in Lumi Companion."
: "Speech recognition stopped."))
.replace(/[\r\n]+/g, " ")
.trim()
.slice(0, 300);
session.metrics.captions = {
...session.metrics.captions,
state,
detail,
status_at: Date.now()
};
if (state === "disabled") session.captions = [];
session.captionWarnings = state === "failed"
? [{ severity: "critical", code: "caption_unavailable", message: detail }]
: [];
refreshWarnings(session);
this.notify();
return session.metrics.captions;
}
captionFile(id) {
const session = this.active?.id === id ? this.active : null;
if (!session) return null;
@ -279,7 +361,7 @@ class StreamTestingService {
const date = new Date(milliseconds);
return `${String(Math.floor(milliseconds / 3600000)).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}:${String(date.getUTCSeconds()).padStart(2, "0")}.${String(date.getUTCMilliseconds()).padStart(3, "0")}`;
};
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${visibleCaptionCues(session).map((cue, index) => `${index + 1}\n${timestamp(cue.start)} --> ${timestamp(cue.end)}\n${cue.text}\n`).join("\n")}`;
}
async proxyMedia(id, asset, req, res) {
@ -512,44 +594,69 @@ class StreamTestingService {
}
}
async function resolveIngestConfiguration(device) {
async function resolveIngestConfiguration(device, options = {}) {
const pairedOrigin = String(device?.pairing_host || "").trim();
let pairedHost = "";
let pairedUrl;
try {
pairedHost = new URL(pairedOrigin).hostname;
pairedUrl = new URL(pairedOrigin);
} catch {}
const host = validateHostname(String(process.env.LUMI_STREAM_TEST_INGEST_HOST || pairedHost).trim());
const pairedHost = validateHostname(pairedUrl?.hostname || "");
const localDevelopment = isLoopbackHostname(pairedHost);
const configuredHost = localDevelopment ? pairedHost : process.env.LUMI_STREAM_TEST_INGEST_HOST || pairedHost;
const host = validateHostname(String(configuredHost).trim());
if (!host) throw coded("STREAM_TEST_INGEST_UNCONFIGURED", "The paired Lumi hostname could not be used for private Stream Testing.");
if (localDevelopment) {
const resolved = await resolveHostAddresses(host);
return {
host,
transport: "rtmp",
tlsCert: "",
tlsKey: "",
bindHost: resolved[0] || "127.0.0.1",
ingestPort: INGEST_PORT,
publicPort: PUBLIC_INGEST_PORT
};
}
if (pairedUrl?.protocol !== "https:") {
throw coded("STREAM_TEST_TLS_REQUIRED", "Non-local Companion sessions require an HTTPS pairing URL and RTMPS ingest.");
}
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") {
let tlsCert = String(process.env.LUMI_STREAM_TEST_TLS_CERT || "").trim();
let tlsKey = String(process.env.LUMI_STREAM_TEST_TLS_KEY || "").trim();
if (requested && !["rtmps", "rtmp"].includes(requested)) throw coded("STREAM_TEST_TRANSPORT_INVALID", "LUMI_STREAM_TEST_TRANSPORT must be rtmps or rtmp.");
// Transport overrides never weaken the authenticated origin policy. This also
// makes an old development `rtmp` override harmless after moving Lumi behind
// its production HTTPS hostname.
const transport = "rtmps";
if (tlsCert || tlsKey) {
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.`);
throw coded("STREAM_TEST_TLS_UNCONFIGURED", `The advanced RTMPS override requires a readable ${label}. Configure both LUMI_STREAM_TEST_TLS_CERT and LUMI_STREAM_TEST_TLS_KEY, or remove both so Lumi can manage the certificate.`);
}
}
} 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."
);
} else {
const managed = await (options.certificateManager || streamTestCertificateManager).resolve(host);
tlsCert = managed.certificate;
tlsKey = managed.privateKey;
}
return {
host,
transport,
tlsCert,
tlsKey,
bindHost: transport === "rtmp" && privateHost ? resolved[0] : "0.0.0.0",
bindHost: "0.0.0.0",
ingestPort: INGEST_PORT,
publicPort: PUBLIC_INGEST_PORT
};
}
function isLoopbackHostname(value) {
const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1";
}
function validateHostname(value) {
if (!value || value.length > 253) return "";
const host = value.replace(/^\[|\]$/g, "");

View File

@ -171,7 +171,13 @@ class TwitchEventSubManager {
if (type === "notification") this.emitNotification(message);
if (type === "revocation") {
this.status = { ...this.status, detail: "A Twitch event permission was revoked. Reconnect Twitch events if alerts stop." };
eventLog.warn("Twitch revoked an EventSub subscription", message.payload?.subscription || {}, { event: "eventsub_revoked" });
const subscription = message.payload?.subscription || {};
eventLog.warn("Twitch revoked an EventSub subscription", {
subscription_id: subscription.id || null,
subscription_type: subscription.type || null,
status: subscription.status || null,
reason: message.payload?.status || null
}, { event: "eventsub_revoked" });
}
}

View File

@ -11,7 +11,7 @@ const {
} = require("./update-repository");
const { createLogger } = require("./logger");
const logger = createLogger("updater");
const logger = createLogger("core:updater", { category: "updates" });
let restartHandler = null;
@ -105,7 +105,9 @@ function requestRestart(options = {}) {
return;
}
Promise.resolve(restartHandler(10)).catch((error) => {
logger.error("Graceful restart failed; forcing wrapper restart.", error);
logger.error("Graceful restart failed; forcing wrapper restart", error, {
event: "graceful_restart_failed"
});
process.exit(10);
});
}, delayMs);

View File

@ -63,12 +63,11 @@ function createWebhookService({ limit = "256kb" } = {}) {
return sendHandlerResult(res, await endpoint.handler(context));
} catch (error) {
webhookLog.error("Webhook handler failed", {
pluginId: endpoint.pluginId,
endpointId: endpoint.endpointId,
plugin_id: endpoint.pluginId,
endpoint_id: endpoint.endpointId,
namespace,
slug,
message: error?.message || String(error),
stack: error?.stack || ""
error
}, { event: "inbound_webhook_failed" });
if (!res.headersSent) {
return res.status(500).json({ error: "Webhook processing failed." });
@ -145,7 +144,10 @@ function createWebhookService({ limit = "256kb" } = {}) {
if (!pluginEndpoints.size) {
endpointKeysByPlugin.delete((pluginId || "").toString());
}
webhookLog.debug("Webhook endpoint unregistered", { pluginId, endpointId }, { event: "webhook_unregistered" });
webhookLog.debug("Webhook endpoint unregistered", {
plugin_id: pluginId,
endpoint_id: endpointId
}, { event: "webhook_unregistered" });
return true;
}
@ -232,7 +234,7 @@ async function sendWebhook({
if (response.ok || attempt === attempts) {
if (!response.ok) {
webhookLog.warn("Outbound webhook returned an error", {
pluginId: pluginId || null,
plugin_id: pluginId || null,
url: redactUrl(url),
status: response.status,
attempt
@ -245,10 +247,10 @@ async function sendWebhook({
lastDurationMs = Date.now() - startedAt;
if (attempt === attempts) {
webhookLog.error("Outbound webhook failed", {
pluginId: pluginId || null,
plugin_id: pluginId || null,
url: redactUrl(url),
attempt,
message: error?.message || String(error)
error
}, { event: "outbound_webhook_failed" });
}
} finally {

View File

@ -1,9 +1,48 @@
@layer features {
.stream-test-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(18rem, .7fr); gap: var(--lumi-space-4); }
.stream-test-player-shell { position: relative; aspect-ratio: 16 / 9; overflow: hidden; border-radius: var(--lumi-radius-md); background: #05070a; }
.stream-test-player-shell:fullscreen { width: 100vw; height: 100vh; aspect-ratio: auto; border-radius: 0; }
.stream-test-player-shell video { display: block; width: 100%; height: 100%; object-fit: contain; background: transparent; }
.stream-test-player-shell video::cue { color: transparent; background: transparent; text-shadow: none; }
.stream-test-player-shell video::-webkit-media-controls-fullscreen-button { display: none; }
.stream-test-player-shell video:not([src]) { display: none; }
.stream-test-player-shell .empty-state { position: absolute; inset: 0; display: grid; place-items: center; padding: var(--lumi-space-5); text-align: center; }
.stream-test-caption-overlay {
position: absolute;
z-index: 3;
inset-inline: 4%;
bottom: clamp(3.5rem, 10%, 6.5rem);
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
gap: .14em .3em;
width: fit-content;
max-width: min(92%, 68rem);
margin-inline: auto;
padding: .38em .62em;
border: 1px solid color-mix(in srgb, var(--lumi-border) 78%, transparent);
border-radius: var(--lumi-radius-sm);
background: color-mix(in srgb, #090d14 84%, transparent);
box-shadow: 0 .5rem 1.8rem rgb(0 0 0 / 35%);
color: #f7f9fc;
font-size: clamp(1rem, 2.15vw, 1.72rem);
font-weight: 650;
line-height: 1.28;
letter-spacing: -.012em;
text-align: center;
overflow-wrap: anywhere;
pointer-events: none;
backdrop-filter: blur(.45rem);
}
.stream-test-caption-overlay[hidden] { display: none; }
.stream-test-caption-word { min-width: 0; overflow-wrap: anywhere; }
.stream-test-caption-word.is-uncertain { color: color-mix(in srgb, #f7f9fc 72%, var(--lumi-primary)); }
.stream-test-caption-word.is-new { animation: stream-caption-reveal 180ms var(--lumi-ease-out, ease-out) both; animation-delay: var(--caption-word-delay, 0ms); }
@keyframes stream-caption-reveal {
from { opacity: 0; translate: 0 .28em; filter: blur(.08em); }
to { opacity: 1; translate: 0 0; filter: blur(0); }
}
.stream-test-summary, .stream-test-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--lumi-space-3); }
.stream-test-summary > div, .stream-test-metrics > div { padding: var(--lumi-space-3); border: 1px solid var(--lumi-border); border-radius: var(--lumi-radius-sm); background: var(--lumi-surface-subtle); }
.stream-test-summary span, .stream-test-summary strong, .stream-test-metrics span, .stream-test-metrics strong { display: block; }
@ -12,5 +51,6 @@
.stream-test-timeline li::marker { color: var(--lumi-primary); }
.stream-test-warning { margin-top: var(--lumi-space-3); }
.stream-runtime-actions { margin-top: var(--lumi-space-3); }
@media (prefers-reduced-motion: reduce) { .stream-test-caption-word.is-new { animation: none; } }
@media (max-width: 900px) { .stream-test-layout { grid-template-columns: 1fr; } }
}

View File

@ -2,7 +2,9 @@
const root = document.querySelector("[data-stream-testing]");
if (!root) return;
const player = root.querySelector("[data-stream-player]");
const playerShell = root.querySelector(".stream-test-player-shell");
const captions = root.querySelector("[data-stream-captions]");
const captionOverlay = root.querySelector("[data-stream-caption-overlay]");
const empty = root.querySelector("[data-stream-empty]");
const statePill = root.querySelector("[data-stream-state]");
const summary = root.querySelector("[data-stream-summary]");
@ -21,8 +23,17 @@
const runtimeResult = root.querySelector("[data-runtime-result]");
let hls = null;
let sessionId = null;
let latestSession = null;
let playerStalls = 0;
let playerErrors = 0;
let autoplayPending = false;
let captionSignature = "";
let captionMode = "showing";
let overlayCaptionId = null;
let overlayTokens = [];
let overlayNodes = [];
let overlayExpiryTimer = null;
const captionCueState = new Map();
const text = (value) => String(value ?? "");
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
@ -35,16 +46,153 @@
return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`;
};
async function attemptAutoplay() {
if (!sessionId || !autoplayPending) return;
try {
await player.play();
autoplayPending = false;
} catch {
if (!player.muted) {
player.muted = true;
try {
await player.play();
autoplayPending = false;
} catch {}
}
}
}
function clearCaptionOverlay() {
if (overlayExpiryTimer) window.clearTimeout(overlayExpiryTimer);
overlayExpiryTimer = null;
overlayCaptionId = null;
overlayTokens = [];
overlayNodes = [];
captionOverlay.replaceChildren();
captionOverlay.hidden = true;
}
function captionTokens(cue) {
const stable = text(cue?.stable_text).trim().split(/\s+/).filter(Boolean);
const uncertain = text(cue?.uncertain_text).trim().split(/\s+/).filter(Boolean);
if (!stable.length && !uncertain.length) {
return text(cue?.text).trim().split(/\s+/).filter(Boolean).map((value) => ({ value, uncertain: false }));
}
return [
...stable.map((value) => ({ value, uncertain: false })),
...uncertain.map((value) => ({ value, uncertain: true }))
];
}
function renderCaptionOverlay(cue) {
if (!cue || captionMode !== "showing" || Number(cue.expires_at) <= Date.now()) {
clearCaptionOverlay();
return;
}
const id = String(cue.id || "");
const nextTokens = captionTokens(cue);
if (!nextTokens.length) {
clearCaptionOverlay();
return;
}
if (id !== overlayCaptionId) {
captionOverlay.replaceChildren();
overlayCaptionId = id;
overlayTokens = [];
overlayNodes = [];
}
let common = 0;
while (common < overlayTokens.length && common < nextTokens.length && overlayTokens[common].value === nextTokens[common].value) common += 1;
while (overlayNodes.length > common) overlayNodes.pop()?.remove();
overlayTokens.length = common;
for (let index = 0; index < common; index += 1) {
overlayTokens[index] = nextTokens[index];
overlayNodes[index].classList.toggle("is-uncertain", nextTokens[index].uncertain);
}
for (let index = common; index < nextTokens.length; index += 1) {
const token = nextTokens[index];
const word = document.createElement("span");
word.className = `stream-test-caption-word is-new${token.uncertain ? " is-uncertain" : ""}`;
word.style.setProperty("--caption-word-delay", `${Math.min((index - common) * 45, 360)}ms`);
word.textContent = token.value;
captionOverlay.append(word);
overlayTokens.push(token);
overlayNodes.push(word);
}
captionOverlay.hidden = false;
if (overlayExpiryTimer) window.clearTimeout(overlayExpiryTimer);
overlayExpiryTimer = window.setTimeout(() => clearCaptionOverlay(), Math.max(50, Number(cue.expires_at) - Date.now()));
}
function syncCaptionOverlay(session) {
const cues = Array.isArray(session?.caption_cues) ? session.caption_cues : [];
renderCaptionOverlay(cues.at(-1));
}
function syncCaptions(session, force = false) {
const cues = Array.isArray(session?.caption_cues) ? session.caption_cues : [];
syncCaptionOverlay(session);
const signature = `${session?.id || ""}:${cues.length}:${cues.at(-1)?.id || ""}:${cues.at(-1)?.revision || ""}:${cues.at(-1)?.start || ""}:${cues.at(-1)?.end || ""}:${cues.at(-1)?.text || ""}`;
if (!force && signature === captionSignature) return;
captionSignature = signature;
const track = captions.track;
const Cue = window.VTTCue || window.TextTrackCue;
if (!track || !Cue) return;
const desired = new Set();
for (const [index, cue] of cues.entries()) {
const start = Number(cue.start);
const end = Number(cue.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || !cue.text) continue;
const id = String(cue.id || `${session.id}:${index}`);
desired.add(id);
let textCue = captionCueState.get(id);
if (!textCue) {
textCue = new Cue(start, end, "\u200B");
textCue.id = id;
track.addCue(textCue);
captionCueState.set(id, textCue);
} else {
textCue.startTime = start;
textCue.endTime = end;
textCue.text = "\u200B";
}
}
for (const [id, cue] of captionCueState) {
if (desired.has(id)) continue;
try { track.removeCue(cue); } catch {}
captionCueState.delete(id);
}
track.mode = captionMode;
}
function resetCaptionCues() {
const track = captions.track;
while (track?.cues?.length) track.removeCue(track.cues[0]);
captionCueState.clear();
}
function attach(session) {
if (!session || sessionId === session.id) return;
sessionId = session.id;
playerStalls = 0;
playerErrors = 0;
autoplayPending = true;
captionSignature = "";
hls?.destroy?.();
hls = null;
player.autoplay = true;
player.defaultMuted = true;
player.muted = true;
player.removeAttribute("src");
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}`;
captions.addEventListener("load", () => {
if (sessionId === session.id && latestSession) {
resetCaptionCues();
syncCaptions(latestSession, true);
}
}, { once: true });
syncCaptions(session);
quality.replaceChildren(new Option(session.source?.variants?.[0]?.label || "Source output", "-1"));
if (window.Hls?.isSupported?.()) {
hls = new window.Hls({
@ -57,18 +205,24 @@
});
hls.loadSource(url);
hls.attachMedia(player);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => player.play().catch(() => {}));
hls.on(window.Hls.Events.MANIFEST_PARSED, attemptAutoplay);
hls.on(window.Hls.Events.ERROR, (_event, data) => {
if (data?.fatal) playerErrors += 1;
if (String(data?.details || "").toLowerCase().includes("stall")) playerStalls += 1;
});
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
player.src = url;
player.load();
}
}
function detach() {
sessionId = null;
latestSession = null;
autoplayPending = false;
captionSignature = "";
resetCaptionCues();
clearCaptionOverlay();
hls?.destroy?.();
hls = null;
player.pause();
@ -81,8 +235,12 @@
function render(status) {
const session = status.session;
latestSession = session;
if (session) attach(session); else detach();
if (session) captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}&at=${Date.now()}`;
if (session) {
syncCaptions(session);
void attemptAutoplay();
}
statePill.textContent = session?.state || "Idle";
statePill.className = `status-pill ${session?.state === "receiving" ? "success" : session ? "warning" : "neutral"}`;
empty.hidden = Boolean(session);
@ -120,7 +278,15 @@
metric("Preview viewers", receiver.hls_sessions ?? 0),
metric("Player latency", playerLatency),
metric("Playback stalls", playerMetrics.stalls ?? playerStalls),
metric("Caption delivery", captionMetrics.delivered ? `${captionMetrics.delivered} cues · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay` : "Waiting")
metric("Caption delivery", captionMetrics.revisions
? `${captionMetrics.delivered || 0} finalized · progressive · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay`
: captionMetrics.state === "ready"
? "Ready · listening for speech"
: captionMetrics.state === "disabled"
? "Off in Companion"
: captionMetrics.state === "failed"
? `Unavailable · ${captionMetrics.detail || "speech recognition stopped"}`
: "Starting")
].join("");
warnings.replaceChildren(...(session?.warnings || (!runtime.available ? [{ severity: "critical", message: runtime.detail }] : [])).map((warning) => {
const item = document.createElement("div");
@ -181,7 +347,12 @@
}
player.addEventListener("error", () => { playerErrors += 1; });
fullscreen.addEventListener("click", () => player.requestFullscreen?.());
player.addEventListener("canplay", () => void attemptAutoplay());
player.textTracks?.addEventListener?.("change", () => {
if (captions.track?.mode) captionMode = captions.track.mode;
syncCaptionOverlay(latestSession);
});
fullscreen.addEventListener("click", () => playerShell.requestFullscreen?.());
stop.addEventListener("click", async () => {
if (!sessionId) return;
stop.disabled = true;
@ -220,6 +391,7 @@
window.addEventListener("pagehide", () => {
events.close();
window.clearInterval(metricTimer);
if (overlayExpiryTimer) window.clearTimeout(overlayExpiryTimer);
hls?.destroy?.();
}, { once: true });
refresh();

View File

@ -60,7 +60,6 @@ const {
createLogger,
listLogFacets,
listLogs,
log,
summarizeLogs,
withLogContext
} = require("../services/logger");
@ -81,6 +80,7 @@ const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing");
const { streamTestCertificateManager } = require("../services/stream-test-certificates");
const { getClient: getYouTubeClient } = require("../services/youtube");
const {
conditionalRepliesFromBody,
@ -3171,20 +3171,20 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
return html;
}
const message = err?.message || "";
const detail = {
view,
message,
stack: err?.stack || ""
};
const detail = { view, error: err };
const isMissing = message.includes("Failed to lookup view");
if (!isMissing) {
log("error", "View render failed", detail);
webLog.error("View render failed", detail, {
event: "view_render_failed"
});
if (typeof callback === "function") {
return callback(err);
}
throw err;
}
log("warn", "Missing view fallback", detail);
webLog.warn("Missing view fallback", detail, {
event: "missing_view_fallback"
});
if (view === "missing-view") {
const fallback =
"<!doctype html><title>Content missing</title><h1>Content unavailable</h1><p>Some content could not be loaded.</p>";
@ -3259,6 +3259,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.setHeader("X-Content-Type-Options", "nosniff");
next();
});
app.get("/.well-known/acme-challenge/:token", (req, res) => {
const response = streamTestCertificateManager.challenge(req.params.token);
res.set("Cache-Control", "no-store");
if (!response) return res.status(404).type("text/plain").send("Not found");
return res.status(200).type("text/plain").send(response);
});
app.use(express.static(path.join(__dirname, "public")));
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
@ -3705,12 +3711,13 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
view,
method: req.method,
path: req.path,
userId: req.session.user?.id || null,
message,
stack: err?.stack || ""
user_id: req.session.user?.id || null,
error: err
};
if (!isMissing) {
log("error", "Render failed", context);
webLog.error("Render failed", context, {
event: "response_render_failed"
});
return originalRender(
"error",
{
@ -3720,7 +3727,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
callback
);
}
log("warn", "Missing view fallback", context);
webLog.warn("Missing view fallback", context, {
event: "missing_view_fallback"
});
res.locals.softError = "Some content could not be loaded.";
if (view === "missing-view") {
return res
@ -3811,7 +3820,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
: true
};
} catch (error) {
webLog.error(`Assistant panel ${panel.id} availability check failed`, error);
webLog.error("Assistant panel availability check failed", {
panel_id: panel.id,
error
}, { event: "assistant_panel_availability_failed" });
panels.push({ ...unavailableAssistantPanel(panel, "availability_check_failed"), debug: panelDebug });
continue;
}
@ -3855,7 +3867,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
}
});
} catch (error) {
webLog.error(`Assistant panel ${panel.id} render failed`, error);
webLog.error("Assistant panel render failed", {
panel_id: panel.id,
error
}, { event: "assistant_panel_render_failed" });
panel.onRenderDiagnostic?.({
panel_endpoint_status: 500,
panel_html_length: 0,
@ -4285,7 +4300,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
setFlash(req, "success", "Logged in.");
res.redirect("/");
} catch (error) {
webLog.error(error);
webLog.error("Discord authentication failed", error, {
event: "discord_authentication_failed"
});
res.status(500).render("error", {
title: "Login failed",
message: "Discord authentication failed."
@ -4507,7 +4524,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.redirect("/profile");
}
} catch (error) {
webLog.error(error);
webLog.error("Twitch authentication or account link failed", {
mode: isLogin ? "login" : isEvent ? "event_authorization" : "account_link",
error
}, { event: "twitch_authentication_failed" });
res.status(500).render("error", {
title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed",
message: isLogin
@ -4670,7 +4690,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
setFlash(req, "success", "YouTube account linked.");
res.redirect("/profile");
} catch (error) {
webLog.error(error);
webLog.error("YouTube authentication or account link failed", {
mode: isBot ? "bot_connection" : isLogin ? "login" : "account_link",
error
}, { event: "youtube_authentication_failed" });
res.status(500).render("error", {
title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed",
message: isBot
@ -7223,7 +7246,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
try {
fs.rmSync(plugin.path, { recursive: true, force: true });
} catch (error) {
webLog.error(error);
webLog.error("Plugin files could not be removed during uninstall", {
plugin_id: req.params.id,
error
}, { event: "plugin_uninstall_files_failed" });
}
}
removePlugin(req.params.id);
@ -7809,13 +7835,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
}
const message = err?.message || "";
const isViewMissing = message.includes("Failed to lookup view");
log("error", "Unhandled error", {
webLog.error("Unhandled HTTP error", {
path: req.path,
method: req.method,
userId: req.session.user?.id || null,
message,
stack: err?.stack || ""
});
user_id: req.session.user?.id || null,
error: err
}, { event: "unhandled_http_error" });
if (isViewMissing) {
res.locals.softError = "Some content could not be loaded.";
return res.status(200).render("missing-view", {

View File

@ -34,7 +34,8 @@
<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="stream-test-player-shell">
<video data-stream-player controls playsinline crossorigin="use-credentials"><track data-stream-captions kind="captions" srclang="en" label="Lumi captions" default /></video>
<video data-stream-player controls autoplay muted playsinline crossorigin="use-credentials"><track data-stream-captions kind="captions" srclang="en" label="Lumi captions" default /></video>
<div class="stream-test-caption-overlay" data-stream-caption-overlay hidden aria-hidden="true"></div>
<div class="empty-state" data-stream-empty>Start Stream testing from Lumi Companion on the streaming computer.</div>
</div>
<div class="inline-actions">