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:
82
src/server/youtube.ts
Normal file
82
src/server/youtube.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user