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);
}
}