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:
2026-06-05 16:08:26 +09:00
parent d9ba2b0f35
commit d5f88e0e76
5 changed files with 219 additions and 154 deletions

View File

@@ -62,25 +62,33 @@ type ProbeResult = { ok: true } | { ok: false; detail: string }
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
*/
export async function ensureYtDlp(): Promise<string> {
export async function ensureYtDlp(force = false): Promise<string> {
const target = getYtDlpInstallPath()
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
}
// Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용
if (process.platform !== 'win32') {
const zipappPath = getYtDlpZipappPath()
if (await fileExists(zipappPath)) {
const probe = await probeVersion(zipappPath)
if (probe.ok) return zipappPath
if (!force) {
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
}
// Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용
if (process.platform !== 'win32') {
const zipappPath = getYtDlpZipappPath()
if (await fileExists(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
installPromise = (async () => {
try {
return await prepareYtDlp(target)
return await prepareYtDlp(target, force)
} finally {
installPromise = null
}
@@ -88,31 +96,34 @@ export async function ensureYtDlp(): Promise<string> {
return installPromise
}
async function prepareYtDlp(target: string): Promise<string> {
async function prepareYtDlp(target: string, force = false): Promise<string> {
const diagnostics: string[] = []
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
if (await fileExists(target)) {
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}`)
// 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다.
if (!force) {
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${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}`)
// 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) 가 시스템 전역으로 설치돼 있으면 그걸 사용
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. 최후 수단: 새로 다운로드해서 시도
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 의 메타데이터를 가져온다.
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
*/
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
const bin = await ensureYtDlp()
return new Promise((resolve, reject) => {
const child = spawn(bin, [
'--dump-json',
'--no-warnings',
'--no-playlist',
'--skip-download',
url
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
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)
}
})
})
const stdout = await runYtDlp(
['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
(code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
)
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
if (!line) return null
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) return null
return {
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}`
}
}
/**
@@ -288,47 +327,31 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
*/
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
const bin = await ensureYtDlp()
return new Promise((resolve, reject) => {
const child = spawn(bin, [
'--flat-playlist',
'--dump-json',
'--no-warnings',
url
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
const parsed: YtPlaylistEntry[] = []
for (const line of lines) {
try {
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) continue
parsed.push({
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.url === 'string' && obj.url.length > 0
? obj.url
: `https://www.youtube.com/watch?v=${id}`
})
} catch {
// 한 줄이 깨져도 나머지는 살림
}
}
resolve(parsed)
})
})
const stdout = await runYtDlp(
['--flat-playlist', '--dump-json', '--no-warnings', url],
(code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))
)
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
const parsed: YtPlaylistEntry[] = []
for (const line of lines) {
try {
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) continue
parsed.push({
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.url === 'string' && obj.url.length > 0
? obj.url
: `https://www.youtube.com/watch?v=${id}`
})
} catch {
// 한 줄이 깨져도 나머지는 살림
}
}
return parsed
}