Compare commits
4 Commits
v0.3.4
...
8c9dc88e8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c9dc88e8b | |||
| b769f453a3 | |||
| 5c13648f63 | |||
| 9efd4a696a |
@@ -106,6 +106,8 @@
|
||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
||||
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||
"tracksAdded": "음악 트랙 추가됨: {{count}}곡",
|
||||
"paintingsAdded": "사진 텍스처 추가됨: {{count}}장",
|
||||
"ytdlpLine": "yt-dlp> {{line}}"
|
||||
},
|
||||
"progress": {
|
||||
@@ -139,6 +141,8 @@
|
||||
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
|
||||
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
|
||||
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
|
||||
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}",
|
||||
"baseTrackCollision": "베이스 리소스팩에 같은 트랙 ID 가 이미 있어 설치를 중단합니다: {{trackId}}\n베이스 자산을 보존하면서 새 트랙을 같은 ID 로 추가할 수 없습니다. 베이스의 sounds.json 엔트리/sounds 폴더에서 충돌하는 항목을 제거하거나 다른 베이스를 사용하세요.",
|
||||
"basePaintingCollision": "베이스 리소스팩에 같은 사진 파일이 이미 있어 설치를 중단합니다: {{name}}\n베이스의 painting 텍스처를 보존하면서 같은 파일명을 추가할 수 없습니다. 베이스에서 충돌하는 파일을 제거하거나 다른 베이스를 사용하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
"youtube": {
|
||||
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
|
||||
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ytdlpVerifyFailedDetail": "yt-dlp 를 사용할 수 없습니다. 시도한 경로 진단: {{detail}}",
|
||||
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
|
||||
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
|
||||
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -131,6 +131,10 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
||||
|
||||
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
||||
// 핵심 정책: 베이스 리소스팩에 이미 있는 자산은 절대 덮어쓰지 않는다.
|
||||
// - 베이스 sounds.json 의 엔트리는 그대로 보존하고, 우리 트랙은 그 위에 "추가" 만 한다.
|
||||
// - 베이스 sounds/track_NN.ogg 가 이미 있으면 덮어쓰지 않고 건너뛴다.
|
||||
// - 키나 파일명이 충돌하면 우리 트랙을 스킵하고 로그로 알린다.
|
||||
const musicFiles = (await fs.readdir(opts.musicDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
||||
.sort()
|
||||
@@ -152,7 +156,19 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
||||
const stem = path.basename(fname, path.extname(fname)) // "01"
|
||||
const trackId = `track_${stem}`
|
||||
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`))
|
||||
const destFile = path.join(soundsDir, `${trackId}.ogg`)
|
||||
// 베이스에 같은 trackId 의 엔트리/파일이 있으면 두 선택지 다 깨진다:
|
||||
// (a) 덮어쓰면 베이스의 기존 곡이 사라지고,
|
||||
// (b) 새 곡을 스킵하면 데이터팩이 가리키는 곡이 빠진 채로 설치된다.
|
||||
// 안전하게 설치를 즉시 실패시키고 어떤 키가 충돌했는지 알린다.
|
||||
let collides = soundsJson[trackId] !== undefined
|
||||
if (!collides) {
|
||||
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
|
||||
}
|
||||
if (collides) {
|
||||
throw new Error(t('errors.baseTrackCollision', { trackId }))
|
||||
}
|
||||
await fs.copyFile(path.join(opts.musicDir, fname), destFile)
|
||||
soundsJson[trackId] = {
|
||||
sounds: [
|
||||
{ name: `${NAMESPACE}:${trackId}`, stream: true }
|
||||
@@ -160,16 +176,25 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
}
|
||||
}
|
||||
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
||||
opts.log?.(t('log.tracksAdded', { count: musicFiles.length }))
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태).
|
||||
// 음악과 동일한 정책: 베이스에 같은 파일명이 이미 있으면 설치를 실패시킨다.
|
||||
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.png'))
|
||||
.sort()
|
||||
for (const fname of paintingFiles) {
|
||||
throwIfCancelled(cancel)
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
|
||||
const destFile = path.join(paintingOutDir, fname)
|
||||
let collides = false
|
||||
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
|
||||
if (collides) {
|
||||
throw new Error(t('errors.basePaintingCollision', { name: fname }))
|
||||
}
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), destFile)
|
||||
}
|
||||
opts.log?.(t('log.paintingsAdded', { count: paintingFiles.length }))
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
|
||||
|
||||
@@ -40,40 +40,25 @@ export function getYtDlpInstallPath(): string {
|
||||
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
type ProbeResult = { ok: true } | { ok: false; detail: string }
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
||||
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
||||
* 다운로드된 바이너리가 실행되지 않는 환경(antivirus, 권한 문제 등)이라면
|
||||
* PATH 의 `yt-dlp(.exe)` 로 폴백하고, 그것도 실패하면 진단정보가 포함된 에러를 던진다.
|
||||
*/
|
||||
export async function ensureYtDlp(): Promise<string> {
|
||||
const target = getYtDlpInstallPath()
|
||||
// 이미 설치돼 있고 실행 가능하면 그대로 사용
|
||||
if (await canExecute(target)) return target
|
||||
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
|
||||
if (await fileExists(target)) {
|
||||
const probe = await probeVersion(target)
|
||||
if (probe.ok) return target
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
const dir = getMcCustomDir()
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const asset = getYtDlpAssetName()
|
||||
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||
await downloadToFile(url, target)
|
||||
// POSIX 계열은 실행 권한 부여
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(target, 0o755)
|
||||
}
|
||||
// 검증
|
||||
const okVersion = await probeVersion(target)
|
||||
if (!okVersion) {
|
||||
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
|
||||
}
|
||||
return target
|
||||
} catch (err) {
|
||||
// 실패 흔적(부분 다운로드) 삭제
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw err instanceof YtDlpUnavailableError
|
||||
? err
|
||||
: new YtDlpUnavailableError(
|
||||
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
|
||||
)
|
||||
return await prepareYtDlp(target)
|
||||
} finally {
|
||||
installPromise = null
|
||||
}
|
||||
@@ -81,31 +66,89 @@ export async function ensureYtDlp(): Promise<string> {
|
||||
return installPromise
|
||||
}
|
||||
|
||||
async function canExecute(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.F_OK)
|
||||
} catch {
|
||||
return false
|
||||
async function prepareYtDlp(target: string): Promise<string> {
|
||||
const diagnostics: string[] = []
|
||||
|
||||
// 1. 기존 파일이 있으면 우선 그걸로 시도
|
||||
if (await fileExists(target)) {
|
||||
const probe = await probeVersion(target)
|
||||
if (probe.ok) return target
|
||||
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
|
||||
}
|
||||
// POSIX 면 X 비트도 확인
|
||||
|
||||
// 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 {
|
||||
await fs.mkdir(getMcCustomDir(), { recursive: true })
|
||||
const asset = getYtDlpAssetName()
|
||||
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
await downloadToFile(url, target)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.X_OK)
|
||||
} catch {
|
||||
return false
|
||||
await fs.chmod(target, 0o755)
|
||||
} else {
|
||||
// Windows: 인터넷에서 받은 파일에는 NTFS ADS 'Zone.Identifier' 가 붙어
|
||||
// SmartScreen/Attachment Manager 가 실행을 막을 수 있다. 베스트에포트로 제거.
|
||||
try { await fs.unlink(`${target}:Zone.Identifier`) } catch { /* noop */ }
|
||||
}
|
||||
const probe = await probeVersion(target)
|
||||
if (probe.ok) return target
|
||||
diagnostics.push(`새로 받은 ${asset} 검증 실패: ${probe.detail}`)
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
} catch (err) {
|
||||
diagnostics.push(`다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
}
|
||||
// 실제로 --version 으로 한 번 더 확인
|
||||
return probeVersion(filePath)
|
||||
|
||||
throw new YtDlpUnavailableError(
|
||||
t('youtube.ytdlpVerifyFailedDetail', { detail: diagnostics.join(' | ') })
|
||||
)
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<boolean> {
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let ok = false
|
||||
child.stdout.on('data', () => { ok = true })
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => resolve(ok && code === 0))
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
||||
} catch (err) {
|
||||
resolve({ ok: false, detail: `spawn throw: ${err instanceof Error ? err.message : String(err)}` })
|
||||
return
|
||||
}
|
||||
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: NodeJS.ErrnoException) => {
|
||||
const code = err.code ? `${err.code} ` : ''
|
||||
resolve({ ok: false, detail: `spawn error: ${code}${err.message}` })
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
const out = stdout.trim()
|
||||
if (out && code === 0) {
|
||||
resolve({ ok: true })
|
||||
return
|
||||
}
|
||||
const parts: string[] = []
|
||||
parts.push(`exit=${code === null ? `signal:${signal}` : code}`)
|
||||
if (!out) parts.push('stdout=(empty)')
|
||||
const errLine = stderr.trim().split('\n')[0]
|
||||
if (errLine) parts.push(`stderr="${errLine.slice(0, 200)}"`)
|
||||
resolve({ ok: false, detail: parts.join(', ') })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user