142 lines
5.8 KiB
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);
|
|
});
|
|
}
|
|
}
|