Compare commits
37 Commits
3560dcb802
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692a824e78 | ||
|
|
745b1db1cc | ||
|
|
0abd21533b | ||
|
|
584ec8b92b | ||
|
|
6850313c0b | ||
|
|
6ade552683 | ||
|
|
03e97d771d | ||
|
|
ff78b60a4b | ||
|
|
042d9f7059 | ||
|
|
0ec6b06b57 | ||
|
|
66469ca418 | ||
|
|
8ed3b7d82a | ||
|
|
f9a26aaa86 | ||
|
|
e9924805cf | ||
|
|
2f916fe958 | ||
|
|
7105349a45 | ||
|
|
34c040c15d | ||
|
|
066ae6b112 | ||
|
|
210787131b | ||
|
|
0faf7af42a | ||
|
|
0e39836c1f | ||
|
|
ad42c88eaf | ||
|
|
8f72fcf4f9 | ||
|
|
83b391c0a8 | ||
|
|
6eac31401a | ||
|
|
90e227d857 | ||
|
|
5693e1c6b0 | ||
|
|
4909d18493 | ||
|
|
a0a094b517 | ||
|
|
ef65d6674f | ||
|
|
31c754a3ad | ||
|
|
36c9899040 | ||
|
|
b0c15f49a4 | ||
|
|
652df2dbdb | ||
|
|
55ce30733b | ||
|
|
3089af1ad8 | ||
|
|
c5faa8808c |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
bin
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@ data/app.db
|
|||||||
data/app.db-journal
|
data/app.db-journal
|
||||||
data/app.db-shm
|
data/app.db-shm
|
||||||
data/app.db-wal
|
data/app.db-wal
|
||||||
|
data/bin/
|
||||||
|
data/cookies.txt
|
||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
49
Dockerfile
Normal file
49
Dockerfile
Normal 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
107
README.md
@@ -1,5 +1,6 @@
|
|||||||
# 비디오 사이트
|
# 비디오 사이트
|
||||||
## 영상 업로드 및 저장을 위한 사이트
|
|
||||||
|
영상 업로드/관리/공개 재생을 다 한 곳에서 처리하는 Express + EJS + better-sqlite3 사이트입니다. 컴퓨터 파일이나 YouTube 링크(단일 영상/플레이리스트) 를 가져와 폴더 단위로 정리하고, 짧은 공유 URL로 외부에 노출합니다.
|
||||||
|
|
||||||
## 실행
|
## 실행
|
||||||
|
|
||||||
@@ -26,58 +27,88 @@ npm start
|
|||||||
- `SESSION_SECRET` — 운영 시 반드시 충분히 긴 랜덤 문자열로 교체.
|
- `SESSION_SECRET` — 운영 시 반드시 충분히 긴 랜덤 문자열로 교체.
|
||||||
- `UPLOAD_MAX_BYTES` — 업로드 용량 한도(바이트). 비우거나 미설정이면 기본 `1073741824` (1 GiB). 무제한으로 두려면 `0` 또는 `Infinity`. 잘못된 값은 기본 1 GiB 로 fallback.
|
- `UPLOAD_MAX_BYTES` — 업로드 용량 한도(바이트). 비우거나 미설정이면 기본 `1073741824` (1 GiB). 무제한으로 두려면 `0` 또는 `Infinity`. 잘못된 값은 기본 1 GiB 로 fallback.
|
||||||
- `HTTP_REQUEST_TIMEOUT_MS` — 대용량 업로드용 HTTP 요청 타임아웃(밀리초). `0`/미설정이면 무제한.
|
- `HTTP_REQUEST_TIMEOUT_MS` — 대용량 업로드용 HTTP 요청 타임아웃(밀리초). `0`/미설정이면 무제한.
|
||||||
|
- `MAX_DOWNLOAD_CONCURRENCY` — 플레이리스트 임포트 시 동시 yt-dlp 잡 개수 상한 (기본 `2`).
|
||||||
- 관리자 비밀번호는 `account.json` 의 `password` 값 (초기값 `admin`, 운영 시 반드시 변경).
|
- 관리자 비밀번호는 `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 은 경고만 출력하고 빌드는 계속 진행됩니다.)
|
- `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/
|
data/
|
||||||
folders/<폴더이름>/<videoId>/
|
app.db # 메인 sqlite (폴더 트리, 영상 메타, 세션)
|
||||||
meta.json # 영상 메타 (제목, trim, 원본/편집본 파일명)
|
folders/<topName>[/<subName>]/<videoId>/ # 영상 파일
|
||||||
original.<ext> # 원본 (항상 보존)
|
original.<ext> # 원본 (항상 보존)
|
||||||
edited.<ext> # 편집본 (저장 시 생성)
|
edited.<ext> # 트림 저장 시 생성
|
||||||
jobs/<jobId>.json # YouTube 다운로드 작업 상태
|
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/:폴더이름)
|
### 폴더 화면 (`/folder/:topName[/:subName]`)
|
||||||
- 폴더 내부에 영상을 유튜브처럼 목록으로 보여줍니다.
|
- 1단계 폴더 화면: **하위 폴더 카드 + 이 폴더에 직접 담긴 영상**을 함께 보여줍니다.
|
||||||
- 누르면 재생 플레이어가 뜨며 영상이 재생됩니다.
|
- 2단계 폴더 화면: **영상만** 보여줍니다 (썸네일 + 제목). 더 깊이 들어갈 수 없습니다.
|
||||||
- 재생플레이어는 /player/:videoId로 합니다.
|
- 영상 카드 클릭 → `/video/:topName[/:subName]/:videoId` 로 이동, 플레이어가 뜨고 재생.
|
||||||
- 플레이어는 내부팝업처럼 띄워줍니다.
|
|
||||||
|
|
||||||
### 관리자 페이지 (/op)
|
### 관리자 페이지 (`/op`)
|
||||||
- 동영상을 추가, 삭제, 관리하는 페이지입니다.
|
- 로그인 로직과 navbar 는 `git.tkrmagid.kr/tkrmagid/minecraft_launcher` 의 op 페이지에서 가져온 형식을 따릅니다.
|
||||||
- https://git.tkrmagid.kr/tkrmagid/minecraft_launcher repo에 사이트에 있는 op페이지에 로그인 로직을 똑같이 가져와서 적용합니다. (navbar도 채택해서 가져오기)
|
- 메인 페이지처럼 폴더를 나열, 우클릭으로 이름 수정 / 삭제.
|
||||||
- 메인 페이지처럼 폴더를 나열하고 우클릭으로 폴더 이름 수정, 삭제가 가능하도록 합니다.
|
- 오른쪽 위 "폴더 추가" 버튼으로 폴더 생성 (팝업에서 이름 입력).
|
||||||
- 폴더를 선택하면 /op/folder/:폴더이름 으로 이동합니다.
|
|
||||||
- 오른쪽위에 폴더추가버튼으로 폴더를 추가할수있도록 합니다.
|
|
||||||
- 폴더추가버튼 누르면 팝업으로 생성할 폴더이름을 작성한뒤 생성누르면 폴더 생성되게 설정합니다.
|
|
||||||
|
|
||||||
### 관리자 폴더 화면 (/op/folder/:폴더이름)
|
### 관리자 폴더 화면 (`/op/folder/...`)
|
||||||
- 폴더 화면 페이지처럼 유튜브형식으로 영상을 나열하고 우클릭으로 수정, 삭제가 가능하도록 합니다.
|
- 영상/서브폴더 목록을 유튜브 스타일로 나열. 우클릭으로 수정/삭제.
|
||||||
- 오른쪽위에 영상추가버튼으로 영상을 추가할수있도록 합니다.
|
- 영상 ID 인라인 편집 가능 (전체 영상에 걸쳐 고유, 안전한 ID 형태만 허용. 변경하면 공유 URL 의 마지막 세그먼트가 같이 바뀝니다).
|
||||||
- 영상추가버튼 누르면 /op/folder/:폴더이름/video/editor 로 이동합니다.
|
- 오른쪽 위 "영상 추가" → `/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)
|
### 영상추가 화면 (`/op/folder/.../video/editor`)
|
||||||
- 예시1: https://github.com/boostcamp-2020/Project13-Web-Video-Editor
|
- 입력: 컴퓨터 파일 드래그&드롭, 파일 선택 다이얼로그, YouTube URL.
|
||||||
- 예시2: https://github.com/mifi/lossless-cut
|
- YouTube URL 의 경우:
|
||||||
- 예시1, 예시2 처럼 영상을 수정할수있는 편집기 페이지를 보여줍니다.
|
1. "확인" 버튼으로 `probe` (title / duration / 예상 파일 크기). probe 가 끝나야 "가져오기" 버튼이 활성화됩니다 (probe 전에 가져오기 누르는 것 자체가 막힘).
|
||||||
- 영상 추가는 컴퓨터에있는 영상을 드래그 드랍 하거나, 직접 폴더에서 찾거나, 유튜브 영상 주소를 추가해서 영상을 추가할수있도록 합니다.
|
2. 예상 소요시간이 5분 이상으로 추정되면 경고를 띄움.
|
||||||
- 유튜브에서 영상 가져오는건 백그라운드에서도 진행 되도록해서 하면서 다른걸 할수있도록 합니다.
|
3. 백그라운드 잡이 시작되고, 다른 페이지를 만지더라도 진행 상황이 계속 업데이트됩니다.
|
||||||
- 영상 가져오는 시간을 미리 계산해서 5분 이상 걸릴거같으면 미리 경고를 보냅니다.
|
- 진행률 바: 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` 상태로 큐에서 대기.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var ctxMenu = document.getElementById('ctxMenu')
|
var ctxMenu = document.getElementById('ctxMenu')
|
||||||
|
var targetId = null
|
||||||
var targetName = null
|
var targetName = null
|
||||||
|
|
||||||
function showCtx(x, y) {
|
function showCtx(x, y) {
|
||||||
@@ -7,11 +8,12 @@
|
|||||||
ctxMenu.style.top = y + 'px'
|
ctxMenu.style.top = y + 'px'
|
||||||
ctxMenu.hidden = false
|
ctxMenu.hidden = false
|
||||||
}
|
}
|
||||||
function hideCtx() { ctxMenu.hidden = true; targetName = null }
|
function hideCtx() { ctxMenu.hidden = true; targetId = null; targetName = null }
|
||||||
|
|
||||||
document.querySelectorAll('.adminFolder').forEach(function (card) {
|
document.querySelectorAll('.adminFolder').forEach(function (card) {
|
||||||
card.addEventListener('contextmenu', function (e) {
|
card.addEventListener('contextmenu', function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
targetId = card.getAttribute('data-folder-id')
|
||||||
targetName = card.getAttribute('data-name')
|
targetName = card.getAttribute('data-name')
|
||||||
showCtx(e.clientX, e.clientY)
|
showCtx(e.clientX, e.clientY)
|
||||||
})
|
})
|
||||||
@@ -23,15 +25,15 @@
|
|||||||
|
|
||||||
ctxMenu.addEventListener('click', function (e) {
|
ctxMenu.addEventListener('click', function (e) {
|
||||||
var btn = e.target.closest('button')
|
var btn = e.target.closest('button')
|
||||||
if (!btn) return
|
if (!btn || !targetId) return
|
||||||
var action = btn.getAttribute('data-action')
|
var action = btn.getAttribute('data-action')
|
||||||
if (action === 'rename') {
|
if (action === 'rename') {
|
||||||
var newName = window.prompt('새 폴더 이름', targetName)
|
var newName = window.prompt('새 폴더 이름', targetName)
|
||||||
if (newName && newName !== targetName) {
|
if (newName && newName !== targetName) {
|
||||||
fetch('/op/folders/rename', {
|
fetch('/op/folders/' + encodeURIComponent(targetId) + '/rename', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ oldName: targetName, newName: newName })
|
body: JSON.stringify({ newName: newName })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || '이름 변경 실패')
|
else alert(j.message || '이름 변경 실패')
|
||||||
@@ -39,10 +41,8 @@
|
|||||||
}
|
}
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
if (window.confirm('"' + targetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) {
|
if (window.confirm('"' + targetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) {
|
||||||
fetch('/op/folders/delete', {
|
fetch('/op/folders/' + encodeURIComponent(targetId) + '/delete', {
|
||||||
method: 'POST',
|
method: 'POST'
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: targetName })
|
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || '삭제 실패')
|
else alert(j.message || '삭제 실패')
|
||||||
@@ -61,20 +61,141 @@
|
|||||||
|
|
||||||
function openModal() { modal.hidden = false; input.value = ''; setTimeout(function () { input.focus() }, 0) }
|
function openModal() { modal.hidden = false; input.value = ''; setTimeout(function () { input.focus() }, 0) }
|
||||||
function closeModal() { modal.hidden = true }
|
function closeModal() { modal.hidden = true }
|
||||||
addBtn.addEventListener('click', openModal)
|
if (addBtn) {
|
||||||
cancelBtn.addEventListener('click', closeModal)
|
addBtn.addEventListener('click', openModal)
|
||||||
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() })
|
cancelBtn.addEventListener('click', closeModal)
|
||||||
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') confirmBtn.click() })
|
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() })
|
||||||
confirmBtn.addEventListener('click', function () {
|
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') confirmBtn.click() })
|
||||||
var name = input.value.trim()
|
confirmBtn.addEventListener('click', function () {
|
||||||
if (!name) return
|
var name = input.value.trim()
|
||||||
fetch('/op/folders', {
|
if (!name) return
|
||||||
method: 'POST',
|
fetch('/op/folders', {
|
||||||
headers: { 'content-type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: name })
|
headers: { 'content-type': 'application/json' },
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
body: JSON.stringify({ name: name })
|
||||||
if (j.ok) location.reload()
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
else alert(j.message || '폴더 생성 실패')
|
if (j.ok) location.reload()
|
||||||
|
else alert(j.message || '폴더 생성 실패')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도구 관리(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()
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
120
public/editor.js
120
public/editor.js
@@ -1,6 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var ctx = window.__EDITOR__ || { folder: '', video: null }
|
var ctx = window.__EDITOR__ || { folderPath: [], folderId: null, video: null }
|
||||||
var folder = ctx.folder
|
var folderPathEnc = (ctx.folderPath || []).map(encodeURIComponent).join('/')
|
||||||
|
var folderBaseUrl = '/op/folder/' + folderPathEnc
|
||||||
var video = ctx.video
|
var video = ctx.video
|
||||||
|
|
||||||
var dropZone = document.getElementById('dropZone')
|
var dropZone = document.getElementById('dropZone')
|
||||||
@@ -19,6 +20,103 @@
|
|||||||
var endSec = document.getElementById('endSec')
|
var endSec = document.getElementById('endSec')
|
||||||
var saveBtn = document.getElementById('saveBtn')
|
var saveBtn = document.getElementById('saveBtn')
|
||||||
|
|
||||||
|
// ── 영상 ID 인라인 편집 (실시간 가용성 표시) ───────────────────────
|
||||||
|
var videoIdInput = document.getElementById('videoIdInput')
|
||||||
|
var idStatus = document.getElementById('idStatus')
|
||||||
|
var changeIdBtn = document.getElementById('changeIdBtn')
|
||||||
|
var copyShareBtn = document.getElementById('copyShareBtn')
|
||||||
|
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
||||||
|
var idDebounce = null
|
||||||
|
|
||||||
|
function setIdStatus(text, cls) {
|
||||||
|
if (!idStatus) return
|
||||||
|
idStatus.textContent = text
|
||||||
|
idStatus.className = 'idStatus ' + (cls || 'muted')
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshIdStatus() {
|
||||||
|
if (!video || !videoIdInput) return
|
||||||
|
var v = (videoIdInput.value || '').trim()
|
||||||
|
if (!v) {
|
||||||
|
setIdStatus('비어 있음', 'idStatusBad')
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (v === video.id) {
|
||||||
|
setIdStatus('현재 ID', 'muted')
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ID_RE.test(v)) {
|
||||||
|
setIdStatus('형식: 영문/숫자/_/- (1~64자)', 'idStatusBad')
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIdStatus('확인 중…', 'muted')
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
clearTimeout(idDebounce)
|
||||||
|
idDebounce = setTimeout(function () {
|
||||||
|
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
|
||||||
|
if ((videoIdInput.value || '').trim() !== v) return
|
||||||
|
if (j && j.ok && j.available) {
|
||||||
|
setIdStatus('사용 가능', 'idStatusOk')
|
||||||
|
changeIdBtn.disabled = false
|
||||||
|
} else {
|
||||||
|
setIdStatus(j && j.reason ? j.reason : '이미 사용 중', 'idStatusBad')
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if ((videoIdInput.value || '').trim() !== v) return
|
||||||
|
setIdStatus('확인 실패', 'idStatusBad')
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
})
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoIdInput && video) {
|
||||||
|
videoIdInput.addEventListener('input', refreshIdStatus)
|
||||||
|
changeIdBtn.addEventListener('click', function () {
|
||||||
|
var newId = (videoIdInput.value || '').trim()
|
||||||
|
if (!newId || newId === video.id) return
|
||||||
|
changeIdBtn.disabled = true
|
||||||
|
setIdStatus('변경 중…', 'muted')
|
||||||
|
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
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')
|
||||||
|
changeIdBtn.disabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 같은 페이지를 새 ID 로 다시 진입 (URL ?id= 도 갱신).
|
||||||
|
location.replace(folderBaseUrl + '/video/editor?id=' + encodeURIComponent(j.video.id))
|
||||||
|
}).catch(function (e) {
|
||||||
|
setIdStatus('변경 실패: ' + e.message, 'idStatusBad')
|
||||||
|
changeIdBtn.disabled = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (copyShareBtn) {
|
||||||
|
copyShareBtn.addEventListener('click', function () {
|
||||||
|
// 입력란의 후보 ID 가 아니라 실제로 DB 에 저장된 video.id 로 URL 을 만든다.
|
||||||
|
// (변경 전에는 새 URL 이 아직 살아있지 않으므로.)
|
||||||
|
var shareUrl = location.origin + '/video/' + folderPathEnc + '/' + encodeURIComponent(video.id)
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(function () {
|
||||||
|
setIdStatus('공유 주소 복사됨', 'idStatusOk')
|
||||||
|
}, function () { window.prompt('주소를 복사하세요:', shareUrl) })
|
||||||
|
} else {
|
||||||
|
window.prompt('주소를 복사하세요:', shareUrl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 드래그&드롭
|
// 드래그&드롭
|
||||||
;['dragenter', 'dragover'].forEach(function (evt) {
|
;['dragenter', 'dragover'].forEach(function (evt) {
|
||||||
dropZone.addEventListener(evt, function (e) {
|
dropZone.addEventListener(evt, function (e) {
|
||||||
@@ -46,7 +144,7 @@
|
|||||||
form.append('title', titleInput.value || file.name)
|
form.append('title', titleInput.value || file.name)
|
||||||
uploadStatus.textContent = '업로드 중...'
|
uploadStatus.textContent = '업로드 중...'
|
||||||
var xhr = new XMLHttpRequest()
|
var xhr = new XMLHttpRequest()
|
||||||
xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload')
|
xhr.open('POST', folderBaseUrl + '/video/upload')
|
||||||
xhr.upload.addEventListener('progress', function (e) {
|
xhr.upload.addEventListener('progress', function (e) {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
var pct = Math.round((e.loaded / e.total) * 100)
|
var pct = Math.round((e.loaded / e.total) * 100)
|
||||||
@@ -57,7 +155,7 @@
|
|||||||
try {
|
try {
|
||||||
var res = JSON.parse(xhr.responseText)
|
var res = JSON.parse(xhr.responseText)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId)
|
location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(res.videoId)
|
||||||
} else {
|
} else {
|
||||||
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
|
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
|
||||||
}
|
}
|
||||||
@@ -89,7 +187,7 @@
|
|||||||
probeInfo.textContent = '확인 중...'
|
probeInfo.textContent = '확인 중...'
|
||||||
ytProbeBtn.disabled = true
|
ytProbeBtn.disabled = true
|
||||||
ytStartBtn.disabled = true
|
ytStartBtn.disabled = true
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
|
fetch(folderBaseUrl + '/video/youtube/probe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ url: url })
|
body: JSON.stringify({ url: url })
|
||||||
@@ -144,7 +242,7 @@
|
|||||||
}
|
}
|
||||||
// 중복 클릭 방지
|
// 중복 클릭 방지
|
||||||
ytStartBtn.disabled = true
|
ytStartBtn.disabled = true
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
|
fetch(folderBaseUrl + '/video/youtube/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ url: url, title: titleInput.value })
|
body: JSON.stringify({ url: url, title: titleInput.value })
|
||||||
@@ -180,7 +278,7 @@
|
|||||||
dlProgress.value = job.progress
|
dlProgress.value = job.progress
|
||||||
probeInfo.textContent = job.message
|
probeInfo.textContent = job.message
|
||||||
if (job.status === 'done') {
|
if (job.status === 'done') {
|
||||||
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
|
location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(videoId)
|
||||||
} else if (job.status === 'error') {
|
} else if (job.status === 'error') {
|
||||||
probeInfo.textContent = '실패: ' + (job.error || '')
|
probeInfo.textContent = '실패: ' + (job.error || '')
|
||||||
} else {
|
} else {
|
||||||
@@ -364,14 +462,14 @@
|
|||||||
saveBtn.addEventListener('click', function () {
|
saveBtn.addEventListener('click', function () {
|
||||||
if (!video) { alert('먼저 영상을 추가하세요.'); return }
|
if (!video) { alert('먼저 영상을 추가하세요.'); return }
|
||||||
var payload = {
|
var payload = {
|
||||||
id: video.id,
|
|
||||||
title: titleInput.value,
|
title: titleInput.value,
|
||||||
startSec: trimStart,
|
startSec: trimStart,
|
||||||
endSec: trimEnd
|
endSec: trimEnd,
|
||||||
|
folderId: ctx.folderId
|
||||||
}
|
}
|
||||||
saveBtn.disabled = true
|
saveBtn.disabled = true
|
||||||
saveBtn.textContent = '저장 중...'
|
saveBtn.textContent = '저장 중...'
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', {
|
fetch('/op/videos/' + encodeURIComponent(video.id) + '/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -380,7 +478,7 @@
|
|||||||
saveBtn.textContent = '저장'
|
saveBtn.textContent = '저장'
|
||||||
if (j.ok) {
|
if (j.ok) {
|
||||||
alert(j.note || '저장 완료')
|
alert(j.note || '저장 완료')
|
||||||
location.href = '/op/folder/' + encodeURIComponent(folder)
|
location.href = folderBaseUrl
|
||||||
} else {
|
} else {
|
||||||
alert(j.message || '저장 실패')
|
alert(j.message || '저장 실패')
|
||||||
}
|
}
|
||||||
|
|||||||
173
public/folder.js
173
public/folder.js
@@ -1,74 +1,173 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var folder = (window.__OP__ || {}).folder
|
var op = window.__OP__ || { folderId: null, folderPath: [], isSubFolder: false }
|
||||||
var ctxMenu = document.getElementById('ctxMenu')
|
var folderPathEnc = (op.folderPath || []).map(encodeURIComponent).join('/')
|
||||||
var targetId = null
|
|
||||||
var targetTitle = null
|
|
||||||
|
|
||||||
function showCtx(x, y) {
|
// ── 영상 컨텍스트 메뉴 ───────────────────────────────────────────────
|
||||||
ctxMenu.style.left = x + 'px'
|
var videoCtxMenu = document.getElementById('ctxMenu')
|
||||||
ctxMenu.style.top = y + 'px'
|
var videoTargetId = null
|
||||||
ctxMenu.hidden = false
|
var videoTargetTitle = null
|
||||||
|
var videoTargetShareUrl = null
|
||||||
|
|
||||||
|
function showVideoCtx(x, y) {
|
||||||
|
videoCtxMenu.style.left = x + 'px'
|
||||||
|
videoCtxMenu.style.top = y + 'px'
|
||||||
|
videoCtxMenu.hidden = false
|
||||||
}
|
}
|
||||||
function hideCtx() { ctxMenu.hidden = true }
|
function hideVideoCtx() { videoCtxMenu.hidden = true }
|
||||||
|
|
||||||
document.querySelectorAll('.adminVideo').forEach(function (card) {
|
document.querySelectorAll('.adminVideo').forEach(function (card) {
|
||||||
card.addEventListener('contextmenu', function (e) {
|
card.addEventListener('contextmenu', function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
targetId = card.getAttribute('data-id')
|
videoTargetId = card.getAttribute('data-id')
|
||||||
targetTitle = card.getAttribute('data-title')
|
videoTargetTitle = card.getAttribute('data-title')
|
||||||
showCtx(e.clientX, e.clientY)
|
videoTargetShareUrl = card.getAttribute('data-share-url')
|
||||||
|
showVideoCtx(e.clientX, e.clientY)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
videoCtxMenu.addEventListener('click', function (e) {
|
||||||
if (!ctxMenu.contains(e.target)) hideCtx()
|
|
||||||
})
|
|
||||||
|
|
||||||
ctxMenu.addEventListener('click', function (e) {
|
|
||||||
var btn = e.target.closest('button')
|
var btn = e.target.closest('button')
|
||||||
if (!btn) return
|
if (!btn || !videoTargetId) return
|
||||||
var action = btn.getAttribute('data-action')
|
var action = btn.getAttribute('data-action')
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(targetId)
|
location.href = '/op/folder/' + folderPathEnc + '/video/editor?id=' + encodeURIComponent(videoTargetId)
|
||||||
} else if (action === 'copyUrl') {
|
} else if (action === 'copyUrl') {
|
||||||
copyVideoUrl(targetId)
|
copyUrl(location.origin + videoTargetShareUrl)
|
||||||
} else if (action === 'rename') {
|
} else if (action === 'rename') {
|
||||||
var t = window.prompt('새 영상 제목', targetTitle)
|
var t = window.prompt('새 영상 제목', videoTargetTitle)
|
||||||
if (t && t !== targetTitle) {
|
if (t && t !== videoTargetTitle) {
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/rename', {
|
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ id: targetId, title: t })
|
body: JSON.stringify({ title: t, folderId: op.folderId })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || '이름 변경 실패')
|
else alert(j.message || '이름 변경 실패')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'changeId') {
|
||||||
if (window.confirm('"' + targetTitle + '" 영상을 정말 삭제할까요?')) {
|
var newId = window.prompt('새 영상 ID (영문/숫자/_/-, 1~64자)', videoTargetId)
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/delete', {
|
if (newId && newId !== videoTargetId) {
|
||||||
|
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ id: targetId })
|
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 변경 실패')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
|
||||||
|
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folderId: op.folderId })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || '삭제 실패')
|
else alert(j.message || '삭제 실패')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hideCtx()
|
hideVideoCtx()
|
||||||
})
|
})
|
||||||
|
|
||||||
function copyVideoUrl(id) {
|
// ── 하위 폴더 컨텍스트 메뉴 (rename / delete) ────────────────────────
|
||||||
var url = location.origin + '/file/video/' + encodeURIComponent(id)
|
var folderCtxMenu = document.getElementById('folderCtxMenu')
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
var folderTargetId = null
|
||||||
navigator.clipboard.writeText(url).then(function () {
|
var folderTargetName = null
|
||||||
flashToast('주소가 복사되었습니다.')
|
|
||||||
}, function () {
|
function showFolderCtx(x, y) {
|
||||||
fallbackCopy(url)
|
folderCtxMenu.style.left = x + 'px'
|
||||||
|
folderCtxMenu.style.top = y + 'px'
|
||||||
|
folderCtxMenu.hidden = false
|
||||||
|
}
|
||||||
|
function hideFolderCtx() { folderCtxMenu.hidden = true }
|
||||||
|
|
||||||
|
document.querySelectorAll('#subFolderGrid .adminFolder').forEach(function (card) {
|
||||||
|
card.addEventListener('contextmenu', function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
folderTargetId = card.getAttribute('data-folder-id')
|
||||||
|
folderTargetName = card.getAttribute('data-name')
|
||||||
|
showFolderCtx(e.clientX, e.clientY)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (folderCtxMenu) {
|
||||||
|
folderCtxMenu.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('button')
|
||||||
|
if (!btn || !folderTargetId) return
|
||||||
|
var action = btn.getAttribute('data-action')
|
||||||
|
if (action === 'rename') {
|
||||||
|
var newName = window.prompt('새 폴더 이름', folderTargetName)
|
||||||
|
if (newName && newName !== folderTargetName) {
|
||||||
|
fetch('/op/folders/' + encodeURIComponent(folderTargetId) + '/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ newName: newName })
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (j.ok) location.reload()
|
||||||
|
else alert(j.message || '이름 변경 실패')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
if (window.confirm('"' + folderTargetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) {
|
||||||
|
fetch('/op/folders/' + encodeURIComponent(folderTargetId) + '/delete', {
|
||||||
|
method: 'POST'
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (j.ok) location.reload()
|
||||||
|
else alert(j.message || '삭제 실패')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideFolderCtx()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (videoCtxMenu && !videoCtxMenu.contains(e.target)) hideVideoCtx()
|
||||||
|
if (folderCtxMenu && !folderCtxMenu.contains(e.target)) hideFolderCtx()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 하위 폴더 추가 모달 ──────────────────────────────────────────────
|
||||||
|
var addSubBtn = document.getElementById('addSubFolderBtn')
|
||||||
|
if (addSubBtn) {
|
||||||
|
var subModal = document.getElementById('addSubFolderModal')
|
||||||
|
var subInput = document.getElementById('addSubFolderInput')
|
||||||
|
var subCancel = document.getElementById('addSubFolderCancel')
|
||||||
|
var subConfirm = document.getElementById('addSubFolderConfirm')
|
||||||
|
|
||||||
|
function openSubModal() {
|
||||||
|
subModal.hidden = false; subInput.value = ''
|
||||||
|
setTimeout(function () { subInput.focus() }, 0)
|
||||||
|
}
|
||||||
|
function closeSubModal() { subModal.hidden = true }
|
||||||
|
addSubBtn.addEventListener('click', openSubModal)
|
||||||
|
subCancel.addEventListener('click', closeSubModal)
|
||||||
|
subModal.addEventListener('click', function (e) { if (e.target === subModal) closeSubModal() })
|
||||||
|
subInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') subConfirm.click() })
|
||||||
|
subConfirm.addEventListener('click', function () {
|
||||||
|
var name = subInput.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
fetch('/op/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name, parentId: op.folderId })
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (j.ok) location.reload()
|
||||||
|
else alert(j.message || '폴더 생성 실패')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 유틸 ──────────────────────────────────────────────────────────
|
||||||
|
function copyUrl(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
flashToast('주소가 복사되었습니다.')
|
||||||
|
}, function () { fallbackCopy(text) })
|
||||||
} else {
|
} else {
|
||||||
fallbackCopy(url)
|
fallbackCopy(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function fallbackCopy(text) {
|
function fallbackCopy(text) {
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var overlay = document.getElementById('playerOverlay')
|
var overlay = document.getElementById('playerOverlay')
|
||||||
|
if (!overlay) return
|
||||||
var closeBtn = document.getElementById('playerClose')
|
var closeBtn = document.getElementById('playerClose')
|
||||||
var video = document.getElementById('playerVideo')
|
var video = document.getElementById('playerVideo')
|
||||||
var titleEl = document.getElementById('playerTitle')
|
var titleEl = document.getElementById('playerTitle')
|
||||||
|
|
||||||
function openPlayer(videoId, title) {
|
function openPlayer(videoId, title, shareUrl) {
|
||||||
// 스펙: /player/:videoId 로 이동한 것처럼 동작하면서 내부 팝업으로 띄운다.
|
// 사이트 내 재생도 share URL 을 주소창에 보이도록 pushState.
|
||||||
// pushState 로 URL 만 바꿔, 새로고침/직접접근 시 player.ejs 가 응답한다.
|
// share URL 이 없으면 단순한 /file/video/:id 로 폴백.
|
||||||
history.pushState({ player: true, videoId: videoId }, '', '/player/' + encodeURIComponent(videoId))
|
var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId))
|
||||||
|
history.pushState({ player: true, videoId: videoId }, '', url)
|
||||||
titleEl.textContent = title || ''
|
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
|
overlay.hidden = false
|
||||||
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
|
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
|
||||||
}
|
}
|
||||||
@@ -19,17 +25,19 @@
|
|||||||
video.pause()
|
video.pause()
|
||||||
video.removeAttribute('src')
|
video.removeAttribute('src')
|
||||||
video.load()
|
video.load()
|
||||||
// 폴더 페이지로 되돌리기
|
|
||||||
if (history.state && history.state.player) {
|
if (history.state && history.state.player) {
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.videoCard').forEach(function (card) {
|
document.querySelectorAll('.videoCard').forEach(function (card) {
|
||||||
|
// <a> 태그면 클릭 시 share URL 로 풀 페이지 이동하도록 두고, 모달은 띄우지 않는다.
|
||||||
|
if (card.tagName && card.tagName.toLowerCase() === 'a') return
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
var id = card.getAttribute('data-video-id')
|
var id = card.getAttribute('data-video-id')
|
||||||
var title = card.querySelector('.videoTitle')
|
var titleNode = card.querySelector('.videoTitle')
|
||||||
openPlayer(id, title ? title.textContent : '')
|
var shareUrl = card.getAttribute('data-share-url')
|
||||||
|
openPlayer(id, titleNode ? titleNode.textContent : '', shareUrl)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
1230
public/playlist.js
Normal file
1230
public/playlist.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var ctxMenu = document.getElementById('ctxMenu')
|
var ctxMenu = document.getElementById('ctxMenu')
|
||||||
if (!ctxMenu) return
|
if (!ctxMenu) return
|
||||||
var targetId = null
|
var targetShareUrl = null
|
||||||
|
|
||||||
function showCtx(x, y) {
|
function showCtx(x, y) {
|
||||||
ctxMenu.style.left = x + 'px'
|
ctxMenu.style.left = x + 'px'
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
document.querySelectorAll('.videoCard').forEach(function (card) {
|
document.querySelectorAll('.videoCard').forEach(function (card) {
|
||||||
card.addEventListener('contextmenu', function (e) {
|
card.addEventListener('contextmenu', function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
targetId = card.getAttribute('data-video-id') || card.getAttribute('data-id')
|
targetShareUrl = card.getAttribute('data-share-url')
|
||||||
showCtx(e.clientX, e.clientY)
|
showCtx(e.clientX, e.clientY)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -27,22 +27,19 @@
|
|||||||
var btn = e.target.closest('button')
|
var btn = e.target.closest('button')
|
||||||
if (!btn) return
|
if (!btn) return
|
||||||
var action = btn.getAttribute('data-action')
|
var action = btn.getAttribute('data-action')
|
||||||
if (action === 'copyUrl' && targetId) {
|
if (action === 'copyUrl' && targetShareUrl) {
|
||||||
copyVideoUrl(targetId)
|
copyUrl(location.origin + targetShareUrl)
|
||||||
}
|
}
|
||||||
hideCtx()
|
hideCtx()
|
||||||
})
|
})
|
||||||
|
|
||||||
function copyVideoUrl(id) {
|
function copyUrl(text) {
|
||||||
var url = location.origin + '/file/video/' + encodeURIComponent(id)
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
navigator.clipboard.writeText(url).then(function () {
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
flashToast('주소가 복사되었습니다.')
|
flashToast('주소가 복사되었습니다.')
|
||||||
}, function () {
|
}, function () { fallbackCopy(text) })
|
||||||
fallbackCopy(url)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
fallbackCopy(url)
|
fallbackCopy(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function fallbackCopy(text) {
|
function fallbackCopy(text) {
|
||||||
|
|||||||
@@ -86,18 +86,41 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.folderCard {
|
.folderCard {
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: 12px; padding: 18px; text-decoration: none; color: var(--text);
|
border-radius: 12px; text-decoration: none; color: var(--text);
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
display: flex; flex-direction: column; align-items: stretch;
|
||||||
cursor: pointer; transition: transform .08s ease;
|
cursor: pointer; transition: transform .08s ease;
|
||||||
}
|
}
|
||||||
.folderCard:hover { transform: translateY(-2px); border-color: var(--accent); }
|
.folderCard:hover { transform: translateY(-2px); border-color: var(--accent); }
|
||||||
|
/* 공개 화면: 카드 자체가 <a> 라 패딩을 직접 적용 */
|
||||||
|
.folderCard:not(.adminFolder) { padding: 18px; align-items: center; gap: 8px; }
|
||||||
|
/* 관리자 화면: 카드는 .adminFolder div, 내부 a 가 카드 전체를 채워 클릭 영역이 됨 */
|
||||||
.folderCardLink {
|
.folderCardLink {
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
text-decoration: none; color: inherit;
|
text-decoration: none; color: inherit;
|
||||||
|
padding: 18px; flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.folderIcon { font-size: 40px; }
|
.folderIcon { font-size: 40px; }
|
||||||
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
|
.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 */
|
/* video grid */
|
||||||
.videoGrid {
|
.videoGrid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
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); }
|
.videoCard:hover { border-color: var(--accent); }
|
||||||
.videoThumb {
|
.videoThumb {
|
||||||
|
position: relative;
|
||||||
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
|
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 36px; color: var(--accent);
|
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; }
|
.videoTitle { padding: 10px 12px; font-size: 14px; }
|
||||||
|
|
||||||
/* login */
|
/* login */
|
||||||
@@ -230,6 +259,20 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
.videoPanel video {
|
.videoPanel video {
|
||||||
width: 100%; max-height: 60vh; background: #000; border-radius: 10px;
|
width: 100%; max-height: 60vh; background: #000; border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
.videoIdRow {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.videoIdRow label { color: var(--text-muted); font-size: 13px; }
|
||||||
|
.videoIdRow input[type="text"] {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 6px 10px; font-family: ui-monospace, monospace;
|
||||||
|
font-size: 14px; min-width: 260px; flex: 1; max-width: 420px;
|
||||||
|
}
|
||||||
|
.idStatus { font-size: 13px; min-width: 90px; }
|
||||||
|
.idStatusOk { color: #5dd28a; }
|
||||||
|
.idStatusBad { color: #e57373; }
|
||||||
.trimTimeline {
|
.trimTimeline {
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 16px;
|
border-radius: 10px; padding: 16px;
|
||||||
@@ -298,3 +341,186 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.adminFolder { position: relative; }
|
.adminFolder { position: relative; }
|
||||||
.adminVideo { position: relative; }
|
.adminVideo { position: relative; }
|
||||||
|
|
||||||
|
/* ─── 플레이리스트 import ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.playlistProbeSection {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.playlistProbeRow {
|
||||||
|
display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.playlistProbeRow input[type="text"] {
|
||||||
|
flex: 1; min-width: 320px;
|
||||||
|
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;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlistControlsRow {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.idModeBox {
|
||||||
|
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.idModeLabel { color: var(--text-muted); font-size: 13px; }
|
||||||
|
.idModeBox label { color: var(--text); font-size: 14px; cursor: pointer; }
|
||||||
|
.zeroPadWrap { margin-left: 8px; color: var(--text-muted) !important; font-size: 13px; }
|
||||||
|
.playlistSummary { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.playlistEntryList {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.playlistEntryRow {
|
||||||
|
display: grid;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.playlistEntryRow.dragGhost { opacity: 0.5; }
|
||||||
|
.dragHandle {
|
||||||
|
cursor: grab; color: var(--text-muted); font-size: 18px;
|
||||||
|
user-select: none; text-align: center;
|
||||||
|
}
|
||||||
|
.entryNum {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.entryTitleInput {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 5px 8px; font-size: 14px; width: 100%;
|
||||||
|
}
|
||||||
|
.entryIdRow {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.entryIdInput {
|
||||||
|
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;
|
||||||
|
width: 240px; max-width: 100%;
|
||||||
|
}
|
||||||
|
.entryIdStatus { font-size: 12px; }
|
||||||
|
.entryDuration {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px; text-align: right;
|
||||||
|
}
|
||||||
|
.entryRemove {
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted); border-radius: 6px; cursor: pointer;
|
||||||
|
width: 28px; height: 28px; padding: 0; font-size: 14px;
|
||||||
|
}
|
||||||
|
.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
89
src/cookies.ts
Normal 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 })
|
||||||
|
}
|
||||||
76
src/db.ts
76
src/db.ts
@@ -14,7 +14,8 @@
|
|||||||
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
|
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
|
||||||
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
|
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
|
||||||
*
|
*
|
||||||
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다.
|
* 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)).
|
||||||
|
* 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다.
|
||||||
*/
|
*/
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
@@ -43,6 +44,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
db.pragma('synchronous = NORMAL')
|
db.pragma('synchronous = NORMAL')
|
||||||
|
|
||||||
createSchema(db)
|
createSchema(db)
|
||||||
|
migrateVideosSchema(db)
|
||||||
_db = db
|
_db = db
|
||||||
|
|
||||||
// 비어 있으면 디스크 스캔 → 마이그레이션.
|
// 비어 있으면 디스크 스캔 → 마이그레이션.
|
||||||
@@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void {
|
|||||||
ON folders(name) WHERE parent_id IS NULL;
|
ON folders(name) WHERE parent_id IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS videos (
|
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,
|
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
original_file TEXT NOT NULL,
|
original_file TEXT NOT NULL,
|
||||||
@@ -82,12 +84,80 @@ function createSchema(db: Database.Database): void {
|
|||||||
trim_end_sec REAL,
|
trim_end_sec REAL,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
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);
|
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 에 적재.
|
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
|
||||||
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.
|
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.
|
||||||
|
|||||||
485
src/editor.ts
485
src/editor.ts
@@ -1,7 +1,9 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process'
|
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 path from 'node:path'
|
||||||
import { videoDir, loadVideoMeta, saveVideoMeta, type VideoTrim } from './store.js'
|
import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||||
|
import { binDir } from './paths.js'
|
||||||
|
|
||||||
export class FfmpegUnavailableError extends Error {
|
export class FfmpegUnavailableError extends Error {
|
||||||
constructor() {
|
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 resolvedFfmpegPath: string | null = null
|
||||||
|
let resolvedFfprobePath: string | null = null
|
||||||
|
|
||||||
export function getFfmpegPath(): string {
|
export function getFfmpegPath(): string {
|
||||||
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
||||||
const r = spawnSync('ffmpeg', ['-version'])
|
const p = resolveBinary('ffmpeg')
|
||||||
if (r.status === 0) {
|
if (!p) throw new FfmpegUnavailableError()
|
||||||
resolvedFfmpegPath = 'ffmpeg'
|
resolvedFfmpegPath = p
|
||||||
return 'ffmpeg'
|
return p
|
||||||
}
|
}
|
||||||
throw new FfmpegUnavailableError()
|
|
||||||
|
/** 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. */
|
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
||||||
export function probeVideoFps(inputPath: string): number | 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',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=avg_frame_rate',
|
'-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
|
return Number.isFinite(single) && single > 0 ? single : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 입력 영상의 해상도(가로×세로 px) 를 ffprobe 로 조회. 실패하면 null. */
|
/**
|
||||||
export function probeVideoResolution(inputPath: string): { width: number; height: number } | null {
|
* fps + 해상도 + 길이를 한 번의 ffprobe 호출로 동시에 받아온다.
|
||||||
const r = spawnSync('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',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=width,height',
|
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
|
||||||
'-of', 'csv=s=x:p=0',
|
'-of', 'default=noprint_wrappers=1',
|
||||||
inputPath
|
inputPath
|
||||||
])
|
])
|
||||||
if (r.status !== 0) return null
|
if (r.status !== 0) return empty
|
||||||
const raw = String(r.stdout).trim()
|
const fields: Record<string, string> = {}
|
||||||
const m = /^(\d+)x(\d+)$/.exec(raw)
|
for (const line of String(r.stdout).split(/\r?\n/)) {
|
||||||
if (!m) return null
|
const eq = line.indexOf('=')
|
||||||
const w = Number(m[1])
|
if (eq <= 0) continue
|
||||||
const h = Number(m[2])
|
fields[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
|
||||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
|
}
|
||||||
return { width: w, height: h }
|
// fps: "N/D" 또는 단일 숫자
|
||||||
}
|
let fps: number | null = null
|
||||||
|
const fpsRaw = fields.avg_frame_rate
|
||||||
/** 입력 영상의 총 재생 길이(초) 를 ffprobe 로 조회. 실패하면 null. */
|
if (fpsRaw) {
|
||||||
export function probeVideoDuration(inputPath: string): number | null {
|
const m = /^(\d+)\/(\d+)$/.exec(fpsRaw)
|
||||||
const r = spawnSync('ffprobe', [
|
if (m) {
|
||||||
'-v', 'error',
|
const n = Number(m[1]); const d = Number(m[2])
|
||||||
'-show_entries', 'format=duration',
|
if (d > 0 && Number.isFinite(n / d) && n / d > 0) fps = n / d
|
||||||
'-of', 'default=nokey=1:noprint_wrappers=1',
|
} else {
|
||||||
inputPath
|
const single = Number(fpsRaw)
|
||||||
])
|
if (Number.isFinite(single) && single > 0) fps = single
|
||||||
if (r.status !== 0) return null
|
}
|
||||||
const v = Number(String(r.stdout).trim())
|
}
|
||||||
return Number.isFinite(v) && v > 0 ? v : null
|
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
|
export const TARGET_FPS = 60
|
||||||
@@ -81,6 +132,164 @@ export const TARGET_FPS = 60
|
|||||||
export const MAX_WIDTH = 1920
|
export const MAX_WIDTH = 1920
|
||||||
export const MAX_HEIGHT = 1080
|
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
|
return inputName
|
||||||
}
|
}
|
||||||
const inputPath = path.join(dir, inputName)
|
const inputPath = path.join(dir, inputName)
|
||||||
const sourceFps = probeVideoFps(inputPath)
|
const meta = probeVideoMeta(inputPath)
|
||||||
const sourceRes = probeVideoResolution(inputPath)
|
const sourceFps = meta.fps
|
||||||
if (sourceFps === null && sourceRes === null) {
|
const sourceWidth = meta.width
|
||||||
|
const sourceHeight = meta.height
|
||||||
|
if (sourceFps === null && sourceWidth === null && sourceHeight === null) {
|
||||||
console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`)
|
console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`)
|
||||||
return inputName
|
return inputName
|
||||||
}
|
}
|
||||||
const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5
|
const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5
|
||||||
const needDownscale =
|
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) {
|
if (!needBumpFps && !needDownscale) {
|
||||||
// 이미 충분히 부드럽고 해상도도 캡 이하.
|
// 이미 충분히 부드럽고 해상도도 캡 이하.
|
||||||
return inputName
|
return inputName
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceDurationSec = probeVideoDuration(inputPath)
|
const sourceDurationSec = meta.durationSec
|
||||||
|
|
||||||
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
|
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
|
||||||
const outName = 'original.mp4'
|
const outName = 'original.mp4'
|
||||||
const outPath = path.join(dir, outName)
|
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 조립:
|
// vfilter 조립:
|
||||||
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
|
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
|
||||||
@@ -139,27 +352,40 @@ export async function upscaleOriginalTo60Fps(
|
|||||||
`scale=trunc(iw/2)*2:trunc(ih/2)*2`
|
`scale=trunc(iw/2)*2:trunc(ih/2)*2`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const vfilter = filters.join(',')
|
// vaapi 의 경우 hwupload 가 vfilter 끝에 붙어야 한다 (그 뒤로는 GPU 전용 필터만 가능).
|
||||||
const args = [
|
// filters 가 비어있는 케이스는 needBumpFps/needDownscale 가 둘 다 false 인
|
||||||
'-y',
|
// 케이스에서 이미 early-return 됐으므로 여기 도달하면 filters.length >= 1.
|
||||||
'-i', inputPath,
|
const buildUpscaleArgs = (profile: EncoderProfile): string[] => {
|
||||||
'-vf', vfilter,
|
const vfilter = filters.join(',') + profile.vfilterSuffix
|
||||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
return [
|
||||||
'-c:a', 'aac', '-b:a', '160k',
|
'-y',
|
||||||
'-movflags', '+faststart',
|
...profile.preInputArgs,
|
||||||
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
'-i', inputPath,
|
||||||
'-progress', 'pipe:1',
|
'-vf', vfilter,
|
||||||
'-nostats',
|
...profile.codecArgs,
|
||||||
tmpPath
|
'-c:a', 'aac', '-b:a', '160k',
|
||||||
]
|
'-movflags', '+faststart',
|
||||||
const ok = await runFfmpegWithProgress(bin, args, (outTimeUs) => {
|
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
||||||
|
'-progress', 'pipe:1',
|
||||||
|
'-nostats',
|
||||||
|
tmpPath
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const onProgressCb = (outTimeUs: number) => {
|
||||||
if (!onProgress || !sourceDurationSec || sourceDurationSec <= 0) return
|
if (!onProgress || !sourceDurationSec || sourceDurationSec <= 0) return
|
||||||
const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100))
|
const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100))
|
||||||
onProgress(pct)
|
onProgress(pct)
|
||||||
})
|
}
|
||||||
|
const ok = await encodeWithFallback(
|
||||||
|
bin,
|
||||||
|
buildUpscaleArgs,
|
||||||
|
tmpPath,
|
||||||
|
(b, a) => runFfmpegWithProgress(b, a, onProgressCb),
|
||||||
|
'upscale'
|
||||||
|
)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
await fs.unlink(tmpPath).catch(() => undefined)
|
await fs.unlink(tmpPath).catch(() => undefined)
|
||||||
console.warn(`[upscale] 후처리 실패 (vf=${vfilter}) — 원본 ${inputName} 유지`)
|
console.warn(`[upscale] 후처리 실패 (filters=${filters.join(',')}) — 원본 ${inputName} 유지`)
|
||||||
return inputName
|
return inputName
|
||||||
}
|
}
|
||||||
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
|
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
|
||||||
@@ -184,21 +410,22 @@ export async function upscaleOriginalTo60Fps(
|
|||||||
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
||||||
*/
|
*/
|
||||||
export async function applyTrimToVideo(
|
export async function applyTrimToVideo(
|
||||||
folder: string,
|
folderId: number,
|
||||||
videoId: string,
|
videoId: string,
|
||||||
trim: VideoTrim
|
trim: VideoTrim
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const bin = getFfmpegPath()
|
const bin = getFfmpegPath()
|
||||||
const meta = await loadVideoMeta(folder, videoId)
|
const meta = getVideoInFolder(folderId, videoId)
|
||||||
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
||||||
const dir = videoDir(folder, videoId)
|
const dir = videoDiskDir(folderId, videoId)
|
||||||
const inputPath = path.join(dir, meta.originalFile)
|
const inputPath = path.join(dir, meta.originalFile)
|
||||||
await fs.access(inputPath)
|
await fs.access(inputPath)
|
||||||
|
|
||||||
const ext = path.extname(meta.originalFile) || '.mp4'
|
const ext = path.extname(meta.originalFile) || '.mp4'
|
||||||
const outName = `edited${ext}`
|
const outName = `edited${ext}`
|
||||||
const outPath = path.join(dir, outName)
|
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 startSec = Math.max(0, Number(trim.startSec) || 0)
|
||||||
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
|
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
|
||||||
@@ -222,26 +449,136 @@ export async function applyTrimToVideo(
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
|
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
|
||||||
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
|
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
|
||||||
const vfilter = needBumpFps ? `fps=${TARGET_FPS}` : null
|
//
|
||||||
const encArgs = [
|
// baseArgs(['-y', '-ss', start, ['-to', end]?, '-i', inputPath]) 를 그대로 쓰면
|
||||||
...baseArgs,
|
// preInputArgs(`-vaapi_device ...` 같은 ffmpeg 전역 옵션) 가 들어갈 곳이 없다.
|
||||||
...(vfilter ? ['-vf', vfilter] : []),
|
// 전역 옵션은 -ss/-to/-i 보다 더 앞이어야 안전하므로 args 를 처음부터 다시 만든다.
|
||||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
const buildTrimEncArgs = (profile: EncoderProfile): string[] => {
|
||||||
'-c:a', 'aac', '-b:a', '128k',
|
let vfilter: string | null = needBumpFps ? `fps=${TARGET_FPS}` : null
|
||||||
'-movflags', '+faststart',
|
if (profile.vfilterSuffix) {
|
||||||
tmpPath
|
// vaapi: hwupload 가 반드시 필터 끝에 필요. 사용자 필터 없어도 format 단계 들어가야 함.
|
||||||
]
|
vfilter = (vfilter ?? '') + profile.vfilterSuffix
|
||||||
ok = await runFfmpeg(bin, encArgs)
|
// 빈 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 실패')
|
if (!ok) throw new Error('ffmpeg trim 실패')
|
||||||
}
|
}
|
||||||
await fs.rename(tmpPath, outPath)
|
await fs.rename(tmpPath, outPath)
|
||||||
|
|
||||||
meta.editedFile = outName
|
updateVideo(folderId, videoId, {
|
||||||
meta.trim = { startSec, endSec }
|
editedFile: outName,
|
||||||
await saveVideoMeta(folder, meta)
|
trim: { startSec, endSec }
|
||||||
|
})
|
||||||
return outName
|
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> {
|
function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(bin, args)
|
const child = spawn(bin, args)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const foldersDir = path.join(dataDir, 'folders')
|
|||||||
export const jobsDir = path.join(dataDir, 'jobs')
|
export const jobsDir = path.join(dataDir, 'jobs')
|
||||||
export const tmpDir = path.join(dataDir, 'tmp')
|
export const tmpDir = path.join(dataDir, 'tmp')
|
||||||
export const dbPath = path.join(dataDir, 'app.db')
|
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 viewsDir = path.join(projectRoot, 'views')
|
||||||
export const publicDir = path.join(projectRoot, 'public')
|
export const publicDir = path.join(projectRoot, 'public')
|
||||||
|
|||||||
16
src/routes/helpers.ts
Normal file
16
src/routes/helpers.ts
Normal 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
|
||||||
|
}
|
||||||
749
src/routes/op.ts
749
src/routes/op.ts
@@ -2,39 +2,49 @@ import { Router } from 'express'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import multer, { MulterError } from 'multer'
|
import multer, { MulterError } from 'multer'
|
||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { promises as fs } from 'node:fs'
|
|
||||||
import { requireAuth } from '../auth.js'
|
import { requireAuth } from '../auth.js'
|
||||||
|
import { readAccounts } from '../store.js'
|
||||||
import {
|
import {
|
||||||
|
changeVideoId,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
createVideo,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
folderPath,
|
folderPathNames,
|
||||||
listFolders,
|
getFolder,
|
||||||
listVideos,
|
getFolderByPath,
|
||||||
loadVideoMeta,
|
getVideoInFolder,
|
||||||
moveUploadIntoVideo,
|
getVideoByIdGlobal,
|
||||||
newVideoId,
|
isSafeVideoId,
|
||||||
readAccounts,
|
listChildFolders,
|
||||||
|
listTopFolders,
|
||||||
|
listVideosInFolder,
|
||||||
|
moveUploadIntoVideoDir,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
sanitizeFolderName,
|
updateVideo,
|
||||||
saveVideoMeta,
|
videoExistsInFolder,
|
||||||
type VideoMeta
|
savePlaylistDraft,
|
||||||
} from '../store.js'
|
getPlaylistDraft,
|
||||||
|
deletePlaylistDraft
|
||||||
|
} from '../storeDb.js'
|
||||||
import { tmpDir } from '../paths.js'
|
import { tmpDir } from '../paths.js'
|
||||||
import {
|
import {
|
||||||
YtDlpUnavailableError,
|
YtDlpUnavailableError,
|
||||||
getJob,
|
getJob,
|
||||||
probeYoutube,
|
probeYoutube,
|
||||||
startYoutubeDownload
|
probeYoutubePlaylist,
|
||||||
|
startYoutubeDownload,
|
||||||
|
retryDownloadJob
|
||||||
} from '../youtube.js'
|
} from '../youtube.js'
|
||||||
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.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()
|
export const opRouter = Router()
|
||||||
|
|
||||||
// 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능.
|
// 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능.
|
||||||
// - 비어있거나 미설정이면 기본 1 GiB
|
|
||||||
// - "0" 또는 "Infinity" 만 명시적 무제한으로 인정
|
|
||||||
// - 잘못된 값 (오타 등) 은 기본 1 GiB 로 fallback (경고 출력)
|
|
||||||
const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024
|
const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024
|
||||||
const uploadMaxBytes = (() => {
|
const uploadMaxBytes = (() => {
|
||||||
const raw = process.env.UPLOAD_MAX_BYTES
|
const raw = process.env.UPLOAD_MAX_BYTES
|
||||||
@@ -43,7 +53,9 @@ const uploadMaxBytes = (() => {
|
|||||||
if (trimmed === '0' || trimmed.toLowerCase() === 'infinity') return Infinity
|
if (trimmed === '0' || trimmed.toLowerCase() === 'infinity') return Infinity
|
||||||
const n = Number(trimmed)
|
const n = Number(trimmed)
|
||||||
if (!Number.isFinite(n) || n <= 0) {
|
if (!Number.isFinite(n) || n <= 0) {
|
||||||
console.warn(`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`)
|
console.warn(
|
||||||
|
`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`
|
||||||
|
)
|
||||||
return DEFAULT_UPLOAD_MAX_BYTES
|
return DEFAULT_UPLOAD_MAX_BYTES
|
||||||
}
|
}
|
||||||
return Math.max(1, Math.floor(n))
|
return Math.max(1, Math.floor(n))
|
||||||
@@ -53,11 +65,33 @@ const upload = multer({
|
|||||||
limits: { fileSize: uploadMaxBytes }
|
limits: { fileSize: uploadMaxBytes }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다.
|
||||||
|
const cookieUpload = multer({
|
||||||
|
dest: tmpDir,
|
||||||
|
limits: { fileSize: 1024 * 1024 }
|
||||||
|
})
|
||||||
|
|
||||||
function pickStr(v: unknown): string {
|
function pickStr(v: unknown): string {
|
||||||
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
||||||
return typeof v === 'string' ? v : ''
|
return typeof v === 'string' ? v : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickIntId(v: unknown): number | null {
|
||||||
|
// JSON body 로 들어오면 number, form 으로 들어오면 string. 둘 다 지원.
|
||||||
|
let n: number
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
n = v
|
||||||
|
} else {
|
||||||
|
const s = pickStr(v).trim()
|
||||||
|
if (!s) return null
|
||||||
|
n = Number(s)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(n) || n < 1 || Math.floor(n) !== n) return null
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 인증 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
opRouter.get('/op', (req, res) => {
|
opRouter.get('/op', (req, res) => {
|
||||||
if (req.session?.userId) {
|
if (req.session?.userId) {
|
||||||
res.redirect('/op/dashboard')
|
res.redirect('/op/dashboard')
|
||||||
@@ -88,119 +122,306 @@ opRouter.post('/op/logout', (req, res) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
|
// ─── 폴더 뷰 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const folders = await listFolders()
|
const folders = listTopFolders()
|
||||||
res.render('op/dashboard', { userId: req.session.userId, folders })
|
res.render('op/dashboard', { userId: req.session.userId, folders })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.post('/op/folders', requireAuth, async (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 {
|
try {
|
||||||
const name = pickStr(req.body.name)
|
const name = req.params.name
|
||||||
const safe = await createFolder(name)
|
let tool
|
||||||
res.json({ ok: true, name: safe })
|
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) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.post('/op/folders/rename', requireAuth, async (req, res) => {
|
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
||||||
try {
|
try {
|
||||||
const oldName = pickStr(req.body.oldName)
|
const segments = collectFolderSegments(req.params)
|
||||||
const newName = pickStr(req.body.newName)
|
const folder = getFolderByPath(segments)
|
||||||
const result = await renameFolder(oldName, newName)
|
if (!folder) {
|
||||||
res.json({ ok: true, name: result })
|
|
||||||
} catch (err) {
|
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
opRouter.post('/op/folders/delete', requireAuth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const name = pickStr(req.body.name)
|
|
||||||
await deleteFolder(name)
|
|
||||||
res.json({ ok: true })
|
|
||||||
} catch (err) {
|
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
opRouter.get('/op/folder/:name', requireAuth, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
|
||||||
if (!safe) {
|
|
||||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
|
||||||
await fs.access(folderPath(safe))
|
const videos = listVideosInFolder(folder.id)
|
||||||
} catch {
|
res.render('op/folder', {
|
||||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const videos = await listVideos(safe)
|
|
||||||
res.render('op/folder', { userId: req.session.userId, folder: safe, videos })
|
|
||||||
} catch (err) {
|
|
||||||
next(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
opRouter.get('/op/folder/:name/video/editor', requireAuth, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
|
||||||
if (!safe) {
|
|
||||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
|
||||||
let video: VideoMeta | null = null
|
|
||||||
if (videoId) {
|
|
||||||
video = await loadVideoMeta(safe, videoId)
|
|
||||||
}
|
|
||||||
res.render('op/editor', {
|
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
folder: safe,
|
folder,
|
||||||
video
|
breadcrumb: folderPathNames(folder.id),
|
||||||
|
subFolders,
|
||||||
|
videos,
|
||||||
|
isSubFolder: folder.parentId !== null
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
opRouter.get('/op/folder/:topName', requireAuth, folderViewHandler)
|
||||||
|
opRouter.get('/op/folder/:topName/:subName', requireAuth, folderViewHandler)
|
||||||
|
|
||||||
|
// ─── 폴더 mutation (id 기반) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
opRouter.post('/op/folders', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const name = pickStr(req.body.name)
|
||||||
|
const parentIdRaw = req.body.parentId
|
||||||
|
const parentId =
|
||||||
|
parentIdRaw === undefined || parentIdRaw === null || parentIdRaw === ''
|
||||||
|
? null
|
||||||
|
: pickIntId(parentIdRaw)
|
||||||
|
if (parentIdRaw !== undefined && parentIdRaw !== null && parentIdRaw !== '' && parentId === null) {
|
||||||
|
throw new Error('상위 폴더 ID 가 올바르지 않습니다.')
|
||||||
|
}
|
||||||
|
const folder = await createFolder({ name, parentId })
|
||||||
|
res.json({ ok: true, folder })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.post('/op/folder/:name/video/rename', requireAuth, async (req, res) => {
|
opRouter.post('/op/folders/:id/rename', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
const id = pickIntId(req.params.id)
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||||
const id = pickStr(req.body.id)
|
const newName = pickStr(req.body.newName)
|
||||||
const title = pickStr(req.body.title).trim()
|
const folder = await renameFolder(id, newName)
|
||||||
if (!title) throw new Error('제목을 입력해 주세요.')
|
res.json({ ok: true, folder })
|
||||||
const meta = await loadVideoMeta(safe, id)
|
} catch (err) {
|
||||||
if (!meta) throw new Error('영상을 찾을 수 없습니다.')
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
meta.title = title
|
}
|
||||||
await saveVideoMeta(safe, meta)
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/folders/:id/delete', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = pickIntId(req.params.id)
|
||||||
|
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||||
|
await deleteFolder(id)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.post('/op/folder/:name/video/delete', requireAuth, async (req, res) => {
|
// ─── 영상 에디터 페이지 ────────────────────────────────────────────────
|
||||||
try {
|
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
opRouter.get(
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
['/op/folder/:topName/video/editor', '/op/folder/:topName/:subName/video/editor'],
|
||||||
const id = pickStr(req.body.id)
|
requireAuth,
|
||||||
await deleteVideo(safe, id)
|
(req, res, next) => {
|
||||||
res.json({ ok: true })
|
try {
|
||||||
} catch (err) {
|
const segments = collectFolderSegments(req.params)
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
const folder = getFolderByPath(segments)
|
||||||
}
|
if (!folder) {
|
||||||
})
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
|
||||||
|
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
||||||
|
const video = videoId ? getVideoInFolder(folder.id, videoId) : null
|
||||||
|
res.render('op/editor', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
folder,
|
||||||
|
breadcrumb: folderPathNames(folder.id),
|
||||||
|
video
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 플레이리스트 import 페이지 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
opRouter.get(
|
||||||
|
'/op/folder/:topName/:subName/playlist/new',
|
||||||
|
requireAuth,
|
||||||
|
(req, res, next) => {
|
||||||
|
try {
|
||||||
|
const segments = collectFolderSegments(req.params)
|
||||||
|
const folder = getFolderByPath(segments)
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (folder.parentId === null) {
|
||||||
|
res.status(400).send('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.render('op/playlist', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
folder,
|
||||||
|
breadcrumb: folderPathNames(folder.id)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 목록 뽑기 (영상 ID + 실제 영상 주소 txt) ──────────────────────────
|
||||||
|
|
||||||
|
/** YouTube URL 의 11자 영상 ID 추출. watch?v= / youtu.be / embed / shorts / live, nocookie 도메인 지원. */
|
||||||
|
function extractYoutubeId(url: string): string | null {
|
||||||
|
const m = url.match(
|
||||||
|
/(?:youtu\.be\/|(?:youtube\.com|youtube-nocookie\.com)\/(?:watch\?(?:[^#]*&)?v=|embed\/|shorts\/|live\/|v\/))([\w-]{11})/
|
||||||
|
)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영상의 "실제 주소". YouTube 는 https://youtu.be/ID 로 간결화, 그 외(네이버 등)는 원본 그대로. */
|
||||||
|
function videoSourceUrl(sourceUrl: string | null): string {
|
||||||
|
if (!sourceUrl) return ''
|
||||||
|
const ytId = extractYoutubeId(sourceUrl)
|
||||||
|
return ytId ? `https://youtu.be/${ytId}` : sourceUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
opRouter.get(
|
||||||
|
'/op/folder/:topName/:subName/list.txt',
|
||||||
|
requireAuth,
|
||||||
|
(req, res, next) => {
|
||||||
|
try {
|
||||||
|
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 실제 외부 주소(유튜브/네이버 등)가 있는 영상만. 업로드 영상처럼 주소가
|
||||||
|
// 없는 항목은 `[ID] [URL]` 두 칸 형식이 깨지므로 목록에서 제외한다.
|
||||||
|
const lines = listVideosInFolder(folder.id)
|
||||||
|
.map((v) => ({ id: v.id, url: videoSourceUrl(v.sourceUrl) }))
|
||||||
|
.filter((r) => r.url !== '')
|
||||||
|
.map((r) => `${r.id} ${r.url}`)
|
||||||
|
const body = lines.join('\n') + (lines.length ? '\n' : '')
|
||||||
|
const baseName = folderPathNames(folder.id).join('_') || 'list'
|
||||||
|
const asciiName = baseName.replace(/[^\x20-\x7e]+/g, '_').replace(/["\\]/g, '_')
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${asciiName}.txt"; filename*=UTF-8''${encodeURIComponent(baseName)}.txt`
|
||||||
|
)
|
||||||
|
res.send(body)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 플레이리스트 임시저장(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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
||||||
|
|
||||||
// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해
|
|
||||||
// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다.
|
|
||||||
function uploadSingle(fieldName: string) {
|
function uploadSingle(fieldName: string) {
|
||||||
const mw = upload.single(fieldName)
|
const mw = upload.single(fieldName)
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -219,80 +440,190 @@ function uploadSingle(fieldName: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업로드: 단일 파일. multipart/form-data, fields: title, file
|
|
||||||
opRouter.post(
|
opRouter.post(
|
||||||
'/op/folder/:name/video/upload',
|
['/op/folder/:topName/video/upload', '/op/folder/:topName/:subName/video/upload'],
|
||||||
requireAuth,
|
requireAuth,
|
||||||
uploadSingle('file'),
|
uploadSingle('file'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
const file = req.file
|
const file = req.file
|
||||||
if (!file) throw new Error('파일이 없습니다.')
|
if (!file) throw new Error('파일이 없습니다.')
|
||||||
const title = pickStr(req.body?.title).trim() || file.originalname
|
const title = pickStr(req.body?.title).trim() || file.originalname
|
||||||
const ext = (path.extname(file.originalname) || '.mp4').toLowerCase()
|
const ext = (path.extname(file.originalname) || '.mp4').toLowerCase()
|
||||||
const videoId = newVideoId()
|
|
||||||
const destName = `original${ext}`
|
const destName = `original${ext}`
|
||||||
await moveUploadIntoVideo(safe, videoId, file.path, destName)
|
const video = await createVideo({
|
||||||
const now = new Date().toISOString()
|
folderId: folder.id,
|
||||||
const meta = {
|
|
||||||
id: videoId,
|
|
||||||
title,
|
title,
|
||||||
originalFile: destName,
|
originalFile: destName,
|
||||||
editedFile: null,
|
sourceType: 'upload'
|
||||||
durationSec: null,
|
})
|
||||||
sourceType: 'upload' as const,
|
await moveUploadIntoVideoDir(folder.id, video.id, file.path, destName)
|
||||||
sourceUrl: null,
|
res.json({ ok: true, videoId: video.id })
|
||||||
trim: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}
|
|
||||||
await saveVideoMeta(safe, meta)
|
|
||||||
res.json({ ok: true, videoId, folder: safe })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 유튜브 프로브: 다운받기 전에 길이/사이즈/예상시간/5분초과경고
|
opRouter.post(
|
||||||
opRouter.post('/op/folder/:name/video/youtube/probe', requireAuth, async (req, res) => {
|
['/op/folder/:topName/video/youtube/probe', '/op/folder/:topName/:subName/video/youtube/probe'],
|
||||||
try {
|
requireAuth,
|
||||||
const url = pickStr(req.body.url).trim()
|
async (req, res) => {
|
||||||
if (!url) throw new Error('URL 을 입력해 주세요.')
|
try {
|
||||||
const probe = await probeYoutube(url)
|
const url = pickStr(req.body.url).trim()
|
||||||
res.json({ ok: true, probe })
|
if (!url) throw new Error('URL 을 입력해 주세요.')
|
||||||
} catch (err) {
|
const probe = await probeYoutube(url)
|
||||||
if (err instanceof YtDlpUnavailableError) {
|
res.json({ ok: true, probe })
|
||||||
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
|
} catch (err) {
|
||||||
return
|
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 })
|
||||||
}
|
}
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, res) => {
|
opRouter.post(
|
||||||
try {
|
['/op/folder/:topName/video/youtube/start', '/op/folder/:topName/:subName/video/youtube/start'],
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
requireAuth,
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
async (req, res) => {
|
||||||
const url = pickStr(req.body.url).trim()
|
try {
|
||||||
const title = pickStr(req.body.title).trim() || undefined
|
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||||
if (!url) throw new Error('URL 을 입력해 주세요.')
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
const job = await startYoutubeDownload({ folder: safe, url, title })
|
const url = pickStr(req.body.url).trim()
|
||||||
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
|
const title = pickStr(req.body.title).trim() || undefined
|
||||||
} catch (err) {
|
if (!url) throw new Error('URL 을 입력해 주세요.')
|
||||||
if (err instanceof YtDlpUnavailableError) {
|
const job = await startYoutubeDownload({ folderId: folder.id, url, title })
|
||||||
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
|
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
|
||||||
return
|
} 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 })
|
||||||
}
|
}
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
|
/** 플레이리스트 URL 을 받아 항목 목록을 돌려준다. (등록은 별도 start 호출) */
|
||||||
|
opRouter.post(
|
||||||
|
['/op/folder/:topName/:subName/playlist/probe'],
|
||||||
|
requireAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
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()
|
||||||
|
if (!url) throw new Error('플레이리스트 URL 을 입력해 주세요.')
|
||||||
|
const result = await probeYoutubePlaylist(url)
|
||||||
|
res.json({ ok: true, entries: result.entries })
|
||||||
|
} 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 가 정리한 항목 목록을 받아 영상 row 를 만들고 다운로드 잡을 큐잉.
|
||||||
|
* body.entries: [{ url, title, videoId }]
|
||||||
|
* - videoId 는 클라이언트가 랜덤/시퀀셜 토글 결과로 이미 확정한 값
|
||||||
|
* - 형식/요청 내 중복/DB 충돌은 서버에서도 다시 검증 (정합성 + 동시성)
|
||||||
|
*/
|
||||||
|
opRouter.post(
|
||||||
|
['/op/folder/:topName/:subName/playlist/start'],
|
||||||
|
requireAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||||
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
|
if (folder.parentId === null) {
|
||||||
|
throw new Error('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
|
||||||
|
}
|
||||||
|
// yt-dlp 가 아예 없으면 항목을 만들지조차 못하므로 사전에 던진다.
|
||||||
|
// (단일 항목 시작 endpoint 와 동일한 정책)
|
||||||
|
// startYoutubeDownload 내부에서도 호출하지만, 일괄 등록 중간 실패를 막기 위해
|
||||||
|
// 먼저 한 번 확인한다.
|
||||||
|
// 결과는 무시 (path 캐싱 효과).
|
||||||
|
// — YtDlpUnavailableError 가 던져지면 아래 catch 에서 503 으로 응답.
|
||||||
|
// ts: getYtDlpPath 는 youtube.ts 에서 내부 함수라 import 하지 않고
|
||||||
|
// startYoutubeDownload 첫 호출 시점에 자연스럽게 노출됨.
|
||||||
|
const rawEntries = Array.isArray(req.body.entries) ? req.body.entries : []
|
||||||
|
if (rawEntries.length === 0) {
|
||||||
|
throw new Error('등록할 항목이 없습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const e = rawEntries[i] as Record<string, unknown>
|
||||||
|
const url = pickStr(e?.url).trim()
|
||||||
|
const title = pickStr(e?.title).trim()
|
||||||
|
const videoId = pickStr(e?.videoId).trim()
|
||||||
|
if (!url) throw new Error(`항목 ${i + 1}: URL 이 비어 있습니다.`)
|
||||||
|
if (!title) throw new Error(`항목 ${i + 1}: 제목이 비어 있습니다.`)
|
||||||
|
if (!videoId) throw new Error(`항목 ${i + 1}: 영상 ID 가 비어 있습니다.`)
|
||||||
|
if (!isSafeVideoId(videoId)) {
|
||||||
|
throw new Error(`항목 ${i + 1}: 영상 ID 형식이 올바르지 않습니다 (영문/숫자/_/- 1~64자).`)
|
||||||
|
}
|
||||||
|
if (seen.has(videoId)) {
|
||||||
|
throw new Error(`항목 ${i + 1}: 영상 ID "${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, startSec, endSec })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망.
|
||||||
|
const jobs: { jobId: string; videoId: string }[] = []
|
||||||
|
for (const entry of cleaned) {
|
||||||
|
const job = await startYoutubeDownload({
|
||||||
|
folderId: folder.id,
|
||||||
|
url: entry.url,
|
||||||
|
title: entry.title,
|
||||||
|
videoId: entry.videoId,
|
||||||
|
startSec: entry.startSec,
|
||||||
|
endSec: entry.endSec
|
||||||
|
})
|
||||||
|
jobs.push({ jobId: job.id, videoId: job.videoId })
|
||||||
|
}
|
||||||
|
res.json({ ok: true, jobs })
|
||||||
|
} 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
||||||
// 폴링 응답이 304 로 캐싱되면 브라우저가 body 없는 응답을 돌려줘서
|
// 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다.
|
||||||
// 클라이언트의 r.json() 이 reject → 폴링이 중단된다. 항상 신선한 응답.
|
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||||
res.set('Pragma', 'no-cache')
|
res.set('Pragma', 'no-cache')
|
||||||
const job = getJob(req.params.id)
|
const job = getJob(req.params.id)
|
||||||
@@ -303,12 +634,98 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
|||||||
res.json({ ok: true, job })
|
res.json({ ok: true, job })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 편집 저장: trim 정보를 받아 ffmpeg 로 edited.<ext> 생성. 원본은 그대로 보존.
|
/** 실패한 다운로드를 같은 ID·URL·자르기 설정으로 재시도 (기존 영상 레코드 재사용). */
|
||||||
opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
|
opRouter.post('/op/job/:id/retry', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
const job = await retryDownloadJob(req.params.id)
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
|
||||||
const id = pickStr(req.body.id)
|
} 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 (!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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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(folder.id, oldId, newId)
|
||||||
|
res.json({ ok: true, video })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 새 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
|
||||||
|
}
|
||||||
|
if (!isSafeVideoId(id)) {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
available: false,
|
||||||
|
reason: '형식: 영문/숫자/_/- (1~64자)'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const folder = folderFromBody(req)
|
||||||
|
const id = req.params.id
|
||||||
|
const video = getVideoInFolder(folder.id, id)
|
||||||
|
if (!video) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
const title = pickStr(req.body.title).trim()
|
const title = pickStr(req.body.title).trim()
|
||||||
const startSec = Number(req.body.startSec ?? 0) || 0
|
const startSec = Number(req.body.startSec ?? 0) || 0
|
||||||
const endRaw = req.body.endSec
|
const endRaw = req.body.endSec
|
||||||
@@ -316,14 +733,14 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
|
|||||||
endRaw === null || endRaw === undefined || endRaw === ''
|
endRaw === null || endRaw === undefined || endRaw === ''
|
||||||
? null
|
? null
|
||||||
: Number(endRaw)
|
: Number(endRaw)
|
||||||
const meta = await loadVideoMeta(safe, id)
|
const trim = {
|
||||||
if (!meta) throw new Error('영상을 찾을 수 없습니다.')
|
startSec,
|
||||||
if (title) meta.title = title
|
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
|
||||||
meta.trim = { startSec, endSec: endSec == null || Number.isNaN(endSec) ? null : endSec }
|
}
|
||||||
await saveVideoMeta(safe, meta)
|
updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
|
||||||
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
||||||
try {
|
try {
|
||||||
const outName = await applyTrimToVideo(safe, id, meta.trim)
|
const outName = await applyTrimToVideo(folder.id, id, trim)
|
||||||
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof FfmpegUnavailableError) {
|
if (err instanceof FfmpegUnavailableError) {
|
||||||
@@ -340,3 +757,19 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
|
|||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */
|
||||||
|
export function adminFolderUrl(folderId: number): string {
|
||||||
|
const names = folderPathNames(folderId)
|
||||||
|
return '/op/folder/' + names.map((s) => encodeURIComponent(s)).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
|
||||||
|
export function videoShareUrl(videoId: string): string | null {
|
||||||
|
const v = getVideoByIdGlobal(videoId)
|
||||||
|
if (!v) return null
|
||||||
|
const names = folderPathNames(v.folderId)
|
||||||
|
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,102 +1,228 @@
|
|||||||
import { Router, type RequestHandler } from 'express'
|
import { Router, type RequestHandler } from 'express'
|
||||||
import path from 'node:path'
|
|
||||||
import { promises as fs } from 'node:fs'
|
|
||||||
import {
|
import {
|
||||||
findVideoAnywhere,
|
getFolderByPath,
|
||||||
folderPath,
|
getVideoInFolder,
|
||||||
listFolders,
|
getVideoByIdGlobal,
|
||||||
listVideos,
|
listChildFolders,
|
||||||
loadVideoMeta,
|
listTopFolders,
|
||||||
sanitizeFolderName,
|
listVideosInFolder,
|
||||||
videoDir,
|
folderPathNames,
|
||||||
videoFileFsPath
|
videoFileFsPath,
|
||||||
} from '../store.js'
|
type Folder,
|
||||||
|
type Video
|
||||||
|
} from '../storeDb.js'
|
||||||
|
import { collectFolderSegments } from './helpers.js'
|
||||||
|
import { ensureThumbnail } from '../editor.js'
|
||||||
|
|
||||||
export const publicRouter = Router()
|
export const publicRouter = Router()
|
||||||
|
|
||||||
publicRouter.get('/', async (_req, res, next) => {
|
publicRouter.get('/', (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const folders = await listFolders()
|
const folders = listTopFolders()
|
||||||
res.render('index', { folders })
|
res.render('index', { folders })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
publicRouter.get('/folder/:name', async (req, res, next) => {
|
/**
|
||||||
|
* 폴더 보기 (공개).
|
||||||
|
* - /folder/:topName — 최상위 폴더 (자식 폴더 + 영상 혼합 가능)
|
||||||
|
* - /folder/:topName/:subName — 서브폴더 (영상만)
|
||||||
|
*/
|
||||||
|
const folderViewHandler: RequestHandler = (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
const segments = collectFolderSegments(req.params)
|
||||||
if (!safe) {
|
const folder = getFolderByPath(segments)
|
||||||
|
if (!folder) {
|
||||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 존재 확인
|
const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
|
||||||
try {
|
const videos = listVideosInFolder(folder.id)
|
||||||
await fs.access(folderPath(safe))
|
res.render('folder', {
|
||||||
} catch {
|
folder,
|
||||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
breadcrumb: folderPathNames(folder.id),
|
||||||
return
|
subFolders,
|
||||||
}
|
videos,
|
||||||
const videos = await listVideos(safe)
|
isAdmin: false,
|
||||||
res.render('folder', { folder: safe, videos, isAdmin: false })
|
isSubFolder: folder.parentId !== null
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
publicRouter.get('/folder/:topName', folderViewHandler)
|
||||||
|
publicRouter.get('/folder/:topName/:subName', folderViewHandler)
|
||||||
|
|
||||||
publicRouter.get('/player/:videoId', async (req, res, next) => {
|
/**
|
||||||
|
* 영상 보기 (공개).
|
||||||
|
* - /video/:topName/:videoId
|
||||||
|
* - /video/:topName/:subName/:videoId
|
||||||
|
*
|
||||||
|
* 경로의 마지막 세그먼트는 영상 ID. 앞쪽은 그 영상이 속한 폴더 경로와 일치해야 한다.
|
||||||
|
* (DB lookup 한 번이므로 path 가 ID 의 정합성 검증 역할도 함)
|
||||||
|
*/
|
||||||
|
const videoViewHandler: RequestHandler = (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const found = await findVideoAnywhere(req.params.videoId)
|
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||||
if (!found) {
|
const folder = getFolderByPath(folderSegments)
|
||||||
|
if (!folder) {
|
||||||
res.status(404).send('영상을 찾을 수 없습니다.')
|
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res.render('player', { folder: found.folder, video: found.meta })
|
const video = getVideoInFolder(folder.id, videoId)
|
||||||
|
if (!video) {
|
||||||
|
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.render('player', { video, breadcrumb: folderPathNames(folder.id) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
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 — 외부 공유 호환용 / 레거시).
|
||||||
* - 짧은 외부 공유용 경로: /file/video/:videoId
|
* - GET /file/video/:videoId
|
||||||
* - 기존 경로: /api/video/:videoId/file (호환용으로 유지)
|
* ID 가 전역에서 정확히 1건일 때만 제공한다. 같은 ID 가 여러 폴더에 있으면
|
||||||
* - ?edited=0 이면 원본을 강제하고, 기본은 편집본 있으면 편집본.
|
* 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 = async (req, res, next) => {
|
const streamVideoHandler: RequestHandler = (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const found = await findVideoAnywhere(req.params.videoId)
|
const video = getVideoByIdGlobal(req.params.videoId)
|
||||||
if (!found) {
|
if (!video) {
|
||||||
res.status(404).end()
|
res.status(404).end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
|
sendVideoFile(video, req, res)
|
||||||
const wantOriginal = editedParam === '0' || editedParam === 'false'
|
|
||||||
const fileName =
|
|
||||||
!wantOriginal && found.meta.editedFile ? found.meta.editedFile : found.meta.originalFile
|
|
||||||
if (!fileName || fileName.includes('%(ext)s')) {
|
|
||||||
res.status(404).end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const fsPath = videoFileFsPath(found.folder, found.meta.id, fileName)
|
|
||||||
res.sendFile(fsPath)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
||||||
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
|
|
||||||
|
|
||||||
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용) */
|
/**
|
||||||
publicRouter.get('/api/video/:videoId', async (req, res, next) => {
|
* 영상 파일 스트리밍 (폴더 경로 + 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 {
|
try {
|
||||||
const found = await findVideoAnywhere(req.params.videoId)
|
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||||
if (!found) {
|
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 = getVideoByIdGlobal(req.params.videoId)
|
||||||
|
if (!video) {
|
||||||
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res.json({ ok: true, folder: found.folder, video: found.meta })
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
video,
|
||||||
|
breadcrumb: folderPathNames(video.folderId)
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function collectVideoSegments(params: Record<string, string | undefined>): {
|
||||||
|
folderSegments: string[]
|
||||||
|
videoId: string
|
||||||
|
} {
|
||||||
|
const folderSegments: string[] = []
|
||||||
|
if (params.topName) folderSegments.push(params.topName)
|
||||||
|
if (params.subName) folderSegments.push(params.subName)
|
||||||
|
return { folderSegments, videoId: params.videoId ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Folder, Video }
|
||||||
|
|||||||
203
src/store.ts
203
src/store.ts
@@ -1,31 +1,12 @@
|
|||||||
import { promises as fs, createReadStream, createWriteStream } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
import { accountJsonPath } from './paths.js'
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import { foldersDir, accountJsonPath } from './paths.js'
|
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: string
|
id: string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoTrim {
|
/** account.json 에서 로그인 계정 목록을 읽는다. (나머지 파일 기반 저장소는 storeDb.ts 로 이전됨.) */
|
||||||
startSec: number
|
|
||||||
endSec: number | null // null = until end
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoMeta {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
originalFile: string // relative to <folder>/<id>/
|
|
||||||
editedFile: string | null
|
|
||||||
durationSec: number | null
|
|
||||||
sourceType: 'upload' | 'youtube'
|
|
||||||
sourceUrl: string | null
|
|
||||||
trim: VideoTrim | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readAccounts(): Promise<Account[]> {
|
export async function readAccounts(): Promise<Account[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(accountJsonPath, 'utf8')
|
const raw = await fs.readFile(accountJsonPath, 'utf8')
|
||||||
@@ -40,181 +21,3 @@ export async function readAccounts(): Promise<Account[]> {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴더/영상 이름에 사용 가능한 문자만 허용. 경로 탈출 방지.
|
|
||||||
const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
|
|
||||||
|
|
||||||
export function sanitizeFolderName(raw: string): string {
|
|
||||||
const trimmed = (raw || '').trim()
|
|
||||||
if (trimmed.length === 0 || trimmed.length > 80) return ''
|
|
||||||
if (!SAFE_NAME.test(trimmed)) return ''
|
|
||||||
if (trimmed === '.' || trimmed === '..') return ''
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSafeVideoId(id: string): boolean {
|
|
||||||
return /^[a-zA-Z0-9_-]{8,64}$/.test(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureFoldersDir(): Promise<void> {
|
|
||||||
await fs.mkdir(foldersDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listFolders(): Promise<string[]> {
|
|
||||||
await ensureFoldersDir()
|
|
||||||
const entries = await fs.readdir(foldersDir, { withFileTypes: true })
|
|
||||||
return entries
|
|
||||||
.filter((e) => e.isDirectory())
|
|
||||||
.map((e) => e.name)
|
|
||||||
.filter((name) => sanitizeFolderName(name).length > 0)
|
|
||||||
.sort((a, b) => a.localeCompare(b, 'ko'))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFolder(name: string): Promise<string> {
|
|
||||||
const safe = sanitizeFolderName(name)
|
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
|
||||||
const target = path.join(foldersDir, safe)
|
|
||||||
await fs.mkdir(target, { recursive: false }).catch((err) => {
|
|
||||||
if (err.code === 'EEXIST') throw new Error('이미 존재하는 폴더입니다.')
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
return safe
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renameFolder(oldName: string, newName: string): Promise<string> {
|
|
||||||
const oldSafe = sanitizeFolderName(oldName)
|
|
||||||
const newSafe = sanitizeFolderName(newName)
|
|
||||||
if (!oldSafe || !newSafe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
|
||||||
if (oldSafe === newSafe) return oldSafe
|
|
||||||
const from = path.join(foldersDir, oldSafe)
|
|
||||||
const to = path.join(foldersDir, newSafe)
|
|
||||||
try {
|
|
||||||
await fs.access(to)
|
|
||||||
throw new Error('이미 존재하는 폴더입니다.')
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
||||||
}
|
|
||||||
await fs.rename(from, to)
|
|
||||||
return newSafe
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteFolder(name: string): Promise<void> {
|
|
||||||
const safe = sanitizeFolderName(name)
|
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
|
||||||
const target = path.join(foldersDir, safe)
|
|
||||||
await fs.rm(target, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function folderPath(name: string): string {
|
|
||||||
const safe = sanitizeFolderName(name)
|
|
||||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
|
||||||
return path.join(foldersDir, safe)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function videoDir(folder: string, videoId: string): string {
|
|
||||||
if (!isSafeVideoId(videoId)) throw new Error('비디오 ID가 올바르지 않습니다.')
|
|
||||||
return path.join(folderPath(folder), videoId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function videoMetaPath(folder: string, videoId: string): string {
|
|
||||||
return path.join(videoDir(folder, videoId), 'meta.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listVideos(folder: string): Promise<VideoMeta[]> {
|
|
||||||
const dir = folderPath(folder)
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
||||||
const metas: VideoMeta[] = []
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) continue
|
|
||||||
if (!isSafeVideoId(entry.name)) continue
|
|
||||||
const meta = await loadVideoMeta(folder, entry.name).catch(() => null)
|
|
||||||
if (meta) metas.push(meta)
|
|
||||||
}
|
|
||||||
metas.sort((a, b) => (b.createdAt < a.createdAt ? -1 : 1))
|
|
||||||
return metas
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadVideoMeta(folder: string, videoId: string): Promise<VideoMeta | null> {
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(videoMetaPath(folder, videoId), 'utf8')
|
|
||||||
return JSON.parse(raw) as VideoMeta
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findVideoAnywhere(
|
|
||||||
videoId: string
|
|
||||||
): Promise<{ folder: string; meta: VideoMeta } | null> {
|
|
||||||
if (!isSafeVideoId(videoId)) return null
|
|
||||||
const folders = await listFolders()
|
|
||||||
for (const folder of folders) {
|
|
||||||
const meta = await loadVideoMeta(folder, videoId).catch(() => null)
|
|
||||||
if (meta) return { folder, meta }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveVideoMeta(folder: string, meta: VideoMeta): Promise<void> {
|
|
||||||
const dir = videoDir(folder, meta.id)
|
|
||||||
await fs.mkdir(dir, { recursive: true })
|
|
||||||
meta.updatedAt = new Date().toISOString()
|
|
||||||
await fs.writeFile(videoMetaPath(folder, meta.id), JSON.stringify(meta, null, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteVideo(folder: string, videoId: string): Promise<void> {
|
|
||||||
const dir = videoDir(folder, videoId)
|
|
||||||
await fs.rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newVideoId(): string {
|
|
||||||
// URL-safe 22-char id (uuid 압축).
|
|
||||||
return randomUUID().replace(/-/g, '').slice(0, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function videoFileFsPath(folder: string, videoId: string, rel: string): string {
|
|
||||||
// rel 은 meta 에 저장된 파일명. 경로 탈출 방지.
|
|
||||||
if (rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
|
|
||||||
throw new Error('잘못된 파일 경로입니다.')
|
|
||||||
}
|
|
||||||
return path.join(videoDir(folder, videoId), rel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 업로드 임시 파일을 비디오 디렉토리로 이동. */
|
|
||||||
export async function moveUploadIntoVideo(
|
|
||||||
folder: string,
|
|
||||||
videoId: string,
|
|
||||||
tmpFile: string,
|
|
||||||
destName: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
|
||||||
throw new Error('잘못된 파일명입니다.')
|
|
||||||
}
|
|
||||||
const dir = videoDir(folder, videoId)
|
|
||||||
await fs.mkdir(dir, { recursive: true })
|
|
||||||
const target = path.join(dir, destName)
|
|
||||||
try {
|
|
||||||
await fs.rename(tmpFile, target)
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
|
||||||
// 다른 디바이스에 있으면 copy + unlink
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const r = createReadStream(tmpFile)
|
|
||||||
const w = createWriteStream(target)
|
|
||||||
r.on('error', reject)
|
|
||||||
w.on('error', reject)
|
|
||||||
w.on('close', () => resolve())
|
|
||||||
r.pipe(w)
|
|
||||||
})
|
|
||||||
await fs.unlink(tmpFile).catch(() => undefined)
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
583
src/storeDb.ts
Normal file
583
src/storeDb.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
/**
|
||||||
|
* DB(SQLite) 기반 폴더/영상 CRUD + 디스크 동기화 레이어.
|
||||||
|
*
|
||||||
|
* 디스크 레이아웃 (변경 없음):
|
||||||
|
* data/folders/{topName}/[{subName}/]{videoId}/...
|
||||||
|
*
|
||||||
|
* DB 가 source of truth. 변경 흐름:
|
||||||
|
* 1) 검증 (이름/ID/깊이/존재여부)
|
||||||
|
* 2) DB UPDATE/INSERT/DELETE
|
||||||
|
* 3) 디스크 동기화 (mkdir / rename / rm)
|
||||||
|
* 4) 실패 시 DB 롤백 (사용 가능한 경우)
|
||||||
|
*
|
||||||
|
* DB → 디스크 순서로 가는 이유: DB 가 UNIQUE 등 제약을 검사해 주므로 충돌을 일찍 잡고
|
||||||
|
* 디스크 작업으로 안 넘어간다. 디스크 작업이 실패하면 DB 변경을 되돌린다.
|
||||||
|
*/
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { getDb } from './db.js'
|
||||||
|
import { foldersDir } from './paths.js'
|
||||||
|
|
||||||
|
// ─── 검증 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
|
||||||
|
const SAFE_VIDEO_ID = /^[a-zA-Z0-9_-]{1,64}$/
|
||||||
|
|
||||||
|
export function sanitizeFolderName(raw: string): string {
|
||||||
|
const trimmed = (raw || '').trim()
|
||||||
|
if (!trimmed || trimmed.length > 80) return ''
|
||||||
|
if (trimmed === '.' || trimmed === '..') return ''
|
||||||
|
if (!SAFE_NAME.test(trimmed)) return ''
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeVideoId(id: string): boolean {
|
||||||
|
return typeof id === 'string' && SAFE_VIDEO_ID.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newRandomVideoId(): string {
|
||||||
|
// URL-safe 24-char id (uuid 압축).
|
||||||
|
return randomUUID().replace(/-/g, '').slice(0, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Folder {
|
||||||
|
id: number
|
||||||
|
parentId: number | null
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoTrim {
|
||||||
|
startSec: number
|
||||||
|
endSec: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Video {
|
||||||
|
id: string
|
||||||
|
folderId: number
|
||||||
|
title: string
|
||||||
|
originalFile: string
|
||||||
|
editedFile: string | null
|
||||||
|
durationSec: number | null
|
||||||
|
sourceType: 'upload' | 'youtube'
|
||||||
|
sourceUrl: string | null
|
||||||
|
trim: VideoTrim | null
|
||||||
|
position: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderRow {
|
||||||
|
id: number
|
||||||
|
parent_id: number | null
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoRow {
|
||||||
|
id: string
|
||||||
|
folder_id: number
|
||||||
|
title: string
|
||||||
|
original_file: string
|
||||||
|
edited_file: string | null
|
||||||
|
duration_sec: number | null
|
||||||
|
source_type: 'upload' | 'youtube'
|
||||||
|
source_url: string | null
|
||||||
|
trim_start_sec: number | null
|
||||||
|
trim_end_sec: number | null
|
||||||
|
position: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToFolder(r: FolderRow): Folder {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
parentId: r.parent_id,
|
||||||
|
name: r.name,
|
||||||
|
position: r.position,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToVideo(r: VideoRow): Video {
|
||||||
|
const hasTrim = r.trim_start_sec !== null || r.trim_end_sec !== null
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
folderId: r.folder_id,
|
||||||
|
title: r.title,
|
||||||
|
originalFile: r.original_file,
|
||||||
|
editedFile: r.edited_file,
|
||||||
|
durationSec: r.duration_sec,
|
||||||
|
sourceType: r.source_type,
|
||||||
|
sourceUrl: r.source_url,
|
||||||
|
trim: hasTrim
|
||||||
|
? { startSec: r.trim_start_sec ?? 0, endSec: r.trim_end_sec }
|
||||||
|
: null,
|
||||||
|
position: r.position,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 폴더 조회 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function listTopFolders(): Folder[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM folders WHERE parent_id IS NULL ORDER BY position, name`
|
||||||
|
)
|
||||||
|
.all() as FolderRow[]
|
||||||
|
return rows.map(rowToFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listChildFolders(parentId: number): Folder[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(`SELECT * FROM folders WHERE parent_id = ? ORDER BY position, name`)
|
||||||
|
.all(parentId) as FolderRow[]
|
||||||
|
return rows.map(rowToFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolder(id: number): Folder | null {
|
||||||
|
const r = getDb()
|
||||||
|
.prepare(`SELECT * FROM folders WHERE id = ?`)
|
||||||
|
.get(id) as FolderRow | undefined
|
||||||
|
return r ? rowToFolder(r) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopFolderByName(name: string): Folder | null {
|
||||||
|
const safe = sanitizeFolderName(name)
|
||||||
|
if (!safe) return null
|
||||||
|
const r = getDb()
|
||||||
|
.prepare(`SELECT * FROM folders WHERE parent_id IS NULL AND name = ?`)
|
||||||
|
.get(safe) as FolderRow | undefined
|
||||||
|
return r ? rowToFolder(r) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChildFolderByName(parentId: number, name: string): Folder | null {
|
||||||
|
const safe = sanitizeFolderName(name)
|
||||||
|
if (!safe) return null
|
||||||
|
const r = getDb()
|
||||||
|
.prepare(`SELECT * FROM folders WHERE parent_id = ? AND name = ?`)
|
||||||
|
.get(parentId, safe) as FolderRow | undefined
|
||||||
|
return r ? rowToFolder(r) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 경로 [topName] 또는 [topName, subName] → 폴더 1건. 못 찾으면 null. */
|
||||||
|
export function getFolderByPath(names: string[]): Folder | null {
|
||||||
|
if (names.length === 0 || names.length > 2) return null
|
||||||
|
const top = getTopFolderByName(names[0])
|
||||||
|
if (!top) return null
|
||||||
|
if (names.length === 1) return top
|
||||||
|
return getChildFolderByName(top.id, names[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폴더 → 디스크 경로 (parent 까지 거슬러 올라가 이름 join). */
|
||||||
|
export function folderDiskPath(folderId: number): string {
|
||||||
|
const names = folderPathNames(folderId)
|
||||||
|
return path.join(foldersDir, ...names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폴더 → URL 경로 세그먼트 배열 (인코딩 전). */
|
||||||
|
export function folderPathNames(folderId: number): string[] {
|
||||||
|
const names: string[] = []
|
||||||
|
let cur: Folder | null = getFolder(folderId)
|
||||||
|
while (cur) {
|
||||||
|
names.unshift(cur.name)
|
||||||
|
cur = cur.parentId !== null ? getFolder(cur.parentId) : null
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 폴더 변경 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createFolder(input: {
|
||||||
|
name: string
|
||||||
|
parentId: number | null
|
||||||
|
}): Promise<Folder> {
|
||||||
|
const safe = sanitizeFolderName(input.name)
|
||||||
|
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||||
|
// 깊이 검사: 부모가 이미 서브폴더면 손자 폴더는 만들 수 없다 (최대 2단계).
|
||||||
|
if (input.parentId !== null) {
|
||||||
|
const parent = getFolder(input.parentId)
|
||||||
|
if (!parent) throw new Error('상위 폴더를 찾을 수 없습니다.')
|
||||||
|
if (parent.parentId !== null) {
|
||||||
|
throw new Error('서브폴더 안에는 폴더를 만들 수 없습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const db = getDb()
|
||||||
|
// position = 현재 형제 개수 (끝에 추가)
|
||||||
|
const siblings = (db
|
||||||
|
.prepare(
|
||||||
|
input.parentId === null
|
||||||
|
? `SELECT COUNT(*) as c FROM folders WHERE parent_id IS NULL`
|
||||||
|
: `SELECT COUNT(*) as c FROM folders WHERE parent_id = ?`
|
||||||
|
)
|
||||||
|
.get(...(input.parentId === null ? [] : [input.parentId])) as { c: number }).c
|
||||||
|
|
||||||
|
let info
|
||||||
|
try {
|
||||||
|
info = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO folders (parent_id, name, position, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(input.parentId, safe, siblings, now, now)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).message.includes('UNIQUE')) {
|
||||||
|
throw new Error('이미 존재하는 폴더입니다.')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const folderId = Number(info.lastInsertRowid)
|
||||||
|
// 디스크 디렉토리 생성 (이미 있을 수 있음 — 마이그레이션 케이스).
|
||||||
|
const diskPath = folderDiskPath(folderId)
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(diskPath, { recursive: true })
|
||||||
|
} catch (err) {
|
||||||
|
// 디스크 실패 시 DB 롤백.
|
||||||
|
db.prepare(`DELETE FROM folders WHERE id = ?`).run(folderId)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return getFolder(folderId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(folderId: number, newName: string): Promise<Folder> {
|
||||||
|
const folder = getFolder(folderId)
|
||||||
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
|
const safe = sanitizeFolderName(newName)
|
||||||
|
if (!safe) throw new Error('새 폴더 이름이 올바르지 않습니다.')
|
||||||
|
if (safe === folder.name) return folder
|
||||||
|
|
||||||
|
const oldDisk = folderDiskPath(folderId)
|
||||||
|
const db = getDb()
|
||||||
|
try {
|
||||||
|
db.prepare(`UPDATE folders SET name = ?, updated_at = ? WHERE id = ?`).run(
|
||||||
|
safe,
|
||||||
|
new Date().toISOString(),
|
||||||
|
folderId
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).message.includes('UNIQUE')) {
|
||||||
|
throw new Error('이미 존재하는 폴더 이름입니다.')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const newDisk = folderDiskPath(folderId)
|
||||||
|
try {
|
||||||
|
await fsp.rename(oldDisk, newDisk)
|
||||||
|
} catch (err) {
|
||||||
|
// ENOENT (디스크 디렉토리가 없는 경우) 는 무시하고 비어있는 새 디렉토리 생성.
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
await fsp.mkdir(newDisk, { recursive: true })
|
||||||
|
} else {
|
||||||
|
// 디스크 rename 실패 → DB 롤백
|
||||||
|
db.prepare(`UPDATE folders SET name = ? WHERE id = ?`).run(folder.name, folderId)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getFolder(folderId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolder(folderId: number): Promise<void> {
|
||||||
|
const folder = getFolder(folderId)
|
||||||
|
if (!folder) return // idempotent
|
||||||
|
const diskPath = folderDiskPath(folderId)
|
||||||
|
// DB CASCADE 가 자식 폴더/영상까지 모두 삭제.
|
||||||
|
getDb().prepare(`DELETE FROM folders WHERE id = ?`).run(folderId)
|
||||||
|
await fsp.rm(diskPath, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 영상 조회 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 폴더 안에서 영상 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 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(
|
||||||
|
`SELECT * FROM videos WHERE folder_id = ? ORDER BY position, created_at DESC`
|
||||||
|
)
|
||||||
|
.all(folderId) as VideoRow[]
|
||||||
|
return rows.map(rowToVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoExistsInFolder(folderId: number, id: string): boolean {
|
||||||
|
if (!isSafeVideoId(id)) return false
|
||||||
|
const r = getDb()
|
||||||
|
.prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
|
||||||
|
.get(folderId, id)
|
||||||
|
return !!r
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
|
||||||
|
export function videoDiskDir(folderId: number, id: string): string {
|
||||||
|
return path.join(folderDiskPath(folderId), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
|
||||||
|
export function videoPathNames(folderId: number, id: string): string[] {
|
||||||
|
return [...folderPathNames(folderId), id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
|
||||||
|
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(folderId, id), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 영상 변경 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateVideoInput {
|
||||||
|
id?: string // 미지정 시 랜덤
|
||||||
|
folderId: number
|
||||||
|
title: string
|
||||||
|
originalFile: string // 디렉토리 안 상대 경로 (또는 'original.%(ext)s' 같은 yt-dlp 임시 패턴)
|
||||||
|
sourceType: 'upload' | 'youtube'
|
||||||
|
sourceUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
||||||
|
const folder = getFolder(input.folderId)
|
||||||
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
|
let id = input.id ?? newRandomVideoId()
|
||||||
|
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
|
||||||
|
if (videoExistsInFolder(input.folderId, id)) {
|
||||||
|
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const db = getDb()
|
||||||
|
// position = 현재 폴더 안 영상 개수 (끝에 추가)
|
||||||
|
const count = (db
|
||||||
|
.prepare(`SELECT COUNT(*) as c FROM videos WHERE folder_id = ?`)
|
||||||
|
.get(input.folderId) as { c: number }).c
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO videos
|
||||||
|
(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)
|
||||||
|
VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
input.folderId,
|
||||||
|
input.title,
|
||||||
|
input.originalFile,
|
||||||
|
input.sourceType,
|
||||||
|
input.sourceUrl ?? null,
|
||||||
|
count,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
// 디스크 디렉토리 생성
|
||||||
|
const diskDir = path.join(folderDiskPath(input.folderId), id)
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(diskDir, { recursive: true })
|
||||||
|
} catch (err) {
|
||||||
|
db.prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(input.folderId, id)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return getVideoInFolder(input.folderId, id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeVideoId(
|
||||||
|
folderId: number,
|
||||||
|
oldId: string,
|
||||||
|
newId: string
|
||||||
|
): Promise<Video> {
|
||||||
|
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
|
||||||
|
if (oldId === newId) {
|
||||||
|
const v = getVideoInFolder(folderId, oldId)
|
||||||
|
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
const current = getVideoInFolder(folderId, oldId)
|
||||||
|
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
|
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 folder_id = ? AND id = ?`).run(
|
||||||
|
newId,
|
||||||
|
new Date().toISOString(),
|
||||||
|
folderId,
|
||||||
|
oldId
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).message.includes('UNIQUE')) {
|
||||||
|
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fsp.rename(oldDir, newDir)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
||||||
|
await fsp.mkdir(newDir, { recursive: true })
|
||||||
|
} else {
|
||||||
|
db.prepare(`UPDATE videos SET id = ? WHERE folder_id = ? AND id = ?`).run(
|
||||||
|
oldId,
|
||||||
|
folderId,
|
||||||
|
newId
|
||||||
|
)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getVideoInFolder(folderId, newId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateVideoPatch {
|
||||||
|
title?: string
|
||||||
|
originalFile?: string
|
||||||
|
editedFile?: string | null
|
||||||
|
durationSec?: number | null
|
||||||
|
sourceUrl?: string | null
|
||||||
|
trim?: VideoTrim | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
|
||||||
|
const current = getVideoInFolder(folderId, id)
|
||||||
|
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
|
|
||||||
|
const fields: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (patch.title !== undefined) {
|
||||||
|
fields.push('title = ?')
|
||||||
|
params.push(patch.title)
|
||||||
|
}
|
||||||
|
if (patch.originalFile !== undefined) {
|
||||||
|
fields.push('original_file = ?')
|
||||||
|
params.push(patch.originalFile)
|
||||||
|
}
|
||||||
|
if (patch.editedFile !== undefined) {
|
||||||
|
fields.push('edited_file = ?')
|
||||||
|
params.push(patch.editedFile)
|
||||||
|
}
|
||||||
|
if (patch.durationSec !== undefined) {
|
||||||
|
fields.push('duration_sec = ?')
|
||||||
|
params.push(patch.durationSec)
|
||||||
|
}
|
||||||
|
if (patch.sourceUrl !== undefined) {
|
||||||
|
fields.push('source_url = ?')
|
||||||
|
params.push(patch.sourceUrl)
|
||||||
|
}
|
||||||
|
if (patch.trim !== undefined) {
|
||||||
|
if (patch.trim === null) {
|
||||||
|
fields.push('trim_start_sec = NULL', 'trim_end_sec = NULL')
|
||||||
|
} else {
|
||||||
|
fields.push('trim_start_sec = ?', 'trim_end_sec = ?')
|
||||||
|
params.push(patch.trim.startSec, patch.trim.endSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return current
|
||||||
|
fields.push('updated_at = ?')
|
||||||
|
params.push(new Date().toISOString())
|
||||||
|
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(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 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
|
||||||
|
): Promise<void> {
|
||||||
|
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
||||||
|
throw new Error('잘못된 파일명입니다.')
|
||||||
|
}
|
||||||
|
const dir = videoDiskDir(folderId, videoId)
|
||||||
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
|
const target = path.join(dir, destName)
|
||||||
|
try {
|
||||||
|
await fsp.rename(tmpFile, target)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||||
|
// 다른 디바이스면 copyFile + unlink
|
||||||
|
await fsp.copyFile(tmpFile, target)
|
||||||
|
await fsp.unlink(tmpFile).catch(() => undefined)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 플레이리스트 임시저장(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
207
src/tools.ts
Normal 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()
|
||||||
|
}
|
||||||
474
src/youtube.ts
474
src/youtube.ts
@@ -1,16 +1,17 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process'
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { jobsDir, projectRoot } from './paths.js'
|
import { binDir, jobsDir, projectRoot } from './paths.js'
|
||||||
import { upscaleOriginalTo60Fps } from './editor.js'
|
import { getCookieArgs, hasCookies } from './cookies.js'
|
||||||
|
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||||
import {
|
import {
|
||||||
loadVideoMeta,
|
createVideo,
|
||||||
newVideoId,
|
getFolder,
|
||||||
saveVideoMeta,
|
getVideoInFolder,
|
||||||
sanitizeFolderName,
|
updateVideo,
|
||||||
videoDir,
|
videoDiskDir,
|
||||||
type VideoMeta
|
type VideoTrim
|
||||||
} from './store.js'
|
} from './storeDb.js'
|
||||||
|
|
||||||
export class YtDlpUnavailableError extends Error {
|
export class YtDlpUnavailableError extends Error {
|
||||||
constructor(message?: string) {
|
constructor(message?: string) {
|
||||||
@@ -22,13 +23,18 @@ let resolvedYtDlpPath: string | null = null
|
|||||||
|
|
||||||
export function getYtDlpPath(): string {
|
export function getYtDlpPath(): string {
|
||||||
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||||
// 1) 프로젝트 bin/yt-dlp(.exe)
|
// 0) data/bin/yt-dlp — 런타임에 최신으로 재설치한 바이너리 (가장 우선, 볼륨 영속)
|
||||||
const localCandidates =
|
// 1) 프로젝트 bin/yt-dlp(.exe) — 이미지에 포함된 기본 바이너리
|
||||||
|
const candidates =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
? [
|
||||||
: ['bin/yt-dlp']
|
path.join(binDir, 'yt-dlp.exe'),
|
||||||
for (const rel of localCandidates) {
|
path.join(binDir, 'yt-dlp'),
|
||||||
const abs = path.join(projectRoot, rel)
|
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'])
|
const r = spawnSync(abs, ['--version'])
|
||||||
if (r.status === 0) {
|
if (r.status === 0) {
|
||||||
resolvedYtDlpPath = abs
|
resolvedYtDlpPath = abs
|
||||||
@@ -44,6 +50,27 @@ export function getYtDlpPath(): string {
|
|||||||
throw new YtDlpUnavailableError()
|
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 {
|
export interface ProbeResult {
|
||||||
title: string
|
title: string
|
||||||
durationSec: number
|
durationSec: number
|
||||||
@@ -60,6 +87,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
|||||||
return new Promise<ProbeResult>((resolve, reject) => {
|
return new Promise<ProbeResult>((resolve, reject) => {
|
||||||
const child = spawn(bin, [
|
const child = spawn(bin, [
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
|
...getCookieArgs(),
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
'--print',
|
'--print',
|
||||||
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
||||||
@@ -67,11 +95,12 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
|||||||
let stdout = ''
|
let stdout = ''
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
|
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('error', (err) => reject(err))
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
|
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp probe 실패 (code=${code})`)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lines = stdout.trim().split('\n')
|
const lines = stdout.trim().split('\n')
|
||||||
@@ -89,14 +118,105 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 플레이리스트 probe ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PlaylistEntry {
|
||||||
|
/** 단일 영상 시작 endpoint 에 그대로 넘기는 watch URL. */
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
/** 메타에 없으면 null. UI 표시용. */
|
||||||
|
durationSec: number | null
|
||||||
|
/** 미리보기용 썸네일 URL. 못 구하면 null. */
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
/** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */
|
||||||
|
originalIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistProbeResult {
|
||||||
|
entries: PlaylistEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** yt-dlp 로 플레이리스트의 각 항목 메타만 빠르게 긁어온다 (실제 다운로드 X). */
|
||||||
|
export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeResult> {
|
||||||
|
const bin = getYtDlpPath()
|
||||||
|
return new Promise<PlaylistProbeResult>((resolve, reject) => {
|
||||||
|
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
|
||||||
|
const child = spawn(bin, [
|
||||||
|
'--no-warnings',
|
||||||
|
...getCookieArgs(),
|
||||||
|
'--flat-playlist',
|
||||||
|
'--dump-json',
|
||||||
|
url
|
||||||
|
])
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.on('data', (chunk) => (stdout += 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(friendlyYtDlpError(stderr, `yt-dlp playlist probe 실패 (code=${code})`)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const entries: PlaylistEntry[] = []
|
||||||
|
let idx = 0
|
||||||
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
let obj: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(trimmed) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 상위 _type==='playlist' 같은 컨테이너 라인이 섞이면 스킵.
|
||||||
|
if (obj._type === 'playlist') continue
|
||||||
|
idx += 1
|
||||||
|
const id = typeof obj.id === 'string' ? obj.id : null
|
||||||
|
const webpageUrl =
|
||||||
|
typeof obj.webpage_url === 'string' && obj.webpage_url ? obj.webpage_url : null
|
||||||
|
const rawUrl = typeof obj.url === 'string' && obj.url ? obj.url : null
|
||||||
|
const entryUrl =
|
||||||
|
webpageUrl ||
|
||||||
|
rawUrl ||
|
||||||
|
(id ? `https://www.youtube.com/watch?v=${id}` : null)
|
||||||
|
if (!entryUrl) continue
|
||||||
|
const title =
|
||||||
|
typeof obj.title === 'string' && obj.title.trim()
|
||||||
|
? obj.title.trim()
|
||||||
|
: `항목 ${idx}`
|
||||||
|
const dur = obj.duration
|
||||||
|
const durationSec =
|
||||||
|
typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : null
|
||||||
|
// 썸네일: 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('플레이리스트에서 항목을 찾지 못했습니다.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({ entries })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 백그라운드 다운로드 잡 ────────────────────────────────────────────────
|
// ─── 백그라운드 다운로드 잡 ────────────────────────────────────────────────
|
||||||
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
||||||
|
|
||||||
export interface DownloadJob {
|
export interface DownloadJob {
|
||||||
id: string
|
id: string
|
||||||
folder: string
|
folderId: number
|
||||||
videoId: string
|
videoId: string
|
||||||
url: string
|
url: string
|
||||||
|
/** 영상 제목. 재시도 시 레코드가 지워졌을 때 복원용으로도 쓴다. */
|
||||||
|
title: string
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
progress: number // 0..100
|
progress: number // 0..100
|
||||||
message: string
|
message: string
|
||||||
@@ -104,15 +224,70 @@ export interface DownloadJob {
|
|||||||
finishedAt: string | null
|
finishedAt: string | null
|
||||||
outputFile: string | null
|
outputFile: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
|
||||||
|
trim: VideoTrim | null
|
||||||
|
/** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */
|
||||||
|
retriedBy: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobs = new Map<string, DownloadJob>()
|
const jobs = new Map<string, DownloadJob>()
|
||||||
|
|
||||||
|
// 동시 실행 상한. 플레이리스트 일괄 등록 시 yt-dlp+ffmpeg 가 N개 동시에 떠
|
||||||
|
// 서버가 터지는 것을 막는다. 기본 2 — 네트워크-바운드 다운로드와 CPU-바운드
|
||||||
|
// 60fps 후처리가 한 번에 한쌍 정도는 같이 진행될 수 있게.
|
||||||
|
const MAX_CONCURRENCY = (() => {
|
||||||
|
const raw = Number(process.env.MAX_DOWNLOAD_CONCURRENCY)
|
||||||
|
if (Number.isFinite(raw) && raw >= 1) return Math.floor(raw)
|
||||||
|
return 2
|
||||||
|
})()
|
||||||
|
let activeJobCount = 0
|
||||||
|
|
||||||
async function persistJob(job: DownloadJob): Promise<void> {
|
async function persistJob(job: DownloadJob): Promise<void> {
|
||||||
await fs.mkdir(jobsDir, { recursive: true })
|
await fs.mkdir(jobsDir, { recursive: true })
|
||||||
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대기 큐를 끌어올린다.
|
||||||
|
* - 동시 실행 슬롯이 남는 동안, 가장 먼저 등록된 `queued` 잡부터 launchJob 호출.
|
||||||
|
* - launchJob 의 finally 에서 다시 호출되어 새 슬롯을 채운다.
|
||||||
|
*/
|
||||||
|
function pump(): void {
|
||||||
|
if (activeJobCount >= MAX_CONCURRENCY) return
|
||||||
|
for (const job of jobs.values()) {
|
||||||
|
if (activeJobCount >= MAX_CONCURRENCY) return
|
||||||
|
if (job.status !== 'queued') continue
|
||||||
|
let bin: string
|
||||||
|
try {
|
||||||
|
bin = getYtDlpPath()
|
||||||
|
} catch (err) {
|
||||||
|
// yt-dlp 가 중간에 사라진 경우. 이 잡만 실패시키고 다음 잡으로 진행 (다음도 같은 사유로 실패하겠지만).
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = err instanceof Error ? err.message : String(err)
|
||||||
|
job.finishedAt = new Date().toISOString()
|
||||||
|
void persistJob(job)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launchJob(job, bin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchJob(job: DownloadJob, bin: string): void {
|
||||||
|
activeJobCount += 1
|
||||||
|
runJob(job, bin)
|
||||||
|
.catch((err) => {
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = err instanceof Error ? err.message : String(err)
|
||||||
|
job.finishedAt = new Date().toISOString()
|
||||||
|
void persistJob(job)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
activeJobCount -= 1
|
||||||
|
// 비동기 완료 시점에 다음 큐 슬롯을 즉시 채운다.
|
||||||
|
pump()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getJob(id: string): DownloadJob | null {
|
export function getJob(id: string): DownloadJob | null {
|
||||||
return jobs.get(id) ?? null
|
return jobs.get(id) ?? null
|
||||||
}
|
}
|
||||||
@@ -122,56 +297,139 @@ export function listActiveJobs(): DownloadJob[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StartDownloadOpts {
|
export interface StartDownloadOpts {
|
||||||
folder: string
|
folderId: number
|
||||||
url: string
|
url: string
|
||||||
title?: string
|
title?: string
|
||||||
|
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
|
||||||
|
videoId?: string
|
||||||
|
/** 다운로드 후 적용할 자르기 시작 초 (기본 0). */
|
||||||
|
startSec?: number
|
||||||
|
/** 다운로드 후 적용할 자르기 종료 초. null/미지정 이면 끝까지. */
|
||||||
|
endSec?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
|
/**
|
||||||
|
* 백그라운드 yt-dlp 다운로드를 큐잉. videoId 도 함께 생성/저장한다.
|
||||||
|
* 실제 실행은 동시성 한도(MAX_CONCURRENCY) 안에서 pump() 가 결정한다.
|
||||||
|
*/
|
||||||
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
||||||
const safeFolder = sanitizeFolderName(opts.folder)
|
const folder = getFolder(opts.folderId)
|
||||||
if (!safeFolder) throw new Error('폴더 이름이 올바르지 않습니다.')
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
const bin = getYtDlpPath() // 없으면 즉시 던짐
|
// yt-dlp 없으면 큐잉도 안 한다. 100건 등록 후 전부 error 로 깨지는 것보다
|
||||||
const videoId = newVideoId()
|
// 즉시 endpoint 가 503 으로 거절하는 편이 낫다.
|
||||||
const dir = videoDir(safeFolder, videoId)
|
getYtDlpPath()
|
||||||
await fs.mkdir(dir, { recursive: true })
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const video = await createVideo({
|
||||||
const meta: VideoMeta = {
|
id: opts.videoId,
|
||||||
id: videoId,
|
folderId: opts.folderId,
|
||||||
title: opts.title?.trim() || '제목 없음',
|
title: opts.title?.trim() || '제목 없음',
|
||||||
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
|
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
|
||||||
editedFile: null,
|
|
||||||
durationSec: null,
|
|
||||||
sourceType: 'youtube',
|
sourceType: 'youtube',
|
||||||
sourceUrl: opts.url,
|
sourceUrl: opts.url
|
||||||
trim: null,
|
})
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}
|
|
||||||
await saveVideoMeta(safeFolder, meta)
|
|
||||||
|
|
||||||
const job: DownloadJob = {
|
// 자르기 구간 정규화. start>0 이거나 end 가 지정된 경우에만 의미가 있다.
|
||||||
id: newVideoId(),
|
const startSec = Math.max(0, Number(opts.startSec) || 0)
|
||||||
folder: safeFolder,
|
const endSecRaw = opts.endSec == null ? null : Number(opts.endSec)
|
||||||
videoId,
|
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,
|
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',
|
status: 'queued',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: '대기 중',
|
message: '대기 중',
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
finishedAt: null,
|
finishedAt: null,
|
||||||
outputFile: null,
|
outputFile: null,
|
||||||
error: null
|
error: null,
|
||||||
|
trim: p.trim,
|
||||||
|
retriedBy: null
|
||||||
}
|
}
|
||||||
jobs.set(job.id, job)
|
jobs.set(job.id, job)
|
||||||
void persistJob(job)
|
void persistJob(job)
|
||||||
setImmediate(() => runJob(job, bin).catch((err) => {
|
return job
|
||||||
job.status = 'error'
|
}
|
||||||
job.error = err instanceof Error ? err.message : String(err)
|
|
||||||
job.finishedAt = new Date().toISOString()
|
/**
|
||||||
void persistJob(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
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +438,12 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
job.message = '다운로드 시작'
|
job.message = '다운로드 시작'
|
||||||
await persistJob(job)
|
await persistJob(job)
|
||||||
|
|
||||||
const dir = videoDir(job.folder, job.videoId)
|
const dir = videoDiskDir(job.folderId, job.videoId)
|
||||||
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||||
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||||
const args = [
|
const args = [
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
|
...getCookieArgs(),
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--newline',
|
'--newline',
|
||||||
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
|
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
|
||||||
@@ -201,9 +460,22 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
job.url
|
job.url
|
||||||
]
|
]
|
||||||
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
|
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
|
||||||
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
|
//
|
||||||
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
|
// yt-dlp 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받으면 각 스트림이
|
||||||
let downloadPctRaw = 0
|
// 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) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
|
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
|
||||||
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
|
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
|
||||||
@@ -220,10 +492,24 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
if (m) {
|
if (m) {
|
||||||
const pct = Number(m[1])
|
const pct = Number(m[1])
|
||||||
if (Number.isFinite(pct)) {
|
if (Number.isFinite(pct)) {
|
||||||
downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct))
|
const clamped = Math.max(0, Math.min(100, pct))
|
||||||
const mapped = Math.round(downloadPctRaw * 0.5) // 0..50
|
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
|
if (mapped > job.progress) job.progress = mapped
|
||||||
job.message = `다운로드 ${Math.round(downloadPctRaw)}%`
|
job.message = `다운로드 ${Math.round(phasePct)}%`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
||||||
@@ -236,7 +522,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
child.on('error', (err) => reject(err))
|
child.on('error', (err) => reject(err))
|
||||||
child.on('close', async (code) => {
|
child.on('close', async (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
|
reject(new Error(friendlyYtDlpError(stderrTail, `yt-dlp 실패 (code=${code})`)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 실제 파일명 결정
|
// 실제 파일명 결정
|
||||||
@@ -252,34 +538,54 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
|
|
||||||
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
|
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
|
||||||
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
|
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
|
||||||
// minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
|
// 프레임 복제로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
|
||||||
|
//
|
||||||
|
// 5/16 버그 ("60fps 변환 확인 중에서 하루종일 멈춰있어") 대응:
|
||||||
|
// - ffmpeg `-progress pipe:1` 이 영상 길이를 모르거나 짧은 영상이 1초 안에
|
||||||
|
// 끝나면 onProgress 가 한 번도 안 불려 progress 가 50% 에 박혀 있었다.
|
||||||
|
// - 사용자 입장에선 "멈춤" 으로 보이므로, ffmpeg 진행률과 무관하게
|
||||||
|
// 매 2초마다 message 에 경과시간을 박아 화면이 살아있다는 신호를 준다.
|
||||||
|
// (progress 막대 자체는 실제 데이터가 들어올 때만 움직임 — 거짓 증가 X.)
|
||||||
try {
|
try {
|
||||||
// 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다.
|
const phaseStartedAt = Date.now()
|
||||||
job.progress = 50
|
job.progress = 50
|
||||||
job.message = '60fps 변환 준비 중'
|
job.message = '60fps/FHD 변환 시작'
|
||||||
await persistJob(job)
|
await persistJob(job)
|
||||||
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
|
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
|
||||||
let lastPersistAt = 0
|
let lastPersistAt = 0
|
||||||
let lastBumpPct = 0
|
let lastBumpPct = 0
|
||||||
const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
|
let realProgressSeen = false
|
||||||
if (pct <= lastBumpPct) return
|
const heartbeat = setInterval(() => {
|
||||||
lastBumpPct = pct
|
if (realProgressSeen) return
|
||||||
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
|
const elapsed = Math.floor((Date.now() - phaseStartedAt) / 1000)
|
||||||
if (mapped > job.progress) job.progress = mapped
|
job.message = `60fps/FHD 변환 중 (${elapsed}s 경과)`
|
||||||
job.message = `60fps 변환 ${Math.round(pct)}%`
|
}, 2000)
|
||||||
const now = Date.now()
|
let bumped: string
|
||||||
if (now - lastPersistAt > 2000) {
|
try {
|
||||||
lastPersistAt = now
|
bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
|
||||||
void persistJob(job)
|
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) {
|
if (bumped !== finalName) {
|
||||||
job.message = '60fps 변환 완료'
|
job.message = '60fps/FHD 변환 완료'
|
||||||
finalName = bumped
|
finalName = bumped
|
||||||
} else {
|
} else {
|
||||||
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
|
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
|
||||||
// (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패.
|
// (a) 이미 60fps + FHD 이하라 변환 자체가 필요 없거나
|
||||||
// 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다.
|
// (b) ffmpeg 없거나 (c) 인코딩 실패. 어느 경우든 후처리는 끝났다고 보고
|
||||||
|
// 진행률만 99 까지 채운다.
|
||||||
job.progress = 99
|
job.progress = 99
|
||||||
}
|
}
|
||||||
await persistJob(job)
|
await persistJob(job)
|
||||||
@@ -288,11 +594,25 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
console.error('[youtube] 60fps 후처리 실패:', err)
|
console.error('[youtube] 60fps 후처리 실패:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await loadVideoMeta(job.folder, job.videoId)
|
const meta = getVideoInFolder(job.folderId, job.videoId)
|
||||||
if (meta) {
|
if (meta) {
|
||||||
meta.originalFile = finalName
|
updateVideo(job.folderId, job.videoId, { originalFile: finalName })
|
||||||
await saveVideoMeta(job.folder, meta)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자가 지정한 자르기 구간이 있으면 다운로드/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.progress = 100
|
||||||
job.status = 'done'
|
job.status = 'done'
|
||||||
job.message = '완료'
|
job.message = '완료'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= folder %> · 비디오 사이트</title>
|
<title><%= folder.name %> · 비디오 사이트</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
@@ -15,41 +15,56 @@
|
|||||||
<a class="secondaryButton" href="/op">관리자</a>
|
<a class="secondaryButton" href="/op">관리자</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<%
|
||||||
|
// 현재 폴더까지의 경로 prefix (영상/하위폴더 URL 조립용)
|
||||||
|
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||||
|
var parentHref = isSubFolder ? '/folder/' + encodeURIComponent(breadcrumb[0]) : '/'
|
||||||
|
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
|
||||||
|
%>
|
||||||
|
|
||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<a class="muted" href="/">← 폴더 목록</a>
|
<a class="muted" href="<%= parentHref %>"><%= parentLabel %></a>
|
||||||
<h1>📁 <%= folder %></h1>
|
<h1>📁 <%= breadcrumb.join(' / ') %></h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<% if (!isSubFolder) { %>
|
||||||
|
<section class="folderGrid">
|
||||||
|
<% if ((!subFolders || subFolders.length === 0) && videos.length === 0) { %>
|
||||||
|
<p class="muted">아직 하위 폴더가 없습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<% (subFolders || []).forEach(function (sf) { %>
|
||||||
|
<a class="folderCard" href="/folder/<%= folderPathEnc %>/<%= encodeURIComponent(sf.name) %>">
|
||||||
|
<span class="folderIcon">📁</span>
|
||||||
|
<span class="folderName"><%= sf.name %></span>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<section class="videoGrid">
|
<section class="videoGrid">
|
||||||
<% if (videos.length === 0) { %>
|
<% if (videos.length === 0 && isSubFolder) { %>
|
||||||
<p class="muted">이 폴더에 영상이 없습니다.</p>
|
<p class="muted">이 폴더에 영상이 없습니다.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% videos.forEach(function (v) { %>
|
<% videos.forEach(function (v) {
|
||||||
<button type="button" class="videoCard" data-video-id="<%= v.id %>">
|
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
|
||||||
<div class="videoThumb">▶</div>
|
%>
|
||||||
|
<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>
|
<div class="videoTitle"><%= v.title %></div>
|
||||||
</button>
|
</a>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="playerOverlay" id="playerOverlay" hidden>
|
|
||||||
<div class="playerModal" role="dialog" aria-modal="true">
|
|
||||||
<button type="button" class="playerClose" id="playerClose" aria-label="닫기">✕</button>
|
|
||||||
<div class="playerTitle" id="playerTitle"></div>
|
|
||||||
<video id="playerVideo" controls preload="metadata"></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
<button type="button" data-action="copyUrl">영상 주소 복사</button>
|
<button type="button" data-action="copyUrl">영상 주소 복사</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
window.__SITE__ = { folder: <%- jsonForScript(folder) %> }
|
|
||||||
</script>
|
|
||||||
<script src="/static/player.js"></script>
|
|
||||||
<script src="/static/public-folder.js"></script>
|
<script src="/static/public-folder.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
<% if (folders.length === 0) { %>
|
<% if (folders.length === 0) { %>
|
||||||
<p class="muted">아직 폴더가 없습니다. 관리자 페이지에서 폴더를 추가해 주세요.</p>
|
<p class="muted">아직 폴더가 없습니다. 관리자 페이지에서 폴더를 추가해 주세요.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% folders.forEach(function (name) { %>
|
<% folders.forEach(function (f) { %>
|
||||||
<a class="folderCard" href="/folder/<%= encodeURIComponent(name) %>">
|
<a class="folderCard" href="/folder/<%= encodeURIComponent(f.name) %>">
|
||||||
<span class="folderIcon">📁</span>
|
<span class="folderIcon">📁</span>
|
||||||
<span class="folderName"><%= name %></span>
|
<span class="folderName"><%= f.name %></span>
|
||||||
</a>
|
</a>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -21,15 +21,61 @@
|
|||||||
<% if (folders.length === 0) { %>
|
<% if (folders.length === 0) { %>
|
||||||
<p class="muted">아직 폴더가 없습니다. 우측 상단에서 폴더를 추가해 주세요.</p>
|
<p class="muted">아직 폴더가 없습니다. 우측 상단에서 폴더를 추가해 주세요.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% folders.forEach(function (name) { %>
|
<% folders.forEach(function (f) { %>
|
||||||
<div class="folderCard adminFolder" data-name="<%= name %>">
|
<div class="folderCard adminFolder" data-folder-id="<%= f.id %>" data-name="<%= f.name %>">
|
||||||
<a class="folderCardLink" href="/op/folder/<%= encodeURIComponent(name) %>">
|
<a class="folderCardLink" href="/op/folder/<%= encodeURIComponent(f.name) %>">
|
||||||
<span class="folderIcon">📁</span>
|
<span class="folderIcon">📁</span>
|
||||||
<span class="folderName"><%= name %></span>
|
<span class="folderName"><%= f.name %></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>영상 편집 · <%= folder %></title>
|
<title>영상 편집 · <%= breadcrumb.join(' / ') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
|
<%
|
||||||
|
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||||
|
var folderHref = '/op/folder/' + folderPathEnc
|
||||||
|
var folderLabel = breadcrumb.join(' / ')
|
||||||
|
%>
|
||||||
<header class="editorNav">
|
<header class="editorNav">
|
||||||
<div class="editorNavLeft">
|
<div class="editorNavLeft">
|
||||||
<a class="muted" href="/op/folder/<%= encodeURIComponent(folder) %>">← <%= folder %></a>
|
<a class="muted" href="<%= folderHref %>">← <%= folderLabel %></a>
|
||||||
<input type="text" id="titleInput" class="titleInput" placeholder="영상 제목" value="<%= video ? video.title : '' %>" />
|
<input type="text" id="titleInput" class="titleInput" placeholder="영상 제목" value="<%= video ? video.title : '' %>" />
|
||||||
</div>
|
</div>
|
||||||
<div class="editorNavRight">
|
<div class="editorNavRight">
|
||||||
@@ -34,8 +39,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
||||||
|
<div class="videoIdRow">
|
||||||
|
<label for="videoIdInput">영상 ID</label>
|
||||||
|
<input type="text" id="videoIdInput" autocomplete="off" spellcheck="false"
|
||||||
|
value="<%= video ? video.id : '' %>" />
|
||||||
|
<span id="idStatus" class="idStatus muted"></span>
|
||||||
|
<button type="button" class="secondaryButton" id="changeIdBtn" disabled>변경</button>
|
||||||
|
<button type="button" class="secondaryButton" id="copyShareBtn">공유 주소 복사</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<video id="editVideo" controls preload="metadata"
|
<video id="editVideo" controls preload="metadata"
|
||||||
<% if (video) { %>src="/api/video/<%= video.id %>/file?edited=0"<% } %>></video>
|
<% if (video) { %>src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>?edited=0"<% } %>></video>
|
||||||
|
|
||||||
<div class="trimTimeline">
|
<div class="trimTimeline">
|
||||||
<div class="trimTimelineHeader">
|
<div class="trimTimelineHeader">
|
||||||
@@ -66,7 +80,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.__EDITOR__ = {
|
window.__EDITOR__ = {
|
||||||
folder: <%- jsonForScript(folder) %>,
|
folderPath: <%- jsonForScript(breadcrumb) %>,
|
||||||
|
folderId: <%- jsonForScript(folder.id) %>,
|
||||||
video: <%- jsonForScript(video) %>
|
video: <%- jsonForScript(video) %>
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,32 +3,73 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>관리자 · <%= folder %></title>
|
<title>관리자 · <%= breadcrumb.join(' / ') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
<%- include('../partials/navbar', { userId }) %>
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||||
|
var parentHref = isSubFolder ? '/op/folder/' + encodeURIComponent(breadcrumb[0]) : '/op/dashboard'
|
||||||
|
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
|
||||||
|
var editorHref = '/op/folder/' + folderPathEnc + '/video/editor'
|
||||||
|
var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new'
|
||||||
|
var listTxtHref = '/op/folder/' + folderPathEnc + '/list.txt'
|
||||||
|
%>
|
||||||
|
|
||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<div>
|
<div>
|
||||||
<a class="muted" href="/op/dashboard">← 폴더 목록</a>
|
<a class="muted" href="<%= parentHref %>"><%= parentLabel %></a>
|
||||||
<h1>📁 <%= folder %></h1>
|
<h1>📁 <%= breadcrumb.join(' / ') %></h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboardActions">
|
<div class="dashboardActions">
|
||||||
<a class="primaryButton" href="/op/folder/<%= encodeURIComponent(folder) %>/video/editor">영상 추가</a>
|
<% if (!isSubFolder) { %>
|
||||||
|
<button type="button" class="primaryButton" id="addSubFolderBtn">하위 폴더 추가</button>
|
||||||
|
<% } %>
|
||||||
|
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
|
||||||
|
<% if (isSubFolder) { %>
|
||||||
|
<a class="primaryButton" href="<%= playlistHref %>">플레이리스트 추가</a>
|
||||||
|
<a class="primaryButton" href="<%= listTxtHref %>" download>목록 뽑기</a>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<% if (!isSubFolder) { %>
|
||||||
|
<section class="folderGrid" id="subFolderGrid">
|
||||||
|
<% if (!subFolders || subFolders.length === 0) { %>
|
||||||
|
<p class="muted">아직 하위 폴더가 없습니다. 우측 상단에서 하위 폴더를 추가하세요. (영상도 이 폴더에 바로 추가할 수 있습니다.)</p>
|
||||||
|
<% } %>
|
||||||
|
<% (subFolders || []).forEach(function (sf) { %>
|
||||||
|
<div class="folderCard adminFolder" data-folder-id="<%= sf.id %>" data-name="<%= sf.name %>">
|
||||||
|
<a class="folderCardLink" href="/op/folder/<%= folderPathEnc %>/<%= encodeURIComponent(sf.name) %>">
|
||||||
|
<span class="folderIcon">📁</span>
|
||||||
|
<span class="folderName"><%= sf.name %></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<section class="videoGrid" id="videoGrid">
|
<section class="videoGrid" id="videoGrid">
|
||||||
<% if (videos.length === 0) { %>
|
<% if (videos.length === 0) { %>
|
||||||
<p class="muted">이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.</p>
|
<p class="muted">이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% videos.forEach(function (v) { %>
|
<% videos.forEach(function (v) {
|
||||||
<div class="videoCard adminVideo" data-id="<%= v.id %>" data-video-id="<%= v.id %>" data-title="<%= v.title %>">
|
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
|
||||||
<div class="videoThumb">▶</div>
|
%>
|
||||||
|
<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>
|
<div class="videoTitle"><%= v.title %></div>
|
||||||
<% if (v.sourceType === 'youtube' && !v.originalFile.includes('original.') === false) { %>
|
<% if (v.sourceType === 'youtube') { %>
|
||||||
<div class="muted">YouTube</div>
|
<div class="muted">YouTube</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,10 +80,16 @@
|
|||||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
<button type="button" data-action="edit">수정</button>
|
<button type="button" data-action="edit">수정</button>
|
||||||
<button type="button" data-action="rename">이름 변경</button>
|
<button type="button" data-action="rename">이름 변경</button>
|
||||||
|
<button type="button" data-action="changeId">ID 변경</button>
|
||||||
<button type="button" data-action="copyUrl">영상 주소 복사</button>
|
<button type="button" data-action="copyUrl">영상 주소 복사</button>
|
||||||
<button type="button" data-action="delete" class="dangerLink">삭제</button>
|
<button type="button" data-action="delete" class="dangerLink">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ctxMenu" id="folderCtxMenu" hidden>
|
||||||
|
<button type="button" data-action="rename">이름 변경</button>
|
||||||
|
<button type="button" data-action="delete" class="dangerLink">삭제</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="playerOverlay" id="playerOverlay" hidden>
|
<div class="playerOverlay" id="playerOverlay" hidden>
|
||||||
<div class="playerModal" role="dialog" aria-modal="true">
|
<div class="playerModal" role="dialog" aria-modal="true">
|
||||||
<button type="button" class="playerClose" id="playerClose" aria-label="닫기">✕</button>
|
<button type="button" class="playerClose" id="playerClose" aria-label="닫기">✕</button>
|
||||||
@@ -51,9 +98,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (!isSubFolder) { %>
|
||||||
|
<div class="modalOverlay" id="addSubFolderModal" hidden>
|
||||||
|
<div class="modalCard">
|
||||||
|
<h2>하위 폴더 추가</h2>
|
||||||
|
<label>
|
||||||
|
<span>폴더 이름</span>
|
||||||
|
<input type="text" id="addSubFolderInput" autofocus />
|
||||||
|
</label>
|
||||||
|
<div class="modalActions">
|
||||||
|
<button type="button" class="secondaryButton" id="addSubFolderCancel">취소</button>
|
||||||
|
<button type="button" class="primaryButton" id="addSubFolderConfirm">생성</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.__OP__ = { folder: <%- jsonForScript(folder) %> }
|
window.__OP__ = {
|
||||||
window.__SITE__ = { folder: <%- jsonForScript(folder) %> }
|
folderId: <%- jsonForScript(folder.id) %>,
|
||||||
|
folderPath: <%- jsonForScript(breadcrumb) %>,
|
||||||
|
isSubFolder: <%- jsonForScript(isSubFolder) %>
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/folder.js"></script>
|
<script src="/static/folder.js"></script>
|
||||||
<script src="/static/player.js"></script>
|
<script src="/static/player.js"></script>
|
||||||
|
|||||||
140
views/op/playlist.ejs
Normal file
140
views/op/playlist.ejs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>플레이리스트 추가 · <%= breadcrumb.join(' / ') %></title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%
|
||||||
|
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||||
|
var folderHref = '/op/folder/' + folderPathEnc
|
||||||
|
var folderLabel = breadcrumb.join(' / ')
|
||||||
|
%>
|
||||||
|
<header class="editorNav">
|
||||||
|
<div class="editorNavLeft">
|
||||||
|
<a class="muted" href="<%= folderHref %>">← <%= folderLabel %></a>
|
||||||
|
<span class="titleInput" style="cursor:default;">플레이리스트 추가</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="editorMain">
|
||||||
|
<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>
|
||||||
|
<div class="playlistControlsRow">
|
||||||
|
<div class="idModeBox">
|
||||||
|
<span class="idModeLabel">영상 ID 생성 방식</span>
|
||||||
|
<label><input type="radio" name="idMode" value="random" checked /> 랜덤</label>
|
||||||
|
<label><input type="radio" name="idMode" value="sequential" /> 정렬된 번호</label>
|
||||||
|
<label class="zeroPadWrap" id="zeroPadWrap" hidden>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="playlistEntryList" id="entryList"></ol>
|
||||||
|
|
||||||
|
<div class="playlistFooter">
|
||||||
|
<p id="registerStatus" class="muted"></p>
|
||||||
|
<button type="button" class="primaryButton" id="registerBtn">등록 & 다운로드 시작</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="현재 시점을 시작점으로">[ 시작점</button>
|
||||||
|
<button type="button" data-pmark="end" title="현재 시점을 끝점으로">끝점 ]</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>
|
||||||
@@ -7,22 +7,27 @@
|
|||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
|
<%
|
||||||
|
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||||
|
var folderHref = '/folder/' + folderPathEnc
|
||||||
|
var folderLabel = breadcrumb.join(' / ')
|
||||||
|
%>
|
||||||
<header class="publicNav">
|
<header class="publicNav">
|
||||||
<a class="navBrand" href="/">
|
<a class="navBrand" href="/">
|
||||||
<span class="navLogo">🎬</span>
|
<span class="navLogo">🎬</span>
|
||||||
<span class="navTitle">비디오 사이트</span>
|
<span class="navTitle">비디오 사이트</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="secondaryButton" href="/folder/<%= encodeURIComponent(folder) %>">폴더로</a>
|
<a class="secondaryButton" href="<%= folderHref %>">폴더로</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<a class="muted" href="/folder/<%= encodeURIComponent(folder) %>">← <%= folder %></a>
|
<a class="muted" href="<%= folderHref %>">← <%= folderLabel %></a>
|
||||||
<h1><%= video.title %></h1>
|
<h1><%= video.title %></h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="standalonePlayer">
|
<div class="standalonePlayer">
|
||||||
<video controls autoplay preload="metadata" src="/api/video/<%= video.id %>/file"></video>
|
<video controls autoplay preload="metadata" src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>"></video>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user