using Application.Abstractions.Cache; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.YouTube; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.Channel.GetLiveList; public sealed class Handler( IAppDbContext db, IYouTubeLiveStateStore liveStateStore, IYouTubeChannelCache channelCache, ICacheService cache ) : IQueryHandler> { private const string CacheKey = "channel:live:list:main"; private const int MaxLimit = 100; private const int DefaultLimit = 24; private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); public async Task> Handle(Query request, CancellationToken ct) { var limit = request.Limit <= 0 ? DefaultLimit : Math.Min(request.Limit, MaxLimit); var offset = Math.Max(request.Offset, 0); var fullList = await cache.GetAsync>(CacheKey, ct); if (fullList is null) { fullList = await BuildFullLiveListAsync(ct); await cache.SetAsync(CacheKey, fullList, CacheTtl, ct); } IEnumerable filtered = fullList; if (!string.IsNullOrWhiteSpace(request.Keyword)) { var keyword = request.Keyword.Trim(); filtered = filtered.Where(c => c.ChannelName.Contains(keyword, StringComparison.OrdinalIgnoreCase) || (c.Handle is not null && c.Handle.Contains(keyword, StringComparison.OrdinalIgnoreCase))); } filtered = request.Sort switch { "newest" => filtered.OrderByDescending(c => c.StartedAt ?? DateTime.MinValue), "name" => filtered.OrderBy(c => c.ChannelName), _ => filtered.OrderByDescending(c => c.ViewerCount) }; var ordered = filtered.ToList(); var total = ordered.Count; var page = ordered.Skip(offset).Take(limit).ToList(); return new Response(page, total); } private async Task> BuildFullLiveListAsync(CancellationToken ct) { var channels = await db.Channel.AsNoTracking().Where(c => c.IsActive).Select(c => new { c.SID, c.Name, c.Handle }).ToListAsync(ct); if (channels.Count == 0) { return []; } var allLive = await liveStateStore.GetAllLiveAsync(); var liveMap = allLive.Where(l => l.IsLive).ToDictionary(l => l.ChannelId); if (liveMap.Count == 0) { return []; } var liveChannels = channels.Where(c => liveMap.ContainsKey(c.SID)).ToList(); if (liveChannels.Count == 0) { return []; } var ytCache = await channelCache.GetManyAsync(liveChannels.Select(c => c.SID)); // ViewerCount 는 YouTube concurrentViewers 를 poller 가 30초 주기로 Redis 에 갱신한 값 // (과거 IChatConnectionTracker 는 "사이트 접속자 수" 였음 — YouTube 시청자 수가 아니라서 제거) var items = liveChannels.Select(ch => { var liveInfo = liveMap[ch.SID]; var ytInfo = ytCache.GetValueOrDefault(ch.SID); return new LiveChannelItem( ch.SID, ch.Name, ch.Handle, ytInfo?.ThumbnailUrl, liveInfo.VideoId, $"https://img.youtube.com/vi/{liveInfo.VideoId}/mqdefault.jpg", liveInfo.Title, liveInfo.ViewerCount, liveInfo.ActualStartTime ); }).ToList(); items.Sort((a, b) => b.ViewerCount.CompareTo(a.ViewerCount)); return items; } }