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
+6
View File
@@ -57,6 +57,12 @@ public partial class LayoutStore : IDisposable
public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
=> 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 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.Linq;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
@@ -25,6 +28,12 @@ public sealed class YppTrackerViewModel : ViewModelBase
private readonly Func<bool> _isConnected;
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 _isLoading;
private bool _isRefreshing;
@@ -43,12 +52,18 @@ public sealed class YppTrackerViewModel : ViewModelBase
Func<LayoutStore> store,
Func<ChannelStatsService> stats,
Func<bool> isConnected,
Func<string?> displayName)
Func<string?> displayName,
Func<ISuperChatEventSource>? rewardEvents = null,
Func<string?>? channelId = null,
Func<DateTime?>? sessionStartedAtUtc = null)
{
_store = store;
_stats = stats;
_isConnected = isConnected;
_displayName = displayName;
_rewardEvents = rewardEvents ?? (() => null!);
_channelId = channelId ?? (() => null);
_sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null);
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
@@ -58,6 +73,7 @@ public sealed class YppTrackerViewModel : ViewModelBase
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
_showMonetaryMetrics = _store().LoadShowMonetaryMetrics();
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
}
@@ -92,6 +108,63 @@ public sealed class YppTrackerViewModel : ViewModelBase
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
{
get => _loadError;
@@ -203,11 +276,38 @@ public sealed class YppTrackerViewModel : ViewModelBase
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()
{
if (_isRefreshing) return;
_isRefreshing = true;
IsLoading = true;
await RefreshSessionRewardsAsync();
LoadError = null;
try
{