h-lab/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java

142 lines
5.8 KiB
Java

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;
/**
* 소재 발굴 피드 수집기.
*
* <p>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<String, Object> collectAll() {
return collect(null);
}
/** 특정 주제만 즉시 수집한다. null은 스케줄러용 전체 수집이다. */
public Map<String, Object> collect(FeedTopic topic) {
List<Channel> 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<ChannelVideo> 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<ChannelVideo> 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<String, Object> 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);
});
}
}