Add /op/list, /op/list/:pack, /op/datapack web admin + spec lock

docs/add.md
 - 사진 PNG 규격을 1024×1024 (4×4 블록 슬롯 × ×16 배율) 로 못박음
 - 짧은 변 기준 가운데 정사각 크롭 + 1024 초과 시만 축소, 미만은 native 유지

신규 라우트 (모두 requireAuth):
 - GET  /op/list                          → manifest 카드 목록
 - GET  /op/list/:pack                    → 음악목록·사진목록 탭 편집기
 - POST /op/list/:pack                    → file/list/<pack>.json 저장 (JSON)
 - POST /op/list/:pack/playlist           → yt-dlp 로 플레이리스트 펼치기
 - GET  /op/datapack                      → 음악퀴즈 선택 + 출력
 - GET  /op/datapack/:pack/generate       → 임시 포맷 mcfunction 텍스트

shared/types.ts: PackList / MusicListEntry / ImageListEntry
shared/store.ts: loadPackList, savePackList, normalizePackList
shared/paths.ts: fileListDirPath = file/list/
server/youtube.ts: yt-dlp 플레이리스트 펼치기 (--flat-playlist --dump-json),
  설치 안 됐을 때 NO_YTDLP 코드로 503.

UI:
 - views/op/list.ejs: 가로 카드 목록 + 돌아가기 버튼
 - views/op/listEditor.ejs + public/listEditor.js: 탭 전환, 드래그 정렬,
   우클릭 컨텍스트 메뉴(수정/삭제), 사진 수정 모달의 [유튜브 / 이미지] 토글,
   목록 저장·초기화·플레이리스트 불러오기 확인 팝업
 - views/op/datapack.ejs: 음악퀴즈 카드 선택 팝업 → 데이터팩 출력 + 복사
 - views/op/dashboard.ejs: 상단에 [음악목록 수정] [데이터팩 수정] 버튼
 - public/styles.css: 탭, 트랙 로우, 이미지 그리드, 컨텍스트 메뉴, 모달, 코드블록

.gitignore: conversations/ 추가.

스모크: login → /op/list 렌더, POST 저장 라운드트립 OK,
/op/datapack/.../generate 텍스트 출력 OK, 플레이리스트 fetch는 yt-dlp 미설치
환경에서 503 NO_YTDLP 메시지 정상.

Section 1 (리소스팩 설치기 EXE: yt-dlp 음악 다운로드, painting variant
텍스처 패키징, 리소스팩 zip 배치) 은 후속 커밋에서 작업.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 11:38:30 +09:00
parent 26cc625de6
commit a2817c921d
14 changed files with 1034 additions and 9 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
release/
logs/
*.log
conversations/

View File

@@ -49,11 +49,15 @@ yt-dlp로 음악 다운로드, 별도 경로로 사진 다운로드 → 리소
다운로드 후 painting variant 슬롯 규격에 맞춰 정규화한다 (자세한 슬롯 구조는
`docs/painting-variant.md` 참고).
- 데이터팩에 슬롯 `cover_01 … cover_N` 이 미리 등록돼 있음. 각 슬롯의 가로·세로(블록 단위)는 고정.
- 설치기는 이미지 N장을 받아 슬롯 N개의 PNG 로 변환:
- 목표 해상도 = `width * 16 × height * 16` (또는 그 정수배).
- 비율이 다르면 letterbox(투명 패딩)로 맞춰 잘림 방지.
- 파일명은 `cover_<NN>.png`, 저장 경로는 `resourcepack/assets/musicquiz/textures/painting/`.
- **슬롯 규격(고정, 데이터팩 측)**: `4 × 4` 블록 정사각, `cover_01 … cover_N`.
- **최종 PNG 규격(리소스팩 측)**: 정사각 1:1, 최대 `1024 × 1024` px.
- `4 × 4` 블록 × 블록당 `256` px (×16 배율) → 1024×1024 가 픽셀 그리드와 정확히 일치.
- **정규화 알고리즘**:
1. 가운데 정사각 크롭: `s = min(원본 가로, 원본 세로)``s × s`.
2. `s > 1024` 이면 `1024 × 1024` 로 축소 (Lanczos 권장).
3. `s ≤ 1024` 이면 그대로 `s × s` 유지 (업스케일 없음).
- 파일명: `cover_<NN>.png` (`NN` 은 2자리 0패딩).
- 저장 경로: `resourcepack/assets/musicquiz/textures/painting/`.
- 패키지 완성된 리소스팩을 `%appdata%/.minecraft/resourcepacks/` 에 zip 으로 배치.
### 3) 설치 완료

View File

@@ -0,0 +1,6 @@
{
"musicPlaylistUrl": "",
"imagePlaylistUrl": "",
"music": [],
"images": []
}

313
public/listEditor.js Normal file
View File

@@ -0,0 +1,313 @@
(function () {
'use strict'
var state = {
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
music: Array.isArray(INITIAL.music) ? INITIAL.music.slice() : [],
images: Array.isArray(INITIAL.images) ? INITIAL.images.slice() : []
}
// ── 탭 ────────────────────────────────────────────
var tabBtns = document.querySelectorAll('.tabBtn')
tabBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
tabBtns.forEach(function (b) { b.classList.remove('active') })
btn.classList.add('active')
var key = btn.getAttribute('data-tab')
document.getElementById('tab-music').hidden = (key !== 'music')
document.getElementById('tab-image').hidden = (key !== 'image')
})
})
// ── 유틸 ──────────────────────────────────────────
function ytIdFromUrl(url) {
if (!url) return ''
var m = url.match(/[?&]v=([\w-]{11})/) || url.match(/youtu\.be\/([\w-]{11})/) ||
url.match(/\/embed\/([\w-]{11})/) || url.match(/\/shorts\/([\w-]{11})/)
return m ? m[1] : ''
}
function isYtUrl(url) { return ytIdFromUrl(url).length > 0 }
function thumbUrl(url) {
var id = ytIdFromUrl(url)
if (id) return 'https://i.ytimg.com/vi/' + id + '/hqdefault.jpg'
return url
}
function fmtTime(sec) {
var s = Math.max(0, Math.floor(Number(sec) || 0))
var m = Math.floor(s / 60)
var rem = s % 60
return m + ':' + (rem < 10 ? '0' : '') + rem
}
function setStatus(id, text, isError) {
var el = document.getElementById(id)
el.textContent = text || ''
el.classList.toggle('error', !!isError)
}
// ── 렌더 ──────────────────────────────────────────
function renderMusic() {
var ol = document.getElementById('music-list')
ol.innerHTML = ''
state.music.forEach(function (entry, idx) {
var li = document.createElement('li')
li.className = 'trackRow'
li.draggable = true
li.dataset.index = String(idx)
li.innerHTML =
'<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
'<div class="rowMeta">' +
'<div class="rowTitle">' + escapeHtml(entry.title || '(제목 없음)') + '</div>' +
'<div class="rowSub">' + escapeHtml(entry.artist || '') + '</div>' +
'</div>' +
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
attachRowEvents(li, 'music', idx)
ol.appendChild(li)
})
}
function renderImage() {
var grid = document.getElementById('image-list')
grid.innerHTML = ''
state.images.forEach(function (entry, idx) {
var card = document.createElement('div')
card.className = 'imageCard'
card.draggable = true
card.dataset.index = String(idx)
card.innerHTML =
'<span class="cardNum">' + (idx + 1) + '</span>' +
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>'
attachRowEvents(card, 'image', idx)
grid.appendChild(card)
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]
})
}
// ── 행/카드 이벤트 (드래그·우클릭) ─────────────────
var dragSrc = null
function attachRowEvents(el, type, idx) {
el.addEventListener('dragstart', function (e) {
dragSrc = { type: type, index: idx }
el.classList.add('dragging')
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
e.dataTransfer.effectAllowed = 'move'
})
el.addEventListener('dragend', function () {
el.classList.remove('dragging')
dragSrc = null
})
el.addEventListener('dragover', function (e) {
if (!dragSrc || dragSrc.type !== type) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
el.classList.add('dragOver')
})
el.addEventListener('dragleave', function () { el.classList.remove('dragOver') })
el.addEventListener('drop', function (e) {
e.preventDefault()
el.classList.remove('dragOver')
if (!dragSrc || dragSrc.type !== type) return
var srcIdx = dragSrc.index
var dstIdx = idx
if (srcIdx === dstIdx) return
var arr = (type === 'music') ? state.music : state.images
var moved = arr.splice(srcIdx, 1)[0]
arr.splice(dstIdx, 0, moved)
if (type === 'music') renderMusic(); else renderImage()
})
el.addEventListener('contextmenu', function (e) {
e.preventDefault()
openCtxMenu(e.pageX, e.pageY, type, idx)
})
}
// ── 컨텍스트 메뉴 ─────────────────────────────────
var ctxMenu = document.getElementById('ctxMenu')
var ctxTarget = null
function openCtxMenu(x, y, type, idx) {
ctxTarget = { type: type, index: idx }
ctxMenu.style.left = x + 'px'
ctxMenu.style.top = y + 'px'
ctxMenu.hidden = false
}
function closeCtxMenu() {
ctxMenu.hidden = true
ctxTarget = null
}
document.addEventListener('click', function (e) {
if (ctxMenu.hidden) return
if (!ctxMenu.contains(e.target)) closeCtxMenu()
})
ctxMenu.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () {
if (!ctxTarget) return
var action = b.getAttribute('data-ctx')
var t = ctxTarget
closeCtxMenu()
if (action === 'delete') {
if (t.type === 'music') state.music.splice(t.index, 1)
else state.images.splice(t.index, 1)
if (t.type === 'music') renderMusic(); else renderImage()
} else if (action === 'edit') {
openEditModal(t.type, t.index)
}
})
})
// ── 수정 팝업 ─────────────────────────────────────
var editMusic = document.getElementById('editMusicModal')
var editImage = document.getElementById('editImageModal')
var editingIdx = -1
var editingImageMode = 'yt'
function openEditModal(type, idx) {
editingIdx = idx
if (type === 'music') {
document.getElementById('edit-music-url').value = state.music[idx].url || ''
editMusic.hidden = false
} else {
var url = state.images[idx].url || ''
editingImageMode = isYtUrl(url) ? 'yt' : 'img'
updateSegButtons()
document.getElementById('edit-image-url').value = url
editImage.hidden = false
}
}
function closeAllModals() {
document.querySelectorAll('.modalOverlay').forEach(function (m) { m.hidden = true })
}
document.querySelectorAll('[data-modal-close]').forEach(function (b) {
b.addEventListener('click', closeAllModals)
})
document.querySelectorAll('.modalOverlay').forEach(function (m) {
m.addEventListener('click', function (e) {
if (e.target === m) closeAllModals()
})
})
document.getElementById('edit-music-save').addEventListener('click', function () {
var url = document.getElementById('edit-music-url').value.trim()
if (!url) { return }
state.music[editingIdx].url = url
closeAllModals()
renderMusic()
})
// 이미지 수정 팝업의 토글 (유튜브 주소 / 이미지 주소)
function updateSegButtons() {
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
})
}
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
b.addEventListener('click', function () {
editingImageMode = b.getAttribute('data-seg')
updateSegButtons()
})
})
document.getElementById('edit-image-save').addEventListener('click', function () {
var url = document.getElementById('edit-image-url').value.trim()
if (!url) return
state.images[editingIdx].url = url
closeAllModals()
renderImage()
})
// ── 액션 (save/clear/fetch) ───────────────────────
var confirmModal = document.getElementById('confirmModal')
function ask(title, message, onOk) {
document.getElementById('confirm-title').textContent = title
document.getElementById('confirm-message').textContent = message
confirmModal.hidden = false
var ok = document.getElementById('confirm-ok')
var handler = function () {
ok.removeEventListener('click', handler)
confirmModal.hidden = true
onOk()
}
ok.addEventListener('click', handler)
}
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.getAttribute('data-action')
var target = btn.getAttribute('data-target')
if (action === 'clear') {
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
if (target === 'music') { state.music = []; renderMusic() }
else { state.images = []; renderImage() }
})
} else if (action === 'save') {
doSave(target)
} else if (action === 'fetch') {
doFetch(target)
}
})
})
function doSave(target) {
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
var statusId = 'status-' + target
setStatus(statusId, '저장 중…')
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state)
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (result.ok && result.body.ok) setStatus(statusId, '저장 완료')
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
}).catch(function (err) {
setStatus(statusId, '저장 실패: ' + err.message, true)
})
}
function doFetch(target) {
var input = document.getElementById(target + '-playlist-url')
var url = input.value.trim()
if (!url) {
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
return
}
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
setStatus('status-' + target, '불러오는 중…')
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (!result.ok || !result.body.ok) {
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
return
}
var entries = result.body.entries || []
if (target === 'music') {
state.music = entries.map(function (e) {
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 }
})
renderMusic()
} else {
state.images = entries.map(function (e) { return { url: e.url } })
renderImage()
}
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
}).catch(function (err) {
setStatus('status-' + target, '실패: ' + err.message, true)
})
})
}
// 초기 렌더
renderMusic()
renderImage()
})()

View File

@@ -357,3 +357,125 @@ body.siteBody.centerLayout {
font-size: 13px;
margin: 0 0 14px;
}
/* ── /op/list, /op/list/:pack, /op/datapack ────────────── */
.tabBar { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
.tabBtn {
background: transparent; border: none; color: var(--text-muted);
padding: 10px 18px; cursor: pointer; font-size: 14px;
border-bottom: 2px solid transparent;
}
.tabBtn:hover { color: var(--text); }
.tabBtn.active { color: var(--text); border-bottom-color: var(--accent); }
.tabPanel { display: block; }
.listActionsRow { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.statusText { font-size: 13px; color: var(--text-muted); margin-left: 8px; }
.statusText.error { color: var(--danger); }
.playlistRow { display: flex; gap: 8px; margin-bottom: 16px; }
.textInput {
flex: 1; background: var(--bg); color: var(--text);
border: 1px solid var(--border); padding: 10px 12px; border-radius: 8px;
font-size: 14px;
}
.textInput:focus { outline: none; border-color: var(--accent); }
/* 음악 행 */
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.trackRow {
display: grid;
grid-template-columns: 36px 80px 1fr auto;
gap: 12px; align-items: center;
padding: 8px 12px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px;
cursor: grab; user-select: none;
}
.trackRow.dragging { opacity: 0.5; }
.trackRow.dragOver { border-color: var(--accent); }
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
.rowMeta { min-width: 0; }
.rowTitle { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rowSub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.rowDur { color: var(--text-muted); font-size: 13px; }
/* 사진 그리드 */
.imageGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.imageCard {
position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; cursor: grab; user-select: none;
}
.imageCard.dragging { opacity: 0.5; }
.imageCard.dragOver { border-color: var(--accent); }
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cardNum {
position: absolute; top: 6px; left: 6px;
background: rgba(0,0,0,0.7); color: #fff;
padding: 2px 8px; border-radius: 999px;
font-size: 12px; font-weight: 600;
}
/* 컨텍스트 메뉴 */
.ctxMenu {
position: absolute; z-index: 200;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 8px; padding: 4px; min-width: 120px;
box-shadow: 0 12px 24px rgba(0,0,0,0.5);
}
.ctxMenu button {
display: block; width: 100%; text-align: left;
background: transparent; border: none; color: var(--text);
padding: 8px 12px; cursor: pointer; font-size: 13px; border-radius: 4px;
}
.ctxMenu button:hover { background: var(--bg); }
/* 모달 (음악퀴즈 인스톨러의 modalOverlay 와 호환) */
.modalOverlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modalOverlay[hidden] { display: none; }
.modalCard {
background: var(--bg-alt); border: 1px solid var(--border);
border-radius: 12px; width: min(560px, 92vw); max-height: 86vh;
display: grid; grid-template-rows: auto 1fr auto; overflow: hidden;
}
.modalCard > header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.modalCard > header h3 { margin: 0; font-size: 16px; }
.modalCard > footer { padding: 12px 16px; border-top: 1px solid var(--border); }
.modalClose { background: transparent; border: none; color: var(--text-muted); font-size: 22px; cursor: pointer; }
.modalClose:hover { color: var(--text); }
.modalBody { padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
.modalBody label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-muted); }
/* 토글 버튼 (segmented) */
.segmentedRow { display: flex; gap: 4px; }
.segBtn {
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-muted);
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
.segBtn.active { border-color: var(--accent); color: var(--text); background: rgba(47,129,247,0.15); }
/* 데이터팩 페이지 */
.dpControls { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.dpActions { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
.codeBlock {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 16px; overflow-x: auto;
font-family: 'Consolas','SFMono-Regular',monospace; font-size: 13px;
white-space: pre-wrap; word-break: break-word;
max-height: 60vh; overflow-y: auto;
}
.packCard.pickable { cursor: pointer; }
.packCard.pickable:hover { border-color: var(--accent); }

View File

@@ -4,14 +4,18 @@ import {
deletePackKeys,
listPackKeys,
loadPackDefinition,
loadPackList,
normalizePackDefinition,
normalizePackList,
readAccounts,
renamePack,
sanitizePackKey
sanitizePackKey,
savePackList
} from '../../shared/store'
import { fetchReleaseVersions } from '../../shared/mojang'
import { fetchPlaylistEntries, YtDlpUnavailableError } from '../youtube'
import { requireAuth } from '../middleware/auth'
import type { PackDefinition } from '../../shared/types'
import type { PackDefinition, PackList } from '../../shared/types'
export const opRouter = Router()
@@ -117,6 +121,119 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
}
})
// ─── /op/list ──────────────────────────────────────────────────────────
// 음악퀴즈를 카드 한 줄로 표시. 카드 클릭 → /op/list/:packName
opRouter.get('/op/list', requireAuth, async (req, res, next) => {
try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => ({
key,
definition: await loadPackDefinition(key)
})))
res.render('op/list', { userId: req.session.userId, items })
} catch (error) {
next(error)
}
})
// 음악퀴즈 음악/사진 목록 편집 페이지.
opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
return
}
const list = await loadPackList(packKey)
res.render('op/listEditor', {
userId: req.session.userId,
packKey,
pack: definition,
list
})
} catch (error) {
next(error)
}
})
// 음악/사진 목록 저장. JSON body.
opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' })
return
}
const normalized = normalizePackList(req.body)
await savePackList(packKey, normalized)
res.json({ ok: true })
} catch (error) {
next(error)
}
})
// 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
// body: { url: string }
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' })
return
}
try {
const entries = await fetchPlaylistEntries(url)
res.json({ ok: true, entries })
} catch (error) {
if (error instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' })
return
}
res.status(500).json({ ok: false, message: (error as Error).message })
}
})
// ─── /op/datapack ──────────────────────────────────────────────────────
opRouter.get('/op/datapack', requireAuth, async (req, res, next) => {
try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => ({
key,
definition: await loadPackDefinition(key)
})))
res.render('op/datapack', { userId: req.session.userId, items })
} catch (error) {
next(error)
}
})
// 데이터팩 출력: 임시 포맷의 mcfunction 텍스트를 반환.
opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).type('text/plain').send('음악퀴즈를 찾을 수 없습니다.')
return
}
const list = await loadPackList(packKey)
const lines: string[] = []
lines.push(`# === musicquiz: ${definition.name} ===`)
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}`)
lines.push(`say [musicquiz] 데이터팩 초기화`)
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
list.music.forEach((entry, index) => {
const title = entry.title || '(제목 없음)'
const artist = entry.artist || '(가수 미상)'
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
})
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))

82
src/server/youtube.ts Normal file
View File

@@ -0,0 +1,82 @@
import { spawn } from 'node:child_process'
export interface YtPlaylistEntry {
id: string
title: string
channel: string
durationSec: number
url: string
}
export class YtDlpUnavailableError extends Error {
constructor() {
super('서버에 yt-dlp가 설치돼 있지 않습니다. (수동 입력으로 진행)')
}
}
/**
* yt-dlp 가 시스템에 있는지와 그 경로를 빠르게 확인.
* 없으면 YtDlpUnavailableError.
*/
async function probeYtDlp(): Promise<string> {
return new Promise((resolve, reject) => {
const probe = spawn('yt-dlp', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
let stderr = ''
probe.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
probe.on('error', () => reject(new YtDlpUnavailableError()))
probe.on('close', (code) => {
if (code === 0) resolve('yt-dlp')
else reject(new Error(`yt-dlp 실행 실패 (code=${code}): ${stderr}`))
})
})
}
/**
* 플레이리스트 URL 을 yt-dlp 로 펼쳐 각 영상의 메타데이터를 가져온다.
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
*/
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
const bin = await probeYtDlp()
return new Promise((resolve, reject) => {
const child = spawn(bin, [
'--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(`yt-dlp 플레이리스트 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
return
}
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
const parsed: YtPlaylistEntry[] = []
for (const line of lines) {
try {
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) continue
parsed.push({
id,
title: typeof obj.title === 'string' ? obj.title : '',
channel: typeof obj.channel === 'string'
? obj.channel
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
url: typeof obj.url === 'string' && obj.url.length > 0
? obj.url
: `https://www.youtube.com/watch?v=${id}`
})
} catch {
// 한 줄이 깨져도 나머지는 살림
}
}
resolve(parsed)
})
})
}

View File

@@ -6,5 +6,6 @@ export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const manifestDirPath = path.join(projectRoot, 'manifest')
export const accountFilePath = path.join(projectRoot, 'account.json')
export const fileDirPath = path.join(projectRoot, 'file')
export const fileListDirPath = path.join(fileDirPath, 'list')
export const viewsDirPath = path.join(projectRoot, 'views')
export const publicDirPath = path.join(projectRoot, 'public')

View File

@@ -1,8 +1,11 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths'
import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
PackList, MusicListEntry, ImageListEntry
} from './types'
export async function readManifest(): Promise<Manifest> {
try {
@@ -204,6 +207,63 @@ async function syncManifestWith(key: string, name: string, action: ManifestSyncA
await writeManifest({ packs: filtered })
}
function defaultPackList(): PackList {
return { musicPlaylistUrl: '', imagePlaylistUrl: '', music: [], images: [] }
}
function sanitizeStr(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function sanitizeNumber(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(n) || n < 0) return 0
return Math.floor(n)
}
export function normalizePackList(input: unknown): PackList {
const fallback = defaultPackList()
if (!input || typeof input !== 'object') return fallback
const obj = input as Record<string, unknown>
const music = Array.isArray(obj.music) ? obj.music : []
const images = Array.isArray(obj.images) ? obj.images : []
return {
musicPlaylistUrl: sanitizeStr(obj.musicPlaylistUrl),
imagePlaylistUrl: sanitizeStr(obj.imagePlaylistUrl),
music: music
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): MusicListEntry => ({
url: sanitizeStr(entry.url),
title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec)
}))
.filter((entry) => entry.url.length > 0),
images: images
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) }))
.filter((entry) => entry.url.length > 0)
}
}
export async function loadPackList(packKey: string): Promise<PackList> {
const filePath = path.join(fileListDirPath, `${packKey}.json`)
try {
const raw = await fsp.readFile(filePath, 'utf8')
return normalizePackList(JSON.parse(raw))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultPackList()
throw error
}
}
export async function savePackList(packKey: string, list: PackList): Promise<void> {
await fsp.mkdir(fileListDirPath, { recursive: true })
const filePath = path.join(fileListDirPath, `${packKey}.json`)
const normalized = normalizePackList(list)
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
}
export async function readAccounts(): Promise<AccountEntry[]> {
try {
const raw = await fsp.readFile(accountFilePath, 'utf8')

View File

@@ -36,3 +36,26 @@ export interface AccountEntry {
id: string
password: string
}
export interface MusicListEntry {
/** 유튜브 영상 주소. */
url: string
title: string
artist: string
/** 노래 길이 (초). */
durationSec: number
}
export interface ImageListEntry {
/** 유튜브 영상 주소 또는 일반 이미지 URL. */
url: string
}
export interface PackList {
/** 음악 플레이리스트 원본 주소 (저장 시 기억해서 재사용). */
musicPlaylistUrl: string
/** 사진 플레이리스트 원본 주소. */
imagePlaylistUrl: string
music: MusicListEntry[]
images: ImageListEntry[]
}

View File

@@ -13,6 +13,8 @@
<section class="dashboardHeader">
<h1>음악퀴즈 목록</h1>
<div class="dashboardActions">
<a class="secondaryButton" href="/op/list">음악목록 수정</a>
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a>
<form method="post" action="/op/dashboard/create" class="inlineForm">
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
</form>

121
views/op/datapack.ejs Normal file
View File

@@ -0,0 +1,121 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>데이터팩 수정</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:8px;">데이터팩 수정</h1>
</div>
</section>
<section class="dpControls">
<button type="button" class="primaryButton" id="pickPackBtn">음악퀴즈 선택</button>
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span>
</section>
<p class="muted" id="countLabel"></p>
<section class="dpActions" hidden id="dpActions">
<button type="button" class="secondaryButton" id="exportBtn">데이터팩 출력</button>
<button type="button" class="secondaryButton" id="copyBtn">복사</button>
<span class="statusText" id="dp-status"></span>
</section>
<pre class="codeBlock" id="codeOut" hidden></pre>
</main>
<!-- 음악퀴즈 선택 팝업 -->
<div class="modalOverlay" id="pickModal" hidden>
<div class="modalCard">
<header><h3>음악퀴즈 선택</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
</header>
<div class="modalBody">
<div class="cardRow horizontalScroll" id="pickList">
<% items.forEach(function (item) { %>
<article class="packCard pickable" data-key="<%= item.key %>" data-name="<%= item.definition ? item.definition.name : item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
</ul>
<% } %>
</article>
<% }) %>
</div>
</div>
</div>
</div>
<script>
(function () {
var pickModal = document.getElementById('pickModal')
var pickedKey = ''
document.getElementById('pickPackBtn').addEventListener('click', function () {
pickModal.hidden = false
})
document.querySelectorAll('[data-modal-close]').forEach(function (b) {
b.addEventListener('click', function () { pickModal.hidden = true })
})
pickModal.addEventListener('click', function (e) {
if (e.target === pickModal) pickModal.hidden = true
})
document.querySelectorAll('#pickList .pickable').forEach(function (card) {
card.addEventListener('click', function () {
pickedKey = card.getAttribute('data-key')
var name = card.getAttribute('data-name')
document.getElementById('pickedLabel').textContent = '선택: ' + name
pickModal.hidden = true
document.getElementById('dpActions').hidden = false
// 곡 수 미리 가져오기
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
document.getElementById('countLabel').textContent = ''
document.getElementById('codeOut').hidden = true
})
})
document.getElementById('exportBtn').addEventListener('click', function () {
if (!pickedKey) return
var s = document.getElementById('dp-status')
s.textContent = '출력 중…'; s.classList.remove('error')
fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate')
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
.then(function (res) {
if (!res.ok) {
s.textContent = '실패: ' + res.text; s.classList.add('error')
return
}
var out = document.getElementById('codeOut')
out.textContent = res.text
out.hidden = false
// 첫줄/둘째줄에서 카운트 가져와 표기
var m = res.text.match(/총\s+(\d+)곡/)
if (m) document.getElementById('countLabel').textContent = '총 ' + m[1] + '개의 음악을 찾았습니다.'
s.textContent = '출력 완료'
})
.catch(function (err) { s.textContent = '실패: ' + err.message; s.classList.add('error') })
})
document.getElementById('copyBtn').addEventListener('click', function () {
var out = document.getElementById('codeOut')
if (out.hidden) return
navigator.clipboard.writeText(out.textContent).then(function () {
var s = document.getElementById('dp-status')
s.textContent = '복사됨'
s.classList.remove('error')
})
})
})()
</script>
</body>
</html>

42
views/op/list.ejs Normal file
View File

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악목록 수정</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:8px;">음악목록 수정</h1>
</div>
</section>
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard">
<a class="cardLink" href="/op/list/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
</ul>
<% } %>
</a>
</article>
<% }) %>
</section>
</main>
</body>
</html>

131
views/op/listEditor.ejs Normal file
View File

@@ -0,0 +1,131 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> — 음악/사진 목록</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/list">← 돌아가기</a>
<h1 style="margin-top:8px;"><%= pack.name %></h1>
<p class="muted"><%= packKey %>.json</p>
</div>
</section>
<div class="tabBar">
<button type="button" class="tabBtn active" data-tab="music">음악목록</button>
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
</div>
<!-- 음악 탭 -->
<section class="tabPanel" id="tab-music">
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="music">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="music">목록 초기화</button>
<span class="statusText" id="status-music"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="music-playlist-url"
placeholder="유튜브 플레이리스트 URL"
value="<%= list.musicPlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="music">플레이리스트 불러오기</button>
</div>
<ol class="trackList" id="music-list"></ol>
</section>
<!-- 사진 탭 -->
<section class="tabPanel" id="tab-image" hidden>
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
<span class="statusText" id="status-image"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="image-playlist-url"
placeholder="유튜브 플레이리스트 URL"
value="<%= list.imagePlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="image">플레이리스트 불러오기</button>
</div>
<div class="imageGrid" id="image-list"></div>
</section>
</main>
<!-- Context menu -->
<div class="ctxMenu" id="ctxMenu" hidden>
<button type="button" data-ctx="edit">수정</button>
<button type="button" data-ctx="delete">삭제</button>
</div>
<!-- Confirm modal -->
<div class="modalOverlay" id="confirmModal" hidden>
<div class="modalCard">
<header><h3 id="confirm-title">확인</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
</header>
<div class="modalBody">
<p id="confirm-message"></p>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
</footer>
</div>
</div>
<!-- Edit modal (music) -->
<div class="modalOverlay" id="editMusicModal" hidden>
<div class="modalCard">
<header><h3>음악 항목 수정</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
</header>
<div class="modalBody">
<label>유튜브 영상 주소
<input type="url" id="edit-music-url" class="textInput" />
</label>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="edit-music-save">저장</button>
</footer>
</div>
</div>
<!-- Edit modal (image) -->
<div class="modalOverlay" id="editImageModal" hidden>
<div class="modalCard">
<header><h3>사진 항목 수정</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
</header>
<div class="modalBody">
<div class="segmentedRow">
<button type="button" class="segBtn active" data-seg="yt">유튜브 주소</button>
<button type="button" class="segBtn" data-seg="img">이미지 주소</button>
</div>
<label>주소
<input type="url" id="edit-image-url" class="textInput" />
</label>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="edit-image-save">저장</button>
</footer>
</div>
</div>
<script>
var PACK_KEY = <%- JSON.stringify(packKey) %>;
var INITIAL = <%- JSON.stringify(list) %>;
</script>
<script src="/static/listEditor.js"></script>
</body>
</html>