diff --git a/src/routes/op.ts b/src/routes/op.ts index eda0db3..e60013c 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -28,6 +28,7 @@ import { YtDlpUnavailableError, getJob, probeYoutube, + probeYoutubePlaylist, startYoutubeDownload } from '../youtube.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' @@ -307,6 +308,106 @@ opRouter.post( } ) +/** 플레이리스트 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 } + const cleaned: Cleaned[] = [] + const seen = new Set() + for (let i = 0; i < rawEntries.length; i += 1) { + const e = rawEntries[i] as Record + 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 (videoIdExists(videoId)) { + throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`) + } + seen.add(videoId) + cleaned.push({ url, title, videoId }) + } + + // 검증 통과 → 순차적으로 잡 시작. 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 + }) + 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) => { // 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다. res.set('Cache-Control', 'no-store, no-cache, must-revalidate') diff --git a/src/youtube.ts b/src/youtube.ts index 8d65ef9..d392a08 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -88,6 +88,84 @@ export async function probeYoutube(url: string): Promise { }) } +// ─── 플레이리스트 probe ──────────────────────────────────────────────────── + +export interface PlaylistEntry { + /** 단일 영상 시작 endpoint 에 그대로 넘기는 watch URL. */ + url: string + title: string + /** 메타에 없으면 null. UI 표시용. */ + durationSec: number | null + /** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */ + originalIndex: number +} + +export interface PlaylistProbeResult { + entries: PlaylistEntry[] +} + +/** yt-dlp 로 플레이리스트의 각 항목 메타만 빠르게 긁어온다 (실제 다운로드 X). */ +export async function probeYoutubePlaylist(url: string): Promise { + const bin = getYtDlpPath() + return new Promise((resolve, reject) => { + // --flat-playlist + --dump-json 한 줄당 한 JSON 항목. + const child = spawn(bin, [ + '--no-warnings', + '--flat-playlist', + '--dump-json', + 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 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 + try { + obj = JSON.parse(trimmed) as Record + } 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 + entries.push({ url: entryUrl, title, durationSec, originalIndex: idx }) + } + if (entries.length === 0) { + reject(new Error('플레이리스트에서 항목을 찾지 못했습니다.')) + return + } + resolve({ entries }) + }) + }) +} + // ─── 백그라운드 다운로드 잡 ──────────────────────────────────────────────── export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'