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_HOST=lumi.example.com
|
||||||
# LUMI_STREAM_TEST_INGEST_PORT=19350
|
# LUMI_STREAM_TEST_INGEST_PORT=19350
|
||||||
# LUMI_STREAM_TEST_PUBLIC_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_CERT=/absolute/path/to/fullchain.pem
|
||||||
# LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.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.
|
# Advanced/manual runtime override only; normal installations do not need this.
|
||||||
# LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx
|
# 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,
|
components, and legal bundle, then restarts Companion. Pairing credentials,
|
||||||
settings, and plugin state remain in the per-user data directory.
|
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
|
Localhost development builds can also receive checksum-addressed same-version
|
||||||
updates without publishing a release. See
|
updates without publishing a release. See
|
||||||
[Localhost development updates](../docs/local-development-updates.md).
|
[Localhost development updates](../docs/local-development-updates.md).
|
||||||
|
|
||||||
|
## 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
|
## Private Stream Testing
|
||||||
|
|
||||||
The core **Stream Testing** page asks the paired Lumi host for an expiring
|
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
|
RTMP/RTMPS port; Companion does not need a second login, destination editor, or
|
||||||
local media runtime.
|
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
|
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,
|
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
|
`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") {
|
if (type == "select_sources") {
|
||||||
const auto uuid = message.value("primary_source_uuid", "");
|
const auto uuid = message.value("primary_source_uuid", "");
|
||||||
const auto installed = select_source(uuid);
|
const auto installed = select_source(uuid);
|
||||||
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());
|
installed ? "Attached" : "Could not attach", uuid.c_str());
|
||||||
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
|
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
|
||||||
} else if (type == "caption" && message.contains("payload")) {
|
} 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()));
|
const auto response = handle_command(json::parse(body.begin(), body.end()));
|
||||||
if (response && !write_json(pipe, *response)) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -703,7 +703,7 @@ static void run_pipe_worker()
|
|||||||
worker_signal.wait_for(lock, std::chrono::seconds(2));
|
worker_signal.wait_for(lock, std::chrono::seconds(2));
|
||||||
continue;
|
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},
|
bool connected = write_json(pipe, {{"type", "hello"}, {"protocol_version", protocol_version},
|
||||||
{"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version},
|
{"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version},
|
||||||
{"obs_process_id", static_cast<uint32_t>(GetCurrentProcessId())}});
|
{"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));
|
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
|
||||||
}
|
}
|
||||||
CloseHandle(pipe);
|
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 char *version = obs_get_version_string();
|
||||||
const int major = version ? std::atoi(version) : 0;
|
const int major = version ? std::atoi(version) : 0;
|
||||||
if (major < 31) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
stopping.store(false, std::memory_order_release);
|
stopping.store(false, std::memory_order_release);
|
||||||
obs_frontend_add_event_callback(frontend_event, nullptr);
|
obs_frontend_add_event_callback(frontend_event, nullptr);
|
||||||
worker_thread = std::thread(run_pipe_worker);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -798,5 +798,5 @@ void obs_module_unload(void)
|
|||||||
stopping.store(true, std::memory_order_release);
|
stopping.store(true, std::memory_order_release);
|
||||||
worker_signal.notify_all();
|
worker_signal.notify_all();
|
||||||
if (worker_thread.joinable()) worker_thread.join();
|
if (worker_thread.joinable()) worker_thread.join();
|
||||||
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 const int MaxCoverOutputBytes = 500 * 1024;
|
||||||
private readonly Func<SongOverlaySettings> _settings;
|
private readonly Func<SongOverlaySettings> _settings;
|
||||||
private readonly Func<MediaTrack, CancellationToken, Task<MediaTrack>> _enrich;
|
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 GlobalSystemMediaTransportControlsSessionManager? _manager;
|
||||||
private GlobalSystemMediaTransportControlsSession? _session;
|
private GlobalSystemMediaTransportControlsSession? _session;
|
||||||
private MediaSnapshot? _last;
|
private MediaSnapshot? _last;
|
||||||
@ -21,7 +21,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
|||||||
private bool _started;
|
private bool _started;
|
||||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
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;
|
_settings = settings;
|
||||||
_enrich = enrich;
|
_enrich = enrich;
|
||||||
@ -62,7 +62,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
|||||||
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
|
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
|
||||||
{
|
{
|
||||||
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
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)
|
private async Task SelectSessionAsync(CancellationToken cancellationToken)
|
||||||
@ -105,19 +105,19 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
|||||||
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
|
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
|
||||||
{
|
{
|
||||||
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); }
|
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)
|
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
|
||||||
{
|
{
|
||||||
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
|
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)
|
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
|
||||||
{
|
{
|
||||||
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
|
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)
|
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
|
||||||
@ -206,7 +206,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
|||||||
finally { _refreshLock.Release(); }
|
finally { _refreshLock.Release(); }
|
||||||
await RefreshAndPublishAsync("metadata", false, CancellationToken.None).ConfigureAwait(false);
|
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)
|
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Lumi.Companion.Abstractions;
|
using Lumi.Companion.Abstractions;
|
||||||
using Lumi.Companion.SongOverlay.Providers;
|
using Lumi.Companion.SongOverlay.Providers;
|
||||||
using Lumi.Companion.SongOverlay.Spotify;
|
using Lumi.Companion.SongOverlay.Spotify;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Lumi.Companion.SongOverlay;
|
namespace Lumi.Companion.SongOverlay;
|
||||||
|
|
||||||
@ -192,7 +193,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception error) { Log("Song Overlay heartbeat failed", error); }
|
catch (Exception error) { Log("heartbeat_failed", "Song Overlay heartbeat failed", error); }
|
||||||
}, token);
|
}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,7 +275,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception error)
|
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);
|
SetStatus(CompanionPluginHealth.Warning, "Delivery delayed", "Playback was detected, but Lumi could not be updated: " + error.Message);
|
||||||
}
|
}
|
||||||
finally { _eventLock.Release(); }
|
finally { _eventLock.Release(); }
|
||||||
@ -358,17 +359,49 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void RaiseChanged() => Changed?.Invoke();
|
private void RaiseChanged() => Changed?.Invoke();
|
||||||
private void Log(string message, Exception? error = null)
|
private void Log(string eventId, string message, Exception? error = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(_logDirectory);
|
Directory.CreateDirectory(_logDirectory);
|
||||||
File.AppendAllText(Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyyMMdd}.log"),
|
PruneLogs();
|
||||||
$"{DateTimeOffset.Now:O}\t{message}{(error is null ? "" : "\t" + error)}{Environment.NewLine}");
|
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 { }
|
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
|
private static string Describe(MediaSnapshot snapshot) => snapshot.Track is null
|
||||||
? "Connected; no active song."
|
? "Connected; no active song."
|
||||||
: $"{snapshot.Status}: {snapshot.Track.Title} — {snapshot.Track.Artist}";
|
: $"{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 HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
private readonly SongOverlaySecretProtector _secrets;
|
private readonly SongOverlaySecretProtector _secrets;
|
||||||
private readonly Action _save;
|
private readonly Action _save;
|
||||||
private readonly Action<string, Exception?> _log;
|
private readonly Action<string, string, Exception?> _log;
|
||||||
private readonly SongOverlaySettings _settings;
|
private readonly SongOverlaySettings _settings;
|
||||||
private string _accessToken = "";
|
private string _accessToken = "";
|
||||||
private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue;
|
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;
|
_settings = settings;
|
||||||
_secrets = secrets;
|
_secrets = secrets;
|
||||||
@ -130,7 +130,7 @@ internal sealed class SpotifyWebApiEnricher : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
_log("Spotify metadata enrichment failed", error);
|
_log("spotify_metadata_enrichment_failed", "Spotify metadata enrichment failed", error);
|
||||||
return null;
|
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="CornerRadius" Value="9" />
|
||||||
<Setter Property="Padding" Value="16,9" />
|
<Setter Property="Padding" Value="16,9" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Expander.pluginRoot">
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
|
||||||
<Setter Property="Margin" Value="0,1" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.nav">
|
<Style Selector="Button.nav">
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
@ -85,6 +81,9 @@
|
|||||||
<Setter Property="Padding" Value="13,10" />
|
<Setter Property="Padding" Value="13,10" />
|
||||||
<Setter Property="Margin" Value="0,2" />
|
<Setter Property="Margin" Value="0,2" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.navRoot">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.nav.selected">
|
<Style Selector="Button.nav.selected">
|
||||||
<Setter Property="Background" Value="#DDEEF0" />
|
<Setter Property="Background" Value="#DDEEF0" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource LumiPrimaryBrush}" />
|
<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 RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
private const string PathValidationContract = "voice-free-path-v1";
|
private const string PathValidationContract = "voice-free-path-v1";
|
||||||
private const string RestartObsAfterFailedStreamTestDetail = "OBS could not establish the private output. The normal destination was restored. Restart OBS before trying again.";
|
private 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 CompanionPaths _paths;
|
||||||
private readonly CompanionSettingsStore _settings;
|
private readonly CompanionSettingsStore _settings;
|
||||||
private readonly SecureCredentialStore _credentials;
|
private readonly SecureCredentialStore _credentials;
|
||||||
@ -43,8 +44,11 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
private long _lastVoiceMeterAt;
|
private long _lastVoiceMeterAt;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private readonly SingleFlightOperation _updateChecks = new();
|
private readonly SingleFlightOperation _updateChecks = new();
|
||||||
|
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||||
|
private int _reconnectLoopRunning;
|
||||||
private int _streamTestRestoreRunning;
|
private int _streamTestRestoreRunning;
|
||||||
private int _streamTestStopRequested;
|
private int _streamTestStopRequested;
|
||||||
|
private bool _streamTestTranscriptionRunning;
|
||||||
private int _obsProcessId;
|
private int _obsProcessId;
|
||||||
private int _obsOutputWidth = 1920;
|
private int _obsOutputWidth = 1920;
|
||||||
private int _obsOutputHeight = 1080;
|
private int _obsOutputHeight = 1080;
|
||||||
@ -82,6 +86,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
public event Action<string, bool>? CaptionReceived;
|
public event Action<string, bool>? CaptionReceived;
|
||||||
public event Action<string>? LogAdded;
|
public event Action<string>? LogAdded;
|
||||||
public event Func<Task>? UpdateRestartRequested;
|
public event Func<Task>? UpdateRestartRequested;
|
||||||
|
public bool TranscriptionEnabled => _settings.Current.TranscriptionEnabled;
|
||||||
|
|
||||||
public ICompanionPluginTransport CreatePluginTransport(string pluginId) =>
|
public ICompanionPluginTransport CreatePluginTransport(string pluginId) =>
|
||||||
new CompanionPluginTransport(_http, () => _credentials.Load(), pluginId);
|
new CompanionPluginTransport(_http, () => _credentials.Load(), pluginId);
|
||||||
@ -172,54 +177,126 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
await ConnectAsync(credential, cancellationToken);
|
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();
|
await _connectionGate.WaitAsync(cancellationToken);
|
||||||
_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…" });
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _socket.ConnectAsync(credential, Version, "0.1.0", null, cancellationToken);
|
var previous = _socket;
|
||||||
var bridgeInstalled = State.ObsBridgeInstalled || DetectBridgeInstallation();
|
_socket = null;
|
||||||
SetState(State with
|
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,
|
if (_disposed || !ReferenceEquals(_socket, socket)) return;
|
||||||
Connected = true,
|
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
|
||||||
ObsBridgeInstalled = bridgeInstalled,
|
_benchmarkLifetime?.Cancel();
|
||||||
Health = bridgeInstalled ? TrayHealth.Ready : TrayHealth.Degraded,
|
_streamTestTranscriptionRunning = false;
|
||||||
Detail = bridgeInstalled ? "Lumi is connected. Waiting for OBS." : "Lumi is connected. Install the managed OBS integration to continue setup.",
|
if (State.StreamTestRunning || _streamTestRecovery.Exists) _ = RestoreObsAfterStreamTestAsync("The Lumi connection closed; OBS recovery started.");
|
||||||
Host = credential.Host,
|
SetState(State with
|
||||||
LastConnectedAt = DateTimeOffset.Now
|
{
|
||||||
});
|
Connected = false,
|
||||||
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
|
BenchmarkRunning = false,
|
||||||
await SendRuntimeStateAsync(cancellationToken);
|
Health = TrayHealth.Degraded,
|
||||||
await _socket.SendAsync("readiness", new { }, _socket.SessionId, cancellationToken);
|
Detail = "Lumi is temporarily unavailable. Companion will reconnect automatically.",
|
||||||
await WriteLogAsync("connected", $"Connected to {credential.Host}.");
|
BenchmarkDetail = State.BenchmarkRunning ? "The benchmark was aborted because the Lumi connection closed." : State.BenchmarkDetail
|
||||||
await CheckForUpdatesAsync(cancellationToken);
|
});
|
||||||
|
_ = 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)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
SetState(State with { Paired = true, Connected = false, Health = TrayHealth.Degraded, Detail = $"Lumi could not be reached. {Friendly(error)}" });
|
await WriteLogAsync("reconnect_failed", error.Message);
|
||||||
await WriteLogAsync("connection_failed", error.Message);
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _reconnectLoopRunning, 0);
|
||||||
|
if (!State.Connected && !_disposed && !cancellationToken.IsCancellationRequested) ScheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunTestAsync(CancellationToken cancellationToken = default)
|
public async Task RunTestAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (State.TestRunning || State.BenchmarkRunning) return;
|
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…" });
|
SetState(State with { TestRunning = true, Detail = "Running the transcription path check…" });
|
||||||
var stages = CreateInitialTestStages().ToArray();
|
var stages = CreateInitialTestStages().ToArray();
|
||||||
TestStages = stages;
|
TestStages = stages;
|
||||||
@ -286,6 +363,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
public async Task StartStreamTestAsync(CancellationToken cancellationToken = default)
|
public async Task StartStreamTestAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (State.StreamTestRunning) return;
|
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 (_streamTestRecovery.Exists) throw new InvalidOperationException("Restore the previous OBS stream service before starting another test.");
|
||||||
if (State.StreamTestObsRestartRequired) throw new InvalidOperationException(RestartObsAfterFailedStreamTestDetail);
|
if (State.StreamTestObsRestartRequired) throw new InvalidOperationException(RestartObsAfterFailedStreamTestDetail);
|
||||||
if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect Lumi Companion before starting a private stream test.");
|
if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect Lumi Companion before starting a private stream test.");
|
||||||
@ -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"}.");
|
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.ObsBridgeRepairNeeded) throw new InvalidOperationException("Repair the managed OBS integration before starting Stream Testing.");
|
||||||
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording in OBS before starting a private test.");
|
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording in OBS before starting a private test.");
|
||||||
|
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);
|
Interlocked.Exchange(ref _streamTestStopRequested, 0);
|
||||||
SetState(State with { StreamTestDetail = "Requesting an expiring private receiver from Lumi…", Health = TrayHealth.Operating });
|
SetState(State with { StreamTestDetail = "Requesting an expiring private receiver from Lumi…", Health = TrayHealth.Operating });
|
||||||
@ -305,7 +392,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
source = new { width = _obsOutputWidth, height = _obsOutputHeight, fps = _obsOutputFps }
|
source = new { width = _obsOutputWidth, height = _obsOutputHeight, fps = _obsOutputFps }
|
||||||
}, _socket.SessionId, cancellationToken);
|
}, _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)
|
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 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 key = ReadString(ingest, "key") ?? throw new InvalidDataException("The private OBS stream credential is missing.");
|
||||||
|
|
||||||
|
var obsRedirectAttempted = false;
|
||||||
try
|
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);
|
var snapshotReply = await _obsBridge.RequestAsync("stream_test_snapshot", new { }, TimeSpan.FromSeconds(8), cancellationToken);
|
||||||
if (ReadString(snapshotReply, "state") != "snapshotted")
|
if (ReadString(snapshotReply, "state") != "snapshotted")
|
||||||
throw new InvalidOperationException(ReadString(snapshotReply, "error") ?? "OBS could not snapshot the current streaming service.");
|
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));
|
_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…" });
|
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);
|
var beginReply = await _obsBridge.RequestAsync("stream_test_begin", new { server, key, session_id = sessionId }, TimeSpan.FromSeconds(12), cancellationToken);
|
||||||
if (ReadString(beginReply, "state") != "started")
|
if (ReadString(beginReply, "state") != "started")
|
||||||
throw new InvalidOperationException(ReadString(beginReply, "error") ?? "OBS could not start the private output.");
|
throw new InvalidOperationException(ReadString(beginReply, "error") ?? "OBS could not start the private output.");
|
||||||
@ -351,20 +448,28 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
StreamTestRunning = true,
|
StreamTestRunning = true,
|
||||||
StreamTestRecoveryRequired = true,
|
StreamTestRecoveryRequired = true,
|
||||||
StreamTestSessionId = sessionId,
|
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
|
Health = TrayHealth.Operating
|
||||||
});
|
});
|
||||||
await WriteLogAsync("stream_test_started", "Private stream test started; an encrypted OBS recovery snapshot is active.");
|
await WriteLogAsync("stream_test_started", "Private stream test started; an encrypted OBS recovery snapshot is active.");
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
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 { }
|
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
|
||||||
await SetObsRestartGuardAsync(true);
|
if (obsRedirectAttempted)
|
||||||
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
|
{
|
||||||
|
await SetObsRestartGuardAsync(true);
|
||||||
|
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
|
||||||
|
}
|
||||||
SetState(State with
|
SetState(State with
|
||||||
{
|
{
|
||||||
Health = TrayHealth.Degraded,
|
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);
|
await WriteLogAsync("stream_test_start_failed", error.Message);
|
||||||
throw;
|
throw;
|
||||||
@ -379,11 +484,72 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
await SetObsRestartGuardAsync(true);
|
await SetObsRestartGuardAsync(true);
|
||||||
var sessionId = State.StreamTestSessionId ?? _streamTestRecovery.Load()?.SessionId;
|
var sessionId = State.StreamTestSessionId ?? _streamTestRecovery.Load()?.SessionId;
|
||||||
SetState(State with { StreamTestDetail = "Ending the private receiver and restoring the exact OBS service…" });
|
SetState(State with { StreamTestDetail = "Ending the private receiver and restoring the exact OBS service…" });
|
||||||
|
await StopStreamTestTranscriptionAsync(reason, cancellationToken);
|
||||||
if (_socket is not null && State.Connected && sessionId is not null)
|
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 { }
|
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);
|
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)
|
private async Task RestoreObsAfterStreamTestAsync(string detail, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (Interlocked.CompareExchange(ref _streamTestRestoreRunning, 1, 0) != 0) return;
|
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)
|
public async Task StartBenchmarkAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (State.BenchmarkRunning || State.TestRunning) return;
|
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.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.");
|
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);
|
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.");
|
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)
|
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _updateChecks.RunAsync(async operationToken =>
|
await _updateChecks.RunAsync(async operationToken =>
|
||||||
{
|
{
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(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…" });
|
SetState(State with { UpdateCheckRunning = true, UpdateDetail = "Checking for Companion updates…" });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -694,6 +916,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true });
|
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()
|
public void OpenLogsDirectory()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(_paths.LogsDirectory);
|
Directory.CreateDirectory(_paths.LogsDirectory);
|
||||||
@ -702,10 +931,20 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task ForgetDeviceAsync()
|
public async Task ForgetDeviceAsync()
|
||||||
{
|
{
|
||||||
if (_socket is not null) { await _socket.DisposeAsync(); _socket = null; }
|
await _connectionGate.WaitAsync();
|
||||||
_credentials.Remove();
|
try
|
||||||
SetState(WithBridgeState(new CompanionState(Detail: "Device removed. Pair this computer to reconnect.")));
|
{
|
||||||
await WriteLogAsync("device_removed", "Saved device credential removed locally.");
|
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)
|
private Task OnServerMessageAsync(ServerEnvelope message)
|
||||||
@ -716,6 +955,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var reason = ReadString(message.Payload, "reason") ?? "The private receiver ended.";
|
var reason = ReadString(message.Payload, "reason") ?? "The private receiver ended.";
|
||||||
Interlocked.Exchange(ref _streamTestStopRequested, 1);
|
Interlocked.Exchange(ref _streamTestStopRequested, 1);
|
||||||
|
_ = StopStreamTestTranscriptionAsync("stream_test_ended", CancellationToken.None);
|
||||||
_ = RestoreObsAfterStreamTestAsync($"{reason} OBS was restored to its previous streaming service.");
|
_ = RestoreObsAfterStreamTestAsync($"{reason} OBS was restored to its previous streaming service.");
|
||||||
}
|
}
|
||||||
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
|
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
|
||||||
@ -740,7 +980,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
else if (state.GetString() == "idle") _sessionStopSignal?.TrySetResult(true);
|
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 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;
|
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);
|
CaptionReceived?.Invoke(text, simulated);
|
||||||
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
|
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
|
||||||
if (State.BenchmarkRunning) UpdateBenchmarkCaption(message.Payload, text, final);
|
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 elapsed = Math.Max(0, (DateTimeOffset.UtcNow - _streamTestBeganAt).TotalSeconds);
|
||||||
var captionDelay = message.Payload.TryGetProperty("latency", out var latency)
|
var captionDelay = message.Payload.TryGetProperty("latency", out var latency)
|
||||||
? ReadDouble(latency, "total_ms")
|
? ReadDouble(latency, "total_ms")
|
||||||
: 0;
|
: 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
|
_ = _socket.SendAsync("stream_test_caption", new
|
||||||
{
|
{
|
||||||
session_id = State.StreamTestSessionId,
|
session_id = State.StreamTestSessionId,
|
||||||
|
caption_id = captionId,
|
||||||
|
revision,
|
||||||
|
final,
|
||||||
text,
|
text,
|
||||||
start_seconds = Math.Max(0, elapsed - 3),
|
stable_text = stableText,
|
||||||
end_seconds = elapsed + 1,
|
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
|
delay_ms = captionDelay
|
||||||
}, _socket.SessionId, _lifetimeToken());
|
}, _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."));
|
_streamTestSessionSignal.TrySetException(new InvalidOperationException(serverMessage ?? "Lumi could not create the private stream test."));
|
||||||
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
|
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
|
||||||
_benchmarkLifetime?.Cancel();
|
_benchmarkLifetime?.Cancel();
|
||||||
|
if (State.StreamTestRunning && _streamTestTranscriptionRunning && State.StreamTestSessionId is { } streamTestSessionId)
|
||||||
|
{
|
||||||
|
_streamTestTranscriptionRunning = false;
|
||||||
|
_ = ReportStreamTestCaptionStatusAsync(streamTestSessionId, "failed", serverMessage ?? "Speech recognition stopped.", CancellationToken.None);
|
||||||
|
}
|
||||||
SetState(State with
|
SetState(State with
|
||||||
{
|
{
|
||||||
Health = TrayHealth.Degraded,
|
Health = TrayHealth.Degraded,
|
||||||
@ -989,9 +1247,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow;
|
if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
var audioNeeded = State.TestRunning || (State.BenchmarkRunning
|
var audioNeeded = _settings.Current.TranscriptionEnabled &&
|
||||||
? Volatile.Read(ref _benchmarkStopping) == 0
|
(State.TestRunning || _streamTestTranscriptionRunning || (State.BenchmarkRunning
|
||||||
: State.ObsStreaming);
|
? Volatile.Read(ref _benchmarkStopping) == 0
|
||||||
|
: State.ObsStreaming));
|
||||||
if (audioNeeded && _socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame))
|
if (audioNeeded && _socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame))
|
||||||
_ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery.");
|
_ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery.");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@ -1009,7 +1268,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
program_active = source.Active,
|
program_active = source.Active,
|
||||||
source_missing = source.Missing,
|
source_missing = source.Missing,
|
||||||
single_speaker = true,
|
single_speaker = true,
|
||||||
delivery_enabled = true
|
delivery_enabled = _settings.Current.TranscriptionEnabled
|
||||||
}, _socket.SessionId, _lifetimeToken());
|
}, _socket.SessionId, _lifetimeToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1146,7 +1405,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
streaming = State.ObsStreaming,
|
streaming = State.ObsStreaming,
|
||||||
recording = State.ObsRecording,
|
recording = State.ObsRecording,
|
||||||
version = obsVersion,
|
version = obsVersion,
|
||||||
auto_start = _settings.Current.StartWithObs,
|
auto_start = _settings.Current.TranscriptionEnabled && _settings.Current.StartWithObs && !_streamTestTranscriptionRunning,
|
||||||
bridge_installed = bridge.Valid,
|
bridge_installed = bridge.Valid,
|
||||||
bridge_connected = State.ObsConnected,
|
bridge_connected = State.ObsConnected,
|
||||||
bridge_version = State.ObsBridgeLoadedVersion ?? bridge.InstalledVersion ?? bridge.Version,
|
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 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 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 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 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;
|
private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None;
|
||||||
@ -1275,13 +1550,43 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
Directory.CreateDirectory(_paths.LogsDirectory);
|
Directory.CreateDirectory(_paths.LogsDirectory);
|
||||||
PruneLogs();
|
PruneLogs();
|
||||||
var path = Path.Combine(_paths.LogsDirectory, $"companion-{DateTime.UtcNow:yyyy-MM-dd}.jsonl");
|
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);
|
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 { }
|
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()
|
private void PruneLogs()
|
||||||
{
|
{
|
||||||
var files = new DirectoryInfo(_paths.LogsDirectory).GetFiles("*.jsonl").OrderBy(file => file.CreationTimeUtc).ToList();
|
var files = new DirectoryInfo(_paths.LogsDirectory).GetFiles("*.jsonl").OrderBy(file => file.CreationTimeUtc).ToList();
|
||||||
@ -1327,7 +1632,17 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
_disposed = true;
|
_disposed = true;
|
||||||
_maintenanceLifetime.Cancel();
|
_maintenanceLifetime.Cancel();
|
||||||
_benchmarkLifetime?.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();
|
if (_obsBridge is not null) await _obsBridge.DisposeAsync();
|
||||||
_benchmarkLifetime?.Dispose(); _bridgeSelectionGate.Dispose();
|
_benchmarkLifetime?.Dispose(); _bridgeSelectionGate.Dispose();
|
||||||
_http.Dispose(); _maintenanceLifetime.Dispose();
|
_http.Dispose(); _maintenanceLifetime.Dispose();
|
||||||
|
|||||||
@ -6,6 +6,7 @@ namespace Lumi.Companion.App;
|
|||||||
public sealed record CompanionSettings(
|
public sealed record CompanionSettings(
|
||||||
bool AutoStartWithWindows = false,
|
bool AutoStartWithWindows = false,
|
||||||
bool StartWithObs = true,
|
bool StartWithObs = true,
|
||||||
|
bool TranscriptionEnabled = true,
|
||||||
bool AdvancedMode = false,
|
bool AdvancedMode = false,
|
||||||
string? PrimarySourceUuid = null,
|
string? PrimarySourceUuid = null,
|
||||||
string? PrimarySourceName = null,
|
string? PrimarySourceName = null,
|
||||||
|
|||||||
@ -152,6 +152,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button x:Name="StartStreamTestButton" Classes="primary" Content="Start private stream test" />
|
<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="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" />
|
<Button x:Name="RepairStreamTestButton" Classes="secondary" Content="Repair restoration" IsVisible="False" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@ -172,6 +173,15 @@
|
|||||||
<TextBlock Text="Transcription" Classes="pageTitle" />
|
<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" />
|
<TextBlock Text="Choose the OBS microphone Lumi should caption. Source identity remains stable if you rename it." Classes="muted" FontSize="15" />
|
||||||
</StackPanel>
|
</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">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Text="Primary microphone" FontWeight="SemiBold" />
|
<TextBlock Text="Primary microphone" FontWeight="SemiBold" />
|
||||||
<ComboBox x:Name="SourcePicker" IsEnabled="False" PlaceholderText="Open OBS after installing the managed integration" MinWidth="420" HorizontalAlignment="Left" />
|
<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 SongOverlayRuntime _songOverlay;
|
||||||
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
|
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
|
||||||
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
|
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
|
||||||
|
private readonly Dictionary<CompanionPage, PluginNavigationGroup> _pluginNavigationGroups = [];
|
||||||
private bool _allowExit;
|
private bool _allowExit;
|
||||||
private bool _renderingSources;
|
private bool _renderingSources;
|
||||||
private bool _renderingSongOverlay;
|
private bool _renderingSongOverlay;
|
||||||
|
private bool _renderingSettings;
|
||||||
|
|
||||||
public MainWindow() : this(CreateDefaultServices()) { }
|
public MainWindow() : this(CreateDefaultServices()) { }
|
||||||
|
|
||||||
@ -49,7 +51,7 @@ public partial class MainWindow : Window
|
|||||||
if (simulated) SimulatedCaption.Text = text;
|
if (simulated) SimulatedCaption.Text = text;
|
||||||
});
|
});
|
||||||
runtime.LogAdded += line => Dispatcher.UIThread.Post(() => AddLog(line));
|
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);
|
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +60,35 @@ public partial class MainWindow : Window
|
|||||||
PluginNavigation.Children.Clear();
|
PluginNavigation.Children.Clear();
|
||||||
foreach (var plugin in _plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
|
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))
|
foreach (var page in plugin.Pages.OrderBy(item => item.Order))
|
||||||
{
|
{
|
||||||
if (!Enum.TryParse<CompanionPage>(page.Key, out var parsed)) continue;
|
if (!Enum.TryParse<CompanionPage>(page.Key, out var parsed)) continue;
|
||||||
@ -67,16 +97,12 @@ public partial class MainWindow : Window
|
|||||||
button.Click += OnNavigate;
|
button.Click += OnNavigate;
|
||||||
children.Children.Add(button);
|
children.Children.Add(button);
|
||||||
_navigationButtons[parsed] = button;
|
_navigationButtons[parsed] = button;
|
||||||
|
_pluginNavigationGroups[parsed] = new PluginNavigationGroup(children, arrow);
|
||||||
}
|
}
|
||||||
var expander = new Expander
|
var group = new StackPanel { Spacing = 1 };
|
||||||
{
|
group.Children.Add(root);
|
||||||
Header = new TextBlock { Text = plugin.Descriptor.Name, FontWeight = FontWeight.SemiBold },
|
group.Children.Add(children);
|
||||||
IsExpanded = true,
|
PluginNavigation.Children.Add(group);
|
||||||
Content = children,
|
|
||||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch
|
|
||||||
};
|
|
||||||
expander.Classes.Add("pluginRoot");
|
|
||||||
PluginNavigation.Children.Add(expander);
|
|
||||||
}
|
}
|
||||||
_navigationButtons[CompanionPage.Overview] = OverviewNav;
|
_navigationButtons[CompanionPage.Overview] = OverviewNav;
|
||||||
_navigationButtons[CompanionPage.StreamTesting] = StreamTestingNav;
|
_navigationButtons[CompanionPage.StreamTesting] = StreamTestingNav;
|
||||||
@ -98,6 +124,7 @@ public partial class MainWindow : Window
|
|||||||
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
|
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
|
||||||
StartStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartStreamTestAsync(), StartStreamTestButton);
|
StartStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartStreamTestAsync(), StartStreamTestButton);
|
||||||
StopStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopStreamTestAsync(), StopStreamTestButton);
|
StopStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopStreamTestAsync(), StopStreamTestButton);
|
||||||
|
OpenStreamTestWebButton.Click += (_, _) => _runtime.OpenStreamTestingWebUi();
|
||||||
RepairStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RepairStreamTestRecoveryAsync(), RepairStreamTestButton);
|
RepairStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RepairStreamTestRecoveryAsync(), RepairStreamTestButton);
|
||||||
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
|
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
|
||||||
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
|
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
|
||||||
@ -110,6 +137,7 @@ public partial class MainWindow : Window
|
|||||||
ConnectionUpdateBridgeButton.Click += async (_, _) => await InstallBridgeAsync(ConnectionUpdateBridgeButton);
|
ConnectionUpdateBridgeButton.Click += async (_, _) => await InstallBridgeAsync(ConnectionUpdateBridgeButton);
|
||||||
RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync();
|
RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync();
|
||||||
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
|
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
|
||||||
|
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
|
||||||
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
||||||
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
|
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
|
||||||
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
|
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 pages) item.Value.IsVisible = item.Key == page;
|
||||||
foreach (var item in _navigationButtons)
|
foreach (var item in _navigationButtons)
|
||||||
item.Value.Classes.Set("selected", item.Key == page);
|
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)
|
private async void OnNextAction(object? sender, RoutedEventArgs args)
|
||||||
@ -261,6 +297,30 @@ public partial class MainWindow : Window
|
|||||||
finally { SaveSettingsButton.IsEnabled = true; }
|
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()
|
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");
|
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" }));
|
StatusMark.Background = new SolidColorBrush(Color.Parse(state.Health switch { TrayHealth.Ready => "#176B75", TrayHealth.Operating => "#23845B", TrayHealth.Failed => "#BD4D4D", _ => "#A96612" }));
|
||||||
OverviewDetail.Text = state.Detail;
|
OverviewDetail.Text = state.Detail;
|
||||||
PairingStepSymbol.Text = state.Paired ? "✓" : "○";
|
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 ? "◐" : "○";
|
ObsStepSymbol.Text = state.ObsBridgeUpdateAvailable ? "!" : state.ObsConnected ? "✓" : state.ObsBridgeInstalled ? "◐" : "○";
|
||||||
ObsStepDetail.Text = state.ObsBridgeUpdateAvailable
|
ObsStepDetail.Text = state.ObsBridgeUpdateAvailable
|
||||||
? $"Update required ({state.ObsBridgeLoadedVersion ?? state.ObsBridgeInstalledVersion ?? "unknown"} → {state.ObsBridgeBundledVersion ?? "current"})"
|
? $"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;
|
ReconnectButton.IsEnabled = state.Paired && !state.Connected;
|
||||||
ForgetButton.IsEnabled = state.Paired;
|
ForgetButton.IsEnabled = state.Paired;
|
||||||
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
|
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
|
||||||
RunTestButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
RunTestButton.IsEnabled = _settings.Current.TranscriptionEnabled && !state.TestRunning && !state.BenchmarkRunning && !state.StreamTestRunning;
|
||||||
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
StartBenchmarkButton.IsEnabled = _settings.Current.TranscriptionEnabled && !state.TestRunning && !state.BenchmarkRunning && !state.StreamTestRunning;
|
||||||
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
||||||
|
TranscriptionEnabledToggle.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
||||||
BenchmarkStatusText.Text = state.BenchmarkDetail;
|
BenchmarkStatusText.Text = state.BenchmarkDetail;
|
||||||
StreamTestStatusTitle.Text = state.StreamTestRunning
|
StreamTestStatusTitle.Text = state.StreamTestRunning
|
||||||
? "PRIVATE TEST ACTIVE"
|
? "PRIVATE TEST ACTIVE"
|
||||||
@ -324,8 +385,10 @@ public partial class MainWindow : Window
|
|||||||
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
|
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
|
||||||
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
|
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
|
||||||
StreamTestCongestionText.Text = state.StreamTestRunning ? $"{state.StreamTestCongestion:P0}" : "—";
|
StreamTestCongestionText.Text = state.StreamTestRunning ? $"{state.StreamTestCongestion:P0}" : "—";
|
||||||
StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsBridgeUpdateAvailable && !state.ObsBridgeRepairNeeded && !state.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired && !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;
|
StopStreamTestButton.IsEnabled = state.StreamTestRunning;
|
||||||
|
OpenStreamTestWebButton.IsVisible = state.StreamTestRunning;
|
||||||
|
OpenStreamTestWebButton.IsEnabled = state.StreamTestRunning && state.Host is not null;
|
||||||
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
|
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
|
||||||
UpdatePanel.IsVisible = state.UpdateAvailable;
|
UpdatePanel.IsVisible = state.UpdateAvailable;
|
||||||
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
|
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)
|
else if (!state.Connected)
|
||||||
{
|
{
|
||||||
NextActionTitle.Text = "Reconnect to Lumi";
|
NextActionTitle.Text = "Lumi is temporarily unavailable";
|
||||||
NextActionDetail.Text = "Your device is paired, but Lumi cannot currently be reached.";
|
NextActionDetail.Text = "Companion is reconnecting automatically in the background.";
|
||||||
NextActionButton.Content = "Retry connection";
|
NextActionButton.Content = "Retry now";
|
||||||
}
|
}
|
||||||
else if (state.ObsBridgeUpdateAvailable)
|
else if (state.ObsBridgeUpdateAvailable)
|
||||||
{
|
{
|
||||||
@ -383,12 +446,24 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void RenderSettings(CompanionSettings settings)
|
private void RenderSettings(CompanionSettings settings)
|
||||||
{
|
{
|
||||||
AutoStartToggle.IsChecked = settings.AutoStartWithWindows;
|
_renderingSettings = true;
|
||||||
StartWithObsToggle.IsChecked = settings.StartWithObs;
|
try
|
||||||
AdvancedToggle.IsChecked = settings.AdvancedMode;
|
{
|
||||||
AdvancedPanel.IsVisible = settings.AdvancedMode;
|
AutoStartToggle.IsChecked = settings.AutoStartWithWindows;
|
||||||
if (settings.PrimarySourceUuid is not null)
|
StartWithObsToggle.IsChecked = settings.StartWithObs;
|
||||||
SourcePicker.SelectedItem = _runtime.ObsSources.FirstOrDefault(source => source.Uuid == settings.PrimarySourceUuid);
|
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 ? "✓" : "○";
|
TestStepSymbol.Text = valid ? "✓" : "○";
|
||||||
TestStepDetail.Text = currentPass ? "Passed just now; this result will be reused while the relevant setup stays unchanged." : _runtime.State.PathTestDetail;
|
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.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)
|
private void RenderBenchmark(BenchmarkSnapshot benchmark)
|
||||||
@ -468,6 +546,8 @@ public partial class MainWindow : Window
|
|||||||
VoiceLevelMeter.Foreground = new SolidColorBrush(Color.Parse(color));
|
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)
|
private static void RenderMetric(Panel panel, MetricStatistics metric, Func<double, string> formatter)
|
||||||
{
|
{
|
||||||
panel.Children.Clear();
|
panel.Children.Clear();
|
||||||
|
|||||||
@ -11,11 +11,21 @@ internal sealed class TranscriptionPluginContribution : ICompanionPluginContribu
|
|||||||
_runtime = runtime;
|
_runtime = runtime;
|
||||||
Actions =
|
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(
|
new CompanionPluginAction(
|
||||||
"test",
|
"test",
|
||||||
() => _runtime.State.TestRunning || _runtime.State.BenchmarkRunning ? "Transcription test running…" : "Run transcription test",
|
() => _runtime.State.TestRunning || _runtime.State.BenchmarkRunning ? "Transcription test running…" : "Run transcription test",
|
||||||
cancellationToken => _runtime.RunTestAsync(cancellationToken),
|
cancellationToken => _runtime.RunTestAsync(cancellationToken),
|
||||||
() => !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
|
() => _runtime.TranscriptionEnabled && !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
|
||||||
10)
|
10)
|
||||||
];
|
];
|
||||||
_runtime.StateChanged += _ => Changed?.Invoke();
|
_runtime.StateChanged += _ => Changed?.Invoke();
|
||||||
@ -37,15 +47,17 @@ internal sealed class TranscriptionPluginContribution : ICompanionPluginContribu
|
|||||||
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||||
|
|
||||||
public CompanionPluginStatus Status => new(
|
public CompanionPluginStatus Status => new(
|
||||||
_runtime.State.Health switch
|
!_runtime.TranscriptionEnabled
|
||||||
|
? CompanionPluginHealth.Healthy
|
||||||
|
: _runtime.State.Health switch
|
||||||
{
|
{
|
||||||
TrayHealth.Ready => CompanionPluginHealth.Healthy,
|
TrayHealth.Ready => CompanionPluginHealth.Healthy,
|
||||||
TrayHealth.Operating => CompanionPluginHealth.Healthy,
|
TrayHealth.Operating => CompanionPluginHealth.Healthy,
|
||||||
TrayHealth.Failed => CompanionPluginHealth.Error,
|
TrayHealth.Failed => CompanionPluginHealth.Error,
|
||||||
_ => CompanionPluginHealth.Warning
|
_ => CompanionPluginHealth.Warning
|
||||||
},
|
},
|
||||||
_runtime.State.Summary,
|
_runtime.TranscriptionEnabled ? _runtime.State.Summary : "Captions off",
|
||||||
_runtime.State.Detail);
|
_runtime.TranscriptionEnabled ? _runtime.State.Detail : "Microphone audio is not sent for speech recognition, and captions are not included.");
|
||||||
|
|
||||||
public event Action? Changed;
|
public event Action? Changed;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Lumi.Companion.Abstractions;
|
||||||
using Lumi.Companion.Core;
|
using Lumi.Companion.Core;
|
||||||
|
|
||||||
static void Assert(bool condition, string message)
|
static void Assert(bool condition, string message)
|
||||||
@ -39,4 +40,14 @@ release.SetResult();
|
|||||||
await Task.WhenAll(first, second);
|
await Task.WhenAll(first, second);
|
||||||
Assert(concurrentCalls == 1 && !operation.IsRunning, "Rapid update checks did not share and reset the active operation.");
|
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
|
```js
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const logger = createLogger("updater");
|
const logger = createLogger("core:updater", { category: "updates" });
|
||||||
|
|
||||||
logger.info("Update check completed", { version }, {
|
logger.info("Update check completed", { version }, {
|
||||||
category: "updates",
|
|
||||||
event: "update_check_completed"
|
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
|
that injected logger when practical. A plugin module that must log outside
|
||||||
`init()` may create a logger named `plugin:<plugin-id>`.
|
`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:
|
Use:
|
||||||
|
|
||||||
- `debug` for detailed, low-value troubleshooting information.
|
- `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
|
Do not include passwords, tokens, cookies, pairing secrets, authorization
|
||||||
headers, or full request bodies in messages or metadata. Redaction is a safety
|
headers, signature fragments, full request bodies, or full third-party payloads
|
||||||
net, not a reason to collect secrets.
|
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
|
## 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
|
work history. Operational failures in such a feature still belong in the core
|
||||||
logger.
|
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
|
## Console output
|
||||||
|
|
||||||
Direct `console.*` calls are captured after `hookConsole()` starts, but they lose
|
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
|
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
|
runtime code, including startup and shutdown paths. Console output remains
|
||||||
build scripts where the terminal is the intended consumer.
|
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
|
## Retention and context
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,13 @@ portable fallback.
|
|||||||
7. Use the dedicated transcription test when measuring real speech accuracy,
|
7. Use the dedicated transcription test when measuring real speech accuracy,
|
||||||
confidence, latency, and input level.
|
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
|
Production device HTTP and WebSocket traffic requires HTTPS/WSS. Plain
|
||||||
HTTP/WebSocket is allowed only for a device paired from the exact matching
|
HTTP/WebSocket is allowed only for a device paired from the exact matching
|
||||||
loopback Lumi origin and only while both sides remain on loopback.
|
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
|
- Live sessions require an active OBS stream. Tests use simulated delivery and
|
||||||
never publish captions to Twitch.
|
never publish captions to Twitch.
|
||||||
- Disconnects pause delivery and retain the session/model for a bounded grace
|
- 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;
|
- Manual benchmark completion waits for the final confidence-bearing caption;
|
||||||
silence ends a test after ten seconds.
|
silence ends a test after ten seconds.
|
||||||
- Device revocation takes effect on the next authenticated request or
|
- 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
|
- The MediaMTX API, metrics, and HLS listeners bind only to loopback. The Admin
|
||||||
WebUI accesses HLS through an authenticated, no-store, same-origin streaming
|
WebUI accesses HLS through an authenticated, no-store, same-origin streaming
|
||||||
proxy. RTSP, WebRTC, SRT, playback, pprof, and recording are disabled.
|
proxy. RTSP, WebRTC, SRT, playback, pprof, and recording are disabled.
|
||||||
- Stable transcription captions are reused as a private WebVTT side channel.
|
- Companion explicitly starts a server-hosted `stream_test` transcription
|
||||||
No second transcription pipeline and no local speech inference are added.
|
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
|
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
|
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
|
## Ingest network configuration
|
||||||
|
|
||||||
Lumi reuses the validated hostname from the Companion pairing record unless
|
Lumi derives the network policy from the authenticated Companion pairing
|
||||||
`LUMI_STREAM_TEST_INGEST_HOST` overrides it. A session receives an exact
|
origin. A Companion paired through `localhost`, `127.0.0.1`, or `::1` always
|
||||||
`lumi-test/<uuid>` path and high-entropy publisher credentials. Only that path
|
receives loopback RTMP; production ingest overrides are deliberately ignored
|
||||||
can be published, and the generated MediaMTX configuration contains SHA-256
|
for that local development session. A non-local Companion must have paired
|
||||||
credential hashes rather than plaintext credentials.
|
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
|
```text
|
||||||
LUMI_STREAM_TEST_TRANSPORT=rtmps
|
|
||||||
LUMI_STREAM_TEST_INGEST_PORT=19350
|
LUMI_STREAM_TEST_INGEST_PORT=19350
|
||||||
LUMI_STREAM_TEST_PUBLIC_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
|
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
|
port-forward maps it to the MediaMTX listener. Use the existing paired Lumi
|
||||||
hostname; no extra Stream Testing domain is required.
|
hostname; no extra Stream Testing domain is required.
|
||||||
|
|
||||||
Unencrypted RTMP is allowed automatically only when the paired hostname
|
Operators that already manage a matching certificate can override Lumi's
|
||||||
resolves entirely to loopback or private/LAN addresses:
|
managed certificate by setting both paths:
|
||||||
|
|
||||||
```text
|
```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
|
Unencrypted RTMP is selected automatically only for an authenticated loopback
|
||||||
for operators who understand that publisher credentials and media can cross
|
pairing. It cannot be enabled for a non-local pairing through an environment
|
||||||
the network without transport encryption. It defaults to false. Restrict the
|
override. Restrict the ingest port at the host firewall in either mode.
|
||||||
ingest port at the host firewall to the streaming network in either mode.
|
|
||||||
|
|
||||||
Optional session limits remain:
|
Optional session limits remain:
|
||||||
|
|
||||||
|
|||||||
374
package-lock.json
generated
374
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"acme-client": "^5.4.0",
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"better-sqlite3-session-store": "^0.1.0",
|
"better-sqlite3-session-store": "^0.1.0",
|
||||||
@ -87,6 +88,163 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.61.1",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
@ -167,6 +325,45 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/adm-zip": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
||||||
@ -176,6 +373,41 @@
|
|||||||
"node": ">=14.0"
|
"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": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@ -188,6 +420,20 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@ -210,6 +456,18 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@ -808,6 +1066,26 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
@ -992,6 +1270,42 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@ -1437,6 +1760,15 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||||
@ -1447,6 +1779,24 @@
|
|||||||
"once": "^1.3.1"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
@ -1525,6 +1875,12 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@ -1834,6 +2190,24 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"acme-client": "^5.4.0",
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"better-sqlite3-session-store": "^0.1.0",
|
"better-sqlite3-session-store": "^0.1.0",
|
||||||
|
|||||||
@ -5,7 +5,7 @@ const { Permissions } = require("discord.js");
|
|||||||
const { ensureUserForIdentity } = require("../../src/services/users");
|
const { ensureUserForIdentity } = require("../../src/services/users");
|
||||||
const { createLogger } = require("../../src/services/logger");
|
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 PLUGIN_ID = "auto-vc";
|
||||||
const DEFAULT_TEMPLATE = "[username]'s room";
|
const DEFAULT_TEMPLATE = "[username]'s room";
|
||||||
@ -130,7 +130,7 @@ module.exports = {
|
|||||||
|
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
bootstrapRooms(discordClient, db, state, settings).catch((error) => {
|
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) => {
|
discordClient.on("voiceStateUpdate", (oldState, newState) => {
|
||||||
handleVoiceStateUpdate(oldState, newState, db, settings, state);
|
handleVoiceStateUpdate(oldState, newState, db, settings, state);
|
||||||
@ -583,7 +583,7 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
|
|||||||
|
|
||||||
if (lobby && newState.channelId !== oldState.channelId) {
|
if (lobby && newState.channelId !== oldState.channelId) {
|
||||||
createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => {
|
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 {
|
try {
|
||||||
await channel.delete("Auto VC cleanup");
|
await channel.delete("Auto VC cleanup");
|
||||||
} catch (error) {
|
} 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);
|
await member.voice.setChannel(channel);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to move member", error);
|
logger.error("Failed to move member", error, { event: "member_move_failed" });
|
||||||
try {
|
try {
|
||||||
await member.edit({ channel: channel.id });
|
await member.edit({ channel: channel.id });
|
||||||
return true;
|
return true;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logger.error("Fallback move failed", fallbackError);
|
logger.error("Fallback move failed", fallbackError, { event: "member_move_fallback_failed" });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1381,7 +1381,7 @@ function startNameRefreshTimer(discordClient, db, settings, state) {
|
|||||||
}
|
}
|
||||||
const runSweep = () => {
|
const runSweep = () => {
|
||||||
refreshRoomNames(discordClient, db, settings, state).catch((error) => {
|
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();
|
runSweep();
|
||||||
@ -1411,7 +1411,7 @@ async function refreshRoomNames(discordClient, db, settings, state) {
|
|||||||
const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName);
|
const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName);
|
||||||
if (desiredName && desiredName !== channel.name) {
|
if (desiredName && desiredName !== channel.name) {
|
||||||
await channel.setName(desiredName).catch((error) => {
|
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 = () => {
|
const runSweep = () => {
|
||||||
sweepRooms(discordClient, db, state).catch((error) => {
|
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();
|
runSweep();
|
||||||
|
|||||||
@ -2,7 +2,7 @@ const crypto = require("crypto");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { createLogger } = require("../../src/services/logger");
|
const { createLogger } = require("../../src/services/logger");
|
||||||
|
|
||||||
const logger = createLogger("birthday");
|
const logger = createLogger("plugin:birthday", { category: "plugin" });
|
||||||
|
|
||||||
const PLUGIN_ID = "birthday";
|
const PLUGIN_ID = "birthday";
|
||||||
const TIMER_KEY = Symbol.for("lumi.birthday.interval");
|
const TIMER_KEY = Symbol.for("lumi.birthday.interval");
|
||||||
@ -542,9 +542,13 @@ function restartScheduler({ db, discordClient }) {
|
|||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
return;
|
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(() => {
|
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);
|
}, config.birthday_check_interval_minutes * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const {
|
|||||||
validateUploadedFile
|
validateUploadedFile
|
||||||
} = require("../../src/services/upload-security");
|
} = require("../../src/services/upload-security");
|
||||||
|
|
||||||
const logger = createLogger("economy-framework");
|
const logger = createLogger("plugin:economy-framework", { category: "plugin" });
|
||||||
|
|
||||||
const PLUGIN_ID = "economy-framework";
|
const PLUGIN_ID = "economy-framework";
|
||||||
const LEGACY_STEM = ["echo", "nomy"].join("");
|
const LEGACY_STEM = ["echo", "nomy"].join("");
|
||||||
@ -1426,7 +1426,9 @@ function startActivityRewardFlusher(db) {
|
|||||||
try {
|
try {
|
||||||
flushActivityRewards(db);
|
flushActivityRewards(db);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Activity reward flush failed", error);
|
logger.error("Activity reward flush failed", error, {
|
||||||
|
event: "activity_reward_flush_failed"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
}
|
}
|
||||||
@ -1492,7 +1494,9 @@ function flushActivityRewards(db) {
|
|||||||
"DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?"
|
"DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?"
|
||||||
).run(group.userId, group.hourStart);
|
).run(group.userId, group.hourStart);
|
||||||
} catch (error) {
|
} 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 path = require("path");
|
||||||
const { createLogger } = require("../../src/services/logger");
|
const { createLogger } = require("../../src/services/logger");
|
||||||
|
|
||||||
const logger = createLogger("expression-interaction");
|
const logger = createLogger("plugin:expression-interaction", { category: "plugin" });
|
||||||
|
|
||||||
const DEFAULT_ACTIONS = [
|
const DEFAULT_ACTIONS = [
|
||||||
{ id: "hug", verb: "hugs", past: "hugged" },
|
{ id: "hug", verb: "hugs", past: "hugged" },
|
||||||
@ -675,7 +675,9 @@ function writeCommandsManifest(config) {
|
|||||||
const target = path.join(pluginMeta.dir, "cmds.json");
|
const target = path.join(pluginMeta.dir, "cmds.json");
|
||||||
fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8");
|
fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8");
|
||||||
} catch (error) {
|
} 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();
|
ensureDataDirs();
|
||||||
if (!repoIndexer.loadIndex()) {
|
if (!repoIndexer.loadIndex()) {
|
||||||
try { repoIndexer.refreshIndex(); }
|
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();
|
let config = getConfig();
|
||||||
metrics.configureRetention(config.work_history_retention);
|
metrics.configureRetention(config.work_history_retention);
|
||||||
@ -376,7 +380,11 @@ module.exports = {
|
|||||||
sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3),
|
sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3),
|
||||||
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
|
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
|
||||||
].filter((check) => !check.valid);
|
].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) => ({
|
const models = modelManifest.models.map((model) => ({
|
||||||
...model,
|
...model,
|
||||||
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
||||||
@ -1653,7 +1661,11 @@ module.exports = {
|
|||||||
locals: { endpoint: `/plugins/${PLUGIN_ID}` }
|
locals: { endpoint: `/plugins/${PLUGIN_ID}` }
|
||||||
});
|
});
|
||||||
} else {
|
} 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);
|
ensureSidebarNavItem(settings);
|
||||||
registerAssistantCommands({
|
registerAssistantCommands({
|
||||||
@ -1668,17 +1680,25 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
writeCommandsManifest(plugin?.dir || __dirname, config);
|
writeCommandsManifest(plugin?.dir || __dirname, config);
|
||||||
setImmediate(() => toolManager.loadEnabled().catch((error) =>
|
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) {
|
if (config.enabled) {
|
||||||
setImmediate(() => ensureGateRuntime().catch((error) =>
|
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();
|
const state = getRuntimeState();
|
||||||
if (shouldAutoResume(config, state)) {
|
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 () => {
|
return async () => {
|
||||||
@ -1806,8 +1826,8 @@ function logImprovementActionError(req, error) {
|
|||||||
action: cleanText(req.body?.action, 30),
|
action: cleanText(req.body?.action, 30),
|
||||||
user_id: cleanText(req.session?.user?.id, 100),
|
user_id: cleanText(req.session?.user?.id, 100),
|
||||||
code: cleanText(error?.code, 40),
|
code: cleanText(error?.code, 40),
|
||||||
message: cleanText(error?.message || error, 500)
|
error
|
||||||
});
|
}, { event: "feedback_action_failed" });
|
||||||
}
|
}
|
||||||
function parseIdList(value) {
|
function parseIdList(value) {
|
||||||
return [...new Set(String(value || "")
|
return [...new Set(String(value || "")
|
||||||
|
|||||||
@ -158,6 +158,12 @@ class CompanionGateway {
|
|||||||
service.updateObs(device.id, message.payload || {});
|
service.updateObs(device.id, message.payload || {});
|
||||||
break;
|
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": {
|
case "stream_test_caption": {
|
||||||
const service = global.lumiFrameworks?.streamTesting;
|
const service = global.lumiFrameworks?.streamTesting;
|
||||||
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
|
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
|
||||||
|
|||||||
@ -6,7 +6,7 @@ const AUDIO_HEADER_BYTES = 64;
|
|||||||
const MAX_JSON_BYTES = 64 * 1024;
|
const MAX_JSON_BYTES = 64 * 1024;
|
||||||
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
|
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
|
||||||
const CLIENT_TYPES = new Set(["hello", "ping", "source_update", "obs_state", "readiness", "start", "stop", "ack",
|
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) {
|
function parseEnvelope(input) {
|
||||||
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
|
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
|
||||||
|
|||||||
@ -42,14 +42,27 @@ class JsonlDiagnosticLog {
|
|||||||
function sanitize(value, includeCaptionText) {
|
function sanitize(value, includeCaptionText) {
|
||||||
if (Buffer.isBuffer(value)) return "[binary omitted]";
|
if (Buffer.isBuffer(value)) return "[binary omitted]";
|
||||||
if (Array.isArray(value)) return value.map((entry) => sanitize(entry, includeCaptionText));
|
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;
|
if (!value || typeof value !== "object") return value;
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const [key, child] of Object.entries(value)) {
|
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 if (!includeCaptionText && /(?:stable|uncertain|caption)_text/i.test(key)) result[key] = "[caption text disabled]";
|
||||||
else result[key] = sanitize(child, includeCaptionText);
|
else result[key] = sanitize(child, includeCaptionText);
|
||||||
}
|
}
|
||||||
return result;
|
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 = {}) {
|
async start(sessionId, options = {}) {
|
||||||
const session = this.require(sessionId);
|
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" });
|
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" });
|
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);
|
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" });
|
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 provider = new Provider();
|
||||||
const delivered = [];
|
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 coordinator = new SessionCoordinator({ provider, deliveryFactory, graceMs: 25 });
|
||||||
const { session } = coordinator.create({ id: "device" }, () => {});
|
const { session } = coordinator.create({ id: "device" }, () => {});
|
||||||
const primary = crypto.randomUUID();
|
const primary = crypto.randomUUID();
|
||||||
@ -370,6 +371,13 @@ async function verifySessionLifecycle() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
assert.equal(coordinator.status(session.id).state, "idle");
|
assert.equal(coordinator.status(session.id).state, "idle");
|
||||||
assert.equal(provider.stops, 1);
|
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();
|
await coordinator.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,7 +532,18 @@ function verifyArtifactsAndLogs(temp) {
|
|||||||
manager.installZip(runtimeEntry, runtimeZip);
|
manager.installZip(runtimeEntry, runtimeZip);
|
||||||
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(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 logsRoot = path.join(temp, "logs");
|
||||||
const logs = new JsonlDiagnosticLog(logsRoot, { retentionDays: 1, maxBytes: 100 });
|
const logs = new JsonlDiagnosticLog(logsRoot, { retentionDays: 1, maxBytes: 100 });
|
||||||
logs.append({ kind: "caption", stable_text: "hello", pcm: Buffer.alloc(10) });
|
logs.append({ kind: "caption", stable_text: "hello", pcm: Buffer.alloc(10) });
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { createLogger } = require("../../src/services/logger");
|
||||||
let overlayService = null;
|
let overlayService = null;
|
||||||
let encryptSecret = null;
|
let encryptSecret = null;
|
||||||
let decryptSecret = null;
|
let decryptSecret = null;
|
||||||
@ -60,7 +61,7 @@ const DEFAULTS = Object.freeze({
|
|||||||
|
|
||||||
const renderClients = new Set();
|
const renderClients = new Set();
|
||||||
const announcementTimers = new Map();
|
const announcementTimers = new Map();
|
||||||
let pluginLogger = console;
|
let pluginLogger = createLogger("plugin:now_playing", { category: "plugin" });
|
||||||
let activeDb = null;
|
let activeDb = null;
|
||||||
let activeSettings = null;
|
let activeSettings = null;
|
||||||
let activeClients = null;
|
let activeClients = null;
|
||||||
@ -80,7 +81,7 @@ module.exports = {
|
|||||||
activeClients = { twitchClient, youtubeClient };
|
activeClients = { twitchClient, youtubeClient };
|
||||||
placeholderApi = placeholders;
|
placeholderApi = placeholders;
|
||||||
webApi = web;
|
webApi = web;
|
||||||
pluginLogger = logger || console;
|
pluginLogger = logger || pluginLogger;
|
||||||
|
|
||||||
fs.mkdirSync(COVER_DIR, { recursive: true });
|
fs.mkdirSync(COVER_DIR, { recursive: true });
|
||||||
ensureTables(db);
|
ensureTables(db);
|
||||||
@ -244,7 +245,9 @@ module.exports = {
|
|||||||
if (changedTrack && next.playback_status === "playing") scheduleAnnouncement(db, next.track_key);
|
if (changedTrack && next.playback_status === "playing") scheduleAnnouncement(db, next.track_key);
|
||||||
return res.json({ accepted: true, server_time: Date.now(), sequence: next.sequence });
|
return res.json({ accepted: true, server_time: Date.now(), sequence: next.sequence });
|
||||||
} catch (error) {
|
} 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." });
|
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);
|
const current = getStateRow(db);
|
||||||
if (!current || current.track_key !== trackKey || current.playback_status !== "playing") return;
|
if (!current || current.track_key !== trackKey || current.playback_status !== "playing") return;
|
||||||
announceTrack({ db, state: current }).catch((error) => {
|
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);
|
}, 1800);
|
||||||
timer.unref?.();
|
timer.unref?.();
|
||||||
|
|||||||
@ -2,11 +2,12 @@ const crypto = require("crypto");
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const discord = require("discord.js");
|
const discord = require("discord.js");
|
||||||
const { log } = require("../../src/services/logger");
|
const { createLogger } = require("../../src/services/logger");
|
||||||
const { getPlatformStatus } = require("../../src/services/platforms");
|
const { getPlatformStatus } = require("../../src/services/platforms");
|
||||||
const placeholderService = require("../../src/services/placeholders");
|
const placeholderService = require("../../src/services/placeholders");
|
||||||
|
|
||||||
const PLUGIN_ID = "throne_wishlist";
|
const PLUGIN_ID = "throne_wishlist";
|
||||||
|
let pluginLogger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" });
|
||||||
const NAMESPACE = "throne";
|
const NAMESPACE = "throne";
|
||||||
const REPLAY_WINDOW_SECONDS = 300;
|
const REPLAY_WINDOW_SECONDS = 300;
|
||||||
const DEBUG_IDLE_MS = 300000;
|
const DEBUG_IDLE_MS = 300000;
|
||||||
@ -74,8 +75,10 @@ module.exports = {
|
|||||||
settings,
|
settings,
|
||||||
discordClient,
|
discordClient,
|
||||||
twitchClient,
|
twitchClient,
|
||||||
youtubeClient
|
youtubeClient,
|
||||||
|
logger
|
||||||
}) {
|
}) {
|
||||||
|
pluginLogger = logger || pluginLogger;
|
||||||
ensureTables(db);
|
ensureTables(db);
|
||||||
ensureDefaults(db);
|
ensureDefaults(db);
|
||||||
registerPlaceholderSupport(placeholders);
|
registerPlaceholderSupport(placeholders);
|
||||||
@ -446,17 +449,19 @@ function humanizePlaceholderLabel(value) {
|
|||||||
|
|
||||||
function registerStoredEndpoints({ db, webhookApi, clients }) {
|
function registerStoredEndpoints({ db, webhookApi, clients }) {
|
||||||
if (!webhookApi) {
|
if (!webhookApi) {
|
||||||
log("warn", "Throne Wishlist webhook framework unavailable");
|
pluginLogger.warn("Throne Wishlist webhook framework unavailable", null, {
|
||||||
|
event: "webhook_framework_unavailable"
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const endpoint of listEndpoints(db).filter((entry) => entry.enabled)) {
|
for (const endpoint of listEndpoints(db).filter((entry) => entry.enabled)) {
|
||||||
try {
|
try {
|
||||||
registerEndpoint({ db, webhookApi, endpoint, clients });
|
registerEndpoint({ db, webhookApi, endpoint, clients });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("error", "Throne endpoint registration failed", {
|
pluginLogger.error("Throne endpoint registration failed", {
|
||||||
endpointId: endpoint.id,
|
endpoint_id: endpoint.id,
|
||||||
message: error?.message || String(error)
|
error
|
||||||
});
|
}, { event: "endpoint_registration_failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -555,7 +560,7 @@ async function handleIncoming({
|
|||||||
"SELECT event_id FROM throne_event_deliveries WHERE event_id = ?"
|
"SELECT event_id FROM throne_event_deliveries WHERE event_id = ?"
|
||||||
).get(eventId);
|
).get(eventId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
log("info", `Webhook - Throne - ${EVENT_TYPES.includes(eventType) ? eventType : "unknown"}`, JSON.stringify({
|
pluginLogger.info("Duplicate Throne webhook ignored", {
|
||||||
provider: "throne",
|
provider: "throne",
|
||||||
authentic: true,
|
authentic: true,
|
||||||
authenticity_status: "duplicate",
|
authenticity_status: "duplicate",
|
||||||
@ -564,7 +569,7 @@ async function handleIncoming({
|
|||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
received_at: new Date(context.receivedAt).toISOString()
|
received_at: new Date(context.receivedAt).toISOString()
|
||||||
}));
|
}, { event: "webhook_duplicate_ignored" });
|
||||||
return { status: 204 };
|
return { status: 204 };
|
||||||
}
|
}
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@ -581,10 +586,10 @@ async function handleIncoming({
|
|||||||
receivedAt: context.receivedAt,
|
receivedAt: context.receivedAt,
|
||||||
clients
|
clients
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log("error", "Throne platform delivery failed", {
|
pluginLogger.error("Throne platform delivery failed", {
|
||||||
eventId,
|
event_id: eventId,
|
||||||
message: error?.message || String(error)
|
error
|
||||||
});
|
}, { event: "platform_delivery_failed" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { status: 202, body: { accepted: true } };
|
return { status: 202, body: { accepted: true } };
|
||||||
@ -669,11 +674,11 @@ async function sendPlatformMessages({ db, payload, endpoint, receivedAt, clients
|
|||||||
eventId
|
eventId
|
||||||
);
|
);
|
||||||
for (const failure of failed) {
|
for (const failure of failed) {
|
||||||
log("warn", "Throne destination send failed", {
|
pluginLogger.warn("Throne destination send failed", {
|
||||||
eventId,
|
event_id: eventId,
|
||||||
platform: failure.platform,
|
platform: failure.platform,
|
||||||
message: failure.error
|
message: failure.error
|
||||||
});
|
}, { event: "destination_send_failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -815,7 +820,7 @@ function writePayloadLog({
|
|||||||
: EVENT_TYPES.includes(eventType)
|
: EVENT_TYPES.includes(eventType)
|
||||||
? eventType
|
? eventType
|
||||||
: "unknown";
|
: "unknown";
|
||||||
log("info", `Webhook - Throne - ${logEventType}`, JSON.stringify({
|
pluginLogger.info("Throne webhook received", {
|
||||||
provider: "throne",
|
provider: "throne",
|
||||||
authentic: verification.authentic,
|
authentic: verification.authentic,
|
||||||
authenticity_status: authenticityStatus,
|
authenticity_status: authenticityStatus,
|
||||||
@ -824,10 +829,10 @@ function writePayloadLog({
|
|||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
received_at: new Date(context.receivedAt).toISOString(),
|
received_at: new Date(context.receivedAt).toISOString(),
|
||||||
payload: payload || null,
|
payload_present: Boolean(payload),
|
||||||
raw_body_preview: context.jsonError ? context.rawBodyText.slice(0, 4000) : null,
|
payload_key_count: payload && typeof payload === "object" ? Object.keys(payload).length : 0,
|
||||||
signature_prefix: (headerValue(context.headers, "x-signature-ed25519") || "").slice(0, 12)
|
raw_body_bytes: Buffer.byteLength(context.rawBody || "")
|
||||||
}));
|
}, { event: `webhook_${logEventType}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayloadPreview({ receivedAt, eventType, eventId, authentic, status }) {
|
function buildPayloadPreview({ receivedAt, eventType, eventId, authentic, status }) {
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const discord = require("discord.js");
|
const discord = require("discord.js");
|
||||||
const { Permissions } = discord;
|
const { Permissions } = discord;
|
||||||
const { ensureUserForIdentity } = require("../../src/services/users");
|
const { ensureUserForIdentity } = require("../../src/services/users");
|
||||||
const { log } = require("../../src/services/logger");
|
const { createLogger } = require("../../src/services/logger");
|
||||||
|
|
||||||
const PLUGIN_ID = "welcome_messages";
|
const PLUGIN_ID = "welcome_messages";
|
||||||
|
let pluginLogger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" });
|
||||||
const CONFIG_KEY = "config";
|
const CONFIG_KEY = "config";
|
||||||
const ALLOWED_PLACEHOLDERS = new Set([
|
const ALLOWED_PLACEHOLDERS = new Set([
|
||||||
"username",
|
"username",
|
||||||
@ -44,10 +45,13 @@ const PRONOUN_SETS = [
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: PLUGIN_ID,
|
id: PLUGIN_ID,
|
||||||
init({ web, db, discordClient, settings }) {
|
init({ web, db, discordClient, settings, logger }) {
|
||||||
ensureTables(db);
|
pluginLogger = logger || pluginLogger;
|
||||||
ensureConfig(db);
|
ensureTables(db);
|
||||||
log("info", "Welcome Messages plugin initialized");
|
ensureConfig(db);
|
||||||
|
pluginLogger.info("Welcome Messages plugin initialized", null, {
|
||||||
|
event: "plugin_initialized"
|
||||||
|
});
|
||||||
|
|
||||||
const router = web.createRouter();
|
const router = web.createRouter();
|
||||||
|
|
||||||
@ -382,40 +386,42 @@ function ensureSidebarNavItem(settings) {
|
|||||||
function attachDiscordListener(discordClient, db) {
|
function attachDiscordListener(discordClient, db) {
|
||||||
if (!discordClient || discordClient[LISTENER_KEY]) {
|
if (!discordClient || discordClient[LISTENER_KEY]) {
|
||||||
if (!discordClient) {
|
if (!discordClient) {
|
||||||
log("warn", "Welcome Messages listener not attached", {
|
pluginLogger.warn("Welcome Messages listener not attached", {
|
||||||
reason: "Discord client is unavailable"
|
reason: "Discord client is unavailable"
|
||||||
});
|
}, { event: "listener_not_attached" });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
discordClient[LISTENER_KEY] = true;
|
discordClient[LISTENER_KEY] = true;
|
||||||
discordClient.on("guildMemberAdd", (member) => {
|
discordClient.on("guildMemberAdd", (member) => {
|
||||||
handleMemberJoin(member, db).catch((error) => {
|
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", {
|
pluginLogger.info("Welcome Messages listener attached", {
|
||||||
event: "guildMemberAdd",
|
discord_event: "guildMemberAdd",
|
||||||
memberIntent: detectGuildMembersIntent(discordClient)
|
member_intent: detectGuildMembersIntent(discordClient)
|
||||||
});
|
}, { event: "listener_attached" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMemberJoin(member, db) {
|
async function handleMemberJoin(member, db) {
|
||||||
const config = getConfig(db);
|
const config = getConfig(db);
|
||||||
if (!config.enabled || !config.channelId) {
|
if (!config.enabled || !config.channelId) {
|
||||||
log("debug", "Welcome Messages skipped join", {
|
pluginLogger.debug("Welcome Messages skipped join", {
|
||||||
reason: !config.enabled ? "Plugin posting disabled" : "No welcome channel configured",
|
reason: !config.enabled ? "Plugin posting disabled" : "No welcome channel configured",
|
||||||
guildId: member?.guild?.id || null,
|
guild_id: member?.guild?.id || null,
|
||||||
userId: member?.user?.id || member?.id || null
|
user_id: member?.user?.id || member?.id || null
|
||||||
});
|
}, { event: "join_skipped" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const discordId = member?.user?.id || member?.id;
|
const discordId = member?.user?.id || member?.id;
|
||||||
if (!discordId) {
|
if (!discordId) {
|
||||||
log("warn", "Welcome Messages skipped join", {
|
pluginLogger.warn("Welcome Messages skipped join", {
|
||||||
reason: "Missing Discord user id",
|
reason: "Missing Discord user id",
|
||||||
guildId: member?.guild?.id || null
|
guild_id: member?.guild?.id || null
|
||||||
});
|
}, { event: "join_skipped" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existingIdentity = db
|
const existingIdentity = db
|
||||||
@ -427,12 +433,12 @@ async function handleMemberJoin(member, db) {
|
|||||||
const profile = ensureDiscordIdentity(member);
|
const profile = ensureDiscordIdentity(member);
|
||||||
const channelResult = await validateTextChannel(member.client, config.channelId);
|
const channelResult = await validateTextChannel(member.client, config.channelId);
|
||||||
if (!channelResult.valid || !channelResult.channel) {
|
if (!channelResult.valid || !channelResult.channel) {
|
||||||
log("warn", "Welcome Messages skipped join", {
|
pluginLogger.warn("Welcome Messages skipped join", {
|
||||||
reason: channelResult.message,
|
reason: channelResult.message,
|
||||||
guildId: member?.guild?.id || null,
|
guild_id: member?.guild?.id || null,
|
||||||
userId: discordId,
|
user_id: discordId,
|
||||||
channelId: config.channelId
|
channel_id: config.channelId
|
||||||
});
|
}, { event: "join_skipped" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pool =
|
const pool =
|
||||||
@ -441,11 +447,11 @@ async function handleMemberJoin(member, db) {
|
|||||||
: config.welcomeMessages;
|
: config.welcomeMessages;
|
||||||
const message = chooseMessage(pool) || chooseMessage(config.welcomeMessages);
|
const message = chooseMessage(pool) || chooseMessage(config.welcomeMessages);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
log("warn", "Welcome Messages skipped join", {
|
pluginLogger.warn("Welcome Messages skipped join", {
|
||||||
reason: "No enabled message templates",
|
reason: "No enabled message templates",
|
||||||
guildId: member?.guild?.id || null,
|
guild_id: member?.guild?.id || null,
|
||||||
userId: discordId
|
user_id: discordId
|
||||||
});
|
}, { event: "join_skipped" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pronoun = getSubjectPronoun(db, profile?.id);
|
const pronoun = getSubjectPronoun(db, profile?.id);
|
||||||
@ -455,21 +461,21 @@ async function handleMemberJoin(member, db) {
|
|||||||
content: rendered,
|
content: rendered,
|
||||||
allowedMentions: { parse: [] }
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
log("info", "Welcome Messages sent welcome message", {
|
pluginLogger.info("Welcome Messages sent welcome message", {
|
||||||
guildId: member?.guild?.id || null,
|
guild_id: member?.guild?.id || null,
|
||||||
userId: discordId,
|
user_id: discordId,
|
||||||
channelId: config.channelId,
|
channel_id: config.channelId,
|
||||||
returning,
|
returning,
|
||||||
pool: returning && config.welcomeBackEnabled ? "welcomeBackMessages" : "welcomeMessages",
|
pool: returning && config.welcomeBackEnabled ? "welcomeBackMessages" : "welcomeMessages",
|
||||||
messageId: message.id
|
message_id: message.id
|
||||||
});
|
}, { event: "welcome_message_sent" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("error", "Welcome Messages failed to send message", {
|
pluginLogger.error("Welcome Messages failed to send message", {
|
||||||
guildId: member?.guild?.id || null,
|
guild_id: member?.guild?.id || null,
|
||||||
userId: discordId,
|
user_id: discordId,
|
||||||
channelId: config.channelId,
|
channel_id: config.channelId,
|
||||||
error: error?.stack || error?.message || String(error)
|
error
|
||||||
});
|
}, { event: "welcome_message_send_failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -638,21 +644,23 @@ async function buildDiagnostics(discordClient, channelId) {
|
|||||||
|
|
||||||
function logStartupDiagnostics(discordClient) {
|
function logStartupDiagnostics(discordClient) {
|
||||||
const memberIntent = detectGuildMembersIntent(discordClient);
|
const memberIntent = detectGuildMembersIntent(discordClient);
|
||||||
const details = {
|
const details = {
|
||||||
discordClientAvailable: Boolean(discordClient),
|
discord_client_available: Boolean(discordClient),
|
||||||
discordClientReady: Boolean(discordClient?.readyAt),
|
discord_client_ready: Boolean(discordClient?.readyAt),
|
||||||
memberIntent
|
member_intent: memberIntent
|
||||||
};
|
};
|
||||||
if (memberIntent === "configured") {
|
if (memberIntent === "configured") {
|
||||||
log("info", "Welcome Messages diagnostics passed", details);
|
pluginLogger.info("Welcome Messages diagnostics passed", details, {
|
||||||
return;
|
event: "startup_diagnostics_passed"
|
||||||
}
|
});
|
||||||
log("warn", "Welcome Messages diagnostics warning", {
|
return;
|
||||||
...details,
|
}
|
||||||
message:
|
pluginLogger.warn("Welcome Messages diagnostics warning", {
|
||||||
"Guild member join events require the runtime Discord client to start with GuildMembers/GUILD_MEMBERS and the Discord Developer Portal Server Members Intent enabled."
|
...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) {
|
function detectGuildMembersIntent(discordClient) {
|
||||||
if (!discordClient) {
|
if (!discordClient) {
|
||||||
|
|||||||
@ -150,6 +150,8 @@ async function main() {
|
|||||||
assert(updateSource.includes("SameOrigin"));
|
assert(updateSource.includes("SameOrigin"));
|
||||||
assert(updateSource.includes("SupportsPendingBuild"));
|
assert(updateSource.includes("SupportsPendingBuild"));
|
||||||
assert(updateSource.includes("BuildPending"));
|
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("supports_pending_build"));
|
||||||
assert(transcriptionSource.includes("build_pending"));
|
assert(transcriptionSource.includes("build_pending"));
|
||||||
const applierSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "UpdateApplier.cs"), "utf8");
|
const applierSource = fs.readFileSync(path.join(__dirname, "..", "companion", "src", "Lumi.Companion.App", "UpdateApplier.cs"), "utf8");
|
||||||
|
|||||||
@ -33,6 +33,9 @@ try {
|
|||||||
pluginLog.warn("Example warning", {
|
pluginLog.warn("Example warning", {
|
||||||
access_token: "must-not-be-stored",
|
access_token: "must-not-be-stored",
|
||||||
nested: { password: "also-secret", safe: "visible" },
|
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"
|
url: "https://example.com/run?token=hidden&mode=safe"
|
||||||
}, { event: "example_warning", requestId: "request-123" });
|
}, { event: "example_warning", requestId: "request-123" });
|
||||||
logger.withLogContext({ source: "core:test", category: "verification", event: "context_entry" }, () => {
|
logger.withLogContext({ source: "core:test", category: "verification", event: "context_entry" }, () => {
|
||||||
@ -48,6 +51,9 @@ try {
|
|||||||
assert(warning.details.includes("[REDACTED]"));
|
assert(warning.details.includes("[REDACTED]"));
|
||||||
assert.equal(warning.details.includes("must-not-be-stored"), false);
|
assert.equal(warning.details.includes("must-not-be-stored"), false);
|
||||||
assert.equal(warning.details.includes("also-secret"), 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.equal(warning.details.includes("token=hidden"), false);
|
||||||
assert(warning.details.includes("visible"));
|
assert(warning.details.includes("visible"));
|
||||||
|
|
||||||
@ -78,8 +84,163 @@ try {
|
|||||||
assert.match(serverSource, /summarizeLogs\(query\)/);
|
assert.match(serverSource, /summarizeLogs\(query\)/);
|
||||||
assert.match(serverSource, /"admin_action"/);
|
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 {
|
} finally {
|
||||||
try { database?.db?.close(); } catch {}
|
try { database?.db?.close(); } catch {}
|
||||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
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,
|
sourceFor,
|
||||||
validateHostname
|
validateHostname
|
||||||
} = require("../src/services/stream-testing");
|
} = require("../src/services/stream-testing");
|
||||||
|
const {
|
||||||
|
StreamTestCertificateManager,
|
||||||
|
normalizeCertificateHostname
|
||||||
|
} = require("../src/services/stream-test-certificates");
|
||||||
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
|
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
|
||||||
|
|
||||||
class FakeRuntime extends EventEmitter {
|
class FakeRuntime extends EventEmitter {
|
||||||
@ -186,11 +190,13 @@ async function verifyStreamService() {
|
|||||||
const old = {
|
const old = {
|
||||||
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
|
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
|
||||||
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
|
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_INGEST_HOST = "public.example.test";
|
||||||
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
|
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps";
|
||||||
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
|
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 runtime = new FakeRuntime();
|
||||||
const service = new StreamTestingService({ runtime, timer: false });
|
const service = new StreamTestingService({ runtime, timer: false });
|
||||||
const sent = [];
|
const sent = [];
|
||||||
@ -201,6 +207,7 @@ async function verifyStreamService() {
|
|||||||
(type, payload) => sent.push({ type, payload })
|
(type, payload) => sent.push({ type, payload })
|
||||||
);
|
);
|
||||||
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/);
|
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.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(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder");
|
||||||
assert.equal(runtime.session.path, `lumi-test/${created.id}`);
|
assert.equal(runtime.session.path, `lumi-test/${created.id}`);
|
||||||
@ -223,13 +230,88 @@ async function verifyStreamService() {
|
|||||||
total_frames: 10000,
|
total_frames: 10000,
|
||||||
congestion: 0
|
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(
|
assert.throws(
|
||||||
() => service.addCaption("device-2", { session_id: created.id, text: "Wrong device" }),
|
() => service.addCaption("device-2", { session_id: created.id, text: "Wrong device" }),
|
||||||
/not active for this Companion/
|
/not active for this Companion/
|
||||||
);
|
);
|
||||||
service.reportPlayer({ session_id: created.id, latency_seconds: 1.2, buffer_seconds: 1.5, stalls: 0, errors: 0 });
|
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;
|
const endedSession = service.active;
|
||||||
await service.stop({ session_id: created.id, reason: "Verification complete." });
|
await service.stop({ session_id: created.id, reason: "Verification complete." });
|
||||||
assert.equal(runtime.cleared, 1);
|
assert.equal(runtime.cleared, 1);
|
||||||
@ -270,7 +352,8 @@ async function verifyStreamService() {
|
|||||||
await service.close();
|
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.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.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("stream.example.com"), "stream.example.com");
|
||||||
assert.equal(validateHostname("https://stream.example.com"), "");
|
assert.equal(validateHostname("https://stream.example.com"), "");
|
||||||
assert.equal(validateHostname("[::1]"), "[::1]");
|
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(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.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]);
|
||||||
assert.match(rewriteManifest(
|
assert.match(rewriteManifest(
|
||||||
@ -314,17 +399,20 @@ async function main() {
|
|||||||
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
|
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
|
||||||
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
|
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
|
||||||
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
|
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
|
||||||
key: process.env.LUMI_STREAM_TEST_TLS_KEY,
|
key: process.env.LUMI_STREAM_TEST_TLS_KEY
|
||||||
insecure: process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
|
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
|
||||||
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
|
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
|
||||||
delete process.env.LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE;
|
const forcedSecure = await resolveIngestConfiguration(
|
||||||
await assert.rejects(
|
{ pairing_host: "https://stream.example.test" },
|
||||||
() => resolveIngestConfiguration({ pairing_host: "https://8.8.8.8" }),
|
{ certificateManager: { resolve: async () => ({ certificate: "managed-cert.pem", privateKey: "managed-key.pem" }) } }
|
||||||
(error) => error.code === "STREAM_TEST_INSECURE_REMOTE"
|
|
||||||
);
|
);
|
||||||
|
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 cert = path.join(tempRoot, "cert.pem");
|
||||||
const key = path.join(tempRoot, "key.pem");
|
const key = path.join(tempRoot, "key.pem");
|
||||||
fs.writeFileSync(cert, "test certificate");
|
fs.writeFileSync(cert, "test certificate");
|
||||||
@ -335,19 +423,22 @@ async function main() {
|
|||||||
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" });
|
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" });
|
||||||
assert.equal(secure.transport, "rtmps");
|
assert.equal(secure.transport, "rtmps");
|
||||||
assert.equal(secure.host, "lumi.example.test");
|
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 {
|
} finally {
|
||||||
for (const [key, value] of Object.entries({
|
for (const [key, value] of Object.entries({
|
||||||
LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host,
|
LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host,
|
||||||
LUMI_STREAM_TEST_TRANSPORT: oldNetwork.transport,
|
LUMI_STREAM_TEST_TRANSPORT: oldNetwork.transport,
|
||||||
LUMI_STREAM_TEST_TLS_CERT: oldNetwork.cert,
|
LUMI_STREAM_TEST_TLS_CERT: oldNetwork.cert,
|
||||||
LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key,
|
LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key
|
||||||
LUMI_STREAM_TEST_ALLOW_INSECURE_REMOTE: oldNetwork.insecure
|
|
||||||
})) {
|
})) {
|
||||||
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
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))));
|
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
|
||||||
assert.strictEqual(parsed.type, type);
|
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 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 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 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 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\/runtime\/install/);
|
||||||
assert.match(server, /admin\/stream-testing\/media\/:id\/\*/);
|
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, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/);
|
||||||
assert.match(server, /streamTestingService\.proxyMedia/);
|
assert.match(server, /streamTestingService\.proxyMedia/);
|
||||||
assert.match(service, /INACTIVITY_MS/);
|
assert.match(service, /INACTIVITY_MS/);
|
||||||
assert.match(service, /caption_delay/);
|
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(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
|
||||||
assert(!/shell:\s*true/.test(runtime));
|
assert(!/shell:\s*true/.test(runtime));
|
||||||
assert.match(gateway, /await service\.create/);
|
assert.match(gateway, /await service\.create/);
|
||||||
@ -383,11 +487,54 @@ async function main() {
|
|||||||
assert.match(companion, /StreamTestRecoveryStore/);
|
assert.match(companion, /StreamTestRecoveryStore/);
|
||||||
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
|
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
|
||||||
assert.match(companion, /StreamTestObsRestartRequired/);
|
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, /LastObsProcessId/);
|
||||||
assert.match(companion, /Restart OBS before trying again/);
|
assert.match(companion, /Restart OBS before trying again/);
|
||||||
assert.match(webUi, /data-runtime-install/);
|
assert.match(webUi, /data-runtime-install/);
|
||||||
assert.match(webUi, /data-runtime-health/);
|
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(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"));
|
assert(!webUi.includes("Run test pattern"));
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
@ -397,6 +544,55 @@ async function main() {
|
|||||||
console.log("Managed MediaMTX Stream Testing verification passed.");
|
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) => {
|
main().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
83
src/main.js
83
src/main.js
@ -37,24 +37,26 @@ async function main() {
|
|||||||
const runtimeEnvironment = installGlobalRuntimeEnvironment();
|
const runtimeEnvironment = installGlobalRuntimeEnvironment();
|
||||||
migrate();
|
migrate();
|
||||||
ensureDefaults();
|
ensureDefaults();
|
||||||
|
logger.hookConsole();
|
||||||
|
const runtimeLog = logger.createLogger("core:runtime", { category: "lifecycle" });
|
||||||
const configuredRemote = getSetting("git_remote", "origin");
|
const configuredRemote = getSetting("git_remote", "origin");
|
||||||
const configuredBranch = getSetting("git_branch", "main");
|
const configuredBranch = getSetting("git_branch", "main");
|
||||||
const completedDeployment = await completePendingBranchDeployment(configuredRemote, configuredBranch);
|
const completedDeployment = await completePendingBranchDeployment(configuredRemote, configuredBranch);
|
||||||
if (completedDeployment) {
|
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 });
|
requestRestart({ delayMs: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const completedPluginSync = await completePendingBundledPluginSync(configuredRemote);
|
const completedPluginSync = await completePendingBundledPluginSync(configuredRemote);
|
||||||
if (completedPluginSync) {
|
if (completedPluginSync) {
|
||||||
console.log(
|
runtimeLog.info("Completed bundled plugin synchronization", {
|
||||||
`Completed bundled plugin synchronization for Lumi ${completedPluginSync.version} ` +
|
version: completedPluginSync.version,
|
||||||
`(${completedPluginSync.plugin_ids.length} plugins).`
|
plugin_count: completedPluginSync.plugin_ids.length
|
||||||
);
|
}, { event: "bundled_plugin_sync_completed" });
|
||||||
}
|
}
|
||||||
registerCorePlaceholders();
|
registerCorePlaceholders();
|
||||||
logger.hookConsole();
|
|
||||||
const runtimeLog = logger.createLogger("core:runtime", { category: "lifecycle" });
|
|
||||||
const logCleanup = logger.cleanupLogs({
|
const logCleanup = logger.cleanupLogs({
|
||||||
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
|
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
|
||||||
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
|
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
|
||||||
@ -70,12 +72,18 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
cleanupSnapshots();
|
cleanupSnapshots();
|
||||||
} catch (error) {
|
} 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 safeModeRequested = isSafeModeRequested();
|
||||||
const startupMarker = markStartupVerification();
|
const startupMarker = markStartupVerification();
|
||||||
if (startupMarker?.status === "stale") {
|
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 };
|
const settingsApi = { getSetting, setSetting };
|
||||||
@ -89,7 +97,9 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
discordClient = await startBot({ commandRouter });
|
discordClient = await startBot({ commandRouter });
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
twitchClient = await startTwitchBot({ commandRouter });
|
twitchClient = await startTwitchBot({ commandRouter });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Twitch bot failed to start", error);
|
runtimeLog.error("Twitch bot failed to start", error, {
|
||||||
|
event: "twitch_start_failed"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await twitchEventSubManager.start();
|
await twitchEventSubManager.start();
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
youtubeClient = await startYouTubeBot({ commandRouter });
|
youtubeClient = await startYouTubeBot({ commandRouter });
|
||||||
} catch (error) {
|
} 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) {
|
if (!safeModeRequested) {
|
||||||
overlayConnectorManager.start().catch((error) => {
|
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 port = Number(process.env.PORT || 3000);
|
||||||
const listenHost = String(process.env.LUMI_HOST || "").trim();
|
const listenHost = String(process.env.LUMI_HOST || "").trim();
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
runtimeLog.run({ event: "web_ready" }, () => {
|
const displayHost = listenHost || "localhost";
|
||||||
const displayHost = listenHost || "localhost";
|
runtimeLog.info(`WebUI listening on http://${displayHost}:${port}`, {
|
||||||
console.log(`WebUI listening on http://${displayHost}:${port}`, { port, host: listenHost || null, runtime_mode: runtimeEnvironment.mode });
|
port,
|
||||||
});
|
host: listenHost || null,
|
||||||
|
runtime_mode: runtimeEnvironment.mode
|
||||||
|
}, { event: "web_ready" });
|
||||||
};
|
};
|
||||||
const webServer = listenHost ? app.listen(port, listenHost, onListening) : app.listen(port, onListening);
|
const webServer = listenHost ? app.listen(port, listenHost, onListening) : app.listen(port, onListening);
|
||||||
app.locals.lumiUpgradeRegistry?.attach(webServer);
|
app.locals.lumiUpgradeRegistry?.attach(webServer);
|
||||||
@ -165,7 +185,9 @@ async function main() {
|
|||||||
requestRestart();
|
requestRestart();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auto-update failed", error);
|
runtimeLog.error("Auto-update failed", error, {
|
||||||
|
event: "auto_update_failed"
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
autoUpdateRunning = false;
|
autoUpdateRunning = false;
|
||||||
}
|
}
|
||||||
@ -181,19 +203,22 @@ async function main() {
|
|||||||
app.locals.lumiUpgradeRegistry?.close();
|
app.locals.lumiUpgradeRegistry?.close();
|
||||||
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
|
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
|
||||||
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
||||||
for (const stop of [
|
for (const service of [
|
||||||
() => streamTestingService.close(),
|
{ name: "stream_testing", stop: () => streamTestingService.close() },
|
||||||
() => overlayConnectorManager.stop(),
|
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
|
||||||
() => twitchEventSubManager.stop(),
|
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
|
||||||
() => stopPlugins(),
|
{ name: "plugins", stop: () => stopPlugins() },
|
||||||
() => stopBot(),
|
{ name: "discord", stop: () => stopBot() },
|
||||||
() => stopTwitchBot(),
|
{ name: "twitch", stop: () => stopTwitchBot() },
|
||||||
() => stopYouTubeBot()
|
{ name: "youtube", stop: () => stopYouTubeBot() }
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
await stop();
|
await service.stop();
|
||||||
} catch (error) {
|
} 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([
|
await Promise.race([
|
||||||
|
|||||||
@ -271,14 +271,15 @@ function redactValue(value, seen = new WeakSet()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSensitiveKey(key) {
|
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) {
|
function redactText(value) {
|
||||||
return String(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(/([?&](?: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) {
|
function truncate(value, maxLength) {
|
||||||
|
|||||||
@ -44,9 +44,17 @@ function emitLumiEvent(type, payload = {}, metadata = {}) {
|
|||||||
});
|
});
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
try {
|
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) {
|
} 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;
|
return event;
|
||||||
|
|||||||
@ -182,7 +182,7 @@ function registerPublicOverlayRoutes(app) {
|
|||||||
module_id: source.moduleId,
|
module_id: source.moduleId,
|
||||||
error_code: error?.code || null,
|
error_code: error?.code || null,
|
||||||
error: error?.message || "Unknown website loading error."
|
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));
|
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 { getPlugins } = require("./plugins");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
|
|
||||||
const logger = createLogger("plugin-stats");
|
const logger = createLogger("core:plugin-stats", { category: "plugin" });
|
||||||
|
|
||||||
function readJsonSafe(filePath) {
|
function readJsonSafe(filePath) {
|
||||||
try {
|
try {
|
||||||
@ -38,7 +38,10 @@ function loadStatProviders() {
|
|||||||
try {
|
try {
|
||||||
provider = require(providerPath);
|
provider = require(providerPath);
|
||||||
} catch (error) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
providers.push({ plugin, manifest, provider });
|
providers.push({ plugin, manifest, provider });
|
||||||
@ -54,7 +57,11 @@ function buildProfileSection({ plugin, manifest, provider, userId }) {
|
|||||||
try {
|
try {
|
||||||
result = provider.getProfileStats({ db, userId, plugin, manifest });
|
result = provider.getProfileStats({ db, userId, plugin, manifest });
|
||||||
} catch (error) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
const stats = Array.isArray(result?.stats) ? result.stats : [];
|
const stats = Array.isArray(result?.stats) ? result.stats : [];
|
||||||
@ -75,7 +82,10 @@ function buildLeaderboardSection({ plugin, manifest, provider, limit }) {
|
|||||||
try {
|
try {
|
||||||
result = provider.getLeaderboards({ db, limit, plugin, manifest });
|
result = provider.getLeaderboards({ db, limit, plugin, manifest });
|
||||||
} catch (error) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
const boards = Array.isArray(result?.boards) ? result.boards : [];
|
const boards = Array.isArray(result?.boards) ? result.boards : [];
|
||||||
@ -115,7 +125,10 @@ async function getAdminDashboardSections() {
|
|||||||
actions: normalizeDashboardActions(result.actions)
|
actions: normalizeDashboardActions(result.actions)
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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 {
|
return {
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
eyebrow: "Companion service",
|
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_prefix", `${key.slice(0, TOKEN_PREFIX.length + 8)}…`);
|
||||||
setSetting("production_diagnostics_key_created_at", new Date().toISOString());
|
setSetting("production_diagnostics_key_created_at", new Date().toISOString());
|
||||||
setSetting("production_diagnostics_enabled", true);
|
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;
|
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 { Readable } = require("stream");
|
||||||
const { pipeline } = require("stream/promises");
|
const { pipeline } = require("stream/promises");
|
||||||
const { MediaMtxRuntime } = require("./mediamtx-runtime");
|
const { MediaMtxRuntime } = require("./mediamtx-runtime");
|
||||||
|
const { streamTestCertificateManager } = require("./stream-test-certificates");
|
||||||
const { publishWebEvent } = require("./web-events");
|
const { publishWebEvent } = require("./web-events");
|
||||||
|
|
||||||
const DATA_ROOT = path.join(
|
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 {
|
class StreamTestingService {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.runtime = options.runtime || new MediaMtxRuntime();
|
this.runtime = options.runtime || new MediaMtxRuntime();
|
||||||
@ -96,6 +102,18 @@ class StreamTestingService {
|
|||||||
source: session.source,
|
source: session.source,
|
||||||
playback_url: `/admin/stream-testing/media/${session.id}/index.m3u8`,
|
playback_url: `/admin/stream-testing/media/${session.id}/index.m3u8`,
|
||||||
captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
|
captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
|
||||||
|
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,
|
metrics: session.metrics,
|
||||||
warnings: session.warnings,
|
warnings: session.warnings,
|
||||||
ingest: `${session.transport}://${session.ingestHost}:${session.publicPort}/lumi-test/[session]`
|
ingest: `${session.transport}://${session.ingestHost}:${session.publicPort}/lumi-test/[session]`
|
||||||
@ -127,7 +145,7 @@ class StreamTestingService {
|
|||||||
? [{
|
? [{
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
code: "unencrypted_ingest",
|
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 = {
|
const session = {
|
||||||
@ -256,12 +274,49 @@ class StreamTestingService {
|
|||||||
const session = this.requireDeviceSession(deviceId, input.session_id);
|
const session = this.requireDeviceSession(deviceId, input.session_id);
|
||||||
const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
|
const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
|
||||||
if (!text) return;
|
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 rawCaptionId = String(input.caption_id || "");
|
||||||
const end = Math.max(start + 0.3, finite(input.end_seconds, start + 3, start, MAX_SESSION_MS / 1000));
|
const captionId = /^[a-z0-9_.:-]{1,100}$/i.test(rawCaptionId) ? rawCaptionId : crypto.randomUUID();
|
||||||
session.captions.push({ start, end, text });
|
const revision = Math.round(finite(input.revision, 1, 1, Number.MAX_SAFE_INTEGER));
|
||||||
if (session.captions.length > 1000) session.captions.shift();
|
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);
|
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
|
session.captionWarnings = delayMs >= 5000
|
||||||
? [{ severity: "critical", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
|
? [{ severity: "critical", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
|
||||||
: delayMs >= 2500
|
: delayMs >= 2500
|
||||||
@ -271,6 +326,33 @@ class StreamTestingService {
|
|||||||
this.notify();
|
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) {
|
captionFile(id) {
|
||||||
const session = this.active?.id === id ? this.active : null;
|
const session = this.active?.id === id ? this.active : null;
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
@ -279,7 +361,7 @@ class StreamTestingService {
|
|||||||
const date = new Date(milliseconds);
|
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 `${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) {
|
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();
|
const pairedOrigin = String(device?.pairing_host || "").trim();
|
||||||
let pairedHost = "";
|
let pairedUrl;
|
||||||
try {
|
try {
|
||||||
pairedHost = new URL(pairedOrigin).hostname;
|
pairedUrl = new URL(pairedOrigin);
|
||||||
} catch {}
|
} 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 (!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 requested = String(process.env.LUMI_STREAM_TEST_TRANSPORT || "").trim().toLowerCase();
|
||||||
const tlsCert = String(process.env.LUMI_STREAM_TEST_TLS_CERT || "").trim();
|
let tlsCert = String(process.env.LUMI_STREAM_TEST_TLS_CERT || "").trim();
|
||||||
const tlsKey = String(process.env.LUMI_STREAM_TEST_TLS_KEY || "").trim();
|
let tlsKey = String(process.env.LUMI_STREAM_TEST_TLS_KEY || "").trim();
|
||||||
const resolved = await resolveHostAddresses(host);
|
if (requested && !["rtmps", "rtmp"].includes(requested)) throw coded("STREAM_TEST_TRANSPORT_INVALID", "LUMI_STREAM_TEST_TRANSPORT must be rtmps or rtmp.");
|
||||||
const privateHost = resolved.length > 0 && resolved.every(isPrivateAddress);
|
// Transport overrides never weaken the authenticated origin policy. This also
|
||||||
const transport = requested || (tlsCert && tlsKey ? "rtmps" : "rtmp");
|
// makes an old development `rtmp` override harmless after moving Lumi behind
|
||||||
if (!["rtmps", "rtmp"].includes(transport)) throw coded("STREAM_TEST_TRANSPORT_INVALID", "LUMI_STREAM_TEST_TRANSPORT must be rtmps or rtmp.");
|
// its production HTTPS hostname.
|
||||||
if (transport === "rtmps") {
|
const transport = "rtmps";
|
||||||
|
if (tlsCert || tlsKey) {
|
||||||
for (const [label, target] of [["certificate", tlsCert], ["private key", tlsKey]]) {
|
for (const [label, target] of [["certificate", tlsCert], ["private key", tlsKey]]) {
|
||||||
if (!target || !fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
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") {
|
} else {
|
||||||
throw coded(
|
const managed = await (options.certificateManager || streamTestCertificateManager).resolve(host);
|
||||||
"STREAM_TEST_INSECURE_REMOTE",
|
tlsCert = managed.certificate;
|
||||||
"Lumi will not expose unencrypted RTMP to a public address. Configure RTMPS, or use a private/LAN pairing hostname."
|
tlsKey = managed.privateKey;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
transport,
|
transport,
|
||||||
tlsCert,
|
tlsCert,
|
||||||
tlsKey,
|
tlsKey,
|
||||||
bindHost: transport === "rtmp" && privateHost ? resolved[0] : "0.0.0.0",
|
bindHost: "0.0.0.0",
|
||||||
ingestPort: INGEST_PORT,
|
ingestPort: INGEST_PORT,
|
||||||
publicPort: PUBLIC_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) {
|
function validateHostname(value) {
|
||||||
if (!value || value.length > 253) return "";
|
if (!value || value.length > 253) return "";
|
||||||
const host = value.replace(/^\[|\]$/g, "");
|
const host = value.replace(/^\[|\]$/g, "");
|
||||||
|
|||||||
@ -171,7 +171,13 @@ class TwitchEventSubManager {
|
|||||||
if (type === "notification") this.emitNotification(message);
|
if (type === "notification") this.emitNotification(message);
|
||||||
if (type === "revocation") {
|
if (type === "revocation") {
|
||||||
this.status = { ...this.status, detail: "A Twitch event permission was revoked. Reconnect Twitch events if alerts stop." };
|
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");
|
} = require("./update-repository");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
|
|
||||||
const logger = createLogger("updater");
|
const logger = createLogger("core:updater", { category: "updates" });
|
||||||
|
|
||||||
let restartHandler = null;
|
let restartHandler = null;
|
||||||
|
|
||||||
@ -105,7 +105,9 @@ function requestRestart(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Promise.resolve(restartHandler(10)).catch((error) => {
|
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);
|
process.exit(10);
|
||||||
});
|
});
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
|
|||||||
@ -63,12 +63,11 @@ function createWebhookService({ limit = "256kb" } = {}) {
|
|||||||
return sendHandlerResult(res, await endpoint.handler(context));
|
return sendHandlerResult(res, await endpoint.handler(context));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
webhookLog.error("Webhook handler failed", {
|
webhookLog.error("Webhook handler failed", {
|
||||||
pluginId: endpoint.pluginId,
|
plugin_id: endpoint.pluginId,
|
||||||
endpointId: endpoint.endpointId,
|
endpoint_id: endpoint.endpointId,
|
||||||
namespace,
|
namespace,
|
||||||
slug,
|
slug,
|
||||||
message: error?.message || String(error),
|
error
|
||||||
stack: error?.stack || ""
|
|
||||||
}, { event: "inbound_webhook_failed" });
|
}, { event: "inbound_webhook_failed" });
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({ error: "Webhook processing failed." });
|
return res.status(500).json({ error: "Webhook processing failed." });
|
||||||
@ -145,7 +144,10 @@ function createWebhookService({ limit = "256kb" } = {}) {
|
|||||||
if (!pluginEndpoints.size) {
|
if (!pluginEndpoints.size) {
|
||||||
endpointKeysByPlugin.delete((pluginId || "").toString());
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,7 +234,7 @@ async function sendWebhook({
|
|||||||
if (response.ok || attempt === attempts) {
|
if (response.ok || attempt === attempts) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
webhookLog.warn("Outbound webhook returned an error", {
|
webhookLog.warn("Outbound webhook returned an error", {
|
||||||
pluginId: pluginId || null,
|
plugin_id: pluginId || null,
|
||||||
url: redactUrl(url),
|
url: redactUrl(url),
|
||||||
status: response.status,
|
status: response.status,
|
||||||
attempt
|
attempt
|
||||||
@ -245,10 +247,10 @@ async function sendWebhook({
|
|||||||
lastDurationMs = Date.now() - startedAt;
|
lastDurationMs = Date.now() - startedAt;
|
||||||
if (attempt === attempts) {
|
if (attempt === attempts) {
|
||||||
webhookLog.error("Outbound webhook failed", {
|
webhookLog.error("Outbound webhook failed", {
|
||||||
pluginId: pluginId || null,
|
plugin_id: pluginId || null,
|
||||||
url: redactUrl(url),
|
url: redactUrl(url),
|
||||||
attempt,
|
attempt,
|
||||||
message: error?.message || String(error)
|
error
|
||||||
}, { event: "outbound_webhook_failed" });
|
}, { event: "outbound_webhook_failed" });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,9 +1,48 @@
|
|||||||
@layer features {
|
@layer features {
|
||||||
.stream-test-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(18rem, .7fr); gap: var(--lumi-space-4); }
|
.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 { 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 { 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 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-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, .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 > 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; }
|
.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-timeline li::marker { color: var(--lumi-primary); }
|
||||||
.stream-test-warning { margin-top: var(--lumi-space-3); }
|
.stream-test-warning { margin-top: var(--lumi-space-3); }
|
||||||
.stream-runtime-actions { margin-top: var(--lumi-space-3); }
|
.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; } }
|
@media (max-width: 900px) { .stream-test-layout { grid-template-columns: 1fr; } }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
const root = document.querySelector("[data-stream-testing]");
|
const root = document.querySelector("[data-stream-testing]");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const player = root.querySelector("[data-stream-player]");
|
const player = root.querySelector("[data-stream-player]");
|
||||||
|
const playerShell = root.querySelector(".stream-test-player-shell");
|
||||||
const captions = root.querySelector("[data-stream-captions]");
|
const captions = root.querySelector("[data-stream-captions]");
|
||||||
|
const captionOverlay = root.querySelector("[data-stream-caption-overlay]");
|
||||||
const empty = root.querySelector("[data-stream-empty]");
|
const empty = root.querySelector("[data-stream-empty]");
|
||||||
const statePill = root.querySelector("[data-stream-state]");
|
const statePill = root.querySelector("[data-stream-state]");
|
||||||
const summary = root.querySelector("[data-stream-summary]");
|
const summary = root.querySelector("[data-stream-summary]");
|
||||||
@ -21,8 +23,17 @@
|
|||||||
const runtimeResult = root.querySelector("[data-runtime-result]");
|
const runtimeResult = root.querySelector("[data-runtime-result]");
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
|
let latestSession = null;
|
||||||
let playerStalls = 0;
|
let playerStalls = 0;
|
||||||
let playerErrors = 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 text = (value) => String(value ?? "");
|
||||||
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
||||||
@ -35,16 +46,153 @@
|
|||||||
return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
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) {
|
function attach(session) {
|
||||||
if (!session || sessionId === session.id) return;
|
if (!session || sessionId === session.id) return;
|
||||||
sessionId = session.id;
|
sessionId = session.id;
|
||||||
playerStalls = 0;
|
playerStalls = 0;
|
||||||
playerErrors = 0;
|
playerErrors = 0;
|
||||||
|
autoplayPending = true;
|
||||||
|
captionSignature = "";
|
||||||
hls?.destroy?.();
|
hls?.destroy?.();
|
||||||
hls = null;
|
hls = null;
|
||||||
|
player.autoplay = true;
|
||||||
|
player.defaultMuted = true;
|
||||||
|
player.muted = true;
|
||||||
player.removeAttribute("src");
|
player.removeAttribute("src");
|
||||||
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
|
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
|
||||||
captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}`;
|
captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}`;
|
||||||
|
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"));
|
quality.replaceChildren(new Option(session.source?.variants?.[0]?.label || "Source output", "-1"));
|
||||||
if (window.Hls?.isSupported?.()) {
|
if (window.Hls?.isSupported?.()) {
|
||||||
hls = new window.Hls({
|
hls = new window.Hls({
|
||||||
@ -57,18 +205,24 @@
|
|||||||
});
|
});
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(player);
|
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) => {
|
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||||
if (data?.fatal) playerErrors += 1;
|
if (data?.fatal) playerErrors += 1;
|
||||||
if (String(data?.details || "").toLowerCase().includes("stall")) playerStalls += 1;
|
if (String(data?.details || "").toLowerCase().includes("stall")) playerStalls += 1;
|
||||||
});
|
});
|
||||||
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
player.src = url;
|
player.src = url;
|
||||||
|
player.load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detach() {
|
function detach() {
|
||||||
sessionId = null;
|
sessionId = null;
|
||||||
|
latestSession = null;
|
||||||
|
autoplayPending = false;
|
||||||
|
captionSignature = "";
|
||||||
|
resetCaptionCues();
|
||||||
|
clearCaptionOverlay();
|
||||||
hls?.destroy?.();
|
hls?.destroy?.();
|
||||||
hls = null;
|
hls = null;
|
||||||
player.pause();
|
player.pause();
|
||||||
@ -81,8 +235,12 @@
|
|||||||
|
|
||||||
function render(status) {
|
function render(status) {
|
||||||
const session = status.session;
|
const session = status.session;
|
||||||
|
latestSession = session;
|
||||||
if (session) attach(session); else detach();
|
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.textContent = session?.state || "Idle";
|
||||||
statePill.className = `status-pill ${session?.state === "receiving" ? "success" : session ? "warning" : "neutral"}`;
|
statePill.className = `status-pill ${session?.state === "receiving" ? "success" : session ? "warning" : "neutral"}`;
|
||||||
empty.hidden = Boolean(session);
|
empty.hidden = Boolean(session);
|
||||||
@ -120,7 +278,15 @@
|
|||||||
metric("Preview viewers", receiver.hls_sessions ?? 0),
|
metric("Preview viewers", receiver.hls_sessions ?? 0),
|
||||||
metric("Player latency", playerLatency),
|
metric("Player latency", playerLatency),
|
||||||
metric("Playback stalls", playerMetrics.stalls ?? playerStalls),
|
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("");
|
].join("");
|
||||||
warnings.replaceChildren(...(session?.warnings || (!runtime.available ? [{ severity: "critical", message: runtime.detail }] : [])).map((warning) => {
|
warnings.replaceChildren(...(session?.warnings || (!runtime.available ? [{ severity: "critical", message: runtime.detail }] : [])).map((warning) => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
@ -181,7 +347,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
player.addEventListener("error", () => { playerErrors += 1; });
|
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 () => {
|
stop.addEventListener("click", async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
stop.disabled = true;
|
stop.disabled = true;
|
||||||
@ -220,6 +391,7 @@
|
|||||||
window.addEventListener("pagehide", () => {
|
window.addEventListener("pagehide", () => {
|
||||||
events.close();
|
events.close();
|
||||||
window.clearInterval(metricTimer);
|
window.clearInterval(metricTimer);
|
||||||
|
if (overlayExpiryTimer) window.clearTimeout(overlayExpiryTimer);
|
||||||
hls?.destroy?.();
|
hls?.destroy?.();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@ -60,7 +60,6 @@ const {
|
|||||||
createLogger,
|
createLogger,
|
||||||
listLogFacets,
|
listLogFacets,
|
||||||
listLogs,
|
listLogs,
|
||||||
log,
|
|
||||||
summarizeLogs,
|
summarizeLogs,
|
||||||
withLogContext
|
withLogContext
|
||||||
} = require("../services/logger");
|
} = require("../services/logger");
|
||||||
@ -81,6 +80,7 @@ const { getClient: getTwitchClient } = require("../services/twitch");
|
|||||||
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
||||||
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
||||||
const { streamTestingService } = require("../services/stream-testing");
|
const { streamTestingService } = require("../services/stream-testing");
|
||||||
|
const { streamTestCertificateManager } = require("../services/stream-test-certificates");
|
||||||
const { getClient: getYouTubeClient } = require("../services/youtube");
|
const { getClient: getYouTubeClient } = require("../services/youtube");
|
||||||
const {
|
const {
|
||||||
conditionalRepliesFromBody,
|
conditionalRepliesFromBody,
|
||||||
@ -3171,20 +3171,20 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
const message = err?.message || "";
|
const message = err?.message || "";
|
||||||
const detail = {
|
const detail = { view, error: err };
|
||||||
view,
|
|
||||||
message,
|
|
||||||
stack: err?.stack || ""
|
|
||||||
};
|
|
||||||
const isMissing = message.includes("Failed to lookup view");
|
const isMissing = message.includes("Failed to lookup view");
|
||||||
if (!isMissing) {
|
if (!isMissing) {
|
||||||
log("error", "View render failed", detail);
|
webLog.error("View render failed", detail, {
|
||||||
|
event: "view_render_failed"
|
||||||
|
});
|
||||||
if (typeof callback === "function") {
|
if (typeof callback === "function") {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
log("warn", "Missing view fallback", detail);
|
webLog.warn("Missing view fallback", detail, {
|
||||||
|
event: "missing_view_fallback"
|
||||||
|
});
|
||||||
if (view === "missing-view") {
|
if (view === "missing-view") {
|
||||||
const fallback =
|
const fallback =
|
||||||
"<!doctype html><title>Content missing</title><h1>Content unavailable</h1><p>Some content could not be loaded.</p>";
|
"<!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");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
next();
|
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")));
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
|
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
|
||||||
@ -3705,12 +3711,13 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
view,
|
view,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.path,
|
path: req.path,
|
||||||
userId: req.session.user?.id || null,
|
user_id: req.session.user?.id || null,
|
||||||
message,
|
error: err
|
||||||
stack: err?.stack || ""
|
|
||||||
};
|
};
|
||||||
if (!isMissing) {
|
if (!isMissing) {
|
||||||
log("error", "Render failed", context);
|
webLog.error("Render failed", context, {
|
||||||
|
event: "response_render_failed"
|
||||||
|
});
|
||||||
return originalRender(
|
return originalRender(
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@ -3720,7 +3727,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
callback
|
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.";
|
res.locals.softError = "Some content could not be loaded.";
|
||||||
if (view === "missing-view") {
|
if (view === "missing-view") {
|
||||||
return res
|
return res
|
||||||
@ -3811,7 +3820,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
: true
|
: true
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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 });
|
panels.push({ ...unavailableAssistantPanel(panel, "availability_check_failed"), debug: panelDebug });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -3855,7 +3867,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.onRenderDiagnostic?.({
|
||||||
panel_endpoint_status: 500,
|
panel_endpoint_status: 500,
|
||||||
panel_html_length: 0,
|
panel_html_length: 0,
|
||||||
@ -4285,7 +4300,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
setFlash(req, "success", "Logged in.");
|
setFlash(req, "success", "Logged in.");
|
||||||
res.redirect("/");
|
res.redirect("/");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
webLog.error(error);
|
webLog.error("Discord authentication failed", error, {
|
||||||
|
event: "discord_authentication_failed"
|
||||||
|
});
|
||||||
res.status(500).render("error", {
|
res.status(500).render("error", {
|
||||||
title: "Login failed",
|
title: "Login failed",
|
||||||
message: "Discord authentication failed."
|
message: "Discord authentication failed."
|
||||||
@ -4507,7 +4524,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
res.redirect("/profile");
|
res.redirect("/profile");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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", {
|
res.status(500).render("error", {
|
||||||
title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed",
|
title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed",
|
||||||
message: isLogin
|
message: isLogin
|
||||||
@ -4670,7 +4690,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
setFlash(req, "success", "YouTube account linked.");
|
setFlash(req, "success", "YouTube account linked.");
|
||||||
res.redirect("/profile");
|
res.redirect("/profile");
|
||||||
} catch (error) {
|
} 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", {
|
res.status(500).render("error", {
|
||||||
title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed",
|
title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed",
|
||||||
message: isBot
|
message: isBot
|
||||||
@ -7223,7 +7246,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(plugin.path, { recursive: true, force: true });
|
fs.rmSync(plugin.path, { recursive: true, force: true });
|
||||||
} catch (error) {
|
} 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);
|
removePlugin(req.params.id);
|
||||||
@ -7809,13 +7835,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
}
|
}
|
||||||
const message = err?.message || "";
|
const message = err?.message || "";
|
||||||
const isViewMissing = message.includes("Failed to lookup view");
|
const isViewMissing = message.includes("Failed to lookup view");
|
||||||
log("error", "Unhandled error", {
|
webLog.error("Unhandled HTTP error", {
|
||||||
path: req.path,
|
path: req.path,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
userId: req.session.user?.id || null,
|
user_id: req.session.user?.id || null,
|
||||||
message,
|
error: err
|
||||||
stack: err?.stack || ""
|
}, { event: "unhandled_http_error" });
|
||||||
});
|
|
||||||
if (isViewMissing) {
|
if (isViewMissing) {
|
||||||
res.locals.softError = "Some content could not be loaded.";
|
res.locals.softError = "Some content could not be loaded.";
|
||||||
return res.status(200).render("missing-view", {
|
return res.status(200).render("missing-view", {
|
||||||
|
|||||||
@ -34,7 +34,8 @@
|
|||||||
<div class="card stream-test-player-card">
|
<div class="card stream-test-player-card">
|
||||||
<div class="section-heading"><div><span class="eyebrow">Private receiver</span><h2>Live output</h2></div><span class="status-pill neutral" data-stream-state>Idle</span></div>
|
<div class="section-heading"><div><span class="eyebrow">Private receiver</span><h2>Live output</h2></div><span class="status-pill neutral" data-stream-state>Idle</span></div>
|
||||||
<div class="stream-test-player-shell">
|
<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 class="empty-state" data-stream-empty>Start Stream testing from Lumi Companion on the streaming computer.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user