feat: implement video site per README spec
- Express + EJS + express-session stack (auth/navbar ported from minecraft_launcher)
- Public: main folder list, folder video grid, internal popup player (/player/:videoId)
- Admin (/op): login, folder CRUD with right-click context menu + add-folder modal
- Admin folder: video grid with right-click edit/rename/delete, "영상 추가" -> editor
- Video editor: drag-drop upload, file picker, YouTube URL probe (ETA + 5분 경고),
background yt-dlp download with progress polling, navbar title edit, trim controls,
save runs ffmpeg trim (original preserved)
- Filesystem storage under data/folders/<name>/<videoId>/{meta.json, original.<ext>, edited.<ext>}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
243
src/youtube.ts
Normal file
243
src/youtube.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { jobsDir, projectRoot } from './paths.js'
|
||||
import {
|
||||
loadVideoMeta,
|
||||
newVideoId,
|
||||
saveVideoMeta,
|
||||
sanitizeFolderName,
|
||||
videoDir,
|
||||
type VideoMeta
|
||||
} from './store.js'
|
||||
|
||||
export class YtDlpUnavailableError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || 'yt-dlp 가 설치되어 있지 않습니다. yt-dlp 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedYtDlpPath: string | null = null
|
||||
|
||||
export function getYtDlpPath(): string {
|
||||
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||
// 1) 프로젝트 bin/yt-dlp(.exe)
|
||||
const localCandidates =
|
||||
process.platform === 'win32'
|
||||
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
||||
: ['bin/yt-dlp']
|
||||
for (const rel of localCandidates) {
|
||||
const abs = path.join(projectRoot, rel)
|
||||
const r = spawnSync(abs, ['--version'])
|
||||
if (r.status === 0) {
|
||||
resolvedYtDlpPath = abs
|
||||
return abs
|
||||
}
|
||||
}
|
||||
// 2) PATH 에서 검색
|
||||
const r = spawnSync('yt-dlp', ['--version'])
|
||||
if (r.status === 0) {
|
||||
resolvedYtDlpPath = 'yt-dlp'
|
||||
return 'yt-dlp'
|
||||
}
|
||||
throw new YtDlpUnavailableError()
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
title: string
|
||||
durationSec: number
|
||||
filesizeApprox: number | null
|
||||
/** 추정 다운로드 ETA (초). filesize_approx / 가정대역폭. 모를 때 null. */
|
||||
etaSec: number | null
|
||||
warnOver5min: boolean
|
||||
}
|
||||
|
||||
const ASSUMED_BPS = 5 * 1024 * 1024 // 5 MB/s 가정. 실측은 어렵지만 경고 트리거용으로만 사용.
|
||||
|
||||
export async function probeYoutube(url: string): Promise<ProbeResult> {
|
||||
const bin = getYtDlpPath()
|
||||
return new Promise<ProbeResult>((resolve, reject) => {
|
||||
const child = spawn(bin, [
|
||||
'--no-warnings',
|
||||
'--skip-download',
|
||||
'--print',
|
||||
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
||||
].concat([url]))
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()))
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
|
||||
return
|
||||
}
|
||||
const lines = stdout.trim().split('\n')
|
||||
const title = lines[0] || ''
|
||||
const durationSec = Number(lines[1]) || 0
|
||||
const sizeRaw = lines[2]
|
||||
const filesizeApprox =
|
||||
sizeRaw && sizeRaw !== 'NA' && Number.isFinite(Number(sizeRaw))
|
||||
? Number(sizeRaw)
|
||||
: null
|
||||
const etaSec = filesizeApprox ? Math.round(filesizeApprox / ASSUMED_BPS) : null
|
||||
const warnOver5min = (etaSec ?? 0) > 5 * 60 || durationSec > 60 * 60
|
||||
resolve({ title, durationSec, filesizeApprox, etaSec, warnOver5min })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 백그라운드 다운로드 잡 ────────────────────────────────────────────────
|
||||
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string
|
||||
folder: string
|
||||
videoId: string
|
||||
url: string
|
||||
status: JobStatus
|
||||
progress: number // 0..100
|
||||
message: string
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
outputFile: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const jobs = new Map<string, DownloadJob>()
|
||||
|
||||
async function persistJob(job: DownloadJob): Promise<void> {
|
||||
await fs.mkdir(jobsDir, { recursive: true })
|
||||
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
||||
}
|
||||
|
||||
export function getJob(id: string): DownloadJob | null {
|
||||
return jobs.get(id) ?? null
|
||||
}
|
||||
|
||||
export function listActiveJobs(): DownloadJob[] {
|
||||
return Array.from(jobs.values()).filter((j) => j.status === 'queued' || j.status === 'downloading')
|
||||
}
|
||||
|
||||
export interface StartDownloadOpts {
|
||||
folder: string
|
||||
url: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
|
||||
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
||||
const safeFolder = sanitizeFolderName(opts.folder)
|
||||
if (!safeFolder) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const bin = getYtDlpPath() // 없으면 즉시 던짐
|
||||
const videoId = newVideoId()
|
||||
const dir = videoDir(safeFolder, videoId)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const meta: VideoMeta = {
|
||||
id: videoId,
|
||||
title: opts.title?.trim() || '제목 없음',
|
||||
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
|
||||
editedFile: null,
|
||||
durationSec: null,
|
||||
sourceType: 'youtube',
|
||||
sourceUrl: opts.url,
|
||||
trim: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
await saveVideoMeta(safeFolder, meta)
|
||||
|
||||
const job: DownloadJob = {
|
||||
id: newVideoId(),
|
||||
folder: safeFolder,
|
||||
videoId,
|
||||
url: opts.url,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
message: '대기 중',
|
||||
startedAt: now,
|
||||
finishedAt: null,
|
||||
outputFile: null,
|
||||
error: null
|
||||
}
|
||||
jobs.set(job.id, job)
|
||||
void persistJob(job)
|
||||
setImmediate(() => 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)
|
||||
}))
|
||||
return job
|
||||
}
|
||||
|
||||
async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
job.status = 'downloading'
|
||||
job.message = '다운로드 시작'
|
||||
await persistJob(job)
|
||||
|
||||
const dir = videoDir(job.folder, job.videoId)
|
||||
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||
const args = [
|
||||
'--no-warnings',
|
||||
'--no-playlist',
|
||||
'--newline',
|
||||
'--progress-template', 'download:PROGRESS %(progress._percent_str)s',
|
||||
'--print', 'after_move:OUT %(filepath)s',
|
||||
'-o', path.join(dir, 'original.%(ext)s'),
|
||||
job.url
|
||||
]
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(bin, args)
|
||||
let outputFile: string | null = null
|
||||
let stderrTail = ''
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
|
||||
if (m) {
|
||||
job.progress = Math.min(99, Math.round(Number(m[1])))
|
||||
job.message = `다운로드 ${job.progress}%`
|
||||
}
|
||||
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
||||
if (o) outputFile = o[1].trim()
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-2000)
|
||||
})
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', async (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
|
||||
return
|
||||
}
|
||||
// 실제 파일명 결정
|
||||
let finalName = 'original'
|
||||
if (outputFile) {
|
||||
finalName = path.basename(outputFile)
|
||||
} else {
|
||||
// fallback: 디렉토리 내 original.* 찾기
|
||||
const entries = await fs.readdir(dir).catch(() => [])
|
||||
const found = entries.find((n) => n.startsWith('original.'))
|
||||
if (found) finalName = found
|
||||
}
|
||||
const meta = await loadVideoMeta(job.folder, job.videoId)
|
||||
if (meta) {
|
||||
meta.originalFile = finalName
|
||||
await saveVideoMeta(job.folder, meta)
|
||||
}
|
||||
job.progress = 100
|
||||
job.status = 'done'
|
||||
job.message = '완료'
|
||||
job.outputFile = finalName
|
||||
job.finishedAt = new Date().toISOString()
|
||||
await persistJob(job)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user