h-lab/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java
hehihoho3@gmail.com d4547056e3 feat: 소재 피드 10분 이상만 수집·표시 (min-duration-sec)
피드 수집 필터를 쇼츠/롱폼 구분에서 최소 길이(초) 기준으로 교체.
기본 600초(10분) — FEED_MIN_DURATION_SEC 로 조정 가능.
화면 조회에도 같은 하한을 적용해 이미 담긴 10분 미만 영상도
소스·경쟁 탭에서 바로 사라진다. 길이 필터 라벨(공식클립 10~15분)도 정리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:42:02 +09:00

127 lines
5.0 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;
@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() {
List<Channel> seeds = channelRepository.findFeedSeeds();
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
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 {
saved += channelService.collectFeedVideos(c, publishedAfter, minDurationSec);
markSuccess(c.getId());
ok++;
} catch (Exception e) {
failed++;
markFailure(c.getId());
log.error("[Feed] 채널 {}({}) 수집 실패", c.getTitle(), c.getChannelId(), e);
}
}
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("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);
});
}
}