fix: surface stream test prerequisites

This commit is contained in:
Franz Rolfsvaag 2026-07-24 23:39:14 +02:00
parent dceebce43a
commit ea212c31b8
22 changed files with 330 additions and 51 deletions

View File

@ -1,5 +1,11 @@
# Lumi changelog # Lumi changelog
## 0.3.1
- Fixed apparently inert Stream Testing starts by surfacing the server rejection immediately in the Stream Testing panel and local diagnostic log.
- Added bounded Windows FFmpeg discovery for verified WinGet and conventional installations, while preserving explicit `LUMI_FFMPEG_PATH` configuration and Linux PATH behavior.
- Added explicit loaded/installed/bundled OBS Bridge version comparison, update-required notices and actions on Overview and Connection & device, and blocked Stream Testing until an outdated bridge is updated and loaded.
## 0.3.0 ## 0.3.0
- Added admin-only private stream testing for the real OBS output with expiring authenticated sessions, supervised FFmpeg ingest, source/720p/480p no-upscale HLS, automatic/manual quality, audio, fullscreen, reused captions, real OBS/receiver diagnostics, bounded cleanup, and a deterministic test pattern. - Added admin-only private stream testing for the real OBS output with expiring authenticated sessions, supervised FFmpeg ingest, source/720p/480p no-upscale HLS, automatic/manual quality, audio, fullscreen, reused captions, real OBS/receiver diagnostics, bounded cleanup, and a deterministic test pattern.

View File

@ -1,5 +1,5 @@
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.2.0" #define AppVersion "0.2.1"
#endif #endif
#ifndef SourceRoot #ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory. #error SourceRoot must point at the self-contained Companion publish directory.

View File

@ -1,5 +1,5 @@
param( param(
[string]$Version = "0.2.0", [string]$Version = "0.2.1",
[string]$BridgeVersion = "0.2.0", [string]$BridgeVersion = "0.2.0",
[string]$ObsVersion = "31.1.1" [string]$ObsVersion = "31.1.1"
) )

View File

@ -278,6 +278,9 @@ public sealed class CompanionRuntime : IAsyncDisposable
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.Connected || _socket is null) throw new InvalidOperationException("Reconnect Lumi Companion before starting a private stream test."); if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect Lumi Companion before starting a private stream test.");
if (!State.ObsConnected || _obsBridge is null) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect."); if (!State.ObsConnected || _obsBridge is null) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect.");
if (State.ObsBridgeUpdateAvailable)
throw new InvalidOperationException($"Update OBS Bridge before starting Stream Testing. Loaded: {State.ObsBridgeLoadedVersion ?? State.ObsBridgeInstalledVersion ?? "unknown"}; required: {State.ObsBridgeBundledVersion ?? "current bundled version"}.");
if (State.ObsBridgeRepairNeeded) throw new InvalidOperationException("Repair the managed OBS integration before starting Stream Testing.");
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording in OBS before starting a private test."); if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording in OBS before starting a private test.");
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 });
@ -291,9 +294,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
}, _socket.SessionId, cancellationToken); }, _socket.SessionId, cancellationToken);
created = await _streamTestSessionSignal.Task.WaitAsync(TimeSpan.FromSeconds(12), cancellationToken); created = await _streamTestSessionSignal.Task.WaitAsync(TimeSpan.FromSeconds(12), cancellationToken);
} }
catch catch (Exception error)
{ {
try { await _socket.SendAsync("stream_test_stop", new { reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { } try { await _socket.SendAsync("stream_test_stop", new { reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
SetState(State with
{
Health = TrayHealth.Degraded,
StreamTestDetail = $"The private test could not start. {Friendly(error)}"
});
await WriteLogAsync("stream_test_start_failed", error.Message);
throw; throw;
} }
finally finally
@ -334,10 +343,16 @@ public sealed class CompanionRuntime : IAsyncDisposable
}); });
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 catch (Exception error)
{ {
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { } try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS."); await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
SetState(State with
{
Health = TrayHealth.Degraded,
StreamTestDetail = $"The private test could not start. {Friendly(error)}"
});
await WriteLogAsync("stream_test_start_failed", error.Message);
throw; throw;
} }
} }
@ -731,11 +746,19 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (message.Type == "error") if (message.Type == "error")
{ {
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error."; var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error.";
var streamTestPending = _streamTestSessionSignal is not null;
if (_streamTestSessionSignal is not null) if (_streamTestSessionSignal is not null)
_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();
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error.", BenchmarkRunning = false, BenchmarkDetail = serverMessage ?? "Lumi reported an inference error." }); SetState(State with
{
Health = TrayHealth.Degraded,
Detail = serverMessage ?? "Lumi reported an error.",
StreamTestDetail = streamTestPending ? serverMessage ?? "Lumi could not create the private stream test." : State.StreamTestDetail,
BenchmarkRunning = false,
BenchmarkDetail = serverMessage ?? "Lumi reported an inference error."
});
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -749,7 +772,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
{ {
var health = connected && State.Connected ? TrayHealth.Ready : State.Health; var health = connected && State.Connected ? TrayHealth.Ready : State.Health;
if (!connected) _bridgeSelectionAttached = null; if (!connected) _bridgeSelectionAttached = null;
SetState(State with { ObsBridgeInstalled = connected || State.ObsBridgeInstalled, ObsConnected = connected, Health = health, Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail }); SetState(WithBridgeState(State with
{
ObsConnected = connected,
ObsBridgeLoadedVersion = connected ? State.ObsBridgeLoadedVersion : null,
Health = health,
Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail
}));
RefreshPathReadiness(); RefreshPathReadiness();
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected."); _ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
if (connected) if (connected)
@ -765,7 +794,19 @@ public sealed class CompanionRuntime : IAsyncDisposable
private async Task OnObsMessageAsync(JsonElement message) private async Task OnObsMessageAsync(JsonElement message)
{ {
var type = message.GetProperty("type").GetString(); var type = message.GetProperty("type").GetString();
if (type == "obs_state") if (type == "hello")
{
var loadedVersion = ReadString(message, "bridge_version");
var next = WithBridgeState(State with { ObsConnected = true, ObsBridgeLoadedVersion = loadedVersion });
SetState(next with
{
Detail = next.ObsBridgeUpdateAvailable
? $"OBS Bridge {loadedVersion ?? "unknown"} is loaded, but Companion requires {next.ObsBridgeBundledVersion ?? "the bundled version"}. Close OBS and update the bridge."
: State.Detail
});
RefreshPathReadiness();
}
else if (type == "obs_state")
{ {
var streaming = ReadBoolean(message, "streaming"); var streaming = ReadBoolean(message, "streaming");
var recording = ReadBoolean(message, "recording"); var recording = ReadBoolean(message, "recording");
@ -1029,7 +1070,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
auto_start = _settings.Current.StartWithObs, auto_start = _settings.Current.StartWithObs,
bridge_installed = bridge.Valid, bridge_installed = bridge.Valid,
bridge_connected = State.ObsConnected, bridge_connected = State.ObsConnected,
bridge_version = bridge.Version, bridge_version = State.ObsBridgeLoadedVersion ?? bridge.InstalledVersion ?? bridge.Version,
path_test_valid = pathValid, path_test_valid = pathValid,
path_test_at = pathValid ? _settings.Current.PathTestPassedAt?.ToUnixTimeMilliseconds() : null path_test_at = pathValid ? _settings.Current.PathTestPassedAt?.ToUnixTimeMilliseconds() : null
}, _socket.SessionId, cancellationToken); }, _socket.SessionId, cancellationToken);
@ -1042,7 +1083,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
string.IsNullOrWhiteSpace(State.Host) || string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid)) return null; string.IsNullOrWhiteSpace(State.Host) || string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid)) return null;
if (State.ObsConnected && _bridgeSelectionAttached == false) return null; if (State.ObsConnected && _bridgeSelectionAttached == false) return null;
if (State.ObsConnected && ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid) is not { Missing: false, Active: true }) return null; if (State.ObsConnected && ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid) is not { Missing: false, Active: true }) return null;
var value = string.Join("|", PathValidationContract, State.Host, _settings.Current.PrimarySourceUuid, bridge.Version, _serverReadinessFingerprint); var value = string.Join("|", PathValidationContract, State.Host, _settings.Current.PrimarySourceUuid, State.ObsBridgeLoadedVersion ?? bridge.Version, _serverReadinessFingerprint);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
} }
@ -1106,7 +1147,26 @@ public sealed class CompanionRuntime : IAsyncDisposable
} }
private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid; private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid;
private CompanionState WithBridgeState(CompanionState state) { var bridge = _bridgeManager.Inspect(); return state with { ObsBridgeInstalled = bridge.Valid, ObsBridgeRepairNeeded = bridge.Installed && !bridge.Valid, ObsBridgePackageAvailable = bridge.PackageAvailable, ObsBridgeDetail = bridge.Detail }; } private CompanionState WithBridgeState(CompanionState state)
{
var bridge = _bridgeManager.Inspect();
var loadedUpdate = state.ObsConnected && !string.IsNullOrWhiteSpace(state.ObsBridgeLoadedVersion) &&
!string.Equals(state.ObsBridgeLoadedVersion, bridge.Version, StringComparison.OrdinalIgnoreCase);
var updateAvailable = bridge.UpdateAvailable || loadedUpdate;
var detail = loadedUpdate
? $"OBS is running Bridge {state.ObsBridgeLoadedVersion}; Companion includes {bridge.Version}. Close OBS, choose Update OBS Bridge, then restart OBS."
: bridge.Detail;
return state with
{
ObsBridgeInstalled = bridge.Valid,
ObsBridgeRepairNeeded = bridge.Installed && !bridge.Valid && !bridge.UpdateAvailable,
ObsBridgeUpdateAvailable = updateAvailable,
ObsBridgePackageAvailable = bridge.PackageAvailable,
ObsBridgeInstalledVersion = bridge.InstalledVersion,
ObsBridgeBundledVersion = bridge.Version,
ObsBridgeDetail = detail
};
}
private static string? FindBundledPairingPackage() private static string? FindBundledPairingPackage()
{ {

View File

@ -34,7 +34,11 @@ public sealed record CompanionState(
bool PathTestValid = false, bool PathTestValid = false,
string PathTestDetail = "Run once after setup or a relevant configuration change.", string PathTestDetail = "Run once after setup or a relevant configuration change.",
bool ObsBridgeRepairNeeded = false, bool ObsBridgeRepairNeeded = false,
bool ObsBridgeUpdateAvailable = false,
bool ObsBridgePackageAvailable = false, bool ObsBridgePackageAvailable = false,
string? ObsBridgeInstalledVersion = null,
string? ObsBridgeLoadedVersion = null,
string? ObsBridgeBundledVersion = null,
string ObsBridgeDetail = "Checking the managed OBS integration…") string ObsBridgeDetail = "Checking the managed OBS integration…")
{ {
public string Summary => Health switch public string Summary => Health switch
@ -44,6 +48,7 @@ public sealed record CompanionState(
TrayHealth.Failed => "Needs attention", TrayHealth.Failed => "Needs attention",
_ when !Paired => "Setup required", _ when !Paired => "Setup required",
_ when !Connected => "Lumi offline", _ when !Connected => "Lumi offline",
_ when ObsBridgeUpdateAvailable => "OBS update required",
_ when !ObsBridgeInstalled => "OBS setup required", _ when !ObsBridgeInstalled => "OBS setup required",
_ => "Partially ready" _ => "Partially ready"
}; };

View File

@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon> <ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.2.0</Version> <Version>0.2.1</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion> <AssemblyVersion>0.2.1.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" /> <AvaloniaResource Include="Assets\Lumi.Companion.ico" />

View File

@ -82,6 +82,16 @@
</Grid> </Grid>
</Border> </Border>
<Border x:Name="OverviewBridgeUpdatePanel" Background="#FFF2D6" BorderBrush="#D58A1F" BorderThickness="1" CornerRadius="12" Padding="18" IsVisible="False">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
<StackPanel Spacing="4">
<TextBlock Text="Update OBS Bridge" FontWeight="Bold" Foreground="#764600" />
<TextBlock x:Name="OverviewBridgeUpdateDetail" Text="The OBS integration loaded by OBS is older than the version bundled with Companion." TextWrapping="Wrap" Foreground="#764600" />
</StackPanel>
<Button Grid.Column="1" x:Name="OverviewUpdateBridgeButton" Classes="primary" Content="Update OBS Bridge" VerticalAlignment="Center" />
</Grid>
</Border>
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Setup progress" Classes="sectionTitle" /> <TextBlock Text="Setup progress" Classes="sectionTitle" />
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12"> <Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
@ -313,6 +323,15 @@
<Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" /> <Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" />
<Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" /> <Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" />
</StackPanel> </StackPanel>
<Border x:Name="ConnectionBridgeUpdatePanel" Background="#FFF2D6" BorderBrush="#D58A1F" BorderThickness="1" CornerRadius="12" Padding="18" IsVisible="False">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
<StackPanel Spacing="4">
<TextBlock Text="OBS Bridge update required" FontWeight="Bold" Foreground="#764600" />
<TextBlock x:Name="ConnectionBridgeUpdateDetail" Text="Close OBS before updating the managed integration." TextWrapping="Wrap" Foreground="#764600" />
</StackPanel>
<Button Grid.Column="1" x:Name="ConnectionUpdateBridgeButton" Classes="primary" Content="Update OBS Bridge" VerticalAlignment="Center" />
</Grid>
</Border>
<Border Classes="soft"> <Border Classes="soft">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<TextBlock Text="Managed OBS integration" FontWeight="SemiBold" /> <TextBlock Text="Managed OBS integration" FontWeight="SemiBold" />

View File

@ -105,7 +105,9 @@ public partial class MainWindow : Window
SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync(); SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync();
CheckUpdateButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.CheckForUpdatesAsync(), CheckUpdateButton); CheckUpdateButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.CheckForUpdatesAsync(), CheckUpdateButton);
ApplyUpdateButton.Click += async (_, _) => await ApplyUpdateAsync(); ApplyUpdateButton.Click += async (_, _) => await ApplyUpdateAsync();
InstallBridgeButton.Click += async (_, _) => await InstallBridgeAsync(); InstallBridgeButton.Click += async (_, _) => await InstallBridgeAsync(InstallBridgeButton);
OverviewUpdateBridgeButton.Click += async (_, _) => await InstallBridgeAsync(OverviewUpdateBridgeButton);
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;
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync(); SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
@ -149,7 +151,7 @@ public partial class MainWindow : Window
{ {
if (!_runtime.State.Paired) { await ChoosePairingPackageAsync(); return; } if (!_runtime.State.Paired) { await ChoosePairingPackageAsync(); return; }
if (!_runtime.State.Connected) { await RunUiActionAsync(() => _runtime.RetryConnectionAsync(), NextActionButton); return; } if (!_runtime.State.Connected) { await RunUiActionAsync(() => _runtime.RetryConnectionAsync(), NextActionButton); return; }
if (!_runtime.State.ObsBridgeInstalled) { ShowPage(CompanionPage.Connection); return; } if (_runtime.State.ObsBridgeUpdateAvailable || !_runtime.State.ObsBridgeInstalled) { ShowPage(CompanionPage.Connection); return; }
ShowPage(CompanionPage.Test); ShowPage(CompanionPage.Test);
} }
@ -174,9 +176,12 @@ public partial class MainWindow : Window
finally { RenderState(_runtime.State); } finally { RenderState(_runtime.State); }
} }
private async Task InstallBridgeAsync() private async Task InstallBridgeAsync(Button initiatingButton)
{ {
initiatingButton.IsEnabled = false;
InstallBridgeButton.IsEnabled = false; InstallBridgeButton.IsEnabled = false;
OverviewUpdateBridgeButton.IsEnabled = false;
ConnectionUpdateBridgeButton.IsEnabled = false;
BridgeStatusText.Text = "Checking the packaged integration and requesting Windows approval…"; BridgeStatusText.Text = "Checking the packaged integration and requesting Windows approval…";
try { await _runtime.InstallOrRepairObsBridgeAsync(); } try { await _runtime.InstallOrRepairObsBridgeAsync(); }
catch (Exception error) catch (Exception error)
@ -291,8 +296,10 @@ public partial class MainWindow : Window
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, currently offline" : "Not paired";
ObsStepSymbol.Text = state.ObsConnected ? "✓" : state.ObsBridgeInstalled ? "◐" : "○"; ObsStepSymbol.Text = state.ObsBridgeUpdateAvailable ? "!" : state.ObsConnected ? "✓" : state.ObsBridgeInstalled ? "◐" : "○";
ObsStepDetail.Text = state.ObsConnected ? "Connected to OBS" : state.ObsBridgeInstalled ? "Installed; waiting for OBS" : "Installation required"; ObsStepDetail.Text = state.ObsBridgeUpdateAvailable
? $"Update required ({state.ObsBridgeLoadedVersion ?? state.ObsBridgeInstalledVersion ?? "unknown"} → {state.ObsBridgeBundledVersion ?? "current"})"
: state.ObsConnected ? "Connected to OBS" : state.ObsBridgeInstalled ? "Installed; waiting for OBS" : "Installation required";
TestStepSymbol.Text = state.PathTestValid ? "✓" : "○"; TestStepSymbol.Text = state.PathTestValid ? "✓" : "○";
TestStepDetail.Text = state.PathTestDetail; TestStepDetail.Text = state.PathTestDetail;
DeviceNameText.Text = state.DeviceName ?? "Not paired"; DeviceNameText.Text = state.DeviceName ?? "Not paired";
@ -311,7 +318,7 @@ 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.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired; StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsBridgeUpdateAvailable && !state.ObsBridgeRepairNeeded && !state.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired;
StopStreamTestButton.IsEnabled = state.StreamTestRunning; StopStreamTestButton.IsEnabled = state.StreamTestRunning;
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning; RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
UpdatePanel.IsVisible = state.UpdateAvailable; UpdatePanel.IsVisible = state.UpdateAvailable;
@ -323,10 +330,17 @@ public partial class MainWindow : Window
CurrentVersionText.Text = CompanionRuntime.DisplayVersion; CurrentVersionText.Text = CompanionRuntime.DisplayVersion;
UpdateStatusText.Text = state.UpdateDetail; UpdateStatusText.Text = state.UpdateDetail;
BridgeStatusText.Text = state.ObsBridgeDetail; BridgeStatusText.Text = state.ObsBridgeDetail;
InstallBridgeButton.Content = state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration"; OverviewBridgeUpdatePanel.IsVisible = state.ObsBridgeUpdateAvailable;
ConnectionBridgeUpdatePanel.IsVisible = state.ObsBridgeUpdateAvailable;
OverviewBridgeUpdateDetail.Text = state.ObsBridgeDetail;
ConnectionBridgeUpdateDetail.Text = state.ObsBridgeDetail;
InstallBridgeButton.Content = state.ObsBridgeUpdateAvailable ? "Update OBS Bridge" : state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration";
// The maintenance action performs a fresh package/process check and reports the // The maintenance action performs a fresh package/process check and reports the
// actual blocker, so a stale OBS connection signal must never strand repair. // actual blocker, so a stale OBS connection signal must never strand repair.
InstallBridgeButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning; var bridgeMaintenanceEnabled = !state.TestRunning && !state.BenchmarkRunning && !state.StreamTestRunning;
InstallBridgeButton.IsEnabled = bridgeMaintenanceEnabled;
OverviewUpdateBridgeButton.IsEnabled = bridgeMaintenanceEnabled;
ConnectionUpdateBridgeButton.IsEnabled = bridgeMaintenanceEnabled;
RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded; RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded;
if (!state.Paired) if (!state.Paired)
@ -341,6 +355,12 @@ public partial class MainWindow : Window
NextActionDetail.Text = "Your device is paired, but Lumi cannot currently be reached."; NextActionDetail.Text = "Your device is paired, but Lumi cannot currently be reached.";
NextActionButton.Content = "Retry connection"; NextActionButton.Content = "Retry connection";
} }
else if (state.ObsBridgeUpdateAvailable)
{
NextActionTitle.Text = "Update OBS Bridge";
NextActionDetail.Text = state.ObsBridgeDetail;
NextActionButton.Content = "Manage OBS integration";
}
else if (!state.ObsBridgeInstalled) else if (!state.ObsBridgeInstalled)
{ {
NextActionTitle.Text = "Install the OBS integration"; NextActionTitle.Text = "Install the OBS integration";

View File

@ -23,9 +23,11 @@ public sealed class ObsBridgeManager
var packageAvailable = package is not null; var packageAvailable = package is not null;
var fileInstalled = File.Exists(InstalledDll); var fileInstalled = File.Exists(InstalledDll);
var valid = packageAvailable && fileInstalled && HashFile(InstalledDll) == package!.Manifest.Sha256 && installed?.Version == package.Manifest.Version; var valid = packageAvailable && fileInstalled && HashFile(InstalledDll) == package!.Manifest.Sha256 && installed?.Version == package.Manifest.Version;
return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, package?.Manifest.Version, var updateAvailable = packageAvailable && fileInstalled && installed is not null && installed.Version != package!.Manifest.Version;
return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, updateAvailable, installed?.Version, package?.Manifest.Version,
valid ? $"OBS integration {package!.Manifest.Version} is installed. Restart OBS if it was open during the last repair." : valid ? $"OBS integration {package!.Manifest.Version} is installed. Restart OBS if it was open during the last repair." :
!packageAvailable ? $"The bundled OBS integration could not be verified. {_packageFailure ?? "The packaged component was not found."}" : !packageAvailable ? $"The bundled OBS integration could not be verified. {_packageFailure ?? "The packaged component was not found."}" :
updateAvailable ? $"OBS Bridge {installed!.Version} is installed; Companion includes {package!.Manifest.Version}. Close OBS, then update the bridge." :
fileInstalled ? "The OBS integration is outdated or damaged. Repair it while OBS is closed." : "The OBS integration is ready to install."); fileInstalled ? "The OBS integration is outdated or damaged. Repair it while OBS is closed." : "The OBS integration is ready to install.");
} }
@ -242,5 +244,12 @@ public sealed record ObsBridgeManifest(
[property: JsonPropertyName("version")] string Version, [property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("sha256")] string Sha256, [property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("obs_minimum_version")] string ObsMinimumVersion); [property: JsonPropertyName("obs_minimum_version")] string ObsMinimumVersion);
public sealed record ObsBridgeStatus(bool PackageAvailable, bool Installed, bool Valid, string? Version, string Detail); public sealed record ObsBridgeStatus(
bool PackageAvailable,
bool Installed,
bool Valid,
bool UpdateAvailable,
string? InstalledVersion,
string? Version,
string Detail);
internal sealed record ObsBridgePackage(ObsBridgeManifest Manifest, byte[] Dll, byte[]? Locale); internal sealed record ObsBridgePackage(ObsBridgeManifest Manifest, byte[] Dll, byte[]? Locale);

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.3.0 Version: 0.3.1
## Routes ## Routes
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events
@ -93,6 +93,13 @@ Version: 0.3.0
- POST /admin/theming/custom/:id/delete - POST /admin/theming/custom/:id/delete
- POST /admin/theming - POST /admin/theming
- GET /admin/diagnostics - GET /admin/diagnostics
- GET /admin/stream-testing
- GET /admin/stream-testing/status
- POST /admin/stream-testing/stop
- POST /admin/stream-testing/pattern/start
- GET /admin/stream-testing/media/:id/captions.vtt
- GET /admin/stream-testing/media/:id/:name
- GET /admin/stream-testing/hls.js
- POST /admin/diagnostics/run - POST /admin/diagnostics/run
- POST /admin/diagnostics/access/renew - POST /admin/diagnostics/access/renew
- POST /admin/diagnostics/access/revoke - POST /admin/diagnostics/access/revoke
@ -908,6 +915,69 @@ Version: 0.3.0
- Side effects: Usually read-only. - Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/stream-testing
- Purpose: Renders the admin stream testing WebUI page.
- Inputs: No request parameters detected by static analysis.
- Response format: HTML page rendered from an EJS view
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/stream-testing/status
- Purpose: Provides admin stream testing status data as JSON.
- Inputs: No request parameters detected by static analysis.
- Response format: JSON response
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### POST /admin/stream-testing/stop
- Purpose: Provides admin stream testing stop data as JSON.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: JSON response
- Access: admin access expected
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/stream-testing/pattern/start
- Purpose: Processes the admin stream testing pattern start action and stores or applies submitted form data.
- Inputs: No request parameters detected by static analysis.
- Response format: Form/action response; exact format was not detected statically.
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### GET /admin/stream-testing/media/:id/captions.vtt
- Purpose: Handles admin stream testing media id captions vtt.
- Inputs: path params: `id`
- Response format: HTML or data response; exact format was not detected statically.
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/stream-testing/media/:id/:name
- Purpose: Handles admin stream testing media id name.
- Inputs: path params: `id`, `name`
- Response format: static file response
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/stream-testing/hls.js
- Purpose: Handles admin stream testing hls js.
- Inputs: No request parameters detected by static analysis.
- Response format: static file response
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### POST /admin/diagnostics/run ### POST /admin/diagnostics/run
- Purpose: Processes the admin diagnostics run action and stores or applies submitted form data. - Purpose: Processes the admin diagnostics run action and stores or applies submitted form data.

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata ## Metadata
Plugin ID: lumi_transcription Plugin ID: lumi_transcription
Version: 0.2.0 Version: 0.2.1
Default state: enabled Default state: enabled
## Web Routes ## Web Routes
- /plugins/lumi_transcription - /plugins/lumi_transcription

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.0", "version": "0.3.1",
"dependencies": { "dependencies": {
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.0", "version": "0.3.1",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -1,5 +1,11 @@
# Lumi Transcription changelog # Lumi Transcription changelog
## 0.2.1
- Surface Stream Testing startup failures immediately instead of leaving the Companion action waiting with a stale status.
- Detect the OBS Bridge version actually loaded by OBS and show Update OBS Bridge actions on Overview and Connection & device when it differs from the bundled integration.
- Prevent private output redirection until an outdated or damaged OBS integration is updated or repaired.
## 0.2.0 ## 0.2.0
- Added authenticated private stream-test session control over the existing Companion transport, including reused live caption cues and OBS output diagnostics. - Added authenticated private stream-test session control over the existing Companion transport, including reused live caption cues and OBS output diagnostics.

View File

@ -1,17 +1,17 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.2.0", "version": "0.2.1",
"signed": false, "signed": false,
"release_notes": "Adds private OBS stream testing with encrypted exact-service recovery, live output diagnostics, and repeatable update checks.", "release_notes": "Fixes Stream Testing startup feedback, discovers local FFmpeg installations, and adds explicit outdated OBS Bridge update actions.",
"installer": { "installer": {
"id": "windows-x64-installer", "id": "windows-x64-installer",
"platform": "win32", "platform": "win32",
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 per-user installer", "label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe", "filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.0/Lumi.Companion-Setup.exe", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.1/Lumi.Companion-Setup.exe",
"sha256": "cdeb43dc1512f537ff4cf8840fb36ee7888c1a92d901a50984079beabba221da", "sha256": "9bf4d9308c31fe9933878ed784350236b0fd4098cbe838dc2dcb8387922c40cd",
"bytes": 51882646 "bytes": 51883242
}, },
"artifacts": [ "artifacts": [
{ {
@ -20,9 +20,9 @@
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 self-contained", "label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip", "filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.0/Lumi.Companion-win-x64.zip", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.1/Lumi.Companion-win-x64.zip",
"sha256": "bf42a9abbf1df9eb604f3d7f0216939608af071fecb8b17e3def55037ce2e77f", "sha256": "32ce845e7576f8cd65352df561998f60c00dfb6507ab96089790194446b799e6",
"bytes": 66775103, "bytes": 66773784,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_transcription", "id": "lumi_transcription",
"name": "Lumi Transcription", "name": "Lumi Transcription",
"version": "0.2.0", "version": "0.2.1",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js", "main": "index.js",
"channel": "stable", "channel": "stable",

View File

@ -2,6 +2,38 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.3.1",
"ref": "refs/tags/v0.3.1",
"released_at": "2026-07-24",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Fixes Stream Testing startup feedback and Windows FFmpeg discovery, and adds explicit loaded OBS Bridge version checks with update actions. Existing local and plugin data remains preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"lumi_transcription": "0.2.1",
"moderation": "0.1.5",
"now_playing": "0.1.2",
"okf": "0.1.2",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{ {
"version": "0.3.0", "version": "0.3.0",
"ref": "refs/tags/v0.3.0", "ref": "refs/tags/v0.3.0",

View File

@ -4,12 +4,12 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.3.0"; const releaseVersion = "0.3.1";
const previousStableVersion = "0.2.27"; const previousStableVersion = "0.3.0";
const priorStableVersion = "0.2.26"; const priorStableVersion = "0.2.27";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = { const introducedPlugins = {
lumi_transcription: { version: "0.2.0", knowledge: "lumi-transcription" }, lumi_transcription: { version: "0.2.1", knowledge: "lumi-transcription" },
now_playing: { version: "0.1.2", knowledge: "now-playing" } now_playing: { version: "0.1.2", knowledge: "now-playing" }
}; };
@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.3.0 after 0.2.27 with synchronized Companion plugin metadata."); console.log("Release metadata verification passed: stable core 0.3.1 after 0.3.0 with synchronized Companion plugin metadata.");

View File

@ -3,10 +3,11 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const { ladderFor, ffmpegArgs, streamTestingService } = require("../src/services/stream-testing"); const { ladderFor, ffmpegArgs, streamTestingService, resolveFfmpegExecutable } = require("../src/services/stream-testing");
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol"); const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
async function main() { async function main() {
assert.equal(typeof resolveFfmpegExecutable(), "string");
const fullHd = ladderFor({ width: 1920, height: 1080, fps: 60 }); const fullHd = ladderFor({ width: 1920, height: 1080, fps: 60 });
assert.deepStrictEqual(fullHd.variants.map((item) => item.name), ["source", "720p", "480p"]); assert.deepStrictEqual(fullHd.variants.map((item) => item.name), ["source", "720p", "480p"]);
assert(fullHd.variants.every((item) => item.width <= 1920 && item.height <= 1080), "adaptive ladder must never upscale"); assert(fullHd.variants.every((item) => item.width <= 1920 && item.height <= 1080), "adaptive ladder must never upscale");
@ -38,6 +39,8 @@ async function main() {
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 companionUi = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8"); const companionUi = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
const companionView = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8");
const bridgeManager = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/ObsBridgeManager.cs"), "utf8");
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8"); const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
assert.match(server, /admin\/stream-testing", requireRole\("admin"\)/); assert.match(server, /admin\/stream-testing", requireRole\("admin"\)/);
@ -49,6 +52,7 @@ async function main() {
assert.match(service, /caption_delay/); assert.match(service, /caption_delay/);
assert.match(service, /receiver_slow/); assert.match(service, /receiver_slow/);
assert.match(service, /PUBLIC_INGEST_PORT/); assert.match(service, /PUBLIC_INGEST_PORT/);
assert.match(service, /Microsoft", "WinGet", "Packages/);
assert.strictEqual(/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test("master.m3u8"), true); assert.strictEqual(/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test("master.m3u8"), true);
assert.match(service, /spawn\(FFMPEG, args, \{[^}]*stdio:/s); assert.match(service, /spawn\(FFMPEG, args, \{[^}]*stdio:/s);
assert.match(service, /testsrc2=size=1280x720:rate=30/); assert.match(service, /testsrc2=size=1280x720:rate=30/);
@ -63,8 +67,16 @@ async function main() {
assert.match(companion, /StartStreamTestAsync/); assert.match(companion, /StartStreamTestAsync/);
assert.match(companion, /RestoreObsAfterStreamTestAsync/); assert.match(companion, /RestoreObsAfterStreamTestAsync/);
assert.match(companion, /finally\s*\{\s*Interlocked\.Exchange\(ref _streamTestRestoreRunning, 0\)/s); assert.match(companion, /finally\s*\{\s*Interlocked\.Exchange\(ref _streamTestRestoreRunning, 0\)/s);
assert.match(companion, /StreamTestDetail = streamTestPending/);
assert.match(companion, /stream_test_start_failed/);
assert.match(companionUi, /CheckUpdateButton\.IsEnabled = !state\.UpdateCheckRunning/); assert.match(companionUi, /CheckUpdateButton\.IsEnabled = !state\.UpdateCheckRunning/);
assert.match(companionUi, /CheckUpdateButton\.Content = state\.UpdateCheckRunning \? "Checking…" : "Check now"/); assert.match(companionUi, /CheckUpdateButton\.Content = state\.UpdateCheckRunning \? "Checking…" : "Check now"/);
assert.match(companionUi, /ObsBridgeUpdateAvailable/);
assert.match(companionUi, /Update OBS Bridge/);
assert.match(companionView, /OverviewBridgeUpdatePanel/);
assert.match(companionView, /ConnectionBridgeUpdatePanel/);
assert.match(bridgeManager, /InstalledVersion/);
assert.match(bridgeManager, /UpdateAvailable/);
assert.match(webUi, /data-stream-player/); assert.match(webUi, /data-stream-player/);
assert.match(webUi, /data-stream-quality/); assert.match(webUi, /data-stream-quality/);
assert.match(webUi, /data-stream-captions/); assert.match(webUi, /data-stream-captions/);

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json"); const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version); assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable"); assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.3.0"); assert.equal(packageVersion, "0.3.1");
assert.equal(currentRelease.version, "0.3.0"); assert.equal(currentRelease.version, "0.3.1");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]); assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) { for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`); assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = { const baseTarget = {
current_version: "0.2.4", current_version: "0.2.4",
available_versions: [ available_versions: [
{ version: "0.3.1", ref: "refs/tags/v0.3.1", rollback_safe: true },
{ version: "0.3.0", ref: "refs/tags/v0.3.0", rollback_safe: true }, { version: "0.3.0", ref: "refs/tags/v0.3.0", rollback_safe: true },
{ version: "0.2.27", ref: "refs/tags/v0.2.27", rollback_safe: true }, { version: "0.2.27", ref: "refs/tags/v0.2.27", rollback_safe: true },
{ version: "0.2.26", ref: "refs/tags/v0.2.26", rollback_safe: true }, { version: "0.2.26", ref: "refs/tags/v0.2.26", rollback_safe: true },
@ -149,7 +150,7 @@ const corrected = buildStatus({
channel: "stable" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.3.0"); assert.equal(corrected.safe_target_version, "0.3.1");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -13,7 +13,34 @@ const INACTIVITY_MS = Math.min(5 * 60 * 1000, Math.max(20 * 1000, Number(process
const MAX_OUTPUT_BYTES = Math.min(4 * 1024 ** 3, Math.max(128 * 1024 ** 2, Number(process.env.LUMI_STREAM_TEST_MAX_BYTES) || 1024 ** 3)); const MAX_OUTPUT_BYTES = Math.min(4 * 1024 ** 3, Math.max(128 * 1024 ** 2, Number(process.env.LUMI_STREAM_TEST_MAX_BYTES) || 1024 ** 3));
const INGEST_PORT = Math.min(65535, Math.max(1024, Number(process.env.LUMI_STREAM_TEST_INGEST_PORT) || 19350)); const INGEST_PORT = Math.min(65535, Math.max(1024, Number(process.env.LUMI_STREAM_TEST_INGEST_PORT) || 19350));
const PUBLIC_INGEST_PORT = Math.min(65535, Math.max(1, Number(process.env.LUMI_STREAM_TEST_PUBLIC_PORT) || INGEST_PORT)); const PUBLIC_INGEST_PORT = Math.min(65535, Math.max(1, Number(process.env.LUMI_STREAM_TEST_PUBLIC_PORT) || INGEST_PORT));
const FFMPEG = String(process.env.LUMI_FFMPEG_PATH || "ffmpeg");
function resolveFfmpegExecutable() {
if (process.env.LUMI_FFMPEG_PATH) return String(process.env.LUMI_FFMPEG_PATH);
if (process.platform !== "win32") return "ffmpeg";
const local = process.env.LOCALAPPDATA;
const programFiles = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean);
const direct = [
local && path.join(local, "Microsoft", "WinGet", "Links", "ffmpeg.exe"),
local && path.join(local, "Programs", "ffmpeg", "bin", "ffmpeg.exe"),
...programFiles.map((root) => path.join(root, "ffmpeg", "bin", "ffmpeg.exe"))
].filter(Boolean);
for (const candidate of direct) {
try { if (fs.statSync(candidate).isFile()) return candidate; } catch {}
}
const packages = local && path.join(local, "Microsoft", "WinGet", "Packages");
try {
for (const packageName of fs.readdirSync(packages).filter((name) => /ffmpeg/i.test(name)).slice(0, 32)) {
const packageRoot = path.join(packages, packageName);
for (const versionName of fs.readdirSync(packageRoot).slice(0, 32)) {
const candidate = path.join(packageRoot, versionName, "bin", "ffmpeg.exe");
try { if (fs.statSync(candidate).isFile()) return candidate; } catch {}
}
}
} catch {}
return "ffmpeg";
}
const FFMPEG = resolveFfmpegExecutable();
function finite(value, fallback, min, max) { function finite(value, fallback, min, max) {
const parsed = Number(value); const parsed = Number(value);
@ -502,4 +529,4 @@ class StreamTestingService {
const streamTestingService = new StreamTestingService(); const streamTestingService = new StreamTestingService();
module.exports = { StreamTestingService, streamTestingService, ladderFor, executableStatus, ffmpegArgs, DATA_ROOT }; module.exports = { StreamTestingService, streamTestingService, ladderFor, executableStatus, ffmpegArgs, resolveFfmpegExecutable, DATA_ROOT };

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.3.0", "version": "0.3.1",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-24", "released_at": "2026-07-24",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "minor", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Adds private OBS stream testing, crash-safe exact-service restoration, corrected overlay media boundaries, and repeatable Companion update checks. Existing settings, databases, pairing records, stream credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved. Stream-test fragments are ephemeral and removed at session end.", "migration_notes": "Fixes Stream Testing startup feedback and Windows FFmpeg discovery, and adds explicit outdated OBS Bridge update guidance and actions. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer", "Node.js 18 or newer",
@ -350,6 +350,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Completes exact-release bundled-plugin synchronization after legacy core-only updates while preserving local data." "migration_notes": "Completes exact-release bundled-plugin synchronization after legacy core-only updates while preserving local data."
},
{
"version": "0.3.0",
"channel": "stable",
"released_at": "2026-07-24",
"compatible_from": "0.1.9",
"migration_kind": "minor",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds private OBS stream testing, crash-safe exact-service restoration, corrected overlay media boundaries, and repeatable Companion update checks while preserving local data."
} }
] ]
} }