From 26b8be1ff161ecd2f32766e3c4bb53e49a659815 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Wed, 24 Jun 2026 15:58:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(rework):=20yt-dlp=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=E2=86=92=20=EC=A0=84?= =?UTF-8?q?=EC=82=AC=C2=B7=EB=A0=8C=EB=8D=94=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9E=90=EB=8F=99=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재가공 화면 '원본 다운로드' 버튼으로 저장된 videoId를 yt-dlp(로컬 ProcessBuilder)로 받아 서버에 캐시하고, 그 파일을 기존 Python /transcribe·/render 에 그대로 투입한다. 수동 다운로드+업로드 단계를 전사·렌더 양쪽에서 제거. - VideoDownloadService 신규: 인자 리스트 ProcessBuilder(셸 미사용), videoId 검증, 캐시 조회 - ChannelService: 전사·렌더를 Resource 기반 공통 메서드로 추출(업로드/캐시 공유) - 컨트롤러: POST /{id}/download(다운로드+전사), /render 의 file 을 선택값으로(없으면 캐시) - rework.html: '원본 다운로드' 버튼 + downloadOriginal(), 렌더가 서버 캐시 재사용 - 설정 ytdlp.*/download.dir(기본값 有), downloads/ gitignore - buildCommand/videoId 검증 단위테스트(src/test 신규) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + .../domain/channel/ChannelService.java | 42 ++++- .../ChannelVideoCurationController.java | 21 ++- .../channel/ChannelVideoCurationService.java | 31 ++++ .../domain/channel/VideoDownloadService.java | 152 ++++++++++++++++++ src/main/resources/application.yml | 9 ++ src/main/resources/templates/rework.html | 41 ++++- .../channel/VideoDownloadServiceTest.java | 56 +++++++ 8 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/VideoDownloadService.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/VideoDownloadServiceTest.java diff --git a/.gitignore b/.gitignore index 490ec55..d6dda1b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ src/main/resources/application-local.yml tokens/ *.log +# yt-dlp 다운로드 캐시(영상 파일은 커밋하지 않음) +/downloads/ + # OS .DS_Store Thumbs.db diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index a5661a8..0313b70 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -20,6 +21,7 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.util.UriComponentsBuilder; +import java.io.File; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -393,19 +395,34 @@ public class ChannelService { */ @Transactional public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) { + try { + return doTranscribe(channelVideoId, toFileResource(file), file.getSize(), language); + } catch (java.io.IOException e) { + throw new RuntimeException("업로드 파일을 읽지 못했습니다", e); + } + } + + /** 다운로드 캐시 파일(yt-dlp 결과)을 Whisper 로 전사한다 — 업로드 없이 동일 파이프라인 재사용. */ + @Transactional + public ScriptResponseDto transcribeFromCached(Long channelVideoId, File file, String language) { + return doTranscribe(channelVideoId, toFileResource(file), file.length(), language); + } + + /** 전사 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */ + private ScriptResponseDto doTranscribe(Long channelVideoId, Resource resource, long sizeBytes, String language) { ChannelVideo video = channelVideoRepository.findById(channelVideoId) .orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId)); String apiUrl = pythonBaseUrl + "/transcribe"; log.info("Requesting whisper transcription for video {} ({} bytes, lang={})", - channelVideoId, file.getSize(), language); + channelVideoId, sizeBytes, language); try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", toFileResource(file)); + body.add("file", resource); if (language != null && !language.isBlank()) { body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja 등) } @@ -478,6 +495,20 @@ public class ChannelService { * keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다. */ public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) { + try { + return doRender(channelVideoId, toFileResource(file), pad, minGap, speed); + } catch (java.io.IOException e) { + throw new RuntimeException("업로드 파일을 읽지 못했습니다", e); + } + } + + /** 다운로드 캐시 파일(yt-dlp 결과)로 말 없는 구간 제거(+배속) 렌더 — 업로드 없이 동일 파이프라인 재사용. */ + public byte[] renderTrimmedFromCached(Long channelVideoId, File file, double pad, double minGap, double speed) { + return doRender(channelVideoId, toFileResource(file), pad, minGap, speed); + } + + /** 렌더 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */ + private byte[] doRender(Long channelVideoId, Resource resource, double pad, double minGap, double speed) { List segments = getSegments(channelVideoId); if (segments.isEmpty()) { // 사용자에게 이유가 보이도록 400(IllegalArgumentException → GlobalExceptionHandler 가 메시지 노출). @@ -491,7 +522,7 @@ public class ChannelService { headers.setContentType(MediaType.MULTIPART_FORM_DATA); MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", toFileResource(file)); + body.add("file", resource); body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...] body.add("speed", String.valueOf(speed)); @@ -507,6 +538,11 @@ public class ChannelService { } } + /** File(다운로드 캐시) → multipart 전송용 Resource(파일명 보존, 디스크 스트리밍). */ + private Resource toFileResource(File file) { + return new FileSystemResource(file); + } + /** MultipartFile → multipart 전송용 Resource(파일명 보존). */ private Resource toFileResource(MultipartFile file) throws java.io.IOException { final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank()) diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java index 06be788..0e3a752 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -133,6 +133,16 @@ public class ChannelVideoCurationController { return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t)); } + @PostMapping("/{id}/download") + @Operation(summary = "원본 다운로드 + 자동 전사", + description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. " + + "쿼리: language(선택: ko|en|zh|ja… 비우면 자동감지). 응답: {downloaded, sizeBytes, hasScript, language, duration, transcript, segments}. " + + "수동 다운로드+업로드 단계를 제거. 이후 렌더도 같은 캐시 파일을 재사용한다.") + public ApiResponse> download(@PathVariable Long id, + @RequestParam(value = "language", required = false) String language) { + return ApiResponse.ok(curationService.downloadAndTranscribe(id, language)); + } + @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "업로드 영상 전사(Whisper)", description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. " @@ -156,14 +166,17 @@ public class ChannelVideoCurationController { @PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더", - description = "업로드 영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. " - + "multipart: file, pad, minGap, speed. keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).") + description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. " + + "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 " + + "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).") public ResponseEntity render(@PathVariable Long id, - @RequestParam("file") MultipartFile file, + @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 = curationService.renderTrimmed(id, file, pad, minGap, 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( diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java index e146d39..f959334 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -24,6 +24,7 @@ public class ChannelVideoCurationService { private final ChannelVideoRepository channelVideoRepository; private final ChannelVideoScriptRepository channelVideoScriptRepository; private final ChannelService channelService; + private final VideoDownloadService videoDownloadService; private static final Set ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED"); private static final Set ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec"); @@ -121,6 +122,36 @@ public class ChannelVideoCurationService { return result; } + /** + * 저장된 videoId 로 원본을 yt-dlp 로 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. + * 수동 다운로드+업로드 단계를 제거한다. language=null 이면 자동감지. + */ + @Transactional + public Map downloadAndTranscribe(Long videoId, String language) { + ChannelVideo v = find(videoId); + java.io.File file = videoDownloadService.download(v.getId()); + ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language); + + Map result = new LinkedHashMap<>(); + result.put("downloaded", true); + result.put("sizeBytes", file.length()); + result.put("hasScript", true); + result.put("language", dto.getLanguage()); + result.put("duration", dto.getDuration()); + result.put("transcript", dto.getTranscript() == null ? "" : dto.getTranscript()); + result.put("segments", channelService.getSegments(v.getId())); + return result; + } + + /** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */ + public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) { + ChannelVideo v = find(videoId); + java.io.File file = videoDownloadService.cachedFile(v.getId()) + .orElseThrow(() -> new IllegalArgumentException( + "원본 영상이 없습니다. 먼저 '원본 다운로드'를 실행하세요.")); + return channelService.renderTrimmedFromCached(v.getId(), file, pad, minGap, speed); + } + /** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */ public String buildSrt(Long videoId, double speed) { ChannelVideo v = find(videoId); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/VideoDownloadService.java b/src/main/java/com/hlab/yanalyst/domain/channel/VideoDownloadService.java new file mode 100644 index 0000000..af1866a --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/VideoDownloadService.java @@ -0,0 +1,152 @@ +package com.hlab.yanalyst.domain.channel; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +/** + * 저장된 {@code videoId} 로 yt-dlp 를 로컬에서 직접 실행(ProcessBuilder)해 원본 영상을 받아 + * {@code {download.dir}/{channelVideoId}.mp4} 에 캐시한다. 받은 파일은 기존 Python 파이프라인 + * ({@code /transcribe}, {@code /render})에 그대로 투입된다(전사·렌더 양쪽에서 캐시 재사용). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VideoDownloadService { + + /** YouTube videoId 는 정확히 11자의 [A-Za-z0-9_-]. 셸 인젝션·오입력 차단용. */ + private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$"); + + private final ChannelVideoRepository channelVideoRepository; + + @Value("${ytdlp.bin:yt-dlp}") + private String ytdlpBin; + + @Value("${ytdlp.ffmpeg-location:}") + private String ffmpegLocation; + + @Value("${ytdlp.max-height:1080}") + private int maxHeight; + + @Value("${ytdlp.timeout-seconds:600}") + private long timeoutSeconds; + + @Value("${download.dir:downloads}") + private String downloadDir; + + /** + * 저장된 videoId 로 영상을 받아 {@code {download.dir}/{channelVideoId}.mp4} 로 저장하고 그 File 을 반환한다. + * 셸을 거치지 않고 인자 리스트를 ProcessBuilder 에 전달한다(인젝션 차단). + */ + public File download(Long channelVideoId) { + ChannelVideo video = channelVideoRepository.findById(channelVideoId) + .orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId)); + String videoId = video.getVideoId(); + + try { + Path dir = Path.of(downloadDir); + Files.createDirectories(dir); + Path out = dir.resolve(channelVideoId + ".mp4"); + + List cmd = buildCommand(ytdlpBin, ffmpegLocation, maxHeight, videoId, out); + log.info("yt-dlp 다운로드 시작: videoId={} -> {}", videoId, out); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process process = pb.start(); + + // 출력을 별도 스레드로 흡수(버퍼 막힘 방지 + waitFor 타임아웃이 실제로 동작하도록). + StringBuilder outBuf = new StringBuilder(); + Thread drainer = new Thread(() -> { + try (BufferedReader r = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + synchronized (outBuf) { outBuf.append(line).append('\n'); } + } + } catch (IOException ignored) { /* 프로세스 종료 시 정상 */ } + }); + drainer.setDaemon(true); + drainer.start(); + + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("yt-dlp 다운로드 타임아웃(" + timeoutSeconds + "s)"); + } + drainer.join(2000); + + int code = process.exitValue(); + if (code != 0) { + String output = outBuf.toString(); + String tail = output.length() > 600 ? output.substring(output.length() - 600) : output; + throw new RuntimeException("yt-dlp 다운로드 실패(exit " + code + "): " + tail.strip()); + } + + File file = out.toFile(); + if (!file.exists() || file.length() == 0) { + throw new RuntimeException("다운로드 결과 파일이 비어있습니다: " + out); + } + log.info("yt-dlp 다운로드 완료: {} ({} bytes)", out, file.length()); + return file; + } catch (IOException e) { + throw new RuntimeException("yt-dlp 실행 실패 (설치/PATH 확인): " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("yt-dlp 다운로드가 중단되었습니다", e); + } + } + + /** 캐시된 다운로드 파일(있고 비어있지 않을 때만). 렌더가 업로드 없이 재사용한다. */ + public Optional cachedFile(Long channelVideoId) { + File file = Path.of(downloadDir).resolve(channelVideoId + ".mp4").toFile(); + return (file.exists() && file.length() > 0) ? Optional.of(file) : Optional.empty(); + } + + /** 다운로드 캐시 존재 여부. */ + public boolean isCached(Long channelVideoId) { + return cachedFile(channelVideoId).isPresent(); + } + + /** + * yt-dlp 명령 인자 리스트를 조립한다(프로세스 실행 없음 — 단위테스트 대상). + * videoId 는 {@code [A-Za-z0-9_-]{11}} 형식만 허용한다. + */ + static List buildCommand(String ytdlpBin, String ffmpegLocation, int maxHeight, + String videoId, Path outPath) { + if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) { + throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId); + } + String fmt = "bv*[height<=" + maxHeight + "]+ba/b[height<=" + maxHeight + "]/b"; + List cmd = new ArrayList<>(); + cmd.add(ytdlpBin); + cmd.add("--no-playlist"); + cmd.add("--force-overwrites"); + cmd.add("-f"); + cmd.add(fmt); + cmd.add("--merge-output-format"); + cmd.add("mp4"); + if (ffmpegLocation != null && !ffmpegLocation.isBlank()) { + cmd.add("--ffmpeg-location"); + cmd.add(ffmpegLocation.trim()); + } + cmd.add("-o"); + cmd.add(outPath.toString()); + cmd.add("https://www.youtube.com/watch?v=" + videoId); + return cmd; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 96d062d..3ed0b47 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,6 +54,15 @@ youtube: python: base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop} +# yt-dlp 원본 다운로드(로컬 ProcessBuilder 실행). 재가공 '원본 다운로드' 버튼이 사용. +ytdlp: + bin: ${YTDLP_BIN:yt-dlp} # PATH 에 있으면 그대로, 아니면 절대경로 지정 + ffmpeg-location: ${FFMPEG_LOCATION:} # 비우면 PATH 사용. 필요시 ffmpeg bin 폴더 경로(예: D:\\utils\\ffmpeg\\bin) + max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지 + timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} +download: + dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능) + hlab: # 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집 scheduler: diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index 1c3d3bd..7d3bcba 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -78,7 +78,10 @@ -