diff --git a/docs/superpowers/specs/2026-06-24-ytdlp-download-integration-design.md b/docs/superpowers/specs/2026-06-24-ytdlp-download-integration-design.md new file mode 100644 index 0000000..bfc492a --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-ytdlp-download-integration-design.md @@ -0,0 +1,133 @@ +# yt-dlp 다운로드 → 재가공 파이프라인 자동연결 설계 + +- 날짜: 2026-06-24 +- 상태: 설계 확정(구현 대기) +- 관련: `2026-06-12-subtitle-timeline-studio-design.md`(자막 타임라인 스튜디오) + +## 1. 목적 + +재가공(`/rework/{id}`) 화면에서 **"원본 다운로드" 버튼 한 번**으로 +`yt-dlp`가 저장된 `videoId`로 영상을 받아 서버에 캐시하고, 그 파일을 그대로 +기존 Python 파이프라인(`/transcribe`, `/render`)에 흘려보낸다. 결과적으로 +지금까지 필요했던 **수동 다운로드 + 수동 업로드** 단계를 전사·렌더 양쪽에서 모두 제거한다. + +## 2. 배경 / 현재 구조 + +- 백엔드: Spring Boot 3.4 / Java 21, Thymeleaf SSR. 로컬 실행(`bootRun`, 포트 8088, Dockerfile 없음). +- 영상 처리는 원격 Python 마이크로서비스(`PYTHON_BASE_URL`, 기본 `http://h-python.tolag.shop`)의 + `/transcribe`(faster-whisper), `/render`(ffmpeg)를 `pythonRestTemplate`로 호출. +- 현재 흐름: 사용자가 **유튜브에서 수동 다운로드 → 브라우저에서 파일 업로드**. + 업로드한 File을 JS변수 `UPLOADED_FILE`에 담아 전사·렌더가 클라이언트에서 재사용. + 렌더 버튼은 `UPLOADED_FILE`이 없으면 "먼저 업로드하세요"로 막힘. +- 로컬 설치 확인: `yt-dlp 2026.03.17`(`C:\...\Python313\Scripts\yt-dlp`), `ffmpeg 8.0.1`(`D:\utils\...`). +- `ChannelVideo`: 전체 URL 없이 `videoId`만 저장(+`is_shorts` 플래그). URL은 `https://www.youtube.com/watch?v={videoId}`로 구성. + +### 실행 위치 결정 +yt-dlp는 **Spring이 로컬에서 직접 실행(ProcessBuilder)**, 받은 mp4를 기존처럼 원격 Python으로 전송한다. +- 근거: yt-dlp가 로컬 설치됨, h-lab도 로컬 실행, 유튜브는 데이터센터 IP를 자주 차단하므로 가정용 IP에서 받는 게 안정적. + +## 3. 사용자 흐름 + +``` +[재가공 /rework/{id}] "원본 다운로드" 클릭 + │ POST /api/curation/videos/{id}/download + ▼ +[Spring] videoId 검증 → yt-dlp(ProcessBuilder) → {download.dir}/{id}.mp4 캐시 저장 + ▼ +[Spring] 캐시 파일 → 원격 Python /transcribe → 세그먼트 저장 + hasScript=true + ▼ +[화면] 세그먼트 표시 + 서버파일 보유 플래그 set + ▼ +"말없는구간 제거·렌더" → 같은 캐시 파일 재사용(업로드 불필요) +``` + +## 4. 컴포넌트 + +### 4.1 `VideoDownloadService` (신규, `domain/channel`) +서버사이드 yt-dlp 실행과 다운로드 캐시를 담당. + +- `File download(Long channelVideoId)` + 1. `ChannelVideo` 조회, `videoId` 형식 검증: 정규식 `^[A-Za-z0-9_-]{11}$`. 불일치 시 `IllegalArgumentException`. + 2. 출력 경로 `{download.dir}/{channelVideoId}.mp4` 계산(폴더 없으면 생성). + 3. `buildCommand(videoId, outPath)`로 만든 인자 리스트를 ProcessBuilder에 전달(**shell 미사용**). + 4. 프로세스 실행, `timeout-seconds` 초과 시 강제종료 + 예외. 비정상 종료 시 stderr 일부를 메시지에 담아 예외. + 5. 결과 File 반환. +- `Optional cachedFile(Long id)` / `boolean isCached(Long id)`: 캐시 존재·반환(렌더 재사용용). +- `List buildCommand(String videoId, Path outPath)` — **순수 메서드(프로세스 실행 없음, 단위테스트 대상)**: + ``` + {ytdlp.bin} + --no-playlist + --force-overwrites + -f bv*[height<={max-height}]+ba/b[height<={max-height}]/b + --merge-output-format mp4 + [--ffmpeg-location {ffmpeg-location}] # 값이 있을 때만 + -o {outPath} + https://www.youtube.com/watch?v={videoId} + ``` + +### 4.2 `ChannelService` 리팩터 — Resource 기반 공통화 +전사·렌더 핵심 로직을 `Resource` 입력으로 추출해 MultipartFile(기존)과 File(캐시)이 공유. + +- 내부 공통: `doTranscribe(ChannelVideo, Resource, language)`, `doRender(ChannelVideo, Resource, pad, minGap, speed)`. +- 기존 `transcribeFromFile(id, MultipartFile, lang)` / `renderTrimmed(id, MultipartFile, ...)`는 + Resource 어댑터를 거쳐 공통 메서드 호출하도록 유지(외부 동작 불변). +- 신규: `transcribeFromCached(id, lang)`, `renderTrimmedFromCached(id, pad, minGap, speed)` — + `VideoDownloadService.cachedFile(id)`로 `FileSystemResource` 생성 후 공통 메서드 호출. + 캐시 없으면 `IllegalArgumentException("원본 영상이 없습니다. 먼저 '원본 다운로드'를 실행하세요.")`. +- `toFileResource(File)` 헬퍼 추가(파일명 보존). 기존 `toFileResource(MultipartFile)` 유지. + +### 4.3 `ChannelVideoCurationController` 엔드포인트 +- `POST /{id}/download` — yt-dlp 다운로드 + 캐시 전사를 한 호출로 수행. + 응답: `{ downloaded:true, sizeBytes, ...전사결과(hasScript, language, duration, transcript, segments) }`. + (서비스 위임: `VideoDownloadService.download` → `ChannelService.transcribeFromCached`.) +- 기존 `POST /{id}/render`의 `file`을 **선택값**으로 변경: + - `file` 있으면 기존대로(업로드 렌더). + - `file` 없으면 `renderTrimmedFromCached` 사용(캐시 없으면 400, 메시지 노출). +- `CurationService`는 컨트롤러↔ChannelService 사이 기존 위임 패턴을 따라 대응 메서드 추가. + +### 4.4 `rework.html` UI +- "영상 업로드·전사" 버튼 옆에 **"원본 다운로드"** 버튼 추가(아이콘 `download`). +- 클릭 → 스피너/상태표시 → `POST /{id}/download`. + 성공 시 기존 전사 성공 핸들러 재사용(세그먼트 렌더), 전역 플래그 `HAS_SERVER_FILE=true` set. +- `renderVideo()` 수정: `UPLOADED_FILE` 없어도 `HAS_SERVER_FILE`이면 진행. + 업로드 파일 있으면 multipart로 전송, 없으면 `file` 없이 `/render` 호출(서버 캐시 사용). + +## 5. 설정 (`application.yml`, 모두 기본값 有) + +```yaml +ytdlp: + bin: ${YTDLP_BIN:yt-dlp} + ffmpeg-location: ${FFMPEG_LOCATION:} # 비우면 PATH 사용 + max-height: ${YTDLP_MAX_HEIGHT:1080} + timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} +download: + dir: ${DOWNLOAD_DIR:downloads} # 캐시 폴더(상대경로 = 작업디렉토리 기준) +``` + +`downloads/`는 `.gitignore`에 추가(영상 캐시 커밋 방지). + +## 6. 에러 처리 + +- videoId 형식 오류 → 400(`IllegalArgumentException` → `GlobalExceptionHandler`). +- yt-dlp 실행 실패/타임아웃 → 500, 로그에 stderr 요약. 화면에 "다운로드 실패" 상태 표시. +- 렌더 시 캐시·세그먼트 없음 → 400, 사용자에게 다음 행동 안내 메시지. +- 외부로 시크릿 노출 없음(여기선 API 키 불필요). 명령은 인자 리스트 전달로 셸 인젝션 차단. + +## 7. 동기/비동기 + +MVP는 기존 전사·렌더와 동일하게 **동기 호출 + 프론트 스피너**. `timeout-seconds` 가드만 둔다. +비동기 작업큐/진행률 %는 범위 외(추후). + +## 8. 테스트 (`src/test` 신규 생성) + +프로세스 실행 없이 검증 가능한 단위 위주: +- `buildCommand`: 인자 순서/포맷 문자열/ffmpeg-location 유무 분기/URL 구성. +- videoId 검증: 정상 11자 통과, 비정상(길이/문자) 거부. +- 렌더 폴백 분기: `file` 없음 + 캐시 없음 → 예외, 캐시 있음 → 캐시 경로 사용(서비스 레벨, Python 호출은 모킹). + +## 9. 범위에서 제외 (YAGNI) + +- 비동기 작업큐 / 진행률 표시 +- 임의 URL 붙여넣기(이번엔 저장된 videoId 버튼만) +- 캐시 자동정리·만료 정책 +- 다운로드 화질/포맷 사용자 선택 UI(설정값 고정)