feat(playlist): yt-dlp playlist probe + bulk start backend
영상 단건 import 와 동일한 패턴으로 2단계 폴더 전용 endpoint 두 개를
추가했다.
- src/youtube.ts: `probeYoutubePlaylist(url)` — yt-dlp 의
`--flat-playlist --dump-json` 으로 한 줄당 한 항목을 받아
`{ url, title, durationSec, originalIndex }` 배열로 정리한다.
내부 컨테이너 라인(`_type==='playlist'`) 은 스킵.
- src/routes/op.ts:
- POST `/op/folder/:topName/:subName/playlist/probe`
URL 만 받고 위 함수를 호출해서 entries 를 돌려준다.
- POST `/op/folder/:topName/:subName/playlist/start`
UI 가 큐레이션한 `[{ url, title, videoId }]` 를 받아
`isSafeVideoId` 형식 검사, 요청 내 중복 검사, DB 충돌 검사를
순서대로 한 뒤 항목별로 `startYoutubeDownload` 를 호출.
두 endpoint 모두 1단계 폴더 path 는 router 매칭 자체에서 빠져
(sub 만 등록) 자연스럽게 404 가 난다.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,84 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 플레이리스트 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<PlaylistProbeResult> {
|
||||
const bin = getYtDlpPath()
|
||||
return new Promise<PlaylistProbeResult>((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<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
|
||||
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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user