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);