- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
|
|
import path from 'node:path'
|
|
import https from 'node:https'
|
|
import http from 'node:http'
|
|
import { getMcCustomDir } from '../shared/paths.js'
|
|
import { t } from './i18n.js'
|
|
|
|
export interface YtPlaylistEntry {
|
|
id: string
|
|
title: string
|
|
channel: string
|
|
durationSec: number
|
|
url: string
|
|
}
|
|
|
|
export class YtDlpUnavailableError extends Error {
|
|
constructor(message?: string) {
|
|
super(message || t('youtube.ytdlpUnavailable'))
|
|
}
|
|
}
|
|
|
|
/** 현재 OS/아키텍처에서 GitHub Releases 가 제공하는 yt-dlp 파일 이름. */
|
|
function getYtDlpAssetName(): string {
|
|
if (process.platform === 'win32') return 'yt-dlp.exe'
|
|
if (process.platform === 'darwin') return 'yt-dlp_macos'
|
|
if (process.platform === 'linux') {
|
|
if (process.arch === 'arm64') return 'yt-dlp_linux_aarch64'
|
|
if (process.arch === 'arm') return 'yt-dlp_linux_armv7l'
|
|
return 'yt-dlp_linux'
|
|
}
|
|
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
|
|
}
|
|
|
|
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */
|
|
export function getYtDlpInstallPath(): string {
|
|
return path.join(getMcCustomDir(), getYtDlpAssetName())
|
|
}
|
|
|
|
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
|
let installPromise: Promise<string> | null = null
|
|
|
|
/**
|
|
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
|
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
|
*/
|
|
export async function ensureYtDlp(): Promise<string> {
|
|
const target = getYtDlpInstallPath()
|
|
// 이미 설치돼 있고 실행 가능하면 그대로 사용
|
|
if (await canExecute(target)) return target
|
|
if (installPromise) return installPromise
|
|
installPromise = (async () => {
|
|
try {
|
|
const dir = getMcCustomDir()
|
|
await fs.mkdir(dir, { recursive: true })
|
|
const asset = getYtDlpAssetName()
|
|
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
|
await downloadToFile(url, target)
|
|
// POSIX 계열은 실행 권한 부여
|
|
if (process.platform !== 'win32') {
|
|
await fs.chmod(target, 0o755)
|
|
}
|
|
// 검증
|
|
const okVersion = await probeVersion(target)
|
|
if (!okVersion) {
|
|
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
|
|
}
|
|
return target
|
|
} catch (err) {
|
|
// 실패 흔적(부분 다운로드) 삭제
|
|
try { await fs.unlink(target) } catch { /* noop */ }
|
|
throw err instanceof YtDlpUnavailableError
|
|
? err
|
|
: new YtDlpUnavailableError(
|
|
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
|
|
)
|
|
} finally {
|
|
installPromise = null
|
|
}
|
|
})()
|
|
return installPromise
|
|
}
|
|
|
|
async function canExecute(filePath: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(filePath, fsConst.F_OK)
|
|
} catch {
|
|
return false
|
|
}
|
|
// POSIX 면 X 비트도 확인
|
|
if (process.platform !== 'win32') {
|
|
try {
|
|
await fs.access(filePath, fsConst.X_OK)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
// 실제로 --version 으로 한 번 더 확인
|
|
return probeVersion(filePath)
|
|
}
|
|
|
|
function probeVersion(bin: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
let ok = false
|
|
child.stdout.on('data', () => { ok = true })
|
|
child.on('error', () => resolve(false))
|
|
child.on('close', (code) => resolve(ok && code === 0))
|
|
})
|
|
}
|
|
|
|
/** GitHub Releases 의 latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
|
|
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (redirects > 8) {
|
|
reject(new Error(t('youtube.tooManyRedirects')))
|
|
return
|
|
}
|
|
const lib = url.startsWith('https://') ? https : http
|
|
const req = lib.get(url, {
|
|
headers: { 'user-agent': 'mc-music-quiz-launcher' }
|
|
}, (res) => {
|
|
const code = res.statusCode || 0
|
|
if (code >= 300 && code < 400 && res.headers.location) {
|
|
res.resume()
|
|
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
|
|
return
|
|
}
|
|
if (code !== 200) {
|
|
res.resume()
|
|
reject(new Error(`HTTP ${code} (${url})`))
|
|
return
|
|
}
|
|
const out = createWriteStream(dest)
|
|
res.pipe(out)
|
|
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
|
|
out.on('error', reject)
|
|
res.on('error', reject)
|
|
})
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 단일 영상 URL 의 메타데이터를 가져온다.
|
|
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
|
*/
|
|
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
|
const bin = await ensureYtDlp()
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(bin, [
|
|
'--dump-json',
|
|
'--no-warnings',
|
|
'--no-playlist',
|
|
'--skip-download',
|
|
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(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
|
return
|
|
}
|
|
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
|
if (!line) { resolve(null); return }
|
|
try {
|
|
const obj = JSON.parse(line) as Record<string, unknown>
|
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
|
if (!id) { resolve(null); return }
|
|
resolve({
|
|
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.webpage_url === 'string' && obj.webpage_url.length > 0
|
|
? obj.webpage_url
|
|
: `https://www.youtube.com/watch?v=${id}`
|
|
})
|
|
} catch (err) {
|
|
reject(err)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
|
|
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
|
*/
|
|
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
|
const bin = await ensureYtDlp()
|
|
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(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: 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)
|
|
})
|
|
})
|
|
}
|