using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using ytLive.Models; using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass; using AppLogMessageType = ytLive.Helpers.AppLogMessageType; using AppLog = ytLive.Helpers.AppLog; namespace ytLive.Services; /// /// Manages YouTube live stream lifecycle — create broadcasts, /// bind stream keys, monitor health. /// 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 EnsureToken() { if (_auth.CurrentChannel == null) return false; if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5)) return await _auth.RefreshToken(); return true; } /// — 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. public async Task 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(json); return data.GetProperty("id").GetString(); } private static Dictionary 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 { ["enableAutoStart"] = true, ["enableAutoStop"] = true, ["enableMonitorStream"] = false, ["latencyPreference"] = "low", }; } /// /// 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. /// public async Task 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}", AppLogMessageType.Error, AppLogErrorClass.Class2); return $"YouTube rejected the update ({(int)response.StatusCode})"; } /// 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. public async Task 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}", AppLogMessageType.Error, AppLogErrorClass.Class2); return $"YouTube rejected the end transition ({(int)response.StatusCode})"; } /// The broadcast's current status.privacyStatus ("private", "unlisted", /// "public"), read from YouTube rather than trusted from local state — the creator can /// change visibility mid-stream from the broadcast form. Null when signed out or the /// read fails (the caller then does nothing destructive). /// TASK 32·1: the broadcast's lifecycle status (created/ready/testing/ /// live/complete), or null when it cannot be read. Used after a reconnect: a push can /// succeed at the socket level while YouTube has already ended the broadcast. public async Task GetBroadcastStatusAsync(string broadcastId) { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( $"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}"); if (!response.IsSuccessStatusCode) return null; var json = JsonSerializer.Deserialize(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; if (!status.TryGetProperty("lifeCycleStatus", out var lifeCycle)) return null; return lifeCycle.ValueKind == JsonValueKind.String ? lifeCycle.GetString() : null; } public async Task GetBroadcastPrivacyAsync(string broadcastId) { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); try { var response = await _http.GetAsync( $"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}"); if (!response.IsSuccessStatusCode) return null; var json = JsonSerializer.Deserialize(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("privacyStatus", out var privacy) ? privacy.GetString() : null; } catch (Exception ex) { AppLog.Write($"Broadcast privacy read failed: {ex.Message}", AppLogMessageType.Error, AppLogErrorClass.Class2); return null; } } /// Deletes a finished broadcast's recording from YouTube (the creator pressed /// Escape / Delete on the keep-or-delete prompt). A live broadcast's id IS its video's /// id, so this is videos.delete (Data API, 204 on success); if YouTube hasn't /// created the video yet (404) it falls back to liveBroadcasts.delete. Returns /// null on success, or an error string — never throws. /// Docs: https://developers.google.com/youtube/v3/docs/videos/delete , /// https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/delete public async Task DeleteFinishedBroadcastAsync(string broadcastId) { if (!await EnsureToken()) return "not signed in"; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); try { var id = Uri.EscapeDataString(broadcastId); var video = await _http.DeleteAsync($"{ApiBase}/videos?id={id}"); if (video.IsSuccessStatusCode) return null; if (video.StatusCode == System.Net.HttpStatusCode.NotFound) { var broadcast = await _http.DeleteAsync($"{ApiBase}/liveBroadcasts?id={id}"); if (broadcast.IsSuccessStatusCode) return null; var bBody = await broadcast.Content.ReadAsStringAsync(); AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}", AppLogMessageType.Error, AppLogErrorClass.Class2); return $"YouTube refused to delete the broadcast ({(int)broadcast.StatusCode})"; } var body = await video.Content.ReadAsStringAsync(); AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}", AppLogMessageType.Error, AppLogErrorClass.Class2); return $"YouTube refused to delete the video ({(int)video.StatusCode})"; } catch (Exception ex) { AppLog.Write($"Broadcast delete failed: {ex.Message}", AppLogMessageType.Error, AppLogErrorClass.Class2); return ex.Message; } } /// 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). private async Task 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(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; } /// 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. public async Task 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}", AppLogMessageType.Error, AppLogErrorClass.Class2); return null; } } /// 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 default when there is no item (empty list, /// or a shape we do not recognise) so callers can tell "absent" from "present". 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; } /// 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. 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 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})", AppLogMessageType.Error, AppLogErrorClass.Class2); return default; } return JsonSerializer.Deserialize(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"; /// 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. public async Task 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}", AppLogMessageType.Error, AppLogErrorClass.Class2); return BindFailureMessage((int)response.StatusCode, reason); } /// 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. 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; } /// 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. 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).", }; /// 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. public async Task 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( 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(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); } /// 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. public async Task 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(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(); 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; } /// TASK 41 — mock chat input: posts a real text message into the live /// chat via liveChat/messages.insert. 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. public async Task 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}", AppLogMessageType.Error, AppLogErrorClass.Class2); return $"YouTube rejected the message ({(int)response.StatusCode})"; } /// 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 snippet.liveChatId — contentDetails 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 live (the sample lists /// broadcastStatus=active) — fetching right after insert (lifecycleStatus /// ready) 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. public async Task 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 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(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; } }