From a2817c921dcdc842b97cc726daefa1ab38240a76 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 11 May 2026 11:38:30 +0900 Subject: [PATCH] Add /op/list, /op/list/:pack, /op/datapack web admin + spec lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 --- .gitignore | 1 + docs/add.md | 14 +- file/list/music-quiz.json | 6 + public/listEditor.js | 313 ++++++++++++++++++++++++++++++++++++++ public/styles.css | 122 +++++++++++++++ src/server/routes/op.ts | 121 ++++++++++++++- src/server/youtube.ts | 82 ++++++++++ src/shared/paths.ts | 1 + src/shared/store.ts | 64 +++++++- src/shared/types.ts | 23 +++ views/op/dashboard.ejs | 2 + views/op/datapack.ejs | 121 +++++++++++++++ views/op/list.ejs | 42 +++++ views/op/listEditor.ejs | 131 ++++++++++++++++ 14 files changed, 1034 insertions(+), 9 deletions(-) create mode 100644 file/list/music-quiz.json create mode 100644 public/listEditor.js create mode 100644 src/server/youtube.ts create mode 100644 views/op/datapack.ejs create mode 100644 views/op/list.ejs create mode 100644 views/op/listEditor.ejs diff --git a/.gitignore b/.gitignore index 41983ac..7810a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ release/ logs/ *.log +conversations/ diff --git a/docs/add.md b/docs/add.md index c2df94d..2a8e36a 100644 --- a/docs/add.md +++ b/docs/add.md @@ -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_.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_.png` (`NN` 은 2자리 0패딩). +- 저장 경로: `resourcepack/assets/musicquiz/textures/painting/`. - 패키지 완성된 리소스팩을 `%appdata%/.minecraft/resourcepacks/` 에 zip 으로 배치. ### 3) 설치 완료 diff --git a/file/list/music-quiz.json b/file/list/music-quiz.json new file mode 100644 index 0000000..a4f594f --- /dev/null +++ b/file/list/music-quiz.json @@ -0,0 +1,6 @@ +{ + "musicPlaylistUrl": "", + "imagePlaylistUrl": "", + "music": [], + "images": [] +} diff --git a/public/listEditor.js b/public/listEditor.js new file mode 100644 index 0000000..619e61c --- /dev/null +++ b/public/listEditor.js @@ -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 = + '' + (idx + 1) + '' + + '' + + '
' + + '
' + escapeHtml(entry.title || '(제목 없음)') + '
' + + '
' + escapeHtml(entry.artist || '') + '
' + + '
' + + '' + fmtTime(entry.durationSec) + '' + 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 = + '' + (idx + 1) + '' + + '' + attachRowEvents(card, 'image', idx) + grid.appendChild(card) + }) + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[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() +})() diff --git a/public/styles.css b/public/styles.css index 0b8e2c1..918b11c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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); } diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index f6d369c..52adcd1 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -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)) diff --git a/src/server/youtube.ts b/src/server/youtube.ts new file mode 100644 index 0000000..63310ea --- /dev/null +++ b/src/server/youtube.ts @@ -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 { + 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 { + 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 + 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) + }) + }) +} diff --git a/src/shared/paths.ts b/src/shared/paths.ts index 08d0a78..6e8a3bc 100644 --- a/src/shared/paths.ts +++ b/src/shared/paths.ts @@ -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') diff --git a/src/shared/store.ts b/src/shared/store.ts index 29e51bd..58c3ade 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -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 { 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 + 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 => !!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 => !!entry && typeof entry === 'object') + .map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) })) + .filter((entry) => entry.url.length > 0) + } +} + +export async function loadPackList(packKey: string): Promise { + 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 { + 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 { try { const raw = await fsp.readFile(accountFilePath, 'utf8') diff --git a/src/shared/types.ts b/src/shared/types.ts index 5f64155..9f06572 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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[] +} diff --git a/views/op/dashboard.ejs b/views/op/dashboard.ejs index ae26949..dd618c5 100644 --- a/views/op/dashboard.ejs +++ b/views/op/dashboard.ejs @@ -13,6 +13,8 @@

음악퀴즈 목록

+ 음악목록 수정 + 데이터팩 수정
diff --git a/views/op/datapack.ejs b/views/op/datapack.ejs new file mode 100644 index 0000000..2e8f311 --- /dev/null +++ b/views/op/datapack.ejs @@ -0,0 +1,121 @@ + + + + + + 데이터팩 수정 + + + + <%- include('../partials/navbar', { userId }) %> + +
+
+
+ ← 돌아가기 +

데이터팩 수정

+
+
+ +
+ + 선택된 음악퀴즈 없음 +
+ +

+ + + + +
+ + + + + + + diff --git a/views/op/list.ejs b/views/op/list.ejs new file mode 100644 index 0000000..6f4829a --- /dev/null +++ b/views/op/list.ejs @@ -0,0 +1,42 @@ + + + + + + 음악목록 수정 + + + + <%- include('../partials/navbar', { userId }) %> + +
+
+
+ ← 돌아가기 +

음악목록 수정

+
+
+ +
+ <% if (items.length === 0) { %> +

등록된 음악퀴즈가 없습니다.

+ <% } %> + <% items.forEach(function (item) { %> + + <% }) %> +
+
+ + diff --git a/views/op/listEditor.ejs b/views/op/listEditor.ejs new file mode 100644 index 0000000..2b95c62 --- /dev/null +++ b/views/op/listEditor.ejs @@ -0,0 +1,131 @@ + + + + + + <%= pack.name %> — 음악/사진 목록 + + + + <%- include('../partials/navbar', { userId }) %> + +
+
+
+ ← 돌아가기 +

<%= pack.name %>

+

<%= packKey %>.json

+
+
+ +
+ + +
+ + +
+
+ + + +
+ +
+ + +
+ +
    +
    + + + +
    + + + + + + + + + + + + + + + + +