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