root cause (proven, not inferred): contentDetails.boundStreamId on liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING | streamStatus=active created is Google's "incomplete settings" state, so enableAutoStart has no bound stream to fire from; close-out then 403s invalidTransition because created -> complete is not a legal edge. A reusable stream accepts bytes for a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session is exactly what an unbound broadcast looks like -- every observable pointed away from the binding. Google's migration guide states the rule outright: a liveBroadcast must be bound to exactly one stream before it can start, and is not bound at creation. That was in the docs read during the original design and was not applied. Shipped: - BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body), called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump. Failure is fatal to the session: an unbound broadcast can never go live, so a live-looking UI over one is a lie. - boundStreamId removed from the insert body, and the streamId parameter removed from CreateBroadcast. A parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. - Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, with tests for the omitted field and the real bind endpoint. Error messages (creator ruling 2026-10-04): bind failures are not all "check the sign-in". Six documented reasons exist and only one is auth; BindFailureMessage gives each its own text, and the fallback passes the raw reason through verbatim so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters most -- it means our ordering is wrong, and a generic re-auth message would hide a bug in our own code behind a plausible excuse. See MyMistakes.md. ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the broadcast is live. The diagnostic shows it present while lifecycle is created. That claim was inferred from a sample's active-status guard, which is not a spec. Suite 395/395, 0 warnings.
604 lines
30 KiB
C#
604 lines
30 KiB
C#
using System.Net.Http;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using ytLive.Models;
|
|
using AppLog = ytLive.Helpers.AppLog;
|
|
|
|
namespace ytLive.Services;
|
|
/// <summary>
|
|
/// Manages YouTube live stream lifecycle — create broadcasts,
|
|
/// bind stream keys, monitor health.
|
|
/// </summary>
|
|
public class YouTubeStreamService
|
|
{
|
|
private readonly YouTubeAuthService _auth;
|
|
private readonly HttpClient _http;
|
|
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
|
|
|
|
public YouTubeStreamService(YouTubeAuthService auth, HttpClient? http = null)
|
|
{
|
|
_auth = auth;
|
|
_http = http ?? new HttpClient();
|
|
}
|
|
|
|
private async Task<bool> EnsureToken()
|
|
{
|
|
if (_auth.CurrentChannel == null) return false;
|
|
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
|
return await _auth.RefreshToken();
|
|
return true;
|
|
}
|
|
|
|
/// <param name="scheduledStartTime"/> — note there is deliberately NO streamId
|
|
/// parameter (2026-10-04). It used to be here and was ignored: sending
|
|
/// `contentDetails.boundStreamId` does not bind the broadcast. A parameter that looks
|
|
/// meaningful but cannot affect the outcome is a defect — it is exactly what made the
|
|
/// week-long misdiagnosis possible — so the signature now says what it actually does.</param>
|
|
public async Task<string?> CreateBroadcast(string title, string description, DateTime scheduledStartTime)
|
|
{
|
|
if (!await EnsureToken()) return null;
|
|
|
|
var broadcast = new
|
|
{
|
|
snippet = new
|
|
{
|
|
title,
|
|
description,
|
|
scheduledStartTime = scheduledStartTime.ToString("o"),
|
|
categoryId = "22" // People & Blogs
|
|
},
|
|
status = new
|
|
{
|
|
// Private-only by enforcement (ship step 7) — the Go Live dialog
|
|
// is locked to Private and the service refuses anything else.
|
|
privacyStatus = "private",
|
|
selfDeclaredMadeForKids = false
|
|
},
|
|
// One-click go-live (TASK 5 design decision 1): auto start/stop with
|
|
// no monitor stream and low latency.
|
|
//
|
|
// ⛔ `boundStreamId` is DELIBERATELY NOT SENT HERE (2026-10-04). It used to
|
|
// be, on the assumption that binding at insert saved a round-trip. It does
|
|
// not bind anything — proven: the insert was accepted, sibling fields in
|
|
// this same object took effect, and the broadcast still read `bound=NONE`,
|
|
// stayed in lifeCycleStatus `created`, and never went live. Binding is done
|
|
// by the explicit BindBroadcastAsync call instead. Leaving the field out
|
|
// stops the next reader trusting it; see that method for the full account.
|
|
contentDetails = BuildContentDetails()
|
|
};
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
var response = await _http.PostAsJsonAsync(
|
|
$"{ApiBase}/liveBroadcasts?part=snippet,status,contentDetails", broadcast);
|
|
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
|
return data.GetProperty("id").GetString();
|
|
}
|
|
|
|
private static Dictionary<string, object?> BuildContentDetails()
|
|
{
|
|
// No `boundStreamId` here on purpose — it does not bind (proven 2026-10-04).
|
|
// Binding is the explicit BindBroadcastAsync call; see CreateBroadcast.
|
|
return new Dictionary<string, object?>
|
|
{
|
|
["enableAutoStart"] = true,
|
|
["enableAutoStop"] = true,
|
|
["enableMonitorStream"] = false,
|
|
["latencyPreference"] = "low",
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the creator-editable broadcast fields to YouTube via
|
|
/// liveBroadcasts.update (part=snippet,status). Valid any time, including
|
|
/// while live. Returns null on success, otherwise a human-readable error.
|
|
/// Note: liveBroadcasts.update REPLACES the snippet part, so scheduledStartTime
|
|
/// is re-sent unchanged from the stored value — omitting it would clear the
|
|
/// schedule server-side.
|
|
/// </summary>
|
|
public async Task<string?> UpdateBroadcast(string broadcastId, BroadcastMetadata meta)
|
|
{
|
|
if (!await EnsureToken()) return "not signed in";
|
|
|
|
var tags = meta.TagsCsv
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.ToList();
|
|
|
|
var broadcast = new
|
|
{
|
|
id = broadcastId,
|
|
snippet = new
|
|
{
|
|
title = meta.Title,
|
|
description = meta.Description,
|
|
tags = tags,
|
|
// update replaces the whole snippet part; echo the original schedule
|
|
scheduledStartTime = (meta.ScheduledStartTime ?? DateTime.UtcNow).ToString("o"),
|
|
categoryId = "22"
|
|
},
|
|
status = new
|
|
{
|
|
privacyStatus = string.IsNullOrWhiteSpace(meta.Visibility) ? "private" : meta.Visibility.ToLowerInvariant(),
|
|
selfDeclaredMadeForKids = meta.MadeForKids
|
|
}
|
|
};
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
var response = await _http.PutAsJsonAsync(
|
|
$"{ApiBase}/liveBroadcasts?part=snippet,status", broadcast);
|
|
|
|
if (response.IsSuccessStatusCode) return null;
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}");
|
|
return $"YouTube rejected the update ({(int)response.StatusCode})";
|
|
}
|
|
|
|
/// <summary>Proper close-out (TASK 9 design decision 6 — implemented 2026-09-01;
|
|
/// until then we relied entirely on enableAutoStop, leaving viewers on a frozen
|
|
/// "stream offline" screen for ~a minute): POST liveBroadcasts.transition
|
|
/// broadcastStatus=complete. MUST be called AFTER the encoder closed the RTMP
|
|
/// push so no frames post-date the end. **Pre-flight (2026-09-22):** the POST is
|
|
/// now gated on the broadcast's OWN lifeCycleStatus — every 2026-09-22 end
|
|
/// logged 403 invalidTransition because enableAutoStop/YouTube had already marked
|
|
/// the broadcast complete; a blind complete only earned the 403 + log noise. We
|
|
/// skip ONLY on a confirmed already-ended status (complete/revoked) — an
|
|
/// inconclusive check still posts (old behavior) rather than silently stranding a
|
|
/// live broadcast. The POST is never-throwing: a rare still-failing transition
|
|
/// surfaces as an error string, never a throw — the stop path must not fail over a
|
|
/// cosmetic close-out.</summary>
|
|
public async Task<string?> EndBroadcastAsync(string broadcastId)
|
|
{
|
|
if (!await EnsureToken()) return "not signed in";
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
|
|
var lifeCycle = await GetLifeCycleStatusAsync(broadcastId);
|
|
if (lifeCycle is "complete" or "revoked")
|
|
{
|
|
// Already ended (autoStop/YouTube raced us) — a blind complete only
|
|
// earns a 403 + noise. enableAutoStop finished it.
|
|
AppLog.Write($"End close-out: {broadcastId} already at lifeCycleStatus '{lifeCycle}' — skipping complete");
|
|
return null;
|
|
}
|
|
|
|
var response = await _http.PostAsync(
|
|
$"{ApiBase}/liveBroadcasts/transition?broadcastStatus=complete&id={Uri.EscapeDataString(broadcastId)}&part=status",
|
|
content: null);
|
|
|
|
if (response.IsSuccessStatusCode) return null;
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}");
|
|
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
|
|
}
|
|
|
|
/// <summary>One liveBroadcasts.list (part=status) read of the broadcast's own
|
|
/// lifeCycleStatus — the only reliable "can we complete?" signal. Null when the
|
|
/// list fails or the status is missing (caller degrades gracefully).</summary>
|
|
private async Task<string?> GetLifeCycleStatusAsync(string broadcastId)
|
|
{
|
|
var response = await _http.GetAsync(
|
|
$"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
|
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
|
|
if (!items[0].TryGetProperty("status", out var status)) return null;
|
|
return status.TryGetProperty("lifeCycleStatus", out var lifeCycle)
|
|
? lifeCycle.GetString()
|
|
: null;
|
|
}
|
|
|
|
/// <summary>One compact log line answering the only question that matters while a
|
|
/// stream is running: is the broadcast actually bound to the stream we are pushing
|
|
/// to, and has it moved off `ready`?
|
|
///
|
|
/// Added 2026-10-04 after two test runs proved the push itself is healthy — ffmpeg
|
|
/// delivered 101,229 KiB video + 1,419 KiB audio over 112s with no socket error,
|
|
/// yet `enableAutoStart` never fired and close-out 403'd with `invalidTransition`.
|
|
/// The config is correct by Google's documentation (boundStreamId is a valid
|
|
/// contentDetails field with no restriction against reusable streams), so the only
|
|
/// way forward is to observe rather than guess. Everything the API reports about
|
|
/// this broadcast and its stream goes in one greppable line:
|
|
/// lifeCycleStatus (so the caller can stop asking once it is live), privacyStatus, the boundStreamId
|
|
/// *compared against the stream we think we are pushing*, enableAutoStart/Stop, liveChatId
|
|
/// presence, the stream's own status.streamStatus, and the CDN settings we declared.
|
|
/// Never throws.</summary>
|
|
public async Task<string?> LogBroadcastDiagnosticsAsync(string broadcastId, string? streamId)
|
|
{
|
|
if (!await EnsureToken()) return null;
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
|
|
try
|
|
{
|
|
var broadcastList = await GetJsonAsync($"liveBroadcasts?part=status,contentDetails,snippet&id={Uri.EscapeDataString(broadcastId)}");
|
|
// FIX 2026-10-04: liveBroadcasts.list returns a LIST wrapper, so the
|
|
// broadcast lives at items[0]. The first version of this diagnostic read
|
|
// the wrapper's root and reported every field as "?" — which then made
|
|
// BoundMatches report a confident "NO-BINDING" from a parse that never
|
|
// happened. A diagnostic that lies when it fails is worse than none.
|
|
var broadcast = FirstItem(broadcastList);
|
|
var read = broadcast.ValueKind == JsonValueKind.Object;
|
|
var status = Nested(broadcast, "status");
|
|
var details = Nested(broadcast, "contentDetails");
|
|
var snippet = Nested(broadcast, "snippet");
|
|
|
|
var lifeCycle = Text(status, "lifeCycleStatus") ?? "?";
|
|
var bound = Text(details, "boundStreamId");
|
|
JsonElement? stream = string.IsNullOrWhiteSpace(streamId)
|
|
? null
|
|
: await GetJsonAsync($"liveStreams?part=status,cdn&id={Uri.EscapeDataString(streamId)}");
|
|
var streamItem = stream is { } present ? FirstItem(present) : default(JsonElement);
|
|
|
|
AppLog.Write(
|
|
$"Broadcast diag: id={broadcastId} lifeCycle={lifeCycle} privacy={Text(status, "privacyStatus") ?? "?"} " +
|
|
$"recording={Text(status, "recordingStatus") ?? "?"} bound={(read ? (bound ?? "NONE") : "UNREAD")} " +
|
|
$"expect={streamId ?? "none"} boundMatches={(read ? BoundMatches(bound, streamId) : "unread")} " +
|
|
$"boundLastUpdate={Text(details, "boundStreamLastUpdateTimeMs") ?? "?"} " +
|
|
$"autoStart={Flag(details, "enableAutoStart")} autoStop={Flag(details, "enableAutoStop")} " +
|
|
$"liveChatId={(string.IsNullOrWhiteSpace(Text(snippet, "liveChatId")) ? "none" : "present")} | " +
|
|
$"streamId={Text(streamItem, "id") ?? "none"} streamStatus={Text(Nested(streamItem, "status"), "streamStatus") ?? "?"} " +
|
|
$"activeChat={(string.IsNullOrWhiteSpace(Text(Nested(streamItem, "status"), "activeLiveChatId")) ? "none" : "present")} " +
|
|
$"cdn={Text(Nested(streamItem, "cdn"), "ingestionType") ?? "?"}/{Text(Nested(streamItem, "cdn"), "resolution") ?? "?"}@{Text(Nested(streamItem, "cdn"), "frameRate") ?? "?"}");
|
|
return lifeCycle;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"Broadcast diag failed: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Descends into a `*.list` response and returns its single item. Every list
|
|
/// endpoint wraps the resource in `items[]`, so reading the response root reads
|
|
/// the wrapper and silently yields nothing — which is exactly the bug this
|
|
/// exists to prevent. Returns <c>default</c> when there is no item (empty list,
|
|
/// or a shape we do not recognise) so callers can tell "absent" from "present".</summary>
|
|
internal static JsonElement FirstItem(JsonElement listResponse)
|
|
{
|
|
return listResponse.ValueKind == JsonValueKind.Object
|
|
&& listResponse.TryGetProperty("items", out var items)
|
|
&& items.ValueKind == JsonValueKind.Array
|
|
&& items.GetArrayLength() > 0
|
|
? items[0]
|
|
: default;
|
|
}
|
|
|
|
/// <summary>The bound stream must be the one we are actually feeding. A mismatch is
|
|
/// the failure mode where ingest succeeds (the reusable stream happily accepts the
|
|
/// bytes) while the broadcast waits forever for a stream that is not sending.</summary>
|
|
internal static string BoundMatches(string? boundStreamId, string? expectedStreamId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(boundStreamId)) return "NO-BINDING";
|
|
if (string.IsNullOrWhiteSpace(expectedStreamId)) return "unknown";
|
|
return boundStreamId == expectedStreamId ? "yes" : "MISMATCH";
|
|
}
|
|
|
|
private async Task<JsonElement> GetJsonAsync(string pathAndQuery)
|
|
{
|
|
var response = await _http.GetAsync($"{ApiBase}/{pathAndQuery}");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})");
|
|
return default;
|
|
}
|
|
return JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
|
}
|
|
|
|
private static JsonElement Nested(JsonElement parent, string name) =>
|
|
parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out var child) ? child : default;
|
|
|
|
private static string? Text(JsonElement parent, string name) =>
|
|
parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
|
? value.GetString()
|
|
: null;
|
|
|
|
private static string Flag(JsonElement parent, string name) =>
|
|
parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out var value)
|
|
? (value.ValueKind == JsonValueKind.True ? "true" : value.ValueKind == JsonValueKind.False ? "false" : value.ValueKind.ToString())
|
|
: "unset";
|
|
|
|
/// <summary>Binds the broadcast to its stream — THE call that actually makes a broadcast
|
|
/// go live, and the reason it took a week to find (2026-10-04).
|
|
///
|
|
/// `POST liveBroadcasts/bind?id=…&streamId=…&part=id`, no request body, `youtube.force-ssl`.
|
|
///
|
|
/// **Sending `contentDetails.boundStreamId` on `liveBroadcasts.insert` does NOT bind the
|
|
/// broadcast.** Proven, not assumed: the insert carried `boundStreamId`, the API accepted the
|
|
/// request (sibling fields in the same `contentDetails` — `enableAutoStart`, `enableAutoStop` —
|
|
/// demonstrably took effect), and the created broadcast still came back
|
|
/// `bound=NONE`. YouTube's own migration guide says so outright: *"a liveBroadcast must be bound
|
|
/// to exactly one stream before the live broadcast can actually start. The broadcast is not bound
|
|
/// to any streams at the time that it's created."*
|
|
///
|
|
/// The failure it produces is a **silent stall, not an error**, which is why it survived so long:
|
|
/// an unbound broadcast reports `lifeCycleStatus = created` ("incomplete settings, not ready to
|
|
/// transition to a live or testing status"), so `enableAutoStart` has no bound stream to fire
|
|
/// from, the broadcast never leaves `created`, and close-out then 403s with `invalidTransition`
|
|
/// because `created → complete` is not a legal edge. Meanwhile ffmpeg pushes ~99 MB happily into
|
|
/// the reusable stream, because a reusable stream accepts bytes for a broadcast that is not
|
|
/// watching. Every symptom pointed away from the binding.
|
|
///
|
|
/// Returns null on success, otherwise a human-readable error naming the ACTUAL cause.
|
|
/// Never throws.</summary>
|
|
public async Task<string?> BindBroadcastAsync(string broadcastId, string streamId)
|
|
{
|
|
if (!await EnsureToken()) return "not signed in";
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
|
|
var response = await _http.PostAsync(
|
|
$"{ApiBase}/liveBroadcasts/bind?part=id&id={Uri.EscapeDataString(broadcastId)}&streamId={Uri.EscapeDataString(streamId)}",
|
|
content: null);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
AppLog.Write($"Broadcast bound: {broadcastId} -> stream {streamId}");
|
|
return null;
|
|
}
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
var reason = ErrorReason(body);
|
|
AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}");
|
|
return BindFailureMessage((int)response.StatusCode, reason);
|
|
}
|
|
|
|
/// <summary>Pulls `errors[0].reason` out of a Google API error body. Needed because
|
|
/// "rejected the binding" alone is not actionable — the reason is what tells the
|
|
/// creator whether to re-auth, wait, or suspect our own ordering.</summary>
|
|
internal static string? ErrorReason(string body)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(body);
|
|
if (doc.RootElement.TryGetProperty("error", out var error)
|
|
&& error.TryGetProperty("errors", out var errors)
|
|
&& errors.GetArrayLength() > 0
|
|
&& errors[0].TryGetProperty("reason", out var reason))
|
|
return reason.GetString();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// A non-JSON error body tells us nothing actionable; the status code is enough.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Bind failures are NOT all "sign in again" (2026-10-04). Google documents six
|
|
/// distinct reasons and only one is an auth problem, so a single sign-in message
|
|
/// actively misdirects: `liveBroadcastBindingNotAllowed` in particular means OUR
|
|
/// ordering is wrong, and telling the creator to re-authenticate would hide a bug in
|
|
/// our own code behind a plausible excuse. Each documented reason gets its own text,
|
|
/// because the caller's only lever is the string it shows.</summary>
|
|
internal static string BindFailureMessage(int status, string? reason) => reason switch
|
|
{
|
|
"liveBroadcastBindingNotAllowed" =>
|
|
"YouTube won't bind a broadcast in its current state — it may already be live. End the session and start again.",
|
|
"liveStreamNotFound" =>
|
|
"YouTube couldn't find the reusable stream. Sign in again so it can be recreated.",
|
|
"liveBroadcastNotFound" =>
|
|
"YouTube couldn't find the broadcast that was just created. Try again.",
|
|
"liveStreamingNotEnabled" =>
|
|
"This channel isn't enabled for live streaming — check the channel's live settings in YouTube Studio.",
|
|
"insufficientLivePermissions" =>
|
|
"This account doesn't have permission to stream live. Check the YouTube sign-in.",
|
|
"internalError" =>
|
|
"YouTube hit an internal error binding the stream. Try again.",
|
|
"userRequestsExceededRateLimit" =>
|
|
"YouTube is rate-limiting us. Wait a moment and try again.",
|
|
// Unknown or absent reason: keep it verbatim. Never collapse distinct causes into
|
|
// one bucket — an unmapped reason must stay greppable against the raw API response.
|
|
null => $"YouTube rejected the stream binding (HTTP {status}, no reason in the body — see startup.log).",
|
|
_ => $"YouTube rejected the stream binding (HTTP {status}, reason '{reason}' — see startup.log).",
|
|
};
|
|
|
|
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
|
|
/// lists existing streams first and reuses the one with cdn.isReusable=true,
|
|
/// creating it with variable resolution/frame rate on first use. Binding to a
|
|
/// broadcast happens at broadcast insert (boundStreamId), so one reusable
|
|
/// stream serves every broadcast without recreation.</summary>
|
|
public async Task<ReusableStream?> GetOrCreateReusableStreamAsync()
|
|
{
|
|
if (!await EnsureToken()) return null;
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
var listResponse = await _http.GetAsync(
|
|
$"{ApiBase}/liveStreams?mine=true&part=snippet,cdn,status");
|
|
if (!listResponse.IsSuccessStatusCode) return null;
|
|
|
|
var listJson = JsonSerializer.Deserialize<JsonElement>(
|
|
await listResponse.Content.ReadAsStringAsync());
|
|
if (listJson.TryGetProperty("items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
if (item.TryGetProperty("cdn", out var cdn) &&
|
|
cdn.TryGetProperty("isReusable", out var reusable) &&
|
|
reusable.GetBoolean())
|
|
{
|
|
var parsed = ParseStream(item);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
var stream = new
|
|
{
|
|
snippet = new { title = "LlamaCasty Reusable Stream" },
|
|
cdn = new
|
|
{
|
|
ingestionType = "rtmp",
|
|
resolution = "variable",
|
|
frameRate = "variable",
|
|
isReusable = true
|
|
}
|
|
};
|
|
var response = await _http.PostAsJsonAsync(
|
|
$"{ApiBase}/liveStreams?part=snippet,cdn", stream);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
|
return ParseStream(json);
|
|
}
|
|
|
|
private static ReusableStream? ParseStream(JsonElement item)
|
|
{
|
|
if (!item.TryGetProperty("id", out var id) ||
|
|
!item.TryGetProperty("cdn", out var cdn) ||
|
|
!cdn.TryGetProperty("ingestionInfo", out var info))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var streamId = id.GetString();
|
|
var address = info.TryGetProperty("ingestionAddress", out var addr) ? addr.GetString() : null;
|
|
var name = info.TryGetProperty("streamName", out var nameEl) ? nameEl.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(streamId) || string.IsNullOrWhiteSpace(address) || string.IsNullOrWhiteSpace(name))
|
|
return null;
|
|
|
|
return new ReusableStream(streamId, address, name);
|
|
}
|
|
|
|
/// <summary>Polls the reusable stream's health (TASK 5 item 3) via
|
|
/// liveStreams.status — report-by-exception: good/ok/noData yield an empty
|
|
/// issue list, warning/error entries in configurationIssues[] drive the
|
|
/// banner. Null on failure or an empty response, never a throw.</summary>
|
|
public async Task<StreamHealth?> GetStreamHealthAsync(string streamId)
|
|
{
|
|
if (!await EnsureToken()) return null;
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
var response = await _http.GetAsync(
|
|
$"{ApiBase}/liveStreams?part=status&id={streamId}");
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
|
var items = json.GetProperty("items");
|
|
if (items.GetArrayLength() == 0) return null;
|
|
|
|
var status = items[0].GetProperty("status");
|
|
// The real API nests health as status.healthStatus = { status, lastUpdateTimeSeconds,
|
|
// configurationIssues[] }. The flat "healthStatus":"bad" shape is our old wrong
|
|
// assumption — accept both so neither crashes the report-by-exception poll.
|
|
var health = new StreamHealth();
|
|
var issueElements = new List<JsonElement>();
|
|
if (status.TryGetProperty("healthStatus", out var healthStatus))
|
|
{
|
|
if (healthStatus.ValueKind == JsonValueKind.String)
|
|
{
|
|
health.HealthStatus = healthStatus.GetString();
|
|
}
|
|
else if (healthStatus.TryGetProperty("status", out var inner) &&
|
|
inner.ValueKind == JsonValueKind.String)
|
|
{
|
|
health.HealthStatus = inner.GetString();
|
|
}
|
|
if (healthStatus.TryGetProperty("configurationIssues", out var nested))
|
|
issueElements.AddRange(nested.EnumerateArray());
|
|
}
|
|
if (status.TryGetProperty("configurationIssues", out var flat))
|
|
issueElements.AddRange(flat.EnumerateArray());
|
|
foreach (var issue in issueElements)
|
|
{
|
|
var severity = issue.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.String
|
|
? sev.GetString()
|
|
: null;
|
|
var type = issue.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
|
? t.GetString()
|
|
: null;
|
|
health.ConfigurationIssues.Add(new StreamConfigurationIssue
|
|
{
|
|
Severity = severity switch
|
|
{
|
|
"error" => StreamIssueSeverity.Error,
|
|
"warning" => StreamIssueSeverity.Warning,
|
|
_ => StreamIssueSeverity.Info,
|
|
},
|
|
Type = type,
|
|
});
|
|
}
|
|
return health;
|
|
}
|
|
|
|
/// <summary>TASK 41 — mock chat input: posts a real text message into the live
|
|
/// chat via <c>liveChat/messages.insert</c>. Requires the same OAuth scopes the
|
|
/// app already holds (youtube.force-ssl). Returns itself or a human-readable
|
|
/// error; never throws. The inserted message round-trips back through the normal
|
|
/// chat poll (~2s) and renders through the live overlay path.</summary>
|
|
public async Task<string?> InsertChatMessageAsync(string liveChatId, string messageText)
|
|
{
|
|
if (!await EnsureToken()) return "not signed in";
|
|
|
|
var body = new
|
|
{
|
|
snippet = new
|
|
{
|
|
liveChatId,
|
|
type = "textMessageEvent",
|
|
textMessageDetails = new { messageText }
|
|
}
|
|
};
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
var response = await _http.PostAsJsonAsync(
|
|
$"{ApiBase}/liveChat/messages?part=snippet", body);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
AppLog.Write($"Test chat message inserted into {liveChatId}");
|
|
return null;
|
|
}
|
|
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
|
|
return $"YouTube rejected the message ({(int)response.StatusCode})";
|
|
}
|
|
|
|
/// <summary>Resolves the broadcast's liveChatId (needed to poll chat). Two facts
|
|
/// pin this down (2026-09-25, from the official liveBroadcasts reference + the
|
|
/// GetLiveChatId.java sample):
|
|
/// 1. It lives under <c>snippet.liveChatId</c> — <c>contentDetails</c> has no such
|
|
/// property, so part=contentDetails could NEVER resolve it (the "Chat polling
|
|
/// couldn't start" report).
|
|
/// 2. YouTube only populates it once the broadcast is <c>live</c> (the sample lists
|
|
/// broadcastStatus=active) — fetching right after insert (lifecycleStatus
|
|
/// <c>ready</c>) returns no id. So this polls with a bounded retry, which the
|
|
/// caller must run AFTER the encoder starts pushing RTMP (enableAutoStart flips
|
|
/// the broadcast to live). Returns the id or null after the retries exhaust;
|
|
/// never throws.</summary>
|
|
public async Task<string?> GetBroadcastLiveChatIdAsync(
|
|
string broadcastId, int maxAttempts = 10, int delayMs = 2000)
|
|
{
|
|
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
|
{
|
|
if (attempt > 0) await Task.Delay(delayMs);
|
|
var liveChatId = await FetchLiveChatIdAsync(broadcastId);
|
|
if (liveChatId != null) return liveChatId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async Task<string?> FetchLiveChatIdAsync(string broadcastId)
|
|
{
|
|
if (!await EnsureToken()) return null;
|
|
|
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
|
var response = await _http.GetAsync(
|
|
$"{ApiBase}/liveBroadcasts?part=snippet&id={broadcastId}");
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
|
var items = data.GetProperty("items");
|
|
if (items.GetArrayLength() == 0) return null;
|
|
|
|
var snippet = items[0].GetProperty("snippet");
|
|
if (snippet.TryGetProperty("liveChatId", out var liveChatId))
|
|
return liveChatId.GetString();
|
|
|
|
return null;
|
|
}
|
|
}
|