diff --git a/HANDOFF.md b/HANDOFF.md
index b5f90c7..928a355 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -43,5 +43,12 @@ rewrite, TASK 55 (disk hygiene) + TASK 56 (secret scan before going public) queu
- TASK 56: secret-scan the full history BEFORE the Gitea repo (`gramps/LlamaCasty`) goes public.
- Carried: "Use default background" toggle still not working (from 2026-10-07).
+## Also shipped after TASK 51 (2026-10-09)
+- **Brand flash OFF by default** (`MainViewModel.ShowBrandFlash = false`; feature kept).
+- **Audio loudness:** `SpeechLeveler` auto-gain in the voice chain + honest mic meter (no +10 dB boost).
+ Creator's upload was −28.5 LUFS; the same audio through the new chain measures −17.9 LUFS.
+- **Private broadcast close-out:** Enter keeps / Escape deletes (`KeepBroadcastDialog`,
+ `videos.delete`). Untested against the real API — first real private stream is the check.
+
## Next step
- Creator runs the app: webcam → Layers properties → Background → Remove / Blur + Strength.
diff --git a/KeepBroadcastDialog.xaml b/KeepBroadcastDialog.xaml
new file mode 100644
index 0000000..52416d8
--- /dev/null
+++ b/KeepBroadcastDialog.xaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/KeepBroadcastDialog.xaml.cs b/KeepBroadcastDialog.xaml.cs
new file mode 100644
index 0000000..f95faf7
--- /dev/null
+++ b/KeepBroadcastDialog.xaml.cs
@@ -0,0 +1,23 @@
+using System.Windows;
+
+namespace ytLive;
+
+/// Asked when a PRIVATE broadcast ends — the same contract as the recording's save
+/// dialog (creator ruling 2026-10-09): Enter keeps it on YouTube as a private video,
+/// Escape deletes it. DialogResult == true = keep; false (Delete, Escape, the X)
+/// = delete.
+public partial class KeepBroadcastDialog : Window
+{
+ public KeepBroadcastDialog(string title)
+ {
+ InitializeComponent();
+ MessageText.Text = string.IsNullOrWhiteSpace(title)
+ ? "Your private broadcast is on YouTube as a private video. Keep it, or delete it?"
+ : $"\u201c{title}\u201d is on YouTube as a private video. Keep it, or delete it?";
+ Loaded += (_, _) => KeepBroadcastButton.Focus();
+ }
+
+ private void Keep_Click(object sender, RoutedEventArgs e) => DialogResult = true;
+
+ private void Delete_Click(object sender, RoutedEventArgs e) => DialogResult = false;
+}
diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs
index 7726826..ff8b965 100644
--- a/Services/YouTubeStreamService.cs
+++ b/Services/YouTubeStreamService.cs
@@ -174,6 +174,66 @@ public class YouTubeStreamService
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
}
+ /// The broadcast's current status.privacyStatus ("private", "unlisted",
+ /// "public"), read from YouTube rather than trusted from local state — the creator can
+ /// change visibility mid-stream from the broadcast form. Null when signed out or the
+ /// read fails (the caller then does nothing destructive).
+ public async Task GetBroadcastPrivacyAsync(string broadcastId)
+ {
+ if (!await EnsureToken()) return null;
+ _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
+ try
+ {
+ var response = await _http.GetAsync(
+ $"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
+ if (!response.IsSuccessStatusCode) return null;
+ var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync());
+ if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
+ if (!items[0].TryGetProperty("status", out var status)) return null;
+ return status.TryGetProperty("privacyStatus", out var privacy) ? privacy.GetString() : null;
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Broadcast privacy read failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// Deletes a finished broadcast's recording from YouTube (the creator pressed
+ /// Escape / Delete on the keep-or-delete prompt). A live broadcast's id IS its video's
+ /// id, so this is videos.delete (Data API, 204 on success); if YouTube hasn't
+ /// created the video yet (404) it falls back to liveBroadcasts.delete. Returns
+ /// null on success, or an error string — never throws.
+ /// Docs: https://developers.google.com/youtube/v3/docs/videos/delete ,
+ /// https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/delete
+ public async Task DeleteFinishedBroadcastAsync(string broadcastId)
+ {
+ if (!await EnsureToken()) return "not signed in";
+ _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
+ try
+ {
+ var id = Uri.EscapeDataString(broadcastId);
+ var video = await _http.DeleteAsync($"{ApiBase}/videos?id={id}");
+ if (video.IsSuccessStatusCode) return null;
+ if (video.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ var broadcast = await _http.DeleteAsync($"{ApiBase}/liveBroadcasts?id={id}");
+ if (broadcast.IsSuccessStatusCode) return null;
+ var bBody = await broadcast.Content.ReadAsStringAsync();
+ AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}");
+ return $"YouTube refused to delete the broadcast ({(int)broadcast.StatusCode})";
+ }
+ var body = await video.Content.ReadAsStringAsync();
+ AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}");
+ return $"YouTube refused to delete the video ({(int)video.StatusCode})";
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Broadcast delete failed: {ex.Message}");
+ return ex.Message;
+ }
+ }
+
/// One liveBroadcasts.list (part=status) read of the broadcast's own
/// lifeCycleStatus — the only reliable "can we complete?" signal. Null when the
/// list fails or the status is missing (caller degrades gracefully).
diff --git a/Services/index.md b/Services/index.md
index 3828717..c526d51 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -7,6 +7,7 @@ External-facing logic: YouTube API, persistence. See
|------|---------|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
+| `YouTubeStreamService.cs` (2026-10-09 additions) | `GetBroadcastPrivacyAsync(id)` (`liveBroadcasts.list part=status` → privacyStatus) and `DeleteFinishedBroadcastAsync(id)` (`videos.delete`, fallback `liveBroadcasts.delete` on 404; never throws) — the private-broadcast keep-or-delete close-out |
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable`. **TASK 43 rewrite (2026-09-24):** re-arms its one-shot poll on the server's `pollingIntervalMillis` (streamList semantics — reconnect on that cadence with `nextPageToken`, clamp 1000–6000ms, `maxResults=2000`); `ParsePage` (internal static seam) decodes **all six** event types into `ChatMessage.Kind`/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional `HttpClient` ctor seam endures; `InjectSimulatedMessage` (TASK 41) preserved |
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 07b3622..3791f98 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -360,9 +360,10 @@ public partial class MainViewModel : ViewModelBase
// 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 endedBroadcastId = wasLive ? _currentBroadcastId : null;
+ if (endedBroadcastId != null)
{
- var closeOutError = await _youtubeStream.EndBroadcastAsync(_currentBroadcastId);
+ var closeOutError = await _youtubeStream.EndBroadcastAsync(endedBroadcastId);
if (closeOutError != null)
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast");
}
@@ -370,6 +371,11 @@ public partial class MainViewModel : ViewModelBase
_currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId));
+ // Creator ruling 2026-10-09: a PRIVATE broadcast ends like a recording — Enter keeps
+ // it on YouTube, Escape deletes it.
+ if (endedBroadcastId != null)
+ await OfferToKeepPrivateBroadcastAsync(endedBroadcastId);
+
if (wasRecording) await FinalizeRecordingAsync();
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
@@ -519,4 +525,49 @@ public partial class MainViewModel : ViewModelBase
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
+
+ /// What happened to a finished broadcast at the keep-or-delete prompt.
+ internal enum PrivateBroadcastOutcome { NotPrivate, Kept, Deleted, DeleteFailed }
+
+ /// Test seam: answers the keep-or-delete prompt (title → keep?) instead of showing
+ /// . Mirrors the other static overrides.
+ internal static Func? KeepBroadcastPromptOverride { get; set; }
+
+ private async Task OfferToKeepPrivateBroadcastAsync(string broadcastId)
+ {
+ var title = BroadcastForm.Title;
+ var (outcome, error) = await ClosePrivateBroadcastAsync(_youtubeStream, broadcastId,
+ () => KeepBroadcastPromptOverride?.Invoke(title) ?? new KeepBroadcastDialog(title).ShowDialog() == true);
+ switch (outcome)
+ {
+ case PrivateBroadcastOutcome.Kept:
+ AppLog.Write($"Private broadcast kept: {broadcastId}");
+ _notifications.Info("Broadcast kept", "It's on YouTube as a private video.");
+ break;
+ case PrivateBroadcastOutcome.Deleted:
+ AppLog.Write($"Private broadcast deleted by creator: {broadcastId}");
+ _notifications.Info("Broadcast deleted", "The broadcast was removed from YouTube.");
+ break;
+ case PrivateBroadcastOutcome.DeleteFailed:
+ AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}");
+ _notifications.Error("Couldn't delete the broadcast",
+ $"{error}. It's still on YouTube as a private video — you can delete it in YouTube Studio.");
+ break;
+ }
+ }
+
+ /// The keep-or-delete decision for a finished broadcast. Asks ONLY when YouTube
+ /// itself reports the broadcast as private (visibility can change mid-stream, so local state
+ /// isn't trusted); an unreadable status asks nothing and deletes nothing. Keep = leave it;
+ /// otherwise delete the video from YouTube.
+ internal static async Task<(PrivateBroadcastOutcome Outcome, string? Error)> ClosePrivateBroadcastAsync(
+ YouTubeStreamService youtube, string broadcastId, Func askKeep)
+ {
+ var privacy = await youtube.GetBroadcastPrivacyAsync(broadcastId);
+ if (!string.Equals(privacy, "private", StringComparison.OrdinalIgnoreCase))
+ return (PrivateBroadcastOutcome.NotPrivate, null);
+ if (askKeep()) return (PrivateBroadcastOutcome.Kept, null);
+ var error = await youtube.DeleteFinishedBroadcastAsync(broadcastId);
+ return error == null ? (PrivateBroadcastOutcome.Deleted, null) : (PrivateBroadcastOutcome.DeleteFailed, error);
+ }
}
diff --git a/ai.md b/ai.md
index 3fe943a..5e1e427 100644
--- a/ai.md
+++ b/ai.md
@@ -982,6 +982,15 @@ lights when a session is actually running.
survives the rename, and without it two instances started in the same minute compute the *same* start
path and both ffmpeg processes write one file — which is exactly the dev multi-instance case
(`Helpers/InstanceProfile`, one instance recording the other).
+- **Private broadcasts end the same way (creator ruling 2026-10-09).** After `StopStream`'s
+ transition(complete), `OfferToKeepPrivateBroadcastAsync` reads the broadcast's privacy **from YouTube**
+ (`GetBroadcastPrivacyAsync` — visibility can change mid-stream, local state isn't trusted) and, only if
+ it is `private`, shows `KeepBroadcastDialog` (repo root): **Enter = Keep** (`IsDefault`), **Escape /
+ Delete / the X = delete** via `DeleteFinishedBroadcastAsync` — `videos.delete?id=` (a live
+ broadcast's id IS its video id; 204), falling back to `liveBroadcasts.delete` on 404. An unreadable
+ status asks nothing and deletes nothing; a failed delete toasts that the video is still on YouTube.
+ Decision logic = `MainViewModel.ClosePrivateBroadcastAsync` (internal static, tested in
+ `PrivateBroadcastCloseOutTests`); `KeepBroadcastPromptOverride` is the dialog seam.
- **Save dialog: Cancel DISCARDS (creator ruling 2026-09-26).** The `RenameRecordingDialog` result is a
two-outcome decision, and it lives in the extracted `MainViewModel.CompleteRecordingSave(startPath, dir,
autoStem, chosenStem, creatorSaved)` (internal, so `ytLive.Tests` can drive it against real files without
diff --git a/ytLive.Tests/PrivateBroadcastCloseOutTests.cs b/ytLive.Tests/PrivateBroadcastCloseOutTests.cs
new file mode 100644
index 0000000..43059d4
--- /dev/null
+++ b/ytLive.Tests/PrivateBroadcastCloseOutTests.cs
@@ -0,0 +1,71 @@
+using System.Net;
+using System.Text;
+using System.Xml.Linq;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+/// Creator ruling 2026-10-09: a PRIVATE broadcast ends like a recording — Enter keeps
+/// it on YouTube, Escape deletes it.
+public class PrivateBroadcastCloseOutTests
+{
+ private sealed class Handler : HttpMessageHandler
+ {
+ public string Privacy = "private";
+ public readonly List Requests = new();
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ Requests.Add($"{request.Method.Method} {request.RequestUri!.PathAndQuery}");
+ if (request.Method == HttpMethod.Delete)
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
+ var body = "{\"items\":[{\"id\":\"BC1\",\"status\":{\"privacyStatus\":\"" + Privacy + "\",\"lifeCycleStatus\":\"complete\"}}]}";
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ });
+ }
+ }
+
+ private static YouTubeStreamService Service(Handler handler)
+ {
+ var auth = new YouTubeAuthService("test-id", "test-secret");
+ auth.SetSession(new YouTubeChannel { AccessToken = "acc", TokenExpiry = DateTime.UtcNow.AddHours(1) });
+ return new YouTubeStreamService(auth, new HttpClient(handler));
+ }
+
+ [Fact]
+ public async Task Private_Broadcast_Escape_Deletes_Enter_Keeps_Public_Is_Never_Asked()
+ {
+ // Escape / Delete → the video is deleted from YouTube.
+ var h = new Handler();
+ var (outcome, error) = await MainViewModel.ClosePrivateBroadcastAsync(Service(h), "BC1", askKeep: () => false);
+ Assert.Equal(MainViewModel.PrivateBroadcastOutcome.Deleted, outcome);
+ Assert.Null(error);
+ Assert.Contains(h.Requests, r => r.StartsWith("DELETE /youtube/v3/videos?id=BC1"));
+
+ // Enter / Keep → nothing is deleted.
+ h = new Handler();
+ (outcome, _) = await MainViewModel.ClosePrivateBroadcastAsync(Service(h), "BC1", askKeep: () => true);
+ Assert.Equal(MainViewModel.PrivateBroadcastOutcome.Kept, outcome);
+ Assert.DoesNotContain(h.Requests, r => r.StartsWith("DELETE"));
+
+ // Not private (changed mid-stream) → no prompt, nothing deleted.
+ h = new Handler { Privacy = "public" };
+ var asked = false;
+ (outcome, _) = await MainViewModel.ClosePrivateBroadcastAsync(Service(h), "BC1", askKeep: () => asked = true);
+ Assert.Equal(MainViewModel.PrivateBroadcastOutcome.NotPrivate, outcome);
+ Assert.False(asked);
+ Assert.DoesNotContain(h.Requests, r => r.StartsWith("DELETE"));
+
+ // The prompt itself: Enter = Keep (IsDefault), Escape = Delete (IsCancel).
+ XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
+ var doc = XDocument.Load(SourceAssert.RepoPath("KeepBroadcastDialog.xaml"));
+ XElement Named(string name) => doc.Descendants().Single(e => (string?)e.Attribute(x + "Name") == name);
+ Assert.Equal("True", (string?)Named("KeepBroadcastButton").Attribute("IsDefault"));
+ Assert.Equal("True", (string?)Named("DeleteBroadcastButton").Attribute("IsCancel"));
+ }
+}