19 Commits

Author SHA1 Message Date
f6df5f936c installer-rp: retry image download on HTTP 429/5xx with backoff
i.ytimg.com 썸네일 서버가 연속 요청을 속도제한(HTTP 429)하면 사진
다운로드가 즉시 실패해 전체 설치가 중단됐다. 일시적 상태코드
(408/425/429/5xx)와 네트워크 오류를 Retry-After 우선 + 지수 백오프(jitter)로
최대 5회 재시도하도록 fetchBuffer 를 보강. v0.3.9.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:36:30 +09:00
dfb7acba2f installer: use 128x128 launcher profile icon (correct spec)
마인크래프트 런처 사용자 지정 설치 아이콘 규격은 128x128 PNG 고정이다
(minecraft.wiki/w/Launcher). 64x64/256x256 등 규격과 다른 크기는 런처가
무시하고 기본 아이콘(화로)으로 폴백한다. ICON_SIZE 를 128 로 맞춰 음악
아이콘이 실제로 표시되게 한다. v0.3.8.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:04:34 +09:00
f4c9504c1a installer: shrink launcher profile icon to 64x64 data URL
256x256(~44KB base64) 아이콘은 일부 마인크래프트 런처에서 렌더링되지 않고
기본 아이콘(화로)으로 폴백한다. 프로필 아이콘은 작은 타일로 표시되므로
sharp 로 64x64(~8KB base64) 로 다운스케일해 안정적으로 표시되게 한다.
exe 아이콘(build/icon.*)은 256x256 그대로 유지. v0.3.7.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 22:54:48 +09:00
60a52a9bec installer-rp: delete partial artifacts on failure; bump to 0.3.6
Resume previously skipped any track/cover whose file merely existed, so a
partially written NN.ogg or cover_NN.png from a failed download/convert
could be mistaken for a finished file on the next attempt. Now the
failure path removes the expected output before bailing, so only fully
completed artifacts are skipped on resume.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:30:45 +09:00
fe0d2f75e3 installer-rp: add resume-on-retry and discard-on-quit for failed installs
On install failure the temp folder is now preserved instead of wiped, so
already-downloaded songs/images are skipped on the next attempt. The
error screen offers 재시도 (resume from the failed item) and 처음으로
(discard the partial download and restart). Closing the program without
retrying still wipes the partial download via window-all-closed, and an
explicit cancel also clears it.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:23:34 +09:00
399f4af808 installer-rp: defer yt-dlp/ffmpeg reinstall until all music workers finish
Avoid the Windows file-lock race where one worker deletes/overwrites
yt-dlp.exe/ffmpeg.exe while sibling workers still run those processes.
Now pass 1 downloads all tracks and collects failures without any
mid-flight refresh; after Promise.all (no live child processes), the
binaries are force-reinstalled once and only the failed tracks retry.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:18:12 +09:00
d5f88e0e76 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회 재시도.
2026-06-05 16:08:26 +09:00
d9ba2b0f35 installer-rp: decode data: URL images instead of crashing
사진 URL 에 data: URI 가 들어오면 http/https 만 처리하는 다운로더가
'Protocol "data:" not supported' 로 설치 전체를 중단시키던 문제 수정.
data: URL 은 이미지 바이트를 직접 품고 있으므로 base64/percent-encoding
을 디코드해 Buffer 로 바로 반환한다. 잘못된 형식은 명확한 메시지로 거절.
2026-06-05 15:58:22 +09:00
3baf84cfd1 op: emit painting_variant author/title as plain strings
이미지 zip 의 cover_NN.json 이 title/author 를 {text:...} 객체로 내보내
일부 환경에서 인식되지 않던 문제. 요청 형식대로 author:"musicquiz",
title:"cover_NN" 평문 문자열로 바꿔 asset_id/width/height 뒤에 배치한다.
2026-06-05 15:51:59 +09:00
d22c6f17a3 store: rename file/list JSON when pack key changes
renamePack 가 manifest 정의와 약관 폴더는 새 키로 옮기면서 정작 음악·사진
목록(file/list/<key>.json)은 옛 키 파일에 남겨, 이름 변경 후 목록이 비어
보이던 버그 수정. 약관 폴더와 동일하게 fsp.rename 으로 옮기고 옛 파일이
없으면(ENOENT) 무시한다.
2026-06-05 01:57:30 +09:00
0629aa54aa datapack: emit volume:1.0 default on every SNBT entry
운영자가 곡별로 /playsound 음량을 빠르게 조정할 수 있도록
launcher 가 생성하는 모든 SNBT 항목에 volume:1.0 기본값을 항상 넣는다.
주석의 예시도 volume:1.0 으로 통일.
2026-05-28 00:37:37 +09:00
201043e289 datapack: include description in SNBT entry output
entrySnbt() now emits {title, author, alias, description} so the
mcfunction export carries the operator-entered song description
into the data modify storage command. Multiline descriptions are
flattened via newline/tab escapes inside the SNBT string literal
(escapeSnbtString extended to handle \r, \n, \t alongside the
existing backslash + quote escapes) so each `data modify` stays a
single line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:28:37 +09:00
acd3dd995d list-editor: preserve aliases + description across URL edit
The url-edit modal's save handler was rebuilding state.music[idx]
from scratch using only meta-lookup fields, silently dropping aliases
and (newly added) description. Carry them over from prev so editing
a track's URL no longer wipes operator-entered metadata.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:48:55 +09:00
b4160aefc1 list-editor: add per-track description button + modal
Each music list row now shows a 설명 button immediately to the left of
the 별칭 button. Click opens a modal with a multi-line textarea; on
close the value is persisted into MusicListEntry.description and saved
to the same pack list JSON. The button gets a hasDesc visual indicator
when filled. Description is stored but intentionally not consumed by
datapack export or alias matching — purely informational metadata.

- types.ts: add description: string to MusicListEntry
- store.ts: normalize entry.description via sanitizeStr (defaults to '')
- listEditor.ejs: new #descModal alongside aliasModal
- listEditor.js: render descBtn left of aliasBtn, attach handlers,
  also set description: '' on playlist-fetched entries
- styles.css: extend trackRow grid to 6 cols, reuse aliasBtn styling
  for descBtn, add descTextarea sizing
- locale (ko-kr): descBtn / descModalTitle / descBack / descPlaceholder
  / descHint

Backwards-compatible: existing list JSON files without description
field normalize to ''.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:24 +09:00
1ac13a03ff server-youtube: fast-path reuse for cached zipapp
ensureYtDlp() and prepareYtDlp() now check the on-disk yt-dlp_zipapp
before re-running the native-fail -> network-download path. On Linux
servers where the native binary always fails verification (glibc/musl/
arch mismatch), every previous request was re-downloading both the
native (~33MB) and the zipapp (~3MB). With the fast path, after the
first successful zipapp install all subsequent requests short-circuit
to the cached zipapp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:39:10 +09:00
542f759585 chore: remove stray 0-byte garbage file from repo root
Accidentally tracked by the previous commit's git add -A. Untracked
artifact from shell-command mangling in an earlier smoke test, not
part of any feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:35:30 +09:00
3248d096e4 server-youtube: add POSIX zipapp fallback when native bundled binary won't run
If the native yt-dlp_linux/yt-dlp_macos binary fails to execute (glibc
mismatch, musl libc, wrong arch) AND no system yt-dlp is on PATH, fall
back to downloading the universal Python zipapp ('yt-dlp', ~3MB) and
running it via shebang. Requires python3 on PATH, which is standard on
modern Linux servers. Also: rewrite stale Windows-flavored install-path
comments to reflect actual cross-platform behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:35:11 +09:00
8c9dc88e8b server-youtube: strip Zone.Identifier ADS on Windows after download
NTFS marks files downloaded over HTTP with a Zone.Identifier alternate
data stream, which SmartScreen/Attachment Manager can use to block
execution of yt-dlp.exe. Remove the ADS best-effort after each
download to reduce one likely cause of "execution verification failed"
in the user-reported failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:29:27 +09:00
b769f453a3 server-youtube: diagnostic detail + PATH fallback when bundled yt-dlp won't run
probeVersion() now captures stderr/exit-code/signal/spawn-error instead of
returning a bare boolean, and ensureYtDlp() tries the bundled binary first,
falls back to `yt-dlp(.exe)` on PATH if the bundled one won't execute (AV
block, missing libc symbol, broken download), and only then re-downloads.
The final user-facing error includes the per-attempt diagnostics so we can
actually see WHY verification failed instead of the opaque
"yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다." message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:24:54 +09:00
19 changed files with 688 additions and 210 deletions

View File

@@ -485,10 +485,43 @@ function renderStep2() {
}).catch(function (err) { }).catch(function (err) {
state.installing = false state.installing = false
if (stopProgress) stopProgress() if (stopProgress) stopProgress()
if (!cancelInitiated) { if (cancelInitiated) {
alert(tt('common.installFailed', { message: (err && err.message) || err })) // 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로.
}
renderStep1() renderStep1()
return
}
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
showInstallError((err && err.message) || String(err))
})
}
// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다.
// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다.
function showInstallError(message) {
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
'<p class="formMessage error">' + escapeHtml(tt('install.errorMessage', { message: message })) + '</p>' +
'<p class="formMessage">' + escapeHtml(tt('install.resumeHint')) + '</p>' +
'<div class="actionRow">' +
' <button class="secondaryBtn" id="startOver">' + escapeHtml(tt('install.startOver')) + '</button>' +
' <button class="primaryBtn" id="retry">' + escapeHtml(tt('install.retry')) + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#retry').addEventListener('click', function () {
// 같은 음악퀴즈로 설치를 다시 시작. backend 가 받아둔 산출물을 건너뛴다.
renderStep2()
})
section.querySelector('#startOver').addEventListener('click', function () {
// 이어받지 않고 처음으로: 받아둔 임시 파일을 정리한 뒤 1단계로.
api.discardInstall().then(function () {
renderStep1()
}).catch(function () {
renderStep1()
})
}) })
} }

View File

@@ -65,6 +65,12 @@
"heading": "완료", "heading": "완료",
"message": "리소스팩 설치를 완료했습니다." "message": "리소스팩 설치를 완료했습니다."
}, },
"install": {
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}",
"resumeHint": "재시도를 누르면 이미 받아둔 음악·사진은 건너뛰고 실패한 지점부터 이어서 설치합니다. 처음으로를 누르거나 프로그램을 닫으면 지금까지 받아둔 파일은 삭제됩니다.",
"retry": "재시도",
"startOver": "처음으로"
},
"log": { "log": {
"manifestDownload": "manifest 다운로드: {{url}}", "manifestDownload": "manifest 다운로드: {{url}}",
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백", "packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
@@ -82,9 +88,14 @@
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)", "musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
"musicTrackStart": "{{idx}}번 노래 다운로드 시작", "musicTrackStart": "{{idx}}번 노래 다운로드 시작",
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}", "musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)",
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
"imageStart": "사진 다운로드 시작 ({{total}}장)", "imageStart": "사진 다운로드 시작 ({{total}}장)",
"imageDownloading": "{{idx}}번 사진 다운로드 중…", "imageDownloading": "{{idx}}번 사진 다운로드 중…",
"imageDone": "{{idx}}번 사진 완료: {{name}}", "imageDone": "{{idx}}번 사진 완료: {{name}}",
"imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)",
"baseDownload": "베이스 리소스팩 다운로드: {{path}}", "baseDownload": "베이스 리소스팩 다운로드: {{path}}",
"baseUrl": " URL: {{url}}", "baseUrl": " URL: {{url}}",
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)", "baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
@@ -137,6 +148,7 @@
"ytdlpNoStderr": "(stderr 없음)", "ytdlpNoStderr": "(stderr 없음)",
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}", "ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
"imageMetaUnknown": "이미지 크기를 읽지 못함", "imageMetaUnknown": "이미지 크기를 읽지 못함",
"imageDataUrlInvalid": "data: URL 형식이 올바르지 않아 이미지를 디코드하지 못했습니다.",
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.", "ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}", "ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.", "ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",

View File

@@ -78,6 +78,11 @@
"aliasPlaceholder": "별칭 입력", "aliasPlaceholder": "별칭 입력",
"aliasRemove": "삭제", "aliasRemove": "삭제",
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.", "aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
"descBtn": "설명",
"descModalTitle": "설명 - {{title}}",
"descBack": "← 돌아가기",
"descPlaceholder": "이 곡에 대한 설명을 입력하세요",
"descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.",
"metaLoading": "메타데이터 가져오는 중…", "metaLoading": "메타데이터 가져오는 중…",
"metaFailedShort": "메타 조회 실패", "metaFailedShort": "메타 조회 실패",
"metaFailedTitle": "메타데이터 조회 실패", "metaFailedTitle": "메타데이터 조회 실패",
@@ -220,6 +225,7 @@
"youtube": { "youtube": {
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)", "ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.", "ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpVerifyFailedDetail": "yt-dlp 를 사용할 수 없습니다. 시도한 경로 진단: {{detail}}",
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}", "ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}", "ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}", "ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.3.5", "version": "0.3.9",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {

View File

@@ -103,6 +103,7 @@
var aliasLabel = aliasCount > 0 var aliasLabel = aliasCount > 0
? tt('aliasBtnWithCount', { count: aliasCount }) ? tt('aliasBtnWithCount', { count: aliasCount })
: tt('aliasBtn') : tt('aliasBtn')
var hasDesc = typeof entry.description === 'string' && entry.description.trim().length > 0
li.innerHTML = li.innerHTML =
'<span class="rowNum">' + (idx + 1) + '</span>' + '<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' + '<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
@@ -114,12 +115,16 @@
escapeHtml(entry.artist || '') + escapeHtml(entry.artist || '') +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<button type="button" class="descBtn' + (hasDesc ? ' hasDesc' : '') + '" data-desc-open="' + idx + '" draggable="false">' +
escapeHtml(tt('descBtn')) +
'</button>' +
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' + '<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
escapeHtml(aliasLabel) + escapeHtml(aliasLabel) +
'</button>' + '</button>' +
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>' '<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
attachDraggable(li, 'music', idx) attachDraggable(li, 'music', idx)
attachInlineEdit(li, idx) attachInlineEdit(li, idx)
attachDescBtn(li, idx)
attachAliasBtn(li, idx) attachAliasBtn(li, idx)
ol.appendChild(li) ol.appendChild(li)
}) })
@@ -391,7 +396,10 @@
url: meta.url || url, url: meta.url || url,
title: meta.title || prev.title || '', title: meta.title || prev.title || '',
artist: meta.channel || prev.artist || '', artist: meta.channel || prev.artist || '',
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0) durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0),
// URL 만 바뀌었다고 운영자가 손으로 입력한 메타(별칭/설명)까지 날려선 안 된다.
aliases: Array.isArray(prev.aliases) ? prev.aliases : [],
description: typeof prev.description === 'string' ? prev.description : ''
} }
markDirty() markDirty()
closeAllModals() closeAllModals()
@@ -527,6 +535,57 @@
if (e.target === aliasModal) closeAliasModalSaving() if (e.target === aliasModal) closeAliasModalSaving()
}) })
// ── 설명 모달 (음악) ─────────────────────────────────
// 별칭 모달과 같은 패턴: 모달 닫힐 때 textarea 값을 state.music[idx].description 에 저장.
var descModal = document.getElementById('descModal')
var descTextarea = document.getElementById('desc-textarea')
var descModalTitleEl = document.getElementById('desc-modal-title')
var descBackBtn = document.getElementById('desc-back')
var descEditingIdx = -1
function attachDescBtn(li, idx) {
var btn = li.querySelector('[data-desc-open]')
if (!btn) return
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
btn.addEventListener('click', function (e) {
e.stopPropagation()
openDescModal(idx)
})
}
function openDescModal(idx) {
if (!state.music[idx]) return
descEditingIdx = idx
var entry = state.music[idx]
descModalTitleEl.textContent = tt('descModalTitle', { title: entry.title || tt('titleFallback') })
descTextarea.value = typeof entry.description === 'string' ? entry.description : ''
descModal.hidden = false
setTimeout(function () { descTextarea.focus() }, 0)
}
function closeDescModalSaving() {
if (descEditingIdx < 0 || !state.music[descEditingIdx]) {
descModal.hidden = true
descEditingIdx = -1
return
}
// textarea 값을 그대로 저장하되, 줄바꿈은 보존하고 양끝 공백만 다듬는다.
var nextDesc = (descTextarea.value || '').replace(/\r\n/g, '\n').trim()
var prev = state.music[descEditingIdx].description || ''
if (nextDesc !== prev) {
state.music[descEditingIdx].description = nextDesc
markDirty()
renderMusic()
}
descModal.hidden = true
descEditingIdx = -1
}
descBackBtn.addEventListener('click', closeDescModalSaving)
descModal.addEventListener('click', function (e) {
if (e.target === descModal) closeDescModalSaving()
})
// ── 사진목록: 음악목록 그대로 복사 ───────────────── // ── 사진목록: 음악목록 그대로 복사 ─────────────────
document.getElementById('image-from-music').addEventListener('click', function () { document.getElementById('image-from-music').addEventListener('click', function () {
if (state.music.length === 0) { if (state.music.length === 0) {
@@ -637,7 +696,7 @@
var entries = result.body.entries || [] var entries = result.body.entries || []
if (target === 'music') { if (target === 'music') {
state.music = entries.map(function (e) { state.music = entries.map(function (e) {
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 } return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0, aliases: [], description: '' }
}) })
renderMusic() renderMusic()
} else { } else {

View File

@@ -407,19 +407,24 @@ body.siteBody.centerLayout {
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; } .trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.trackRow { .trackRow {
display: grid; display: grid;
grid-template-columns: 36px 80px 1fr auto auto; grid-template-columns: 36px 80px 1fr auto auto auto;
gap: 12px; align-items: center; gap: 12px; align-items: center;
padding: 8px 12px; background: var(--bg-card); padding: 8px 12px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; border: 1px solid var(--border); border-radius: 8px;
cursor: grab; user-select: none; cursor: grab; user-select: none;
} }
.aliasBtn { .aliasBtn, .descBtn {
background: var(--bg); border: 1px solid var(--border); color: var(--text); background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
.aliasBtn:hover { border-color: var(--accent); } .aliasBtn:hover, .descBtn:hover { border-color: var(--accent); }
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); } .aliasBtn.hasAliases, .descBtn.hasDesc { border-color: var(--accent); color: var(--accent); }
.descTextarea {
width: 100%; min-height: 140px; resize: vertical;
font-family: inherit; font-size: 13px; line-height: 1.5;
padding: 8px 10px;
}
/* 별칭 모달 */ /* 별칭 모달 */
.aliasModalHeader { .aliasModalHeader {

View File

@@ -8,26 +8,45 @@
// build/ 폴더는 electron-builder 가 exe 아이콘으로만 쓰고 asar 에 // build/ 폴더는 electron-builder 가 exe 아이콘으로만 쓰고 asar 에
// 포함되지 않아서, 런타임에 그 파일을 읽을 수 없다. 대신 빌드(개발) 시점에 // 포함되지 않아서, 런타임에 그 파일을 읽을 수 없다. 대신 빌드(개발) 시점에
// 이 스크립트를 돌려 PNG 를 소스 코드에 인라인한다. // 이 스크립트를 돌려 PNG 를 소스 코드에 인라인한다.
//
// 마인크래프트 런처의 사용자 지정 설치 아이콘 규격은 "128x128 PNG" 로
// 고정돼 있다(https://minecraft.wiki/w/Launcher). 이 규격과 다른 크기
// (예: 원본 256x256)를 주면 런처가 아이콘을 무시하고 기본 아이콘(화로)으로
// 폴백한다. 그래서 build/icon.png 를 정확히 128x128 로 리사이즈해서 박는다.
// exe 아이콘(build/icon.ico, build/icon.png)은 256x256 그대로 둔다.
'use strict' 'use strict'
const fs = require('node:fs') const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const sharp = require('sharp')
const repoRoot = path.resolve(__dirname, '..') const repoRoot = path.resolve(__dirname, '..')
const pngPath = path.join(repoRoot, 'build', 'icon.png') const pngPath = path.join(repoRoot, 'build', 'icon.png')
const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts') const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts')
const buf = fs.readFileSync(pngPath) const ICON_SIZE = 128
const b64 = buf.toString('base64')
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png. async function main() {
const buf = await sharp(pngPath)
.resize(ICON_SIZE, ICON_SIZE, { fit: 'cover' })
.png({ compressionLevel: 9 })
.toBuffer()
const b64 = buf.toString('base64')
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png.
// 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은 // 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은
// 이미지를 쓰기 위해 빌드 시점에 PNG 를 data URL 로 인라인한다. 변경하려면 // 이미지를 ${ICON_SIZE}x${ICON_SIZE} 로 줄여 빌드 시점에 data URL 로 인라인한다.
// build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행. // 변경하려면 build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행.
export const LAUNCHER_PROFILE_ICON = export const LAUNCHER_PROFILE_ICON =
'data:image/png;base64,${b64}' 'data:image/png;base64,${b64}'
` `
fs.writeFileSync(tsPath, ts, 'utf8') fs.writeFileSync(tsPath, ts, 'utf8')
console.log(`wrote ${tsPath} (${buf.length} bytes PNG → ${b64.length} chars base64)`) console.log(`wrote ${tsPath} (${ICON_SIZE}x${ICON_SIZE}, ${buf.length} bytes PNG → ${b64.length} chars base64)`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -50,14 +50,20 @@ let installPromise: Promise<string> | null = null
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다. * ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
*/ */
export async function ensureFfmpegExe( export async function ensureFfmpegExe(
log?: (line: string) => void log?: (line: string) => void,
force = false
): Promise<string> { ): Promise<string> {
const target = getFfmpegExePath() const target = getFfmpegExePath()
await migrateLegacyExe(target) await migrateLegacyExe(target)
if (await canExecute(target)) { if (!force && await canExecute(target)) {
log?.(t('log.ffmpegExists', { path: target })) log?.(t('log.ffmpegExists', { path: target }))
return target return target
} }
if (force) {
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
log?.(t('log.ffmpegReinstall'))
try { await fs.unlink(target) } catch { /* noop */ }
}
if (installPromise) return installPromise if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {

View File

@@ -29,8 +29,35 @@ export function ytIdFromUrl(url: string): string {
} }
} }
/** 단순 HTTP/HTTPS GET (302 따라감, 4xx/5xx 는 reject). */ /**
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> { * 일시적(transient) 으로 보고 재시도할 HTTP 상태코드.
* 429 = Too Many Requests (i.ytimg.com 썸네일 서버가 연속 요청을 속도제한).
* 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다.
*/
const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRIES = 5
/** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */
const MAX_BACKOFF_MS = 60000
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
function parseRetryAfter(h: string | string[] | undefined): number | null {
if (!h) return null
const v = Array.isArray(h) ? h[0] : h
const secs = Number(v)
if (Number.isFinite(secs)) return Math.min(MAX_BACKOFF_MS, Math.max(0, secs * 1000))
const date = Date.parse(v)
if (!Number.isNaN(date)) return Math.min(MAX_BACKOFF_MS, Math.max(0, date - Date.now()))
return null
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
/**
* 단순 HTTP/HTTPS GET (302 따라감).
* 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대
* MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject.
*/
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (redirects > 8) { if (redirects > 8) {
reject(new Error(t('common.tooManyRedirects'))) reject(new Error(t('common.tooManyRedirects')))
@@ -38,6 +65,11 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
} }
const target = new URL(url) const target = new URL(url)
const lib = target.protocol === 'https:' ? https : http const lib = target.protocol === 'https:' ? https : http
const retryLater = (headerDelay: number | null): void => {
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
const delay = headerDelay ?? backoff
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
}
const req = lib.get(target, { const req = lib.get(target, {
timeout: 30000, timeout: 30000,
headers: { 'user-agent': 'mc-music-quiz-rp-installer' } headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
@@ -45,10 +77,15 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
const code = res.statusCode || 0 const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) { if (code >= 300 && code < 400 && res.headers.location) {
res.resume() res.resume()
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1) fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt)
.then(resolve, reject) .then(resolve, reject)
return return
} }
if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) {
res.resume()
retryLater(parseRetryAfter(res.headers['retry-after']))
return
}
if (code !== 200) { if (code !== 200) {
res.resume() res.resume()
reject(new Error(`HTTP ${code}`)) reject(new Error(`HTTP ${code}`))
@@ -58,18 +95,48 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
res.on('data', (c: Buffer) => chunks.push(c)) res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => resolve(Buffer.concat(chunks))) res.on('end', () => resolve(Buffer.concat(chunks)))
}) })
req.on('error', reject) req.on('error', (err) => {
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
if (attempt < MAX_RETRIES) {
retryLater(null)
return
}
reject(err)
})
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout')))) req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
}) })
} }
/**
* data: URL 이면 그 안에 들어 있는 바이트를 바로 Buffer 로 디코드한다.
* data: URL 은 이미지 데이터 자체를 품고 있어 네트워크 요청이 필요 없으며,
* http/https 만 다루는 fetchBuffer 에 넘기면 `Protocol "data:" not supported`
* 로 터지므로 여기서 가로챈다. data: URL 이 아니면 null.
*/
function decodeDataUrl(url: string): Buffer | null {
if (!/^data:/i.test(url)) return null
const comma = url.indexOf(',')
if (comma < 0) throw new Error(t('errors.imageDataUrlInvalid'))
const meta = url.slice(5, comma)
const data = url.slice(comma + 1)
// `;base64` 가 있으면 base64, 없으면 percent-encoding 된 텍스트.
const buf = /;base64/i.test(meta)
? Buffer.from(data, 'base64')
: Buffer.from(decodeURIComponent(data), 'utf8')
if (buf.length === 0) throw new Error(t('errors.imageDataUrlInvalid'))
return buf
}
/** /**
* 이미지 URL 을 다운로드해 Buffer 로 돌려준다. * 이미지 URL 을 다운로드해 Buffer 로 돌려준다.
* - data: URL 이면 내장 바이트를 바로 디코드 (네트워크 없음).
* - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 → * - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 →
* 실패하면 `hqdefault.jpg` 로 폴백. * 실패하면 `hqdefault.jpg` 로 폴백.
* - 그 외 URL 은 HTTP GET 으로 그대로 받음. * - 그 외 URL 은 HTTP GET 으로 그대로 받음.
*/ */
export async function downloadImage(rawUrl: string): Promise<Buffer> { export async function downloadImage(rawUrl: string): Promise<Buffer> {
const dataBuf = decodeDataUrl(rawUrl)
if (dataBuf) return dataBuf
const ytId = ytIdFromUrl(rawUrl) const ytId = ytIdFromUrl(rawUrl)
if (ytId) { if (ytId) {
try { try {

View File

@@ -89,6 +89,16 @@ function acquireMusicStartSlot(): Promise<void> {
return slot return slot
} }
/** 파일이 존재하면 true. 이어받기(재시도) 시 이미 받아둔 산출물 감지에 사용. */
async function fileExists(p: string): Promise<boolean> {
try {
await fsp.access(p)
return true
} catch {
return false
}
}
const DEFAULT_MANIFEST_URL = getManifestUrl() const DEFAULT_MANIFEST_URL = getManifestUrl()
const state: RpInstallerState = { const state: RpInstallerState = {
@@ -311,16 +321,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe) // 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog(t('log.ytdlpPreparing')) sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') }) sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog) let ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(t('log.ytdlpPath', { path: ytDlpBin })) sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled() throwIfCancelled()
sendLog(t('log.ffmpegPreparing')) sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') }) sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog) let ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(t('log.ffmpegPath', { path: ffmpegBin })) sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true }) sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled() throwIfCancelled()
// 음악 다운로드가 실패하면 yt-dlp/ffmpeg 가 너무 오래된 버전이라 유튜브 변경을
// 못 따라가는 경우일 수 있다. 그때 최신 버전으로 한 번만 강제 재설치한다.
// 워커 여러 개가 동시에 실패해도 재설치는 단 한 번만 일어나도록 락으로 직렬화.
let binRefreshPromise: Promise<void> | null = null
async function refreshBinariesOnce(): Promise<void> {
if (!binRefreshPromise) {
binRefreshPromise = (async () => {
ytDlpBin = await ensureYtDlpExe(sendLog, true)
ffmpegBin = await ensureFfmpegExe(sendLog, true)
})()
}
await binRefreshPromise
}
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환) // 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
const musicDir = path.join(tempRoot, 'music') const musicDir = path.join(tempRoot, 'music')
await fsp.mkdir(musicDir, { recursive: true }) await fsp.mkdir(musicDir, { recursive: true })
@@ -333,17 +357,17 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias. // 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music const musicList = pack.list.music
let nextIndex = 0 // 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
async function musicWorker(): Promise<void> { const failedMessages = new Map<number, string>()
while (true) {
if (state.cancelRequested) return // 한 곡을 한 번 받아본다. 성공 true / 실패 false.
const i = nextIndex++ // emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
if (i >= musicTotal) return async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
const entry = musicList[i] const entry = musicList[i]
const idx = i + 1 const idx = i + 1
// 최종 산출물 경로. 실패 시 부분 생성된 파일을 지워, 다음 재시도(이어받기)에서
// 완성본으로 오인해 건너뛰는 일을 막는다.
const expectedOut = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
sendLog(t('log.musicTrackStart', { idx })) sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' }) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null let child: ChildProcess | null = null
@@ -370,15 +394,47 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
if (child) state.activeChildren.delete(child) if (child) state.activeChildren.delete(child)
sendLog(t('log.musicTrackDone', { idx, name: 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' }) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
return true
} catch (err) { } catch (err) {
if (child) state.activeChildren.delete(child) if (child) state.activeChildren.delete(child)
// 부분 생성된 .ogg 를 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(expectedOut, { force: true }).catch(() => {})
if (state.cancelRequested) { if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') }) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return return false
} }
failedMessages.set(i, (err as Error).message)
if (emitErrorProgress) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', 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 }))
} }
return false
}
}
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
const failed: number[] = []
let nextIndex = 0
async function musicWorker(): Promise<void> {
while (true) {
if (state.cancelRequested) return
const i = nextIndex++
if (i >= musicTotal) return
const idx = i + 1
// 이전 시도에서 이미 받아둔 곡(.ogg 존재)은 시차 게이트 없이 즉시 완료 처리
// 한다. '재시도' 로 이어받을 때 받았던 곡을 다시 받지 않기 위함.
const outPath = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
if (await fileExists(outPath)) {
sendLog(t('log.musicTrackSkip', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
continue
}
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
const ok = await tryDownloadTrack(i, false)
if (!ok && !state.cancelRequested) failed.push(i)
} }
} }
@@ -388,6 +444,29 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await Promise.all(workers) await Promise.all(workers)
throwIfCancelled() throwIfCancelled()
// 1차에서 실패한 곡이 있으면, 모든 워커가 끝나 실행 중인 yt-dlp/ffmpeg 자식
// 프로세스가 하나도 없는 지금 시점에 단 한 번 최신 버전으로 강제 재설치한다.
// (각 워커 promise 는 자식 프로세스 close 후 resolve 되므로 여기선 exe 가 잠겨
// 있지 않다 → Windows 파일 잠금 문제 없음.) 그런 다음 실패한 곡만 순차 재시도.
if (failed.length > 0) {
failed.sort((a, b) => a - b)
sendLog(t('log.musicRefreshRetry', { count: failed.length }))
await refreshBinariesOnce()
throwIfCancelled()
nextMusicStartAt = Date.now()
for (const i of failed) {
throwIfCancelled()
await acquireMusicStartSlot()
throwIfCancelled()
const ok = await tryDownloadTrack(i, true)
if (!ok) {
throwIfCancelled()
const idx = i + 1
throw new Error(t('errors.musicDownloadFailed', { idx, message: failedMessages.get(i) ?? '' }))
}
}
}
// 2-3. 사진 다운로드 + painting variant 정규화 // 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting') const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true }) await fsp.mkdir(paintingDir, { recursive: true })
@@ -396,21 +475,32 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
throwIfCancelled() throwIfCancelled()
const entry = pack.list.images[i] const entry = pack.list.images[i]
const idx = i + 1 const idx = i + 1
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
const coverPath = path.join(paintingDir, coverFileName(idx))
if (await fileExists(coverPath)) {
sendLog(t('log.imageSkip', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
continue
}
sendLog(t('log.imageDownloading', { idx })) sendLog(t('log.imageDownloading', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' }) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
let buf: Buffer let buf: Buffer
try { try {
buf = await downloadImage(entry.url) buf = await downloadImage(entry.url)
} catch (err) { } catch (err) {
// 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {})
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message }) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message })) throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
} }
throwIfCancelled() throwIfCancelled()
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' }) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
const outPath = path.join(paintingDir, coverFileName(idx)) const outPath = coverPath
try { try {
await normalizeToCover(buf, outPath) await normalizeToCover(buf, outPath)
} catch (err) { } catch (err) {
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {})
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message }) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message })) throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
} }
@@ -484,11 +574,23 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
} }
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true }) sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
// 성공: 임시 파일 정리
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
return { resourcepackPath } return { resourcepackPath }
} finally { } catch (err) {
// 임시 파일 정리 // 사용자가 취소한 경우에만 임시 파일을 지운다(처음부터 새로 시작).
// 그 외 오류는 받아둔 음악·사진을 보존해 '재시도' 시 실패 지점부터 이어받게 한다.
// (재시도 없이 프로그램을 닫으면 window-all-closed 에서 .temp 를 정리한다.)
if (state.cancelRequested) {
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {}) await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
} }
throw err
}
})
// '처음으로' 버튼: 재시도하지 않고 처음 단계로 돌아갈 때 받아둔 임시 파일을 정리한다.
ipcMain.handle('rp:install:discard', async () => {
await fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
}) })
ipcMain.handle('rp:install:cancel', async () => { ipcMain.handle('rp:install:cancel', async () => {

View File

@@ -27,6 +27,10 @@ const api = {
cancelInstall: (): Promise<void> => cancelInstall: (): Promise<void> =>
ipcRenderer.invoke('rp:install:cancel'), ipcRenderer.invoke('rp:install:cancel'),
/** 재시도하지 않고 처음으로 돌아갈 때 받아둔 임시 파일을 정리한다. */
discardInstall: (): Promise<void> =>
ipcRenderer.invoke('rp:install:discard'),
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */ /** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
openResourcepackFolder: (): Promise<void> => openResourcepackFolder: (): Promise<void> =>
ipcRenderer.invoke('rp:finish:openFolder'), ipcRenderer.invoke('rp:finish:openFolder'),

View File

@@ -47,14 +47,20 @@ let installPromise: Promise<string> | null = null
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다. * 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
*/ */
export async function ensureYtDlpExe( export async function ensureYtDlpExe(
log?: (line: string) => void log?: (line: string) => void,
force = false
): Promise<string> { ): Promise<string> {
const target = getYtDlpExePath() const target = getYtDlpExePath()
await migrateLegacyExe(target) await migrateLegacyExe(target)
if (await canExecute(target)) { if (!force && await canExecute(target)) {
log?.(t('log.ytdlpExists', { path: target })) log?.(t('log.ytdlpExists', { path: target }))
return target return target
} }
if (force) {
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
log?.(t('log.ytdlpReinstall'))
try { await fs.unlink(target) } catch { /* noop */ }
}
if (installPromise) return installPromise if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,17 @@
import type { MusicListEntry, PackList } from '../shared/types.js' import type { MusicListEntry, PackList } from '../shared/types.js'
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */ /**
* SNBT 문자열 리터럴 안에 들어갈 문자열을 escape.
* 백슬래시·따옴표 외에도 줄바꿈·탭을 이스케이프해서 `data modify` 한 줄 명령이
* description 같은 멀티라인 입력 때문에 깨지지 않게 한다.
*/
function escapeSnbtString(input: string): string { function escapeSnbtString(input: string): string {
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"') return input
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
} }
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */ /** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
@@ -12,13 +21,16 @@ function aliasListSnbt(aliases: string[]): string {
return `[${parts.join(',')}]` return `[${parts.join(',')}]`
} }
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */ /** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...], description:"...", volume:1.0}` SNBT. */
function entrySnbt(entry: MusicListEntry): string { function entrySnbt(entry: MusicListEntry): string {
const title = escapeSnbtString(entry.title ?? '') const title = escapeSnbtString(entry.title ?? '')
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다. // launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
const author = escapeSnbtString(entry.artist ?? '') const author = escapeSnbtString(entry.artist ?? '')
const alias = aliasListSnbt(entry.aliases ?? []) const alias = aliasListSnbt(entry.aliases ?? [])
return `{title:"${title}", author:"${author}", alias:${alias}}` const description = escapeSnbtString(entry.description ?? '')
// launcher 가 생성하는 항목에는 volume 기본값 1.0 을 항상 넣는다.
// 운영자는 생성된 mcfunction 에서 곡별로 직접 값을 바꿔 사용한다.
return `{title:"${title}", author:"${author}", alias:${alias}, description:"${description}", volume:1.0}`
} }
/** /**
@@ -29,11 +41,11 @@ function entrySnbt(entry: MusicListEntry): string {
export function buildSongsMcfunction(list: PackList): string { export function buildSongsMcfunction(list: PackList): string {
const lines: string[] = [] const lines: string[] = []
lines.push('# 곡 한 개 = 한 줄.') lines.push('# 곡 한 개 = 한 줄.')
lines.push('# 필수 — title, author, alias') lines.push('# 필수 — title, author, alias, description')
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction') lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
lines.push('# 의 audio.volume 사용)') lines.push('# 의 audio.volume 사용)')
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.') lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}') lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], description:"...", volume:1.0}')
lines.push('data modify storage mq:main songs set value []') lines.push('data modify storage mq:main songs set value []')
for (const entry of list.music) { for (const entry of list.music) {
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`) lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)

View File

@@ -293,8 +293,8 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
asset_id: `musicquiz:cover_${nn}`, asset_id: `musicquiz:cover_${nn}`,
width: size, width: size,
height: size, height: size,
title: { text: `Cover ${nn}` }, author: 'musicquiz',
author: { text: 'music quiz' } title: `cover_${nn}`
} }
archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` }) archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` })
} }

View File

@@ -32,48 +32,63 @@ function getYtDlpAssetName(): string {
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작 return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
} }
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */ /**
* 로컬 설치 경로: OS별 사용자 데이터 디렉터리 안의 .mc_custom/<asset>.
* - Windows: %APPDATA%/.mc_custom/yt-dlp.exe
* - macOS : ~/Library/Application Support/.mc_custom/yt-dlp_macos
* - Linux 등: $XDG_CONFIG_HOME 또는 ~/.config/.mc_custom/yt-dlp_linux (arch 따라 다름)
*/
export function getYtDlpInstallPath(): string { export function getYtDlpInstallPath(): string {
return path.join(getMcCustomDir(), getYtDlpAssetName()) return path.join(getMcCustomDir(), getYtDlpAssetName())
} }
/** 순수 파이썬 zipapp(`yt-dlp`) 의 로컬 설치 경로. python3 가 PATH 에 있어야 동작. */
function getYtDlpZipappPath(): string {
return path.join(getMcCustomDir(), 'yt-dlp_zipapp')
}
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */ /** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
let installPromise: Promise<string> | null = null let installPromise: Promise<string> | null = null
type ProbeResult = { ok: true } | { ok: false; detail: string }
/** /**
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 * .mc_custom/ 디렉터리에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. * 현재 OS/아키텍처용 네이티브 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
*
* 네이티브 바이너리가 실행되지 않는 환경(glibc 미스매치, musl libc, antivirus 차단 등)
* 이면 다음 순서로 폴백한다:
* 1) PATH 의 `yt-dlp(.exe)` (시스템에 따로 깐 거)
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
*/ */
export async function ensureYtDlp(): Promise<string> { export async function ensureYtDlp(force = false): Promise<string> {
const target = getYtDlpInstallPath() const target = getYtDlpInstallPath()
// 이미 설치돼 있고 실행 가능하면 그대로 사용 if (!force) {
if (await canExecute(target)) return target // 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 if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {
try { try {
const dir = getMcCustomDir() return await prepareYtDlp(target, force)
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) })
)
} finally { } finally {
installPromise = null installPromise = null
} }
@@ -81,31 +96,121 @@ export async function ensureYtDlp(): Promise<string> {
return installPromise return installPromise
} }
async function canExecute(filePath: string): Promise<boolean> { async function prepareYtDlp(target: string, force = false): Promise<string> {
try { const diagnostics: string[] = []
await fs.access(filePath, fsConst.F_OK)
} catch { // 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다.
return false if (!force) {
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
} }
// POSIX 면 X 비트도 확인
// 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도
if (process.platform !== 'win32') { 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 { try {
await fs.access(filePath, fsConst.X_OK) await fs.mkdir(getMcCustomDir(), { recursive: true })
} catch { const asset = getYtDlpAssetName()
return false 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') {
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 */ }
}
// 4. POSIX 한정 최후 폴백: 범용 파이썬 zipapp `yt-dlp` 다운로드 후 shebang 실행.
// 네이티브 바이너리가 glibc/musl/arch 문제로 못 도는 리눅스 환경이라도
// python3 가 PATH 에 있으면 동작한다. ~ 3MB 짜리 스크립트.
if (process.platform !== 'win32') {
const zipappPath = getYtDlpZipappPath()
try {
try { await fs.unlink(zipappPath) } catch { /* noop */ }
await downloadToFile('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', zipappPath)
await fs.chmod(zipappPath, 0o755)
const probe = await probeVersion(zipappPath)
if (probe.ok) return zipappPath
diagnostics.push(`zipapp yt-dlp 검증 실패: ${probe.detail} (python3 누락이거나 PATH 에 없음)`)
try { await fs.unlink(zipappPath) } catch { /* noop */ }
} catch (err) {
diagnostics.push(`zipapp 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
try { await fs.unlink(zipappPath) } 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) => { return new Promise((resolve) => {
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) let child: ReturnType<typeof spawn>
let ok = false try {
child.stdout.on('data', () => { ok = true }) child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
child.on('error', () => resolve(false)) } catch (err) {
child.on('close', (code) => resolve(ok && code === 0)) 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(', ') })
})
}) })
} }
@@ -141,37 +246,70 @@ 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 의 메타데이터를 가져온다. * 단일 영상 URL 의 메타데이터를 가져온다.
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음. * `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
*/ */
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> { export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
const bin = await ensureYtDlp() const stdout = await runYtDlp(
return new Promise((resolve, reject) => { ['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
const child = spawn(bin, [ (code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
'--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) const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
if (!line) { resolve(null); return } if (!line) return null
try {
const obj = JSON.parse(line) as Record<string, unknown> const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : '' const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) { resolve(null); return } if (!id) return null
resolve({ return {
id, id,
title: typeof obj.title === 'string' ? obj.title : '', title: typeof obj.title === 'string' ? obj.title : '',
channel: typeof obj.channel === 'string' channel: typeof obj.channel === 'string'
@@ -181,12 +319,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0 url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
? obj.webpage_url ? obj.webpage_url
: `https://www.youtube.com/watch?v=${id}` : `https://www.youtube.com/watch?v=${id}`
})
} catch (err) {
reject(err)
} }
})
})
} }
/** /**
@@ -194,24 +327,10 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON. * `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
*/ */
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> { export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
const bin = await ensureYtDlp() const stdout = await runYtDlp(
return new Promise((resolve, reject) => { ['--flat-playlist', '--dump-json', '--no-warnings', url],
const child = spawn(bin, [ (code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))
'--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 lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
const parsed: YtPlaylistEntry[] = [] const parsed: YtPlaylistEntry[] = []
for (const line of lines) { for (const line of lines) {
@@ -234,7 +353,5 @@ export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry
// 한 줄이 깨져도 나머지는 살림 // 한 줄이 깨져도 나머지는 살림
} }
} }
resolve(parsed) return parsed
})
})
} }

View File

@@ -206,6 +206,18 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
} }
// 음악·사진 목록 JSON(file/list/<key>.json)도 함께 이름을 바꾼다. 이걸 빼먹으면
// manifest 정의는 새 키로 옮겨졌는데 정작 목록 데이터는 옛 키 파일에 남아,
// 새 packKey 로는 빈 목록만 보이고 인스톨러도 곡/사진을 받지 못한다.
const oldListFile = path.join(fileListDirPath, `${oldKey}.json`)
const newListFile = path.join(fileListDirPath, `${safeNew}.json`)
try {
await fsp.mkdir(fileListDirPath, { recursive: true })
await fsp.rename(oldListFile, newListFile)
} catch (error) {
// 옛 목록 파일이 없으면(한 번도 저장 안 한 새 pack) 그냥 둔다.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
// 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이 // 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다. // 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
const oldTermsDir = path.join(manifestTermsDirPath, oldKey) const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
@@ -287,7 +299,8 @@ export function normalizePackList(input: unknown): PackList {
title: sanitizeStr(entry.title), title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist), artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec), durationSec: sanitizeNumber(entry.durationSec),
aliases: sanitizeAliases(entry.aliases) aliases: sanitizeAliases(entry.aliases),
description: sanitizeStr(entry.description)
})) }))
.filter((entry) => entry.url.length > 0), .filter((entry) => entry.url.length > 0),
images: images images: images

View File

@@ -57,6 +57,8 @@ export interface MusicListEntry {
durationSec: number durationSec: number
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */ /** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
aliases: string[] aliases: string[]
/** 곡 설명 / 트리비아 메모. 정답 채점이나 데이터팩 생성에는 사용되지 않는다. */
description: string
} }
export interface ImageListEntry { export interface ImageListEntry {

View File

@@ -124,6 +124,21 @@
</div> </div>
</div> </div>
<!-- Description modal (music) -->
<div class="modalOverlay" id="descModal" hidden>
<div class="modalCard">
<header class="aliasModalHeader">
<button type="button" class="ghostLink" id="desc-back"><%= t('listEditor.descBack') %></button>
<h3 id="desc-modal-title"></h3>
<span></span>
</header>
<div class="modalBody">
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.descHint') %></p>
<textarea id="desc-textarea" class="textInput descTextarea" placeholder="<%= t('listEditor.descPlaceholder') %>" rows="6"></textarea>
</div>
</div>
</div>
<!-- Edit modal (image) --> <!-- Edit modal (image) -->
<div class="modalOverlay" id="editImageModal" hidden> <div class="modalOverlay" id="editImageModal" hidden>
<div class="modalCard"> <div class="modalCard">