using System.Net.Http; using System.Text.Json; using ytLive.Models; namespace ytLive.Services; /// /// Polls YouTube Live Chat API for messages. /// YouTube doesn't have WebSocket for chat — polling is the only option. The /// cadence follows the server's pollingIntervalMillis (the streamList /// connection semantics: reconnect with nextPageToken on that cadence, never /// faster than the refresh rate — YouTube rejects requests sent too quickly). /// The chat feed IS YouTube's event feed: Super Chats, stickers, memberships, /// gifts and milestones arrive here as typed messages (see ). /// public class YouTubeChatService : IDisposable { private readonly YouTubeAuthService _auth; private readonly HttpClient _http; private Timer? _pollTimer; private string? _nextPageToken; private string? _liveChatId; private bool _isRunning; private int _intervalMs = 2000; public event Action? MessageReceived; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null) { _auth = auth; _http = http ?? new HttpClient(); } public void Start(string liveChatId, int pollIntervalMs = 2000) { _liveChatId = liveChatId; _isRunning = true; _nextPageToken = null; _intervalMs = Math.Clamp(pollIntervalMs, 1000, 6000); _pollTimer = new Timer(async _ => await Poll(), null, Timeout.Infinite, Timeout.Infinite); _pollTimer.Change(0, Timeout.Infinite); } public void Stop() { _isRunning = false; _pollTimer?.Dispose(); _pollTimer = null; } /// TASK 41: injects a synthetic message through the exact seam the real /// poll uses (MessageReceived), so a simulated member / SuperChat / subscriber /// event renders through the whole overlay pipeline. YouTube's insert API only /// creates text messages — these events are local-only by design and never /// reach YouTube (marked ). public void InjectSimulatedMessage(ChatMessage message) => MessageReceived?.Invoke(message); private async Task Poll() { try { if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return; if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5)) await _auth.RefreshToken(); var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=2000"; if (_nextPageToken != null) url += $"&pageToken={_nextPageToken}"; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel.AccessToken); var response = await _http.GetAsync(url); if (!response.IsSuccessStatusCode) return; var json = await response.Content.ReadAsStringAsync(); var page = ParsePage(json); if (page.NextPageToken != null) _nextPageToken = page.NextPageToken; if (page.PollIntervalMs > 0 && page.PollIntervalMs != _intervalMs) _intervalMs = page.PollIntervalMs; foreach (var message in page.Messages) MessageReceived?.Invoke(message); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}"); } finally { // Re-arm the one-shot poll on the server's cadence. Guarded by // _isRunning so a concurrent Stop() (dispose + null) can't race a // Change() back onto the disposed timer. if (_isRunning) _pollTimer?.Change(_intervalMs, Timeout.Infinite); } } /// One liveChat/messages page → typed chat messages + pagination + /// the server's poll cadence. Internal + separated from the HTTP so the six /// event parses are deterministically testable without any network. internal static ChatPage ParsePage(string json) { var data = JsonSerializer.Deserialize(json); var messages = new List(); var nextPageToken = data.TryGetProperty("nextPageToken", out var token) ? token.GetString() : null; var intervalMs = data.TryGetProperty("pollingIntervalMillis", out var interval) && interval.ValueKind == JsonValueKind.Number ? interval.GetInt32() : 0; if (!data.TryGetProperty("items", out var items)) return new ChatPage(messages, nextPageToken, intervalMs); foreach (var item in items.EnumerateArray()) { var snippet = item.GetProperty("snippet"); var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : "textMessageEvent"; var message = new ChatMessage { Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(), Timestamp = DateTime.UtcNow, }; if (item.TryGetProperty("authorDetails", out var author)) { message.AuthorName = author.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty; message.AuthorChannelId = author.TryGetProperty("channelId", out var cid) ? cid.GetString() ?? string.Empty : string.Empty; message.AuthorImageUrl = author.TryGetProperty("profileImageUrl", out var im) ? im.GetString() ?? string.Empty : string.Empty; message.IsMember = author.TryGetProperty("isChatSponsor", out var sp) ? sp.GetBoolean() : false; message.MembershipLevel = author.TryGetProperty("badgeText", out var badge) ? badge.GetString() : null; } if (snippet.TryGetProperty("publishedAt", out var published) && DateTime.TryParse(published.GetString(), out var publishedAt)) message.Timestamp = publishedAt; switch (type) { case "superChatEvent": message.IsSuperChat = true; message.Kind = ChatEventKind.SuperChat; if (snippet.TryGetProperty("superChatDetails", out var scDetails)) { if (scDetails.TryGetProperty("displayString", out var display) && display.ValueKind == JsonValueKind.String) message.SuperChatDisplayString = display.GetString(); if (scDetails.TryGetProperty("amountMicros", out var amt) && amt.ValueKind == JsonValueKind.Number) message.SuperChatAmount = amt.GetDouble() / 1_000_000.0; message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur) ? cur.GetString() : null; message.Message = scDetails.TryGetProperty("userComment", out var comment) ? comment.GetString() ?? string.Empty : string.Empty; } break; case "superStickerEvent": message.Kind = ChatEventKind.SuperSticker; if (snippet.TryGetProperty("superStickerDetails", out var stickerDetails)) { message.SuperStickerDescription = stickerDetails.TryGetProperty("stickerDescription", out var desc) ? desc.GetString() : stickerDetails.TryGetProperty("description", out var descAlt) ? descAlt.GetString() : null; if (stickerDetails.TryGetProperty("displayString", out var display) && display.ValueKind == JsonValueKind.String) message.SuperChatDisplayString = display.GetString(); message.SuperChatCurrency = stickerDetails.TryGetProperty("currency", out var cur) ? cur.GetString() : null; message.Message = stickerDetails.TryGetProperty("userComment", out var comment) ? comment.GetString() ?? string.Empty : string.Empty; } break; case "newSponsorEvent": message.Kind = ChatEventKind.NewMember; message.IsMember = true; message.Message = $"🎉 {message.AuthorName} became a member!"; if (snippet.TryGetProperty("membershipDetails", out var memDetails)) { message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level) ? level.GetString() : message.MembershipLevel; } break; case "membershipGiftingEvent": message.Kind = ChatEventKind.MemberGift; if (snippet.TryGetProperty("membershipGiftingDetails", out var giftingDetails) && giftingDetails.TryGetProperty("giftNewMembersCount", out var count) && count.ValueKind == JsonValueKind.Number) message.GiftNewMembersCount = count.GetInt32(); message.Message = message.GiftNewMembersCount > 0 ? $"🎁 {message.AuthorName} gifted {message.GiftNewMembersCount} memberships!" : $"🎁 {message.AuthorName} gifted memberships!"; break; case "giftMembershipReceivedEvent": message.Kind = ChatEventKind.GiftReceived; if (snippet.TryGetProperty("giftMembershipReceivedDetails", out var giftReceived) && giftReceived.TryGetProperty("gifterName", out var gifter)) message.GifterName = gifter.GetString(); message.Message = message.GifterName != null ? $"{message.GifterName} gifted you a membership! 🎉" : $"{message.AuthorName} received a membership gift! 🎉"; break; case "memberMilestoneChatEvent": message.Kind = ChatEventKind.MemberMilestone; message.IsMember = true; message.Message = snippet.TryGetProperty("textMessageDetails", out var msd) ? msd.TryGetProperty("messageText", out var mst) ? mst.GetString() ?? string.Empty : string.Empty : string.Empty; if (message.Message.Length > 0) { var match = System.Text.RegularExpressions.Regex.Match(message.Message, @"(\d+)\s*month", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (match.Success && int.TryParse(match.Groups[1].Value, out var months)) message.MilestoneMonths = months; } break; default: // textMessageEvent and anything else — anonymous chat rows only. message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails) ? textDetails.TryGetProperty("messageText", out var text) ? text.GetString() ?? string.Empty : string.Empty : string.Empty; break; } messages.Add(message); } return new ChatPage(messages, nextPageToken, intervalMs); } public void Dispose() { Stop(); _http.Dispose(); } } /// Decoded liveChat/messages response: the messages + where to resume + /// how long to wait before reconnecting. internal sealed record ChatPage(List Messages, string? NextPageToken, int PollIntervalMs);