feat: complete companion streaming and logging upgrades
This commit is contained in:
parent
2368c01eb0
commit
2d8dc86b20
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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}" />
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
374
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 || "")
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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) });
|
||||
|
||||
@ -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?.();
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
83
src/main.js
83
src/main.js
@ -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([
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
223
src/services/stream-test-certificates.js
Normal file
223
src/services/stream-test-certificates.js
Normal 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
|
||||
};
|
||||
@ -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, "");
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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; } }
|
||||
}
|
||||
|
||||
@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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();
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user