feat: 소재 피드 10분 이상만 수집·표시 (min-duration-sec)

피드 수집 필터를 쇼츠/롱폼 구분에서 최소 길이(초) 기준으로 교체.
기본 600초(10분) — FEED_MIN_DURATION_SEC 로 조정 가능.
화면 조회에도 같은 하한을 적용해 이미 담긴 10분 미만 영상도
소스·경쟁 탭에서 바로 사라진다. 길이 필터 라벨(공식클립 10~15분)도 정리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-07 12:42:02 +09:00
parent e964cecceb
commit d4547056e3
5 changed files with 28 additions and 16 deletions

View File

@ -299,13 +299,13 @@ public class ChannelService {
/**
* videos.list 상세를 받아 ChannelVideo upsert 한다.
*
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 쇼츠)
* @param shortsOnly null 이면 전체, TRUE Shorts , FALSE 롱폼만 저장
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 채널)
* @param minDurationSec 길이() 미만이거나 길이를 모르는 영상은 건너뜀(null 이면 전체)
* @param publishedAfter 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
* @return 저장·갱신한 영상
*/
private int upsertVideos(Channel channel, List<String> videoIds, String source,
Boolean shortsOnly, LocalDateTime publishedAfter) {
Integer minDurationSec, LocalDateTime publishedAfter) {
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
// status 임베드 가능 여부(embeddable) 때문에 필요하다. part 늘려도 쿼터는 그대로다.
.queryParam("part", "snippet,statistics,contentDetails,status")
@ -339,8 +339,8 @@ public class ChannelService {
Integer durationSec = VideoMetrics.parseDurationSec(duration);
Boolean isShorts = VideoMetrics.isShorts(durationSec);
// 포맷/기간 필터 피드 수집에서만 사용(일반 채널 수집은 null)
if (shortsOnly != null && shortsOnly != isShorts) continue;
// 길이/기간 필터 피드 수집에서만 사용(일반 채널 수집은 null)
if (minDurationSec != null && (durationSec == null || durationSec < minDurationSec)) continue;
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
@ -390,15 +390,16 @@ public class ChannelService {
/**
* 피드 시드 채널의 최근 업로드를 1페이지(최대 50건) 수집한다.
* 역할과 무관하게 롱폼만 저장한다 쇼츠는 이미 잘린 결과물이라 소재가 아니.
* 역할과 무관하게 {@code minDurationSec} 이상 롱폼만 저장한다 짧은 클립·쇼츠는 자를 원본이 .
*
* @param channel SOURCE 또는 RIVAL 역할의 채널
* @param publishedAfter 시각 이후 업로드만 수집
* @param minDurationSec 길이() 미만은 수집하지 않음
* @return 저장·갱신된 영상
* @throws IllegalStateException uploads 플레이리스트를 찾을 없을
*/
@Transactional
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter) {
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter, int minDurationSec) {
String role = channel.roleOrDefault();
String uploadsPlaylistId = channel.getUploadsPlaylistId();
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
@ -422,9 +423,8 @@ public class ChannelService {
}
if (videoIds.isEmpty()) return 0;
// 피드는 롱폼만 수집한다 (쇼츠는 이미 잘린 결과물 자를 원본이 아니다)
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
return upsertVideos(channel, videoIds, source, Boolean.FALSE, publishedAfter);
return upsertVideos(channel, videoIds, source, minDurationSec, publishedAfter);
}

View File

@ -18,7 +18,8 @@ import java.util.Map;
/**
* 소재 발굴 피드 수집기.
*
* <p>SOURCE(웹예능 공식채널) RIVAL(경쟁 클립 채널) 신규 롱폼을 주기적으로 받아온다(쇼츠는 수집하지 않는다).
* <p>SOURCE(웹예능 공식채널) RIVAL(경쟁 클립 채널) 신규 롱폼을 주기적으로 받아온다
* (min-duration-sec 미만의 짧은 클립·쇼츠는 수집하지 않는다).
* uploads 플레이리스트 1페이지만 조회하므로 채널당 2 units 검색(search.list 100 units)보다 훨씬 싸다.
*/
@Slf4j
@ -40,6 +41,10 @@ public class FeedCollectionService {
@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 채워주지 않으므로 애플리케이션에서 처리)
@ -78,7 +83,7 @@ public class FeedCollectionService {
continue;
}
try {
saved += channelService.collectFeedVideos(c, publishedAfter);
saved += channelService.collectFeedVideos(c, publishedAfter, minDurationSec);
markSuccess(c.getId());
ok++;
} catch (Exception e) {

View File

@ -37,6 +37,10 @@ public class FeedService {
@org.springframework.beans.factory.annotation.Value("${hlab.feed.person.max-people:10}")
private int maxPeople;
/** 수집 하한과 같은 값 — 이미 담겨 있는 더 짧은 영상도 화면에서 걸러 일관되게 보인다. */
@org.springframework.beans.factory.annotation.Value("${hlab.feed.min-duration-sec:600}")
private int minDurationSec;
/**
* 피드 카드 목록(항상 최신순).
*
@ -79,6 +83,8 @@ public class FeedService {
default -> { /* 미인식 값은 필터 없음 */ }
}
}
// 수집 하한을 화면에도 적용 하한을 올렸을 과거에 담긴 짧은 영상이 남아 보이지 않게
minSec = (minSec == null) ? Integer.valueOf(minDurationSec) : Integer.valueOf(Math.max(minSec, minDurationSec));
List<ChannelVideo> rows = channelVideoRepository.feed(
source, publishedAfter, channelFilter, minSec, maxSec, hideWorked,

View File

@ -100,11 +100,12 @@ hlab:
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
format: ${DISCOVERY_FORMAT:LONG_FORM}
# 소재 발굴 피드: 소스(웹예능 공식채널)·경쟁(클립 채널)의 신규 롱폼만 최신순으로 수집 (쇼츠 제외)
# 소재 발굴 피드: 소스(웹예능 공식채널)·경쟁(클립 채널)의 신규 롱폼만 최신순으로 수집 (10분 미만 제외)
feed:
enabled: ${FEED_ENABLED:true}
cron: ${FEED_CRON:0 15 */3 * * *} # 3시간마다 (소재 선점은 업로드 후 몇 시간이 승부)
period-days: ${FEED_PERIOD_DAYS:14} # 이보다 오래된 업로드는 수집하지 않음
min-duration-sec: ${FEED_MIN_DURATION_SEC:600} # 이보다 짧은 영상은 수집·표시하지 않음 (기본 10분)
seed:
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
min-tag-count: ${FEED_SEED_MIN_TAG_COUNT:2} # 이 횟수 미만 해시태그는 프로그램명으로 보지 않음

View File

@ -11,7 +11,7 @@
<div class="page-header">
<div>
<h1>소재 피드</h1>
<p class="sub">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼 업로드를 최신순으로 봅니다.</p>
<p class="sub">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼(10분 이상) 업로드를 최신순으로 봅니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openPersons()">
@ -64,7 +64,7 @@
<label class="field" for="fLength" id="lengthField">길이
<select id="fLength" onchange="applyFilters()">
<option value="">전체</option>
<option value="CLIP">공식클립 (~15분)</option>
<option value="CLIP">공식클립 (10~15분)</option>
<option value="FULL">풀에피 (15분+)</option>
</select>
</label>
@ -102,7 +102,7 @@
<div class="modal-body">
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
<b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널,
<b>경쟁 채널</b>은 같은 소재를 다루는 예능짤 채널입니다. 두 역할 모두 롱폼만 수집합니다.
<b>경쟁 채널</b>은 같은 소재를 다루는 예능짤 채널입니다. 두 역할 모두 10분 이상 롱폼만 수집합니다.
</p>
<div class="seed-add">
@ -645,7 +645,7 @@
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
} else {
showState('inbox', '아직 소재가 없습니다',
'경쟁 예능짤 채널을 시드로 등록한 뒤 수집하세요. (롱폼만 수집됩니다)',
'경쟁 예능짤 채널을 시드로 등록한 뒤 수집하세요. (10분 이상만 수집됩니다)',
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
}