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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user