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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:36:02 +09:00

298 lines
11 KiB
TypeScript

import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { jobsDir, projectRoot } from './paths.js'
import { upscaleOriginalTo60Fps } from './editor.js'
import {
createVideo,
getFolder,
getVideo,
updateVideo,
videoDiskDir
} from './storeDb.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
folderId: number
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 {
folderId: number
url: string
title?: string
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
videoId?: string
}
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
const folder = getFolder(opts.folderId)
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
const bin = getYtDlpPath() // 없으면 즉시 던짐
const video = await createVideo({
id: opts.videoId,
folderId: opts.folderId,
title: opts.title?.trim() || '제목 없음',
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
sourceType: 'youtube',
sourceUrl: opts.url
})
const now = new Date().toISOString()
const job: DownloadJob = {
id: 'job-' + Math.random().toString(36).slice(2, 14),
folderId: opts.folderId,
videoId: video.id,
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 = videoDiskDir(job.videoId)
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [
'--no-warnings',
'--no-playlist',
'--newline',
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
// 마지막 fallback 으로 무제한 best 를 받는다 (그 경우 후처리에서 FHD 로 다운스케일).
'-f', 'bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b',
// 가능한 한 부드러운 영상을 받기 위해 fps 를 최우선으로 정렬한다.
// yt-dlp 기본 정렬은 fps 를 가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦다.
// 우선순위: fps → 해상도 → 비트레이트 → 확장자.
// 60fps 가 아예 없는 영상은 자연스럽게 그 영상의 최고 fps 로 폴백된다.
'-S', 'fps,res,br,ext',
'--progress-template', 'download:PROGRESS %(progress._percent_str)s',
'--print', 'after_move:OUT %(filepath)s',
'-o', path.join(dir, 'original.%(ext)s'),
job.url
]
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
let downloadPctRaw = 0
return new Promise<void>((resolve, reject) => {
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
// 이게 없으면 "다운로드 끝나자마자 한꺼번에 progress 0→50 점프" 처럼 보임.
const child = spawn(bin, args, {
env: { ...process.env, PYTHONUNBUFFERED: '1' }
})
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) {
const pct = Number(m[1])
if (Number.isFinite(pct)) {
downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct))
const mapped = Math.round(downloadPctRaw * 0.5) // 0..50
if (mapped > job.progress) job.progress = mapped
job.message = `다운로드 ${Math.round(downloadPctRaw)}%`
}
}
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
}
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
// minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
try {
// 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다.
job.progress = 50
job.message = '60fps 변환 준비 중'
await persistJob(job)
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
let lastPersistAt = 0
let lastBumpPct = 0
const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
if (pct <= lastBumpPct) return
lastBumpPct = pct
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
if (mapped > job.progress) job.progress = mapped
job.message = `60fps 변환 ${Math.round(pct)}%`
const now = Date.now()
if (now - lastPersistAt > 2000) {
lastPersistAt = now
void persistJob(job)
}
})
if (bumped !== finalName) {
job.message = '60fps 변환 완료'
finalName = bumped
} else {
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
// (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패.
// 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다.
job.progress = 99
}
await persistJob(job)
} catch (err) {
// 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선.
console.error('[youtube] 60fps 후처리 실패:', err)
}
const meta = getVideo(job.videoId)
if (meta) {
updateVideo(job.videoId, { originalFile: finalName })
}
job.progress = 100
job.status = 'done'
job.message = '완료'
job.outputFile = finalName
job.finishedAt = new Date().toISOString()
await persistJob(job)
resolve()
})
})
}