package com.hlab.yanalyst.domain.channel;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 인물 검색 결과에서 실제로 담을 영상을 고르는 순수 로직.
*
*
검색은 최신순 50건을 주는데 대부분이 쇼츠다. 롱폼만 남기고, 내 채널·경쟁 채널 것은
* 소재가 아니므로 뺀다.
*/
public final class PersonPicks {
/**
* 제목에 이게 들어가면 버린다. 아이돌 이름으로 검색하면 음악방송 직캠·무대·교차편집이
* 결과를 뒤덮어 정작 예능 출연분이 묻힌다. 말로 터지는 순간이 소재이므로 무대 영상은 소용없다.
*/
private static final List STAGE_MARKERS = List.of(
"직캠", "페이스캠", "풀캠", "fancam", "focus cam",
"교차편집", "stage mix", "stagemix", "무대교차",
"음중", "쇼챔", "엠카", "뮤직뱅크", "인기가요", "music bank", "show champion",
"performance video", "mv", "m/v", "lyric video", "cover", "커버"
);
/**
* 이 길이 이하는 쇼츠로 보고 버린다. 유튜브 쇼츠가 최대 3분(180초)까지 허용되어
* {@link VideoMetrics#isShorts}의 65초 컷으로는 1~3분짜리 쇼츠가 통과한다.
* 어차피 3분 이하는 자를 원본으로도 부족하다.
*/
public static final int SHORTS_MAX_SEC = 180;
private PersonPicks() {}
/** 음악방송 무대·직캠·커버 영상인가(제목 기준). */
public static boolean isStageVideo(String title) {
if (title == null || title.isBlank()) return false;
String t = title.toLowerCase(java.util.Locale.ROOT);
for (String marker : STAGE_MARKERS) {
if (t.contains(marker)) return true;
}
return false;
}
/**
* 검색 결과 1건.
*
* @param ytChannelId 업로드 채널. 내 채널/경쟁 채널 제외 판정에 쓴다
*/
public record Found(String videoId, String title, String ytChannelId, String channelTitle,
Integer durationSec, LocalDateTime publishedAt, Long viewCount) {}
/**
* 담을 영상만 고른다.
*
* @param found 검색 결과
* @param excludeChannels 제외할 채널 ID(내 채널 OWN, 경쟁 채널 RIVAL)
* @param publishedAfter 이 시각 이전 업로드는 제외(null 이면 제한 없음)
* @param minViewsPerHour 시간당 조회수 하한. 인물명 검색에는 팬채널·커버곡·개인 브이로그가
* 대량으로 딸려 오는데(실측 조회수 30회 미만), 생 조회수로 자르면 방금 올라온
* 영상까지 걸리므로 시간당 조회수로 거른다. 0 이하면 미적용
* @return 롱폼이면서 무대 영상이 아니고 제외 대상도 아닌 것들 (입력 순서 유지)
*/
public static List keep(List found, Set excludeChannels,
LocalDateTime publishedAfter, double minViewsPerHour) {
List out = new ArrayList<>();
if (found == null) return out;
for (Found f : found) {
if (f == null || f.videoId() == null || f.videoId().isBlank()) continue;
// 쇼츠 제외 — 인물 추적의 목적은 자를 원본(롱폼)을 찾는 것
if (f.durationSec() == null || f.durationSec() <= SHORTS_MAX_SEC) continue;
if (f.ytChannelId() != null && excludeChannels != null
&& excludeChannels.contains(f.ytChannelId())) continue;
if (publishedAfter != null && f.publishedAt() != null
&& f.publishedAt().isBefore(publishedAfter)) continue;
if (isStageVideo(f.title())) continue;
if (minViewsPerHour > 0 && !meetsVelocity(f, minViewsPerHour)) continue;
out.add(f);
}
return out;
}
/** 시간당 조회수가 하한 이상인가. 계산에 필요한 값이 없으면 통과시킨다(과도한 배제 방지). */
private static boolean meetsVelocity(Found f, double minViewsPerHour) {
if (f.viewCount() == null || f.publishedAt() == null) return true;
java.math.BigDecimal vph = VideoMetrics.viewsPerHour(f.viewCount(), f.publishedAt());
if (vph == null) return true;
return vph.doubleValue() >= minViewsPerHour;
}
}