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:
@@ -790,6 +790,35 @@
|
||||
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
|
||||
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="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -35,10 +35,14 @@
|
||||
**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
|
||||
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
|
||||
(still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table
|
||||
(all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report
|
||||
rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses
|
||||
events into `ChatMessage` directly in memory; the report reads that table once it exists.**
|
||||
✅ **RESOLVED 2026-11-05 — derived, not persisted.** The creator's ruling: YouTube is the authority
|
||||
for monetization, so the app asks it on demand instead of keeping a ledger. `Services/SuperChatEventService.cs`
|
||||
reads `superChatEvents.list` (existing `auth/youtube` scope — no re-consent), `Models/SessionRewardSummary.cs`
|
||||
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).
|
||||
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.
|
||||
|
||||
@@ -120,6 +120,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
? "LlamaCasty"
|
||||
: $"{StreamTitle} — LlamaCasty";
|
||||
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);
|
||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
@@ -371,6 +374,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
var wasRecording = IsRecording;
|
||||
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
_sessionStartedAtUtc = null;
|
||||
IsRecording = false;
|
||||
LiveScene = null;
|
||||
EditingActive = true;
|
||||
|
||||
@@ -416,6 +416,10 @@ public partial class MainViewModel : ViewModelBase
|
||||
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
|
||||
{
|
||||
get => _liveElapsedText;
|
||||
|
||||
@@ -23,7 +23,10 @@ public partial class MainViewModel
|
||||
() => _layoutStore,
|
||||
() => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth),
|
||||
() => IsConnected,
|
||||
() => _youtubeAuth.CurrentChannel?.DisplayName);
|
||||
() => _youtubeAuth.CurrentChannel?.DisplayName,
|
||||
rewardEvents: () => new SuperChatEventService(_youtubeAuth),
|
||||
channelId: () => _youtubeAuth.CurrentChannel?.ChannelId,
|
||||
sessionStartedAtUtc: () => _sessionStartedAtUtc);
|
||||
|
||||
TestSession = new TestSessionViewModel(
|
||||
_youtubeStream,
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube
|
||||
|
||||
@@ -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
|
||||
/// resume (otherwise the channel grows two identically named videos).
|
||||
/// </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
|
||||
{
|
||||
[Fact]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user