Handler.cs 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Messaging;
  4. using Application.Abstractions.YouTube;
  5. using Microsoft.EntityFrameworkCore;
  6. using SharedKernel.Results;
  7. namespace Application.Features.Api.Channel.GetLiveList;
  8. public sealed class Handler(
  9. IAppDbContext db,
  10. IYouTubeLiveStateStore liveStateStore,
  11. IYouTubeChannelCache channelCache,
  12. ICacheService cache
  13. ) : IQueryHandler<Query, Result<Response>>
  14. {
  15. private const string CacheKey = "channel:live:list:main";
  16. private const int MaxLimit = 100;
  17. private const int DefaultLimit = 24;
  18. private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
  19. public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
  20. {
  21. var limit = request.Limit <= 0 ? DefaultLimit : Math.Min(request.Limit, MaxLimit);
  22. var offset = Math.Max(request.Offset, 0);
  23. var fullList = await cache.GetAsync<List<LiveChannelItem>>(CacheKey, ct);
  24. if (fullList is null)
  25. {
  26. fullList = await BuildFullLiveListAsync(ct);
  27. await cache.SetAsync(CacheKey, fullList, CacheTtl, ct);
  28. }
  29. IEnumerable<LiveChannelItem> filtered = fullList;
  30. if (!string.IsNullOrWhiteSpace(request.Keyword))
  31. {
  32. var keyword = request.Keyword.Trim();
  33. filtered = filtered.Where(c => c.ChannelName.Contains(keyword, StringComparison.OrdinalIgnoreCase) || (c.Handle is not null && c.Handle.Contains(keyword, StringComparison.OrdinalIgnoreCase)));
  34. }
  35. filtered = request.Sort switch
  36. {
  37. "newest" => filtered.OrderByDescending(c => c.StartedAt ?? DateTime.MinValue),
  38. "name" => filtered.OrderBy(c => c.ChannelName),
  39. _ => filtered.OrderByDescending(c => c.ViewerCount)
  40. };
  41. var ordered = filtered.ToList();
  42. var total = ordered.Count;
  43. var page = ordered.Skip(offset).Take(limit).ToList();
  44. return new Response(page, total);
  45. }
  46. private async Task<List<LiveChannelItem>> BuildFullLiveListAsync(CancellationToken ct)
  47. {
  48. var channels = await db.Channel.AsNoTracking().Where(c => c.IsActive).Select(c => new { c.SID, c.Name, c.Handle }).ToListAsync(ct);
  49. if (channels.Count == 0)
  50. {
  51. return [];
  52. }
  53. var allLive = await liveStateStore.GetAllLiveAsync();
  54. var liveMap = allLive.Where(l => l.IsLive).ToDictionary(l => l.ChannelId);
  55. if (liveMap.Count == 0)
  56. {
  57. return [];
  58. }
  59. var liveChannels = channels.Where(c => liveMap.ContainsKey(c.SID)).ToList();
  60. if (liveChannels.Count == 0)
  61. {
  62. return [];
  63. }
  64. var ytCache = await channelCache.GetManyAsync(liveChannels.Select(c => c.SID));
  65. // ViewerCount 는 YouTube concurrentViewers 를 poller 가 30초 주기로 Redis 에 갱신한 값
  66. // (과거 IChatConnectionTracker 는 "사이트 접속자 수" 였음 — YouTube 시청자 수가 아니라서 제거)
  67. var items = liveChannels.Select(ch => {
  68. var liveInfo = liveMap[ch.SID];
  69. var ytInfo = ytCache.GetValueOrDefault(ch.SID);
  70. return new LiveChannelItem(
  71. ch.SID,
  72. ch.Name,
  73. ch.Handle,
  74. ytInfo?.ThumbnailUrl,
  75. liveInfo.VideoId,
  76. $"https://img.youtube.com/vi/{liveInfo.VideoId}/mqdefault.jpg",
  77. liveInfo.Title,
  78. liveInfo.ViewerCount,
  79. liveInfo.ActualStartTime
  80. );
  81. }).ToList();
  82. items.Sort((a, b) => b.ViewerCount.CompareTo(a.ViewerCount));
  83. return items;
  84. }
  85. }