using System; using System.Threading; using System.Threading.Tasks; namespace ytLive.Services; /// What YouTube says about the broadcast once a push is back. public enum BroadcastLiveness { /// Could not tell (no token, network, API hiccup) — treat as recovered. Unknown, /// The broadcast is still live/ready: the push landed and the show continues. Live, /// The broadcast is complete. It cannot be resumed; the push is talking to a dead ingest. Complete, } /// Everything the recovery loop needs from the app, as delegates so it can be tested /// without ffmpeg, without a network, and without a window. public sealed class StreamRecoverySteps { /// Recreates the audio pipe server. The old one is unusable after its client died /// (single-instance server), and the encoder cannot start without it. public required Func RestartAudio { get; init; } /// Starts the push. True when the encoder came up. public required Func> StartPush { get; init; } /// Asks YouTube whether the broadcast survived. Only called after a push starts. public required Func> CheckBroadcast { get; init; } /// State for the alert stack: attempt numbers and the outcome. public required Action Report { get; init; } /// The record: one line per attempt, into the app log. public required Action Log { get; init; } public Func Delay { get; init; } = (delay, ct) => delay <= TimeSpan.Zero ? Task.CompletedTask : Task.Delay(delay, ct); } public enum RecoveryOutcome { Recovered, GaveUp, Cancelled, BroadcastComplete, } public sealed record RecoveryResult(RecoveryOutcome Outcome, int Attempts, TimeSpan Elapsed); /// /// TASK 32·1 — the blip retry. When the encoder dies while the session is live, relaunch the same push /// against the same ingest, restarting the audio pipe each time (its server is single-instance and the /// dead client leaves it unusable). /// The check that stops us lying: a reconnect can succeed at the socket level after /// YouTube has already ended the broadcast. So each successful start asks the API whether the broadcast /// is still there; "complete" ends recovery instead of showing a live sign over a dead stream. /// public sealed class StreamRecovery { private readonly StreamRetryPolicy _policy; private readonly StreamRecoverySteps _steps; public StreamRecovery(StreamRetryPolicy policy, StreamRecoverySteps steps) { _policy = policy; _steps = steps; } public async Task RunAsync(CancellationToken cancellationToken = default) { var clock = System.Diagnostics.Stopwatch.StartNew(); var attempts = 0; for (var attempt = 0; attempt < _policy.Attempts; attempt++) { if (cancellationToken.IsCancellationRequested) return Result(RecoveryOutcome.Cancelled); attempts = attempt + 1; var wait = _policy.WaitBefore(attempt); if (wait > TimeSpan.Zero) { try { await _steps.Delay(wait, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { return Result(RecoveryOutcome.Cancelled); } } if (cancellationToken.IsCancellationRequested) return Result(RecoveryOutcome.Cancelled); _steps.Report($"Stream dropped — reconnecting (attempt {attempts} of {_policy.Attempts})"); _steps.Log($"RECONNECT: attempt {attempts} of {_policy.Attempts} — restarting the audio pipe and the push"); try { await _steps.RestartAudio(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _steps.Log($"RECONNECT: audio pipe restart failed on attempt {attempts}: {ex.Message}"); continue; } bool started; try { started = await _steps.StartPush(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _steps.Log($"RECONNECT: push restart failed on attempt {attempts}: {ex.Message}"); continue; } if (!started) { _steps.Log($"RECONNECT: attempt {attempts} of {_policy.Attempts} failed — the encoder did not come up"); continue; } var liveness = BroadcastLiveness.Unknown; try { liveness = await _steps.CheckBroadcast(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _steps.Log($"RECONNECT: could not check the broadcast on attempt {attempts}: {ex.Message}"); } if (liveness == BroadcastLiveness.Complete) { _steps.Report("Stream ended — YouTube reports this broadcast complete. It cannot be resumed."); _steps.Log("RECONNECT: push came back, but the broadcast is complete — cannot resume"); return Result(RecoveryOutcome.BroadcastComplete); } _steps.Report($"Stream recovered after {clock.Elapsed.TotalSeconds:0.0} s"); _steps.Log($"RECONNECT: push re-established after {clock.Elapsed.TotalSeconds:0.0} s — still live"); return Result(RecoveryOutcome.Recovered); } _steps.Report($"Stream dropped — {_policy.Attempts} attempts failed. The stream is off air."); _steps.Log($"RECONNECT: gave up after {_policy.Attempts} attempts — stream is off air"); return Result(RecoveryOutcome.GaveUp); RecoveryResult Result(RecoveryOutcome outcome) => new(outcome, attempts, clock.Elapsed); } }