fix(stream): end close-out pre-flights lifeCycleStatus — no more 403 invalidTransition noise on every stop
startup.log 2026-09-22 (user runs of the new build): ALL THREE session stops
logged 'Broadcast transition(complete) failed (403) invalidTransition → End
close-out: enableAutoStop will finish'. The old ai.md assumption ('invalidTransition
when autoStop already fired') was wrong — the blind complete POST races
YouTube/autoStop marking the broadcast complete (enableAutoStop=true is always
set, tests included), so a blind POST earned only the 403 + log noise. Per the
transition errors table, invalidTransition is a current-status problem and
complete is not gated on streamStatus (only testing/live are):
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
Fix: EndBroadcastAsync pre-flights liveBroadcasts.list -> status.lifeCycleStatus
and skips the POST when the broadcast is already complete/revoked; an inconclusive
pre-check still posts (old behavior) rather than silently stranding a live
broadcast; still never throws (enableAutoStop finishes every skip).
Good Dog: EndBroadcast test rewritten to the one contract — live -> GET pre-check
+ POST transition (URL shape asserted); already complete -> zero transition POSTs;
inconclusive pre-check + 403 POST -> error string, never a throw. Full suite
315/315 (one-run audio/mouse timing flakes cleared), build 0 warnings.
Recipe recorded in MyMistakes.md; ai.md / Services index / TASK-9 note corrected.
This commit is contained in:
@@ -128,14 +128,30 @@ public class YouTubeStreamService
|
||||
/// until then we relied entirely on enableAutoStop, leaving viewers on a frozen
|
||||
/// "stream offline" screen for ~a minute): POST liveBroadcasts.transition
|
||||
/// broadcastStatus=complete. MUST be called AFTER the encoder closed the RTMP
|
||||
/// push so no frames post-date the end. A broadcast YouTube already auto-completed
|
||||
/// answers 403 invalidTransition / 410 — logged and surfaced as an error string,
|
||||
/// never thrown: the stop path must not fail over a cosmetic close-out.</summary>
|
||||
/// push so no frames post-date the end. **Pre-flight (2026-09-22):** the POST is
|
||||
/// now gated on the broadcast's OWN lifeCycleStatus — every 2026-09-22 end
|
||||
/// logged 403 invalidTransition because enableAutoStop/YouTube had already marked
|
||||
/// the broadcast complete; a blind complete only earned the 403 + log noise. We
|
||||
/// skip ONLY on a confirmed already-ended status (complete/revoked) — an
|
||||
/// inconclusive check still posts (old behavior) rather than silently stranding a
|
||||
/// live broadcast. The POST is never-throwing: a rare still-failing transition
|
||||
/// surfaces as an error string, never a throw — the stop path must not fail over a
|
||||
/// cosmetic close-out.</summary>
|
||||
public async Task<string?> EndBroadcastAsync(string broadcastId)
|
||||
{
|
||||
if (!await EnsureToken()) return "not signed in";
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
|
||||
var lifeCycle = await GetLifeCycleStatusAsync(broadcastId);
|
||||
if (lifeCycle is "complete" or "revoked")
|
||||
{
|
||||
// Already ended (autoStop/YouTube raced us) — a blind complete only
|
||||
// earns a 403 + noise. enableAutoStop finished it.
|
||||
AppLog.Write($"End close-out: {broadcastId} already at lifeCycleStatus '{lifeCycle}' — skipping complete");
|
||||
return null;
|
||||
}
|
||||
|
||||
var response = await _http.PostAsync(
|
||||
$"{ApiBase}/liveBroadcasts/transition?broadcastStatus=complete&id={Uri.EscapeDataString(broadcastId)}&part=status",
|
||||
content: null);
|
||||
@@ -146,6 +162,22 @@ public class YouTubeStreamService
|
||||
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
/// <summary>One liveBroadcasts.list (part=status) read of the broadcast's own
|
||||
/// lifeCycleStatus — the only reliable "can we complete?" signal. Null when the
|
||||
/// list fails or the status is missing (caller degrades gracefully).</summary>
|
||||
private async Task<string?> GetLifeCycleStatusAsync(string broadcastId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
|
||||
if (!items[0].TryGetProperty("status", out var status)) return null;
|
||||
return status.TryGetProperty("lifeCycleStatus", out var lifeCycle)
|
||||
? lifeCycle.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
+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[]` (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) |
|
||||
| `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) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
|
||||
| `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