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:
101
src/routes/op.ts
101
src/routes/op.ts
@@ -28,6 +28,7 @@ import {
|
|||||||
YtDlpUnavailableError,
|
YtDlpUnavailableError,
|
||||||
getJob,
|
getJob,
|
||||||
probeYoutube,
|
probeYoutube,
|
||||||
|
probeYoutubePlaylist,
|
||||||
startYoutubeDownload
|
startYoutubeDownload
|
||||||
} from '../youtube.js'
|
} from '../youtube.js'
|
||||||
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.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<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 (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) => {
|
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
||||||
// 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다.
|
// 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다.
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||||
|
|||||||
@@ -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'
|
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user