op: 데이터팩 출력을 실제 music_quiz zip 으로 교체
가이드 (mc_datapack/launcher_datapack_연동_가이드.txt) 에 따라:
- file/datapacks/music_quiz_template/ 에 mc_datapack 의 music_quiz/ 정적
파일을 미리 동봉 (data/mq/function/init/songs.mcfunction 제외).
- src/server/datapack.ts: list.music → SNBT (`{title, author, alias}`)
songs.mcfunction 빌더와 archiver 기반 zip 스트리머 추가.
- /op/datapack/:packName/generate 가 텍스트 placeholder 대신
music_quiz_<key>.zip 을 Content-Disposition attachment 로 내려준다.
- datapack.ejs 의 코드블록·복사 UI 제거, 곡 수는 서버 렌더 시점에 표시.
- 더 이상 쓰이지 않는 locales 의 datapackOutput.* 키 제거, datapack
버튼 라벨/상태 문구를 zip 다운로드용으로 정리.
This commit is contained in:
79
src/server/datapack.ts
Normal file
79
src/server/datapack.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import archiver from 'archiver'
|
||||
import type { Response } from 'express'
|
||||
import { fileDatapacksDirPath } from '../shared/paths.js'
|
||||
import type { MusicListEntry, PackList } from '../shared/types.js'
|
||||
|
||||
/** music_quiz/ 정적 템플릿 디렉터리. (songs.mcfunction 만 동적으로 생성) */
|
||||
const TEMPLATE_DIR = path.join(fileDatapacksDirPath, 'music_quiz_template')
|
||||
const SONGS_PATH_IN_ZIP = 'music_quiz/data/mq/function/init/songs.mcfunction'
|
||||
|
||||
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */
|
||||
function escapeSnbtString(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
|
||||
function aliasListSnbt(aliases: string[]): string {
|
||||
if (!Array.isArray(aliases) || aliases.length === 0) return '[]'
|
||||
const parts = aliases.map((a) => `"${escapeSnbtString(a)}"`)
|
||||
return `[${parts.join(',')}]`
|
||||
}
|
||||
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */
|
||||
function entrySnbt(entry: MusicListEntry): string {
|
||||
const title = escapeSnbtString(entry.title ?? '')
|
||||
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
|
||||
const author = escapeSnbtString(entry.artist ?? '')
|
||||
const alias = aliasListSnbt(entry.aliases ?? [])
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}}`
|
||||
}
|
||||
|
||||
/** list.music 으로부터 `data/mq/function/init/songs.mcfunction` 본문을 생성. */
|
||||
export function buildSongsMcfunction(list: PackList): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# 곡 한 개 = 한 줄.')
|
||||
lines.push('# 필수 — title, author, alias')
|
||||
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
|
||||
lines.push('# 의 audio.volume 사용)')
|
||||
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}')
|
||||
lines.push('data modify storage mq:main songs set value []')
|
||||
for (const entry of list.music) {
|
||||
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('# 곡 개수는 songs 배열 길이에서 자동 계산됨')
|
||||
lines.push('execute store result storage mq:main max_index int 1 run data get storage mq:main songs')
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** music_quiz 데이터팩 zip 을 Response 로 스트리밍. */
|
||||
export function streamMusicQuizZip(res: Response, packKey: string, list: PackList): void {
|
||||
const fileName = `music_quiz_${packKey}.zip`
|
||||
res.setHeader('Content-Type', 'application/zip')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`)
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
archive.on('warning', (err) => {
|
||||
if (err.code !== 'ENOENT') res.destroy(err)
|
||||
})
|
||||
archive.on('error', (err) => {
|
||||
res.destroy(err)
|
||||
})
|
||||
archive.pipe(res)
|
||||
|
||||
// 정적 템플릿 전체를 music_quiz/ 아래로 묶되 songs.mcfunction 만 제외.
|
||||
archive.glob('**/*', {
|
||||
cwd: TEMPLATE_DIR,
|
||||
dot: false,
|
||||
ignore: ['data/mq/function/init/songs.mcfunction']
|
||||
}, { prefix: 'music_quiz/' })
|
||||
|
||||
// 동적으로 만든 songs.mcfunction 을 추가.
|
||||
const songsText = buildSongsMcfunction(list)
|
||||
archive.append(Readable.from([songsText]), { name: SONGS_PATH_IN_ZIP })
|
||||
|
||||
void archive.finalize()
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import type { PackDefinition, PackList } from '../../shared/types.js'
|
||||
import { t } from '../i18n.js'
|
||||
import { streamMusicQuizZip } from '../datapack.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
@@ -223,17 +224,19 @@ opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||
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)
|
||||
})))
|
||||
const items = await Promise.all(keys.map(async (key) => {
|
||||
const definition = await loadPackDefinition(key)
|
||||
const list = await loadPackList(key)
|
||||
return { key, definition, musicCount: list.music.length }
|
||||
}))
|
||||
res.render('op/datapack', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 데이터팩 출력: 임시 포맷의 mcfunction 텍스트를 반환.
|
||||
// 데이터팩 출력: mc_datapack 의 music_quiz/ 템플릿을 zip 으로 묶고,
|
||||
// data/mq/function/init/songs.mcfunction 만 list.music 으로 새로 만들어 덮어쓴다.
|
||||
opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
@@ -243,25 +246,7 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
|
||||
return
|
||||
}
|
||||
const list = await loadPackList(packKey)
|
||||
const lines: string[] = []
|
||||
lines.push(t('datapackOutput.header', { name: definition.name }))
|
||||
lines.push(t('datapackOutput.summary', {
|
||||
musicCount: list.music.length,
|
||||
imageCount: list.images.length
|
||||
}))
|
||||
lines.push(t('datapackOutput.initLine'))
|
||||
lines.push(t('datapackOutput.placeholder'))
|
||||
list.music.forEach((entry, index) => {
|
||||
const title = entry.title || t('datapackOutput.titleFallback')
|
||||
const artist = entry.artist || t('datapackOutput.artistFallback')
|
||||
lines.push(t('datapackOutput.trackLine', {
|
||||
index: index + 1,
|
||||
title,
|
||||
artist,
|
||||
duration: entry.durationSec
|
||||
}))
|
||||
})
|
||||
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||
streamMusicQuizZip(res, packKey, list)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user