package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.domain.channel.dto.FeedItemDto; import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto; import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto; import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** 소재 발굴 피드 조회 + 시드(소스/경쟁 채널) · 추적 인물 관리. */ @Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class FeedService { /** 한 번에 내려줄 카드 수 상한. */ private static final int MAX_ITEMS = 200; /** 인물 탭 식별자. SOURCE/RIVAL 과 달리 채널 역할이 아니라 조회 방식이 다르다. */ public static final String TAB_PERSON = "PERSON"; private final ChannelRepository channelRepository; private final ChannelVideoRepository channelVideoRepository; private final ChannelService channelService; private final TrackedPersonRepository trackedPersonRepository; @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; /** * 피드 카드 목록(항상 최신순). * * @param tab SOURCE | RIVAL * @param days 최근 N일 (null 이면 제한 없음) * @param ytChannelId 특정 프로그램만 (null/빈값이면 전체) * @param lengthBucket CLIP | FULL | SHORTS (null 이면 전체) * @param hideWorked 이미 손댄 소재 숨기기 */ public List feed(String tab, Integer days, String ytChannelId, String lengthBucket, boolean hideWorked) { return feed(tab, FeedTopic.ENTERTAINMENT.name(), days, ytChannelId, lengthBucket, hideWorked, null); } /** * @param person 인물 탭에서 특정 인물만 (null/빈값이면 전체) */ public List feed(String tab, String topic, Integer days, String ytChannelId, String lengthBucket, boolean hideWorked, String person) { String source = normalizeTab(tab); String normalizedTopic = FeedTopic.normalize(topic).name(); LocalDateTime now = LocalDateTime.now(); LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days); String channelFilter = (ytChannelId == null || ytChannelId.isBlank()) ? null : ytChannelId; if (TAB_PERSON.equals(source)) { String personFilter = (person == null || person.isBlank()) ? null : person; List rows = channelVideoRepository.feedByPerson( publishedAfter, personFilter, hideWorked, PageRequest.of(0, MAX_ITEMS)); List out = new ArrayList<>(rows.size()); for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now)); return out; } Integer minSec = null, maxSec = null; if (lengthBucket != null && !lengthBucket.isBlank()) { switch (lengthBucket.trim().toUpperCase()) { case FeedBadges.BUCKET_SHORTS -> maxSec = 65; case FeedBadges.BUCKET_CLIP -> { minSec = 66; maxSec = FeedBadges.CLIP_MAX_SEC; } case FeedBadges.BUCKET_FULL -> minSec = FeedBadges.CLIP_MAX_SEC + 1; default -> { /* 미인식 값은 필터 없음 */ } } } // 수집 하한을 화면에도 적용 — 하한을 올렸을 때 과거에 담긴 짧은 영상이 남아 보이지 않게 int topicFloor = FeedTopic.POLITICS.name().equals(normalizedTopic) ? 66 : minDurationSec; minSec = (minSec == null) ? Integer.valueOf(topicFloor) : Integer.valueOf(Math.max(minSec, topicFloor)); List rows = channelVideoRepository.feed( source, normalizedTopic, publishedAfter, channelFilter, minSec, maxSec, hideWorked, PageRequest.of(0, MAX_ITEMS)); List out = new ArrayList<>(rows.size()); for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now)); return out; } /** 필터 드롭다운용 프로그램(채널) 목록. */ public List programs(String tab, String topic) { String source = normalizeTab(tab); String normalizedTopic = FeedTopic.normalize(topic).name(); List out = new ArrayList<>(); for (Object[] row : channelVideoRepository.feedPrograms(source, normalizedTopic)) { out.add(new FeedProgramDto((String) row[0], (String) row[1], (Long) row[2])); } return out; } /** 등록된 피드 시드 목록(소스 + 경쟁). */ public List seeds(String topic) { FeedTopic normalizedTopic = FeedTopic.normalize(topic); List out = new ArrayList<>(); for (Channel c : channelRepository.findFeedSeeds()) { if (c.feedTopicOrDefault() != normalizedTopic) continue; out.add(new FeedSeedDto(c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(), c.getSubscriberCount(), c.roleOrDefault(), c.feedTopicOrDefault().name(), c.feedFormatOrDefault().name(), c.feedFailCountOrZero(), c.isFeedDisabled())); } return out; } /** * URL 로 시드 채널을 등록한다. 이미 등록된 채널이면 역할만 바꾼다. * * @param role SOURCE | RIVAL */ @Transactional public FeedSeedDto addSeed(String url, String role, String topic, String format) { String normalized = ChannelRole.normalize(role); if (!ChannelRole.isFeed(normalized)) { throw new IllegalArgumentException("시드 역할은 SOURCE 또는 RIVAL 이어야 합니다: " + role); } Channel channel = channelService.saveChannelFromUrl(url); channel.changeRole(normalized); channel.configureFeed(topic, format); channel.resetFeedFailure(); channelRepository.save(channel); return new FeedSeedDto(channel.getId(), channel.getChannelId(), channel.getTitle(), channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized, channel.feedTopicOrDefault().name(), channel.feedFormatOrDefault().name(), 0, false); } /** 시드 해제 — 채널과 그 채널에서 수집한 피드 영상을 함께 제거한다. */ @Transactional public void removeSeed(Long channelId) { Channel c = channelService.getChannel(channelId); if (!ChannelRole.isFeed(c.roleOrDefault())) { throw new IllegalArgumentException("피드 시드가 아닙니다: " + channelId); } channelService.deleteChannel(channelId); } /** 연속 실패로 자동 비활성화된 시드를 다시 활성화한다. */ @Transactional public void resetSeedFailure(Long channelId) { Channel c = channelService.getChannel(channelId); c.resetFeedFailure(); channelRepository.save(c); } private String normalizeTab(String tab) { String t = tab == null ? "" : tab.trim().toUpperCase(); if (ChannelRole.RIVAL.equals(t)) return ChannelRole.RIVAL; if (TAB_PERSON.equals(t)) return TAB_PERSON; return ChannelRole.SOURCE; } // --- 인물 추적 --- /** 인물 탭 필터용 목록: 추적 중인 인물 + 지금까지 담긴 영상 수. */ public List persons() { Map counts = new LinkedHashMap<>(); for (Object[] row : channelVideoRepository.feedPersons()) { counts.put((String) row[0], (Long) row[1]); } List out = new ArrayList<>(); for (TrackedPerson p : trackedPersonRepository.findAllByOrderByIdAsc()) { out.add(new TrackedPersonDto(p.getId(), p.getName(), p.isEnabled(), counts.getOrDefault(p.getName(), 0L), p.getLastCollectedAt(), p.getLastFound())); } return out; } /** 추적 인물 추가. 활성 인원 상한을 넘으면 거절한다(1명당 검색 100 units). */ @Transactional public TrackedPersonDto addPerson(String name) { String trimmed = name == null ? "" : name.trim(); if (trimmed.isEmpty()) throw new IllegalArgumentException("인물 이름이 필요합니다."); if (trackedPersonRepository.existsByName(trimmed)) { throw new IllegalArgumentException("이미 추적 중인 인물입니다: " + trimmed); } if (trackedPersonRepository.countByEnabledTrue() >= maxPeople) { throw new IllegalArgumentException( "추적 인물은 최대 " + maxPeople + "명입니다. 1명당 검색 100 units 를 쓰기 때문입니다. " + "기존 인물을 끄거나 삭제한 뒤 추가하세요."); } TrackedPerson saved = trackedPersonRepository.save(new TrackedPerson(trimmed)); return new TrackedPersonDto(saved.getId(), saved.getName(), true, 0L, null, null); } /** 추적 on/off. 끄면 수집 대상에서 빠지지만 이미 담은 영상은 남는다. */ @Transactional public void togglePerson(Long id, boolean enabled) { TrackedPerson p = trackedPersonRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("추적 인물을 찾을 수 없습니다: " + id)); if (enabled && !p.isEnabled() && trackedPersonRepository.countByEnabledTrue() >= maxPeople) { throw new IllegalArgumentException("활성 인물이 이미 " + maxPeople + "명입니다."); } p.setEnabled(enabled); trackedPersonRepository.save(p); } /** 추적 해제. 그 인물로 담은 영상의 표시만 지우고 영상 자체는 남긴다. */ @Transactional public void removePerson(Long id) { TrackedPerson p = trackedPersonRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("추적 인물을 찾을 수 없습니다: " + id)); trackedPersonRepository.delete(p); } }