Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb7acba2f | |||
| f4c9504c1a | |||
| 60a52a9bec | |||
| fe0d2f75e3 | |||
| 399f4af808 | |||
| d5f88e0e76 | |||
| d9ba2b0f35 | |||
| 3baf84cfd1 | |||
| d22c6f17a3 | |||
| 0629aa54aa | |||
| 201043e289 | |||
| acd3dd995d | |||
| b4160aefc1 | |||
| 1ac13a03ff | |||
| 542f759585 | |||
| 3248d096e4 | |||
| 8c9dc88e8b | |||
| b769f453a3 | |||
| 5c13648f63 | |||
| 9efd4a696a |
@@ -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()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
renderStep1()
|
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
@@ -106,6 +117,8 @@
|
|||||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||||
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
||||||
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||||
|
"tracksAdded": "음악 트랙 추가됨: {{count}}곡",
|
||||||
|
"paintingsAdded": "사진 텍스처 추가됨: {{count}}장",
|
||||||
"ytdlpLine": "yt-dlp> {{line}}"
|
"ytdlpLine": "yt-dlp> {{line}}"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
@@ -135,10 +148,13 @@
|
|||||||
"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 를 찾을 수 없습니다.",
|
||||||
"ffmpegVerifyFailed": "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 텍스처를 보존하면서 같은 파일명을 추가할 수 없습니다. 베이스에서 충돌하는 파일을 제거하거나 다른 베이스를 사용하세요."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "minecraft-music-quiz-installer",
|
"name": "minecraft-music-quiz-installer",
|
||||||
"version": "0.3.4",
|
"version": "0.3.8",
|
||||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||||
"main": "dist/installer/main.js",
|
"main": "dist/installer/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -63,13 +63,36 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
|||||||
@@ -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,52 +357,84 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
|
|
||||||
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
||||||
const musicList = pack.list.music
|
const musicList = pack.list.music
|
||||||
|
// 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
|
||||||
|
const failedMessages = new Map<number, string>()
|
||||||
|
|
||||||
|
// 한 곡을 한 번 받아본다. 성공 true / 실패 false.
|
||||||
|
// emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
|
||||||
|
async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
|
||||||
|
const entry = musicList[i]
|
||||||
|
const idx = i + 1
|
||||||
|
// 최종 산출물 경로. 실패 시 부분 생성된 파일을 지워, 다음 재시도(이어받기)에서
|
||||||
|
// 완성본으로 오인해 건너뛰는 일을 막는다.
|
||||||
|
const expectedOut = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
|
||||||
|
sendLog(t('log.musicTrackStart', { idx }))
|
||||||
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||||
|
let child: ChildProcess | null = null
|
||||||
|
try {
|
||||||
|
const outPath = await downloadMusicTrack({
|
||||||
|
ytdlpExe: ytDlpBin,
|
||||||
|
ffmpegExe: ffmpegBin,
|
||||||
|
tempDir: musicDir,
|
||||||
|
index: idx,
|
||||||
|
url: entry.url,
|
||||||
|
log: sendLog,
|
||||||
|
onChild: (c) => {
|
||||||
|
child = c
|
||||||
|
state.activeChildren.add(c)
|
||||||
|
},
|
||||||
|
onProgress: (pct) => {
|
||||||
|
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
||||||
|
sendProgress({
|
||||||
|
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
||||||
|
percent: Math.min(90, pct * 0.9), status: 'running'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (child) state.activeChildren.delete(child)
|
||||||
|
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
||||||
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (child) state.activeChildren.delete(child)
|
||||||
|
// 부분 생성된 .ogg 를 제거(이어받기 시 완성본 오인 방지).
|
||||||
|
await fsp.rm(expectedOut, { force: true }).catch(() => {})
|
||||||
|
if (state.cancelRequested) {
|
||||||
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
|
||||||
|
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
|
||||||
|
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
|
||||||
|
const failed: number[] = []
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
async function musicWorker(): Promise<void> {
|
async function musicWorker(): Promise<void> {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (state.cancelRequested) return
|
if (state.cancelRequested) return
|
||||||
const i = nextIndex++
|
const i = nextIndex++
|
||||||
if (i >= musicTotal) return
|
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 간격을 둠.
|
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
|
||||||
await acquireMusicStartSlot()
|
await acquireMusicStartSlot()
|
||||||
if (state.cancelRequested) return
|
if (state.cancelRequested) return
|
||||||
const entry = musicList[i]
|
const ok = await tryDownloadTrack(i, false)
|
||||||
const idx = i + 1
|
if (!ok && !state.cancelRequested) failed.push(i)
|
||||||
sendLog(t('log.musicTrackStart', { idx }))
|
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
|
||||||
let child: ChildProcess | null = null
|
|
||||||
try {
|
|
||||||
const outPath = await downloadMusicTrack({
|
|
||||||
ytdlpExe: ytDlpBin,
|
|
||||||
ffmpegExe: ffmpegBin,
|
|
||||||
tempDir: musicDir,
|
|
||||||
index: idx,
|
|
||||||
url: entry.url,
|
|
||||||
log: sendLog,
|
|
||||||
onChild: (c) => {
|
|
||||||
child = c
|
|
||||||
state.activeChildren.add(c)
|
|
||||||
},
|
|
||||||
onProgress: (pct) => {
|
|
||||||
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
|
||||||
sendProgress({
|
|
||||||
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
|
||||||
percent: Math.min(90, pct * 0.9), status: 'running'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (child) state.activeChildren.delete(child)
|
|
||||||
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
|
||||||
} catch (err) {
|
|
||||||
if (child) state.activeChildren.delete(child)
|
|
||||||
if (state.cancelRequested) {
|
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,13 +574,25 @@ 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 })
|
||||||
return { resourcepackPath }
|
// 성공: 임시 파일 정리
|
||||||
} finally {
|
|
||||||
// 임시 파일 정리
|
|
||||||
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
||||||
|
return { resourcepackPath }
|
||||||
|
} catch (err) {
|
||||||
|
// 사용자가 취소한 경우에만 임시 파일을 지운다(처음부터 새로 시작).
|
||||||
|
// 그 외 오류는 받아둔 음악·사진을 보존해 '재시도' 시 실패 지점부터 이어받게 한다.
|
||||||
|
// (재시도 없이 프로그램을 닫으면 window-all-closed 에서 .temp 를 정리한다.)
|
||||||
|
if (state.cancelRequested) {
|
||||||
|
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 () => {
|
||||||
state.cancelRequested = true
|
state.cancelRequested = true
|
||||||
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
|
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
|||||||
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
||||||
|
|
||||||
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
||||||
|
// 핵심 정책: 베이스 리소스팩에 이미 있는 자산은 절대 덮어쓰지 않는다.
|
||||||
|
// - 베이스 sounds.json 의 엔트리는 그대로 보존하고, 우리 트랙은 그 위에 "추가" 만 한다.
|
||||||
|
// - 베이스 sounds/track_NN.ogg 가 이미 있으면 덮어쓰지 않고 건너뛴다.
|
||||||
|
// - 키나 파일명이 충돌하면 우리 트랙을 스킵하고 로그로 알린다.
|
||||||
const musicFiles = (await fs.readdir(opts.musicDir))
|
const musicFiles = (await fs.readdir(opts.musicDir))
|
||||||
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
||||||
.sort()
|
.sort()
|
||||||
@@ -152,7 +156,19 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
|||||||
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
||||||
const stem = path.basename(fname, path.extname(fname)) // "01"
|
const stem = path.basename(fname, path.extname(fname)) // "01"
|
||||||
const trackId = `track_${stem}`
|
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] = {
|
soundsJson[trackId] = {
|
||||||
sounds: [
|
sounds: [
|
||||||
{ name: `${NAMESPACE}:${trackId}`, stream: true }
|
{ 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')
|
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
||||||
|
opts.log?.(t('log.tracksAdded', { count: musicFiles.length }))
|
||||||
throwIfCancelled(cancel)
|
throwIfCancelled(cancel)
|
||||||
|
|
||||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
|
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태).
|
||||||
|
// 음악과 동일한 정책: 베이스에 같은 파일명이 이미 있으면 설치를 실패시킨다.
|
||||||
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
||||||
.filter((n) => n.toLowerCase().endsWith('.png'))
|
.filter((n) => n.toLowerCase().endsWith('.png'))
|
||||||
.sort()
|
.sort()
|
||||||
for (const fname of paintingFiles) {
|
for (const fname of paintingFiles) {
|
||||||
throwIfCancelled(cancel)
|
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)
|
throwIfCancelled(cancel)
|
||||||
|
|
||||||
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
|
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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)}`)
|
||||||
|
|||||||
@@ -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` })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
const diagnostics: string[] = []
|
||||||
|
|
||||||
|
// 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다.
|
||||||
|
if (!force) {
|
||||||
|
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
|
||||||
|
if (await fileExists(target)) {
|
||||||
|
const probe = await probeVersion(target)
|
||||||
|
if (probe.ok) return target
|
||||||
|
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
const existingZipapp = getYtDlpZipappPath()
|
||||||
|
if (await fileExists(existingZipapp)) {
|
||||||
|
const probe = await probeVersion(existingZipapp)
|
||||||
|
if (probe.ok) return existingZipapp
|
||||||
|
diagnostics.push(`기존 yt-dlp_zipapp 검증 실패: ${probe.detail}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
|
||||||
|
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
|
||||||
|
const pathProbe = await probeVersion(pathCmd)
|
||||||
|
if (pathProbe.ok) return pathCmd
|
||||||
|
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 최후 수단: 새로 다운로드해서 시도
|
||||||
|
try {
|
||||||
|
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') {
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new YtDlpUnavailableError(
|
||||||
|
t('youtube.ytdlpVerifyFailedDetail', { detail: diagnostics.join(' | ') })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath, fsConst.F_OK)
|
await fs.access(filePath, fsConst.F_OK)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// POSIX 면 X 비트도 확인
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
try {
|
|
||||||
await fs.access(filePath, fsConst.X_OK)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 실제로 --version 으로 한 번 더 확인
|
|
||||||
return probeVersion(filePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeVersion(bin: string): Promise<boolean> {
|
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,52 +246,80 @@ 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',
|
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
||||||
'--no-playlist',
|
if (!line) return null
|
||||||
'--skip-download',
|
const obj = JSON.parse(line) as Record<string, unknown>
|
||||||
url
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
if (!id) return null
|
||||||
let stdout = ''
|
return {
|
||||||
let stderr = ''
|
id,
|
||||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
title: typeof obj.title === 'string' ? obj.title : '',
|
||||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
channel: typeof obj.channel === 'string'
|
||||||
child.on('error', (err) => reject(err))
|
? obj.channel
|
||||||
child.on('close', (code) => {
|
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
||||||
if (code !== 0) {
|
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
||||||
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
|
||||||
return
|
? obj.webpage_url
|
||||||
}
|
: `https://www.youtube.com/watch?v=${id}`
|
||||||
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
}
|
||||||
if (!line) { resolve(null); return }
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line) as Record<string, unknown>
|
|
||||||
const id = typeof obj.id === 'string' ? obj.id : ''
|
|
||||||
if (!id) { resolve(null); return }
|
|
||||||
resolve({
|
|
||||||
id,
|
|
||||||
title: typeof obj.title === 'string' ? obj.title : '',
|
|
||||||
channel: typeof obj.channel === 'string'
|
|
||||||
? obj.channel
|
|
||||||
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
|
||||||
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
|
||||||
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
|
|
||||||
? obj.webpage_url
|
|
||||||
: `https://www.youtube.com/watch?v=${id}`
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,47 +327,31 @@ 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',
|
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
||||||
'--no-warnings',
|
const parsed: YtPlaylistEntry[] = []
|
||||||
url
|
for (const line of lines) {
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
try {
|
||||||
let stdout = ''
|
const obj = JSON.parse(line) as Record<string, unknown>
|
||||||
let stderr = ''
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
if (!id) continue
|
||||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
parsed.push({
|
||||||
child.on('error', (err) => reject(err))
|
id,
|
||||||
child.on('close', (code) => {
|
title: typeof obj.title === 'string' ? obj.title : '',
|
||||||
if (code !== 0) {
|
channel: typeof obj.channel === 'string'
|
||||||
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
? obj.channel
|
||||||
return
|
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
||||||
}
|
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
||||||
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
url: typeof obj.url === 'string' && obj.url.length > 0
|
||||||
const parsed: YtPlaylistEntry[] = []
|
? obj.url
|
||||||
for (const line of lines) {
|
: `https://www.youtube.com/watch?v=${id}`
|
||||||
try {
|
})
|
||||||
const obj = JSON.parse(line) as Record<string, unknown>
|
} catch {
|
||||||
const id = typeof obj.id === 'string' ? obj.id : ''
|
// 한 줄이 깨져도 나머지는 살림
|
||||||
if (!id) continue
|
}
|
||||||
parsed.push({
|
}
|
||||||
id,
|
return parsed
|
||||||
title: typeof obj.title === 'string' ? obj.title : '',
|
|
||||||
channel: typeof obj.channel === 'string'
|
|
||||||
? obj.channel
|
|
||||||
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
|
||||||
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
|
||||||
url: typeof obj.url === 'string' && obj.url.length > 0
|
|
||||||
? obj.url
|
|
||||||
: `https://www.youtube.com/watch?v=${id}`
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// 한 줄이 깨져도 나머지는 살림
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve(parsed)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user