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