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()); + } }