The logs answered the question and closed off a whole branch of investigation. The long run: broadcast inserted 14:17:04.577, ffmpeg to rtmp://a.rtmp.youtube.com/live2/5f6z-... at 14:17:05.686, and ffmpeg's own muxing summary at 14:18:56.473 reads video:101229KiB audio:1419KiB muxing overhead: 0.202949% ~99MB of real video plus audio over 112s, which is what 8000+128 kb/s is supposed to produce. No socket error, no 4xx from ingest, no reconnect. And the broadcast still never left `ready`; close-out 403'd invalidTransition again. The 8s run behaved the same way, so "YouTube needed longer" is disproven — 112s is longer. So the RTMP path is provably healthy. That is the finding worth keeping: it kills the entire "the push is broken" theory, which is where this had been heading for a week. The break is between ingest being accepted and the broadcast being told to start. Which puts the binding in frame, and the app was structurally blind to it. Google documents contentDetails.boundStreamId and says enableAutoStart fires "when you start streaming video on the bound live stream" — both valid on insert, neither restricted against reusable streams. But the migration guide also says a broadcast "is not bound to any streams at the time that it's created", and the failure that produces is invisible from the RTMP side precisely because a reusable stream will happily accept bytes for an unbound broadcast. Ingest succeeding proves nothing. Hence LogBroadcastDiagnosticsAsync, riding the existing health poll rather than adding a timer. One greppable line: lifecycle status, privacy, recording, boundStreamId *compared against the stream id we are actually pushing* (BoundMatches: yes / MISMATCH / NO-BINDING / unknown), boundStreamLastUpdate- TimeMs, autoStart/Stop, liveChatId presence, the stream's own streamStatus and activeLiveChatId, and the CDN settings we declared. Deliberately self-limiting: _broadcastSeenLive latches on live/liveStarting so the diagnostic shouts only while the broadcast is stuck and goes quiet once it is healthy. A permanent per-tick log line would be noise; this is the opposite. One test, per the Good Dog rule: BroadcastBindingTests pins the matrix and specifically pins `unknown` when there is no stream id to compare against, so a missing expectation can never be misreported as a match — that is the failure that would make this diagnostic lie. Suite 387/387, 0 warnings.
480 lines
22 KiB
C#
480 lines
22 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;
|
|
}
|
|
|
|
public async Task<string?> CreateBroadcast(string title, string description, DateTime scheduledStartTime, string? streamId = null)
|
|
{
|
|
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. A reusable stream, when given,
|
|
// binds here (boundStreamId) so no second bind round-trip is needed.
|
|
contentDetails = BuildContentDetails(streamId)
|
|
};
|
|
|
|
_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(string? streamId)
|
|
{
|
|
var details = new Dictionary<string, object?>
|
|
{
|
|
["enableAutoStart"] = true,
|
|
["enableAutoStop"] = true,
|
|
["enableMonitorStream"] = false,
|
|
["latencyPreference"] = "low",
|
|
};
|
|
if (streamId != null) details["boundStreamId"] = streamId;
|
|
return details;
|
|
}
|
|
|
|
/// <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 broadcast = await GetJsonAsync($"liveBroadcasts?part=status,contentDetails,snippet&id={Uri.EscapeDataString(broadcastId)}");
|
|
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
|
|
&& present.TryGetProperty("items", out var streamItems)
|
|
&& streamItems.GetArrayLength() > 0
|
|
? streamItems[0]
|
|
: default(JsonElement);
|
|
|
|
AppLog.Write(
|
|
$"Broadcast diag: id={broadcastId} lifeCycle={lifeCycle} privacy={Text(status, "privacyStatus") ?? "?"} " +
|
|
$"recording={Text(status, "recordingStatus") ?? "?"} bound={bound ?? "NONE"} " +
|
|
$"expect={streamId ?? "none"} boundMatches={BoundMatches(bound, streamId)} " +
|
|
$"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>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>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;
|
|
}
|
|
}
|