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
+31 -2
View File
@@ -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);
}
}