fix(stream): health poll crashed on every start — parse the real healthStatus object shape
startup.log 2026-09-22 (via the user's run of the new build): every session
start logged 'Stream health poll failed: The requested operation requires an
element of type String, but the target element has type Object'.
Root cause: GetStreamHealthAsync did status.healthStatus.GetString(), but the
real API nests it as status.healthStatus = {status, lastUpdateTimeSeconds,
configurationIssues[]} — an OBJECT, per the liveStreams reference
https://developers.google.com/youtube/v3/live/docs/liveStreams (checked the
docs before fixing, per derivative-work rule). Our flat-string fixture was the
wrong assumption since TASK 9; the report-by-exception health banner had been
silently dead.
Fix: read the nested healthStatus.status + nested configurationIssues[], tolerating
the legacy flat-string shape so nothing else breaks. Tests flipped to the real
object shape (the Good Dog for this change). Full suite 315/315, build 0 warnings.
Also recorded in MyMistakes.md (API-shape recipe) and ai.md/Services index. End-of-session
transition(complete) 403 invalidTransition (enableAutoStop fallback) observed on all three
2026-09-22 stops — separate racy-design issue, carried, not in this change.
This commit is contained in:
@@ -231,29 +231,45 @@ public class YouTubeStreamService
|
||||
if (items.GetArrayLength() == 0) return null;
|
||||
|
||||
var status = items[0].GetProperty("status");
|
||||
var health = new StreamHealth
|
||||
// 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))
|
||||
{
|
||||
HealthStatus = status.TryGetProperty("healthStatus", out var healthStatus)
|
||||
? healthStatus.GetString()
|
||||
: null,
|
||||
};
|
||||
if (status.TryGetProperty("configurationIssues", out var issues))
|
||||
{
|
||||
foreach (var issue in issues.EnumerateArray())
|
||||
if (healthStatus.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var severity = issue.TryGetProperty("severity", out var sev) ? sev.GetString() : null;
|
||||
var type = issue.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
health.ConfigurationIssues.Add(new StreamConfigurationIssue
|
||||
{
|
||||
Severity = severity switch
|
||||
{
|
||||
"error" => StreamIssueSeverity.Error,
|
||||
"warning" => StreamIssueSeverity.Warning,
|
||||
_ => StreamIssueSeverity.Info,
|
||||
},
|
||||
Type = type,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) |
|
||||
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
|
||||
|
||||
Reference in New Issue
Block a user