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:
@@ -124,6 +124,28 @@ public class YouTubeStreamService
|
||||
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):
|
||||
/// 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ public class YouTubeStreamServiceTests
|
||||
public string? LastBody;
|
||||
public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""";
|
||||
public Func<HttpRequestMessage, string?>? ResponseSelector;
|
||||
public HttpStatusCode StatusCode = HttpStatusCode.OK;
|
||||
public readonly List<string> Requests = new();
|
||||
|
||||
protected override Task<HttpResponseMessage> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user