feat(9): real broadcast close-out — transition(complete) in StopStream after RTMP EOF

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
This commit is contained in:
2026-09-01 20:55:06 -07:00
parent 22b780e075
commit 688682d5b5
5 changed files with 68 additions and 4 deletions
+22
View File
@@ -124,6 +124,28 @@ public class YouTubeStreamService
return $"YouTube rejected the update ({(int)response.StatusCode})"; return $"YouTube rejected the update ({(int)response.StatusCode})";
} }
/// <summary>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.</summary>
public async Task<string?> 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})";
}
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2): /// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
/// lists existing streams first and reuses the one with cdn.isReusable=true, /// 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 /// creating it with variable resolution/frame rate on first use. Binding to a
+1 -1
View File
@@ -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). 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. 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. 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`). 7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
### Requirements: ### Requirements:
@@ -238,6 +238,19 @@ public partial class MainViewModel : ViewModelBase
// monitoring); only the live/report pipe, the pump, and the session stop here. // monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive(); _audioMixer.StopLive();
await _framePump.StopAsync(); 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; _currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId)); OnPropertyChanged(nameof(CurrentBroadcastId));
+1 -1
View File
@@ -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 - **One dialog, three states** — not connected / connected-offline (all editable) / live
(title + description + visibility editable; quality + account greyed out). Both entry points (title + description + visibility editable; quality + account greyed out). Both entry points
(Start Stream button + bottom strip) open it; prefilled from saved session profile. (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, - **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
mono/stereo only. YouTube flags violations via health status. mono/stereo only. YouTube flags violations via health status.
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD - **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
+31 -2
View File
@@ -18,6 +18,7 @@ public class YouTubeStreamServiceTests
public string? LastBody; public string? LastBody;
public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}"""; public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""";
public Func<HttpRequestMessage, string?>? ResponseSelector; public Func<HttpRequestMessage, string?>? ResponseSelector;
public HttpStatusCode StatusCode = HttpStatusCode.OK;
public readonly List<string> Requests = new(); public readonly List<string> Requests = new();
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
@@ -26,7 +27,7 @@ public class YouTubeStreamServiceTests
Requests.Add($"{request.Method.Method} {request.RequestUri}"); Requests.Add($"{request.Method.Method} {request.RequestUri}");
LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
var body = ResponseSelector?.Invoke(request) ?? ResponseBody; 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"), Content = new StringContent(body, Encoding.UTF8, "application/json"),
}); });
@@ -236,4 +237,32 @@ public class YouTubeStreamServiceTests
Assert.Equal("bitrateHigh", report.Text); Assert.Equal("bitrateHigh", report.Text);
Assert.True(report.IsError); 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);
}
}