diff --git a/locales/installer-rp/ko-kr.json b/locales/installer-rp/ko-kr.json index ccf8a24..e73d1df 100644 --- a/locales/installer-rp/ko-kr.json +++ b/locales/installer-rp/ko-kr.json @@ -82,6 +82,9 @@ "musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)", "musicTrackStart": "{{idx}}번 노래 다운로드 시작", "musicTrackDone": "{{idx}}번 노래 완료: {{name}}", + "musicRetryAfterRefresh": "{{idx}}번 노래 실패({{message}}) → yt-dlp/ffmpeg 최신 버전으로 재설치 후 재시도", + "ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…", + "ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…", "imageStart": "사진 다운로드 시작 ({{total}}장)", "imageDownloading": "{{idx}}번 사진 다운로드 중…", "imageDone": "{{idx}}번 사진 완료: {{name}}", diff --git a/src/installer-rp/ffmpeg.ts b/src/installer-rp/ffmpeg.ts index 6274fbd..7340ccb 100644 --- a/src/installer-rp/ffmpeg.ts +++ b/src/installer-rp/ffmpeg.ts @@ -50,14 +50,20 @@ let installPromise: Promise | null = null * ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다. */ export async function ensureFfmpegExe( - log?: (line: string) => void + log?: (line: string) => void, + force = false ): Promise { const target = getFfmpegExePath() await migrateLegacyExe(target) - if (await canExecute(target)) { + if (!force && await canExecute(target)) { log?.(t('log.ffmpegExists', { path: target })) return target } + if (force) { + // 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다. + log?.(t('log.ffmpegReinstall')) + try { await fs.unlink(target) } catch { /* noop */ } + } if (installPromise) return installPromise installPromise = (async () => { diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index ecf7a6f..c48a24c 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -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) sendLog(t('log.ytdlpPreparing')) sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') }) - const ytDlpBin = await ensureYtDlpExe(sendLog) + let ytDlpBin = await ensureYtDlpExe(sendLog) sendLog(t('log.ytdlpPath', { path: ytDlpBin })) throwIfCancelled() sendLog(t('log.ffmpegPreparing')) sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') }) - const ffmpegBin = await ensureFfmpegExe(sendLog) + let ffmpegBin = await ensureFfmpegExe(sendLog) sendLog(t('log.ffmpegPath', { path: ffmpegBin })) sendProgress({ phase: 'prep', message: t('progress.ready'), done: true }) throwIfCancelled() + // 음악 다운로드가 실패하면 yt-dlp/ffmpeg 가 너무 오래된 버전이라 유튜브 변경을 + // 못 따라가는 경우일 수 있다. 그때 최신 버전으로 한 번만 강제 재설치한다. + // 워커 여러 개가 동시에 실패해도 재설치는 단 한 번만 일어나도록 락으로 직렬화. + let binRefreshPromise: Promise | null = null + async function refreshBinariesOnce(): Promise { + if (!binRefreshPromise) { + binRefreshPromise = (async () => { + ytDlpBin = await ensureYtDlpExe(sendLog, true) + ffmpegBin = await ensureFfmpegExe(sendLog, true) + })() + } + await binRefreshPromise + } + // 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환) const musicDir = path.join(tempRoot, 'music') await fsp.mkdir(musicDir, { recursive: true }) @@ -346,38 +360,51 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string const idx = i + 1 sendLog(t('log.musicTrackStart', { idx })) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' }) - let child: ChildProcess | null = null - try { - const outPath = await downloadMusicTrack({ - ytdlpExe: ytDlpBin, - ffmpegExe: ffmpegBin, - tempDir: musicDir, - index: idx, - url: entry.url, - log: sendLog, - onChild: (c) => { - child = c - state.activeChildren.add(c) - }, - onProgress: (pct) => { - // 다운로드(0~90%) + 변환(90~100%) 으로 매핑. - sendProgress({ - phase: 'item', kind: 'music', index: idx, total: musicTotal, - percent: Math.min(90, pct * 0.9), status: 'running' - }) + // 한 곡당 최대 2회 시도: 1차 실패 시 yt-dlp/ffmpeg 를 최신으로 강제 + // 재설치(전역 1회)한 뒤 같은 곡을 다시 받아본다. + let attemptedRefresh = false + while (true) { + let child: ChildProcess | null = null + try { + const outPath = await downloadMusicTrack({ + ytdlpExe: ytDlpBin, + ffmpegExe: ffmpegBin, + tempDir: musicDir, + index: idx, + url: entry.url, + log: sendLog, + onChild: (c) => { + child = c + state.activeChildren.add(c) + }, + 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 (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' }) - } 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) { + attemptedRefresh = true + sendLog(t('log.musicRetryAfterRefresh', { idx, message: (err as Error).message })) + await refreshBinariesOnce() + if (state.cancelRequested) return + continue + } + 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 })) } - 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 })) } } } diff --git a/src/installer-rp/ytdlp.ts b/src/installer-rp/ytdlp.ts index fb2e220..f9e2758 100644 --- a/src/installer-rp/ytdlp.ts +++ b/src/installer-rp/ytdlp.ts @@ -47,14 +47,20 @@ let installPromise: Promise | null = null * 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다. */ export async function ensureYtDlpExe( - log?: (line: string) => void + log?: (line: string) => void, + force = false ): Promise { const target = getYtDlpExePath() await migrateLegacyExe(target) - if (await canExecute(target)) { + if (!force && await canExecute(target)) { log?.(t('log.ytdlpExists', { path: target })) return target } + if (force) { + // 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다. + log?.(t('log.ytdlpReinstall')) + try { await fs.unlink(target) } catch { /* noop */ } + } if (installPromise) return installPromise installPromise = (async () => { diff --git a/src/server/youtube.ts b/src/server/youtube.ts index 02c7831..2978e74 100644 --- a/src/server/youtube.ts +++ b/src/server/youtube.ts @@ -62,25 +62,33 @@ type ProbeResult = { ok: true } | { ok: false; detail: string } * 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요 * 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다. */ -export async function ensureYtDlp(): Promise { +export async function ensureYtDlp(force = false): Promise { 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 { return installPromise } -async function prepareYtDlp(target: string): Promise { +async function prepareYtDlp(target: string, force = false): Promise { 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 }) } +/** 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 + 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 { + 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 { - 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 - 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 + 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 { - 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 - 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 + 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 }