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; /// /// TASK 3·20: reads the channel's recent Super Chat / Super Sticker events so a session report can be /// shown without storing anything. /// YouTube is the authority. 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). /// Scope: the endpoint is authorised by the app's existing auth/youtube scope, so no /// re-consent is needed — unlike the Analytics API, which does. /// public interface ISuperChatEventSource { /// The most recent events for a channel (YouTube caps the window at roughly 30 days). Task> 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> GetRecentAsync( string channelId, int maxResults = 200, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(channelId)) return Array.Empty(); if (_auth.CurrentChannel?.AccessToken is not { } token) return Array.Empty(); _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(); } 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(); } } /// Pure parse of a superChatEvents.list response. Both event types carry their money /// in a different object, so a sticker must not be silently dropped. internal static IReadOnlyList Parse(string json) { var events = new List(); 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; } }