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; } }