Bind broadcasts explicitly; the week-long RTMP dead end was never a bind
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.
This commit is contained in:
@@ -29,7 +29,12 @@ public class YouTubeStreamService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<string?> CreateBroadcast(string title, string description, DateTime scheduledStartTime, string? streamId = null)
|
||||
/// <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;
|
||||
|
||||
@@ -50,9 +55,16 @@ public class YouTubeStreamService
|
||||
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)
|
||||
// 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);
|
||||
@@ -66,17 +78,17 @@ public class YouTubeStreamService
|
||||
return data.GetProperty("id").GetString();
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildContentDetails(string? streamId)
|
||||
private static Dictionary<string, object?> BuildContentDetails()
|
||||
{
|
||||
var details = new Dictionary<string, object?>
|
||||
// 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",
|
||||
};
|
||||
if (streamId != null) details["boundStreamId"] = streamId;
|
||||
return details;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -288,6 +300,100 @@ public class YouTubeStreamService
|
||||
? (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
|
||||
|
||||
Reference in New Issue
Block a user