diff --git a/HANDOFF.md b/HANDOFF.md index f4ffa58..e59581f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -17,7 +17,9 @@ status poll (4 s cadence while recovering) with **measured** elapsed/attempt only — the countdown was dropped after the creator pointed out YouTube's cutoff depends on variables we cannot see. The `chatEndedEvent` in the chat feed now prompts an immediate status check. -- Next: 32·5 (ingest pre-flight), +- **32·5 shipped**: ingest pre-flight (TCP, 2 s) aborts go-live before any API call, so a dead + network leaves no ghost broadcast. +- Next: then 32·2/32·3 (countdown + Back on air) which need YOUR measurement take: pull the network on a private stream and time how long YouTube holds the broadcast. - Open loop: `32·4` is proven by construction only — kill ffmpeg mid-recording and play the file. diff --git a/Services/IngestProbe.cs b/Services/IngestProbe.cs new file mode 100644 index 0000000..c7a451d --- /dev/null +++ b/Services/IngestProbe.cs @@ -0,0 +1,76 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using ytLive.Helpers; + +namespace ytLive.Services; + +/// +/// TASK 32·5: can we actually reach YouTube's ingest right now? One TCP connect, nothing else. +/// This is prevention, not recovery: "I forgot the VPN" used to produce a broadcast that YouTube +/// created and nobody ever saw — the app looked live while the push had nowhere to go. Checking the +/// socket before the API call means the failure is a message, not a ghost broadcast. +/// +public interface IIngestProbe +{ + /// True when the ingest host accepted a TCP connection. False on any failure — the + /// point is a fast, unambiguous answer, not a diagnosis. + Task CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default); +} + +/// Default probe: parse the RTMP URL, connect to the host on its port (1935 for rtmp) with a +/// short timeout. Failures are logged and reported as "not reachable". +public sealed class TcpIngestProbe : IIngestProbe +{ + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2); + + private readonly TimeSpan _timeout; + + public TcpIngestProbe(TimeSpan? timeout = null) => _timeout = timeout ?? DefaultTimeout; + + public async Task CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default) + { + if (!TryParse(rtmpUrl, out var host, out var port)) + { + AppLog.Write($"Ingest probe: '{rtmpUrl}' is not a usable RTMP URL"); + return false; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_timeout); + try + { + using var client = new TcpClient(); + await client.ConnectAsync(host, port, timeoutCts.Token).ConfigureAwait(false); + return client.Connected; + } + catch (OperationCanceledException) + { + AppLog.Write($"Ingest probe: {host}:{port} did not answer within {_timeout.TotalSeconds:0.#} s"); + return false; + } + catch (Exception ex) + { + AppLog.Write($"Ingest probe: {host}:{port} unreachable: {ex.Message}"); + return false; + } + } + + /// rtmp://a.rtmp.youtube.com/live2/key → (a.rtmp.youtube.com, 1935). + /// rtmps uses 443, which is what YouTube's secure ingest listens on. + internal static bool TryParse(string? rtmpUrl, out string host, out int port) + { + host = string.Empty; + port = 1935; + if (string.IsNullOrWhiteSpace(rtmpUrl)) return false; + if (!Uri.TryCreate(rtmpUrl, UriKind.Absolute, out var uri)) return false; + if (string.IsNullOrWhiteSpace(uri.Host)) return false; + + host = uri.Host; + port = uri.IsDefaultPort + ? (uri.Scheme.Equals("rtmps", StringComparison.OrdinalIgnoreCase) ? 443 : 1935) + : uri.Port; + return true; + } +} diff --git a/TASKS.md b/TASKS.md index e322732..a805e1d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -48,7 +48,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block). | 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | | 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) | -| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) | +| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING + ingest pre-flight done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air | [`task-32`](TASKS/task-32-stream-resilience.md) | | 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) | | 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) | | 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) | diff --git a/TASKS/task-32-stream-resilience.md b/TASKS/task-32-stream-resilience.md index 93b13dd..bafdb4d 100644 --- a/TASKS/task-32-stream-resilience.md +++ b/TASKS/task-32-stream-resilience.md @@ -49,9 +49,15 @@ the API — creator ruling 2026-09-01): no lying in the copy. editor tolerance, cite the research). Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` — the flags are on the record block and absent from the streaming block. Verified by construction only: the file still needs one real interrupted-take check on Windows (kill ffmpeg mid-recording, play the result). -5. ☐ **Pre-flight** — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout). +5. ✅ **Pre-flight** (shipped 2026-11-05) — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout). Unreachable → Error toast, no insert, no ghost broadcast ("check VPN/network"). The "forgot the VPN" story, solved as prevention instead of recovery. Test: fake probe failure aborts go-live before any API call. + **Shipped:** `Services/IngestProbe.cs` (`IIngestProbe` + `TcpIngestProbe`, 2 s timeout, rtmp→1935 / + rtmps→443) and `MainViewModel.IngestPreflightAsync`, called BEFORE the first API call whenever a + cached ingest URL exists (first run probes after resolving the stream, still before any broadcast is + created). Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, and the probe is + exercised against real sockets (a listening port answers true, the same port closed answers false + quickly). ### Design decisions diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 35d96e3..6829ecd 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -147,6 +147,22 @@ public partial class MainViewModel : ViewModelBase /// Creates the record file (auto-named ty-…-0000.mp4 in the record /// folder) and returns its path, or null when the folder can't be prepared. + /// 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. + internal async Task 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; + } + /// 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; diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index e65dd28..10ba32e 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -505,6 +505,9 @@ public partial class MainViewModel : ViewModelBase /// a clip's backdrop. internal static IChromaProbe? ChromaProbeOverride { get; set; } + /// Test seam (TASK 32·5): a fake ingest probe so tests never open a socket. + internal static IIngestProbe? IngestProbeOverride { get; set; } + private void LoadLayout() { _isLoading = true; diff --git a/ytLive.Tests/IngestPreflightTests.cs b/ytLive.Tests/IngestPreflightTests.cs new file mode 100644 index 0000000..127c8f0 --- /dev/null +++ b/ytLive.Tests/IngestPreflightTests.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// TASK 32·5 Good Dog: "I forgot the VPN" used to create a broadcast on YouTube that nothing ever +/// pushed to. Go-live now probes the ingest with one TCP connect first; a dead network costs two +/// seconds and leaves nothing behind on YouTube. +/// The gate is asserted through the view-model's own entry point, and the probe is asserted +/// against real sockets — a listening port and a closed one — so neither half is a mock talking to a +/// mock. +/// +[Collection("RealApp")] +public sealed class IngestPreflightTests +{ + private readonly RealAppHost _app; + + public IngestPreflightTests(RealAppHost app) => _app = app; + + [Fact] + public async Task UnreachableIngest_AbortsGoLive() + { + await _app.RunAsync(async () => + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ingest-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + MainViewModel.IngestProbeOverride = new FakeProbe(false); + var window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + try + { + Assert.False(await vm.IngestPreflightAsync("rtmp://a.rtmp.youtube.com/live2/key"), + "an unreachable ingest must abort go-live before anything is created on YouTube"); + Assert.False(vm.IsLive); + } + finally + { + window.Close(); + MainViewModel.IngestProbeOverride = null; + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + }); + } + + [Fact] + public async Task ReachableIngest_LetsGoLiveContinue() + { + await _app.RunAsync(async () => + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ingest-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + MainViewModel.IngestProbeOverride = new FakeProbe(true); + var window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + try + { + Assert.True(await vm.IngestPreflightAsync("rtmp://a.rtmp.youtube.com/live2/key")); + } + finally + { + window.Close(); + MainViewModel.IngestProbeOverride = null; + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + }); + } + + [Fact] + public void TheProbe_ParsesTheIngestUrl_AndDefaultsToTheRtmpPort() + { + Assert.True(TcpIngestProbe.TryParse("rtmp://a.rtmp.youtube.com/live2/key", out var host, out var port)); + Assert.Equal("a.rtmp.youtube.com", host); + Assert.Equal(1935, port); + + Assert.True(TcpIngestProbe.TryParse("rtmps://a.rtmps.youtube.com/live2/key", out _, out var securePort)); + Assert.Equal(443, securePort); + + Assert.False(TcpIngestProbe.TryParse("not a url", out _, out _)); + Assert.False(TcpIngestProbe.TryParse("", out _, out _)); + } + + [Fact] + public async Task TheProbe_AnswersFromRealSockets() + { + // A listener on loopback: reachable. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + try + { + var probe = new TcpIngestProbe(TimeSpan.FromSeconds(2)); + Assert.True(await probe.CanReachAsync($"rtmp://127.0.0.1:{port}/live2/key")); + } + finally + { + listener.Stop(); + } + + // The same port, now closed: unreachable, and it must answer quickly rather than hang. + var closed = new TcpIngestProbe(TimeSpan.FromMilliseconds(400)); + var clock = System.Diagnostics.Stopwatch.StartNew(); + Assert.False(await closed.CanReachAsync($"rtmp://127.0.0.1:{port}/live2/key")); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), + $"the probe must fail fast, took {clock.Elapsed.TotalSeconds:0.0}s"); + } + + private sealed class FakeProbe : IIngestProbe + { + private readonly bool _reachable; + public FakeProbe(bool reachable) => _reachable = reachable; + public Task CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default) + => Task.FromResult(_reachable); + } +}