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:
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·5: can we actually reach YouTube's ingest right now? One TCP connect, nothing else.
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public interface IIngestProbe
|
||||
{
|
||||
/// <summary>True when the ingest host accepted a TCP connection. False on any failure — the
|
||||
/// point is a fast, unambiguous answer, not a diagnosis.</summary>
|
||||
Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>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".</summary>
|
||||
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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>rtmp://a.rtmp.youtube.com/live2/key → (a.rtmp.youtube.com, 1935).
|
||||
/// rtmps uses 443, which is what YouTube's secure ingest listens on.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user