Compare commits

...

28 Commits

Author SHA1 Message Date
Claude (owner)
0abd21533b fix: 동시 영상 처리 시 공유 임시파일 레이스 제거 (고유 tmp 경로)
ensureThumbnail / upscaleOriginalTo60Fps / applyTrimToVideo 가 영상당
고정된 tmp 파일명(thumb.jpg.tmp.jpg, original.bump.tmp.mp4, edited.<ext>.tmp.<ext>)
을 써서, 같은 영상에 대한 요청이 동시에 들어오면 ffmpeg 프로세스들이 서로의
tmp 를 덮어쓰고 rename 이 경합해 일부 요청이 헛되이 실패했다.

증상: 같은 썸네일을 동시 10회 요청하면 6건만 성공하고 4건이 404.
원인: 호출마다 같은 tmp 경로 공유. 해결: randomUUID 로 호출별 고유 tmp 경로.
검증: 수정 후 동일 동시요청 10/10 성공, tmp 잔여물 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 02:53:36 +09:00
Claude (owner)
584ec8b92b fix: harden youtube retry against data corruption
Add a one-shot retry guard (retriedBy) so concurrent retries of the
same failed job can't spawn multiple yt-dlp processes into the same
directory, and a sourceType/sourceUrl match check so retrying a job
whose video ID was repurposed for a different video is rejected
instead of overwriting it.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:18:02 +09:00
Claude (owner)
6850313c0b feat: 플레이리스트 실패 항목 재시도 버튼 + API
실패한 다운로드는 영상 레코드가 남아 같은 ID 재등록이 막혔고, 진행 화면의
"개별로 다시 시도" 안내에 실제 버튼이 없었다. 실패 행에 "재시도" 버튼을 추가하고
POST /op/job/:id/retry 로 같은 ID·URL·자르기 설정으로 새 잡을 시작한다.
retryDownloadJob 은 기존 영상 레코드를 재사용(레코드가 지워졌으면 같은 ID로 복원)
하므로 UNIQUE 충돌 없이 재시도된다. 연령 제한 영상은 쿠키 등록 후 재시도하면 받힌다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:11:59 +09:00
Claude (owner)
6ade552683 feat: 연령제한 영상용 YouTube 쿠키(cookies.txt) 등록 + yt-dlp --cookies 적용
연령 제한("Sign in to confirm your age") 영상이 플레이리스트 임포트에서
실패하던 문제 해결. 관리자 대시보드에서 Netscape cookies.txt 를 등록하면
단일/플레이리스트 probe 와 실제 다운로드에 모두 --cookies 로 적용된다.
쿠키는 data/cookies.txt 에 0600 으로 저장(git 제외). 연령/로그인 실패 시
yt-dlp 원문 대신 쿠키 등록을 안내하는 메시지를 표시.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:04:25 +09:00
Claude (owner)
03e97d771d docs: 레거시 ID-only URL 문서/주석을 실제 동작에 맞게 정정
- README: 제거된 /api/video/:videoId/file 안내 삭제, /file/video/:videoId 만
  레거시 주소로 남기고 전역 유일할 때만 200 임을 명시
- public.ts 썸네일 fallback 주석을 "전역 첫 매치" → "전역에서 유일한 경우만"

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:37:59 +09:00
Claude (owner)
ff78b60a4b fix: 레거시 ID-only URL 은 전역 유일할 때만 제공하고 정규 경로 충돌 제거
폴더별 중복 ID 허용 이후, ID 만으로 영상을 찾는 레거시/공유 fallback
(getVideoByIdGlobal) 이 임의의 폴더(작은 folder_id) 를 돌려줘서 엉뚱한
영상이 나올 수 있었다. 전역에서 정확히 1건일 때만 반환하고, 중복이면
null → 호출 측 404 로 거절한다. 영향: /file/video/:id,
/file/video/:id/thumb, /api/video/:id(meta).

또한 죽은 레거시 라우트 /api/video/:videoId/file 를 제거했다. 이 라우트는
앱이 더는 만들지 않으면서 정규 경로 라우트 /api/video/:topName/:videoId 와
2세그먼트에서 충돌해, ID 가 리터럴 "file" 인 영상의 정규 URL 을 가로챘다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:33:54 +09:00
Claude (owner)
042d9f7059 feat: 플레이리스트 추가 화면 임시저장(draft) 기능
다운로드 시작 전, 수정해둔 값(플레이리스트 URL + 항목별 제목/ID/시작/길이/
ID모드/자릿수채우기)을 폴더당 1개 저장하고 나중에 불러올 수 있게 함.

- db: playlist_drafts 테이블 (folder_id PK, JSON data, updated_at)
- storeDb: savePlaylistDraft/getPlaylistDraft/deletePlaylistDraft
- op: GET/POST/POST(delete) /playlist/draft (하위 폴더 한정)
- UI: 임시저장 / 불러오기 / 삭제 버튼 + 진입 시 기존 draft 자동 감지

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:22:42 +09:00
Claude (owner)
0ec6b06b57 feat: 영상 ID 를 폴더 안에서만 유일하게 (UNIQUE(folder_id, id))
폴더가 다르면 같은 영상 ID(예: 1,2,3,4)를 재사용할 수 있도록 변경.
ID 가 더 이상 전역 PK 가 아니므로 모든 영상 조회를 폴더 스코프로 전환:
- db: videos PK→UNIQUE(folder_id,id) + 기존 DB 자동 마이그레이션
- storeDb: getVideo→getVideoInFolder(folderId,id) + getVideoByIdGlobal 폴백
- public/op/editor/youtube: 재생·썸네일·편집·삭제·rename 모두 폴더 스코프
- 썸네일을 경로기반 URL(/file/video/<폴더>/<id>/thumb)로 전환
- 클라이언트 mutation 요청에 folderId 동봉

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:19:02 +09:00
Claude (owner)
66469ca418 fix(tools): ffmpeg 재설치도 실행 검증 후 원자적 교체
ffmpeg 재설치가 압축에서 찾은 바이너리를 검증 없이 바로 덮어써서,
실행 불가한 바이너리가 기존 정상 바이너리를 망가뜨리거나 ffprobe 누락 시
오래된 시스템 ffprobe 가 계속 쓰일 수 있었다. yt-dlp 와 동일하게:
- ffmpeg/ffprobe 둘 다 필수로 찾고
- *.new 로 복사 후 각각 -version 실행 검증
- 둘 다 통과한 뒤에만 rename 으로 원자적 교체

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:05:44 +09:00
Claude (owner)
8ed3b7d82a feat(tools): 관리자 화면에서 ffmpeg/yt-dlp 최신 버전 재설치
오래된 ffmpeg/yt-dlp 로 다운로드·변환이 실패할 때 관리자 대시보드에서
최신 버전으로 재설치할 수 있게 한다. 재설치 바이너리는 data 볼륨 하위
data/bin 에 두어 컨테이너 재생성에도 유지하고, 바이너리 해석 시 PATH 보다
우선 사용한다.

- src/paths.ts: binDir(data/bin) 추가
- src/editor.ts: binDir 우선 ffmpeg 해석 + getFfprobePath/resetFfmpegResolution
- src/youtube.ts: binDir 우선 yt-dlp 해석 + resetYtDlpResolution
- src/tools.ts(신규): 버전 조회 + 최신 재설치(GitHub yt-dlp, johnvansickle ffmpeg)
- src/routes/op.ts: GET /op/tools, POST /op/tools/:name/update
- views/op/dashboard.ejs, public/dashboard.js, public/styles.css: 도구 관리 UI
- Dockerfile: ffmpeg 정적 빌드(.tar.xz) 해제용 xz-utils 추가

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:02:25 +09:00
Claude (owner)
f9a26aaa86 feat(playlist): show download progress page after import instead of navigating away
플레이리스트 등록 후 폴더로 바로 나가지 않고, 각 다운로드 잡을 폴링하며
항목별/전체 진행률을 보여주는 진행 화면을 추가. 단일 영상 추가 흐름과 동일하게
/op/job/:id 를 폴링하고, 모두 끝나면 "폴더로 이동" 버튼을 노출한다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 02:26:05 +09:00
Claude (owner)
e9924805cf fix(playlist): make trim modal numeric input length-based not end-time
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-03 21:35:35 +09:00
Claude (owner)
2f916fe958 feat(playlist): replace end-time inputs with play-length (default 15s)
Per-entry and bulk trim inputs now take a play length in seconds
(start + length) instead of an end time. Default 15s, so start=30
produces a 30–45s clip. Converted to endSec at register time; the
trim editor maps its end handle to a length on apply. Backend
unchanged (still works in start/end terms).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-03 21:29:58 +09:00
Claude (owner)
7105349a45 feat(stream): canonical folder-path video file URL
영상 파일 스트림 정규 주소를 재생 페이지와 동일한 폴더 경로 규칙으로 변경:
  /api/video/:topName[/:subName]/:videoId
경로의 폴더 부분이 영상 위치와 일치할 때만 200, 불일치 시 404.
재생 페이지(/video/...)와 ID 기반 레거시 스트림(/file/video/:id,
/api/video/:id/file)은 그대로 유지(외부 공유 호환).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-02 01:07:20 +09:00
Claude (owner)
34c040c15d feat(playlist): per-entry/bulk trim, preview, and trim editor on import
플레이리스트 가져오기 화면에 다음을 추가:
- 항목별 제목 입력을 절반으로 줄이고 오른쪽에 시작/종료 시간 입력 추가
  (기본 시작 0, 종료는 영상 길이)
- 정렬 옵션 옆에 일괄 시작/종료 입력과 적용/초기화 버튼 추가
  (초기화 = 시작 0, 종료 = 영상 길이)
- 항목 썸네일 클릭 시 YouTube 임베드로 미리보기 재생
- 항목 우클릭 → 자르기 메뉴 → /video/editor 식 타임라인 모달(YouTube IFrame)
  에서 구간을 정하면 항목의 시작/종료 입력에 반영
- 백엔드: playlist/start 가 항목별 startSec/endSec 를 받아 다운로드 잡에
  실어 보내고, 60fps 후처리 후 ffmpeg 로 해당 구간을 잘라 편집본 생성

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-02 00:50:04 +09:00
Claude (owner)
066ae6b112 feat(thumbnails): generate and serve video thumbnails for folder cards
ffmpeg로 영상 첫 프레임을 즉석 추출해 thumb.jpg로 캐시하고
공개/관리자 폴더 카드에서 실제 썸네일을 표시한다. ffmpeg가 없거나
생성 불가하면 404로 떨어져 ▶ placeholder로 폴백한다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-02 00:41:06 +09:00
Claude (owner)
210787131b 관리자 폴더 카드 전체 영역 클릭 시 폴더 진입
내부 링크(.folderCardLink)에만 적용되던 클릭 영역을 카드 전체로 확장.
패딩을 카드 div 에서 링크로 옮기고 flex:1 로 카드를 가득 채워, 폴더
아이콘/이름 옆 배경을 눌러도 진입되도록 함. 공개 화면 카드는 자체가
<a> 라 :not(.adminFolder) 패딩 규칙으로 기존 동작 유지.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-01 23:01:16 +09:00
Claude (owner)
0faf7af42a feat(playlist): show thumbnails in import preview
플레이리스트 가져오기 미리보기 목록에 각 항목 썸네일을 표시한다.
YouTube video id 로 mqdefault 썸네일 URL 을 만들고, id 가 없으면
yt-dlp 가 준 thumbnail 필드로 폴백. 깨진 이미지는 빈 박스 처리.

- youtube.ts: PlaylistEntry 에 thumbnailUrl 추가 + flat-playlist 파싱에서 채움
- playlist.js: probe 매핑에 thumbnailUrl 반영, 각 행에 img 렌더(lazy + onerror)
- styles.css: 행 그리드에 썸네일 칼럼(96px 16:9) 추가

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-01 22:52:05 +09:00
Claude (owner)
0e39836c1f feat(folders): allow videos in top-level folders too
1단계 폴더에도 영상을 직접 추가할 수 있게 한다 (하위 폴더와 혼합 가능).
플레이리스트 추가는 기존대로 2단계 폴더 한정.

- op.ts: editor/upload/youtube start 의 parentId===null 차단 제거
  (playlist probe/start/new 차단은 유지)
- op/folder.ejs: 1단계에서 "하위 폴더 추가"+"영상 추가" 동시 노출,
  서브폴더 그리드와 영상 그리드를 함께 표시
- folder.ejs(public): 1단계에서 하위 폴더 + 영상 함께 표시
- README: 폴더 구조/버튼 설명 갱신

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-01 22:12:37 +09:00
Claude (owner)
ad42c88eaf refactor(docker): simplify + optimize Dockerfile
- yt-dlp 를 ADD --chmod 로 빌드 타임에 받아 wget 설치/제거 단계 제거
- npm ci 에 BuildKit 캐시 마운트 추가로 재빌드 가속
- build/prune 레이어 통합, 장황한 주석 정리

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-01 21:55:40 +09:00
Claude (owner)
8f72fcf4f9 fix(docker): bind to 0.0.0.0 so port mapping reaches the container
앱 기본값(src/app.ts)이 HOST=127.0.0.1 이라 컨테이너 내부에만 바인딩돼
`docker run -p` 로도 호스트에서 접속이 안 됐다. runtime 스테이지에
ENV HOST=0.0.0.0 / PORT=3000 추가. `docker run -e HOST=...` 로 덮어쓰기 가능.

검증: 재빌드 후 `docker run -p 13901:3000` → 호스트 curl HTTP 200,
서버 로그 `http://0.0.0.0:3000`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 21:43:48 +09:00
Claude (owner)
83b391c0a8 build: add Dockerfile that compiles better-sqlite3 in build stage
도커 빌드가 `RUN npm install` 에서 better-sqlite3 네이티브 빌드 실패로
죽던 문제 해결.

근본 원인:
- 베이스가 Alpine(musl) 이라 better-sqlite3 prebuilt 바이너리가 없음
  (prebuild-install: "No prebuilt binaries found ... libc=musl").
- node-gyp 풀 빌드로 폴백하는데 이미지에 make/g++/python3 가 없어
  "not found: make" 로 실패.

해결:
- 멀티스테이지 Dockerfile 추가. build 스테이지에 python3/make/g++ 를
  깔아 better-sqlite3 를 소스에서 항상 컴파일하고, `npm prune --omit=dev`
  로 정리한 node_modules(컴파일된 .node 포함)를 runtime 스테이지로 복사.
  runtime 엔 빌드 체인이 없어 가볍고, prebuilt 존재 여부에 의존하지 않음.
- glibc(debian-slim) 베이스라 musl 갭 자체를 회피. 번들 yt-dlp 표준
  바이너리도 glibc 에서 동작.
- runtime 에 ffmpeg/ffprobe(트림·60fps·FHD 변환) + yt-dlp(릴리스 바이너리)
  설치. data 를 VOLUME 으로 노출, 3000 포트 EXPOSE.
- .dockerignore 로 node_modules/dist/data/.git/.env 등 컨텍스트 제외.

검증 (로컬 docker 29.4.1):
- `docker build` 성공 (better-sqlite3 컴파일 통과)
- 컨테이너 기동 → `GET /` HTTP 200
- ffmpeg 5.1.9 / ffprobe 5.1.9 / yt-dlp 2026.03.17 / better-sqlite3
  in-memory 쿼리 모두 정상

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 21:39:53 +09:00
Claude (owner)
6eac31401a docs(readme): fix two remaining mismatches with implementation
리뷰어 P2 잔여 2건 반영:
- URL 규칙의 1단계 폴더 설명을 "서브폴더 + 영상 혼합" → "하위 폴더만
  표시" 로 정정. 같은 문서 안 폴더 화면 스펙(73-75)과 일관되게 맞춤.
- 관리자 페이지 우클릭 메뉴에 적혀 있던 "영상-허용 토글" 제거. 실제
  dashboard.js 컨텍스트 메뉴는 이름 변경 / 삭제만 존재하고 DB 에도
  폴더별 영상 허용 필드가 없음.

코드 변경 없음. `npm run build` 통과.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:51:51 +09:00
Claude (owner)
90e227d857 docs(readme): align with actual implementation
리뷰어 P2 지적 3건 반영:
- 데이터 위치: `data/videos/<videoId>/` 라는 잘못된 경로를 실제 구조
  `data/folders/<topName>[/<subName>]/<videoId>/` 로 교정. `data/jobs`,
  `data/tmp` 도 추가. "폴더 이름 바꿔도 파일 이동 없음" 문장은 실제로는
  rename 동작이 일어나므로 삭제.
- 공개 폴더 화면 설명: 1단계가 "서브폴더 + 영상 혼합" 이라고 적혀
  있었지만, views/folder.ejs 구현은 1단계=하위폴더만 / 2단계=영상만 으로
  분리됨. 두 단계별 렌더링을 정확히 기술.
- 관리자 폴더: "영상 카드 드래그 정렬" 항목은 plain admin folder 뷰에
  없으므로 제거. 드래그 정렬은 플레이리스트 임포트 화면 한정임을 명시.
- 랜덤 ID 형식: "8자 base32" → 실제 public/playlist.js 의 24자 hex
  (crypto.randomUUID 압축) 로 정정. 시퀀셜 zero-pad 동작도 보강 설명.

코드 변경 없음. `npm run build` 통과.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:50:05 +09:00
Claude (owner)
5693e1c6b0 refactor: dedupe encoder retry + collectFolderSegments + 3-probe → 1
simplify skill 결과 정리:
- editor.ts: HW→libx264 잡 폴백 패턴을 encodeWithFallback() 한 곳으로
  모으고 upscale/trim 양쪽에서 호출. 진행률 무전송 변형은 runner 인자로 분기.
- editor.ts: upscaleOriginalTo60Fps 가 fps/해상도/길이를 위해 spawnSync
  ffprobe 를 3번 돌리던 것을 probeVideoMeta() 한 번 호출로 통합. 핫패스에서
  이벤트 루프 블로킹을 줄임. 이제 미사용이 된 probeVideoResolution/
  probeVideoDuration 은 제거.
- routes/helpers.ts: op.ts/public.ts 양쪽에 동일하게 있던
  collectFolderSegments 를 공용 모듈로 추출.
- youtube.ts: probeYoutube/probeYoutubePlaylist 의 stderr 누적을
  runFfmpeg 와 동일하게 마지막 2KB 만 유지하도록 캡.

README.md 도 5/31 STEP 1~5 (중첩 폴더 + 영상 ID 편집 + 플레이리스트
임포트 + 짧은 공유 URL) 과 5/16 묶음(부드러운 진행바 + 60fps/FHD 캡 +
HW 인코더 자동 감지) 까지 반영해 전반 갱신.

`npm run build` 통과.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:44:29 +09:00
Claude (owner)
4909d18493 fix(editor): per-job libx264 fallback when HW encoder fails at real encode (STEP B P1)
리뷰어 P1: 1프레임 lavfi 테스트는 통과해도 실제 영상의 픽셀 포맷/색공간/
해상도 조합에서 HW 인코더가 깨지는 경우가 흔하다 (NVENC yuv444 거부,
VAAPI 포맷 chain 까다로움 등). 기존 코드는 HW 가 실제 인코딩에서 실패하면
그대로 포기해 "원본도 60fps + 최대 FHD" 보장이 깨졌다.

수정:
- upscaleOriginalTo60Fps: 선택된 인코더가 실제 영상에서 실패하고 그게
  libx264 가 아니면 libx264 ultrafast 로 한 번 더 시도. 둘 다 실패해야
  진짜 실패로 보고 원본 유지. 인자 조립을 buildUpscaleArgs(profile) 로
  추출해 같은 vfilter/onProgress 흐름을 재사용.
- applyTrimToVideo 재인코딩 경로: 동일 패턴. buildTrimEncArgs(profile)
  로 추출해 HW 실패 시 libx264 재시도. 둘 다 실패해야 throw.
- detectH264Encoder 의 캐시는 그대로 둔다 (다른 잡은 더 일반적인 입력
  일 수 있어 HW 가 동작할 여지를 남긴다).

검증 (fake ffmpeg + fake ffprobe, 3 시나리오, 정리 완료):
- HW 테스트 PASS, REAL FAIL → libx264 재시도로 변환본 생성 ✓
- HW 테스트 PASS, REAL PASS → HW 그대로 사용, 재시도 없음 ✓
- 모든 인코더 REAL FAIL → 원본 그대로, 변환 안 함 ✓
2026-06-01 10:15:56 +09:00
Claude (owner)
a0a094b517 perf(editor): pick hardware h264 encoder (nvenc/qsv/videotoolbox/vaapi) over libx264
5/16 사용자 요구 "변환 너무 느려 20배는 빠르게 해줘" 대응.

libx264 -preset ultrafast 보다 실질적으로 20배 빠른 길은 GPU/전용 ASIC
인코더밖에 없으므로, 첫 사용 시점에 한 번 자동 탐지해서 캐시한다.

탐지 흐름:
1) ffmpeg `-encoders` 출력에서 h264_nvenc, h264_qsv, h264_videotoolbox,
   h264_vaapi 후보를 추린다 (vaapi 는 /dev/dri/renderD128 도 필요).
2) 각 후보를 lavfi color 1프레임 인코드로 실측. 5초 타임아웃.
3) 첫 성공을 캐시; 모두 실패하면 libx264 ultrafast 폴백.
4) 우선순위: nvenc > qsv > videotoolbox > vaapi > libx264.

각 프로필은 (preInputArgs, vfilterSuffix, codecArgs) 세 묶음을 들고 있어
호출자는 자기 vfilter chain 끝과 `-i` 앞에 그대로 spread 만 하면 된다.
vaapi 의 `-vaapi_device .../renderD128` 와 `,format=nv12,hwupload` 가
이 모델로 깨끗하게 흡수된다.

연결 지점:
- upscaleOriginalTo60Fps: libx264 자리에 enc.codecArgs spread. vfilter 끝에
  enc.vfilterSuffix 이어붙임. preInputArgs 를 -i 앞에 prepend.
- applyTrimToVideo 재인코드 경로: 동일 처리. baseArgs 를 처음부터 다시
  쌓아 preInputArgs 가 ffmpeg 전역 옵션 위치(-ss/-to/-i 보다 앞)에 가게.
- stream-copy trim 경로는 그대로 둠 (인코더 안 씀).

검증 (fake ffmpeg + lavfi 1프레임 테스트):
- only libx264 listed                    → libx264 (폴백)
- nvenc+qsv listed, nvenc fails          → qsv
- nvenc works                            → nvenc
- only vaapi works                       → vaapi (+ preInput/vfilter suffix 확인)
- nvenc+qsv+vaapi listed, all HW fail    → libx264 (폴백)
2026-06-01 10:11:18 +09:00
Claude (owner)
ef65d6674f fix(youtube): smooth download progress + post-process heartbeat
5/16 사용자 보고 "진행률이 0 → 50% 한방에 튀고 60fps 변환에서 하루종일 멈춤"
의 두 가지 직접 원인을 잡습니다.

1) yt-dlp 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받을 때,
   기존 `Math.max(prev, pct)` 누적 방식은 첫 스트림이 100% 도달하면
   매핑값이 50% 에 박혀 둘째 스트림 진행이 사용자에게 보이지 않았다.
   스트림 카운트 + 가중평균 방식으로 바꿔 전체 다운로드 phase 가
   부드럽게 0..50% 로 차오르게 한다. (검증: 5%, 13%, 25%, 38%, 50%,
   65%, 80%, 100% 의 8단계 클라이밍을 60ms 간격으로 관찰)

2) 60fps/FHD 후처리 단계가 `-progress pipe:1` 라인을 전혀 못 받는
   경우(영상 길이 unknown, 1초 미만 짧은 클립 등) progress 가 50% 에
   고정돼 사용자는 "멈췄다"고 인식했다. heartbeat interval(2s) 로
   메시지에 경과시간을 박아 화면이 살아있다는 신호를 준다. progress
   막대 자체는 실제 ffmpeg 데이터가 들어올 때만 움직이며, 거짓
   증가는 하지 않는다.
2026-06-01 10:04:48 +09:00
26 changed files with 2811 additions and 332 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
bin
data
.git
.env
*.log
.DS_Store

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@ data/app.db
data/app.db-journal
data/app.db-shm
data/app.db-wal
data/bin/
data/cookies.txt
.env
!.env.example
*.log

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1
# better-sqlite3 는 musl(Alpine) prebuilt 가 없어 빌드 체인이 필요하다.
# build 스테이지에서 컴파일한 node_modules 를 runtime 으로 복사해
# runtime 은 빌드 체인 없이 가볍게 유지한다 (glibc 베이스라 재현성도 좋다).
# ── build: 네이티브 모듈 컴파일 + TS 빌드 ────────────────────────────────
FROM node:20-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
# ── runtime ──────────────────────────────────────────────────────────────
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# 앱 기본값 HOST=127.0.0.1 은 컨테이너 안에만 바인딩돼 포트 매핑이 안 먹는다.
ENV HOST=0.0.0.0
ENV PORT=3000
# ffmpeg: 트림·60fps·FHD 변환. ca-certificates: yt-dlp https.
# xz-utils: 관리자 화면에서 최신 ffmpeg 정적 빌드(.tar.xz) 재설치 시 tar -J 압축 해제에 필요.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates xz-utils \
&& rm -rf /var/lib/apt/lists/*
# yt-dlp 바이너리는 BuildKit 이 빌드 타임에 받아 넣는다 (이미지에 wget 불필요).
ADD --chmod=755 https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux bin/yt-dlp
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
COPY public ./public
COPY views ./views
# 기본 admin 계정 파일. 운영에선 볼륨/시크릿으로 덮어쓰세요 (초기값 admin).
COPY account.json ./account.json
VOLUME ["/app/data"]
EXPOSE 3000
CMD ["node", "dist/app.js"]

107
README.md
View File

@@ -1,5 +1,6 @@
# 비디오 사이트
## 영상 업로드 및 저장을 위한 사이트
영상 업로드/관리/공개 재생을 다 한 곳에서 처리하는 Express + EJS + better-sqlite3 사이트입니다. 컴퓨터 파일이나 YouTube 링크(단일 영상/플레이리스트) 를 가져와 폴더 단위로 정리하고, 짧은 공유 URL로 외부에 노출합니다.
## 실행
@@ -26,58 +27,88 @@ npm start
- `SESSION_SECRET` — 운영 시 반드시 충분히 긴 랜덤 문자열로 교체.
- `UPLOAD_MAX_BYTES` — 업로드 용량 한도(바이트). 비우거나 미설정이면 기본 `1073741824` (1 GiB). 무제한으로 두려면 `0` 또는 `Infinity`. 잘못된 값은 기본 1 GiB 로 fallback.
- `HTTP_REQUEST_TIMEOUT_MS` — 대용량 업로드용 HTTP 요청 타임아웃(밀리초). `0`/미설정이면 무제한.
- `MAX_DOWNLOAD_CONCURRENCY` — 플레이리스트 임포트 시 동시 yt-dlp 잡 개수 상한 (기본 `2`).
- 관리자 비밀번호는 `account.json``password` 값 (초기값 `admin`, 운영 시 반드시 변경).
## 외부 의존
- `yt-dlp` — YouTube 영상 가져오기. `npm run setup``./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. Alpine / distroless 같이 glibc 가 없는 슬림 도커 베이스에서는 번들 바이너리가 안 도니 `apk add yt-dlp` (또는 `apt-get install yt-dlp`, `pip install yt-dlp`) 로 PATH 에 직접 설치하세요. 도커 빌드에서 아예 받지 않으려면 `SKIP_YT_DLP=1 npm run setup`. (검증 실패해도 setup 은 경고만 출력하고 빌드는 계속 진행됩니다.)
- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다. `npm run setup` 이 설치 여부를 검사해 안내 메시지를 출력합니다.
- `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다.
- 하드웨어 인코더(`h264_nvenc` / `h264_qsv` / `h264_videotoolbox` / `h264_vaapi`) — 있으면 자동 감지해 사용. 없으면 `libx264 -preset ultrafast` 폴백. VAAPI 는 `/dev/dri/renderD128` 이 보일 때만 후보로 들어갑니다.
- YouTube 로그인 쿠키 — 연령 제한("Sign in to confirm your age") 영상은 로그인 세션 없이는 yt-dlp 가 받지 못합니다. 관리자 대시보드(`/op/dashboard`)의 "YouTube 쿠키" 패널에서 브라우저 확장으로 내보낸 Netscape 형식 `cookies.txt` 를 등록하면 단일 영상 probe·플레이리스트 probe·실제 다운로드에 모두 `--cookies` 로 적용됩니다. 파일은 `data/cookies.txt` 에 권한 `0600` 으로 저장됩니다(세션 토큰이 들어 있어 git 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다.
## 데이터 위치
```
data/
folders/<폴더이름>/<videoId>/
meta.json # 영상 메타 (제목, trim, 원본/편집본 파일명)
original.<ext> # 원본 (항상 보존)
edited.<ext> # 편집본 (저장 시 생성)
jobs/<jobId>.json # YouTube 다운로드 작업 상태
app.db # 메인 sqlite (폴더 트리, 영상 메타, 세션)
folders/<topName>[/<subName>]/<videoId>/ # 영상 파일
original.<ext> # 원본 (항상 보존)
edited.<ext> # 트림 저장 시 생성
jobs/ # 백그라운드 yt-dlp 다운로드 상태
tmp/ # 작업 임시 (업로드/변환 중간 산출물)
```
폴더 트리 / 영상 메타는 `app.db` 에서 관리하고, 실제 영상 파일은 폴더 경로 + videoId 디렉터리에 저장됩니다. 폴더 이름을 바꾸면 디스크 디렉터리도 같이 rename 됩니다.
## URL 규칙
공개:
- `/` — 최상위 폴더 목록
- `/folder/:topName` — 1단계 폴더 (하위 폴더 + 영상 혼합 표시)
- `/folder/:topName/:subName` — 2단계 폴더 (영상만 표시, 더 깊이 들어갈 수 없음)
- `/video/:topName/:videoId` 또는 `/video/:topName/:subName/:videoId` — 외부 공유용 짧은 URL. 경로의 폴더 부분이 영상 위치와 일치할 때만 200.
- `/api/video/:topName[/:subName]/:videoId` — 실제 파일 스트림 (정규). 재생 페이지와 동일한 폴더 경로 규칙으로, 경로가 영상 위치와 일치할 때만 200. `?edited=0` 으로 원본 강제.
- `/file/video/:videoId` — ID 기반 파일 스트림 (레거시·외부 공유 호환). ID 가 전역에서 유일할 때만 200, 여러 폴더에 같은 ID 가 있으면 404.
관리자(`/op`):
- `/op` — 폴더 목록 (로그인 필요)
- `/op/folder/:topName[/[:subName]]` — 관리자 폴더 화면
- `/op/folder/:topName[/[:subName]]/video/editor` — 영상 추가/편집 화면
## 스펙
### 메인 페이지 (/)
- 동영상이 저장되어있는 폴더를 나열합니다.
- 폴더를 선택해 안에 영상을 확인할수있습니다.
- 폴더를 선택하면 /folder/:폴더이름 으로 이동합니다.
### 메인 페이지 (`/`)
- 최상위 폴더를 카드 그리드로 보여줍니다.
- 폴더를 누르면 `/folder/:폴더이름` 으로 이동.
### 폴더 화면 페이지 (/folder/:폴더이름)
- 폴더 내부에 영상을 유튜브처럼 목록으로 보여줍니다.
- 누르면 재생 플레이어가 뜨며 영상이 재생됩니다.
- 재생플레이어는 /player/:videoId로 합니다.
- 플레이어는 내부팝업처럼 띄워줍니다.
### 폴더 화면 (`/folder/:topName[/:subName]`)
- 1단계 폴더 화면: **하위 폴더 카드 + 이 폴더에 직접 담긴 영상**을 함께 보여줍니다.
- 2단계 폴더 화면: **영상만** 보여줍니다 (썸네일 + 제목). 더 깊이 들어갈 수 없습니다.
- 영상 카드 클릭 → `/video/:topName[/:subName]/:videoId` 로 이동, 플레이어가 뜨고 재생.
### 관리자 페이지 (/op)
- 동영상을 추가, 삭제, 관리하는 페이지입니다.
- https://git.tkrmagid.kr/tkrmagid/minecraft_launcher repo에 사이트에 있는 op페이지에 로그인 로직을 똑같이 가져와서 적용합니다. (navbar도 채택해서 가져오기)
- 메인 페이지처럼 폴더를 나열하고 우클릭으로 폴더 이름 수정, 삭제가 가능하도록 합니다.
- 폴더를 선택하면 /op/folder/:폴더이름 으로 이동합니다.
- 오른쪽위에 폴더추가버튼으로 폴더를 추가할수있도록 합니다.
- 폴더추가버튼 누르면 팝업으로 생성할 폴더이름을 작성한뒤 생성누르면 폴더 생성되게 설정합니다.
### 관리자 페이지 (`/op`)
- 로그인 로직과 navbar 는 `git.tkrmagid.kr/tkrmagid/minecraft_launcher` 의 op 페이지에서 가져온 형식을 따릅니다.
- 메인 페이지처럼 폴더를 나열, 우클릭으로 이름 수정 / 삭제.
- 오른쪽 위 "폴더 추가" 버튼으로 폴더 생성 (팝업에서 이름 입력).
### 관리자 폴더 화면 (/op/folder/:폴더이름)
- 폴더 화면 페이지처럼 유튜브형식으로 영상을 나열하고 우클릭으로 수정, 삭제가 가능하도록 합니다.
- 오른쪽위에 영상추가버튼으로 영상을 추가할수있도록 합니다.
- 영상추가버튼 누르면 /op/folder/:폴더이름/video/editor 로 이동합니다.
### 관리자 폴더 화면 (`/op/folder/...`)
- 영상/서브폴더 목록을 유튜브 스타일로 나열. 우클릭으로 수정/삭제.
- 영상 ID 인라인 편집 가능 (전체 영상에 걸쳐 고유, 안전한 ID 형태만 허용. 변경하면 공유 URL 의 마지막 세그먼트가 같이 바뀝니다).
- 오른쪽 위 "영상 추가" → `/op/folder/.../video/editor` 로 이동. 1단계·2단계 폴더 어디서나 영상을 추가할 수 있습니다 (1단계 폴더에는 "하위 폴더 추가" 버튼도 함께 노출).
- 2단계 폴더에서만 노출되는 "플레이리스트 추가" 버튼: YouTube 플레이리스트 URL 을 넣으면 항목을 미리 보여주고 시작할 수 있습니다.
- 항목별 **썸네일 미리보기**(YouTube `mqdefault`), **드래그 정렬** (정렬 UI 는 플레이리스트 임포트 화면 한정), 제목 / ID 인라인 편집.
- ID 정책 토글:
- **랜덤** — 항목별로 24자 hex (crypto.randomUUID 압축, 폴백은 16진수 24자) 자동 생성.
- **시퀀셜** — 원래 플레이리스트 순서 기준 1, 2, 3, ... 으로 부여. zero-pad 옵션을 켜면 항목 총개수 자릿수에 맞춰 `001, 002, ...` 처럼 패딩.
- "가져오기 시작" 누르면 각 항목이 큐에 들어가고 백그라운드에서 다운로드 + 후처리.
### 영상추가 화면 (/op/folder/:폴더이름/video/editor)
- 예시1: https://github.com/boostcamp-2020/Project13-Web-Video-Editor
- 예시2: https://github.com/mifi/lossless-cut
- 예시1, 예시2 처럼 영상을 수정할수있는 편집기 페이지를 보여줍니다.
- 추가는 컴퓨터에있는 영상을 드래그 드랍 하거나, 직접 폴더에서 찾거나, 유튜브 영상 주소를 추가해서 영상을 추가할수있도록 합니다.
- 유튜브에서 영상 가져오는건 백그라운드에서도 진행 되도록해서 하면서 다른걸 할수있도록 합니다.
- 영상 가져오는 시간을 미리 계산해서 5분 이상 걸릴거같으면 미리 경고를 보냅니다.
- 위쪽에 네비게이션바를 만듭니다.
- 네비게이션바 왼쪽에서 영상 이름을 수정합니다.
- 오른쪽 위에 저장을 누르면 수정한 영상이 저장됩니다.
- 원본 영상과 수정한 내용을 전부 저장해두고, 관리자폴더화면에서 영상을 우클릭해 수정을 누르면 수정하던 화면을 보여줍니다.
### 영상추가 화면 (`/op/folder/.../video/editor`)
- 입력: 컴퓨터 파일 드래그&드롭, 파일 선택 다이얼로그, YouTube URL.
- YouTube URL 의 경우:
1. "확인" 버튼으로 `probe` (title / duration / 예상 파일 크기). probe 가 끝나야 "가져오기" 버튼이 활성화됩니다 (probe 전에 가져오기 누르는 것 자체가 막힘).
2. 소요시간이 5분 이상으로 추정되면 경고를 띄움.
3. 백그라운드 잡이 시작되고, 다른 페이지를 만지더라도 진행 상황이 계속 업데이트됩니다.
- 진행률 바: yt-dlp 가 video / audio 스트림을 순차로 받으면서 매번 0%→100% 로 보고하는 걸 누적해 부드럽게 0→50% 로 climb 합니다. 50→99% 구간은 후처리(60fps 변환 + FHD 캡) 진행률. ffmpeg 가 잠시 진행률을 안 뱉어도 멈춰 보이지 않도록 2초마다 경과시간 메시지가 갱신됩니다.
- 다운로드 해상도는 강제 FHD 캡. 원본이 FHD 이하면 그대로, 초과면 강제로 1920×1080 이하로 다운스케일.
- 다운로드 후 자동 후처리:
- 원본이 60fps 미만이면 fps=60 프레임 복제로 끌어올림.
- 해상도가 FHD 초과면 FHD 로 다운스케일 (종횡비 유지, 짝수 변 보정).
- 위 둘 모두 불필요하면 인코딩 자체를 건너뜁니다.
- 위쪽 navbar 왼쪽에서 영상 이름 수정, 오른쪽 위 "저장" 으로 트림 적용. 원본은 항상 보존, 편집본은 `edited.<ext>` 로 따로 저장. 다시 편집해도 원본에서 시작합니다.
## 성능 메모
- 60fps/FHD 변환에 호스트 환경에서 사용 가능한 하드웨어 H.264 인코더를 자동 감지해 우선 사용합니다 (NVENC > QSV > VideoToolbox > VAAPI > libx264 ultrafast). 1프레임 lavfi 테스트로 실제 동작을 확인한 뒤 캐시합니다.
- 1프레임 테스트는 통과했지만 실파일 인코딩에서 깨지는 HW 인코더가 있어, 그 잡 한 건에 한해 libx264 ultrafast 로 자동 재시도합니다 (캐시는 그대로 둠 — 다음 잡 입력이 더 일반적이면 HW 가 다시 돕니다).
- 플레이리스트 임포트는 한 번에 너무 많은 yt-dlp 프로세스가 떠 디스크/CPU 를 죽이지 않도록 `MAX_DOWNLOAD_CONCURRENCY` (기본 2) 로 제한된 풀에서 순차로 시작됩니다. 대기 항목은 `queued` 상태로 큐에서 대기.

View File

@@ -79,4 +79,123 @@
})
})
}
// 도구 관리(ffmpeg / yt-dlp)
function renderTool(name, tool) {
var card = document.querySelector('.toolCard[data-tool="' + name + '"]')
if (!card) return
var versionEl = card.querySelector('[data-field="version"]')
var pathEl = card.querySelector('[data-field="path"]')
if (!tool || !tool.available) {
versionEl.textContent = '설치되지 않음'
pathEl.textContent = ''
return
}
versionEl.textContent = (tool.version || '버전 미상') + (tool.managed ? ' · 직접 설치본' : '')
pathEl.textContent = tool.path || ''
}
function loadTools() {
fetch('/op/tools').then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) return
renderTool('yt-dlp', j.tools.ytDlp)
renderTool('ffmpeg', j.tools.ffmpeg)
}).catch(function () {})
}
document.querySelectorAll('[data-action="update-tool"]').forEach(function (btn) {
btn.addEventListener('click', function () {
var name = btn.getAttribute('data-name')
var card = btn.closest('.toolCard')
var statusEl = card.querySelector('[data-field="status"]')
btn.disabled = true
statusEl.textContent = '재설치 중… (수십 초 걸릴 수 있습니다)'
fetch('/op/tools/' + encodeURIComponent(name) + '/update', { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j.ok) {
statusEl.textContent = '완료'
renderTool(name, j.tool)
} else {
statusEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
}
})
.catch(function (err) {
statusEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
})
.then(function () { btn.disabled = false })
})
})
if (document.querySelector('.toolsSection')) loadTools()
// YouTube 쿠키 관리
var cookieSection = document.getElementById('cookiesSection')
if (cookieSection) {
var cookieStatusEl = document.getElementById('cookieStatus')
var cookieFileEl = document.getElementById('cookieFile')
var cookieUploadBtn = document.getElementById('cookieUploadBtn')
var cookieDeleteBtn = document.getElementById('cookieDeleteBtn')
var cookieMsgEl = document.getElementById('cookieMsg')
function renderCookies(c) {
if (c && c.present) {
var kb = Math.max(1, Math.round(c.bytes / 1024))
var when = c.updatedAt ? new Date(c.updatedAt).toLocaleString() : ''
cookieStatusEl.textContent = '등록됨 (' + kb + ' KB' + (when ? ' · ' + when : '') + ')'
cookieDeleteBtn.hidden = false
} else {
cookieStatusEl.textContent = '등록되지 않음'
cookieDeleteBtn.hidden = true
}
}
function loadCookies() {
fetch('/op/cookies').then(function (r) { return r.json() }).then(function (j) {
if (j.ok) renderCookies(j.cookies)
}).catch(function () {})
}
cookieUploadBtn.addEventListener('click', function () {
var file = cookieFileEl.files && cookieFileEl.files[0]
if (!file) { cookieMsgEl.textContent = '파일을 선택해 주세요.'; return }
var fd = new FormData()
fd.append('file', file)
cookieUploadBtn.disabled = true
cookieMsgEl.textContent = '등록 중…'
fetch('/op/cookies', { method: 'POST', body: fd })
.then(function (r) { return r.json() })
.then(function (j) {
if (j.ok) {
cookieMsgEl.textContent = '등록 완료'
cookieFileEl.value = ''
renderCookies(j.cookies)
} else {
cookieMsgEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
}
})
.catch(function (err) {
cookieMsgEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
})
.then(function () { cookieUploadBtn.disabled = false })
})
cookieDeleteBtn.addEventListener('click', function () {
if (!window.confirm('등록된 YouTube 쿠키를 삭제할까요?')) return
cookieDeleteBtn.disabled = true
cookieMsgEl.textContent = '삭제 중…'
fetch('/op/cookies/delete', { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j.ok) { cookieMsgEl.textContent = '삭제됨'; renderCookies(j.cookies) }
else cookieMsgEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
})
.catch(function (err) {
cookieMsgEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
})
.then(function () { cookieDeleteBtn.disabled = false })
})
loadCookies()
}
})()

View File

@@ -56,7 +56,7 @@
changeIdBtn.disabled = true
clearTimeout(idDebounce)
idDebounce = setTimeout(function () {
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
@@ -87,7 +87,7 @@
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ newId: newId })
body: JSON.stringify({ newId: newId, folderId: ctx.folderId })
}).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
@@ -464,7 +464,8 @@
var payload = {
title: titleInput.value,
startSec: trimStart,
endSec: trimEnd
endSec: trimEnd,
folderId: ctx.folderId
}
saveBtn.disabled = true
saveBtn.textContent = '저장 중...'

View File

@@ -39,7 +39,7 @@
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: t })
body: JSON.stringify({ title: t, folderId: op.folderId })
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || '이름 변경 실패')
@@ -51,7 +51,7 @@
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ newId: newId })
body: JSON.stringify({ newId: newId, folderId: op.folderId })
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || 'ID 변경 실패')
@@ -60,7 +60,9 @@
} else if (action === 'delete') {
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
method: 'POST'
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ folderId: op.folderId })
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || '삭제 실패')

View File

@@ -11,7 +11,11 @@
var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId))
history.pushState({ player: true, videoId: videoId }, '', url)
titleEl.textContent = title || ''
video.src = '/api/video/' + encodeURIComponent(videoId) + '/file'
// 파일 스트림 URL 은 공유 URL(/video/폴더/.../아이디)의 /video/ 를
// /api/video/ 로 바꾼 폴더 경로 기반 주소. shareUrl 이 없으면 레거시 폴백.
video.src = shareUrl
? shareUrl.replace(/^\/video\//, '/api/video/')
: ('/file/video/' + encodeURIComponent(videoId))
overlay.hidden = false
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
}

View File

@@ -17,9 +17,12 @@
var startUrl = folderBaseUrl + '/playlist/start'
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
var DEFAULT_LENGTH = 15 // 기본 재생 길이(초)
// ─── state ────────────────────────────────────────────────────────────
// entries: [{ url, title, videoId, durationSec, idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
// entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId,
// startSec:number(기본0), lengthSec:number(기본15, 시작부터 재생할 초),
// idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
var state = {
entries: [],
idMode: 'random', // 'random' | 'sequential'
@@ -39,6 +42,12 @@
var zeroPadToggle = document.getElementById('zeroPadToggle')
var registerBtn = document.getElementById('registerBtn')
var registerStatus = document.getElementById('registerStatus')
var probeSection = document.getElementById('probeSection')
var progressSection = document.getElementById('progressSection')
var progressList = document.getElementById('progressList')
var progressSummary = document.getElementById('progressSummary')
var progressOverall = document.getElementById('progressOverall')
var progressDoneBtn = document.getElementById('progressDoneBtn')
// ─── 유틸 ─────────────────────────────────────────────────────────────
function randomId() {
@@ -131,7 +140,7 @@
entry.idDebounce = setTimeout(function () {
var current = state.entries[idx]
if (!current || current.videoId !== v) return
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
var c = state.entries[idx]
@@ -175,6 +184,7 @@
li.innerHTML = ''
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
+ '<span class="entryNum">' + (idx + 1) + '</span>'
+ '<div class="entryThumb" title="클릭하면 재생"></div>'
+ '<div class="entryFields">'
+ '<input type="text" class="entryTitleInput" />'
+ '<div class="entryIdRow">'
@@ -182,17 +192,37 @@
+ '<span class="entryIdStatus muted"></span>'
+ '</div>'
+ '</div>'
+ '<div class="entryTrim">'
+ '<label>시작 <input type="text" class="entryStartInput" autocomplete="off" /></label>'
+ '<label>길이(초) <input type="text" class="entryLengthInput" autocomplete="off" /></label>'
+ '</div>'
+ '<span class="entryDuration muted"></span>'
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
var thumbBox = li.querySelector('.entryThumb')
var titleInput = li.querySelector('.entryTitleInput')
var idInput = li.querySelector('.entryIdInput')
var startInput = li.querySelector('.entryStartInput')
var lengthInput = li.querySelector('.entryLengthInput')
var durSpan = li.querySelector('.entryDuration')
var removeBtn = li.querySelector('.entryRemove')
if (entry.thumbnailUrl) {
var img = document.createElement('img')
img.src = entry.thumbnailUrl
img.alt = ''
img.loading = 'lazy'
// 이미지가 깨지면(예: 비공개/삭제 영상) 빈 박스로 둔다.
img.addEventListener('error', function () { thumbBox.classList.add('entryThumbEmpty') })
thumbBox.appendChild(img)
} else {
thumbBox.classList.add('entryThumbEmpty')
}
titleInput.value = entry.title || ''
idInput.value = entry.videoId || ''
durSpan.textContent = formatDuration(entry.durationSec)
paintTrimInputs(idx, startInput, lengthInput)
titleInput.addEventListener('input', function () {
state.entries[idx].title = titleInput.value
@@ -203,6 +233,26 @@
// 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신
revalidateOtherIds(idx)
})
startInput.addEventListener('change', function () {
var e = state.entries[idx]
var sec = parseClock(startInput.value)
e.startSec = sec != null && sec > 0 ? sec : 0
paintTrimInputs(idx, startInput, lengthInput)
})
lengthInput.addEventListener('change', function () {
var e = state.entries[idx]
var sec = parseClock(lengthInput.value)
e.lengthSec = sec != null && sec > 0 ? sec : DEFAULT_LENGTH
paintTrimInputs(idx, startInput, lengthInput)
})
thumbBox.addEventListener('click', function () { openPreview(idx) })
li.addEventListener('contextmenu', function (ev) {
// input/버튼 위에서의 우클릭은 기본 메뉴를 살려 둔다 (텍스트 편집 편의).
var t = ev.target
if (t && (t.tagName === 'INPUT' || t.tagName === 'BUTTON')) return
ev.preventDefault()
openEntryCtxMenu(idx, ev.clientX, ev.clientY)
})
removeBtn.addEventListener('click', function () {
state.entries.splice(idx, 1)
// sequential 이면 번호 재계산
@@ -215,6 +265,29 @@
return li
}
// 항목의 시작/길이 입력칸 값을 상태로부터 다시 그린다.
function paintTrimInputs(idx, startInput, lengthInput) {
var e = state.entries[idx]
if (!e) return
if (!startInput) {
var row = findRow(idx)
if (!row) return
startInput = row.querySelector('.entryStartInput')
lengthInput = row.querySelector('.entryLengthInput')
if (!startInput || !lengthInput) return
}
startInput.value = formatClock(e.startSec || 0)
lengthInput.value = formatLength(e.lengthSec)
}
function findRow(idx) {
var rows = entryList.children
for (var i = 0; i < rows.length; i++) {
if (Number(rows[i].dataset.idx) === idx) return rows[i]
}
return null
}
function paintEntryStatus(idx) {
var rows = entryList.children
for (var i = 0; i < rows.length; i++) {
@@ -364,7 +437,11 @@
url: e.url,
title: e.title || '제목 없음',
durationSec: e.durationSec,
thumbnailUrl: e.thumbnailUrl || null,
ytId: extractYouTubeId(e.url),
videoId: '',
startSec: 0,
lengthSec: DEFAULT_LENGTH,
idStatus: 'pending',
idReason: ''
}
@@ -390,7 +467,9 @@
return {
url: e.url,
title: (e.title || '').trim(),
videoId: (e.videoId || '').trim()
videoId: (e.videoId || '').trim(),
startSec: e.startSec || 0,
endSec: computeEndSec(e)
}
})
}
@@ -411,13 +490,7 @@
updateRegisterEnable()
return
}
setRegisterStatus(
(res.body.jobs ? res.body.jobs.length : 0) + ' 개 다운로드가 큐에 등록되었습니다. 폴더 페이지로 이동합니다…',
'idStatusOk'
)
setTimeout(function () {
location.href = folderBaseUrl
}, 800)
startDownloadProgress(res.body.jobs || [])
})
.catch(function (err) {
state.busy = false
@@ -426,6 +499,732 @@
})
})
// ─── 다운로드 진행 화면 ────────────────────────────────────────────────
// 등록 후 폴더로 바로 나가지 않고, 각 잡을 폴링하며 진행률을 보여준다.
// (백엔드는 잡을 큐에 넣고 순차 처리 — queued/downloading/done/error.)
function startDownloadProgress(jobs) {
if (probeSection) probeSection.hidden = true
if (entriesSection) entriesSection.hidden = true
if (progressSection) progressSection.hidden = false
var items = jobs.map(function (j, i) {
var title = (state.entries[i] && state.entries[i].title) || j.videoId
return {
jobId: j.jobId,
videoId: j.videoId,
title: title,
status: 'queued',
progress: 0,
message: '대기 중',
done: false,
error: false,
barEl: null,
msgEl: null
}
})
renderProgressRows(items)
updateProgressUI(items)
if (items.length === 0) { finishProgress(items); return }
pollAllJobs(items)
}
function renderProgressRows(items) {
progressList.innerHTML = ''
items.forEach(function (it) {
var li = document.createElement('li')
li.className = 'progressRow'
var name = document.createElement('div')
name.className = 'progressName'
name.textContent = it.title
var bar = document.createElement('progress')
bar.className = 'progressBar'
bar.max = 100
bar.value = 0
var msg = document.createElement('div')
msg.className = 'progressMsg muted'
msg.textContent = it.message
var retry = document.createElement('button')
retry.type = 'button'
retry.className = 'secondaryButton progressRetry'
retry.textContent = '재시도'
retry.hidden = true
retry.addEventListener('click', function () { retryItem(it, items) })
li.appendChild(name)
li.appendChild(bar)
li.appendChild(msg)
li.appendChild(retry)
progressList.appendChild(li)
it.barEl = bar
it.msgEl = msg
it.rowEl = li
it.retryEl = retry
})
}
// 실패한 항목을 같은 ID·URL·자르기 설정으로 다시 받는다. 백엔드가 기존 영상
// 레코드를 재사용하므로 ID 충돌 없이 새 잡이 시작된다.
function retryItem(it, items) {
if (!it.error || it.retrying) return
it.retrying = true
if (it.retryEl) { it.retryEl.disabled = true; it.retryEl.textContent = '재시도 중…' }
fetch('/op/job/' + encodeURIComponent(it.jobId) + '/retry', { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok) {
it.jobId = j.jobId
it.done = false
it.error = false
it.retrying = false
it.status = 'queued'
it.progress = 0
it.message = '재시도 대기 중'
if (it.barEl) it.barEl.classList.remove('progressBarError')
if (it.retryEl) { it.retryEl.hidden = true; it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
updateProgressUI(items)
if (progressDoneBtn) progressDoneBtn.hidden = true
if (!polling) pollAllJobs(items)
} else {
it.retrying = false
if (it.retryEl) { it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
it.message = '재시도 실패: ' + ((j && j.message) || '오류')
updateProgressUI(items)
}
})
.catch(function (err) {
it.retrying = false
if (it.retryEl) { it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
it.message = '재시도 실패: ' + (err && err.message ? err.message : '요청 오류')
updateProgressUI(items)
})
}
var polling = false
function pollAllJobs(items) {
var pending = items.filter(function (it) { return !it.done })
if (pending.length === 0) { polling = false; finishProgress(items); return }
polling = true
Promise.all(pending.map(function (it) {
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok && j.job) {
it.status = j.job.status
it.message = j.job.message || ''
it.progress = typeof j.job.progress === 'number' ? j.job.progress : it.progress
if (j.job.status === 'done') {
it.done = true
it.progress = 100
it.message = '완료'
} else if (j.job.status === 'error') {
it.done = true
it.error = true
it.message = '실패: ' + (j.job.error || j.job.message || '')
}
}
})
.catch(function () { /* 일시 오류는 다음 틱에 재시도 */ })
})).then(function () {
updateProgressUI(items)
if (items.some(function (it) { return !it.done })) {
setTimeout(function () { pollAllJobs(items) }, 1500)
} else {
polling = false
finishProgress(items)
}
})
}
function updateProgressUI(items) {
var total = items.length
var doneCount = 0
var sum = 0
items.forEach(function (it) {
if (it.barEl) {
it.barEl.value = it.progress
if (it.error) it.barEl.classList.add('progressBarError')
}
if (it.msgEl) it.msgEl.textContent = it.message
if (it.rowEl) {
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
it.rowEl.classList.toggle('progressRowError', it.error)
}
if (it.retryEl && !it.retrying) it.retryEl.hidden = !it.error
if (it.done) doneCount += 1
sum += it.progress
})
if (progressOverall) progressOverall.value = total ? Math.round(sum / total) : 0
if (progressSummary) progressSummary.textContent = '다운로드 ' + doneCount + ' / ' + total + ' 완료'
}
function finishProgress(items) {
var errCount = items.filter(function (it) { return it.error }).length
if (progressOverall) progressOverall.value = 100
if (progressSummary) {
progressSummary.textContent = errCount
? ('완료 — ' + items.length + ' 개 중 ' + errCount + ' 개 실패')
: ('전체 ' + items.length + ' 개 다운로드 완료')
}
var statusEl = document.getElementById('progressStatus')
if (statusEl) {
statusEl.textContent = errCount
? '실패한 항목은 아래 "재시도" 버튼으로 다시 받을 수 있습니다. (연령 제한 영상은 먼저 대시보드에서 YouTube 쿠키를 등록하세요.)'
: '모든 영상이 폴더에 추가되었습니다.'
}
if (progressDoneBtn) progressDoneBtn.hidden = false
}
// ─── 시간 파싱/표시 + YouTube id 추출 ──────────────────────────────────
// "ss" / "mm:ss" / "hh:mm:ss" / 소수 초 를 모두 받아 초(number)로. 빈칸/오류는 null.
function parseClock(str) {
if (str == null) return null
str = String(str).trim()
if (!str) return null
var total = 0
if (str.indexOf(':') >= 0) {
var parts = str.split(':')
for (var i = 0; i < parts.length; i++) {
var n = Number(parts[i])
if (!isFinite(n) || n < 0) return null
total = total * 60 + n
}
} else {
var v = Number(str)
if (!isFinite(v) || v < 0) return null
total = v
}
return total
}
// 입력칸 표시용. formatDuration 과 동일 포맷(mm:ss / h:mm:ss).
function formatClock(sec) {
if (sec == null || !isFinite(sec)) return ''
return formatDuration(sec)
}
function extractYouTubeId(url) {
if (!url) return null
var m = String(url).match(
/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/
)
return m ? m[1] : null
}
// 재생 길이(초) 표시용. 정수면 "15", 소수면 "12.3".
function formatLength(sec) {
if (sec == null || !isFinite(sec) || sec <= 0) return ''
var r = Math.round(sec * 10) / 10
return r % 1 === 0 ? String(r) : r.toFixed(1)
}
// 시작 + 길이 → 백엔드용 종료초. 영상 끝을 넘으면 null(끝까지)로.
function computeEndSec(e) {
var start = e.startSec || 0
var len = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH
var end = start + len
if (e.durationSec != null && end >= e.durationSec - 0.01) return null
return end
}
function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
// ─── 일괄 시작/길이 적용 + 초기화 ──────────────────────────────────────
var bulkStart = document.getElementById('bulkStart')
var bulkLength = document.getElementById('bulkLength')
var bulkApplyBtn = document.getElementById('bulkApplyBtn')
var bulkResetBtn = document.getElementById('bulkResetBtn')
bulkApplyBtn.addEventListener('click', function () {
var s = parseClock(bulkStart.value)
var startSec = s != null && s > 0 ? s : 0
var len = parseClock(bulkLength.value)
var lengthSec = len != null && len > 0 ? len : DEFAULT_LENGTH
for (var i = 0; i < state.entries.length; i++) {
var e = state.entries[i]
e.startSec = startSec
e.lengthSec = lengthSec
}
render()
})
bulkResetBtn.addEventListener('click', function () {
for (var i = 0; i < state.entries.length; i++) {
state.entries[i].startSec = 0
state.entries[i].lengthSec = DEFAULT_LENGTH
}
bulkStart.value = ''
bulkLength.value = ''
render()
})
// ─── YouTube IFrame API 준비 ───────────────────────────────────────────
var ytReady = false
var ytReadyCbs = []
window.onYouTubeIframeAPIReady = function () {
ytReady = true
var cbs = ytReadyCbs; ytReadyCbs = []
cbs.forEach(function (cb) { cb() })
}
function whenYt(cb) {
if (ytReady && window.YT && window.YT.Player) cb()
else ytReadyCbs.push(cb)
}
function resetHost(wrap, id) {
if (!wrap) return
wrap.innerHTML = '<div id="' + id + '"></div>'
}
// ─── 미리보기 재생 모달 ────────────────────────────────────────────────
var previewOverlay = document.getElementById('previewOverlay')
var previewClose = document.getElementById('previewClose')
var previewTitle = document.getElementById('previewTitle')
var previewPlayer = null
function openPreview(idx) {
var e = state.entries[idx]
if (!e) return
if (!e.ytId) {
setRegisterStatus('이 항목은 YouTube 영상이 아니라 미리보기를 지원하지 않습니다.', 'idStatusBad')
return
}
previewTitle.textContent = e.title || ''
previewOverlay.hidden = false
whenYt(function () {
if (previewOverlay.hidden) return // API 준비 전에 닫혔으면 무시
destroyPreview()
resetHost(previewOverlay.querySelector('.ytEmbedWrap'), 'previewPlayer')
previewPlayer = new YT.Player('previewPlayer', {
videoId: e.ytId,
playerVars: { autoplay: 1, rel: 0, modestbranding: 1, start: Math.floor(e.startSec || 0) }
})
})
}
function destroyPreview() {
if (previewPlayer) { try { previewPlayer.destroy() } catch (_) {} previewPlayer = null }
}
function closePreview() {
previewOverlay.hidden = true
destroyPreview()
}
previewClose.addEventListener('click', closePreview)
previewOverlay.addEventListener('click', function (e) {
if (e.target === previewOverlay) closePreview()
})
// ─── 우클릭 컨텍스트 메뉴 (자르기) ─────────────────────────────────────
var entryCtxMenu = document.getElementById('entryCtxMenu')
var ctxIdx = -1
function openEntryCtxMenu(idx, x, y) {
ctxIdx = idx
entryCtxMenu.hidden = false
// 화면 밖으로 넘치지 않게 살짝 보정
var mw = 180, mh = 60
entryCtxMenu.style.left = Math.min(x, window.innerWidth - mw) + 'px'
entryCtxMenu.style.top = Math.min(y, window.innerHeight - mh) + 'px'
}
function closeCtxMenu() { entryCtxMenu.hidden = true }
document.addEventListener('click', closeCtxMenu)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeCtxMenu(); closePreview(); closeTrim() }
})
entryCtxMenu.querySelector('[data-action="trim"]').addEventListener('click', function () {
var idx = ctxIdx
closeCtxMenu()
if (idx >= 0) openTrim(idx)
})
// ─── 자르기(트림) 모달 — YouTube 임베드 + 타임라인 ─────────────────────
var trimOverlay = document.getElementById('trimOverlay')
var trimClose = document.getElementById('trimClose')
var trimCancel = document.getElementById('trimCancel')
var trimApply = document.getElementById('trimApply')
var trimTitle = document.getElementById('trimTitle')
var plTrimBar = document.getElementById('plTrimBar')
var plTrimRangeFill = document.getElementById('plTrimRangeFill')
var plTrimHandleStart = document.getElementById('plTrimHandleStart')
var plTrimHandleEnd = document.getElementById('plTrimHandleEnd')
var plTrimPlayhead = document.getElementById('plTrimPlayhead')
var plTimeReadout = document.getElementById('plTimeReadout')
var plTrimDuration = document.getElementById('plTrimDuration')
var plStartSec = document.getElementById('plStartSec')
var plLengthSec = document.getElementById('plLengthSec')
var MIN_SEL = 0.05
var trimPlayer = null
var trimPoll = null
var selStopAt = null // 선택 재생 정지 지점(초). null 이면 비활성.
// 현재 편집 중 컨텍스트: { idx, duration, start, end(null=끝까지) }
var tctx = null
function tEnd() { return tctx.end != null ? tctx.end : tctx.duration }
function formatTimeF(sec) {
if (!isFinite(sec) || sec < 0) sec = 0
var m = Math.floor(sec / 60)
var s = sec - m * 60
var sStr = s.toFixed(1)
if (s < 10) sStr = '0' + sStr
return (m < 10 ? '0' + m : '' + m) + ':' + sStr
}
function renderTrimBar() {
if (!tctx) return
var d = tctx.duration || 0
var end = tEnd()
var startPct = d ? (tctx.start / d) * 100 : 0
var endPct = d ? (end / d) * 100 : 0
plTrimRangeFill.style.left = startPct + '%'
plTrimRangeFill.style.width = Math.max(0, endPct - startPct) + '%'
plTrimHandleStart.style.left = startPct + '%'
plTrimHandleEnd.style.left = endPct + '%'
plTrimDuration.textContent = '선택: ' + (end - tctx.start).toFixed(1) + '초'
plStartSec.value = (tctx.start || 0).toFixed(2)
// 끝 대신 재생 길이(초)를 보여준다 (끝까지면 영상 끝 - 시작).
plLengthSec.value = Math.max(0, end - tctx.start).toFixed(2)
}
function renderPlayhead() {
if (!tctx) return
var d = tctx.duration || 0
var cur = 0
if (trimPlayer && trimPlayer.getCurrentTime) {
try { cur = trimPlayer.getCurrentTime() || 0 } catch (_) { cur = 0 }
}
plTrimPlayhead.style.left = (d ? clampNum((cur / d) * 100, 0, 100) : 0) + '%'
plTimeReadout.textContent = formatTimeF(cur) + ' / ' + formatTimeF(d)
if (selStopAt != null && cur >= selStopAt - 0.05) {
try { trimPlayer.pauseVideo() } catch (_) {}
selStopAt = null
}
}
function openTrim(idx) {
var e = state.entries[idx]
if (!e) return
if (!e.ytId) {
setRegisterStatus('이 항목은 YouTube 영상이 아니라 자르기를 지원하지 않습니다.', 'idStatusBad')
return
}
trimTitle.textContent = e.title || ''
trimOverlay.hidden = false
// 항목의 시작 + 길이를 타임라인의 시작/끝으로 변환. 영상 길이를 넘으면 끝까지로.
var startSec = e.startSec || 0
var lenSec = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH
var dur = e.durationSec || 0
var endSec = startSec + lenSec
if (dur && endSec >= dur - 0.01) endSec = null
tctx = {
idx: idx,
duration: dur,
start: startSec,
end: endSec
}
selStopAt = null
renderTrimBar()
renderPlayhead()
whenYt(function () {
if (trimOverlay.hidden) return // API 준비 전에 닫혔으면 무시
destroyTrimPlayer()
resetHost(trimOverlay.querySelector('.ytEmbedWrap'), 'trimPlayer')
trimPlayer = new YT.Player('trimPlayer', {
videoId: e.ytId,
playerVars: { rel: 0, modestbranding: 1, start: Math.floor(tctx.start || 0) },
events: {
onReady: function () {
var d = 0
try { d = trimPlayer.getDuration() } catch (_) {}
if (d && isFinite(d) && d > 0) {
tctx.duration = d
tctx.start = clampNum(tctx.start, 0, Math.max(0, d - MIN_SEL))
if (tctx.end != null) tctx.end = clampNum(tctx.end, tctx.start + MIN_SEL, d)
renderTrimBar()
}
startTrimPoll()
}
}
})
})
}
function startTrimPoll() {
if (trimPoll) clearInterval(trimPoll)
trimPoll = setInterval(renderPlayhead, 200)
}
function destroyTrimPlayer() {
if (trimPoll) { clearInterval(trimPoll); trimPoll = null }
if (trimPlayer) { try { trimPlayer.destroy() } catch (_) {} trimPlayer = null }
}
function closeTrim() {
trimOverlay.hidden = true
selStopAt = null
destroyTrimPlayer()
tctx = null
}
trimClose.addEventListener('click', closeTrim)
trimCancel.addEventListener('click', closeTrim)
trimOverlay.addEventListener('click', function (e) {
if (e.target === trimOverlay) closeTrim()
})
trimApply.addEventListener('click', function () {
if (!tctx) return
var e = state.entries[tctx.idx]
if (e) {
e.startSec = tctx.start > 0 ? tctx.start : 0
// 타임라인의 끝(없으면 영상 끝) - 시작 = 재생 길이.
var end = tctx.end != null ? tctx.end : tctx.duration
var len = end - e.startSec
e.lengthSec = len > 0 ? len : DEFAULT_LENGTH
if (tctx.duration && e.durationSec == null) e.durationSec = tctx.duration
paintTrimInputs(tctx.idx)
}
closeTrim()
})
// 타임라인 드래그/클릭/마크/숫자입력
function trimClientXtoTime(clientX) {
var rect = plTrimBar.getBoundingClientRect()
var ratio = clampNum((clientX - rect.left) / rect.width, 0, 1)
return ratio * (tctx ? tctx.duration : 0)
}
function attachTrimHandle(handle, which) {
handle.addEventListener('pointerdown', function (e) {
if (!tctx || !tctx.duration) return
e.preventDefault()
handle.setPointerCapture(e.pointerId)
handle.classList.add('dragging')
function onMove(ev) {
var t = trimClientXtoTime(ev.clientX)
if (which === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL)
else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration)
renderTrimBar()
}
function onUp() {
handle.classList.remove('dragging')
handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp)
handle.removeEventListener('pointercancel', onUp)
}
handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp)
handle.addEventListener('pointercancel', onUp)
})
}
attachTrimHandle(plTrimHandleStart, 'start')
attachTrimHandle(plTrimHandleEnd, 'end')
plTrimBar.addEventListener('pointerdown', function (e) {
if (!tctx || !tctx.duration) return
if (e.target === plTrimHandleStart || e.target === plTrimHandleEnd) return
var t = trimClientXtoTime(e.clientX)
if (trimPlayer && trimPlayer.seekTo) { try { trimPlayer.seekTo(t, true) } catch (_) {} }
renderPlayhead()
})
trimOverlay.querySelectorAll('[data-pmark]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!tctx || !tctx.duration || !trimPlayer) return
var t = 0
try { t = trimPlayer.getCurrentTime() || 0 } catch (_) {}
if (btn.getAttribute('data-pmark') === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL)
else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration)
renderTrimBar()
})
})
trimOverlay.querySelectorAll('[data-paction]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.getAttribute('data-paction')
if (action === 'reset') {
if (!tctx) return
tctx.start = 0; tctx.end = null
renderTrimBar()
} else if (action === 'playSelection') {
if (!tctx || !trimPlayer) return
try {
trimPlayer.seekTo(tctx.start, true)
trimPlayer.playVideo()
} catch (_) {}
selStopAt = tEnd()
}
})
})
plStartSec.addEventListener('change', function () {
if (!tctx) return
var v = Number(plStartSec.value || 0)
if (!isFinite(v) || v < 0) v = 0
tctx.start = tctx.duration ? clampNum(v, 0, tEnd() - MIN_SEL) : v
renderTrimBar()
})
plLengthSec.addEventListener('change', function () {
if (!tctx) return
// 길이(초) 입력 → 끝 = 시작 + 길이.
var v = Number(plLengthSec.value)
if (!isFinite(v) || v <= 0) { renderTrimBar(); return }
var end = tctx.start + v
tctx.end = tctx.duration ? clampNum(end, tctx.start + MIN_SEL, tctx.duration) : end
renderTrimBar()
})
// ─── 임시저장(draft) ───────────────────────────────────────────────────
// 다운로드 시작 전, 수정해둔 값(URL + 항목별 제목/ID/시작/길이 등)을 폴더당 1개 저장/복원.
var draftUrl = folderBaseUrl + '/playlist/draft'
var draftDeleteUrl = folderBaseUrl + '/playlist/draft/delete'
var draftSaveBtn = document.getElementById('draftSaveBtn')
var draftLoadBtn = document.getElementById('draftLoadBtn')
var draftDeleteBtn = document.getElementById('draftDeleteBtn')
var draftStatus = document.getElementById('draftStatus')
function setDraftStatus(text, cls) {
if (!draftStatus) return
draftStatus.textContent = text || ''
draftStatus.className = cls || 'muted'
}
function formatSavedAt(iso) {
try {
var d = new Date(iso)
if (isNaN(d.getTime())) return ''
return d.toLocaleString()
} catch (_) { return '' }
}
// 저장 대상: 편집 가능한 값만 추린다 (idStatus/idDebounce 같은 휘발 상태 제외).
function serializeDraft() {
return {
url: (urlInput.value || '').trim(),
idMode: state.idMode,
zeroPad: state.zeroPad,
entries: state.entries.map(function (e) {
return {
url: e.url,
title: e.title || '',
videoId: e.videoId || '',
durationSec: e.durationSec != null ? e.durationSec : null,
thumbnailUrl: e.thumbnailUrl || null,
ytId: e.ytId || null,
startSec: e.startSec || 0,
lengthSec: e.lengthSec != null ? e.lengthSec : DEFAULT_LENGTH
}
})
}
}
// idMode 라디오 / zeroPad 토글을 상태에 맞춰 반영.
function reflectIdModeControls() {
for (var i = 0; i < idModeRadios.length; i++) {
idModeRadios[i].checked = idModeRadios[i].value === state.idMode
}
zeroPadWrap.hidden = state.idMode !== 'sequential'
zeroPadToggle.checked = !!state.zeroPad
}
function restoreDraft(d) {
if (!d || typeof d !== 'object') return
urlInput.value = d.url || ''
state.idMode = d.idMode === 'sequential' ? 'sequential' : 'random'
state.zeroPad = !!d.zeroPad
var raw = Array.isArray(d.entries) ? d.entries : []
state.entries = raw.map(function (e) {
return {
url: e.url,
title: e.title || '',
videoId: (e.videoId || '').trim(),
durationSec: e.durationSec != null ? e.durationSec : null,
thumbnailUrl: e.thumbnailUrl || null,
ytId: e.ytId || extractYouTubeId(e.url),
startSec: e.startSec || 0,
lengthSec: e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH,
idStatus: 'pending',
idReason: ''
}
})
reflectIdModeControls()
render()
entriesSection.hidden = state.entries.length === 0
// 저장된 ID 는 그대로 두고(자동 재생성 금지), 가용성만 다시 확인.
for (var i = 0; i < state.entries.length; i++) scheduleIdCheck(i, 0)
}
function showDraftButtons(updatedAt) {
if (draftLoadBtn) draftLoadBtn.hidden = false
if (draftDeleteBtn) draftDeleteBtn.hidden = false
setDraftStatus('임시저장됨: ' + formatSavedAt(updatedAt), 'muted')
}
function hideDraftButtons() {
if (draftLoadBtn) draftLoadBtn.hidden = true
if (draftDeleteBtn) draftDeleteBtn.hidden = true
}
if (draftSaveBtn) {
draftSaveBtn.addEventListener('click', function () {
draftSaveBtn.disabled = true
setDraftStatus('저장 중…', 'muted')
fetch(draftUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: serializeDraft() })
})
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok) {
showDraftButtons(j.updatedAt)
setDraftStatus('임시저장 완료 (' + formatSavedAt(j.updatedAt) + ')', 'idStatusOk')
} else {
setDraftStatus((j && j.message) || '저장 실패', 'idStatusBad')
}
})
.catch(function (err) {
setDraftStatus('저장 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
})
.finally(function () { draftSaveBtn.disabled = false })
})
}
if (draftLoadBtn) {
draftLoadBtn.addEventListener('click', function () {
if (state.entries.length > 0 &&
!window.confirm('현재 편집 중인 목록을 임시저장 내용으로 덮어쓸까요?')) {
return
}
draftLoadBtn.disabled = true
setDraftStatus('불러오는 중…', 'muted')
fetch(draftUrl, { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok && j.draft) {
var data
try { data = JSON.parse(j.draft.data) } catch (_) { data = null }
if (!data) { setDraftStatus('임시저장 데이터를 읽을 수 없습니다.', 'idStatusBad'); return }
restoreDraft(data)
setDraftStatus('임시저장 불러옴 (' + formatSavedAt(j.draft.updatedAt) + ')', 'idStatusOk')
} else {
hideDraftButtons()
setDraftStatus('저장된 임시저장이 없습니다.', 'muted')
}
})
.catch(function (err) {
setDraftStatus('불러오기 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
})
.finally(function () { draftLoadBtn.disabled = false })
})
}
if (draftDeleteBtn) {
draftDeleteBtn.addEventListener('click', function () {
if (!window.confirm('저장된 임시저장을 삭제할까요?')) return
draftDeleteBtn.disabled = true
fetch(draftDeleteUrl, { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok) {
hideDraftButtons()
setDraftStatus('임시저장을 삭제했습니다.', 'muted')
} else {
setDraftStatus((j && j.message) || '삭제 실패', 'idStatusBad')
}
})
.catch(function (err) {
setDraftStatus('삭제 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
})
.finally(function () { draftDeleteBtn.disabled = false })
})
}
// 진입 시 기존 임시저장 여부 확인 → 있으면 불러오기/삭제 버튼 노출.
fetch(draftUrl, { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok && j.draft) showDraftButtons(j.draft.updatedAt)
})
.catch(function () {})
// 초기 상태
updateRegisterEnable()
})()

View File

@@ -86,18 +86,41 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
}
.folderCard {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 18px; text-decoration: none; color: var(--text);
display: flex; flex-direction: column; align-items: center; gap: 8px;
border-radius: 12px; text-decoration: none; color: var(--text);
display: flex; flex-direction: column; align-items: stretch;
cursor: pointer; transition: transform .08s ease;
}
.folderCard:hover { transform: translateY(-2px); border-color: var(--accent); }
/* 공개 화면: 카드 자체가 <a> 라 패딩을 직접 적용 */
.folderCard:not(.adminFolder) { padding: 18px; align-items: center; gap: 8px; }
/* 관리자 화면: 카드는 .adminFolder div, 내부 a 가 카드 전체를 채워 클릭 영역이 됨 */
.folderCardLink {
display: flex; flex-direction: column; align-items: center; gap: 8px;
text-decoration: none; color: inherit;
padding: 18px; flex: 1 1 auto;
}
.folderIcon { font-size: 40px; }
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
/* tools (ffmpeg / yt-dlp) */
.toolsSection { margin-top: 40px; }
.toolsSection h2 { margin-bottom: 6px; }
.toolsSection .muted { margin-bottom: 16px; }
.toolGrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.toolCard {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 10px;
}
.toolHead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.toolName { font-size: 16px; font-weight: 600; }
.toolVersion { font-size: 13px; color: var(--accent); text-align: right; }
.toolPath { font-size: 12px; word-break: break-all; }
.toolRow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.toolStatus { font-size: 12px; }
/* video grid */
.videoGrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -111,10 +134,16 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
}
.videoCard:hover { border-color: var(--accent); }
.videoThumb {
position: relative;
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
display: flex; align-items: center; justify-content: center;
font-size: 36px; color: var(--accent);
}
.videoThumbImg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block; background: #0d1117; z-index: 1;
}
.videoThumbPlay { position: relative; z-index: 0; }
.videoTitle { padding: 10px 12px; font-size: 14px; }
/* login */
@@ -327,6 +356,9 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 8px 12px; font-size: 14px;
}
.playlistDraftRow {
display: flex; gap: 10px; margin-top: 12px; align-items: center; flex-wrap: wrap;
}
.playlistEntriesSection {
margin-top: 18px;
@@ -353,7 +385,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
}
.playlistEntryRow {
display: grid;
grid-template-columns: 26px 36px 1fr 70px 28px;
grid-template-columns: 24px 28px 90px minmax(0, 1fr) 210px 60px 26px;
align-items: center; gap: 10px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 8px 10px;
@@ -367,6 +399,21 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--text-muted); font-size: 13px; text-align: right;
}
.entryThumb {
width: 90px; aspect-ratio: 16 / 9;
border-radius: 6px; overflow: hidden; position: relative;
background: var(--bg); border: 1px solid var(--border);
cursor: pointer;
}
.entryThumb::after {
content: '▶'; position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 18px; text-shadow: 0 1px 4px rgba(0,0,0,0.7);
opacity: 0; transition: opacity 120ms ease; pointer-events: none;
}
.entryThumb:hover::after { opacity: 1; }
.entryThumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entryThumbEmpty { opacity: 0.6; }
.entryFields {
display: flex; flex-direction: column; gap: 4px; min-width: 0;
}
@@ -395,7 +442,85 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
}
.entryRemove:hover { color: #e57373; border-color: #e57373; }
/* 항목별 시작/종료 시간 입력 (제목 오른쪽 절반 공간) */
.entryTrim {
display: flex; flex-direction: column; gap: 4px;
}
.entryTrim label {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-muted);
}
.entryTrim input {
flex: 1; min-width: 0;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 3px 6px; font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* 일괄 시작/종료 + 적용/초기화 */
.bulkTrimBox {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 14px;
}
.bulkTrimBox label {
display: flex; align-items: center; gap: 6px;
color: var(--text); font-size: 13px;
}
.bulkTrimBox input {
width: 70px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 4px 8px; font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* YouTube 임베드 (미리보기 / 자르기 모달) */
.ytEmbedWrap {
position: relative; width: 100%; aspect-ratio: 16 / 9;
background: #000; border-radius: 8px; overflow: hidden;
}
.ytEmbedWrap iframe, .ytEmbedWrap > div {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
}
.trimModal { width: min(900px, 94vw); gap: 12px; }
.trimModal .trimTimeline { background: var(--bg-alt); }
.playlistFooter {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-top: 8px;
}
/* ─── 플레이리스트 다운로드 진행 화면 ─────────────────────────────── */
.playlistProgressSection {
margin-top: 18px;
display: flex; flex-direction: column; gap: 14px;
}
.progressHeader {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 16px;
}
.progressHeader #progressSummary { font-size: 14px; color: var(--text); }
.progressHeader #progressOverall { flex: 1; min-width: 200px; height: 12px; }
.progressList { list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 8px; }
.progressRow {
display: grid; grid-template-columns: 1fr 180px auto auto; align-items: center;
gap: 12px; background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 14px;
}
.progressName {
color: var(--text); font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.progressBar { width: 100%; height: 10px; }
.progressBar.progressBarError::-webkit-progress-value { background: #e5534b; }
.progressMsg { font-size: 12px; white-space: nowrap; text-align: right; }
.progressRetry { padding: 4px 12px; font-size: 12px; white-space: nowrap; justify-self: end; }
.progressRowDone { border-color: #2ea043; }
.progressRowError { border-color: #e5534b; }
@media (max-width: 640px) {
.progressRow { grid-template-columns: 1fr; gap: 6px; }
.progressMsg { text-align: left; }
.progressRetry { justify-self: start; }
}

89
src/cookies.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* YouTube 로그인 쿠키(Netscape `cookies.txt`) 관리.
*
* 동기:
* - 연령 제한("Sign in to confirm your age") 영상은 로그인 세션 없이는 yt-dlp 가
* 받지 못한다. 브라우저에서 내보낸 Netscape cookies.txt 를 `--cookies` 로 넘기면
* 그 계정 권한으로 다운로드된다.
* - 쿠키에는 세션 토큰이 들어 있어 민감하므로 data 볼륨 하위에 0600 으로 보관한다.
* (data 볼륨이라 컨테이너 재생성에도 유지된다.)
*/
import { promises as fs, existsSync, statSync } from 'node:fs'
import path from 'node:path'
import { dataDir } from './paths.js'
/** 등록된 쿠키 파일 경로 (data 볼륨 하위, 영속). */
export const cookiesPath = path.join(dataDir, 'cookies.txt')
export interface CookiesStatus {
present: boolean
bytes: number
updatedAt: string | null
}
/** 현재 등록된 쿠키 상태 (UI 표시용). */
export function cookiesStatus(): CookiesStatus {
try {
const st = statSync(cookiesPath)
if (!st.isFile() || st.size === 0) return { present: false, bytes: 0, updatedAt: null }
return { present: true, bytes: st.size, updatedAt: st.mtime.toISOString() }
} catch {
return { present: false, bytes: 0, updatedAt: null }
}
}
/**
* yt-dlp 에 넘길 쿠키 인자. 등록돼 있고 비어 있지 않을 때만 `['--cookies', 경로]`,
* 아니면 빈 배열. spawn 직전에 동기로 호출하므로 존재 여부만 빠르게 확인한다.
*/
export function getCookieArgs(): string[] {
try {
if (existsSync(cookiesPath) && statSync(cookiesPath).size > 0) {
return ['--cookies', cookiesPath]
}
} catch {
/* 무시 — 쿠키 없이 진행 */
}
return []
}
/** 등록된 쿠키가 있는지 (에러 메시지 분기용). */
export function hasCookies(): boolean {
return getCookieArgs().length > 0
}
/**
* Netscape cookies.txt 로 보이는지 가볍게 검증한다. 사용자가 실수로 HTML/JSON 등
* 엉뚱한 파일을 올리면 조용히 저장돼 다운로드가 계속 실패하므로 미리 막는다.
* - `# Netscape HTTP Cookie File` 헤더가 있거나
* - 탭으로 7개 필드가 나뉜 쿠키 라인이 한 줄이라도 있으면 통과.
*/
function looksLikeNetscapeCookies(text: string): boolean {
if (/^#\s*(Netscape\s+)?HTTP\s+Cookie\s+File/im.test(text)) return true
for (const line of text.split(/\r?\n/)) {
if (!line || line.startsWith('#')) continue
if (line.split('\t').length >= 7) return true
}
return false
}
/** 쿠키 내용을 검증 후 0600 으로 저장한다. */
export async function saveCookies(content: string): Promise<CookiesStatus> {
const text = content.replace(/\r\n/g, '\n')
if (!text.trim()) throw new Error('쿠키 파일이 비어 있습니다.')
if (!looksLikeNetscapeCookies(text)) {
throw new Error(
'Netscape 형식 cookies.txt 가 아닙니다. 브라우저 확장(Get cookies.txt 등)으로 내보낸 파일을 올려 주세요.'
)
}
await fs.mkdir(dataDir, { recursive: true })
await fs.writeFile(cookiesPath, text, { mode: 0o600 })
// 기존 파일이 더 느슨한 권한이었을 수 있으니 명시적으로 다시 조인다.
await fs.chmod(cookiesPath, 0o600).catch(() => {})
return cookiesStatus()
}
/** 등록된 쿠키를 삭제한다. 없으면 그냥 통과. */
export async function deleteCookies(): Promise<void> {
await fs.rm(cookiesPath, { force: true })
}

View File

@@ -14,7 +14,8 @@
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
*
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다.
* 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)).
* 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다.
*/
import Database from 'better-sqlite3'
import { promises as fsp } from 'node:fs'
@@ -43,6 +44,7 @@ export async function initDatabase(): Promise<void> {
db.pragma('synchronous = NORMAL')
createSchema(db)
migrateVideosSchema(db)
_db = db
// 비어 있으면 디스크 스캔 → 마이그레이션.
@@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void {
ON folders(name) WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
id TEXT NOT NULL,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
title TEXT NOT NULL,
original_file TEXT NOT NULL,
@@ -82,12 +84,80 @@ function createSchema(db: Database.Database): void {
trim_end_sec REAL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
UNIQUE(folder_id, id)
);
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
-- 플레이리스트 추가 화면의 "임시저장": 폴더당 1개의 작업중 상태(JSON)를 보관한다.
-- 다운로드 시작 전에 수정해둔 값(플레이리스트 URL + 항목별 제목/ID/구간 등)을
-- 저장해 두었다가 나중에 불러온다.
CREATE TABLE IF NOT EXISTS playlist_drafts (
folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`)
}
/**
* 구(舊) 스키마(videos.id 가 전역 PRIMARY KEY)를 신(新) 스키마(UNIQUE(folder_id, id))로
* 마이그레이션한다. createSchema 의 CREATE TABLE IF NOT EXISTS 는 이미 존재하는 테이블을
* 건드리지 않으므로, 운영 DB 의 기존 videos 테이블은 이 함수로만 바뀐다.
*
* 판별: PRAGMA table_info(videos) 에서 id 컬럼의 pk 가 1 이면 구 스키마.
* 기존 id 는 전역 유일이므로 (folder_id, id) 로 옮겨도 충돌이 없다.
*/
function migrateVideosSchema(db: Database.Database): void {
const cols = db.prepare(`PRAGMA table_info(videos)`).all() as Array<{
name: string
pk: number
}>
const idCol = cols.find((c) => c.name === 'id')
// 테이블이 없거나(첫 생성), id 가 PK 가 아니면(이미 신 스키마) 할 일 없음.
if (!idCol || idCol.pk === 0) return
db.pragma('foreign_keys = OFF')
try {
const rebuild = db.transaction(() => {
db.exec(`
CREATE TABLE videos_new (
id TEXT NOT NULL,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
title TEXT NOT NULL,
original_file TEXT NOT NULL,
edited_file TEXT,
duration_sec REAL,
source_type TEXT NOT NULL CHECK (source_type IN ('upload', 'youtube')),
source_url TEXT,
trim_start_sec REAL,
trim_end_sec REAL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(folder_id, id)
);
INSERT INTO videos_new
(id, folder_id, title, original_file, edited_file, duration_sec,
source_type, source_url, trim_start_sec, trim_end_sec,
position, created_at, updated_at)
SELECT
id, folder_id, title, original_file, edited_file, duration_sec,
source_type, source_url, trim_start_sec, trim_end_sec,
position, created_at, updated_at
FROM videos;
DROP TABLE videos;
ALTER TABLE videos_new RENAME TO videos;
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
`)
})
rebuild()
console.log('[db] videos 스키마 마이그레이션 완료: UNIQUE(folder_id, id)')
} finally {
db.pragma('foreign_keys = ON')
}
}
/**
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.

View File

@@ -1,7 +1,9 @@
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import { promises as fs, existsSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
import { binDir } from './paths.js'
export class FfmpegUnavailableError extends Error {
constructor() {
@@ -9,21 +11,53 @@ export class FfmpegUnavailableError extends Error {
}
}
/**
* 바이너리 해석: data/bin 에 재설치본이 있으면 그것을 우선 쓰고,
* 없으면 PATH 에서 찾는다. 둘 다 없으면 null.
* (win32 면 .exe 후보도 본다.)
*/
function resolveBinary(name: string): string | null {
const candidates =
process.platform === 'win32'
? [path.join(binDir, name + '.exe'), path.join(binDir, name), name]
: [path.join(binDir, name), name]
for (const cand of candidates) {
const r = spawnSync(cand, ['-version'])
if (r.status === 0) return cand
}
return null
}
let resolvedFfmpegPath: string | null = null
let resolvedFfprobePath: string | null = null
export function getFfmpegPath(): string {
if (resolvedFfmpegPath) return resolvedFfmpegPath
const r = spawnSync('ffmpeg', ['-version'])
if (r.status === 0) {
resolvedFfmpegPath = 'ffmpeg'
return 'ffmpeg'
}
throw new FfmpegUnavailableError()
const p = resolveBinary('ffmpeg')
if (!p) throw new FfmpegUnavailableError()
resolvedFfmpegPath = p
return p
}
/** ffprobe 경로. data/bin 우선, 없으면 PATH. 못 찾으면 null (호출부에서 graceful 처리). */
function getFfprobePath(): string | null {
if (resolvedFfprobePath) return resolvedFfprobePath
const p = resolveBinary('ffprobe')
if (p) resolvedFfprobePath = p
return p
}
/** data/bin 에 새 바이너리를 설치한 뒤 캐시된 해석 결과를 비운다 (다음 호출에서 재탐색). */
export function resetFfmpegResolution(): void {
resolvedFfmpegPath = null
resolvedFfprobePath = null
}
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoFps(inputPath: string): number | null {
const r = spawnSync('ffprobe', [
const probe = getFfprobePath()
if (!probe) return null
const r = spawnSync(probe, [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=avg_frame_rate',
@@ -44,36 +78,53 @@ export function probeVideoFps(inputPath: string): number | null {
return Number.isFinite(single) && single > 0 ? single : null
}
/** 입력 영상의 해상도(가로×세로 px) 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoResolution(inputPath: string): { width: number; height: number } | null {
const r = spawnSync('ffprobe', [
/**
* fps + 해상도 + 길이를 한 번의 ffprobe 호출로 동시에 받아온다.
* upscaleOriginalTo60Fps 의 핫 패스에서 ffprobe 를 3번 spawnSync 하지 않기 위함.
* 각 필드 실패는 null 로 표현 (개별 helper 의 의미론과 동일).
*/
function probeVideoMeta(inputPath: string): {
fps: number | null
width: number | null
height: number | null
durationSec: number | null
} {
const empty = { fps: null, width: null, height: null, durationSec: null }
const probe = getFfprobePath()
if (!probe) return empty
const r = spawnSync(probe, [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=s=x:p=0',
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
'-of', 'default=noprint_wrappers=1',
inputPath
])
if (r.status !== 0) return null
const raw = String(r.stdout).trim()
const m = /^(\d+)x(\d+)$/.exec(raw)
if (!m) return null
const w = Number(m[1])
const h = Number(m[2])
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
return { width: w, height: h }
}
/** 입력 영상의 총 재생 길이(초) 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoDuration(inputPath: string): number | null {
const r = spawnSync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=nokey=1:noprint_wrappers=1',
inputPath
])
if (r.status !== 0) return null
const v = Number(String(r.stdout).trim())
return Number.isFinite(v) && v > 0 ? v : null
if (r.status !== 0) return empty
const fields: Record<string, string> = {}
for (const line of String(r.stdout).split(/\r?\n/)) {
const eq = line.indexOf('=')
if (eq <= 0) continue
fields[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
}
// fps: "N/D" 또는 단일 숫자
let fps: number | null = null
const fpsRaw = fields.avg_frame_rate
if (fpsRaw) {
const m = /^(\d+)\/(\d+)$/.exec(fpsRaw)
if (m) {
const n = Number(m[1]); const d = Number(m[2])
if (d > 0 && Number.isFinite(n / d) && n / d > 0) fps = n / d
} else {
const single = Number(fpsRaw)
if (Number.isFinite(single) && single > 0) fps = single
}
}
const w = Number(fields.width); const h = Number(fields.height)
const width = Number.isFinite(w) && w > 0 ? w : null
const height = Number.isFinite(h) && h > 0 ? h : null
const dur = Number(fields.duration)
const durationSec = Number.isFinite(dur) && dur > 0 ? dur : null
return { fps, width, height, durationSec }
}
export const TARGET_FPS = 60
@@ -81,6 +132,164 @@ export const TARGET_FPS = 60
export const MAX_WIDTH = 1920
export const MAX_HEIGHT = 1080
// ─── 하드웨어 인코더 자동 선택 ─────────────────────────────────────────────
//
// 5/16 사용자 요구: "변환 너무 느려 20배는 빠르게 해줘".
// 현실적으로 libx264 -preset ultrafast 보다 20배 빠른 길은 GPU/전용 ASIC
// 인코더밖에 없다. 호스트에 깔린 ffmpeg 가 노출하는 h264_* 인코더 중
// 실제로 동작하는 것 하나를 우선순위에 따라 선택해 캐시한다.
//
// 우선순위: nvenc (NVIDIA) > qsv (Intel Quick Sync) > videotoolbox (Apple
// Silicon) > vaapi (범용 리눅스 /dev/dri) > libx264 ultrafast (소프트웨어 폴백).
// nvenc/qsv/videotoolbox 는 픽셀 포맷 변환 없이 바로 붙고, vaapi 는
// `format=nv12,hwupload` 필터를 vfilter 끝에 붙이고 `-vaapi_device` 를
// 입력 앞에 prepend 해야 한다. 그래서 각 프로필이 vfilter suffix 와
// preInputArgs 까지 함께 들고 있다.
export type H264Encoder =
| 'libx264'
| 'h264_nvenc'
| 'h264_qsv'
| 'h264_videotoolbox'
| 'h264_vaapi'
interface EncoderProfile {
name: H264Encoder
/** `-i` 앞에 prepend 되는 인자. vaapi 의 `-vaapi_device ...` 같은 것. */
preInputArgs: string[]
/**
* 호출자가 만든 vfilter chain 뒤에 그대로 이어붙이는 suffix.
* 예: vaapi 는 `,format=nv12,hwupload`. 비어 있으면 추가 변환 없음.
* 호출자 vfilter 가 빈 문자열이면 이 suffix 도 무시되므로 lead-comma 처리는 호출 측에서.
*/
vfilterSuffix: string
/** `-c:v libx264 -preset ultrafast -crf 23` 자리를 그대로 대체하는 인자 묶음. */
codecArgs: string[]
}
let resolvedEncoder: EncoderProfile | null = null
let encoderDetectInflight: Promise<EncoderProfile> | null = null
const SOFTWARE_FALLBACK: EncoderProfile = {
name: 'libx264',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23']
}
function listFfmpegEncoders(bin: string): string {
const r = spawnSync(bin, ['-hide_banner', '-encoders'])
return r.status === 0 ? String(r.stdout) : ''
}
function buildCandidates(encodersListed: string): EncoderProfile[] {
const out: EncoderProfile[] = []
if (/\bh264_nvenc\b/.test(encodersListed)) {
// p1 = 가장 빠른 NVENC 프리셋. tune=ll 는 low-latency (인코더 룩어헤드 비활성)
// — 우리 케이스는 모두 파일 입력이라 latency 보다 throughput 위주가 맞다.
// -rc vbr + -cq 23: 가변 비트레이트, 품질 23 (libx264 crf 23 과 비슷한 위치).
out.push({
name: 'h264_nvenc',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'h264_nvenc', '-preset', 'p1', '-tune', 'll', '-rc', 'vbr', '-cq', '23', '-b:v', '0']
})
}
if (/\bh264_qsv\b/.test(encodersListed)) {
out.push({
name: 'h264_qsv',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'h264_qsv', '-preset', 'veryfast', '-global_quality', '23']
})
}
if (/\bh264_videotoolbox\b/.test(encodersListed)) {
// -q:v 는 0..100, 50 이면 중상 품질.
out.push({
name: 'h264_videotoolbox',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'h264_videotoolbox', '-q:v', '50']
})
}
if (/\bh264_vaapi\b/.test(encodersListed) && existsSync('/dev/dri/renderD128')) {
out.push({
name: 'h264_vaapi',
preInputArgs: ['-vaapi_device', '/dev/dri/renderD128'],
vfilterSuffix: ',format=nv12,hwupload',
codecArgs: ['-c:v', 'h264_vaapi', '-qp', '23']
})
}
return out
}
/**
* lavfi color 소스로 1프레임만 뽑아보고 인코더가 실제로 동작하는지 확인.
* - "encoders" 리스트에 떠도 런타임에 디바이스가 없거나 라이브러리가 깨져
* 실제 인코딩에서 실패하는 경우가 흔하다.
* - 5초 안에 안 끝나면 실패로 간주 (정상적인 1프레임 인코딩은 1초 이내).
*/
function testEncoder(bin: string, prof: EncoderProfile): Promise<boolean> {
return new Promise((resolve) => {
const vfArgs =
prof.name === 'h264_vaapi' ? ['-vf', 'format=nv12,hwupload'] : []
const args = [
'-hide_banner', '-loglevel', 'error',
...prof.preInputArgs,
'-f', 'lavfi', '-i', 'color=size=128x128:duration=0.1:rate=30',
...vfArgs,
...prof.codecArgs,
'-frames:v', '1', '-f', 'null', '-'
]
let settled = false
const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] })
const timer = setTimeout(() => {
if (settled) return
settled = true
try { child.kill('SIGKILL') } catch { /* ignore */ }
resolve(false)
}, 5000)
child.on('error', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(false)
})
child.on('close', (code) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(code === 0)
})
})
}
/**
* 우선순위대로 후보 인코더를 1프레임 인코딩 테스트해 첫 성공을 캐시.
* 모두 실패하면 libx264 ultrafast 로 폴백. 동시 호출은 in-flight Promise 공유.
*/
export function detectH264Encoder(bin: string): Promise<EncoderProfile> {
if (resolvedEncoder) return Promise.resolve(resolvedEncoder)
if (encoderDetectInflight) return encoderDetectInflight
encoderDetectInflight = (async () => {
const list = listFfmpegEncoders(bin)
for (const c of buildCandidates(list)) {
const ok = await testEncoder(bin, c)
if (ok) {
console.log(`[encoder] using ${c.name}`)
resolvedEncoder = c
return c
}
console.warn(`[encoder] ${c.name} 사용 불가 — 다음 후보 시도`)
}
console.log('[encoder] using libx264 ultrafast (software fallback)')
resolvedEncoder = SOFTWARE_FALLBACK
return SOFTWARE_FALLBACK
})()
encoderDetectInflight.finally(() => { encoderDetectInflight = null })
return encoderDetectInflight
}
/**
* 원본 영상을 다운스트림 요구사항에 맞추는 후처리.
*
@@ -105,26 +314,30 @@ export async function upscaleOriginalTo60Fps(
return inputName
}
const inputPath = path.join(dir, inputName)
const sourceFps = probeVideoFps(inputPath)
const sourceRes = probeVideoResolution(inputPath)
if (sourceFps === null && sourceRes === null) {
const meta = probeVideoMeta(inputPath)
const sourceFps = meta.fps
const sourceWidth = meta.width
const sourceHeight = meta.height
if (sourceFps === null && sourceWidth === null && sourceHeight === null) {
console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`)
return inputName
}
const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5
const needDownscale =
sourceRes !== null && (sourceRes.width > MAX_WIDTH || sourceRes.height > MAX_HEIGHT)
sourceWidth !== null && sourceHeight !== null &&
(sourceWidth > MAX_WIDTH || sourceHeight > MAX_HEIGHT)
if (!needBumpFps && !needDownscale) {
// 이미 충분히 부드럽고 해상도도 캡 이하.
return inputName
}
const sourceDurationSec = probeVideoDuration(inputPath)
const sourceDurationSec = meta.durationSec
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
const outName = 'original.mp4'
const outPath = path.join(dir, outName)
const tmpPath = path.join(dir, 'original.bump.tmp.mp4')
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
const tmpPath = path.join(dir, `original.bump.${randomUUID()}.tmp.mp4`)
// vfilter 조립:
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
@@ -139,27 +352,40 @@ export async function upscaleOriginalTo60Fps(
`scale=trunc(iw/2)*2:trunc(ih/2)*2`
)
}
const vfilter = filters.join(',')
const args = [
'-y',
'-i', inputPath,
'-vf', vfilter,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
'-progress', 'pipe:1',
'-nostats',
tmpPath
]
const ok = await runFfmpegWithProgress(bin, args, (outTimeUs) => {
// vaapi 의 경우 hwupload 가 vfilter 끝에 붙어야 한다 (그 뒤로는 GPU 전용 필터만 가능).
// filters 가 비어있는 케이스는 needBumpFps/needDownscale 가 둘 다 false 인
// 케이스에서 이미 early-return 됐으므로 여기 도달하면 filters.length >= 1.
const buildUpscaleArgs = (profile: EncoderProfile): string[] => {
const vfilter = filters.join(',') + profile.vfilterSuffix
return [
'-y',
...profile.preInputArgs,
'-i', inputPath,
'-vf', vfilter,
...profile.codecArgs,
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
'-progress', 'pipe:1',
'-nostats',
tmpPath
]
}
const onProgressCb = (outTimeUs: number) => {
if (!onProgress || !sourceDurationSec || sourceDurationSec <= 0) return
const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100))
onProgress(pct)
})
}
const ok = await encodeWithFallback(
bin,
buildUpscaleArgs,
tmpPath,
(b, a) => runFfmpegWithProgress(b, a, onProgressCb),
'upscale'
)
if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] 후처리 실패 (vf=${vfilter}) — 원본 ${inputName} 유지`)
console.warn(`[upscale] 후처리 실패 (filters=${filters.join(',')}) — 원본 ${inputName} 유지`)
return inputName
}
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
@@ -184,20 +410,22 @@ export async function upscaleOriginalTo60Fps(
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
*/
export async function applyTrimToVideo(
folderId: number,
videoId: string,
trim: VideoTrim
): Promise<string> {
const bin = getFfmpegPath()
const meta = getVideo(videoId)
const meta = getVideoInFolder(folderId, videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
const dir = videoDiskDir(videoId)
const dir = videoDiskDir(folderId, videoId)
const inputPath = path.join(dir, meta.originalFile)
await fs.access(inputPath)
const ext = path.extname(meta.originalFile) || '.mp4'
const outName = `edited${ext}`
const outPath = path.join(dir, outName)
const tmpPath = outPath + '.tmp' + ext
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
const tmpPath = `${outPath}.${randomUUID()}.tmp${ext}`
const startSec = Math.max(0, Number(trim.startSec) || 0)
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
@@ -221,27 +449,136 @@ export async function applyTrimToVideo(
if (!ok) {
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
const vfilter = needBumpFps ? `fps=${TARGET_FPS}` : null
const encArgs = [
...baseArgs,
...(vfilter ? ['-vf', vfilter] : []),
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
tmpPath
]
ok = await runFfmpeg(bin, encArgs)
//
// baseArgs(['-y', '-ss', start, ['-to', end]?, '-i', inputPath]) 를 그대로 쓰면
// preInputArgs(`-vaapi_device ...` 같은 ffmpeg 전역 옵션) 가 들어갈 곳이 없다.
// 전역 옵션은 -ss/-to/-i 보다 더 앞이어야 안전하므로 args 를 처음부터 다시 만든다.
const buildTrimEncArgs = (profile: EncoderProfile): string[] => {
let vfilter: string | null = needBumpFps ? `fps=${TARGET_FPS}` : null
if (profile.vfilterSuffix) {
// vaapi: hwupload 가 반드시 필터 끝에 필요. 사용자 필터 없어도 format 단계 들어가야 함.
vfilter = (vfilter ?? '') + profile.vfilterSuffix
// 빈 vfilter 였다가 suffix 가 ',format=nv12,hwupload' 형태로 붙으면
// 선두 쉼표가 ffmpeg 파싱 오류를 낸다. 선두 쉼표 제거.
vfilter = vfilter.replace(/^,+/, '')
}
return [
'-y',
...profile.preInputArgs,
'-ss', String(startSec),
...(endSec !== null ? ['-to', String(endSec)] : []),
'-i', inputPath,
...(vfilter ? ['-vf', vfilter] : []),
...profile.codecArgs,
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
tmpPath
]
}
ok = await encodeWithFallback(bin, buildTrimEncArgs, tmpPath, runFfmpeg, 'trim')
if (!ok) throw new Error('ffmpeg trim 실패')
}
await fs.rename(tmpPath, outPath)
updateVideo(videoId, {
updateVideo(folderId, videoId, {
editedFile: outName,
trim: { startSec, endSec }
})
return outName
}
// ─── 썸네일 ────────────────────────────────────────────────────────────
const THUMB_NAME = 'thumb.jpg'
/**
* 영상 디렉토리에 thumb.jpg 가 없으면 ffmpeg 로 한 장 추출해 만든다.
* - 편집본이 있으면 편집본, 없으면 원본에서 1초 지점 프레임을 뽑는다.
* (1초가 영상보다 길면 0초로 재시도.)
* - 폭 480 으로 다운스케일 (카드 썸네일용).
* - ffmpeg 없음 / 소스 없음 / 실패 시 null 반환 → 호출자가 404 로 응답하고
* 카드에서는 ▶ placeholder 로 폴백.
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
*/
export async function ensureThumbnail(folderId: number, videoId: string): Promise<string | null> {
const meta = getVideoInFolder(folderId, videoId)
if (!meta) return null
const dir = videoDiskDir(folderId, videoId)
const thumbPath = path.join(dir, THUMB_NAME)
try {
await fs.access(thumbPath)
return thumbPath
} catch {
/* 없으면 아래에서 생성 */
}
const sourceName = meta.editedFile || meta.originalFile
if (!sourceName || sourceName.includes('%(ext)s')) return null
const inputPath = path.join(dir, sourceName)
try {
await fs.access(inputPath)
} catch {
return null
}
let bin: string
try {
bin = getFfmpegPath()
} catch {
return null
}
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
// (두 명이 같은 영상을 동시에 열면 thumb 가 아직 없을 때 generation 이 겹쳐
// 서로의 tmp 를 덮어써 일부 요청이 헛되이 실패하던 레이스를 막는다.)
const tmpPath = `${thumbPath}.${randomUUID()}.tmp.jpg`
const vf = "scale='min(480,iw)':-2"
// -ss 1 (1초 지점). 영상이 1초보다 짧으면 프레임이 안 나와 실패하므로 0초로 재시도.
let ok = await runFfmpeg(bin, [
'-y', '-ss', '1', '-i', inputPath,
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
])
if (!ok) {
ok = await runFfmpeg(bin, [
'-y', '-i', inputPath,
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
])
}
if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined)
return null
}
try {
await fs.rename(tmpPath, thumbPath)
} catch {
await fs.unlink(tmpPath).catch(() => undefined)
return null
}
return thumbPath
}
/**
* HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도.
*
* 5/16 STEP B P1: 1프레임 lavfi 테스트는 통과했는데 실제 영상(픽셀 포맷/색공간/
* 해상도 조합)에서 깨지는 HW 인코더가 흔하다. 잡 단위 폴백을 한 군데로 모아
* 캐시는 그대로 두고(`resolvedEncoder` invalidate X), 다음 잡 입력이 더 일반적
* 이면 HW 가 다시 동작할 여지를 남긴다.
*/
async function encodeWithFallback(
bin: string,
build: (profile: EncoderProfile) => string[],
tmpPath: string,
runner: (bin: string, args: string[]) => Promise<boolean>,
label: string
): Promise<boolean> {
const enc = await detectH264Encoder(bin)
let ok = await runner(bin, build(enc))
if (!ok && enc.name !== 'libx264') {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[${label}] ${enc.name} 실패 — libx264 ultrafast 로 재시도`)
ok = await runner(bin, build(SOFTWARE_FALLBACK))
}
return ok
}
function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, args)

View File

@@ -14,6 +14,9 @@ export const foldersDir = path.join(dataDir, 'folders')
export const jobsDir = path.join(dataDir, 'jobs')
export const tmpDir = path.join(dataDir, 'tmp')
export const dbPath = path.join(dataDir, 'app.db')
// 런타임에 최신 버전으로 재설치한 ffmpeg/yt-dlp 바이너리를 두는 곳.
// data 볼륨 하위라 컨테이너를 재생성해도 유지된다. 바이너리 해석 시 PATH 보다 우선.
export const binDir = path.join(dataDir, 'bin')
export const viewsDir = path.join(projectRoot, 'views')
export const publicDir = path.join(projectRoot, 'public')

16
src/routes/helpers.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* 라우트에서 공유하는 작은 헬퍼.
*
* 공개/관리자 라우트 모두 `:topName` (+ optional `:subName`) 패턴을 받는다.
* 둘이 같은 코드를 가지고 있으면 한쪽만 고치는 버그가 나기 쉽다.
*/
/** `:topName` (+ optional `:subName`) URL 파라미터를 폴더 경로 배열로 변환. */
export function collectFolderSegments(
params: Record<string, string | undefined>
): string[] {
const out: string[] = []
if (params.topName) out.push(params.topName)
if (params.subName) out.push(params.subName)
return out
}

View File

@@ -13,7 +13,8 @@ import {
folderPathNames,
getFolder,
getFolderByPath,
getVideo,
getVideoInFolder,
getVideoByIdGlobal,
isSafeVideoId,
listChildFolders,
listTopFolders,
@@ -21,7 +22,10 @@ import {
moveUploadIntoVideoDir,
renameFolder,
updateVideo,
videoIdExists
videoExistsInFolder,
savePlaylistDraft,
getPlaylistDraft,
deletePlaylistDraft
} from '../storeDb.js'
import { tmpDir } from '../paths.js'
import {
@@ -29,9 +33,14 @@ import {
getJob,
probeYoutube,
probeYoutubePlaylist,
startYoutubeDownload
startYoutubeDownload,
retryDownloadJob
} from '../youtube.js'
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
import { cookiesStatus, saveCookies, deleteCookies } from '../cookies.js'
import { collectFolderSegments } from './helpers.js'
import { promises as fs } from 'node:fs'
export const opRouter = Router()
@@ -56,6 +65,12 @@ const upload = multer({
limits: { fileSize: uploadMaxBytes }
})
// 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다.
const cookieUpload = multer({
dest: tmpDir,
limits: { fileSize: 1024 * 1024 }
})
function pickStr(v: unknown): string {
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
return typeof v === 'string' ? v : ''
@@ -118,6 +133,55 @@ opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
}
})
// ─── 외부 도구(ffmpeg/yt-dlp) 상태 + 최신 버전 재설치 ───────────────────
opRouter.get('/op/tools', requireAuth, (req, res) => {
res.json({ ok: true, tools: getToolsStatus() })
})
opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => {
try {
const name = req.params.name
let tool
if (name === 'yt-dlp') tool = await updateYtDlp()
else if (name === 'ffmpeg') tool = await updateFfmpeg()
else {
res.status(400).json({ ok: false, message: '알 수 없는 도구입니다.' })
return
}
res.json({ ok: true, tool })
} catch (err) {
res.status(500).json({ ok: false, message: (err as Error).message })
}
})
// ─── YouTube 로그인 쿠키 (연령 제한 영상 다운로드용) ───────────────────────
opRouter.get('/op/cookies', requireAuth, (req, res) => {
res.json({ ok: true, cookies: cookiesStatus() })
})
opRouter.post('/op/cookies', requireAuth, cookieUpload.single('file'), async (req, res) => {
const file = req.file
try {
if (!file) throw new Error('cookies.txt 파일을 선택해 주세요.')
const content = await fs.readFile(file.path, 'utf8')
const status = await saveCookies(content)
res.json({ ok: true, cookies: status })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
} finally {
if (file) await fs.rm(file.path, { force: true }).catch(() => {})
}
})
opRouter.post('/op/cookies/delete', requireAuth, async (req, res) => {
try {
await deleteCookies()
res.json({ ok: true, cookies: cookiesStatus() })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
try {
const segments = collectFolderSegments(req.params)
@@ -199,13 +263,9 @@ opRouter.get(
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
// 영상은 2단계(서브) 폴더에만 둔다. 1단계 폴더는 컨테이너 전용.
if (folder.parentId === null) {
res.status(400).send('영상은 하위 폴더에서만 추가할 수 있습니다.')
return
}
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
const videoId = typeof req.query.id === 'string' ? req.query.id : null
const video = videoId ? getVideo(videoId) : null
const video = videoId ? getVideoInFolder(folder.id, videoId) : null
res.render('op/editor', {
userId: req.session.userId,
folder,
@@ -246,6 +306,72 @@ opRouter.get(
}
)
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
// 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원.
/** 하위 폴더를 경로로 해석. 못 찾거나 최상위면 throw. */
function requireSubFolder(req: Request): { id: number } {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) {
throw new Error('플레이리스트는 하위 폴더에서만 사용할 수 있습니다.')
}
return folder
}
// 임시저장 JSON 크기 상한 (과도한 payload 방지). 2 MiB 면 수백 항목도 충분.
const DRAFT_MAX_CHARS = 2 * 1024 * 1024
opRouter.get(
'/op/folder/:topName/:subName/playlist/draft',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
const draft = getPlaylistDraft(folder.id)
res.json({ ok: true, draft })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
'/op/folder/:topName/:subName/playlist/draft',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
const data = req.body?.data
if (typeof data !== 'object' || data === null) {
throw new Error('저장할 데이터가 올바르지 않습니다.')
}
const json = JSON.stringify(data)
if (json.length > DRAFT_MAX_CHARS) {
throw new Error('임시저장 데이터가 너무 큽니다.')
}
const saved = savePlaylistDraft(folder.id, json)
res.json({ ok: true, updatedAt: saved.updatedAt })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
'/op/folder/:topName/:subName/playlist/draft/delete',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
deletePlaylistDraft(folder.id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
function uploadSingle(fieldName: string) {
@@ -274,7 +400,6 @@ opRouter.post(
try {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) throw new Error('영상은 하위 폴더에서만 추가할 수 있습니다.')
const file = req.file
if (!file) throw new Error('파일이 없습니다.')
const title = pickStr(req.body?.title).trim() || file.originalname
@@ -286,7 +411,7 @@ opRouter.post(
originalFile: destName,
sourceType: 'upload'
})
await moveUploadIntoVideoDir(video.id, file.path, destName)
await moveUploadIntoVideoDir(folder.id, video.id, file.path, destName)
res.json({ ok: true, videoId: video.id })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
@@ -320,7 +445,6 @@ opRouter.post(
try {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) throw new Error('영상은 하위 폴더에서만 추가할 수 있습니다.')
const url = pickStr(req.body.url).trim()
const title = pickStr(req.body.title).trim() || undefined
if (!url) throw new Error('URL 을 입력해 주세요.')
@@ -390,7 +514,13 @@ opRouter.post(
throw new Error('등록할 항목이 없습니다.')
}
type Cleaned = { url: string; title: string; videoId: string }
type Cleaned = {
url: string
title: string
videoId: string
startSec: number
endSec: number | null
}
const cleaned: Cleaned[] = []
const seen = new Set<string>()
for (let i = 0; i < rawEntries.length; i += 1) {
@@ -407,11 +537,17 @@ opRouter.post(
if (seen.has(videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
}
if (videoIdExists(videoId)) {
if (videoExistsInFolder(folder.id, videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
}
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
const startRaw = Number(e?.startSec)
const startSec = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0
const endRaw = Number(e?.endSec)
const endSec =
Number.isFinite(endRaw) && endRaw > startSec ? endRaw : null
seen.add(videoId)
cleaned.push({ url, title, videoId })
cleaned.push({ url, title, videoId, startSec, endSec })
}
// 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망.
@@ -421,7 +557,9 @@ opRouter.post(
folderId: folder.id,
url: entry.url,
title: entry.title,
videoId: entry.videoId
videoId: entry.videoId,
startSec: entry.startSec,
endSec: entry.endSec
})
jobs.push({ jobId: job.id, videoId: job.videoId })
}
@@ -448,15 +586,39 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
res.json({ ok: true, job })
})
// ─── 영상 mutation (id 기반 — 전역 유일 ID) ─────────────────────────────
/** 실패한 다운로드를 같은 ID·URL·자르기 설정으로 재시도 (기존 영상 레코드 재사용). */
opRouter.post('/op/job/:id/retry', requireAuth, async (req, res) => {
try {
const job = await retryDownloadJob(req.params.id)
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
// ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
function folderFromBody(req: Request): { id: number } {
const fid = pickIntId(req.body?.folderId)
if (fid === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
const folder = getFolder(fid)
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
return folder
}
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
try {
const folder = folderFromBody(req)
const id = req.params.id
const title = pickStr(req.body.title).trim()
if (!title) throw new Error('제목을 입력해 주세요.')
if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.')
updateVideo(id, { title })
if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.')
updateVideo(folder.id, id, { title })
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
@@ -465,19 +627,21 @@ opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
const oldId = req.params.id
const newId = pickStr(req.body.newId).trim()
if (!newId) throw new Error('새 ID 를 입력해 주세요.')
const video = await changeVideoId(oldId, newId)
const video = await changeVideoId(folder.id, oldId, newId)
res.json({ ok: true, video })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
/** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
const folderId = pickIntId(req.query.folderId)
if (!id) {
res.json({ ok: true, available: false, reason: '비어 있음' })
return
@@ -490,13 +654,18 @@ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
})
return
}
const available = !videoIdExists(id)
if (folderId === null) {
res.json({ ok: true, available: false, reason: '폴더 정보 없음' })
return
}
const available = !videoExistsInFolder(folderId, id)
res.json({ ok: true, available })
})
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
try {
await deleteVideo(req.params.id)
const folder = folderFromBody(req)
await deleteVideo(folder.id, req.params.id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
@@ -505,8 +674,9 @@ opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
const id = req.params.id
const video = getVideo(id)
const video = getVideoInFolder(folder.id, id)
if (!video) throw new Error('영상을 찾을 수 없습니다.')
const title = pickStr(req.body.title).trim()
const startSec = Number(req.body.startSec ?? 0) || 0
@@ -519,10 +689,10 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
startSec,
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
}
updateVideo(id, { trim, ...(title ? { title } : {}) })
updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
try {
const outName = await applyTrimToVideo(id, trim)
const outName = await applyTrimToVideo(folder.id, id, trim)
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
} catch (err) {
if (err instanceof FfmpegUnavailableError) {
@@ -542,13 +712,6 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
function collectFolderSegments(params: Record<string, string | undefined>): string[] {
const out: string[] = []
if (params.topName) out.push(params.topName)
if (params.subName) out.push(params.subName)
return out
}
/** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */
export function adminFolderUrl(folderId: number): string {
const names = folderPathNames(folderId)
@@ -557,7 +720,7 @@ export function adminFolderUrl(folderId: number): string {
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
export function videoShareUrl(videoId: string): string | null {
const v = getVideo(videoId)
const v = getVideoByIdGlobal(videoId)
if (!v) return null
const names = folderPathNames(v.folderId)
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')

View File

@@ -1,7 +1,8 @@
import { Router, type RequestHandler } from 'express'
import {
getFolderByPath,
getVideo,
getVideoInFolder,
getVideoByIdGlobal,
listChildFolders,
listTopFolders,
listVideosInFolder,
@@ -10,6 +11,8 @@ import {
type Folder,
type Video
} from '../storeDb.js'
import { collectFolderSegments } from './helpers.js'
import { ensureThumbnail } from '../editor.js'
export const publicRouter = Router()
@@ -63,17 +66,17 @@ publicRouter.get('/folder/:topName/:subName', folderViewHandler)
const videoViewHandler: RequestHandler = (req, res, next) => {
try {
const { folderSegments, videoId } = collectVideoSegments(req.params)
const video = getVideo(videoId)
const folder = getFolderByPath(folderSegments)
if (!folder) {
res.status(404).send('영상을 찾을 수 없습니다.')
return
}
const video = getVideoInFolder(folder.id, videoId)
if (!video) {
res.status(404).send('영상을 찾을 수 없습니다.')
return
}
const folderPath = folderPathNames(video.folderId)
if (!segmentsEqual(folderPath, folderSegments)) {
res.status(404).send('영상 경로가 일치하지 않습니다.')
return
}
res.render('player', { video, breadcrumb: folderPath })
res.render('player', { video, breadcrumb: folderPathNames(folder.id) })
} catch (err) {
next(err)
}
@@ -81,40 +84,121 @@ const videoViewHandler: RequestHandler = (req, res, next) => {
publicRouter.get('/video/:topName/:videoId', videoViewHandler)
publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler)
/** 결정된 비디오의 파일을 응답으로 보낸다 (?edited=0 이면 원본 강제). */
function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Parameters<RequestHandler>[1]): void {
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
const wantOriginal = editedParam === '0' || editedParam === 'false'
const fileName =
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
if (!fileName || fileName.includes('%(ext)s')) {
res.status(404).end()
return
}
res.sendFile(videoFileFsPath(video.folderId, video.id, fileName))
}
/**
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용).
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용 / 레거시).
* - GET /file/video/:videoId
* - GET /api/video/:videoId/file (legacy alias)
* ID 가 전역에서 정확히 1건일 때만 제공한다. 같은 ID 가 여러 폴더에 있으면
* getVideoByIdGlobal 이 null 을 돌려주므로 404 (어느 폴더인지 모호).
* 폴더가 확실한 경우엔 정규 경로 URL `/api/video/폴더/[폴더/]ID` 를 쓸 것.
* ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
*
* 주의: 예전 `/api/video/:videoId/file` 레거시 라우트는 제거했다. 정규 경로
* 라우트 `/api/video/:topName/:videoId` 와 2세그먼트에서 충돌해서, ID 가
* 리터럴 "file" 인 영상의 정규 URL `/api/video/폴더/file` 을 가로챘기 때문.
*/
const streamVideoHandler: RequestHandler = (req, res, next) => {
try {
const video = getVideo(req.params.videoId)
const video = getVideoByIdGlobal(req.params.videoId)
if (!video) {
res.status(404).end()
return
}
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
const wantOriginal = editedParam === '0' || editedParam === 'false'
const fileName =
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
if (!fileName || fileName.includes('%(ext)s')) {
res.status(404).end()
return
}
const fsPath = videoFileFsPath(video.id, fileName)
res.sendFile(fsPath)
sendVideoFile(video, req, res)
} catch (err) {
next(err)
}
}
publicRouter.get('/file/video/:videoId', streamVideoHandler)
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
/**
* 영상 파일 스트리밍 (폴더 경로 + ID — 정규 공유 URL).
* - GET /api/video/:topName/:videoId
* - GET /api/video/:topName/:subName/:videoId
*
* 재생 페이지(/video/...)와 동일한 경로 규칙. 마지막 세그먼트가 영상 ID 이고
* 앞쪽 세그먼트는 그 영상이 실제 속한 폴더 경로와 일치해야 한다.
* (예전 `/api/video/:videoId/file` 레거시 라우트와의 2세그먼트 충돌은 그 라우트를
* 제거해서 해소했다 — 이제 ID 가 "file" 인 영상도 정규 URL 로 정확히 찾는다.)
*/
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
try {
const { folderSegments, videoId } = collectVideoSegments(req.params)
const folder = getFolderByPath(folderSegments)
if (!folder) {
res.status(404).end()
return
}
const video = getVideoInFolder(folder.id, videoId)
if (!video) {
res.status(404).end()
return
}
sendVideoFile(video, req, res)
} catch (err) {
next(err)
}
}
publicRouter.get('/api/video/:topName/:videoId', streamVideoByPathHandler)
publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandler)
/**
* 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시.
* 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백.
*/
function sendThumb(res: Parameters<RequestHandler>[1], thumbPath: string | null): void {
if (!thumbPath) {
res.status(404).end()
return
}
res.set('Cache-Control', 'public, max-age=3600')
res.sendFile(thumbPath)
}
/** 폴더 경로 + ID — 정규 썸네일 URL. 같은 ID 가 여러 폴더에 있어도 정확히 찾는다. */
const thumbByPathHandler: RequestHandler = (req, res, next) => {
const { folderSegments, videoId } = collectVideoSegments(req.params)
const folder = getFolderByPath(folderSegments)
if (!folder) {
res.status(404).end()
return
}
ensureThumbnail(folder.id, videoId)
.then((thumbPath) => sendThumb(res, thumbPath))
.catch(next)
}
publicRouter.get('/file/video/:topName/:videoId/thumb', thumbByPathHandler)
publicRouter.get('/file/video/:topName/:subName/:videoId/thumb', thumbByPathHandler)
/** ID-only 레거시 썸네일 fallback (전역에서 유일한 경우만, 중복이면 404). */
const thumbHandler: RequestHandler = (req, res, next) => {
const video = getVideoByIdGlobal(req.params.videoId)
if (!video) {
res.status(404).end()
return
}
ensureThumbnail(video.folderId, video.id)
.then((thumbPath) => sendThumb(res, thumbPath))
.catch(next)
}
publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
publicRouter.get('/api/video/:videoId', (req, res, next) => {
try {
const video = getVideo(req.params.videoId)
const video = getVideoByIdGlobal(req.params.videoId)
if (!video) {
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
return
@@ -131,13 +215,6 @@ publicRouter.get('/api/video/:videoId', (req, res, next) => {
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
function collectFolderSegments(params: Record<string, string | undefined>): string[] {
const out: string[] = []
if (params.topName) out.push(params.topName)
if (params.subName) out.push(params.subName)
return out
}
function collectVideoSegments(params: Record<string, string | undefined>): {
folderSegments: string[]
videoId: string
@@ -148,12 +225,4 @@ function collectVideoSegments(params: Record<string, string | undefined>): {
return { folderSegments, videoId: params.videoId ?? '' }
}
function segmentsEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export type { Folder, Video }

View File

@@ -299,14 +299,30 @@ export async function deleteFolder(folderId: number): Promise<void> {
// ─── 영상 조회 ─────────────────────────────────────────────────────────
export function getVideo(id: string): Video | null {
/** 폴더 안에서 영상 1건. ID 는 폴더 안에서만 유일하므로 folderId 가 반드시 필요. */
export function getVideoInFolder(folderId: number, id: string): Video | null {
if (!isSafeVideoId(id)) return null
const r = getDb()
.prepare(`SELECT * FROM videos WHERE id = ?`)
.get(id) as VideoRow | undefined
.prepare(`SELECT * FROM videos WHERE folder_id = ? AND id = ?`)
.get(folderId, id) as VideoRow | undefined
return r ? rowToVideo(r) : null
}
/**
* ID 만으로 전역에서 영상을 찾는다 (폴더 경로 없는 레거시/외부공유 fallback).
* ID 는 이제 폴더별로만 유일하므로, 같은 ID 가 여러 폴더에 있으면 어느 쪽인지
* 모호하다. 그런 경우엔 아무 것도 돌려주지 않아 호출 측이 404 로 처리하게 한다.
* 전역에서 정확히 1건일 때만 그 영상을 돌려준다.
*/
export function getVideoByIdGlobal(id: string): Video | null {
if (!isSafeVideoId(id)) return null
const rows = getDb()
.prepare(`SELECT * FROM videos WHERE id = ? ORDER BY folder_id LIMIT 2`)
.all(id) as VideoRow[]
if (rows.length !== 1) return null // 없음 또는 중복(모호) → 404
return rowToVideo(rows[0])
}
export function listVideosInFolder(folderId: number): Video[] {
const rows = getDb()
.prepare(
@@ -316,34 +332,30 @@ export function listVideosInFolder(folderId: number): Video[] {
return rows.map(rowToVideo)
}
export function videoIdExists(id: string): boolean {
export function videoExistsInFolder(folderId: number, id: string): boolean {
if (!isSafeVideoId(id)) return false
const r = getDb()
.prepare(`SELECT 1 FROM videos WHERE id = ?`)
.get(id)
.prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
.get(folderId, id)
return !!r
}
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */
export function videoDiskDir(videoId: string): string {
const v = getVideo(videoId)
if (!v) throw new Error('영상을 찾을 수 없습니다.')
return path.join(folderDiskPath(v.folderId), v.id)
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
export function videoDiskDir(folderId: number, id: string): string {
return path.join(folderDiskPath(folderId), id)
}
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
export function videoPathNames(videoId: string): string[] | null {
const v = getVideo(videoId)
if (!v) return null
return [...folderPathNames(v.folderId), v.id]
export function videoPathNames(folderId: number, id: string): string[] {
return [...folderPathNames(folderId), id]
}
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
export function videoFileFsPath(videoId: string, rel: string): string {
export function videoFileFsPath(folderId: number, id: string, rel: string): string {
if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
throw new Error('잘못된 파일 경로입니다.')
}
return path.join(videoDiskDir(videoId), rel)
return path.join(videoDiskDir(folderId, id), rel)
}
// ─── 영상 변경 ─────────────────────────────────────────────────────────
@@ -362,7 +374,9 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
let id = input.id ?? newRandomVideoId()
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
if (videoIdExists(id)) throw new Error('이미 사용 중인 영상 ID 입니다.')
if (videoExistsInFolder(input.folderId, id)) {
throw new Error('이미 사용 중인 영상 ID 입니다.')
}
const now = new Date().toISOString()
const db = getDb()
@@ -392,30 +406,37 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
try {
await fsp.mkdir(diskDir, { recursive: true })
} catch (err) {
db.prepare(`DELETE FROM videos WHERE id = ?`).run(id)
db.prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(input.folderId, id)
throw err
}
return getVideo(id)!
return getVideoInFolder(input.folderId, id)!
}
export async function changeVideoId(oldId: string, newId: string): Promise<Video> {
export async function changeVideoId(
folderId: number,
oldId: string,
newId: string
): Promise<Video> {
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
if (oldId === newId) {
const v = getVideo(oldId)
const v = getVideoInFolder(folderId, oldId)
if (!v) throw new Error('영상을 찾을 수 없습니다.')
return v
}
const current = getVideo(oldId)
const current = getVideoInFolder(folderId, oldId)
if (!current) throw new Error('영상을 찾을 수 없습니다.')
if (videoIdExists(newId)) throw new Error('이미 사용 중인 영상 ID 입니다.')
if (videoExistsInFolder(folderId, newId)) {
throw new Error('이미 사용 중인 영상 ID 입니다.')
}
const oldDir = path.join(folderDiskPath(current.folderId), current.id)
const newDir = path.join(folderDiskPath(current.folderId), newId)
const db = getDb()
try {
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE id = ?`).run(
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE folder_id = ? AND id = ?`).run(
newId,
new Date().toISOString(),
folderId,
oldId
)
} catch (err) {
@@ -431,11 +452,15 @@ export async function changeVideoId(oldId: string, newId: string): Promise<Video
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
await fsp.mkdir(newDir, { recursive: true })
} else {
db.prepare(`UPDATE videos SET id = ? WHERE id = ?`).run(oldId, newId)
db.prepare(`UPDATE videos SET id = ? WHERE folder_id = ? AND id = ?`).run(
oldId,
folderId,
newId
)
throw err
}
}
return getVideo(newId)!
return getVideoInFolder(folderId, newId)!
}
export interface UpdateVideoPatch {
@@ -447,8 +472,8 @@ export interface UpdateVideoPatch {
trim?: VideoTrim | null
}
export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
const current = getVideo(id)
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
const current = getVideoInFolder(folderId, id)
if (!current) throw new Error('영상을 찾을 수 없습니다.')
const fields: string[] = []
@@ -484,22 +509,25 @@ export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
if (fields.length === 0) return current
fields.push('updated_at = ?')
params.push(new Date().toISOString())
params.push(id)
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
return getVideo(id)!
params.push(folderId, id)
getDb()
.prepare(`UPDATE videos SET ${fields.join(', ')} WHERE folder_id = ? AND id = ?`)
.run(...params)
return getVideoInFolder(folderId, id)!
}
export async function deleteVideo(id: string): Promise<void> {
const v = getVideo(id)
export async function deleteVideo(folderId: number, id: string): Promise<void> {
const v = getVideoInFolder(folderId, id)
if (!v) return
const diskDir = path.join(folderDiskPath(v.folderId), v.id)
getDb().prepare(`DELETE FROM videos WHERE id = ?`).run(id)
getDb().prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(folderId, id)
await fsp.rm(diskDir, { recursive: true, force: true })
}
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
export async function moveUploadIntoVideoDir(
folderId: number,
videoId: string,
tmpFile: string,
destName: string
@@ -507,7 +535,7 @@ export async function moveUploadIntoVideoDir(
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
throw new Error('잘못된 파일명입니다.')
}
const dir = videoDiskDir(videoId)
const dir = videoDiskDir(folderId, videoId)
await fsp.mkdir(dir, { recursive: true })
const target = path.join(dir, destName)
try {
@@ -522,3 +550,34 @@ export async function moveUploadIntoVideoDir(
}
}
}
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
// 폴더당 1개의 작업중 상태(JSON 문자열)를 보관. 다운로드 시작 전 수정값을 저장/복원.
export interface PlaylistDraft {
data: string
updatedAt: string
}
export function savePlaylistDraft(folderId: number, data: string): PlaylistDraft {
const now = new Date().toISOString()
getDb()
.prepare(
`INSERT INTO playlist_drafts (folder_id, data, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(folder_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
)
.run(folderId, data, now)
return { data, updatedAt: now }
}
export function getPlaylistDraft(folderId: number): PlaylistDraft | null {
const r = getDb()
.prepare(`SELECT data, updated_at FROM playlist_drafts WHERE folder_id = ?`)
.get(folderId) as { data: string; updated_at: string } | undefined
return r ? { data: r.data, updatedAt: r.updated_at } : null
}
export function deletePlaylistDraft(folderId: number): void {
getDb().prepare(`DELETE FROM playlist_drafts WHERE folder_id = ?`).run(folderId)
}

207
src/tools.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* 외부 도구(ffmpeg / yt-dlp) 버전 조회 + 최신 버전 재설치.
*
* 동기:
* - yt-dlp 는 YouTube 변경에 맞춰 자주 깨진다. 오래된 버전이면 다운로드가 실패하므로
* 운영 중에 최신 버전으로 갈아끼울 수 있어야 한다.
* - 새 바이너리는 data 볼륨 하위 `data/bin` 에 설치한다(컨테이너 재생성에도 유지).
* 바이너리 해석은 `data/bin` 을 PATH/이미지 기본본보다 먼저 본다(editor.ts / youtube.ts).
*
* Linux 컨테이너(운영 런타임) 기준으로 구현. 다른 OS 에서는 명확한 에러를 던진다.
*/
import { spawnSync } from 'node:child_process'
import { promises as fs, createWriteStream } from 'node:fs'
import path from 'node:path'
import https from 'node:https'
import { binDir } from './paths.js'
import { getFfmpegPath, resetFfmpegResolution } from './editor.js'
import { getYtDlpPath, resetYtDlpResolution } from './youtube.js'
export interface ToolStatus {
name: 'ffmpeg' | 'yt-dlp'
available: boolean
version: string | null
path: string | null
/** data/bin 에 재설치된(직접 관리하는) 바이너리인지 여부. */
managed: boolean
}
function parseFfmpegVersion(out: string): string | null {
const first = String(out).split(/\r?\n/)[0] || ''
const m = /ffmpeg version (\S+)/.exec(first)
return m ? m[1] : null
}
function ffmpegStatus(): ToolStatus {
try {
const p = getFfmpegPath()
const r = spawnSync(p, ['-version'], { encoding: 'utf8' })
const ok = r.status === 0
return {
name: 'ffmpeg',
available: ok,
version: ok ? parseFfmpegVersion(r.stdout) : null,
path: p,
managed: p.startsWith(binDir)
}
} catch {
return { name: 'ffmpeg', available: false, version: null, path: null, managed: false }
}
}
function ytDlpStatus(): ToolStatus {
try {
const p = getYtDlpPath()
const r = spawnSync(p, ['--version'], { encoding: 'utf8' })
const ok = r.status === 0
return {
name: 'yt-dlp',
available: ok,
version: ok ? String(r.stdout).trim() : null,
path: p,
managed: p.startsWith(binDir)
}
} catch {
return { name: 'yt-dlp', available: false, version: null, path: null, managed: false }
}
}
export function getToolsStatus(): { ffmpeg: ToolStatus; ytDlp: ToolStatus } {
return { ffmpeg: ffmpegStatus(), ytDlp: ytDlpStatus() }
}
/** HTTPS 다운로드. 3xx 리다이렉트를 직접 따라간다(GitHub→objects.githubusercontent 등). */
function download(url: string, dest: string, redirectsLeft = 5): Promise<void> {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{ headers: { 'User-Agent': 'make-video-site' } },
(res) => {
const status = res.statusCode || 0
if (status >= 300 && status < 400 && res.headers.location) {
res.resume()
if (redirectsLeft <= 0) {
reject(new Error('리다이렉트 한도 초과'))
return
}
const next = new URL(res.headers.location, url).toString()
download(next, dest, redirectsLeft - 1).then(resolve, reject)
return
}
if (status !== 200) {
res.resume()
reject(new Error('HTTP ' + status + ' — ' + url))
return
}
const out = createWriteStream(dest)
res.pipe(out)
out.on('finish', () => out.close(() => resolve()))
out.on('error', reject)
}
)
req.on('error', reject)
req.setTimeout(180000, () => {
req.destroy(new Error('다운로드 타임아웃'))
})
})
}
function assertLinux(): void {
if (process.platform !== 'linux') {
throw new Error('자동 재설치는 Linux 런타임에서만 지원합니다. 현재: ' + process.platform)
}
}
/** 최신 yt-dlp 를 data/bin/yt-dlp 로 내려받아 교체한다. */
export async function updateYtDlp(): Promise<ToolStatus> {
assertLinux()
const asset = process.arch === 'arm64' ? 'yt-dlp_linux_aarch64' : 'yt-dlp_linux'
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
await fs.mkdir(binDir, { recursive: true })
const tmp = path.join(binDir, 'yt-dlp.download')
const dest = path.join(binDir, 'yt-dlp')
await fs.rm(tmp, { force: true })
await download(url, tmp)
await fs.chmod(tmp, 0o755)
// 검증: 내려받은 바이너리가 실제로 실행되는지 확인 후에만 교체.
const v = spawnSync(tmp, ['--version'], { encoding: 'utf8' })
if (v.status !== 0) {
await fs.rm(tmp, { force: true })
throw new Error('내려받은 yt-dlp 가 실행되지 않습니다.')
}
await fs.rename(tmp, dest)
resetYtDlpResolution()
return ytDlpStatus()
}
/** 추출 디렉토리에서 지정한 이름의 파일을 재귀로 찾는다. */
async function findBinaries(
root: string,
names: string[]
): Promise<Record<string, string | null>> {
const result: Record<string, string | null> = {}
for (const n of names) result[n] = null
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) {
await walk(full)
} else if (names.includes(e.name) && result[e.name] === null) {
result[e.name] = full
}
}
}
await walk(root)
return result
}
/**
* 최신 ffmpeg 정적 빌드(johnvansickle)를 받아 ffmpeg/ffprobe 를 data/bin 으로 교체한다.
* 압축 해제에 xz(tar -J)가 필요하다 — 런타임 이미지에 xz-utils 가 포함돼야 한다.
*/
export async function updateFfmpeg(): Promise<ToolStatus> {
assertLinux()
const arch =
process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'amd64' : null
if (!arch) throw new Error('지원하지 않는 아키텍처: ' + process.arch)
const url = `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${arch}-static.tar.xz`
await fs.mkdir(binDir, { recursive: true })
const work = path.join(binDir, 'ffmpeg-dl')
await fs.rm(work, { recursive: true, force: true })
await fs.mkdir(work, { recursive: true })
try {
const tarPath = path.join(work, 'ffmpeg.tar.xz')
await download(url, tarPath)
const ex = spawnSync('tar', ['-xJf', tarPath, '-C', work], { encoding: 'utf8' })
if (ex.status !== 0) {
throw new Error('압축 해제 실패(xz 필요): ' + (ex.stderr || ex.error?.message || '').trim())
}
const found = await findBinaries(work, ['ffmpeg', 'ffprobe'])
if (!found.ffmpeg) throw new Error('압축에서 ffmpeg 바이너리를 찾지 못했습니다.')
if (!found.ffprobe) throw new Error('압축에서 ffprobe 바이너리를 찾지 못했습니다.')
// 1) 임시 위치(*.new)로 복사 + 실행 검증. 둘 다 통과해야만 교체한다.
const staged: Array<{ name: 'ffmpeg' | 'ffprobe'; tmp: string; dest: string }> = []
for (const name of ['ffmpeg', 'ffprobe'] as const) {
const src = found[name]
if (!src) continue
const dest = path.join(binDir, name)
const tmp = dest + '.new'
await fs.rm(tmp, { force: true })
await fs.copyFile(src, tmp)
await fs.chmod(tmp, 0o755)
const v = spawnSync(tmp, ['-version'], { encoding: 'utf8' })
if (v.status !== 0) {
await fs.rm(tmp, { force: true })
throw new Error('내려받은 ' + name + ' 가 실행되지 않습니다.')
}
staged.push({ name, tmp, dest })
}
// 2) 검증을 모두 통과한 뒤에만 원자적 교체(rename).
for (const s of staged) await fs.rename(s.tmp, s.dest)
} finally {
await fs.rm(work, { recursive: true, force: true })
}
resetFfmpegResolution()
return ffmpegStatus()
}

View File

@@ -1,14 +1,16 @@
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { jobsDir, projectRoot } from './paths.js'
import { upscaleOriginalTo60Fps } from './editor.js'
import { binDir, jobsDir, projectRoot } from './paths.js'
import { getCookieArgs, hasCookies } from './cookies.js'
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
import {
createVideo,
getFolder,
getVideo,
getVideoInFolder,
updateVideo,
videoDiskDir
videoDiskDir,
type VideoTrim
} from './storeDb.js'
export class YtDlpUnavailableError extends Error {
@@ -21,13 +23,18 @@ let resolvedYtDlpPath: string | null = null
export function getYtDlpPath(): string {
if (resolvedYtDlpPath) return resolvedYtDlpPath
// 1) 프로젝트 bin/yt-dlp(.exe)
const localCandidates =
// 0) data/bin/yt-dlp — 런타임에 최신으로 재설치한 바이너리 (가장 우선, 볼륨 영속)
// 1) 프로젝트 bin/yt-dlp(.exe) — 이미지에 포함된 기본 바이너리
const candidates =
process.platform === 'win32'
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
: ['bin/yt-dlp']
for (const rel of localCandidates) {
const abs = path.join(projectRoot, rel)
? [
path.join(binDir, 'yt-dlp.exe'),
path.join(binDir, 'yt-dlp'),
path.join(projectRoot, 'bin/yt-dlp.exe'),
path.join(projectRoot, 'bin/yt-dlp')
]
: [path.join(binDir, 'yt-dlp'), path.join(projectRoot, 'bin/yt-dlp')]
for (const abs of candidates) {
const r = spawnSync(abs, ['--version'])
if (r.status === 0) {
resolvedYtDlpPath = abs
@@ -43,6 +50,27 @@ export function getYtDlpPath(): string {
throw new YtDlpUnavailableError()
}
/** data/bin 에 새 yt-dlp 를 설치한 뒤 캐시된 해석 결과를 비운다. */
export function resetYtDlpResolution(): void {
resolvedYtDlpPath = null
}
/**
* yt-dlp stderr 가 연령/로그인 확인을 요구하면 사용자 친화 메시지로 바꾼다.
* 그 외에는 원본 stderr 를 그대로 돌려준다.
*/
function friendlyYtDlpError(stderr: string, fallback: string): string {
const text = stderr.trim()
if (/Sign in to confirm your age|confirm your age|inappropriate for some users|Sign in to confirm you'?re not a bot|members[- ]only|account.+cookies/i.test(text)) {
const base =
'이 영상은 연령 제한/로그인이 필요합니다. 관리자 대시보드의 "YouTube 쿠키" 에서 로그인된 계정의 cookies.txt 를 등록한 뒤 다시 시도해 주세요.'
return hasCookies()
? base + ' (현재 등록된 쿠키로는 이 영상을 볼 수 없습니다 — 해당 영상을 볼 수 있는 계정의 쿠키가 필요합니다.)'
: base
}
return text || fallback
}
export interface ProbeResult {
title: string
durationSec: number
@@ -59,6 +87,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
return new Promise<ProbeResult>((resolve, reject) => {
const child = spawn(bin, [
'--no-warnings',
...getCookieArgs(),
'--skip-download',
'--print',
'%(title)s\n%(duration)s\n%(filesize_approx)s'
@@ -66,11 +95,12 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
child.stderr.on('data', (chunk) => (stderr += chunk.toString()))
// stderr 는 에러 메시지 끝부분만 의미가 있어 직렬화에 비례해 자란다 — 마지막 2KB 만 유지.
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp probe 실패 (code=${code})`)))
return
}
const lines = stdout.trim().split('\n')
@@ -96,6 +126,8 @@ export interface PlaylistEntry {
title: string
/** 메타에 없으면 null. UI 표시용. */
durationSec: number | null
/** 미리보기용 썸네일 URL. 못 구하면 null. */
thumbnailUrl: string | null
/** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */
originalIndex: number
}
@@ -111,6 +143,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
const child = spawn(bin, [
'--no-warnings',
...getCookieArgs(),
'--flat-playlist',
'--dump-json',
url
@@ -118,11 +151,12 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
child.stderr.on('data', (chunk) => (stderr += chunk.toString()))
// stdout 은 항목별 JSON 라인이 끝에 모두 필요하므로 그대로 유지. stderr 만 캡.
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp playlist probe 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp playlist probe 실패 (code=${code})`)))
return
}
const entries: PlaylistEntry[] = []
@@ -155,7 +189,14 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
const dur = obj.duration
const durationSec =
typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : null
entries.push({ url: entryUrl, title, durationSec, originalIndex: idx })
// 썸네일: YouTube video id 가 있으면 표준 mqdefault URL 로 만든다(목록용 320x180).
// id 가 없으면 yt-dlp 가 준 thumbnail 필드를 폴백으로 사용.
const thumbnailUrl = id
? `https://i.ytimg.com/vi/${id}/mqdefault.jpg`
: typeof obj.thumbnail === 'string' && obj.thumbnail
? obj.thumbnail
: null
entries.push({ url: entryUrl, title, durationSec, thumbnailUrl, originalIndex: idx })
}
if (entries.length === 0) {
reject(new Error('플레이리스트에서 항목을 찾지 못했습니다.'))
@@ -174,6 +215,8 @@ export interface DownloadJob {
folderId: number
videoId: string
url: string
/** 영상 제목. 재시도 시 레코드가 지워졌을 때 복원용으로도 쓴다. */
title: string
status: JobStatus
progress: number // 0..100
message: string
@@ -181,6 +224,10 @@ export interface DownloadJob {
finishedAt: string | null
outputFile: string | null
error: string | null
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
trim: VideoTrim | null
/** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */
retriedBy: string | null
}
const jobs = new Map<string, DownloadJob>()
@@ -255,6 +302,10 @@ export interface StartDownloadOpts {
title?: string
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
videoId?: string
/** 다운로드 후 적용할 자르기 시작 초 (기본 0). */
startSec?: number
/** 다운로드 후 적용할 자르기 종료 초. null/미지정 이면 끝까지. */
endSec?: number | null
}
/**
@@ -277,24 +328,107 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
sourceUrl: opts.url
})
const now = new Date().toISOString()
const job: DownloadJob = {
id: 'job-' + Math.random().toString(36).slice(2, 14),
// 자르기 구간 정규화. start>0 이거나 end 가 지정된 경우에만 의미가 있다.
const startSec = Math.max(0, Number(opts.startSec) || 0)
const endSecRaw = opts.endSec == null ? null : Number(opts.endSec)
const endSec =
endSecRaw != null && Number.isFinite(endSecRaw) && endSecRaw > startSec
? endSecRaw
: null
const trim: VideoTrim | null =
startSec > 0.01 || endSec != null ? { startSec, endSec } : null
const job = enqueueJob({
folderId: opts.folderId,
videoId: video.id,
url: opts.url,
title: video.title,
trim
})
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
setImmediate(pump)
return job
}
/** DownloadJob 을 만들어 jobs 맵에 등록하고 디스크에 기록한다 (pump 는 호출자 책임). */
function enqueueJob(p: {
folderId: number
videoId: string
url: string
title: string
trim: VideoTrim | null
}): DownloadJob {
const now = new Date().toISOString()
const job: DownloadJob = {
id: 'job-' + Math.random().toString(36).slice(2, 14),
folderId: p.folderId,
videoId: p.videoId,
url: p.url,
title: p.title,
status: 'queued',
progress: 0,
message: '대기 중',
startedAt: now,
finishedAt: null,
outputFile: null,
error: null
error: null,
trim: p.trim,
retriedBy: null
}
jobs.set(job.id, job)
void persistJob(job)
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
return job
}
/**
* 실패한 다운로드를 같은 영상 ID·URL·자르기 설정으로 다시 시도한다.
*
* 핵심: 첫 시도에서 이미 만들어진 영상 레코드를 재사용해 같은 디렉터리로 다시 받는다.
* (createVideo 를 다시 부르면 UNIQUE(folder_id,id) 에 걸려 "이미 사용 중" 으로 막히므로,
* 레코드가 남아 있으면 건드리지 않고, 사용자가 지웠을 때만 같은 ID 로 복원한다.)
*/
export async function retryDownloadJob(jobId: string): Promise<DownloadJob> {
const prev = jobs.get(jobId)
if (!prev) throw new Error('원본 작업을 찾을 수 없습니다.')
if (prev.status !== 'error') {
throw new Error('실패한 작업만 다시 시도할 수 있습니다.')
}
// 한 잡당 재시도 1회만. 같은 실패 잡으로 retry 가 동시에 여러 번 들어오면
// 같은 디렉터리로 yt-dlp 가 여럿 떠 파일이 깨진다. 아래 sync 검증을 통과한
// 직후(첫 await 전에) 즉시 표시해 두 번째 호출이 곧바로 거절되게 한다.
if (prev.retriedBy) throw new Error('이미 재시도한 작업입니다. 새로 생성된 작업에서 다시 시도해 주세요.')
if (!getFolder(prev.folderId)) throw new Error('폴더를 찾을 수 없습니다.')
// yt-dlp 가 없으면 다시 시도해도 실패하므로 사전에 던진다.
getYtDlpPath()
const existing = getVideoInFolder(prev.folderId, prev.videoId)
// 사용자가 실패 카드를 지운 뒤 같은 ID 로 다른 영상을 추가했다면, 그 영상 디렉터리에
// 옛 URL 을 덮어쓰면 안 된다. 남아 있는 레코드가 이 잡과 같은 영상일 때만 재시도한다.
if (existing && (existing.sourceType !== 'youtube' || existing.sourceUrl !== prev.url)) {
throw new Error('이 영상 ID 는 다른 영상으로 교체되어 재시도할 수 없습니다.')
}
// 검증 통과 — 이 시점부터 같은 잡의 중복 재시도를 막는다 (createVideo await 이전).
prev.retriedBy = 'pending'
if (!existing) {
// 사용자가 실패 카드를 지웠다면 같은 ID 로 레코드를 다시 만든다.
await createVideo({
id: prev.videoId,
folderId: prev.folderId,
title: prev.title || '제목 없음',
originalFile: 'original.%(ext)s',
sourceType: 'youtube',
sourceUrl: prev.url
})
}
const job = enqueueJob({
folderId: prev.folderId,
videoId: prev.videoId,
url: prev.url,
title: prev.title,
trim: prev.trim
})
prev.retriedBy = job.id
void persistJob(prev)
setImmediate(pump)
return job
}
@@ -304,11 +438,12 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
job.message = '다운로드 시작'
await persistJob(job)
const dir = videoDiskDir(job.videoId)
const dir = videoDiskDir(job.folderId, job.videoId)
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [
'--no-warnings',
...getCookieArgs(),
'--no-playlist',
'--newline',
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
@@ -325,9 +460,22 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
job.url
]
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
let downloadPctRaw = 0
//
// yt-dlp 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받으면 각 스트림이
// 0→100% 를 반복한다. 예전엔 `Math.max(prev, pct)` 로만 누적했는데, 그러면
// 첫 스트림이 100% 도달한 순간 매핑값이 50% 에 박혀서 두번째 스트림 진행이
// 사용자에게 안 보였다 (짧은 영상은 첫 스트림 청크 한 번에 100% 가 돼서
// "0 → 50% 한방 점프" 처럼 보이는 5/16 버그의 직접 원인).
//
// 해결: 스트림 카운트로 가중평균. pct 가 크게 떨어지면(>5%p) 새 스트림 시작.
// overall = (완료스트림수 * 100 + 현재스트림pct) / 예상스트림수 / 100
// 예상스트림수 = 2 (bv + ba). 단일 결합 포맷 `b` 폴백이면 한 스트림만
// 끝나고 끝나지만, 그땐 곧이어 ffmpeg 후처리 메시지로 점프하므로
// 사용자가 "멈췄다"고 느낄 시간이 없다.
const EXPECTED_STREAMS = 2
let streamsCompleted = 0
let lastPctSeen = -1
let currentStreamPct = 0
return new Promise<void>((resolve, reject) => {
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
@@ -344,10 +492,24 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
if (m) {
const pct = Number(m[1])
if (Number.isFinite(pct)) {
downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct))
const mapped = Math.round(downloadPctRaw * 0.5) // 0..50
const clamped = Math.max(0, Math.min(100, pct))
if (lastPctSeen >= 0 && clamped + 5 < lastPctSeen) {
// 새 스트림 시작 (이전 스트림이 100 근처에서 다시 0~ 으로 떨어짐)
streamsCompleted += 1
currentStreamPct = clamped
} else {
// 단조 증가만 인정 (같은 스트림 내부)
currentStreamPct = Math.max(currentStreamPct, clamped)
}
lastPctSeen = clamped
const totalUnits = Math.min(
EXPECTED_STREAMS * 100,
streamsCompleted * 100 + currentStreamPct
)
const phasePct = (totalUnits / (EXPECTED_STREAMS * 100)) * 100
const mapped = Math.round(phasePct * 0.5) // 0..50
if (mapped > job.progress) job.progress = mapped
job.message = `다운로드 ${Math.round(downloadPctRaw)}%`
job.message = `다운로드 ${Math.round(phasePct)}%`
}
}
const o = /^OUT\s+(.+)$/.exec(line.trim())
@@ -360,7 +522,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
child.on('error', (err) => reject(err))
child.on('close', async (code) => {
if (code !== 0) {
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderrTail, `yt-dlp 실패 (code=${code})`)))
return
}
// 실제 파일명 결정
@@ -376,34 +538,54 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
// minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
// 프레임 복제로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
//
// 5/16 버그 ("60fps 변환 확인 중에서 하루종일 멈춰있어") 대응:
// - ffmpeg `-progress pipe:1` 이 영상 길이를 모르거나 짧은 영상이 1초 안에
// 끝나면 onProgress 가 한 번도 안 불려 progress 가 50% 에 박혀 있었다.
// - 사용자 입장에선 "멈춤" 으로 보이므로, ffmpeg 진행률과 무관하게
// 매 2초마다 message 에 경과시간을 박아 화면이 살아있다는 신호를 준다.
// (progress 막대 자체는 실제 데이터가 들어올 때만 움직임 — 거짓 증가 X.)
try {
// 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다.
const phaseStartedAt = Date.now()
job.progress = 50
job.message = '60fps 변환 준비 중'
job.message = '60fps/FHD 변환 시작'
await persistJob(job)
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
let lastPersistAt = 0
let lastBumpPct = 0
const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
if (pct <= lastBumpPct) return
lastBumpPct = pct
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
if (mapped > job.progress) job.progress = mapped
job.message = `60fps 변환 ${Math.round(pct)}%`
const now = Date.now()
if (now - lastPersistAt > 2000) {
lastPersistAt = now
void persistJob(job)
}
})
let realProgressSeen = false
const heartbeat = setInterval(() => {
if (realProgressSeen) return
const elapsed = Math.floor((Date.now() - phaseStartedAt) / 1000)
job.message = `60fps/FHD 변환 중 (${elapsed}s 경과)`
}, 2000)
let bumped: string
try {
bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
if (pct <= lastBumpPct) return
realProgressSeen = true
lastBumpPct = pct
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
if (mapped > job.progress) job.progress = mapped
job.message = `60fps/FHD 변환 ${Math.round(pct)}%`
const now = Date.now()
if (now - lastPersistAt > 2000) {
lastPersistAt = now
void persistJob(job)
}
})
} finally {
clearInterval(heartbeat)
}
if (bumped !== finalName) {
job.message = '60fps 변환 완료'
job.message = '60fps/FHD 변환 완료'
finalName = bumped
} else {
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
// (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패.
// 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다.
// (a) 이미 60fps + FHD 이하라 변환 자체가 필요 없거나
// (b) ffmpeg 없거나 (c) 인코딩 실패. 어느 경우든 후처리는 끝났다고 보고
// 진행률만 99 까지 채운다.
job.progress = 99
}
await persistJob(job)
@@ -412,10 +594,25 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
console.error('[youtube] 60fps 후처리 실패:', err)
}
const meta = getVideo(job.videoId)
const meta = getVideoInFolder(job.folderId, job.videoId)
if (meta) {
updateVideo(job.videoId, { originalFile: finalName })
updateVideo(job.folderId, job.videoId, { originalFile: finalName })
}
// 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에
// ffmpeg 로 적용해 edited.<ext> 를 만든다. 실패해도 원본은 그대로 두고
// 다운로드 자체는 성공으로 본다 (썸네일 후처리와 동일한 정책).
if (job.trim) {
try {
job.message = '자르기 적용 중'
await persistJob(job)
await applyTrimToVideo(job.folderId, job.videoId, job.trim)
job.message = '자르기 완료'
} catch (err) {
console.error('[youtube] 자르기 적용 실패:', err)
}
}
job.progress = 100
job.status = 'done'
job.message = '완료'

View File

@@ -30,7 +30,7 @@
<% if (!isSubFolder) { %>
<section class="folderGrid">
<% if (!subFolders || subFolders.length === 0) { %>
<% if ((!subFolders || subFolders.length === 0) && videos.length === 0) { %>
<p class="muted">아직 하위 폴더가 없습니다.</p>
<% } %>
<% (subFolders || []).forEach(function (sf) { %>
@@ -40,22 +40,25 @@
</a>
<% }) %>
</section>
<% } else { %>
<section class="videoGrid">
<% if (videos.length === 0) { %>
<p class="muted">이 폴더에 영상이 없습니다.</p>
<% } %>
<% videos.forEach(function (v) {
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
%>
<a class="videoCard" href="<%= shareUrl %>"
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
<div class="videoThumb">▶</div>
<div class="videoTitle"><%= v.title %></div>
</a>
<% }) %>
</section>
<% } %>
<section class="videoGrid">
<% if (videos.length === 0 && isSubFolder) { %>
<p class="muted">이 폴더에 영상이 없습니다.</p>
<% } %>
<% videos.forEach(function (v) {
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
%>
<a class="videoCard" href="<%= shareUrl %>"
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
<div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<span class="videoThumbPlay">▶</span>
</div>
<div class="videoTitle"><%= v.title %></div>
</a>
<% }) %>
</section>
</main>
<div class="ctxMenu" id="ctxMenu" hidden>

View File

@@ -30,6 +30,52 @@
</div>
<% }) %>
</section>
<section class="toolsSection">
<h2>도구 관리</h2>
<p class="muted">영상 다운로드·변환에 쓰는 외부 도구입니다. 너무 예전 버전이라 오류가 나거나 다운로드가 안 되면 최신 버전으로 재설치하세요. (Linux 런타임 기준)</p>
<div class="toolGrid">
<div class="toolCard" data-tool="yt-dlp">
<div class="toolHead">
<span class="toolName">yt-dlp</span>
<span class="toolVersion" data-field="version">확인 중…</span>
</div>
<div class="toolPath muted" data-field="path"></div>
<div class="toolRow">
<button type="button" class="secondaryButton" data-action="update-tool" data-name="yt-dlp">최신으로 재설치</button>
<span class="toolStatus muted" data-field="status"></span>
</div>
</div>
<div class="toolCard" data-tool="ffmpeg">
<div class="toolHead">
<span class="toolName">ffmpeg</span>
<span class="toolVersion" data-field="version">확인 중…</span>
</div>
<div class="toolPath muted" data-field="path"></div>
<div class="toolRow">
<button type="button" class="secondaryButton" data-action="update-tool" data-name="ffmpeg">최신으로 재설치</button>
<span class="toolStatus muted" data-field="status"></span>
</div>
</div>
</div>
</section>
<section class="toolsSection" id="cookiesSection">
<h2>YouTube 쿠키</h2>
<p class="muted">연령 제한("Sign in to confirm your age") 영상은 로그인된 계정의 쿠키가 있어야 다운로드됩니다. 브라우저 확장(예: "Get cookies.txt LOCALLY")으로 내보낸 Netscape 형식 <code>cookies.txt</code> 를 등록하세요. 단일 영상·플레이리스트·실제 다운로드에 모두 적용됩니다.</p>
<div class="toolCard">
<div class="toolHead">
<span class="toolName">cookies.txt</span>
<span class="toolVersion" id="cookieStatus">확인 중…</span>
</div>
<div class="toolRow">
<input type="file" id="cookieFile" accept=".txt,text/plain" />
<button type="button" class="secondaryButton" id="cookieUploadBtn">등록 / 교체</button>
<button type="button" class="dangerLink" id="cookieDeleteBtn" hidden>삭제</button>
<span class="toolStatus muted" id="cookieMsg"></span>
</div>
</div>
</section>
</main>
<div class="ctxMenu" id="ctxMenu" hidden>

View File

@@ -49,7 +49,7 @@
</div>
<video id="editVideo" controls preload="metadata"
<% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video>
<% if (video) { %>src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>?edited=0"<% } %>></video>
<div class="trimTimeline">
<div class="trimTimelineHeader">

View File

@@ -26,8 +26,9 @@
<div class="dashboardActions">
<% if (!isSubFolder) { %>
<button type="button" class="primaryButton" id="addSubFolderBtn">하위 폴더 추가</button>
<% } else { %>
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
<% } %>
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
<% if (isSubFolder) { %>
<a class="primaryButton" href="<%= playlistHref %>">플레이리스트 추가</a>
<% } %>
</div>
@@ -36,7 +37,7 @@
<% if (!isSubFolder) { %>
<section class="folderGrid" id="subFolderGrid">
<% if (!subFolders || subFolders.length === 0) { %>
<p class="muted">아직 하위 폴더가 없습니다. 우측 상단에서 하위 폴더를 추가하세요. (영상은 하위 폴더 안에서만 추가할 수 있습니다.)</p>
<p class="muted">아직 하위 폴더가 없습니다. 우측 상단에서 하위 폴더를 추가하세요. (영상도 이 폴더에 바로 추가할 수 있습니다.)</p>
<% } %>
<% (subFolders || []).forEach(function (sf) { %>
<div class="folderCard adminFolder" data-folder-id="<%= sf.id %>" data-name="<%= sf.name %>">
@@ -47,28 +48,31 @@
</div>
<% }) %>
</section>
<% } else { %>
<section class="videoGrid" id="videoGrid">
<% if (videos.length === 0) { %>
<p class="muted">이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.</p>
<% } %>
<% videos.forEach(function (v) {
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
%>
<div class="videoCard adminVideo"
data-id="<%= v.id %>"
data-video-id="<%= v.id %>"
data-title="<%= v.title %>"
data-share-url="<%= shareUrl %>">
<div class="videoThumb">▶</div>
<div class="videoTitle"><%= v.title %></div>
<% if (v.sourceType === 'youtube') { %>
<div class="muted">YouTube</div>
<% } %>
</div>
<% }) %>
</section>
<% } %>
<section class="videoGrid" id="videoGrid">
<% if (videos.length === 0) { %>
<p class="muted">이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.</p>
<% } %>
<% videos.forEach(function (v) {
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
%>
<div class="videoCard adminVideo"
data-id="<%= v.id %>"
data-video-id="<%= v.id %>"
data-title="<%= v.title %>"
data-share-url="<%= shareUrl %>">
<div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<span class="videoThumbPlay">▶</span>
</div>
<div class="videoTitle"><%= v.title %></div>
<% if (v.sourceType === 'youtube') { %>
<div class="muted">YouTube</div>
<% } %>
</div>
<% }) %>
</section>
</main>
<div class="ctxMenu" id="ctxMenu" hidden>

View File

@@ -20,13 +20,19 @@
</header>
<main class="editorMain">
<section class="playlistProbeSection">
<section class="playlistProbeSection" id="probeSection">
<p class="muted">유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.</p>
<div class="playlistProbeRow">
<input type="text" id="playlistUrl" placeholder="https://www.youtube.com/playlist?list=..." />
<button type="button" class="primaryButton" id="probeBtn">목록 불러오기</button>
</div>
<p id="probeStatus" class="muted"></p>
<div class="playlistDraftRow">
<button type="button" class="secondaryButton" id="draftSaveBtn">임시저장</button>
<button type="button" class="secondaryButton" id="draftLoadBtn" hidden>불러오기</button>
<button type="button" class="secondaryButton" id="draftDeleteBtn" hidden>임시저장 삭제</button>
<span id="draftStatus" class="muted"></span>
</div>
</section>
<section class="playlistEntriesSection" id="entriesSection" hidden>
@@ -39,6 +45,13 @@
<input type="checkbox" id="zeroPadToggle" /> 자릿수 채우기 (예: 001)
</label>
</div>
<div class="bulkTrimBox">
<span class="idModeLabel">일괄 구간</span>
<label>시작 <input type="text" id="bulkStart" placeholder="0:00" /></label>
<label>길이(초) <input type="text" id="bulkLength" placeholder="15" /></label>
<button type="button" class="secondaryButton" id="bulkApplyBtn">적용</button>
<button type="button" class="secondaryButton" id="bulkResetBtn">초기화</button>
</div>
<div class="playlistSummary">
<span id="entriesCount" class="muted">0 항목</span>
</div>
@@ -51,14 +64,77 @@
<button type="button" class="primaryButton" id="registerBtn">등록 &amp; 다운로드 시작</button>
</div>
</section>
<section class="playlistProgressSection" id="progressSection" hidden>
<div class="progressHeader">
<span class="idModeLabel" id="progressSummary">다운로드 진행 중…</span>
<progress id="progressOverall" value="0" max="100"></progress>
</div>
<ol class="progressList" id="progressList"></ol>
<div class="playlistFooter">
<p id="progressStatus" class="muted">페이지를 닫아도 다운로드는 백그라운드에서 계속됩니다.</p>
<a class="primaryButton" id="progressDoneBtn" href="<%= folderHref %>" hidden>폴더로 이동</a>
</div>
</section>
</main>
<!-- 항목 우클릭 메뉴 -->
<div class="ctxMenu" id="entryCtxMenu" hidden>
<button type="button" data-action="trim">자르기</button>
</div>
<!-- 미리보기 재생 모달 -->
<div class="playerOverlay" id="previewOverlay" hidden>
<div class="playerModal" role="dialog" aria-modal="true">
<button type="button" class="playerClose" id="previewClose" aria-label="닫기">✕</button>
<div class="playerTitle" id="previewTitle"></div>
<div class="ytEmbedWrap"><div id="previewPlayer"></div></div>
</div>
</div>
<!-- 자르기(트림) 모달 — /video/editor 타임라인을 YouTube 임베드로 재현 -->
<div class="playerOverlay" id="trimOverlay" hidden>
<div class="playerModal trimModal" role="dialog" aria-modal="true">
<button type="button" class="playerClose" id="trimClose" aria-label="닫기">✕</button>
<div class="playerTitle" id="trimTitle"></div>
<div class="ytEmbedWrap"><div id="trimPlayer"></div></div>
<div class="trimTimeline">
<div class="trimTimelineHeader">
<span class="timeReadout" id="plTimeReadout">00:00.0 / 00:00.0</span>
<div class="trimTimelineActions">
<button type="button" data-pmark="start" title="현재 시점을 시작점으로">[&nbsp;시작점</button>
<button type="button" data-pmark="end" title="현재 시점을 끝점으로">끝점&nbsp;]</button>
<button type="button" data-paction="playSelection" title="선택 구간만 재생">▶ 선택 재생</button>
<button type="button" data-paction="reset" title="자르기 초기화">초기화</button>
</div>
</div>
<div class="trimBar" id="plTrimBar">
<div class="trimRangeFill" id="plTrimRangeFill"></div>
<div class="trimHandle trimHandleStart" id="plTrimHandleStart" data-phandle="start" title="시작 핸들"></div>
<div class="trimHandle trimHandleEnd" id="plTrimHandleEnd" data-phandle="end" title="끝 핸들"></div>
<div class="trimPlayhead" id="plTrimPlayhead"></div>
</div>
<div class="trimNumericRow">
<label>시작(초) <input type="number" id="plStartSec" step="0.1" min="0" value="0" /></label>
<span class="trimDuration" id="plTrimDuration">선택: 0.0초</span>
<label>길이(초) <input type="number" id="plLengthSec" step="0.1" min="0.1" value="" /></label>
</div>
<p class="muted">재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/길이가 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.</p>
</div>
<div class="modalActions">
<button type="button" class="secondaryButton" id="trimCancel">취소</button>
<button type="button" class="primaryButton" id="trimApply">적용</button>
</div>
</div>
</div>
<script>
window.__PLAYLIST__ = {
folderPath: <%- jsonForScript(breadcrumb) %>,
folderId: <%- jsonForScript(folder.id) %>
}
</script>
<script src="https://www.youtube.com/iframe_api"></script>
<script src="/static/playlist.js"></script>
</body>
</html>

View File

@@ -27,7 +27,7 @@
</section>
<div class="standalonePlayer">
<video controls autoplay preload="metadata" src="/api/video/<%= encodeURIComponent(video.id) %>/file"></video>
<video controls autoplay preload="metadata" src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>"></video>
</div>
</main>
</body>