feat(stream): probe the ingest before going live — no more ghost broadcasts (TASK 32·5)

"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.

- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
  (rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
  fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
  exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
  A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
  back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.

Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
This commit is contained in:
2026-11-05 14:56:20 -08:00
parent d8c1bdba24
commit ec1091a170
7 changed files with 243 additions and 3 deletions
@@ -147,6 +147,22 @@ public partial class MainViewModel : ViewModelBase
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
/// <summary>TASK 32·5 — is YouTube's ingest reachable right now? A false answer aborts go-live
/// before anything is created on YouTube: "I forgot the VPN" used to produce a broadcast that
/// nobody ever saw, and the app looked live while the push had nowhere to go.</summary>
internal async Task<bool> IngestPreflightAsync(string rtmpUrl)
{
var probe = IngestProbeOverride ?? new TcpIngestProbe();
if (await probe.CanReachAsync(rtmpUrl)) return true;
AppLog.Write("Ingest unreachable — go-live aborted before creating a broadcast",
AppLogMessageType.Error, AppLogErrorClass.Class1);
_notifications.Error("Can't reach YouTube's ingest",
"Nothing was created on YouTube. Check your network or VPN, then try again.");
StopStream(); // rollback: no ghost broadcast, no half-lit session
return false;
}
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
@@ -156,6 +172,13 @@ public partial class MainViewModel : ViewModelBase
{
try
{
// TASK 32·5 — prevention. With a cached ingest URL we probe BEFORE the first API call, so
// a dead network costs nothing and leaves nothing behind on YouTube. First run (no cached
// URL yet) probes once the stream is resolved, still before any broadcast is created.
var cachedIngest = _reusableStreamUrl;
if (!string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(cachedIngest))
return;
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
@@ -170,6 +193,9 @@ public partial class MainViewModel : ViewModelBase
_reusableStreamUrl = stream.RtmpUrl;
_layoutStore.SaveReusableStream(stream);
if (string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(stream.RtmpUrl))
return;
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow);
_broadcastSeenLive = false;