From 688682d5b542e616876a7d13adf50c1039653ff8 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 1 Sep 2026 20:55:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(9):=20real=20broadcast=20close-out=20?= =?UTF-8?q?=E2=80=94=20transition(complete)=20in=20StopStream=20after=20RT?= =?UTF-8?q?MP=20EOF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specced 'End stream -> transition(complete)' call never existed: stopping relied entirely on enableAutoStop (viewers sat on a frozen stream-offline for ~a minute, VOD finalized late). Found during the 2026-09-01 recording-verification pass while the creator asked 'if there's proper close-out info yt needs, we'll provide it?' EndBroadcastAsync POSTs liveBroadcasts/transition?broadcastStatus=complete&id=..&part=status, called after the pump stops (RTMP EOF first) and only when a live session had a broadcast — record-only stops stay offline. invalidTransition/410 (autoStop already ended it) is logged and returned as an error string, never thrown: a stop must never fail over close-out. ONE integration test (URL shape + never-throws on 403). ai.md/TASKS.md design lines marked SHIPPED with the map-lie note. Ref: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition --- Services/YouTubeStreamService.cs | 22 +++++++++++++ TASKS.md | 2 +- .../MainViewModel.Streaming.Operations.cs | 13 ++++++++ ai.md | 2 +- ytLive.Tests/YouTubeStreamServiceTests.cs | 33 +++++++++++++++++-- 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs index a6868f6..4861270 100644 --- a/Services/YouTubeStreamService.cs +++ b/Services/YouTubeStreamService.cs @@ -124,6 +124,28 @@ public class YouTubeStreamService return $"YouTube rejected the update ({(int)response.StatusCode})"; } + /// Proper close-out (TASK 9 design decision 6 — implemented 2026-09-01; + /// 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. + public async Task EndBroadcastAsync(string broadcastId) + { + if (!await EnsureToken()) return "not signed in"; + + _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); + var response = await _http.PostAsync( + $"{ApiBase}/liveBroadcasts/transition?broadcastStatus=complete&id={Uri.EscapeDataString(broadcastId)}&part=status", + content: null); + + if (response.IsSuccessStatusCode) return null; + var body = await response.Content.ReadAsStringAsync(); + AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}"); + return $"YouTube rejected the end transition ({(int)response.StatusCode})"; + } + /// 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 diff --git a/TASKS.md b/TASKS.md index 7b357dd..d84286c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -698,7 +698,7 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash), 3. **Report-by-exception** — poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog). 4. **One dialog, three states** — `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile. 5. **Live edits** — `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy. -6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net. +6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net. **SHIPPED 2026-09-01** (`EndBroadcastAsync`, wired into `StopStream` after RTMP EOF; the call had been specced since TASK 9 but never existed — found during recording verification) 7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`). ### Requirements: diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 73d07c4..9de1e9b 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -238,6 +238,19 @@ public partial class MainViewModel : ViewModelBase // monitoring); only the live/report pipe, the pump, and the session stop here. _audioMixer.StopLive(); await _framePump.StopAsync(); + + // Proper close-out (TASK 9 decision 6, built 2026-09-01): transition(complete) + // AFTER the RTMP push is closed so no frames post-date the end — YouTube then + // finalizes the VOD immediately instead of leaving viewers on a frozen + // "stream offline" until enableAutoStop fires (~1 min). Log-only failure: + // the backstop still ends the broadcast; a stop never fails over cosmetics. + if (wasLive && _currentBroadcastId != null) + { + var closeOutError = await _youtubeStream.EndBroadcastAsync(_currentBroadcastId); + if (closeOutError != null) + AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast"); + } + _currentBroadcastId = null; OnPropertyChanged(nameof(CurrentBroadcastId)); diff --git a/ai.md b/ai.md index 707757f..95d479f 100644 --- a/ai.md +++ b/ai.md @@ -976,7 +976,7 @@ These are the hard facts behind every decision. Full list in `TASKS.md`. - **One dialog, three states** — not connected / connected-offline (all editable) / live (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile. -- **End stream** — stop encoder → `transition(complete)`, `enableAutoStop` as the safety net. +- **End stream (SHIPPED 2026-09-01)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"), log-only on failure (`invalidTransition` when autoStop already fired — never throws, never toasts a finished session); `enableAutoStop` remains the safety net. The design had always called for this call; it was never built until the recording-verification session caught the gap. Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`). - **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz, mono/stereo only. YouTube flags violations via health status. - **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD diff --git a/ytLive.Tests/YouTubeStreamServiceTests.cs b/ytLive.Tests/YouTubeStreamServiceTests.cs index 523c5cc..adcbfac 100644 --- a/ytLive.Tests/YouTubeStreamServiceTests.cs +++ b/ytLive.Tests/YouTubeStreamServiceTests.cs @@ -18,6 +18,7 @@ public class YouTubeStreamServiceTests public string? LastBody; public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}"""; public Func? ResponseSelector; + public HttpStatusCode StatusCode = HttpStatusCode.OK; public readonly List Requests = new(); protected override Task SendAsync( @@ -26,7 +27,7 @@ public class YouTubeStreamServiceTests Requests.Add($"{request.Method.Method} {request.RequestUri}"); LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); var body = ResponseSelector?.Invoke(request) ?? ResponseBody; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(StatusCode) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }); @@ -236,4 +237,32 @@ public class YouTubeStreamServiceTests Assert.Equal("bitrateHigh", report.Text); Assert.True(report.IsError); } -} + // TASK 9 close-out (2026-09-01): the ONE integration test — the transition URL + // shape (broadcastStatus=complete + id + part, POST) and the never-throws + // contract when YouTube already auto-completed the broadcast (invalidTransition). + [Fact] + public async Task EndBroadcast_Transitions_Complete_And_Never_Throws_On_InvalidTransition() + { + var handler = new RecordingHandler(); + var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler)); + + var error = await service.EndBroadcastAsync("BC-42"); + + Assert.Null(error); + var request = Assert.Single(handler.Requests); + Assert.StartsWith("POST ", request); + Assert.Contains("/liveBroadcasts/transition?", request); + Assert.Contains("broadcastStatus=complete", request); + Assert.Contains("id=BC-42", request); + Assert.Contains("part=status", request); + + // Already auto-ended: 403 invalidTransition must surface as an error string, + // never an exception — the stop path must complete regardless (autoStop backstop). + handler.StatusCode = HttpStatusCode.Forbidden; + handler.ResponseBody = """{"error":{"code":403,"message":"Invalid transition"}}"""; + var closeOutError = await service.EndBroadcastAsync("BC-42"); + + Assert.NotNull(closeOutError); + Assert.Contains("403", closeOutError); + } +} \ No newline at end of file