Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6df5f936c | |||
| dfb7acba2f | |||
| f4c9504c1a | |||
| 60a52a9bec | |||
| fe0d2f75e3 | |||
| 399f4af808 | |||
| d5f88e0e76 | |||
| d9ba2b0f35 | |||
| 3baf84cfd1 | |||
| d22c6f17a3 | |||
| 0629aa54aa | |||
| 201043e289 | |||
| acd3dd995d | |||
| b4160aefc1 | |||
| 1ac13a03ff | |||
| 542f759585 | |||
| 3248d096e4 | |||
| 8c9dc88e8b | |||
| b769f453a3 | |||
| 5c13648f63 | |||
| 9efd4a696a | |||
| c580a50fd4 | |||
| 38df72e4f6 | |||
| 6447b1cb78 | |||
| 9ba5dc6b7b | |||
| 05dc9d7166 |
@@ -141,14 +141,55 @@ function renderStep1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
||||||
// rp 인스톨러는 리소스팩·설치기 두 약관만 확인·동의하면 된다.
|
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
|
||||||
|
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
|
||||||
function renderAgreement() {
|
function renderAgreement() {
|
||||||
setActiveStep(1)
|
setActiveStep(1)
|
||||||
clearPage()
|
clearPage()
|
||||||
var KINDS = [
|
var loadingSection = document.createElement('section')
|
||||||
{ id: 'resourcepack', tab: tt('agreement.tabResourcepack') },
|
loadingSection.className = 'page'
|
||||||
{ id: 'installer-rp', tab: tt('agreement.tabInstaller') }
|
loadingSection.innerHTML = '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||||
]
|
'<p class="formMessage">' + escapeHtml(tt('agreement.loading')) + '</p>'
|
||||||
|
pageHost.appendChild(loadingSection)
|
||||||
|
|
||||||
|
api.getTermsList().then(function (res) {
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
showAgreementError((res && res.message) || 'unknown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var terms = (res.terms || []).map(function (t) {
|
||||||
|
return { id: t.kind, tab: t.label }
|
||||||
|
})
|
||||||
|
if (terms.length === 0) {
|
||||||
|
renderStep2()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearPage()
|
||||||
|
renderAgreementWithKinds(terms)
|
||||||
|
}).catch(function (err) {
|
||||||
|
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
|
||||||
|
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
|
||||||
|
function showAgreementError(message) {
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||||
|
'<p class="formMessage error">' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '</p>' +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
'<button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
||||||
|
'<button class="primaryBtn" id="retry">' + escapeHtml(tt('agreement.retry')) + '</button>' +
|
||||||
|
'</div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||||
|
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgreementWithKinds(KINDS) {
|
||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
@@ -444,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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,15 +149,58 @@ function renderStep1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
|
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
|
||||||
// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다.
|
// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms/<pack>/index.json) 가
|
||||||
|
// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비어 있는 (terms:[])
|
||||||
|
// 정상 응답일 때만 단계 자체를 건너뛴다. 네트워크 오류/404/서버 오류는 사용자가 약관 동의
|
||||||
|
// 없이 설치로 넘어가는 것을 막기 위해 오류 화면 + 다시 시도 버튼으로 차단한다.
|
||||||
function renderAgreement() {
|
function renderAgreement() {
|
||||||
setActiveStep(1)
|
setActiveStep(1)
|
||||||
clearPage()
|
clearPage()
|
||||||
var KINDS = [
|
var loadingSection = document.createElement('section')
|
||||||
{ id: 'map', tab: tt('agreement.tabMap') },
|
loadingSection.className = 'page'
|
||||||
{ id: 'mod', tab: tt('agreement.tabMod') },
|
loadingSection.innerHTML = '<h2>' + tt('agreement.heading') + '</h2>' +
|
||||||
{ id: 'installer', tab: tt('agreement.tabInstaller') }
|
'<p class="formMessage">' + tt('agreement.loading') + '</p>'
|
||||||
]
|
pageHost.appendChild(loadingSection)
|
||||||
|
|
||||||
|
installerApi.getTermsList().then(function (res) {
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
showAgreementError((res && res.message) || 'unknown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var terms = (res.terms || []).map(function (t) {
|
||||||
|
return { id: t.kind, tab: t.label }
|
||||||
|
})
|
||||||
|
if (terms.length === 0) {
|
||||||
|
// 명시적으로 표시 대상이 0개라고 서버가 알려준 정상 응답 → 약관 단계 스킵.
|
||||||
|
renderStep2()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearPage()
|
||||||
|
renderAgreementWithKinds(terms)
|
||||||
|
}).catch(function (err) {
|
||||||
|
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도/뒤로 가기 옵션을 보여준다.
|
||||||
|
// 동의 없이 설치 단계로 넘어가지 않도록 next 버튼을 두지 않는다.
|
||||||
|
function showAgreementError(message) {
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>' + tt('agreement.heading') + '</h2>' +
|
||||||
|
'<p class="formMessage error">' + tt('agreement.listLoadFailed', { message: message }) + '</p>' +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
'<button class="secondaryBtn" id="back">' + tt('common.back') + '</button>' +
|
||||||
|
'<button class="primaryBtn" id="retry">' + tt('agreement.retry') + '</button>' +
|
||||||
|
'</div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||||
|
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgreementWithKinds(KINDS) {
|
||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
@@ -165,7 +208,7 @@ function renderAgreement() {
|
|||||||
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
|
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
|
||||||
'<div class="tabBar" id="agTabs">' +
|
'<div class="tabBar" id="agTabs">' +
|
||||||
KINDS.map(function (k, i) {
|
KINDS.map(function (k, i) {
|
||||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + k.tab + '</button>'
|
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + escapeHtml(k.id) + '">' + escapeHtml(k.tab) + '</button>'
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
|
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
|
||||||
@@ -917,6 +960,12 @@ function renderStep5() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
||||||
;(async function () {
|
;(async function () {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
"tabInstaller": "리소스팩 설치기 약관",
|
"tabInstaller": "리소스팩 설치기 약관",
|
||||||
"loading": "약관을 불러오는 중...",
|
"loading": "약관을 불러오는 중...",
|
||||||
"loadFailed": "약관 로드 실패: {{message}}",
|
"loadFailed": "약관 로드 실패: {{message}}",
|
||||||
"agreeAll": "위 모든 약관(리소스팩·설치기)에 동의합니다.",
|
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||||
|
"retry": "다시 시도",
|
||||||
|
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
||||||
"cancelling": "취소 중…"
|
"cancelling": "취소 중…"
|
||||||
},
|
},
|
||||||
@@ -63,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 폴백",
|
||||||
@@ -80,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)",
|
||||||
@@ -104,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": {
|
||||||
@@ -133,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 텍스처를 보존하면서 같은 파일명을 추가할 수 없습니다. 베이스에서 충돌하는 파일을 제거하거나 다른 베이스를 사용하세요."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
"tabInstaller": "설치기 약관",
|
"tabInstaller": "설치기 약관",
|
||||||
"loading": "약관을 불러오는 중...",
|
"loading": "약관을 불러오는 중...",
|
||||||
"loadFailed": "약관 로드 실패: {{message}}",
|
"loadFailed": "약관 로드 실패: {{message}}",
|
||||||
"agreeAll": "위 모든 약관(맵·모드·설치기)에 동의합니다.",
|
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||||
|
"retry": "다시 시도",
|
||||||
|
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
||||||
},
|
},
|
||||||
"step1": {
|
"step1": {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@
|
|||||||
"aliasPlaceholder": "별칭 입력",
|
"aliasPlaceholder": "별칭 입력",
|
||||||
"aliasRemove": "삭제",
|
"aliasRemove": "삭제",
|
||||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||||
|
"descBtn": "설명",
|
||||||
|
"descModalTitle": "설명 - {{title}}",
|
||||||
|
"descBack": "← 돌아가기",
|
||||||
|
"descPlaceholder": "이 곡에 대한 설명을 입력하세요",
|
||||||
|
"descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.",
|
||||||
"metaLoading": "메타데이터 가져오는 중…",
|
"metaLoading": "메타데이터 가져오는 중…",
|
||||||
"metaFailedShort": "메타 조회 실패",
|
"metaFailedShort": "메타 조회 실패",
|
||||||
"metaFailedTitle": "메타데이터 조회 실패",
|
"metaFailedTitle": "메타데이터 조회 실패",
|
||||||
@@ -160,7 +165,11 @@
|
|||||||
"slashQuote": "인용",
|
"slashQuote": "인용",
|
||||||
"slashCode": "코드",
|
"slashCode": "코드",
|
||||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
||||||
"builtinBadge": "기본",
|
"visibilityHeading": "표시 대상 (중복 선택 가능)",
|
||||||
|
"visibilityInstaller": "설치기에 표시",
|
||||||
|
"visibilityInstallerRp": "리소스팩 설치기에 표시",
|
||||||
|
"visibilityInstallerShort": "설치기",
|
||||||
|
"visibilityInstallerRpShort": "리소스팩",
|
||||||
"addHeading": "약관 추가",
|
"addHeading": "약관 추가",
|
||||||
"kindLabel": "식별자",
|
"kindLabel": "식별자",
|
||||||
"kindPlaceholder": "예: privacy",
|
"kindPlaceholder": "예: privacy",
|
||||||
@@ -216,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.2",
|
"version": "0.3.9",
|
||||||
"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 {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
var dirtyMark = document.getElementById('dirty-mark')
|
var dirtyMark = document.getElementById('dirty-mark')
|
||||||
var saveBtn = document.getElementById('saveBtn')
|
var saveBtn = document.getElementById('saveBtn')
|
||||||
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
||||||
|
var visInstaller = document.getElementById('visInstaller')
|
||||||
|
var visInstallerRp = document.getElementById('visInstallerRp')
|
||||||
|
|
||||||
editor.value = INITIAL || ''
|
editor.value = INITIAL || ''
|
||||||
var dirty = false
|
var dirty = false
|
||||||
@@ -23,6 +25,10 @@
|
|||||||
dirtyMark.hidden = !v
|
dirtyMark.hidden = !v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
|
||||||
|
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
|
||||||
|
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
|
||||||
|
|
||||||
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
||||||
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
||||||
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
||||||
@@ -162,10 +168,13 @@
|
|||||||
function save() {
|
function save() {
|
||||||
status.classList.remove('error')
|
status.classList.remove('error')
|
||||||
status.textContent = I18N.saving
|
status.textContent = I18N.saving
|
||||||
|
var payload = { content: editor.value }
|
||||||
|
if (visInstaller) payload.showInInstaller = !!visInstaller.checked
|
||||||
|
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
|
||||||
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
|
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: editor.value })
|
body: JSON.stringify(payload)
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
||||||
}).then(function (res) {
|
}).then(function (res) {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -252,12 +262,13 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
|||||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||||
|
|
||||||
// ── IPC: 약관 다운로드 ──────────────────────────────
|
// ── IPC: 약관 다운로드 ──────────────────────────────
|
||||||
// 사이트가 /manifest/terms/<packKey>/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
|
// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신
|
||||||
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
|
// kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정.
|
||||||
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
|
||||||
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
||||||
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
|
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
|
||||||
|
return { ok: false, message: 'invalid term kind' }
|
||||||
|
}
|
||||||
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||||
try {
|
try {
|
||||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
|
||||||
@@ -268,6 +279,31 @@ ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// rp 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
|
||||||
|
// showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
|
||||||
|
ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
|
||||||
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||||
|
try {
|
||||||
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
|
||||||
|
const buf = await fetchBuffer(url)
|
||||||
|
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
|
||||||
|
const items = Array.isArray(parsed.terms) ? parsed.terms : []
|
||||||
|
const terms: Array<{ kind: string; label: string }> = []
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || typeof it !== 'object') continue
|
||||||
|
const entry = it as Record<string, unknown>
|
||||||
|
if (entry.showInInstallerRp !== true) continue
|
||||||
|
const kind = typeof entry.kind === 'string' ? entry.kind : ''
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label : ''
|
||||||
|
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
|
||||||
|
terms.push({ kind, label })
|
||||||
|
}
|
||||||
|
return { ok: true, terms }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: (error as Error).message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── IPC: 2단계 설치 ──────────────────────────────────
|
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||||
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||||
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
||||||
@@ -285,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 })
|
||||||
@@ -307,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
|
||||||
@@ -344,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,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 })
|
||||||
@@ -370,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 }))
|
||||||
}
|
}
|
||||||
@@ -458,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 () => {
|
||||||
|
|||||||
@@ -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 폴링이 들어간다.
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ const api = {
|
|||||||
selectPack: (packKey: string): Promise<void> =>
|
selectPack: (packKey: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('rp:packs:select', packKey),
|
ipcRenderer.invoke('rp:packs:select', packKey),
|
||||||
|
|
||||||
/** 약관(Markdown) 다운로드. kind: 'resourcepack' | 'installer-rp'. */
|
/** 약관(Markdown) 다운로드. v0.3.4~ : 임의 kind 허용 (사이트에서 설정). */
|
||||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||||
ipcRenderer.invoke('rp:terms:get', kind),
|
ipcRenderer.invoke('rp:terms:get', kind),
|
||||||
|
|
||||||
|
/** rp 인스톨러에 표시할 약관 목록 (사이트의 visibility 토글로 필터링). */
|
||||||
|
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
|
||||||
|
ipcRenderer.invoke('rp:terms:list'),
|
||||||
|
|
||||||
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
||||||
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
||||||
ipcRenderer.invoke('rp:install:start'),
|
ipcRenderer.invoke('rp:install:start'),
|
||||||
@@ -23,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
@@ -155,11 +155,11 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||||
// 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
|
// v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증.
|
||||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||||
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
||||||
if (!TERM_KIND_WHITELIST.has(kind)) {
|
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
|
||||||
return { ok: false, message: 'unknown term kind' }
|
return { ok: false, message: 'invalid term kind' }
|
||||||
}
|
}
|
||||||
if (!state.selectedKey) {
|
if (!state.selectedKey) {
|
||||||
return { ok: false, message: 'pack not selected' }
|
return { ok: false, message: 'pack not selected' }
|
||||||
@@ -173,6 +173,31 @@ ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean;
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 메인 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
|
||||||
|
// showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
|
||||||
|
ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
|
||||||
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||||
|
try {
|
||||||
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
|
||||||
|
const buf = await fetchBuffer(url)
|
||||||
|
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
|
||||||
|
const items = Array.isArray(parsed.terms) ? parsed.terms : []
|
||||||
|
const terms: Array<{ kind: string; label: string }> = []
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || typeof it !== 'object') continue
|
||||||
|
const entry = it as Record<string, unknown>
|
||||||
|
if (entry.showInInstaller !== true) continue
|
||||||
|
const kind = typeof entry.kind === 'string' ? entry.kind : ''
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label : ''
|
||||||
|
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
|
||||||
|
terms.push({ kind, label })
|
||||||
|
}
|
||||||
|
return { ok: true, terms }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: (error as Error).message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
||||||
if (!state.packs.has(packKey)) {
|
if (!state.packs.has(packKey)) {
|
||||||
throw new Error(t('errors.packNotFound'))
|
throw new Error(t('errors.packNotFound'))
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const api = {
|
|||||||
// 약관(Markdown) 다운로드
|
// 약관(Markdown) 다운로드
|
||||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||||
ipcRenderer.invoke('terms:get', kind),
|
ipcRenderer.invoke('terms:get', kind),
|
||||||
|
// 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨)
|
||||||
|
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
|
||||||
|
ipcRenderer.invoke('terms:list'),
|
||||||
|
|
||||||
// 3-1
|
// 3-1
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||||
fileDirPath, viewsDirPath, publicDirPath
|
fileDirPath, viewsDirPath, publicDirPath
|
||||||
} from '../shared/paths.js'
|
} from '../shared/paths.js'
|
||||||
import { isPublicTermsFile } from '../shared/store.js'
|
import {
|
||||||
|
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
|
||||||
|
} from '../shared/store.js'
|
||||||
import { loadEnv } from '../shared/env.js'
|
import { loadEnv } from '../shared/env.js'
|
||||||
import { t, localeDict } from './i18n.js'
|
import { t, localeDict } from './i18n.js'
|
||||||
import { indexRouter } from './routes/index.js'
|
import { indexRouter } from './routes/index.js'
|
||||||
@@ -66,17 +68,55 @@ app.get('/manifest.json', (_req, res) => {
|
|||||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||||
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||||
app.get('/manifest/terms/:packKey/:fileName', (req, res) => {
|
//
|
||||||
|
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
|
||||||
|
// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
|
||||||
|
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
|
||||||
|
// 생성되는 것은 loadPackDefinition 으로 차단.
|
||||||
|
// 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json.
|
||||||
|
// 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이
|
||||||
|
// 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`)
|
||||||
|
// 으로 필터링해서 탭을 만든다.
|
||||||
|
app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { packKey } = req.params
|
||||||
|
if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) {
|
||||||
|
res.status(404).json({ terms: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pack = await loadPackDefinition(packKey)
|
||||||
|
if (!pack) {
|
||||||
|
res.status(404).json({ terms: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const terms = await listTermsWithLabels(packKey)
|
||||||
|
res.json({ terms })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => {
|
||||||
|
try {
|
||||||
const { packKey, fileName } = req.params
|
const { packKey, fileName } = req.params
|
||||||
if (!isPublicTermsFile(packKey, fileName)) {
|
if (!isPublicTermsFile(packKey, fileName)) {
|
||||||
res.status(404).send('Not Found')
|
res.status(404).send('Not Found')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const pack = await loadPackDefinition(packKey)
|
||||||
|
if (!pack) {
|
||||||
|
res.status(404).send('Not Found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ensurePackTermsDir(packKey)
|
||||||
res.type('text/markdown; charset=utf-8')
|
res.type('text/markdown; charset=utf-8')
|
||||||
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
|
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
|
||||||
if (!err || res.headersSent) return
|
if (!err || res.headersSent) return
|
||||||
res.status(404).send('Not Found')
|
res.status(404).send('Not Found')
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
||||||
|
|||||||
@@ -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)}`)
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import {
|
|||||||
createTerm,
|
createTerm,
|
||||||
deletePackKeys,
|
deletePackKeys,
|
||||||
deleteTerm,
|
deleteTerm,
|
||||||
getTermLabel,
|
getTermEntry,
|
||||||
importTerms,
|
importTerms,
|
||||||
isBuiltinTermKind,
|
|
||||||
isTermKind,
|
isTermKind,
|
||||||
listPackKeys,
|
listPackKeys,
|
||||||
listTermsWithLabels,
|
listTermsWithLabels,
|
||||||
@@ -20,7 +19,8 @@ import {
|
|||||||
renamePack,
|
renamePack,
|
||||||
sanitizePackKey,
|
sanitizePackKey,
|
||||||
saveTerm,
|
saveTerm,
|
||||||
savePackList
|
savePackList,
|
||||||
|
setTermVisibility
|
||||||
} from '../../shared/store.js'
|
} from '../../shared/store.js'
|
||||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||||
@@ -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` })
|
||||||
}
|
}
|
||||||
@@ -306,8 +306,9 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
|||||||
|
|
||||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||||
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||||
// builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만
|
||||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<packKey>/<kind>.md 로 받아 표시한다.
|
// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는
|
||||||
|
// /manifest/terms/<packKey>/index.json 으로 자신에게 표시할 약관 목록을 받는다.
|
||||||
|
|
||||||
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||||
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||||
@@ -411,10 +412,6 @@ opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, r
|
|||||||
res.status(400).send(t('terms.invalidKind'))
|
res.status(400).send(t('terms.invalidKind'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isBuiltinTermKind(kind)) {
|
|
||||||
res.status(400).send(t('terms.cannotDeleteBuiltin'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await deleteTerm(packKey, kind)
|
await deleteTerm(packKey, kind)
|
||||||
res.redirect(`/op/agreement/${packKey}`)
|
res.redirect(`/op/agreement/${packKey}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -435,14 +432,20 @@ opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next
|
|||||||
res.status(404).send(t('errors.unknown'))
|
res.status(404).send(t('errors.unknown'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const entry = await getTermEntry(packKey, kind)
|
||||||
|
if (!entry) {
|
||||||
|
res.status(404).send(t('errors.unknown'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const content = await loadTerm(packKey, kind)
|
const content = await loadTerm(packKey, kind)
|
||||||
const label = await getTermLabel(packKey, kind)
|
|
||||||
res.render('op/termsEditor', {
|
res.render('op/termsEditor', {
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
packKey,
|
packKey,
|
||||||
pack: definition,
|
pack: definition,
|
||||||
kind,
|
kind,
|
||||||
label,
|
label: entry.label,
|
||||||
|
showInInstaller: entry.showInInstaller,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp,
|
||||||
content
|
content
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -465,6 +468,17 @@ opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, nex
|
|||||||
}
|
}
|
||||||
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||||
await saveTerm(packKey, kind, content)
|
await saveTerm(packKey, kind, content)
|
||||||
|
// visibility 토글이 함께 전송되면 동시에 갱신. 두 값이 모두 false 면 어디에도
|
||||||
|
// 표시되지 않지만 사용자가 의도적으로 선택한 결과이므로 그대로 저장한다.
|
||||||
|
if (
|
||||||
|
typeof req.body?.showInInstaller === 'boolean'
|
||||||
|
|| typeof req.body?.showInInstallerRp === 'boolean'
|
||||||
|
) {
|
||||||
|
await setTermVisibility(packKey, kind, {
|
||||||
|
showInInstaller: req.body.showInInstaller === true,
|
||||||
|
showInInstallerRp: req.body.showInInstallerRp === true
|
||||||
|
})
|
||||||
|
}
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
|
// pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때
|
||||||
|
// 옛 약관이 부활하는 것을 막기 위함.
|
||||||
|
const termsDir = path.join(manifestTermsDirPath, key)
|
||||||
|
try {
|
||||||
|
await fsp.rm(termsDir, { recursive: true, force: true })
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
await syncManifestWith(key, '', 'remove')
|
await syncManifestWith(key, '', 'remove')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +206,31 @@ 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 이름이 바뀌었는데 약관이
|
||||||
|
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
|
||||||
|
const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
|
||||||
|
const newTermsDir = path.join(manifestTermsDirPath, safeNew)
|
||||||
|
try {
|
||||||
|
await fsp.rename(oldTermsDir, newTermsDir)
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
// 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다
|
||||||
|
// (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때
|
||||||
|
// 새 폴더 내용이 정상적으로 사용된다).
|
||||||
|
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
|
||||||
|
}
|
||||||
await syncManifestWith(oldKey, '', 'remove')
|
await syncManifestWith(oldKey, '', 'remove')
|
||||||
}
|
}
|
||||||
await syncManifestWith(safeNew, pack.name, 'upsert')
|
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||||
@@ -266,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
|
||||||
@@ -297,24 +331,31 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
|||||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||||
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
||||||
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
|
// - 각 약관(.md) 은 `_meta.json` 의 `terms.<kind>` 엔트리로 라벨/표시 대상이 관리된다.
|
||||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `<packKey>/_meta.json` 에 저장.
|
// 엔트리: { label, showInInstaller, showInInstallerRp }
|
||||||
|
// - 모든 약관은 추가/삭제 가능. builtin 같은 보호 개념은 더 이상 없음 (v0.3.4~).
|
||||||
|
// 인스톨러는 하드코딩 5종 대신 `index.json` 에서 자기 인스톨러용 약관 목록을 받는다.
|
||||||
|
// - 첫 접근 시 5개 기본 약관(map/mod/installer + resourcepack/installer-rp) 을 시드.
|
||||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||||
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
||||||
export type TermKind = string
|
export type TermKind = string
|
||||||
|
|
||||||
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
|
/**
|
||||||
export const BUILTIN_TERM_KINDS = ['map', 'resourcepack', 'mod', 'installer', 'installer-rp'] as const
|
* 처음 pack 폴더를 만들 때 시드되는 기본 약관 5종 + 기본 표시 대상.
|
||||||
export type BuiltinTermKind = typeof BUILTIN_TERM_KINDS[number]
|
* 사용자는 이후 자유롭게 삭제하거나 표시 대상을 바꿀 수 있다.
|
||||||
|
*/
|
||||||
/** builtin 라벨. 사용자 정의 kind 는 _meta.json 에 저장된 라벨을 쓴다. */
|
const DEFAULT_TERM_SEEDS: Array<{
|
||||||
const BUILTIN_TERM_LABELS: Record<BuiltinTermKind, string> = {
|
kind: string
|
||||||
'map': '맵 약관',
|
label: string
|
||||||
'resourcepack': '리소스팩 약관',
|
showInInstaller: boolean
|
||||||
'mod': '모드 약관',
|
showInInstallerRp: boolean
|
||||||
'installer': '설치기 약관',
|
}> = [
|
||||||
'installer-rp': '리소스팩 설치기 약관'
|
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false },
|
||||||
}
|
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false },
|
||||||
|
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false },
|
||||||
|
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true },
|
||||||
|
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true }
|
||||||
|
]
|
||||||
|
|
||||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||||
|
|
||||||
@@ -322,13 +363,14 @@ export function isTermKind(value: unknown): value is TermKind {
|
|||||||
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBuiltinTermKind(value: string): value is BuiltinTermKind {
|
export interface TermEntry {
|
||||||
return (BUILTIN_TERM_KINDS as readonly string[]).includes(value)
|
label: string
|
||||||
|
showInInstaller: boolean
|
||||||
|
showInInstallerRp: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TermsMeta {
|
interface TermsMeta {
|
||||||
/** 사용자 정의 kind 라벨. builtin 은 들어가지 않는다. */
|
terms: Record<string, TermEntry>
|
||||||
customLabels: Record<string, string>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMS_META_FILE = '_meta.json'
|
const TERMS_META_FILE = '_meta.json'
|
||||||
@@ -347,31 +389,26 @@ function isValidPackKey(packKey: string): boolean {
|
|||||||
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
|
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
|
||||||
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
||||||
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
||||||
|
*
|
||||||
|
* 공개 라우트(`/manifest/terms/<packKey>/<file>`) 에서도 호출되므로 export 한다.
|
||||||
|
* 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다
|
||||||
|
* (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다).
|
||||||
*/
|
*/
|
||||||
async function ensurePackTermsDir(packKey: string): Promise<string> {
|
export async function ensurePackTermsDir(packKey: string): Promise<string> {
|
||||||
const dir = termsDirForPack(packKey)
|
const dir = termsDirForPack(packKey)
|
||||||
|
let isNew = false
|
||||||
try {
|
try {
|
||||||
await fsp.access(dir)
|
await fsp.access(dir)
|
||||||
return dir
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
isNew = true
|
||||||
await fsp.mkdir(dir, { recursive: true })
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
// 레거시 전역 파일을 시드로 복사.
|
// 레거시(전역) .md 파일이 남아 있으면 그대로 복사 (.md 만, _meta.json 은 새 스키마로 새로 씀).
|
||||||
try {
|
try {
|
||||||
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
||||||
for (const ent of legacyEntries) {
|
for (const ent of legacyEntries) {
|
||||||
if (!ent.isFile()) continue
|
if (!ent.isFile()) continue
|
||||||
const name = ent.name
|
const name = ent.name
|
||||||
if (name === TERMS_META_FILE) {
|
|
||||||
try {
|
|
||||||
await fsp.copyFile(
|
|
||||||
path.join(manifestTermsDirPath, name),
|
|
||||||
path.join(dir, name)
|
|
||||||
)
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!name.toLowerCase().endsWith('.md')) continue
|
if (!name.toLowerCase().endsWith('.md')) continue
|
||||||
const kind = name.slice(0, -3)
|
const kind = name.slice(0, -3)
|
||||||
if (!TERM_KIND_RE.test(kind)) continue
|
if (!TERM_KIND_RE.test(kind)) continue
|
||||||
@@ -382,26 +419,125 @@ async function ensurePackTermsDir(packKey: string): Promise<string> {
|
|||||||
)
|
)
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
} catch (error2) {
|
||||||
|
if ((error2 as NodeJS.ErrnoException).code !== 'ENOENT') throw error2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 폴더가 새로 만들어졌든 기존이든, _meta.json 이 없거나 구 스키마면 5종 기본 + .md 매칭으로 보완.
|
||||||
|
await ensureMetaInitialized(dir, isNew)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_meta.json` 이 없으면 5종 기본 + 디스크 .md 매칭으로 새로 작성한다.
|
||||||
|
* 구 스키마(`customLabels`) 가 있으면 새 스키마(`terms`) 로 변환한다.
|
||||||
|
* 이미 새 스키마면 그대로 둔다 (사용자가 끈 visibility 가 다시 켜지지 않도록).
|
||||||
|
*/
|
||||||
|
async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): Promise<void> {
|
||||||
|
const metaPath = path.join(dir, TERMS_META_FILE)
|
||||||
|
let parsed: unknown = null
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(metaPath, 'utf8')
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
return dir
|
|
||||||
|
// 이미 새 스키마면 종료. 빠진 default kind 가 디스크에 있다면 그것만 보충.
|
||||||
|
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms) {
|
||||||
|
const meta = parsed as { terms: Record<string, unknown> }
|
||||||
|
let changed = false
|
||||||
|
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||||
|
if (meta.terms[seed.kind]) continue
|
||||||
|
// .md 가 실제로 디스크에 있을 때만 보충 (없는 약관까지 자동 부활시키지 않음).
|
||||||
|
try {
|
||||||
|
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta.terms[seed.kind] = {
|
||||||
|
label: seed.label,
|
||||||
|
showInInstaller: seed.showInInstaller,
|
||||||
|
showInInstallerRp: seed.showInInstallerRp
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await fsp.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구 스키마 customLabels 만 있던 경우 → 새 스키마로 변환.
|
||||||
|
const oldCustomLabels: Record<string, string> = {}
|
||||||
|
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).customLabels
|
||||||
|
&& typeof (parsed as Record<string, unknown>).customLabels === 'object') {
|
||||||
|
for (const [k, v] of Object.entries((parsed as { customLabels: Record<string, unknown> }).customLabels)) {
|
||||||
|
if (typeof v === 'string' && TERM_KIND_RE.test(k)) oldCustomLabels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terms: Record<string, TermEntry> = {}
|
||||||
|
// 5종 기본: 디스크에 .md 가 있을 때만 추가 (없는 건 사용자가 의도적으로 지운 것일 수 있음).
|
||||||
|
// 다만 폴더가 막 생성된 경우는 5종을 무조건 시드 (legacy 시드가 비어 있어도).
|
||||||
|
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||||
|
if (!dirWasJustCreated) {
|
||||||
|
try {
|
||||||
|
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 폴더 새로 생성 케이스: .md 가 없으면 빈 파일 만들어 줌.
|
||||||
|
const filePath = path.join(dir, `${seed.kind}.md`)
|
||||||
|
try {
|
||||||
|
await fsp.access(filePath)
|
||||||
|
} catch {
|
||||||
|
await fsp.writeFile(filePath, `# ${seed.label}\n\n`, 'utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
terms[seed.kind] = {
|
||||||
|
label: seed.label,
|
||||||
|
showInInstaller: seed.showInInstaller,
|
||||||
|
showInInstallerRp: seed.showInInstallerRp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로.
|
||||||
|
for (const [k, label] of Object.entries(oldCustomLabels)) {
|
||||||
|
if (terms[k]) continue
|
||||||
|
try {
|
||||||
|
await fsp.access(path.join(dir, `${k}.md`))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
terms[k] = { label, showInInstaller: true, showInInstallerRp: true }
|
||||||
|
}
|
||||||
|
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
|
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
|
||||||
const dir = await ensurePackTermsDir(packKey)
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
try {
|
try {
|
||||||
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
|
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw) as unknown
|
||||||
const customLabels: Record<string, string> = {}
|
const result: TermsMeta = { terms: {} }
|
||||||
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
|
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms
|
||||||
for (const [k, v] of Object.entries(parsed.customLabels as Record<string, unknown>)) {
|
&& typeof (parsed as Record<string, unknown>).terms === 'object') {
|
||||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) customLabels[k] = v
|
for (const [k, v] of Object.entries((parsed as { terms: Record<string, unknown> }).terms)) {
|
||||||
|
if (!TERM_KIND_RE.test(k)) continue
|
||||||
|
if (!v || typeof v !== 'object') continue
|
||||||
|
const entry = v as Record<string, unknown>
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label : k
|
||||||
|
result.terms[k] = {
|
||||||
|
label,
|
||||||
|
showInInstaller: entry.showInInstaller === true,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp === true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { customLabels }
|
}
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { customLabels: {} }
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { terms: {} }
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,49 +554,83 @@ async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
|||||||
export interface TermItem {
|
export interface TermItem {
|
||||||
kind: string
|
kind: string
|
||||||
label: string
|
label: string
|
||||||
builtin: boolean
|
showInInstaller: boolean
|
||||||
|
showInInstallerRp: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 디스크의 .md 파일 + _meta.json 을 합쳐 약관 목록을 만든다.
|
* 디스크의 .md 파일과 매칭되면서 `_meta.json` 의 `terms` 에 등록된 약관 목록을 반환.
|
||||||
* - builtin 5종은 파일 존재 여부와 무관하게 항상 포함된다 (인스톨러가 fetch 하므로).
|
* 정렬: 5종 기본(DEFAULT_TERM_SEEDS 순서) → 그 외 사용자 정의 (kind 사전순).
|
||||||
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
|
|
||||||
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
|
|
||||||
*/
|
*/
|
||||||
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
|
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
|
||||||
const dir = await ensurePackTermsDir(packKey)
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
const meta = await loadTermsMeta(packKey)
|
const meta = await loadTermsMeta(packKey)
|
||||||
const items: TermItem[] = []
|
|
||||||
for (const kind of BUILTIN_TERM_KINDS) {
|
|
||||||
items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true })
|
|
||||||
}
|
|
||||||
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
|
|
||||||
let onDisk: string[] = []
|
let onDisk: string[] = []
|
||||||
try {
|
try {
|
||||||
onDisk = await fsp.readdir(dir)
|
onDisk = await fsp.readdir(dir)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
const customKinds = new Set<string>()
|
const mdKinds = new Set<string>()
|
||||||
for (const fname of onDisk) {
|
for (const fname of onDisk) {
|
||||||
if (!fname.toLowerCase().endsWith('.md')) continue
|
if (!fname.toLowerCase().endsWith('.md')) continue
|
||||||
const kind = fname.slice(0, -3)
|
const kind = fname.slice(0, -3)
|
||||||
if (!TERM_KIND_RE.test(kind)) continue
|
if (!TERM_KIND_RE.test(kind)) continue
|
||||||
if (isBuiltinTermKind(kind)) continue
|
mdKinds.add(kind)
|
||||||
customKinds.add(kind)
|
|
||||||
}
|
}
|
||||||
// _meta.json 에 라벨이 등록된 것만 노출 (라벨 없는 orphan .md 는 무시).
|
const items: TermItem[] = []
|
||||||
for (const kind of Object.keys(meta.customLabels).sort((a, b) => a.localeCompare(b, 'ko'))) {
|
const seen = new Set<string>()
|
||||||
if (!customKinds.has(kind)) continue
|
// 1) 기본 시드 순서 우선.
|
||||||
items.push({ kind, label: meta.customLabels[kind], builtin: false })
|
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||||
|
const entry = meta.terms[seed.kind]
|
||||||
|
if (!entry) continue
|
||||||
|
if (!mdKinds.has(seed.kind)) continue
|
||||||
|
items.push({
|
||||||
|
kind: seed.kind,
|
||||||
|
label: entry.label,
|
||||||
|
showInInstaller: entry.showInInstaller,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp
|
||||||
|
})
|
||||||
|
seen.add(seed.kind)
|
||||||
|
}
|
||||||
|
// 2) 그 외 사용자 정의: 사전순.
|
||||||
|
const rest = Object.keys(meta.terms).filter((k) => !seen.has(k))
|
||||||
|
rest.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||||
|
for (const kind of rest) {
|
||||||
|
if (!mdKinds.has(kind)) continue
|
||||||
|
const entry = meta.terms[kind]
|
||||||
|
items.push({
|
||||||
|
kind,
|
||||||
|
label: entry.label,
|
||||||
|
showInInstaller: entry.showInInstaller,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
||||||
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
|
|
||||||
const meta = await loadTermsMeta(packKey)
|
const meta = await loadTermsMeta(packKey)
|
||||||
return meta.customLabels[kind] ?? kind
|
return meta.terms[kind]?.label ?? kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTermEntry(packKey: string, kind: string): Promise<TermEntry | null> {
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
return meta.terms[kind] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTermVisibility(
|
||||||
|
packKey: string,
|
||||||
|
kind: string,
|
||||||
|
visibility: { showInInstaller: boolean; showInInstallerRp: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
const entry = meta.terms[kind]
|
||||||
|
if (!entry) throw new Error('term not found')
|
||||||
|
entry.showInInstaller = !!visibility.showInInstaller
|
||||||
|
entry.showInInstallerRp = !!visibility.showInInstallerRp
|
||||||
|
await saveTermsMeta(packKey, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
||||||
@@ -483,14 +653,17 @@ export async function saveTerm(packKey: string, kind: TermKind, markdown: string
|
|||||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
|
/**
|
||||||
|
* 새 약관 추가. kind 충돌은 예외. 빈 `.md` 파일을 만든다.
|
||||||
|
* v0.3.4~: builtin 보호 개념이 없어 임의 kind 를 추가/삭제할 수 있다. 다만
|
||||||
|
* `meta.terms` 에 이미 있는 kind 와 충돌하면 거부. 표시 대상 기본값은 양쪽 인스톨러 모두.
|
||||||
|
*/
|
||||||
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
|
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
|
||||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created')
|
|
||||||
const cleanLabel = label.trim()
|
const cleanLabel = label.trim()
|
||||||
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||||
const meta = await loadTermsMeta(packKey)
|
const meta = await loadTermsMeta(packKey)
|
||||||
if (meta.customLabels[kind]) throw new Error('term kind already exists')
|
if (meta.terms[kind]) throw new Error('term kind already exists')
|
||||||
const dir = await ensurePackTermsDir(packKey)
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
const filePath = path.join(dir, `${kind}.md`)
|
const filePath = path.join(dir, `${kind}.md`)
|
||||||
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||||
@@ -501,14 +674,19 @@ export async function createTerm(packKey: string, kind: string, label: string):
|
|||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||||
meta.customLabels[kind] = cleanLabel
|
// 기본 시드 kind 면 그 시드의 visibility 기본을 따르고, 그 외는 양쪽 인스톨러 모두 표시.
|
||||||
|
const seed = DEFAULT_TERM_SEEDS.find((s) => s.kind === kind)
|
||||||
|
meta.terms[kind] = {
|
||||||
|
label: cleanLabel,
|
||||||
|
showInInstaller: seed ? seed.showInInstaller : true,
|
||||||
|
showInInstallerRp: seed ? seed.showInInstallerRp : true
|
||||||
|
}
|
||||||
await saveTermsMeta(packKey, meta)
|
await saveTermsMeta(packKey, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 사용자 정의 약관 삭제. builtin 은 거부. */
|
/** 약관 삭제. v0.3.4~: builtin 보호 없음 — 모든 kind 삭제 가능. */
|
||||||
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
||||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted')
|
|
||||||
const dir = await ensurePackTermsDir(packKey)
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
const filePath = path.join(dir, `${kind}.md`)
|
const filePath = path.join(dir, `${kind}.md`)
|
||||||
try {
|
try {
|
||||||
@@ -517,17 +695,17 @@ export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
|||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
const meta = await loadTermsMeta(packKey)
|
const meta = await loadTermsMeta(packKey)
|
||||||
if (meta.customLabels[kind]) {
|
if (meta.terms[kind]) {
|
||||||
delete meta.customLabels[kind]
|
delete meta.terms[kind]
|
||||||
await saveTermsMeta(packKey, meta)
|
await saveTermsMeta(packKey, meta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
||||||
* - source 의 모든 .md 와 _meta.json 을 target 에 덮어쓴다.
|
* - source 의 모든 .md 를 target 에 덮어쓴다.
|
||||||
* - target 에만 있던 사용자 정의 약관은 그대로 둔다 (source 에는 없으니 안 건드림).
|
* - target 에만 있던 약관 엔트리는 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||||
* - 동일한 kind 가 source 에도 있다면 source 값으로 덮어씀.
|
* - 동일한 kind 가 source 에도 있다면 source 의 라벨/표시 대상으로 덮어씀.
|
||||||
*/
|
*/
|
||||||
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
||||||
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
||||||
@@ -554,12 +732,12 @@ export async function importTerms(targetPackKey: string, sourcePackKey: string):
|
|||||||
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
|
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 정의 라벨도 source 기준으로 머지 (덮어쓰기).
|
// 약관 엔트리도 source 기준으로 머지 (덮어쓰기).
|
||||||
const mergedLabels: Record<string, string> = { ...targetMeta.customLabels }
|
const mergedTerms: Record<string, TermEntry> = { ...targetMeta.terms }
|
||||||
for (const [k, v] of Object.entries(sourceMeta.customLabels)) {
|
for (const [k, v] of Object.entries(sourceMeta.terms)) {
|
||||||
mergedLabels[k] = v
|
mergedTerms[k] = { ...v }
|
||||||
}
|
}
|
||||||
await saveTermsMeta(targetPackKey, { customLabels: mergedLabels })
|
await saveTermsMeta(targetPackKey, { terms: mergedTerms })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -21,11 +21,19 @@
|
|||||||
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
|
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
|
||||||
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||||
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
|
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
|
||||||
.builtinBadge {
|
.visibilityBadges {
|
||||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
display: flex; gap: 6px; flex-wrap: wrap;
|
||||||
background: rgba(255,255,255,0.08); color: var(--text-muted);
|
}
|
||||||
|
.visibilityBadge {
|
||||||
|
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px;
|
||||||
|
background: rgba(76, 175, 80, 0.15); color: #8ed68f;
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.35);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
.visibilityBadge.off {
|
||||||
|
background: rgba(255,255,255,0.05); color: var(--text-muted);
|
||||||
|
border-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
.termsSideBySide {
|
.termsSideBySide {
|
||||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
|
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
|
||||||
}
|
}
|
||||||
@@ -76,21 +84,20 @@
|
|||||||
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
|
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
|
||||||
<div class="termsRowLabel">
|
<div class="termsRowLabel">
|
||||||
<h2><%= item.label %></h2>
|
<h2><%= item.label %></h2>
|
||||||
<% if (item.builtin) { %>
|
<span class="visibilityBadges">
|
||||||
<span class="builtinBadge"><%= t('terms.builtinBadge') %></span>
|
<span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span>
|
||||||
<% } %>
|
<span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="termsRowSub"><%= item.kind %>.md</div>
|
<div class="termsRowSub"><%= item.kind %>.md</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="termsRowActions">
|
<div class="termsRowActions">
|
||||||
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
|
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
|
||||||
<% if (!item.builtin) { %>
|
|
||||||
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
|
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
|
||||||
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
|
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
|
||||||
style="margin:0;">
|
style="margin:0;">
|
||||||
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
|
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
|
||||||
</form>
|
</form>
|
||||||
<% } %>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -29,6 +29,19 @@
|
|||||||
<span class="statusText" id="status"></span>
|
<span class="statusText" id="status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 표시 대상 토글: 어느 인스톨러에서 이 약관을 보여줄지 (중복 선택 가능). -->
|
||||||
|
<fieldset class="termsVisibility" style="margin-top:16px; padding:10px 14px; border:1px solid var(--border, #30363d); border-radius:8px;">
|
||||||
|
<legend style="padding:0 6px; font-size:12px; color:var(--text-muted);"><%= t('terms.visibilityHeading') %></legend>
|
||||||
|
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:18px;">
|
||||||
|
<input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> />
|
||||||
|
<span><%= t('terms.visibilityInstaller') %></span>
|
||||||
|
</label>
|
||||||
|
<label style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> />
|
||||||
|
<span><%= t('terms.visibilityInstallerRp') %></span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
||||||
|
|
||||||
<div id="editorWrap" class="termsEditorWrap">
|
<div id="editorWrap" class="termsEditorWrap">
|
||||||
|
|||||||
Reference in New Issue
Block a user