package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 소재 발굴 피드 수집기. * *

SOURCE(웹예능 공식채널)와 RIVAL(경쟁 클립 채널)의 신규 롱폼을 주기적으로 받아온다 * (min-duration-sec 미만의 짧은 클립·쇼츠는 수집하지 않는다). * uploads 플레이리스트 1페이지만 조회하므로 채널당 약 2 units — 검색(search.list 100 units)보다 훨씬 싸다. */ @Slf4j @Service @RequiredArgsConstructor public class FeedCollectionService { /** 채널 1개 수집 추정 쿼터: playlistItems(1) + videos.list(1). */ private static final long EST_UNITS_PER_CHANNEL = 2; private final ChannelRepository channelRepository; private final ChannelService channelService; private final YoutubeQuotaGuard quotaGuard; private final FeedAlertService feedAlertService; @Value("${hlab.feed.enabled:true}") private boolean enabled; /** 이 일수보다 오래된 업로드는 수집하지 않는다(과거 전체를 끌어오지 않기 위함). */ @Value("${hlab.feed.period-days:14}") private int periodDays; /** 이 길이(초) 미만은 수집하지 않는다 — 자를 원본이 못 되는 짧은 클립·쇼츠 차단. 기본 10분. */ @Value("${hlab.feed.min-duration-sec:600}") private int minDurationSec; /** * role 컬럼이 없던 시절의 기존 채널은 role 이 null 이다. 부팅 시 1회 MY 로 백필한다. * (ddl-auto:update 는 DEFAULT 를 채워주지 않으므로 애플리케이션에서 처리) */ @EventListener(ApplicationReadyEvent.class) @Transactional public void backfillChannelRoles() { int updated = channelRepository.backfillNullRoles(); if (updated > 0) log.info("[Feed] 채널 role 백필: {}건 → MY", updated); } @Scheduled(cron = "${hlab.feed.cron:0 15 */3 * * *}") public void scheduledCollect() { if (!enabled) { log.info("[Feed] 피드 자동 수집 비활성화됨 (hlab.feed.enabled=false)"); return; } log.info("[Feed] 자동 수집 완료: {}", collectAll()); } /** 수동/스케줄 공용. 모든 피드 시드를 쿼터 한도 안에서 수집하고 요약을 반환한다. */ public Map collectAll() { return collect(null); } /** 특정 주제만 즉시 수집한다. null은 스케줄러용 전체 수집이다. */ public Map collect(FeedTopic topic) { List seeds = channelRepository.findFeedSeeds().stream() .filter(c -> topic == null || c.feedTopicOrDefault() == topic) .toList(); LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays); int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0; List goldenCandidates = new java.util.ArrayList<>(); for (Channel c : seeds) { if (c.isFeedDisabled()) { disabled++; continue; } if (!quotaGuard.tryConsume(EST_UNITS_PER_CHANNEL)) { skippedByQuota++; log.warn("[Feed] 쿼터 예산 소진 — 채널 {} 이후 건너뜀 (잔여 {} units)", c.getTitle(), quotaGuard.remaining()); continue; } try { List newOnes = new java.util.ArrayList<>(); saved += channelService.collectFeedVideos(c, publishedAfter, newOnes); // 경쟁(RIVAL) 채널의 신규 영상은 경쟁자의 결과물이라 골든타임 알림 대상이 아니다 if (ChannelRole.SOURCE.equals(c.roleOrDefault())) goldenCandidates.addAll(newOnes); markSuccess(c.getId()); ok++; } catch (Exception e) { failed++; markFailure(c.getId()); log.error("[Feed] 채널 {}({}) 수집 실패", c.getTitle(), c.getChannelId(), e); } } int goldenAlerted = feedAlertService.notifyGoldenTime("소재 피드", goldenCandidates); Map summary = new LinkedHashMap<>(); summary.put("seeds", seeds.size()); summary.put("collected", ok); summary.put("savedVideos", saved); summary.put("failed", failed); summary.put("autoDisabled", disabled); summary.put("skippedByQuota", skippedByQuota); summary.put("goldenAlerted", goldenAlerted); summary.put("quotaRemaining", quotaGuard.remaining()); return summary; } /** * 수집 성공 표시(실패 카운터 리셋). * 같은 빈 안에서 호출되므로 @Transactional 프록시가 적용되지 않는다 — 명시적으로 save 한다. */ private void markSuccess(Long channelId) { channelRepository.findById(channelId).ifPresent(c -> { if (c.feedFailCountOrZero() == 0) return; c.resetFeedFailure(); channelRepository.save(c); }); } /** 수집 실패 누적. 3회 연속이면 다음 수집부터 자동 스킵된다. */ private void markFailure(Long channelId) { channelRepository.findById(channelId).ifPresent(c -> { c.recordFeedFailure(); channelRepository.save(c); }); } }