package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/v1/channel-videos") @RequiredArgsConstructor @Tag(name = "Channel Video Curation API", description = "수집한 채널 영상의 분류/필터/관리") public class ChannelVideoCurationController { private final ChannelVideoCurationService curationService; @GetMapping @Operation(summary = "큐레이션 조회", description = "categoryId/status/source(CHANNEL|SEARCH)/shortsOnly/bookmarkedOnly 로 필터, " + "sortBy(viewsPerHour|viewsPerSubRatio|viewCount|publishedAt|durationSec) 로 내림차순 정렬.") public ApiResponse> search( @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String status, @RequestParam(required = false) String source, @RequestParam(defaultValue = "false") boolean shortsOnly, @RequestParam(defaultValue = "false") boolean bookmarkedOnly, @RequestParam(required = false) String sortBy) { return ApiResponse.ok(curationService.search(categoryId, status, source, shortsOnly, bookmarkedOnly, sortBy)); } @GetMapping("/outperformers") @Operation(summary = "떡상 후보 자동 발굴", description = "구독자 대비 조회수 비율이 높은 Shorts 를 자동 선별. limit(기본20), minRatio(기본2.0).") public ApiResponse> outperformers( @RequestParam(required = false) Integer limit, @RequestParam(required = false) java.math.BigDecimal minRatio) { return ApiResponse.ok(curationService.findOutperformers(limit, minRatio)); } @GetMapping("/discover") @Operation(summary = "발굴(Discovery) 조회", description = "필터: periodDays(최근 N일), minRatio(배율 하한), shortsOnly, source(CHANNEL|SEARCH), " + "unprocessedOnly(NEW/REVIEWING만). 정렬 sortBy(기본 viewsPerSubRatio↓), limit(기본100). " + "EXCLUDED 는 항상 제외.") public ApiResponse> discover( @RequestParam(required = false) Integer periodDays, @RequestParam(required = false) java.math.BigDecimal minRatio, @RequestParam(defaultValue = "false") boolean shortsOnly, @RequestParam(required = false) String source, @RequestParam(defaultValue = "false") boolean unprocessedOnly, @RequestParam(required = false) String sortBy, @RequestParam(required = false) Integer limit) { return ApiResponse.ok(curationService.discover(periodDays, minRatio, shortsOnly, source, unprocessedOnly, sortBy, limit)); } @PostMapping("/backfill") @Operation(summary = "기존 수집 영상 지표 백필", description = "새 컬럼 추가 이전에 수집된 영상의 파생 지표/큐레이션 기본값을 재계산(외부 API 호출 없음, 재실행 안전).") public ApiResponse> backfill() { return ApiResponse.ok(curationService.backfillMetrics()); } @GetMapping("/stats") @Operation(summary = "수집/파이프라인 통계", description = "총 수집 수, 상태별/출처별 분포 — 대시보드·칸반 보드용 요약.") public ApiResponse> stats() { return ApiResponse.ok(curationService.pipelineStats()); } @PostMapping("/{id}/category") @Operation(summary = "카테고리 지정/해제", description = "body: {\"categoryId\": 1} — null 또는 미포함 시 분류 해제") public ApiResponse assignCategory(@PathVariable Long id, @RequestBody(required = false) Map body) { Long categoryId = null; if (body != null && body.get("categoryId") != null) { categoryId = ((Number) body.get("categoryId")).longValue(); } return ApiResponse.ok(curationService.assignCategory(id, categoryId)); } @PostMapping("/{id}/bookmark") @Operation(summary = "북마크 설정", description = "body: {\"bookmarked\": true}") public ApiResponse setBookmark(@PathVariable Long id, @RequestBody Map body) { boolean bookmarked = Boolean.TRUE.equals(body.get("bookmarked")); return ApiResponse.ok(curationService.setBookmark(id, bookmarked)); } @PostMapping("/{id}/status") @Operation(summary = "관심 상태 변경", description = "body: {\"status\": \"TARGET\"} — NEW|REVIEWING|TARGET|EXCLUDED") public ApiResponse changeStatus(@PathVariable Long id, @RequestBody Map body) { return ApiResponse.ok(curationService.changeStatus(id, body.get("status"))); } @PostMapping("/{id}/memo") @Operation(summary = "메모 저장", description = "body: {\"memo\": \"...\"}") public ApiResponse updateMemo(@PathVariable Long id, @RequestBody Map body) { return ApiResponse.ok(curationService.updateMemo(id, body.get("memo"))); } @DeleteMapping("/{id}") @Operation(summary = "수집함에서 영상 제거", description = "연결된 스크립트도 함께 삭제된다.") public ApiResponse delete(@PathVariable Long id) { curationService.delete(id); return ApiResponse.ok(null); } // ===== 재가공(재작성) ===== @GetMapping("/{id}") @Operation(summary = "수집 영상 단건 조회", description = "재가공 작업공간용 상세 정보.") public ApiResponse getOne(@PathVariable Long id) { return ApiResponse.ok(curationService.getOne(id)); } @GetMapping("/{id}/script") @Operation(summary = "원본 스크립트 조회", description = "추출된 transcript(평문) + 영상 싱크 segments([{start,end,text}]). 없으면 transcript=\"\", segments=[].") public ApiResponse> getScript(@PathVariable Long id) { return ApiResponse.ok(curationService.getScriptData(id)); } @PostMapping("/{id}/extract-script") @Operation(summary = "원본 스크립트 추출(URL 자막)", description = "외부 transcript 서비스로 YouTube 자막을 추출해 저장한다.") public ApiResponse> extractScript(@PathVariable Long id) { String t = curationService.extractTranscript(id); return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t)); } @PostMapping("/{id}/download") @Operation(summary = "원본 다운로드(캐시만)", description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). " + "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. " + "응답: {downloaded, sizeBytes}. 이후 전사·렌더가 이 캐시 파일을 재사용한다.") public ApiResponse> download(@PathVariable Long id) { return ApiResponse.ok(curationService.downloadOriginal(id)); } @PostMapping("/{id}/transcribe-cached") @Operation(summary = "받은 원본 전사(Whisper)", description = "'원본 다운로드'로 받아둔 캐시 파일을 Whisper 로 전사해 세그먼트를 저장한다(다운로드와 분리). " + "쿼리: language(선택, 비우면 자동감지). 응답: {hasScript, language, duration, transcript, segments}.") public ApiResponse> transcribeCached(@PathVariable Long id, @RequestParam(value = "language", required = false) String language) { return ApiResponse.ok(curationService.transcribeCached(id, language)); } @GetMapping("/{id}/download") @Operation(summary = "받은 원본 상태", description = "원본 다운로드 캐시 보유 여부와 용량을 반환한다. 응답: {cached, sizeBytes}.") public ApiResponse> downloadStatus(@PathVariable Long id) { return ApiResponse.ok(curationService.downloadStatus(id)); } @DeleteMapping("/{id}/download") @Operation(summary = "받은 원본 삭제", description = "원본 다운로드로 받아둔 캐시 mp4를 삭제한다(자막/세그먼트는 유지). 응답: {deleted}.") public ApiResponse> deleteDownload(@PathVariable Long id) { boolean deleted = curationService.deleteDownloadCache(id); return ApiResponse.ok(Map.of("deleted", deleted)); } @GetMapping("/{id}/download/file") @Operation(summary = "받은 원본 스트리밍", description = "받아둔 원본 mp4를 왼쪽 플레이어 재생용으로 스트리밍한다. HTTP Range(구간요청) 지원으로 seek 가능. " + "세그먼트(타임라인) 클릭 시 이 영상이 그 지점으로 이동/재생된다. 캐시 없으면 400.") public ResponseEntity downloadFile(@PathVariable Long id) { java.io.File file = curationService.cachedDownloadFile(id); org.springframework.core.io.Resource resource = new org.springframework.core.io.FileSystemResource(file); return ResponseEntity.ok() .contentType(new MediaType("video", "mp4")) .header(HttpHeaders.ACCEPT_RANGES, "bytes") .body(resource); // Resource 반환 → Spring 이 Range 요청을 자동으로 206 처리 } @PostMapping("/{id}/translate") @Operation(summary = "원본 스크립트 번역 → 재가공 초안", description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). " + "쿼리: target(기본 ko), format(plain=평문 흐름 | timeline=세그먼트별 '[mm:ss] 번역' 줄). " + "소스 언어는 전사 언어로 자동. 응답: {translatedText, source, target, format}.") public ApiResponse> translate(@PathVariable Long id, @RequestParam(value = "target", required = false) String target, @RequestParam(value = "format", required = false) String format) { return ApiResponse.ok(curationService.translateScript(id, target, format)); } @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "업로드 영상 전사(Whisper)", description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. " + "multipart 필드: file, language(선택: ko|en|zh|ja… 비우면 자동감지). " + "응답: {hasScript, language, duration, transcript, segments}.") public ApiResponse> transcribe(@PathVariable Long id, @RequestParam("file") MultipartFile file, @RequestParam(value = "language", required = false) String language) { return ApiResponse.ok(curationService.transcribeFromFile(id, file, language)); } @GetMapping("/{id}/trim-plan") @Operation(summary = "말 없는 구간 제거 미리보기", description = "저장된 세그먼트로 keep/remove 구간과 재매핑된 세그먼트, 예상 길이를 계산(영상 파일 불필요). " + "pad(말 앞뒤 여백초, 기본0.15), minGap(이하 간격은 안 끊음, 기본0.3).") public ApiResponse> trimPlan(@PathVariable Long id, @RequestParam(defaultValue = "0.15") double pad, @RequestParam(defaultValue = "0.3") double minGap) { return ApiResponse.ok(curationService.trimPlan(id, pad, minGap)); } @PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더", description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. " + "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 " + "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).") public ResponseEntity render(@PathVariable Long id, @RequestParam(value = "file", required = false) MultipartFile file, @RequestParam(defaultValue = "0.15") double pad, @RequestParam(defaultValue = "0.3") double minGap, @RequestParam(defaultValue = "1.0") double speed) { byte[] mp4 = (file != null && !file.isEmpty()) ? curationService.renderTrimmed(id, file, pad, minGap, speed) : curationService.renderTrimmedFromCached(id, pad, minGap, speed); HttpHeaders headers = new HttpHeaders(); headers.setContentType(new MediaType("video", "mp4")); headers.setContentDisposition( org.springframework.http.ContentDisposition.attachment() .filename("trimmed_" + id + ".mp4", StandardCharsets.UTF_8).build()); return ResponseEntity.ok().headers(headers).body(mp4); } @GetMapping("/{id}/script.srt") @Operation(summary = "SRT 자막 내보내기", description = "저장된 세그먼트로 SRT 파일을 생성해 다운로드한다. speed(기본 1.0)로 배속 보정. CapCut import 용.") public ResponseEntity exportSrt(@PathVariable Long id, @RequestParam(defaultValue = "1.0") double speed) { String srt = curationService.buildSrt(id, speed); byte[] bytes = srt.getBytes(StandardCharsets.UTF_8); HttpHeaders headers = new HttpHeaders(); headers.setContentType(new MediaType("application", "x-subrip", StandardCharsets.UTF_8)); headers.setContentDisposition( org.springframework.http.ContentDisposition.attachment() .filename("script_" + id + ".srt", StandardCharsets.UTF_8).build()); return ResponseEntity.ok().headers(headers).body(bytes); } @PostMapping("/{id}/rework") @Operation(summary = "재작성 초안 저장", description = "body: {\"reworkText\": \"...\"} — 저장 시 상태가 TARGET 으로 승격된다.") public ApiResponse saveRework(@PathVariable Long id, @RequestBody Map body) { return ApiResponse.ok(curationService.saveRework(id, body.get("reworkText"))); } }