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

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[]
}