h-lab/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java
hehihoho3@gmail.com 447645c19a feat(rework): AI 자막 2종(음성 Whisper / 화면 Gemini)→한국어, 좌우 분할 + SRT
재가공 화면에 'AI 자막(음성/화면)' 카드 추가. '생성' 한 번에:
- 왼쪽(음성): 저장된 Whisper 세그먼트(정밀 타임스탬프) → LibreTranslate 한국어
- 오른쪽(화면): 유튜브 URL → Gemini가 화면 박힌 자막 추출+한국어 번역
각 패널을 [00:00] 한국어 리스트로 표시, 각각 SRT 다운로드(audio_ko/screen_ko).

- GeminiSubtitleService 신규: youtube URL을 generativelanguage API에 전송,
  responseMimeType=json 구조화 응답 파싱. buildRequest/extractSegments/sanitizeApiKey 순수+단위테스트
- sanitizeApiKey: 잘못 붙은 선행 '='·공백 정리(env var 오타 방어)
- geminiRestTemplate 빈(5분), POST /{id}/gemini-subtitles, CurationService.geminiScreenSubtitles
- 설정 gemini.api-key/model(gemini-2.5-flash), rework.html 분할 카드+JS
- 음성 한국어는 기존 translate(format=segments) 재사용

검증: 470 화면자막 → 한국어 27세그먼트 30초 추출(스타일 일본어 자막도 정확).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:04:24 +09:00

263 lines
16 KiB
Java

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<List<ChannelVideo>> 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<List<ChannelVideo>> 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<List<ChannelVideo>> 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<Map<String, Object>> backfill() {
return ApiResponse.ok(curationService.backfillMetrics());
}
@GetMapping("/stats")
@Operation(summary = "수집/파이프라인 통계",
description = "총 수집 수, 상태별/출처별 분포 — 대시보드·칸반 보드용 요약.")
public ApiResponse<Map<String, Object>> stats() {
return ApiResponse.ok(curationService.pipelineStats());
}
@PostMapping("/{id}/category")
@Operation(summary = "카테고리 지정/해제", description = "body: {\"categoryId\": 1} — null 또는 미포함 시 분류 해제")
public ApiResponse<ChannelVideo> assignCategory(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> 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<ChannelVideo> setBookmark(@PathVariable Long id, @RequestBody Map<String, Object> 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<ChannelVideo> changeStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.changeStatus(id, body.get("status")));
}
@PostMapping("/{id}/memo")
@Operation(summary = "메모 저장", description = "body: {\"memo\": \"...\"}")
public ApiResponse<ChannelVideo> updateMemo(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.updateMemo(id, body.get("memo")));
}
@DeleteMapping("/{id}")
@Operation(summary = "수집함에서 영상 제거", description = "연결된 스크립트도 함께 삭제된다.")
public ApiResponse<Void> delete(@PathVariable Long id) {
curationService.delete(id);
return ApiResponse.ok(null);
}
// ===== 재가공(재작성) =====
@GetMapping("/{id}")
@Operation(summary = "수집 영상 단건 조회", description = "재가공 작업공간용 상세 정보.")
public ApiResponse<ChannelVideo> 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<Map<String, Object>> getScript(@PathVariable Long id) {
return ApiResponse.ok(curationService.getScriptData(id));
}
@PostMapping("/{id}/extract-script")
@Operation(summary = "원본 스크립트 추출(URL 자막)", description = "외부 transcript 서비스로 YouTube 자막을 추출해 저장한다.")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> downloadStatus(@PathVariable Long id) {
return ApiResponse.ok(curationService.downloadStatus(id));
}
@DeleteMapping("/{id}/download")
@Operation(summary = "받은 원본 삭제", description = "원본 다운로드로 받아둔 캐시 mp4를 삭제한다(자막/세그먼트는 유지). 응답: {deleted}.")
public ApiResponse<Map<String, Object>> 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<org.springframework.core.io.Resource> 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}/gemini-subtitles")
@Operation(summary = "화면 자막(Gemini, 한국어)",
description = "저장된 유튜브 URL을 Gemini에 보내 화면에 박힌 자막을 추출·한국어 번역해 세그먼트로 반환한다(저장 안 함). "
+ "음성 자막은 전사+번역(왼쪽)이 담당. 응답: {segments:[{start,end,text(한국어)}]}. "
+ "타임스탬프는 Gemini 특성상 ~1초 근사.")
public ApiResponse<Map<String, Object>> geminiSubtitles(@PathVariable Long id) {
return ApiResponse.ok(curationService.geminiScreenSubtitles(id));
}
@PostMapping("/{id}/translate")
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
+ "쿼리: target(기본 ko), format(plain=평문 흐름 | timeline=세그먼트별 '[mm:ss] 번역' 줄). "
+ "소스 언어는 전사 언어로 자동. 응답: {translatedText, source, target, format}.")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<byte[]> 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<byte[]> 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<ChannelVideo> saveRework(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.saveRework(id, body.get("reworkText")));
}
}