Files
minecraft_launcher/src/server/youtube.ts
claude-bot c2fcc2fbbf i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
2026-05-13 03:43:04 +09:00

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)
})
})
}