yt-dlp/ffmpeg: reinstall latest on failure, retry once
오래된 yt-dlp/ffmpeg 가 유튜브 변경을 못 따라가 다운로드가 실패할 때 최신 버전으로 강제 재설치 후 한 번 더 시도한다. - server youtube.ts: ensureYtDlp(force) 추가(캐시·zipapp 삭제 후 최신 재다운로드). fetchVideoMeta/fetchPlaylistEntries 를 runYtDlp 로 묶어 1차 실패 시 강제 재설치 후 재시도. - installer ytdlp.ts/ffmpeg.ts: ensure*Exe(log, force) 추가. - installer main.ts: 음악 워커가 곡 다운로드 실패 시 전역 1회 강제 재설치 (refreshBinariesOnce) 후 해당 곡을 1회 재시도.
This commit is contained in:
@@ -82,6 +82,9 @@
|
|||||||
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
||||||
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
||||||
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
||||||
|
"musicRetryAfterRefresh": "{{idx}}번 노래 실패({{message}}) → yt-dlp/ffmpeg 최신 버전으로 재설치 후 재시도",
|
||||||
|
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
|
||||||
|
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
|
||||||
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
||||||
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||||
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||||
|
|||||||
@@ -50,14 +50,20 @@ let installPromise: Promise<string> | null = null
|
|||||||
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
|
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
|
||||||
*/
|
*/
|
||||||
export async function ensureFfmpegExe(
|
export async function ensureFfmpegExe(
|
||||||
log?: (line: string) => void
|
log?: (line: string) => void,
|
||||||
|
force = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const target = getFfmpegExePath()
|
const target = getFfmpegExePath()
|
||||||
await migrateLegacyExe(target)
|
await migrateLegacyExe(target)
|
||||||
if (await canExecute(target)) {
|
if (!force && await canExecute(target)) {
|
||||||
log?.(t('log.ffmpegExists', { path: target }))
|
log?.(t('log.ffmpegExists', { path: target }))
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
if (force) {
|
||||||
|
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
|
||||||
|
log?.(t('log.ffmpegReinstall'))
|
||||||
|
try { await fs.unlink(target) } catch { /* noop */ }
|
||||||
|
}
|
||||||
if (installPromise) return installPromise
|
if (installPromise) return installPromise
|
||||||
|
|
||||||
installPromise = (async () => {
|
installPromise = (async () => {
|
||||||
|
|||||||
@@ -311,16 +311,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
||||||
sendLog(t('log.ytdlpPreparing'))
|
sendLog(t('log.ytdlpPreparing'))
|
||||||
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
|
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
|
||||||
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
let ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||||
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
sendLog(t('log.ffmpegPreparing'))
|
sendLog(t('log.ffmpegPreparing'))
|
||||||
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
||||||
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
let ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||||
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
||||||
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
|
|
||||||
|
// 음악 다운로드가 실패하면 yt-dlp/ffmpeg 가 너무 오래된 버전이라 유튜브 변경을
|
||||||
|
// 못 따라가는 경우일 수 있다. 그때 최신 버전으로 한 번만 강제 재설치한다.
|
||||||
|
// 워커 여러 개가 동시에 실패해도 재설치는 단 한 번만 일어나도록 락으로 직렬화.
|
||||||
|
let binRefreshPromise: Promise<void> | null = null
|
||||||
|
async function refreshBinariesOnce(): Promise<void> {
|
||||||
|
if (!binRefreshPromise) {
|
||||||
|
binRefreshPromise = (async () => {
|
||||||
|
ytDlpBin = await ensureYtDlpExe(sendLog, true)
|
||||||
|
ffmpegBin = await ensureFfmpegExe(sendLog, true)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
await binRefreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
||||||
const musicDir = path.join(tempRoot, 'music')
|
const musicDir = path.join(tempRoot, 'music')
|
||||||
await fsp.mkdir(musicDir, { recursive: true })
|
await fsp.mkdir(musicDir, { recursive: true })
|
||||||
@@ -346,38 +360,51 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
const idx = i + 1
|
const idx = i + 1
|
||||||
sendLog(t('log.musicTrackStart', { idx }))
|
sendLog(t('log.musicTrackStart', { idx }))
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||||
let child: ChildProcess | null = null
|
// 한 곡당 최대 2회 시도: 1차 실패 시 yt-dlp/ffmpeg 를 최신으로 강제
|
||||||
try {
|
// 재설치(전역 1회)한 뒤 같은 곡을 다시 받아본다.
|
||||||
const outPath = await downloadMusicTrack({
|
let attemptedRefresh = false
|
||||||
ytdlpExe: ytDlpBin,
|
while (true) {
|
||||||
ffmpegExe: ffmpegBin,
|
let child: ChildProcess | null = null
|
||||||
tempDir: musicDir,
|
try {
|
||||||
index: idx,
|
const outPath = await downloadMusicTrack({
|
||||||
url: entry.url,
|
ytdlpExe: ytDlpBin,
|
||||||
log: sendLog,
|
ffmpegExe: ffmpegBin,
|
||||||
onChild: (c) => {
|
tempDir: musicDir,
|
||||||
child = c
|
index: idx,
|
||||||
state.activeChildren.add(c)
|
url: entry.url,
|
||||||
},
|
log: sendLog,
|
||||||
onProgress: (pct) => {
|
onChild: (c) => {
|
||||||
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
child = c
|
||||||
sendProgress({
|
state.activeChildren.add(c)
|
||||||
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
},
|
||||||
percent: Math.min(90, pct * 0.9), status: 'running'
|
onProgress: (pct) => {
|
||||||
})
|
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
||||||
|
sendProgress({
|
||||||
|
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
||||||
|
percent: Math.min(90, pct * 0.9), status: 'running'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (child) state.activeChildren.delete(child)
|
||||||
|
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
||||||
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
if (child) state.activeChildren.delete(child)
|
||||||
|
if (state.cancelRequested) {
|
||||||
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
if (!attemptedRefresh) {
|
||||||
if (child) state.activeChildren.delete(child)
|
attemptedRefresh = true
|
||||||
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
sendLog(t('log.musicRetryAfterRefresh', { idx, message: (err as Error).message }))
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
await refreshBinariesOnce()
|
||||||
} catch (err) {
|
if (state.cancelRequested) return
|
||||||
if (child) state.activeChildren.delete(child)
|
continue
|
||||||
if (state.cancelRequested) {
|
}
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||||
return
|
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
|
||||||
}
|
}
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
|
||||||
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,14 +47,20 @@ let installPromise: Promise<string> | null = null
|
|||||||
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
|
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
|
||||||
*/
|
*/
|
||||||
export async function ensureYtDlpExe(
|
export async function ensureYtDlpExe(
|
||||||
log?: (line: string) => void
|
log?: (line: string) => void,
|
||||||
|
force = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const target = getYtDlpExePath()
|
const target = getYtDlpExePath()
|
||||||
await migrateLegacyExe(target)
|
await migrateLegacyExe(target)
|
||||||
if (await canExecute(target)) {
|
if (!force && await canExecute(target)) {
|
||||||
log?.(t('log.ytdlpExists', { path: target }))
|
log?.(t('log.ytdlpExists', { path: target }))
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
if (force) {
|
||||||
|
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
|
||||||
|
log?.(t('log.ytdlpReinstall'))
|
||||||
|
try { await fs.unlink(target) } catch { /* noop */ }
|
||||||
|
}
|
||||||
if (installPromise) return installPromise
|
if (installPromise) return installPromise
|
||||||
|
|
||||||
installPromise = (async () => {
|
installPromise = (async () => {
|
||||||
|
|||||||
@@ -62,25 +62,33 @@ type ProbeResult = { ok: true } | { ok: false; detail: string }
|
|||||||
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
|
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
|
||||||
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
|
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
|
||||||
*/
|
*/
|
||||||
export async function ensureYtDlp(): Promise<string> {
|
export async function ensureYtDlp(force = false): Promise<string> {
|
||||||
const target = getYtDlpInstallPath()
|
const target = getYtDlpInstallPath()
|
||||||
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
|
if (!force) {
|
||||||
if (await fileExists(target)) {
|
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
|
||||||
const probe = await probeVersion(target)
|
if (await fileExists(target)) {
|
||||||
if (probe.ok) return target
|
const probe = await probeVersion(target)
|
||||||
}
|
if (probe.ok) return target
|
||||||
// Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용
|
}
|
||||||
if (process.platform !== 'win32') {
|
// Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용
|
||||||
const zipappPath = getYtDlpZipappPath()
|
if (process.platform !== 'win32') {
|
||||||
if (await fileExists(zipappPath)) {
|
const zipappPath = getYtDlpZipappPath()
|
||||||
const probe = await probeVersion(zipappPath)
|
if (await fileExists(zipappPath)) {
|
||||||
if (probe.ok) return zipappPath
|
const probe = await probeVersion(zipappPath)
|
||||||
|
if (probe.ok) return zipappPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 강제 재설치: 캐시된(=오래됐을 수 있는) 바이너리를 지워 최신으로 다시 받게 한다.
|
||||||
|
try { await fs.unlink(target) } catch { /* noop */ }
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
try { await fs.unlink(getYtDlpZipappPath()) } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (installPromise) return installPromise
|
if (installPromise) return installPromise
|
||||||
installPromise = (async () => {
|
installPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
return await prepareYtDlp(target)
|
return await prepareYtDlp(target, force)
|
||||||
} finally {
|
} finally {
|
||||||
installPromise = null
|
installPromise = null
|
||||||
}
|
}
|
||||||
@@ -88,31 +96,34 @@ export async function ensureYtDlp(): Promise<string> {
|
|||||||
return installPromise
|
return installPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareYtDlp(target: string): Promise<string> {
|
async function prepareYtDlp(target: string, force = false): Promise<string> {
|
||||||
const diagnostics: string[] = []
|
const diagnostics: string[] = []
|
||||||
|
|
||||||
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
|
// 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다.
|
||||||
if (await fileExists(target)) {
|
if (!force) {
|
||||||
const probe = await probeVersion(target)
|
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
|
||||||
if (probe.ok) return target
|
if (await fileExists(target)) {
|
||||||
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
|
const probe = await probeVersion(target)
|
||||||
}
|
if (probe.ok) return target
|
||||||
|
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
|
||||||
// 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
const existingZipapp = getYtDlpZipappPath()
|
|
||||||
if (await fileExists(existingZipapp)) {
|
|
||||||
const probe = await probeVersion(existingZipapp)
|
|
||||||
if (probe.ok) return existingZipapp
|
|
||||||
diagnostics.push(`기존 yt-dlp_zipapp 검증 실패: ${probe.detail}`)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
|
// 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도
|
||||||
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
|
if (process.platform !== 'win32') {
|
||||||
const pathProbe = await probeVersion(pathCmd)
|
const existingZipapp = getYtDlpZipappPath()
|
||||||
if (pathProbe.ok) return pathCmd
|
if (await fileExists(existingZipapp)) {
|
||||||
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
|
const probe = await probeVersion(existingZipapp)
|
||||||
|
if (probe.ok) return existingZipapp
|
||||||
|
diagnostics.push(`기존 yt-dlp_zipapp 검증 실패: ${probe.detail}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
|
||||||
|
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
|
||||||
|
const pathProbe = await probeVersion(pathCmd)
|
||||||
|
if (pathProbe.ok) return pathCmd
|
||||||
|
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 최후 수단: 새로 다운로드해서 시도
|
// 3. 최후 수단: 새로 다운로드해서 시도
|
||||||
try {
|
try {
|
||||||
@@ -235,52 +246,80 @@ function downloadToFile(url: string, dest: string, redirects = 0): Promise<void>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** yt-dlp 를 한 번 실행하고 종료코드·stdout·stderr 를 모은다. reject 하지 않는다. */
|
||||||
|
function spawnYtDlp(bin: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let child: ReturnType<typeof spawn>
|
||||||
|
try {
|
||||||
|
child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
} catch (err) {
|
||||||
|
resolve({ code: null, stdout: '', stderr: err instanceof Error ? err.message : String(err) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
let settled = false
|
||||||
|
const done = (r: { code: number | null; stdout: string; stderr: string }) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(r)
|
||||||
|
}
|
||||||
|
child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||||
|
child.on('error', (err) => done({ code: null, stdout, stderr: stderr || (err as Error).message }))
|
||||||
|
child.on('close', (code) => done({ code, stdout, stderr }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp 를 실행하고 stdout 을 돌려준다. 첫 시도가 실패(0 이 아닌 종료코드/실행 불가)하면
|
||||||
|
* yt-dlp 가 오래돼 유튜브 변경을 못 따라가는 상황일 수 있으므로, 최신 버전으로 강제
|
||||||
|
* 재설치한 뒤 한 번 더 시도한다. 그래도 실패하면 makeError 로 만든 에러를 던진다.
|
||||||
|
*/
|
||||||
|
async function runYtDlp(args: string[], makeError: (code: string, detail: string) => Error): Promise<string> {
|
||||||
|
let bin = await ensureYtDlp()
|
||||||
|
let res = await spawnYtDlp(bin, args)
|
||||||
|
if (res.code !== 0) {
|
||||||
|
let refreshed = false
|
||||||
|
try {
|
||||||
|
bin = await ensureYtDlp(true)
|
||||||
|
refreshed = true
|
||||||
|
} catch { /* 재설치 실패 시 아래에서 원래 실패로 보고 */ }
|
||||||
|
if (refreshed) {
|
||||||
|
res = await spawnYtDlp(bin, args)
|
||||||
|
}
|
||||||
|
if (res.code !== 0) {
|
||||||
|
throw makeError(String(res.code), res.stderr.trim() || res.stdout.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.stdout
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단일 영상 URL 의 메타데이터를 가져온다.
|
* 단일 영상 URL 의 메타데이터를 가져온다.
|
||||||
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
||||||
*/
|
*/
|
||||||
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
||||||
const bin = await ensureYtDlp()
|
const stdout = await runYtDlp(
|
||||||
return new Promise((resolve, reject) => {
|
['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
|
||||||
const child = spawn(bin, [
|
(code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
|
||||||
'--dump-json',
|
)
|
||||||
'--no-warnings',
|
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
||||||
'--no-playlist',
|
if (!line) return null
|
||||||
'--skip-download',
|
const obj = JSON.parse(line) as Record<string, unknown>
|
||||||
url
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
if (!id) return null
|
||||||
let stdout = ''
|
return {
|
||||||
let stderr = ''
|
id,
|
||||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
title: typeof obj.title === 'string' ? obj.title : '',
|
||||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
channel: typeof obj.channel === 'string'
|
||||||
child.on('error', (err) => reject(err))
|
? obj.channel
|
||||||
child.on('close', (code) => {
|
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
||||||
if (code !== 0) {
|
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
||||||
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
|
||||||
return
|
? obj.webpage_url
|
||||||
}
|
: `https://www.youtube.com/watch?v=${id}`
|
||||||
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
}
|
||||||
if (!line) { resolve(null); return }
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line) as Record<string, unknown>
|
|
||||||
const id = typeof obj.id === 'string' ? obj.id : ''
|
|
||||||
if (!id) { resolve(null); return }
|
|
||||||
resolve({
|
|
||||||
id,
|
|
||||||
title: typeof obj.title === 'string' ? obj.title : '',
|
|
||||||
channel: typeof obj.channel === 'string'
|
|
||||||
? obj.channel
|
|
||||||
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
|
||||||
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
|
||||||
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
|
|
||||||
? obj.webpage_url
|
|
||||||
: `https://www.youtube.com/watch?v=${id}`
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -288,47 +327,31 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
|
|||||||
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||||
*/
|
*/
|
||||||
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
||||||
const bin = await ensureYtDlp()
|
const stdout = await runYtDlp(
|
||||||
return new Promise((resolve, reject) => {
|
['--flat-playlist', '--dump-json', '--no-warnings', url],
|
||||||
const child = spawn(bin, [
|
(code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))
|
||||||
'--flat-playlist',
|
)
|
||||||
'--dump-json',
|
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
||||||
'--no-warnings',
|
const parsed: YtPlaylistEntry[] = []
|
||||||
url
|
for (const line of lines) {
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
try {
|
||||||
let stdout = ''
|
const obj = JSON.parse(line) as Record<string, unknown>
|
||||||
let stderr = ''
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
if (!id) continue
|
||||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
parsed.push({
|
||||||
child.on('error', (err) => reject(err))
|
id,
|
||||||
child.on('close', (code) => {
|
title: typeof obj.title === 'string' ? obj.title : '',
|
||||||
if (code !== 0) {
|
channel: typeof obj.channel === 'string'
|
||||||
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
? obj.channel
|
||||||
return
|
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
||||||
}
|
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
||||||
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
url: typeof obj.url === 'string' && obj.url.length > 0
|
||||||
const parsed: YtPlaylistEntry[] = []
|
? obj.url
|
||||||
for (const line of lines) {
|
: `https://www.youtube.com/watch?v=${id}`
|
||||||
try {
|
})
|
||||||
const obj = JSON.parse(line) as Record<string, unknown>
|
} catch {
|
||||||
const id = typeof obj.id === 'string' ? obj.id : ''
|
// 한 줄이 깨져도 나머지는 살림
|
||||||
if (!id) continue
|
}
|
||||||
parsed.push({
|
}
|
||||||
id,
|
return parsed
|
||||||
title: typeof obj.title === 'string' ? obj.title : '',
|
|
||||||
channel: typeof obj.channel === 'string'
|
|
||||||
? obj.channel
|
|
||||||
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
|
||||||
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
|
||||||
url: typeof obj.url === 'string' && obj.url.length > 0
|
|
||||||
? obj.url
|
|
||||||
: `https://www.youtube.com/watch?v=${id}`
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// 한 줄이 깨져도 나머지는 살림
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve(parsed)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user