Commit Graph

27 Commits

Author SHA1 Message Date
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)
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
Claude (owner)
31c754a3ad feat(playlist): import UI with drag reorder + random/sequential ID toggle (STEP 5)
Adds /op/folder/:top/:sub/playlist/new page that lets the operator paste a
YouTube playlist URL, probe entries via yt-dlp, reorder them by drag, edit
title/ID per row, and choose between random IDs or zero-padded sequential
IDs before registering all jobs in one batch.
2026-06-01 00:15:39 +09:00
Claude (owner)
36c9899040 fix(playlist): cap concurrent downloads (P1 review fix)
플레이리스트 일괄 등록 시 모든 항목이 즉시 yt-dlp+ffmpeg 를 띄워
서버를 박살내던 문제 수정. `startYoutubeDownload` 는 더 이상 직접
runJob 을 setImmediate 로 띄우지 않고, `queued` 상태로만 등록한
뒤 `pump()` 가 활성 잡 수를 보고 슬롯이 비었을 때만 launchJob
한다. 완료/실패 시 `finally` 에서 카운터를 줄이고 다음 큐를 펌핑.

기본 동시 실행 한도는 2 (네트워크 다운로드 + CPU 후처리가 한쌍은
같이 굴러갈 만한 선). `MAX_DOWNLOAD_CONCURRENCY=N` 환경변수로 조정.

스모크 (yt-dlp 스텁 + 3s sleep):
- MAX=1, 3건 등록 → t+0.5s: 1 downloading + 2 queued → t+4s:
  1 done + 1 downloading + 1 queued → t+10s: 3 done
- MAX=2 (기본), 4건 등록 → t+0.5s: 2 downloading + 2 queued → t+8s: 4 done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:07:42 +09:00
Claude (owner)
b0c15f49a4 feat(playlist): yt-dlp playlist probe + bulk start backend
영상 단건 import 와 동일한 패턴으로 2단계 폴더 전용 endpoint 두 개를
추가했다.

- src/youtube.ts: `probeYoutubePlaylist(url)` — yt-dlp 의
  `--flat-playlist --dump-json` 으로 한 줄당 한 항목을 받아
  `{ url, title, durationSec, originalIndex }` 배열로 정리한다.
  내부 컨테이너 라인(`_type==='playlist'`) 은 스킵.
- src/routes/op.ts:
  - POST `/op/folder/:topName/:subName/playlist/probe`
    URL 만 받고 위 함수를 호출해서 entries 를 돌려준다.
  - POST `/op/folder/:topName/:subName/playlist/start`
    UI 가 큐레이션한 `[{ url, title, videoId }]` 를 받아
    `isSafeVideoId` 형식 검사, 요청 내 중복 검사, DB 충돌 검사를
    순서대로 한 뒤 항목별로 `startYoutubeDownload` 를 호출.
  두 endpoint 모두 1단계 폴더 path 는 router 매칭 자체에서 빠져
  (sub 만 등록) 자연스럽게 404 가 난다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:01:18 +09:00
Claude (owner)
652df2dbdb fix(videos): reject bad-format id in idAvailable (P2 review fix)
`videoIdExists()`는 잘못된 형식의 ID 에 대해 false 를 반환하기 때문에
형식 오류가 곧바로 `{available:true}` 로 흘러나가는 계약 위반이 있었다.
이제 빈 값 검사 직후 `isSafeVideoId` 로 형식을 먼저 거르고,
형식 오류면 `{available:false, reason:'형식: 영문/숫자/_/- (1~64자)'}`
로 응답한다. 클라이언트 regex 와 동일한 형식이라 메시지도 일관된다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:55:55 +09:00
Claude
3089af1ad8 fix(folders): enforce videos-only at depth 2 (P1 review fix)
- routes/op.ts: editor / upload / youtube-start reject when folder.parentId === null
- op/folder.ejs: at depth 1 show only "하위 폴더 추가"; at depth 2 show
  "영상 추가" + "플레이리스트 추가". Video grid and sub-folder modal
  rendered conditionally.
- folder.ejs (public): at depth 1 render only sub-folder grid; at depth 2
  render only video grid.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:43:24 +09:00
Claude
c5faa8808c feat(folders): nested folder tree (max depth 2) + id-based mutations
- new storeDb.ts: DB+FS layer (folders w/ parent_id, videos w/ global id)
- depth cap: sub-folder cannot have children (enforced in createFolder)
- video id: editable anytime (changeVideoId), uniqueness via PK
- routes:
  - public /folder/:topName[/:subName], /video/:topName[/:subName]/:videoId
  - admin /op/folder/:topName[/:subName], id-based /op/folders/:id/*
    and /op/videos/:id/* (rename/changeId/delete/save + idAvailable check)
- views: breadcrumb + subFolder grid; 플레이리스트 추가 button shows at 2단계
- client JS: id-based mutation endpoints, share URL data attribute,
  in-site player pushes share URL to address bar
- store.ts slimmed to Account + readAccounts (FS layer moved to storeDb)
- pickIntId accepts numeric JSON bodies

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:36:02 +09:00
Claude
3560dcb802 feat(db): introduce SQLite (better-sqlite3) backing store
First step of the folder/video refactor. Adds an app-local SQLite DB at
data/app.db with schema for nested folders (max 2 levels enforced in
code, not SQL) and user-editable globally-unique video IDs.

- src/db.ts: open + WAL + schema (folders, videos) + partial unique
  index for top-level folder names (NULL parent_id case)
- One-shot disk-tree migration: on first start scans data/folders/*
  and seeds folder rows; reads each {videoId}/meta.json to seed video
  rows. Skips invalid/missing meta gracefully.
- Wired into app.ts startup before routes mount.
- gitignore: data/app.db* (DB + WAL/journal sidecars).

Schema only — no routes or store.ts changes yet. Next step swaps the
file-based store calls to DB-backed equivalents.

Smoke-tested on the current data dir: 3 folders migrated, 0 videos
(folders were empty). Build (npx tsc) passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:18:51 +09:00
Claude
958fc9da70 feat(import): cap downloaded video at FHD (1920x1080)
User wants YouTube imports capped at FHD: below-FHD sources stay as-is,
above-FHD sources are forcefully downscaled to FHD preserving aspect.

Two layers:
- yt-dlp `-f bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b` strictly
  picks ≤1080 formats when available; falls back to anything otherwise.
- ffmpeg post-process: probeVideoResolution checks output; if >FHD,
  add scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease
  to the existing 60fps vfilter chain (no extra encode pass — combined
  into the one we already run). trunc(/2)*2 ensures even dims for libx264.

The post-process now triggers when either (fps<60) OR (res>FHD), so a
60fps 4K source that previously skipped re-encoding will now get
downscaled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:48:35 +09:00
Claude
11db6df8d2 fix(import): unbuffer yt-dlp progress, speed up 60fps via fps filter, lock probe btn
세 가지 사용자 보고 처리:

1) 다운로드 바가 멈춰 있다가 한번에 50% 로 점프
   - 원인: yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면
     block-buffered 가 되어 진행률 라인들이 4KB 버퍼에 모였다가 종료 직전에
     쏟아짐. node 의 stdout 'data' 핸들러가 그 전엔 아무 것도 못 봄.
   - 수정: spawn 의 env 에 PYTHONUNBUFFERED=1 추가. 라인 단위로 즉시 flush.

2) 변환을 20배 빠르게
   - 원인: minterpolate(mci) 는 motion estimation 자체가 무거워 mci 기본값
     이어도 영상 길이의 수 배 시간이 든다 (blend 도 5~10배가 한계).
   - 수정: 다운로드 후 60fps 변환과 trim 재인코딩 양쪽에서 minterpolate 를
     `fps=60` (단순 프레임 복제) 으로 교체. preset 도 `veryfast` → `ultrafast`.
     실측상 영상 길이의 1/3 ~ 1배 시간 — mci 대비 수십 배 빠름.
   - 시각적 부드러움은 30fps 와 동일하지만 컨테이너/타이밍은 60fps cfr 로 유지.
     사용자가 알려준 "timing 만 60fps 로 바뀌고 실제 부드러움은 그대로" 경로.

3) "확인" 전 "가져오기" 재클릭 막기
   - probe 클릭 시 ytProbeBtn 도 disabled.
   - probe 실패 / 5분 경고 취소 / start 실패 시만 재활성화.
   - 정상 흐름은 probe 성공 → 확인 클릭 → 다운로드 → redirect 라 재활성화
     필요 없음. ytStartBtn 도 클릭 직후 disabled 로 중복 클릭 방지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:07:47 +09:00
Claude
67d4fb89b8 fix(job-poll): disable caching and recover from fetch errors
Review P1: 진행률 폴링이 304 응답을 받으면 r.json() 이 reject 되는데
.catch() 가 없어 setTimeout 도 안 걸리고 폴링이 영구 중단됐습니다.
이게 "60fps 변환 확인 중" 에서 바가 멈춰 보이던 진짜 원인이었어요.

세 곳을 다 고침:

1) src/routes/op.ts `/op/job/:id`
   - `Cache-Control: no-store, no-cache, must-revalidate` + `Pragma: no-cache`
   - 브라우저가 conditional GET 으로 304 를 받지 않게 한다.

2) public/editor.js fetch
   - `{ cache: 'no-store' }` 옵션. 서버 헤더 + 클라 옵션 둘 다.

3) public/editor.js pollJob
   - `.catch()` 추가. 일시적 네트워크/파싱 오류여도 2 초 백오프로
     폴링을 재개한다. 변환이 오래 걸려도 바가 계속 갱신됨을 보장.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:00:58 +09:00
Claude
cdf56b96b7 fix(youtube): split progress 0-50/50-99 and report ffmpeg conversion %
다운로드 후 바가 한 번에 99% 로 튄 뒤 멈춰 있던 문제 두 가지를 같이 고침.

1) 진행률 구간 분리
   - 다운로드: 0 ~ 50% (yt-dlp 의 0-100% 를 절반으로 매핑)
   - 60fps 변환: 50 ~ 99%
   - yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100% 를
     반복하므로 단조 증가만 인정 (downloadPctRaw = max(...)).

2) ffmpeg minterpolate 진행률 실시간 보고
   - editor.ts 에 `probeVideoDuration` 추가, `runFfmpegWithProgress`
     도입해 ffmpeg `-progress pipe:1` 의 out_time_us/ms 를 파싱.
   - `upscaleOriginalTo60Fps` 에 `onProgress(pct)` 콜백 추가.
   - youtube.ts 가 콜백을 받아 50~99% 로 매핑하고 job.message 를
     "60fps 변환 NN%" 로 갱신. persistJob 은 2초 throttle.

3) mci 옵션 단순화로 속도 개선
   - 기존: `mci:mc_mode=aobmc:me_mode=bidir` (가장 느린 조합)
   - 현재: `mci` 기본값 (obmc + bilat). 화질 약간 양보, 속도 수 배 개선.
   - 여전히 mci 자체가 무거워 5분 30fps 영상 변환에 수십 분 단위 시간이
     걸릴 수 있음 — 이제 그 동안 진행률은 실시간으로 움직임.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:57:33 +09:00
Claude
48f84963be fix(editor): rename tmp before unlinking original in 60fps upscale
Review P2: 변환 성공 후 `unlink(input) → rename(tmp)` 순서였는데, unlink 가
성공하고 rename 이 실패하면 원본이 사라진 채 결과물도 없는 상태가 됩니다.

순서를 뒤집어 `rename(tmp → outPath)` 이 먼저 성공한 뒤에만 기존 원본을
지우도록 바꿨습니다. rename 실패 시에는 tmp 만 정리하고 inputName 을 반환해
"실패해도 원본은 그대로" 의도와 일치하게 됩니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:42:59 +09:00
Claude
cdd23b8195 fix(youtube): upscale downloaded original to 60fps after yt-dlp finishes
Review P1: yt-dlp 가 fps 1순위로 정렬해 가져와도, 영상 자체에 60fps
포맷이 없는 경우 original.* 이 30fps 그대로 저장되어 "원본도 60fps 로
받아줘" 요청과 어긋났습니다.

다운로드 완료 직후 후처리 단계를 추가:

- editor.ts 에 `upscaleOriginalTo60Fps(dir, inputName)` 노출
  · ffprobe 로 source fps 측정
  · ≥60fps 면 그대로 두고 inputName 반환
  · <60fps 면 minterpolate(mci, aobmc, bidir) 로 60fps 까지 끌어올려
    `original.mp4` 로 저장하고 기존 파일 제거
  · ffmpeg/ffprobe 없거나 보간 실패하면 원본 그대로 유지 (다운로드 살림)

- youtube.ts `runJob` 마지막에 이 함수를 호출하고, 새 파일명이 돌아오면
  meta.originalFile 도 업데이트. 후처리 중 진행률을 99% 로 표시하고
  실패해도 다운로드 자체는 성공으로 마감.

이로써 편집기에서 다시 보간할 일도 없어집니다 (이미 60fps 원본).
편집 코드 쪽 보호 분기는 직접 업로드 경로용 안전망으로 그대로 둡니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:38:08 +09:00
Claude
105c5bf09d feat(video): prefer max-fps source and bump edited output to 60fps
- youtube.ts: yt-dlp 에 `-S 'fps,res,br,ext'` 추가. 기본 정렬은 fps 를
  가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦았는데,
  이제 fps 1순위로 정렬해 가능한 한 부드러운 원본을 받는다. 60fps 가
  아예 없는 영상은 자연스럽게 그 영상의 최고 fps 로 폴백.

- editor.ts: 편집본은 항상 60fps 이상이 되도록 보장.
  · ffprobe 로 원본 fps 확인
  · ≥60fps 이면 기존대로 stream copy 로 빠르게 trim
  · <60fps 이면 minterpolate(mci, aobmc, bidir) 로 모션 보간해 60fps 로
    재인코딩. mci 는 느리지만 단순 프레임 복제보다 모션이 훨씬 자연스러움.
  · ffprobe 실패 시(확인 불가) 기존 동작 유지(stream copy → 재인코딩 폴백).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:33:37 +09:00
59f96a12a6 feat(routes): add short /file/video/:id alias for video stream
외부 공유용으로 더 짧은 영상 파일 URL 을 제공:
  /file/video/:videoId   ← 신규 (영상 주소 복사 결과)
  /api/video/:videoId/file ← 호환용 alias 로 유지 (기존 링크 안 깨지게)
  /api/video/:videoId     ← 메타 JSON (변경 없음)

핸들러는 한 함수를 두 라우트에 공유. 우클릭 "영상 주소 복사" 가
이제 새 짧은 경로를 클립보드에 넣음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:58:37 +09:00
aae58f645a fix: harden UPLOAD_MAX_BYTES parsing; sync docs with 1 GiB default
리뷰어 지적사항 반영:
- "abc" 같은 오타도 Infinity 로 풀리던 문제 수정. 잘못된 값은 기본 1 GiB 로
  fallback 하고 경고 로그를 남김. 무제한은 "0" 또는 "Infinity" 만 명시적으로 인정.
- .env.example / README 의 "비우면 무제한" 표현을 코드 동작과 일치시켜
  "비우면 기본 1 GiB" 로 정정.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 18:10:27 +09:00
a14963e237 feat: cap upload size at 1 GiB by default (UPLOAD_MAX_BYTES override)
기본 업로드 한도를 1 GiB (1073741824 바이트) 로 설정. .env 의 UPLOAD_MAX_BYTES 로
바꿀 수 있고, 0 이나 Infinity 로 두면 무제한.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 18:06:21 +09:00
e4e697d287 feat: Docker support + .env-driven PORT/HOST config
직접 실행과 Docker compose 가 같은 .env 한 파일로 PORT/HOST/SESSION_SECRET 등을
공유합니다. 컨테이너는 node:22-bookworm-slim 기반에 ffmpeg + 번들된 yt-dlp 포함,
data 볼륨 마운트로 영속화합니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:39:23 +09:00
cb9406d88e feat: npm run setup (yt-dlp + deps + build); lift upload size limit
scripts/setup.mjs runs `npm install`, downloads the platform-specific
yt-dlp binary from GitHub releases to ./bin/yt-dlp (which src/youtube.ts
already prefers), checks for ffmpeg and prints install hints, then runs
`tsc`. One command replaces three for fresh checkouts.

While verifying setup, hit `MulterError: File too large` (LIMIT_FILE_SIZE)
on a 10 GB mkv upload, and ETXTBSY on freshly downloaded yt-dlp.

- ETXTBSY: the redirect path in downloadFile opened a writestream to the
  destination before following the redirect, so the (unused) outer stream
  still held the file open when the post-download spawnSync ran. Split
  redirect-following from file writing so only the final 200 response
  opens the destination file.
- LIMIT_FILE_SIZE: removed the hard-coded 4 GB cap. Upload limit now
  defaults to Infinity and is configurable via UPLOAD_MAX_BYTES.
  Wrapped multer's middleware so its errors (LIMIT_FILE_SIZE etc.) come
  back as a clean 413 JSON instead of a stack trace from the global
  error handler.
- Also disabled Node's default 5 minute requestTimeout so 10 GB uploads
  over slow links don't get cut mid-stream. Configurable via
  HTTP_REQUEST_TIMEOUT_MS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:13:47 +09:00
3f4bcf52d0 fix: escape JSON in <script>; popup player picks edited if available
P1: views were emitting <%- JSON.stringify(...) %> directly inside <script>
tags. A video title like "</script><script>alert(1)</script>" would break
out of the script and inject HTML. Added res.locals.jsonForScript() that
escapes <, >, &, U+2028, U+2029 before output and switched all three
templates (op/editor.ejs, op/folder.ejs, folder.ejs) to use it.

P2: The internal popup player in /folder/:name always hit
/api/video/:id/file which returned the original. Made the file endpoint
default to the edited variant when present and only fall back to original
when ?edited=0 is given. Editor page passes ?edited=0 explicitly so the
operator always re-trims from the original. Standalone /player/:id no
longer needs the ?edited=1 hint.

Verified: rendered editor HTML escapes </script> payloads to \u003c/script,
default file endpoint serves edited.mp4 while ?edited=0 serves original.mp4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:49:03 +09:00
0db04cf5cd feat: implement video site per README spec
- Express + EJS + express-session stack (auth/navbar ported from minecraft_launcher)
- Public: main folder list, folder video grid, internal popup player (/player/:videoId)
- Admin (/op): login, folder CRUD with right-click context menu + add-folder modal
- Admin folder: video grid with right-click edit/rename/delete, "영상 추가" -> editor
- Video editor: drag-drop upload, file picker, YouTube URL probe (ETA + 5분 경고),
  background yt-dlp download with progress polling, navbar title edit, trim controls,
  save runs ffmpeg trim (original preserved)
- Filesystem storage under data/folders/<name>/<videoId>/{meta.json, original.<ext>, edited.<ext>}

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:42:00 +09:00