using Application.Abstractions.Data;
using Application.Abstractions.YouTube;
using Domain.Entities.Members;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Infrastructure.YouTube;
internal sealed record ChannelSnapshot(int ID, string SID, string? Handle);
///
/// 1시간마다 활성 채널의 YouTube 정보를 갱신하여 Redis 캐시 + DB Channel 양쪽에 저장
/// channels.list는 id 파라미터로 최대 50개 채널을 한 번에 조회 가능 (1 unit/요청)
/// 채널 50개 이하 → 1시간에 1 unit만 사용
/// 동기화 대상: ThumbnailUrl, BannerUrl, SubscriberCount, VideoCount, ViewCount, Description, Email, PublishedAt, Handle, Title
/// Handle 변경 감지 시 이전 값을 ChannelHandleHistory 에 저장
///
internal sealed class YouTubeChannelCacheRefreshService(
IServiceScopeFactory scopeFactory,
IYouTubeApiService youTubeApi,
IYouTubeChannelCache channelCache,
ILogger logger
) : BackgroundService
{
private static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
private static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(15);
private const int BatchSize = 50; // YouTube API 최대 50개/요청
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(InitialDelay, stoppingToken);
logger.LogInformation("[ChannelCache] 채널 캐시 갱신 서비스 시작 — 주기: {Interval}시간", RefreshInterval.TotalHours);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RefreshAllChannelsAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "[ChannelCache] 채널 캐시 갱신 중 오류");
}
await Task.Delay(RefreshInterval, stoppingToken);
}
}
private async Task RefreshAllChannelsAsync(CancellationToken ct)
{
// DB에서 활성 채널 (ID, SID, Handle) 목록 조회 (Scoped DbContext)
List channels;
using (var scope = scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
channels = await db.Channel.AsNoTracking()
.Where(c => c.IsActive)
.Select(c => new ChannelSnapshot(c.ID, c.SID, c.Handle))
.ToListAsync(ct);
}
if (channels.Count == 0)
{
return;
}
// 50개씩 배치로 YouTube API 호출
var refreshed = 0;
for (var i = 0; i < channels.Count; i += BatchSize)
{
var batch = channels.Skip(i).Take(BatchSize).ToList();
var channelIds = batch.Select(x => x.SID).ToList();
var results = (await youTubeApi.GetChannelsByIdsAsync(channelIds, ct)).ToList();
foreach (var info in results)
{
await channelCache.SetAsync(info);
refreshed++;
}
// DB 동기화 (전체 YouTube 정보 + Handle 이력)
await SyncDbChannelsAsync(batch, results, ct);
// 배치 간 1초 대기 (API rate limit 방어)
if (i + BatchSize < channels.Count)
{
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}
logger.LogInformation("[ChannelCache] {Refreshed}/{Total} 채널 캐시 갱신 완료", refreshed, channels.Count);
}
private async Task SyncDbChannelsAsync(
List batch,
IReadOnlyList results,
CancellationToken ct
) {
if (results.Count == 0)
{
return;
}
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var batchIds = batch.Select(b => b.ID).ToList();
var dbChannels = await db.Channel.Where(c => batchIds.Contains(c.ID)).ToListAsync(ct);
var snapshotBySID = batch.ToDictionary(b => b.SID);
foreach (var info in results)
{
if (!snapshotBySID.TryGetValue(info.ChannelID, out var snapshot))
{
continue;
}
var channel = dbChannels.FirstOrDefault(c => c.ID == snapshot.ID);
if (channel is null)
{
continue;
}
// Handle 변경 감지 → 이력 저장 (YouTube API의 customUrl 은 '@foo' 형태이므로 normalize 후 비교)
var newNormalized = NormalizeHandle(info.CustomUrl);
if (!string.Equals(snapshot.Handle, newNormalized, StringComparison.Ordinal))
{
if (!string.IsNullOrEmpty(snapshot.Handle))
{
db.ChannelHandleHistory.Add(ChannelHandleHistory.Create(snapshot.ID, snapshot.Handle));
}
logger.LogInformation(
"[ChannelCache] Handle 변경 감지: ChannelID={ChannelID}, {OldHandle} → {NewHandle}",
snapshot.ID, snapshot.Handle, newNormalized
);
}
// YouTube 전체 정보 DB 동기화 (Handle 도 내부 normalize 됨)
channel.UpdateYouTubeInfo(
info.ChannelID,
info.Title,
info.CustomUrl,
info.Description,
info.ThumbnailUrl,
info.BannerUrl,
info.SubscriberCount,
info.VideoCount,
info.ViewCount,
info.Email,
info.PublishedAt
);
}
await db.SaveChangesAsync(ct);
}
private static string? NormalizeHandle(string? handle)
{
if (string.IsNullOrWhiteSpace(handle))
{
return null;
}
var trimmed = handle.TrimStart('@');
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
}