Private broadcasts end like recordings: Enter keeps, Escape deletes (KeepBroadcastDialog). Privacy read from YouTube; delete = videos.delete (fallback liveBroadcasts.delete on 404). Refs: https://developers.google.com/youtube/v3/docs/videos/delete https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/delete (Unit: private broadcast close-out)

This commit is contained in:
2026-10-09 13:32:38 -07:00
parent b140024ebf
commit 87f05ba2ce
8 changed files with 255 additions and 2 deletions
+7
View File
@@ -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.
+31
View File
@@ -0,0 +1,31 @@
<Window x:Class="ytLive.KeepBroadcastDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Keep this broadcast?" Height="240" Width="460"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Keep this broadcast?" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
<TextBlock Grid.Row="1" x:Name="MessageText" TextWrapping="Wrap" Foreground="#a0a0b0" FontSize="13"
Margin="0,0,0,16"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="DeleteBroadcastButton" Content="Delete" IsCancel="True" Click="Delete_Click"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"
ToolTip="Esc — delete the recording from YouTube"/>
<Button x:Name="KeepBroadcastButton" Content="Keep" IsDefault="True" Click="Keep_Click"
Style="{StaticResource YtButton}" MinWidth="90"
ToolTip="Enter — keep it on YouTube as a private video"/>
</StackPanel>
</Grid>
</Window>
+23
View File
@@ -0,0 +1,23 @@
using System.Windows;
namespace ytLive;
/// <summary>Asked when a PRIVATE broadcast ends — the same contract as the recording's save
/// dialog (creator ruling 2026-10-09): <b>Enter keeps</b> it on YouTube as a private video,
/// <b>Escape deletes</b> it. <c>DialogResult == true</c> = keep; false (Delete, Escape, the X)
/// = delete.</summary>
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;
}
+60
View File
@@ -174,6 +174,66 @@ public class YouTubeStreamService
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
}
/// <summary>The broadcast's current <c>status.privacyStatus</c> ("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).</summary>
public async Task<string?> 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<JsonElement>(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;
}
}
/// <summary>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 <c>videos.delete</c> (Data API, 204 on success); if YouTube hasn't
/// created the video yet (404) it falls back to <c>liveBroadcasts.delete</c>. 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</summary>
public async Task<string?> 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;
}
}
/// <summary>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).</summary>
+1
View File
@@ -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 |
@@ -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));
}
/// <summary>What happened to a finished broadcast at the keep-or-delete prompt.</summary>
internal enum PrivateBroadcastOutcome { NotPrivate, Kept, Deleted, DeleteFailed }
/// <summary>Test seam: answers the keep-or-delete prompt (title → keep?) instead of showing
/// <see cref="KeepBroadcastDialog"/>. Mirrors the other static overrides.</summary>
internal static Func<string, bool>? 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;
}
}
/// <summary>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.</summary>
internal static async Task<(PrivateBroadcastOutcome Outcome, string? Error)> ClosePrivateBroadcastAsync(
YouTubeStreamService youtube, string broadcastId, Func<bool> 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);
}
}
+9
View File
@@ -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=<broadcastId>` (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
@@ -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;
/// <summary>Creator ruling 2026-10-09: a PRIVATE broadcast ends like a recording — Enter keeps
/// it on YouTube, Escape deletes it.</summary>
public class PrivateBroadcastCloseOutTests
{
private sealed class Handler : HttpMessageHandler
{
public string Privacy = "private";
public readonly List<string> Requests = new();
protected override Task<HttpResponseMessage> 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"));
}
}