From 8fbc3701d288d8109aa61e4cb0084b3f1fc145b8 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 4 Oct 2026 15:05:28 -0700 Subject: [PATCH] fix: the broadcast diagnostic reported NO-BINDING from a parse that never ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run I asked for came back with three alarming lines: lifeCycle=? privacy=? recording=? bound=NONE boundMatches=NO-BINDING autoStart=unset autoStop=unset liveChatId=none | streamStatus=active Every single broadcast field was empty, and the stream half was fine. That combination is the tell: not a failed request, a wrong nesting level. liveBroadcasts.list returns a LIST wrapper, so the broadcast is at items[0]. I descended into items for the stream and read the response root for the broadcast, so every lookup missed, and BoundMatches — handed a null it could not distinguish from a real null — reported a confident NO-BINDING. The diagnostic invented the bug it was written to find. Three lines of fabricated alarm on the one run that was supposed to produce evidence. Two fixes, and the second is the one that matters: FirstItem() descends into items[0] for both halves, and returns default when there is genuinely no item. The line now prints boundMatches=unread when the broadcast could not be read at all, so an absent parse degrades to "I don't know" instead of a verdict. A diagnostic that guesses is worse than no diagnostic: this one would have sent me off to rebuild a binding that may well be correct. Test guards the regression: FirstItem_DescendsIntoTheListWrapper_AndReports- AbsenceHonestly, including the empty-items and missing-items cases. Suite 390/390, 0 warnings. Credit to the run for catching it. What it did establish, from the half that worked: streamStatus goes ready -> active about 30s into the push. So the ingest registers as active server-side, which combined with the 99MB muxed yesterday means the stream half is verified healthy end to end. The broadcast half needs one more run to say anything. --- Services/YouTubeStreamService.cs | 34 ++++++++++++++++++++------- ai.md | 17 ++++++++++++++ ytLive.Tests/BroadcastBindingTests.cs | 25 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs index 9844fbf..af7ac6a 100644 --- a/Services/YouTubeStreamService.cs +++ b/Services/YouTubeStreamService.cs @@ -201,7 +201,14 @@ public class YouTubeStreamService try { - var broadcast = await GetJsonAsync($"liveBroadcasts?part=status,contentDetails,snippet&id={Uri.EscapeDataString(broadcastId)}"); + 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"); @@ -211,16 +218,12 @@ public class YouTubeStreamService 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); + 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={bound ?? "NONE"} " + - $"expect={streamId ?? "none"} boundMatches={BoundMatches(bound, streamId)} " + + $"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")} | " + @@ -236,6 +239,21 @@ public class YouTubeStreamService } } + /// 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. diff --git a/ai.md b/ai.md index 6d0fe7e..57e561d 100644 --- a/ai.md +++ b/ai.md @@ -1710,6 +1710,23 @@ These are the hard facts behind every decision. Full list in `TASKS.md`. the whole experiment.** Guarded by `BroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush`, which pins `unknown` for a missing expectation so an absent stream id can never be misreported as a match. + - **⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).** + `liveBroadcasts.list` returns a **list wrapper**, so the broadcast lives at `items[0]`. The + original code descended into `items` for the *stream* but read the *broadcast's* response root, + so every broadcast field came back `?` — and `BoundMatches` then dutifully reported a confident + **`boundMatches=NO-BINDING`** from a parse that had never happened. Three lines of fabricated + alarm. Two fixes, both load-bearing: **`FirstItem()`** descends into `items[0]` for both halves + (and returns `default` when there is genuinely no item), and the line now prints + **`boundMatches=unread`** when the broadcast could not be read, so an absent parse can never be + reported as a verdict. Guarded by + `BroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly`. + **Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong + answer** — otherwise one level of JSON nesting manufactures a bug that isn't there. + - **What the one run that *did* work proved:** the stream half was correct, and it shows + `streamStatus=ready` → **`streamStatus=active`** about 30s into the push. So the ingest does + register as active server-side, which is a genuine step forward — combined with the 99MB muxed + in the earlier run, the stream half of the system is now verified healthy. The broadcast half is + still unobserved pending a re-run. - **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).** The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator runs multi-month real-world testing: non-private test streams would clutter the channel with VODs diff --git a/ytLive.Tests/BroadcastBindingTests.cs b/ytLive.Tests/BroadcastBindingTests.cs index 68e3e2f..652543a 100644 --- a/ytLive.Tests/BroadcastBindingTests.cs +++ b/ytLive.Tests/BroadcastBindingTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Xunit; using ytLive.Services; @@ -30,4 +31,28 @@ public class BroadcastBindingTests { Assert.Equal(verdict, YouTubeStreamService.BoundMatches(bound, expected)); } + + /// The regression the first diagnostic shipped with. `liveBroadcasts.list` + /// returns a LIST wrapper, so the broadcast sits at `items[0]`; reading the response + /// root reads the wrapper, every field comes back empty, and the binding check then + /// reports a confident `NO-BINDING` from a parse that never happened. The first real + /// test run produced exactly that — three lines of fabricated alarm. `FirstItem` must + /// descend, and must return `default` (not throw, not an empty object) when there is + /// genuinely no item, so the caller can report "unread" instead of guessing. + [Theory] + [InlineData("{\"kind\":\"youtube#liveBroadcastListResponse\",\"items\":[{\"id\":\"bc1\",\"status\":{\"lifeCycleStatus\":\"ready\"}}]}", "bc1")] + [InlineData("{\"items\":[]}", null)] + [InlineData("{\"kind\":\"youtube#liveBroadcastListResponse\"}", null)] + public void FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly(string listJson, string? expectedId) + { + using var doc = JsonDocument.Parse(listJson); + var item = YouTubeStreamService.FirstItem(doc.RootElement); + + if (expectedId == null) + { + Assert.Equal(JsonValueKind.Undefined, item.ValueKind); + return; + } + Assert.Equal(expectedId, item.GetProperty("id").GetString()); + } }