diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java index 06bf9ea..ea0a04a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java @@ -19,7 +19,7 @@ import java.util.List; import java.util.Map; import java.util.Set; -/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ +/** 지역 인기 영상(롱폼/쇼츠) 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ @Slf4j @Service @RequiredArgsConstructor @@ -43,9 +43,24 @@ public class ChannelDiscoveryService { private double minRatio; @Value("${hlab.discovery.period-days:14}") private int periodDays; + /** 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. */ + @Value("${hlab.discovery.format:LONG_FORM}") + private String defaultFormat; + /** 스케줄러 등 기본 호출 — 설정된 기본 포맷(hlab.discovery.format)으로 발굴. */ @Transactional public Map runDiscovery() { + return runDiscovery(null); + } + + /** + * 발굴 실행. + * @param format "SHORTS" 또는 "LONG_FORM". null/blank/미인식이면 설정 기본값(defaultFormat)을 사용. + */ + @Transactional + public Map runDiscovery(String format) { + String fmt = (format == null || format.isBlank()) ? defaultFormat : format.trim().toUpperCase(); + if (!"SHORTS".equals(fmt) && !"LONG_FORM".equals(fmt)) fmt = defaultFormat; List regions = Arrays.stream(regionsCsv.split(",")) .map(String::trim).filter(s -> !s.isBlank()).toList(); // 예능 특화 발굴용 키워드 시드. 비어있으면(설정 미지정) 추가 검색 없이 기존 동작과 동일. @@ -66,7 +81,7 @@ public class ChannelDiscoveryService { try { YoutubeSearchCondition cond = new YoutubeSearchCondition(); cond.setRegions(List.of(region)); - cond.setFormat("SHORTS"); + cond.setFormat(fmt); cond.setPeriodDays(periodDays); cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순 // 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 시 검색 API 보강 필요(스펙 §4.2). @@ -92,7 +107,7 @@ public class ChannelDiscoveryService { YoutubeSearchCondition cond = new YoutubeSearchCondition(); cond.setKeyword(kw); cond.setRegions(List.of(region)); - cond.setFormat("SHORTS"); + cond.setFormat(fmt); cond.setPeriodDays(periodDays); cond.setOrder("viewCount"); // 떡상 발굴: 조회수순 YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond); @@ -138,6 +153,7 @@ public class ChannelDiscoveryService { } Map summary = new LinkedHashMap<>(); + summary.put("format", fmt); summary.put("regions", searchedRegions); summary.put("keywords", keywords); summary.put("keywordSearches", keywordSearches); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java index 2f5cd0e..afa167d 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.List; import java.util.Map; @@ -19,6 +20,7 @@ public class RecommendedChannelController { private final RecommendedChannelRepository repository; private final ChannelDiscoveryService discoveryService; private final ChannelService channelService; + private final ChannelVideoRepository channelVideoRepository; @GetMapping @Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환") @@ -60,9 +62,31 @@ public class RecommendedChannelController { return ApiResponse.ok(null); } + @PostMapping("/{id}/rework") + @Operation(summary = "대표 영상 재가공", description = "추천 채널의 대표(떡상) 영상을 수집함(ChannelVideo)에 적재하고 재가공 작업공간으로 보낼 id를 반환") + @Transactional + public ApiResponse> rework(@PathVariable Long id) { + RecommendedChannel rc = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id)); + String videoId = rc.getTopVideoId(); + if (videoId == null || videoId.isBlank()) { + throw new IllegalArgumentException("대표 영상이 없어 재가공할 수 없습니다: " + id); + } + ChannelVideo video = channelVideoRepository.findByVideoId(videoId) + .orElseGet(() -> { + BigDecimal ratio = rc.getRatio() == null ? null : BigDecimal.valueOf(rc.getRatio()); + ChannelVideo v = ChannelVideo.fromSearch( + videoId, rc.getTopVideoTitle(), rc.getThumbnailUrl(), null, + rc.getTopVideoViewCount(), rc.getChannelId(), rc.getChannelTitle(), + rc.getSubscriberCount(), null, null, ratio, null); + return channelVideoRepository.save(v); + }); + return ApiResponse.ok(Map.of("videoId", video.getId())); + } + @PostMapping("/run") - @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행") - public ApiResponse> run() { - return ApiResponse.ok(discoveryService.runDiscovery()); + @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행. format=LONG_FORM(기본)|SHORTS") + public ApiResponse> run(@RequestParam(required = false) String format) { + return ApiResponse.ok(discoveryService.runDiscovery(format)); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 42ff788..2bbfd3e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -97,6 +97,8 @@ hlab: # 예능 특화 시드 키워드(쉼표구분). 각 키워드×지역으로 Shorts 검색해 예능 클립 소재를 우선 발굴. # 빈 값이면 키워드 없는 기존 '지역 인기 Shorts' 발굴로 동작. keywords: ${DISCOVERY_KEYWORDS:예능,방송,하이라이트} + # 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능. + format: ${DISCOVERY_FORMAT:LONG_FORM} # 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op. notify: diff --git a/src/main/resources/templates/recommend.html b/src/main/resources/templates/recommend.html index 98e36c2..0eb6f35 100644 --- a/src/main/resources/templates/recommend.html +++ b/src/main/resources/templates/recommend.html @@ -7,10 +7,14 @@ @@ -91,6 +95,7 @@ const actions = excludedView ? '' + moveBtn : '' + moveBtn + + '' + ''; return '
' + '' @@ -107,6 +112,11 @@ async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } } async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } } async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } } + async function rework(id, btn){ + if(btn){ btn.disabled=true; btn.innerHTML=' 준비 중…'; if(window.lucide) lucide.createIcons(); } + try{ const d=await api(API+'/'+id+'/rework',{method:'POST'}); location.href='/rework/'+d.videoId; } + catch(e){ alert('재가공 준비 실패: '+e.message); if(btn){ btn.disabled=false; btn.innerHTML=' 영상 재가공'; if(window.lucide) lucide.createIcons(); } } + } function openVideoModal(el){ const vid = el.dataset.vid || ''; document.getElementById('modalTitle').textContent = el.dataset.title || ''; @@ -120,8 +130,10 @@ document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); }); async function runDiscovery(){ - const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)'; - try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); } + const fmt=(document.getElementById('discFormat')||{}).value||'LONG_FORM'; + const fmtKo=(fmt==='SHORTS')?'쇼츠':'롱폼'; + const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… ('+fmtKo+' 검색, 잠시 걸립니다)'; + try{ const r=await api(API+'/run?format='+encodeURIComponent(fmt),{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · '+((r.format==='SHORTS')?'쇼츠':'롱폼')+' · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); } catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; } } load();