Files
minecraft_launcher/src/installer-rp/music.ts
claude-bot 9abf6da5f1 fix(rp): 노래 403 개선 — 플레이어 클라이언트 다변화 + 곡별 지연 재시도
v0.4.12 의 yt-dlp 내부 재시도(--retries)로도 남는 403(특정 클라이언트/스트림 URL
차단, 예: android_vr)에 대응:
- yt-dlp 에 --extractor-args youtube:player_client=default,web_safari,tv,ios 추가.
  한 클라이언트의 URL 이 403 이어도 다른 클라이언트 포맷으로 받는다(클라이언트명
  유효성은 실제 yt-dlp 로 검증).
- 실패한 곡의 순차 재시도를 1회 → 최대 3회로, 시도마다 점점 더(최대 20s) 쉬어
  유튜브 rate-limit 이 풀릴 시간을 준다. 0.4.13→0.4.14.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 00:45:33 +09:00

115 lines
4.4 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
export interface DownloadMusicOptions {
ytdlpExe: string
ffmpegExe: string
/** %appdata%/.mc_custom/.temp/ 같은 작업 폴더. */
tempDir: string
/** 1부터 시작하는 곡 번호 (파일명 zero-pad 에 사용). */
index: number
/** 유튜브 영상 주소. */
url: string
log?: (line: string) => void
/** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */
onChild?: (child: ChildProcess) => void
/** yt-dlp 의 다운로드 퍼센트 (0~100). 변환 단계는 별도. */
onProgress?: (percent: number) => void
}
/**
* yt-dlp 로 유튜브 영상에서 오디오만 추출해 vorbis(.ogg) 로 변환한다.
* 결과 파일 경로를 돌려준다. 실패하면 reject.
*
* 호출자는 onChild 콜백으로 받은 ChildProcess 에 .kill() 을 호출해 취소할 수 있다.
*/
export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string> {
const padded = String(opts.index).padStart(2, '0')
const outBase = path.join(opts.tempDir, padded)
const outPath = outBase + '.ogg'
return new Promise((resolve, reject) => {
const args = [
'--no-warnings',
'--no-playlist',
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
// 일반 progressive 다운로드에는 영향 없음.
'--concurrent-fragments', '5',
// 유튜브가 미디어 스트림에 간헐적으로 403(Forbidden) 을 준다(특히 두 번째 곡부터
// throttle/클라이언트 이슈). yt-dlp 가 스스로 재시도·재추출하도록 해서 한 번의
// 403 으로 곡 전체가 실패하지 않게 한다.
'--retries', '10',
'--fragment-retries', '10',
'--extractor-retries', '3',
'--retry-sleep', 'http:exp=1:30',
'--socket-timeout', '30',
// 특정 플레이어 클라이언트(예: android_vr)의 스트림 URL 이 403 되는 경우가 있어
// 여러 클라이언트를 함께 시도한다. 하나가 막혀도 다른 클라이언트 포맷으로 받는다.
'--extractor-args', 'youtube:player_client=default,web_safari,tv,ios',
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
'--newline',
'--extract-audio',
'--audio-format', 'vorbis',
'--audio-quality', '0',
'--ffmpeg-location', opts.ffmpegExe,
'-o', outBase + '.%(ext)s',
opts.url
]
const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] })
opts.onChild?.(child)
let stderr = ''
let stdoutBuf = ''
let lastReportedPct = -1
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString('utf8')
// yt-dlp 는 `[download] 3.3% of 3.72MiB at ...` 형식으로
// \r 로 같은 줄을 갱신한다. \r 과 \n 을 모두 split 해서 마지막 진행률을 뽑는다.
const lines = stdoutBuf.split(/[\r\n]/)
stdoutBuf = lines.pop() ?? ''
for (const raw of lines) {
const line = raw.trimEnd()
if (!line) continue
opts.log?.(t('log.ytdlpLine', { line }))
const m = line.match(/\[download\]\s+([\d.]+)%/)
if (m) {
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
// 너무 잦은 이벤트를 피하기 위해 1% 단위로만 전달.
if (Math.floor(pct) !== lastReportedPct) {
lastReportedPct = Math.floor(pct)
opts.onProgress?.(pct)
}
}
}
})
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
child.on('error', (err) => reject(err))
child.on('close', async (code, signal) => {
if (signal) {
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
return
}
if (code !== 0) {
reject(new Error(
t('errors.ytdlpExit', {
code: code ?? '',
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
})
))
return
}
// .ogg 가 실제로 생성됐는지 확인
try {
await fs.access(outPath)
resolve(outPath)
} catch {
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
}
})
})
}