Files
LlamaCasty/Services/YouTubeStreamService.cs
T
gramps a6aea5bf26 feat(ui): color and acknowledge pinned log errors
Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.

Ref: https://logtape.org/manual/levels
2026-11-02 18:17:18 -08:00

676 lines
34 KiB
C#

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;
/// <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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
}
/// <summary>The broadcast's current <c>status.privacyStatus</c> ("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).</summary>
public async Task<string?> 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<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("privacyStatus", out var privacy) ? privacy.GetString() : null;
}
catch (Exception ex)
{
AppLog.Write($"Broadcast privacy read failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
}
/// <summary>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 <c>videos.delete</c> (Data API, 204 on success); if YouTube hasn't
/// created the video yet (404) it falls back to <c>liveBroadcasts.delete</c>. 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</summary>
public async Task<string?> 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;
}
}
/// <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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
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})",
AppLogMessageType.Error, AppLogErrorClass.Class2);
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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
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;
}
}