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