h-lab/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java

223 lines
10 KiB
Java

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<FeedItemDto> 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<FeedItemDto> 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<ChannelVideo> rows = channelVideoRepository.feedByPerson(
publishedAfter, personFilter, hideWorked, PageRequest.of(0, MAX_ITEMS));
List<FeedItemDto> 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<ChannelVideo> rows = channelVideoRepository.feed(
source, normalizedTopic, publishedAfter, channelFilter, minSec, maxSec, hideWorked,
PageRequest.of(0, MAX_ITEMS));
List<FeedItemDto> out = new ArrayList<>(rows.size());
for (ChannelVideo v : rows) out.add(FeedItemDto.from(v, now));
return out;
}
/** 필터 드롭다운용 프로그램(채널) 목록. */
public List<FeedProgramDto> programs(String tab, String topic) {
String source = normalizeTab(tab);
String normalizedTopic = FeedTopic.normalize(topic).name();
List<FeedProgramDto> 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<FeedSeedDto> seeds(String topic) {
FeedTopic normalizedTopic = FeedTopic.normalize(topic);
List<FeedSeedDto> 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<TrackedPersonDto> persons() {
Map<String, Long> counts = new LinkedHashMap<>();
for (Object[] row : channelVideoRepository.feedPersons()) {
counts.put((String) row[0], (Long) row[1]);
}
List<TrackedPersonDto> 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);
}
}