feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)

The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published
promise that no viewer, member or donor information is retained after the session ends, and it would
have meant owning a ledger that YouTube already keeps.

So the report is DERIVED on demand instead:

- `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker
  must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent.
- `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals
  that are never summed together** (a $5 and a €5 are not $10). Nothing is written down.
- The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were
  events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle,
  persisted, **off by default** — a creator who never monetized is never shown a $0.00.

Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated
for weeks, not days).

Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than
a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session
still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds
a MainViewModel directly and was flaking against parallel tests that swap the static layout path.

FAST gate: 557 passed, 0 failed, 0 warnings.
This commit is contained in:
2026-11-05 15:38:32 -08:00
parent 27f655c8de
commit 0fade9702b
13 changed files with 488 additions and 7 deletions
+29
View File
@@ -790,6 +790,35 @@
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now." <TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"/> Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"/>
<!-- TASK 3·20 — THIS SESSION. Counts are neutral and always
shown when there were events; money appears only if the
creator turned it on AND there is something to show, so a
creator who never monetized never sees a zero. Derived from
YouTube on demand; nothing is stored. -->
<StackPanel Visibility="{Binding Ypp.HasSessionRewards, Converter={StaticResource BoolToVis}}">
<TextBlock Text="THIS SESSION" Style="{StaticResource YtLabel}" Margin="0,14,0,2"/>
<TextBlock Text="{Binding Ypp.SessionCountsText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
<ItemsControl ItemsSource="{Binding Ypp.SessionMoneyLines}"
Visibility="{Binding Ypp.HasSessionMoney, Converter={StaticResource BoolToVis}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="{StaticResource Brush.OnSurfaceVariant}"
FontFamily="{StaticResource FontFamily.Mono}" FontSize="12" Margin="0,2,0,0"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Estimates from YouTube — removed or refunded events are excluded and totals can change."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
TextWrapping="Wrap" Margin="0,4,0,0"
Visibility="{Binding Ypp.HasSessionMoney, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<CheckBox Content="Show monetary metrics" Style="{StaticResource YtCheckBox}"
FontSize="12" Margin="0,10,0,0"
IsChecked="{Binding Ypp.ShowMonetaryMetrics, Mode=TwoWay}"
ToolTip="Off by default. Counts still appear; amounts stay hidden until you ask for them."/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/> <TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/> <TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" <TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ytLive.Models;
/// <summary>One Super Chat or Super Sticker event, as YouTube reports it.</summary>
public sealed record SuperChatEvent(
string Id,
ChatEventKind Kind,
long AmountMicros,
string Currency,
string? DisplayAmount,
DateTime PublishedAt);
/// <summary>
/// TASK 3·20: what this session's rewards added up to — <b>in memory only</b>.
/// <para>Derived on demand from <c>superChatEvents.list</c> rather than persisted: YouTube is the system
/// of record for money, and keeping a local ledger would contradict the published promise that no
/// viewer, member or donor information is retained after the session ends.</para>
/// <para>Totals are kept <b>per currency</b> and never summed across currencies — a $5 and a €5 are not
/// $10, and a single blended number would be a lie.</para>
/// </summary>
public sealed record SessionRewardSummary(
int SuperChatCount,
int SuperStickerCount,
IReadOnlyDictionary<string, long> MicrosByCurrency,
DateTime? FirstEventAt,
DateTime? LastEventAt)
{
public static readonly SessionRewardSummary Empty = new(
0, 0, new Dictionary<string, long>(), null, null);
public int TotalEvents => SuperChatCount + SuperStickerCount;
public bool HasEvents => TotalEvents > 0;
/// <summary>Filters to a session window and rolls the events up. Events outside
/// [<paramref name="sinceUtc"/>, <paramref name="untilUtc"/>] are ignored, which is what makes a
/// report "this session" rather than "this channel".</summary>
public static SessionRewardSummary From(
IEnumerable<SuperChatEvent> events, DateTime sinceUtc, DateTime? untilUtc = null)
{
var until = untilUtc ?? DateTime.MaxValue;
var inWindow = events
.Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until)
.OrderBy(e => e.PublishedAt)
.ToList();
if (inWindow.Count == 0) return Empty;
var byCurrency = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (var e in inWindow)
{
var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant();
byCurrency.TryGetValue(currency, out var total);
byCurrency[currency] = total + Math.Max(0, e.AmountMicros);
}
return new SessionRewardSummary(
inWindow.Count(e => e.Kind == ChatEventKind.SuperChat),
inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker),
byCurrency,
inWindow[0].PublishedAt,
inWindow[^1].PublishedAt);
}
/// <summary>Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to
/// show — a creator who never monetized must never be shown a zero.</summary>
public IReadOnlyList<string> MoneyLines()
=> MicrosByCurrency
.Where(pair => pair.Value > 0)
.OrderByDescending(pair => pair.Value)
.Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}")
.ToList();
private static string Format(string currency, long micros)
{
var amount = micros / 1_000_000m;
var symbol = currency switch
{
"USD" => "$",
"EUR" => "€",
"GBP" => "£",
"CAD" => "CA$",
"AUD" => "A$",
_ => string.Empty,
};
return symbol.Length > 0
? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture)
: amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency;
}
}
+6
View File
@@ -57,6 +57,12 @@ public partial class LayoutStore : IDisposable
public void SaveSteamLicenceIndicatorDismissed(bool dismissed) public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
=> UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0"); => UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0");
/// <summary>TASK 3·20: show money in the app's own analytics surfaces. OFF by default — most
/// creators never monetize, and a $0.00 report is a spit in the face (creator ruling 2026-11-05).</summary>
public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1";
public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1"; public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1";
public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0"); public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0");
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// TASK 3·20: reads the channel's recent Super Chat / Super Sticker events so a session report can be
/// shown without storing anything.
/// <para><b>YouTube is the authority.</b> This service only asks; it keeps no ledger, and the events it
/// returns are the ones YouTube still considers valid (removed or refunded events do not come back).</para>
/// <para>Scope: the endpoint is authorised by the app's existing <c>auth/youtube</c> scope, so no
/// re-consent is needed — unlike the Analytics API, which does.</para>
/// </summary>
public interface ISuperChatEventSource
{
/// <summary>The most recent events for a channel (YouTube caps the window at roughly 30 days).</summary>
Task<IReadOnlyList<SuperChatEvent>> GetRecentAsync(
string channelId, int maxResults = 200, CancellationToken cancellationToken = default);
}
public sealed class SuperChatEventService : ISuperChatEventSource
{
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
public SuperChatEventService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
public async Task<IReadOnlyList<SuperChatEvent>> GetRecentAsync(
string channelId, int maxResults = 200, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(channelId)) return Array.Empty<SuperChatEvent>();
if (_auth.CurrentChannel?.AccessToken is not { } token) return Array.Empty<SuperChatEvent>();
_http.DefaultRequestHeaders.Authorization = new("Bearer", token);
var url = $"{ApiBase}/superChatEvents?part=snippet&maxResults={Math.Clamp(maxResults, 1, 200)}" +
$"&channelId={Uri.EscapeDataString(channelId)}";
try
{
var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"superChatEvents.list failed: {(int)response.StatusCode}");
return Array.Empty<SuperChatEvent>();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return Parse(json);
}
catch (Exception ex)
{
AppLog.Write($"superChatEvents.list failed: {ex.Message}");
return Array.Empty<SuperChatEvent>();
}
}
/// <summary>Pure parse of a <c>superChatEvents.list</c> response. Both event types carry their money
/// in a different object, so a sticker must not be silently dropped.</summary>
internal static IReadOnlyList<SuperChatEvent> Parse(string json)
{
var events = new List<SuperChatEvent>();
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("items", out var items)) return events;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("snippet", out var snippet)) continue;
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty;
var publishedAt = snippet.TryGetProperty("publishedAt", out var published)
&& DateTime.TryParse(published.GetString(), out var parsed)
? parsed.ToUniversalTime()
: DateTime.UtcNow;
var kind = ChatEventKind.SuperChat;
JsonElement details;
if (snippet.TryGetProperty("superChatDetails", out var chat))
details = chat;
else if (snippet.TryGetProperty("superStickerDetails", out var sticker))
{
details = sticker;
kind = ChatEventKind.SuperSticker;
}
else
{
continue;
}
var micros = details.TryGetProperty("amountMicros", out var amount)
&& amount.ValueKind == JsonValueKind.Number
&& amount.TryGetInt64(out var value)
? value
: 0;
var currency = details.TryGetProperty("currency", out var cur) && cur.ValueKind == JsonValueKind.String
? cur.GetString() ?? string.Empty
: string.Empty;
var display = details.TryGetProperty("amountDisplayString", out var shown)
&& shown.ValueKind == JsonValueKind.String
? shown.GetString()
: null;
events.Add(new SuperChatEvent(id, kind, micros, currency, display, publishedAt));
}
return events;
}
}
+101 -1
View File
@@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using System.Windows.Input; using System.Windows.Input;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models; using ytLive.Models;
@@ -25,6 +28,12 @@ public sealed class YppTrackerViewModel : ViewModelBase
private readonly Func<bool> _isConnected; private readonly Func<bool> _isConnected;
private readonly Func<string?> _displayName; private readonly Func<string?> _displayName;
// TASK 3·20 — the session report is DERIVED, never stored: ask YouTube for the channel's recent
// reward events and roll them up for the window that starts when this session did.
private readonly Func<ISuperChatEventSource> _rewardEvents;
private readonly Func<string?> _channelId;
private readonly Func<DateTime?> _sessionStartedAtUtc;
private bool _isDrawerOpen; private bool _isDrawerOpen;
private bool _isLoading; private bool _isLoading;
private bool _isRefreshing; private bool _isRefreshing;
@@ -43,12 +52,18 @@ public sealed class YppTrackerViewModel : ViewModelBase
Func<LayoutStore> store, Func<LayoutStore> store,
Func<ChannelStatsService> stats, Func<ChannelStatsService> stats,
Func<bool> isConnected, Func<bool> isConnected,
Func<string?> displayName) Func<string?> displayName,
Func<ISuperChatEventSource>? rewardEvents = null,
Func<string?>? channelId = null,
Func<DateTime?>? sessionStartedAtUtc = null)
{ {
_store = store; _store = store;
_stats = stats; _stats = stats;
_isConnected = isConnected; _isConnected = isConnected;
_displayName = displayName; _displayName = displayName;
_rewardEvents = rewardEvents ?? (() => null!);
_channelId = channelId ?? (() => null);
_sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null);
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync()); RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen); ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
@@ -58,6 +73,7 @@ public sealed class YppTrackerViewModel : ViewModelBase
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl)); OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist(); (_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
_showMonetaryMetrics = _store().LoadShowMonetaryMetrics();
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc(); _lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
} }
@@ -92,6 +108,63 @@ public sealed class YppTrackerViewModel : ViewModelBase
public bool IsSignedOut => !_isConnected(); public bool IsSignedOut => !_isConnected();
private SessionRewardSummary _sessionRewards = SessionRewardSummary.Empty;
/// <summary>This session's reward events, derived on demand. Never persisted.</summary>
public SessionRewardSummary SessionRewards
{
get => _sessionRewards;
private set
{
_sessionRewards = value;
OnPropertyChanged(nameof(SessionRewards));
OnPropertyChanged(nameof(HasSessionRewards));
OnPropertyChanged(nameof(SessionCountsText));
OnPropertyChanged(nameof(SessionMoneyLines));
OnPropertyChanged(nameof(HasSessionMoney));
}
}
public bool HasSessionRewards => _sessionRewards.HasEvents;
/// <summary>Counts are always neutral — "3 Super Chats" says nothing about money, and most
/// creators have none.</summary>
public string SessionCountsText
{
get
{
if (!_sessionRewards.HasEvents) return string.Empty;
var parts = new List<string>();
if (_sessionRewards.SuperChatCount > 0) parts.Add($"{_sessionRewards.SuperChatCount} Super Chat{(_sessionRewards.SuperChatCount == 1 ? "" : "s")}");
if (_sessionRewards.SuperStickerCount > 0) parts.Add($"{_sessionRewards.SuperStickerCount} Super Sticker{(_sessionRewards.SuperStickerCount == 1 ? "" : "s")}");
return string.Join(" · ", parts);
}
}
/// <summary>Money is shown only when the creator asked for it AND there is something to show.
/// A creator who never monetized never sees a zero (creator ruling 2026-11-05).</summary>
public IReadOnlyList<string> SessionMoneyLines
=> ShowMonetaryMetrics ? _sessionRewards.MoneyLines() : Array.Empty<string>();
public bool HasSessionMoney => SessionMoneyLines.Count > 0;
private bool _showMonetaryMetrics;
/// <summary>"Show monetary metrics" — lives in this drawer, persisted, OFF by default. Turning it on
/// is also what would justify requesting the monetary Analytics scope; while it is off the app never
/// asks Google for money data.</summary>
public bool ShowMonetaryMetrics
{
get => _showMonetaryMetrics;
set
{
if (!SetProperty(ref _showMonetaryMetrics, value)) return;
_store().SaveShowMonetaryMetrics(value);
OnPropertyChanged(nameof(SessionMoneyLines));
OnPropertyChanged(nameof(HasSessionMoney));
}
}
public string? LoadError public string? LoadError
{ {
get => _loadError; get => _loadError;
@@ -203,11 +276,38 @@ public sealed class YppTrackerViewModel : ViewModelBase
await RefreshAsync(); await RefreshAsync();
} }
/// <summary>Asks YouTube for the channel's recent reward events and keeps only this session's.
/// Nothing is written down: the report is a view over YouTube's data, not a local ledger.</summary>
private async Task RefreshSessionRewardsAsync()
{
var started = _sessionStartedAtUtc();
var channel = _channelId();
var source = _rewardEvents();
if (started == null || string.IsNullOrWhiteSpace(channel) || source == null)
{
SessionRewards = SessionRewardSummary.Empty;
return;
}
try
{
var events = await source.GetRecentAsync(channel);
SessionRewards = SessionRewardSummary.From(events, started.Value);
}
catch (Exception ex)
{
AppLog.Write($"Session rewards unavailable: {ex.Message}");
SessionRewards = SessionRewardSummary.Empty;
}
}
public async Task RefreshAsync() public async Task RefreshAsync()
{ {
if (_isRefreshing) return; if (_isRefreshing) return;
_isRefreshing = true; _isRefreshing = true;
IsLoading = true; IsLoading = true;
await RefreshSessionRewardsAsync();
LoadError = null; LoadError = null;
try try
{ {
+1 -1
View File
@@ -47,7 +47,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) | | — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | | 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
| 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) | | 3·20 | ✅ | Session report — **derived from `superChatEvents.list` on demand, not persisted** (YouTube is the authority; no ledger). Per-currency totals, money off by default (done 2026-11-05) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING + ingest pre-flight done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air | [`task-32`](TASKS/task-32-stream-resilience.md) | | 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING + ingest pre-flight done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air | [`task-32`](TASKS/task-32-stream-resilience.md) |
| 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) | | 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
| 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) | | 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) |
+8 -4
View File
@@ -35,10 +35,14 @@
**Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube **Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube
emits no public event for free subs), NO viewer count (vetoed), and every card draws the emits no public event for free subs), NO viewer count (vetoed), and every card draws the
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor "made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
(still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table ✅ **RESOLVED 2026-11-05 — derived, not persisted.** The creator's ruling: YouTube is the authority
(all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report for monetization, so the app asks it on demand instead of keeping a ledger. `Services/SuperChatEventService.cs`
rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses reads `superChatEvents.list` (existing `auth/youtube` scope — no re-consent), `Models/SessionRewardSummary.cs`
events into `ChatMessage` directly in memory; the report reads that table once it exists.** rolls it up for the session window with **per-currency totals that are never summed together**, and the YPP
drawer shows "THIS SESSION" counts. No table, no migration, no backfill, and the published promise that no
viewer/member/donor information is retained stays literally true.
**Money is off by default** ("Show monetary metrics", in the drawer, persisted): counts are neutral, and a
creator who never monetized is never shown a zero. Tests: `SessionRewardReportTests`.**
21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem). 21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem).
22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`. 22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`.
23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear. 23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear.
@@ -120,6 +120,9 @@ public partial class MainViewModel : ViewModelBase
? "LlamaCasty" ? "LlamaCasty"
: $"{StreamTitle} — LlamaCasty"; : $"{StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming; StreamStatus = StreamStatus.Streaming;
// TASK 3·20: the session report window starts here. Derived on demand from YouTube, never
// persisted, and reset when the session ends.
_sessionStartedAtUtc = DateTime.UtcNow;
ResetHealth(StreamStatus.Streaming); ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero; _liveElapsed = TimeSpan.Zero;
@@ -371,6 +374,7 @@ public partial class MainViewModel : ViewModelBase
var wasRecording = IsRecording; var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline; StreamStatus = StreamStatus.Offline;
_sessionStartedAtUtc = null;
IsRecording = false; IsRecording = false;
LiveScene = null; LiveScene = null;
EditingActive = true; EditingActive = true;
+4
View File
@@ -416,6 +416,10 @@ public partial class MainViewModel : ViewModelBase
set => SetProperty(ref _previewGlowThickness, value); set => SetProperty(ref _previewGlowThickness, value);
} }
/// <summary>When the current/last streaming session started — the window a session report covers.
/// Null when no session has run since launch.</summary>
private DateTime? _sessionStartedAtUtc;
public string LiveElapsedText public string LiveElapsedText
{ {
get => _liveElapsedText; get => _liveElapsedText;
+4 -1
View File
@@ -23,7 +23,10 @@ public partial class MainViewModel
() => _layoutStore, () => _layoutStore,
() => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth), () => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth),
() => IsConnected, () => IsConnected,
() => _youtubeAuth.CurrentChannel?.DisplayName); () => _youtubeAuth.CurrentChannel?.DisplayName,
rewardEvents: () => new SuperChatEventService(_youtubeAuth),
channelId: () => _youtubeAuth.CurrentChannel?.ChannelId,
sessionStartedAtUtc: () => _sessionStartedAtUtc);
TestSession = new TestSessionViewModel( TestSession = new TestSessionViewModel(
_youtubeStream, _youtubeStream,
+12
View File
@@ -298,6 +298,18 @@ comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No f
— chat history, moderation logs, analytics, crash payloads — may quietly break that — chat history, moderation logs, analytics, crash payloads — may quietly break that
assumption.** If one is ever proposed, the 11.12 position must be re-argued first. assumption.** If one is ever proposed, the 11.12 position must be re-argued first.
### Monetization: derived, never a ledger (2026-11-05)
YouTube is the **sole authority** for monetization progress and qualification; the API exposes **no**
eligibility field (`channels.list` has no monetization part; `auditDetails` needs an MCN scope). So the app
**asks YouTube on demand** (`superChatEvents.list` for the session report; Analytics for the YPP bars in
TASK 39·2) and **stores nothing**. That keeps the promise above literally true — there is no reward ledger
to retain — and it means the app never has to model YouTube's moving thresholds as our own data.
**Money is off by default** ("Show monetary metrics", in the YPP drawer, persisted): counts are neutral,
and a creator who never monetized is never shown a zero. Amounts are estimates that can be restated for
weeks, and are labelled as such wherever they appear.
### Minors ### Minors
The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube
+4
View File
@@ -12,6 +12,10 @@ namespace ytLive.Tests;
/// route back is a NEW broadcast: one click, saved details, no dialog, and a title that says it is a /// route back is a NEW broadcast: one click, saved details, no dialog, and a title that says it is a
/// resume (otherwise the channel grows two identically named videos). /// resume (otherwise the channel grows two identically named videos).
/// </summary> /// </summary>
// Constructs a MainViewModel directly, and `LayoutPathOverride` is static: it belongs in the serialised
// collection with the other direct-construction tests, or a parallel sibling can swap the layout path
// underneath it (observed as a one-off failure in a full run, green in isolation).
[Collection("MainViewModelLayoutPath")]
public class BackOnAirTests public class BackOnAirTests
{ {
[Fact] [Fact]
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 3·20 — the session report is DERIVED from YouTube, never stored: no ledger, no migration, and the
/// published promise ("no viewer, member or donor information is retained after the session ends") stays
/// literally true.
/// <para>Two rules the creator set (2026-11-05): money is hidden unless asked for, and a creator who never
/// monetized is never shown a zero. Counts stay neutral so a normal session still has something to read.</para>
/// </summary>
public class SessionRewardReportTests
{
private static readonly DateTime SessionStart = new(2026, 11, 5, 20, 0, 0, DateTimeKind.Utc);
[Fact]
public void ParsesBothRewardTypes_WithoutDroppingStickers()
{
const string json = """
{
"items": [
{"id":"sc1","snippet":{"publishedAt":"2026-11-05T20:05:00Z","superChatDetails":
{"amountMicros":5000000,"currency":"USD","amountDisplayString":"$5.00","userComment":"hi"}}},
{"id":"ss1","snippet":{"publishedAt":"2026-11-05T20:07:00Z","superStickerDetails":
{"amountMicros":2000000,"currency":"EUR","amountDisplayString":"€2.00"}}}
]
}
""";
var events = SuperChatEventService.Parse(json);
Assert.Equal(2, events.Count);
Assert.Equal(ChatEventKind.SuperChat, events[0].Kind);
Assert.Equal(5_000_000, events[0].AmountMicros);
Assert.Equal("USD", events[0].Currency);
Assert.Equal(ChatEventKind.SuperSticker, events[1].Kind);
Assert.Equal("EUR", events[1].Currency);
}
[Fact]
public void TheWindowMakesItASessionReport_NotAChannelReport()
{
var events = new[]
{
Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(-30)), // before
Event(ChatEventKind.SuperChat, 3_000_000, "USD", SessionStart.AddMinutes(10)),
Event(ChatEventKind.SuperSticker, 2_000_000, "USD", SessionStart.AddMinutes(20)),
};
var summary = SessionRewardSummary.From(events, SessionStart);
Assert.Equal(1, summary.SuperChatCount);
Assert.Equal(1, summary.SuperStickerCount);
Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]);
}
[Fact]
public void CurrenciesAreNeverSummedTogether()
{
var events = new[]
{
Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(1)),
Event(ChatEventKind.SuperChat, 5_000_000, "EUR", SessionStart.AddMinutes(2)),
};
var summary = SessionRewardSummary.From(events, SessionStart);
Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]);
Assert.Equal(5_000_000, summary.MicrosByCurrency["EUR"]);
Assert.Equal(2, summary.MoneyLines().Count);
Assert.Contains(summary.MoneyLines(), line => line.StartsWith("USD $5.00"));
Assert.Contains(summary.MoneyLines(), line => line.StartsWith("EUR €5.00"));
}
[Fact]
public void AnEmptySession_HasNoCountsAndNoMoney()
{
var summary = SessionRewardSummary.From(Array.Empty<SuperChatEvent>(), SessionStart);
Assert.False(summary.HasEvents);
Assert.Empty(summary.MoneyLines());
Assert.Equal(0, summary.TotalEvents);
}
[Fact]
public void AStickerOnlySession_StillCounts()
{
var summary = SessionRewardSummary.From(
new[] { Event(ChatEventKind.SuperSticker, 1_000_000, "USD", SessionStart.AddMinutes(1)) },
SessionStart);
Assert.True(summary.HasEvents);
Assert.Equal(0, summary.SuperChatCount);
Assert.Equal(1, summary.SuperStickerCount);
}
private static SuperChatEvent Event(ChatEventKind kind, long micros, string currency, DateTime at)
=> new($"id-{Guid.NewGuid():N}", kind, micros, currency, null, at);
}