i18n: 리소스팩 설치기 UI 문구를 locales/installer-rp/ko-kr.json 으로 분리

- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행
  메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체
- preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가
- 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의
  extraResources 에 locales/ 폴더 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 04:00:31 +09:00
parent 135bc98840
commit 6cd402121b
10 changed files with 314 additions and 101 deletions

View File

@@ -4,6 +4,9 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
@@ -31,7 +34,7 @@ export async function ensureFfmpegExe(
): Promise<string> {
const target = getFfmpegExePath()
if (await canExecute(target)) {
log?.(`ffmpeg.exe 이미 있음: ${target}`)
log?.(t('log.ffmpegExists', { path: target }))
return target
}
if (installPromise) return installPromise
@@ -46,14 +49,14 @@ export async function ensureFfmpegExe(
await fs.rm(zipPath, { force: true })
await fs.rm(extractDir, { recursive: true, force: true })
log?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`)
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
log?.('ffmpeg zip 압축 해제 중…')
log?.(t('log.ffmpegExtracting'))
await extractZip(zipPath, { dir: extractDir })
const found = await findFile(extractDir, 'ffmpeg.exe')
if (!found) {
throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.')
throw new Error(t('errors.ffmpegNotInZip'))
}
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
try {
@@ -63,14 +66,15 @@ export async function ensureFfmpegExe(
}
const ok = await probeVersion(target)
if (!ok) throw new Error('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
log?.(`ffmpeg.exe 준비 완료: ${target}`)
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
log?.(t('log.ffmpegReady', { path: target }))
return target
} catch (err) {
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
'ffmpeg.exe 자동 설치 실패: ' +
(err instanceof Error ? err.message : String(err))
t('errors.ffmpegInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
// 임시 파일/폴더 정리
@@ -114,7 +118,7 @@ async function findFile(root: string, name: string): Promise<string | null> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http

View File

@@ -4,6 +4,9 @@ import http from 'node:http'
import https from 'node:https'
import { URL } from 'node:url'
import sharp from 'sharp'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
const MAX_SIDE = 1024
@@ -30,7 +33,7 @@ export function ytIdFromUrl(url: string): string {
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const target = new URL(url)
@@ -56,7 +59,7 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', reject)
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
})
}
@@ -91,7 +94,7 @@ export async function normalizeToCover(buffer: Buffer, outPath: string): Promise
const meta = await img.metadata()
const w = meta.width ?? 0
const h = meta.height ?? 0
if (w <= 0 || h <= 0) throw new Error('이미지 크기를 읽지 못함')
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
const s = Math.min(w, h)
const left = Math.floor((w - s) / 2)
const top = Math.floor((h - s) / 2)

View File

@@ -11,6 +11,7 @@ import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js'
import type { RpFetchedPack } from './types.js'
import { ensureYtDlpExe } from './ytdlp.js'
import { ensureFfmpegExe } from './ffmpeg.js'
@@ -19,6 +20,9 @@ import { downloadImage, normalizeToCover, coverFileName } from './images.js'
import { buildResourcepackZip } from './pack.js'
loadEnv()
const i18n = loadComponentI18n('installer-rp')
const t = i18n.t
export const localeDict = i18n.dict
interface RpInstallerState {
manifestUrl: string
@@ -154,7 +158,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
response.on('end', () => resolve(Buffer.concat(chunks)))
})
request.on('error', reject)
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
})
}
@@ -169,7 +173,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
state.manifestUrl = manifestUrlInput
state.baseUrl = deriveBaseUrl(manifestUrlInput)
}
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
const manifest = await fetchJson<Manifest>(state.manifestUrl)
const results: RpFetchedPack[] = []
for (const entry of manifest.packs ?? []) {
@@ -181,7 +185,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
const [listRaw, packRaw] = await Promise.all([
fetchJson<Partial<PackList>>(listUrl),
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
sendLog(`팩 정의 로드 실패 (${entry.file}): ${(err as Error).message} — mcVersion 폴백`)
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
return null
})
])
@@ -202,31 +206,37 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
list
})
} catch (error) {
sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
sendLog(t('log.listLoadFailed', { file: entry.file, message: (error as Error).message }))
}
}
state.packs.clear()
for (const item of results) state.packs.set(item.key, item)
sendLog(`로드된 음악퀴즈: ${results.length}`)
sendLog(t('log.packsLoaded', { count: results.length }))
for (const item of results) {
sendLog(` - ${item.key}: mc=${item.mcVersion || '?'} 베이스=${item.resourcepackPath || '(없음)'}`)
sendLog(t('log.packEntry', {
key: item.key,
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
base: item.resourcepackPath || t('log.packEntryNoBase')
}))
}
return results
})
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
throw new Error(t('errors.selectedPackNotFound'))
}
state.selectedKey = packKey
sendLog(`선택: ${packKey}`)
sendLog(t('log.selectedPack', { key: packKey }))
})
ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 2단계 설치 ──────────────────────────────────
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
const pack = state.packs.get(state.selectedKey)
if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
if (!pack) throw new Error(t('errors.currentPackNotFound'))
state.cancelRequested = false
const tempRoot = path.join(getMcCustomDir(), '.temp')
@@ -237,16 +247,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
try {
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog('yt-dlp 준비 중…')
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled()
sendLog('ffmpeg 준비 중…')
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled()
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
@@ -256,8 +266,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const cpuCount = os.cpus()?.length ?? 0
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
nextMusicStartAt = Date.now()
sendLog(`CPU 코어 ${cpuCount}개 감지 → 동시 다운로드 ${concurrency}`)
sendLog(`음악 다운로드 시작 (${musicTotal}곡, 동시 ${concurrency}개, 시차 ${MUSIC_START_STAGGER_MS}ms)`)
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music
@@ -272,7 +282,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
if (state.cancelRequested) return
const entry = musicList[i]
const idx = i + 1
sendLog(`${idx}번 노래 다운로드 시작`)
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
@@ -296,16 +306,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
}
})
if (child) state.activeChildren.delete(child)
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
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: '취소됨' })
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
}
}
@@ -319,19 +329,19 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
sendLog(t('log.imageStart', { total: imageTotal }))
for (let i = 0; i < imageTotal; i++) {
throwIfCancelled()
const entry = pack.list.images[i]
const idx = i + 1
sendLog(`${idx}번 사진 다운로드 중…`)
sendLog(t('log.imageDownloading', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
let buf: Buffer
try {
buf = await downloadImage(entry.url)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 사진 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
}
throwIfCancelled()
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
@@ -340,9 +350,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await normalizeToCover(buf, outPath)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
}
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
}
@@ -354,18 +364,18 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
baseZipPath = path.join(tempRoot, 'base.zip')
sendLog(`베이스 리소스팩 다운로드: ${cleaned}`)
sendLog(` URL: ${baseUrl}`)
sendProgress({ phase: 'package', message: '베이스 리소스팩 다운로드 중' })
sendLog(t('log.baseDownload', { path: cleaned }))
sendLog(t('log.baseUrl', { url: baseUrl }))
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
try {
const buf = await fetchBuffer(baseUrl)
await fsp.writeFile(baseZipPath, buf)
sendLog(`베이스 리소스팩 받음 (${(buf.length / 1024).toFixed(1)} KB)`)
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
} catch (err) {
throw new Error(`베이스 리소스팩 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
}
} else {
sendLog('베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성')
sendLog(t('log.baseAbsent'))
}
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
@@ -373,8 +383,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
sendProgress({ phase: 'package', message: baseZipPath ? '베이스에 음악·사진 추가 중' : 'zip 빌드 중' })
sendLog(t('log.buildingZip', { name: resourcepackName }))
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
await buildResourcepackZip({
musicDir,
paintingDir,
@@ -387,8 +397,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
})
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(`설치 완료: ${resourcepackPath}`)
sendProgress({ phase: 'package', message: '설치 완료', done: true })
sendLog(t('log.installComplete', { path: resourcepackPath }))
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
return { resourcepackPath }
} finally {
// 임시 파일 정리
@@ -398,7 +408,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true
sendLog(`취소 요청됨. 실행 중 프로세스 ${state.activeChildren.size}개 중단…`)
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
for (const child of state.activeChildren) {
if (!child.killed) child.kill()
}
@@ -406,7 +416,7 @@ ipcMain.handle('rp:install:cancel', async () => {
function throwIfCancelled(): void {
if (state.cancelRequested) {
throw new Error('사용자가 설치를 취소했습니다.')
throw new Error(t('errors.cancelledByUser'))
}
}

View File

@@ -1,6 +1,9 @@
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
@@ -58,7 +61,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
for (const raw of lines) {
const line = raw.trimEnd()
if (!line) continue
opts.log?.(`yt-dlp> ${line}`)
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])))
@@ -76,11 +79,16 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
child.on('error', (err) => reject(err))
child.on('close', async (code, signal) => {
if (signal) {
reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`))
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
return
}
if (code !== 0) {
reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`))
reject(new Error(
t('errors.ytdlpExit', {
code: code ?? '',
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
})
))
return
}
// .ogg 가 실제로 생성됐는지 확인
@@ -88,7 +96,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
await fs.access(outPath)
resolve(outPath)
} catch {
reject(new Error(`예상 출력파일이 없음: ${outPath}`))
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
}
})
})

View File

@@ -3,6 +3,9 @@ import path from 'node:path'
import archiver from 'archiver'
import extract from 'extract-zip'
import { resolveResourcePackFormat } from './packFormat.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
const NAMESPACE = 'musicquiz'
@@ -45,7 +48,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
if (opts.baseZipPath) {
opts.log?.(`베이스 리소스팩 압축 해제: ${path.basename(opts.baseZipPath)}`)
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
await extract(opts.baseZipPath, { dir: root })
}
@@ -57,13 +60,13 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
const resolved = resolveResourcePackFormat(opts.mcVersion)
if (resolved.matched) {
opts.log?.(`pack_format = ${resolved.format} (mcVersion ${resolved.matched})`)
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
} else {
opts.log?.(`pack_format = ${resolved.format} (mcVersion "${opts.mcVersion}" 매칭 실패, 최신 폴백)`)
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
}
const mcmeta = {
pack: {
description: `음악퀴즈 리소스팩 - ${opts.packName}`,
description: t('pack.description', { name: opts.packName }),
pack_format: resolved.format,
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
}
@@ -82,7 +85,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
const parsed = JSON.parse(existing)
if (parsed && typeof parsed === 'object') {
soundsJson = parsed as Record<string, unknown>
opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`)
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
}
} catch {
// 없으면 새로 생성.

View File

@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
import type { RpFetchedPack } from './types.js'
const api = {
/** i18n 사전을 렌더러에 전달. */
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('rp:i18n:dict'),
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
ipcRenderer.invoke('rp:packs:load', manifestUrl),

View File

@@ -4,6 +4,9 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
@@ -27,7 +30,7 @@ export async function ensureYtDlpExe(
): Promise<string> {
const target = getYtDlpExePath()
if (await canExecute(target)) {
log?.(`yt-dlp.exe 이미 있음: ${target}`)
log?.(t('log.ytdlpExists', { path: target }))
return target
}
if (installPromise) return installPromise
@@ -35,20 +38,21 @@ export async function ensureYtDlpExe(
installPromise = (async () => {
try {
await fs.mkdir(path.dirname(target), { recursive: true })
log?.(`yt-dlp.exe 다운로드 중: ${YT_DLP_DOWNLOAD_URL}`)
log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL }))
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new Error('yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
throw new Error(t('errors.ytdlpVerifyFailed'))
}
log?.(`yt-dlp.exe 준비 완료: ${target}`)
log?.(t('log.ytdlpReady', { path: target }))
return target
} catch (err) {
// 부분 다운로드 흔적 정리
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
'yt-dlp.exe 자동 설치 실패: ' +
(err instanceof Error ? err.message : String(err))
t('errors.ytdlpInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
installPromise = null
@@ -80,7 +84,7 @@ function probeVersion(bin: string): Promise<boolean> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http