|
|
|
@@ -32,48 +32,47 @@ function getYtDlpAssetName(): string {
|
|
|
|
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
|
|
|
|
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */
|
|
|
|
/**
|
|
|
|
|
|
|
|
* 로컬 설치 경로: OS별 사용자 데이터 디렉터리 안의 .mc_custom/<asset>.
|
|
|
|
|
|
|
|
* - Windows: %APPDATA%/.mc_custom/yt-dlp.exe
|
|
|
|
|
|
|
|
* - macOS : ~/Library/Application Support/.mc_custom/yt-dlp_macos
|
|
|
|
|
|
|
|
* - Linux 등: $XDG_CONFIG_HOME 또는 ~/.config/.mc_custom/yt-dlp_linux (arch 따라 다름)
|
|
|
|
|
|
|
|
*/
|
|
|
|
export function getYtDlpInstallPath(): string {
|
|
|
|
export function getYtDlpInstallPath(): string {
|
|
|
|
return path.join(getMcCustomDir(), getYtDlpAssetName())
|
|
|
|
return path.join(getMcCustomDir(), getYtDlpAssetName())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 순수 파이썬 zipapp(`yt-dlp`) 의 로컬 설치 경로. python3 가 PATH 에 있어야 동작. */
|
|
|
|
|
|
|
|
function getYtDlpZipappPath(): string {
|
|
|
|
|
|
|
|
return path.join(getMcCustomDir(), 'yt-dlp_zipapp')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
|
|
|
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
|
|
|
let installPromise: Promise<string> | null = null
|
|
|
|
let installPromise: Promise<string> | null = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type ProbeResult = { ok: true } | { ok: false; detail: string }
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
|
|
|
* .mc_custom/ 디렉터리에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
|
|
|
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
|
|
|
* 현재 OS/아키텍처용 네이티브 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* 네이티브 바이너리가 실행되지 않는 환경(glibc 미스매치, musl libc, antivirus 차단 등)
|
|
|
|
|
|
|
|
* 이면 다음 순서로 폴백한다:
|
|
|
|
|
|
|
|
* 1) PATH 의 `yt-dlp(.exe)` (시스템에 따로 깐 거)
|
|
|
|
|
|
|
|
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
|
|
|
|
|
|
|
|
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
export async function ensureYtDlp(): Promise<string> {
|
|
|
|
export async function ensureYtDlp(): Promise<string> {
|
|
|
|
const target = getYtDlpInstallPath()
|
|
|
|
const target = getYtDlpInstallPath()
|
|
|
|
// 이미 설치돼 있고 실행 가능하면 그대로 사용
|
|
|
|
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
|
|
|
|
if (await canExecute(target)) return target
|
|
|
|
if (await fileExists(target)) {
|
|
|
|
|
|
|
|
const probe = await probeVersion(target)
|
|
|
|
|
|
|
|
if (probe.ok) return target
|
|
|
|
|
|
|
|
}
|
|
|
|
if (installPromise) return installPromise
|
|
|
|
if (installPromise) return installPromise
|
|
|
|
installPromise = (async () => {
|
|
|
|
installPromise = (async () => {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const dir = getMcCustomDir()
|
|
|
|
return await prepareYtDlp(target)
|
|
|
|
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 {
|
|
|
|
} finally {
|
|
|
|
installPromise = null
|
|
|
|
installPromise = null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -81,31 +80,108 @@ export async function ensureYtDlp(): Promise<string> {
|
|
|
|
return installPromise
|
|
|
|
return installPromise
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function canExecute(filePath: string): Promise<boolean> {
|
|
|
|
async function prepareYtDlp(target: string): Promise<string> {
|
|
|
|
|
|
|
|
const diagnostics: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 기존 파일이 있으면 우선 그걸로 시도
|
|
|
|
|
|
|
|
if (await fileExists(target)) {
|
|
|
|
|
|
|
|
const probe = await probeVersion(target)
|
|
|
|
|
|
|
|
if (probe.ok) return target
|
|
|
|
|
|
|
|
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
|
|
|
|
|
|
|
|
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
|
|
|
|
|
|
|
|
const pathProbe = await probeVersion(pathCmd)
|
|
|
|
|
|
|
|
if (pathProbe.ok) return pathCmd
|
|
|
|
|
|
|
|
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 최후 수단: 새로 다운로드해서 시도
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await fs.mkdir(getMcCustomDir(), { recursive: true })
|
|
|
|
|
|
|
|
const asset = getYtDlpAssetName()
|
|
|
|
|
|
|
|
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
|
|
|
|
|
|
|
try { await fs.unlink(target) } catch { /* noop */ }
|
|
|
|
|
|
|
|
await downloadToFile(url, target)
|
|
|
|
|
|
|
|
if (process.platform !== 'win32') {
|
|
|
|
|
|
|
|
await fs.chmod(target, 0o755)
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// Windows: 인터넷에서 받은 파일에는 NTFS ADS 'Zone.Identifier' 가 붙어
|
|
|
|
|
|
|
|
// SmartScreen/Attachment Manager 가 실행을 막을 수 있다. 베스트에포트로 제거.
|
|
|
|
|
|
|
|
try { await fs.unlink(`${target}:Zone.Identifier`) } catch { /* noop */ }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const probe = await probeVersion(target)
|
|
|
|
|
|
|
|
if (probe.ok) return target
|
|
|
|
|
|
|
|
diagnostics.push(`새로 받은 ${asset} 검증 실패: ${probe.detail}`)
|
|
|
|
|
|
|
|
try { await fs.unlink(target) } catch { /* noop */ }
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
diagnostics.push(`다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
|
|
|
|
|
|
|
try { await fs.unlink(target) } catch { /* noop */ }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 4. POSIX 한정 최후 폴백: 범용 파이썬 zipapp `yt-dlp` 다운로드 후 shebang 실행.
|
|
|
|
|
|
|
|
// 네이티브 바이너리가 glibc/musl/arch 문제로 못 도는 리눅스 환경이라도
|
|
|
|
|
|
|
|
// python3 가 PATH 에 있으면 동작한다. ~ 3MB 짜리 스크립트.
|
|
|
|
|
|
|
|
if (process.platform !== 'win32') {
|
|
|
|
|
|
|
|
const zipappPath = getYtDlpZipappPath()
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
try { await fs.unlink(zipappPath) } catch { /* noop */ }
|
|
|
|
|
|
|
|
await downloadToFile('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', zipappPath)
|
|
|
|
|
|
|
|
await fs.chmod(zipappPath, 0o755)
|
|
|
|
|
|
|
|
const probe = await probeVersion(zipappPath)
|
|
|
|
|
|
|
|
if (probe.ok) return zipappPath
|
|
|
|
|
|
|
|
diagnostics.push(`zipapp yt-dlp 검증 실패: ${probe.detail} (python3 누락이거나 PATH 에 없음)`)
|
|
|
|
|
|
|
|
try { await fs.unlink(zipappPath) } catch { /* noop */ }
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
diagnostics.push(`zipapp 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
|
|
|
|
|
|
|
try { await fs.unlink(zipappPath) } catch { /* noop */ }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
throw new YtDlpUnavailableError(
|
|
|
|
|
|
|
|
t('youtube.ytdlpVerifyFailedDetail', { detail: diagnostics.join(' | ') })
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
await fs.access(filePath, fsConst.F_OK)
|
|
|
|
await fs.access(filePath, fsConst.F_OK)
|
|
|
|
|
|
|
|
return true
|
|
|
|
} catch {
|
|
|
|
} catch {
|
|
|
|
return false
|
|
|
|
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> {
|
|
|
|
function probeVersion(bin: string): Promise<ProbeResult> {
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
|
|
let child: ReturnType<typeof spawn>
|
|
|
|
let ok = false
|
|
|
|
try {
|
|
|
|
child.stdout.on('data', () => { ok = true })
|
|
|
|
child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
|
|
child.on('error', () => resolve(false))
|
|
|
|
} catch (err) {
|
|
|
|
child.on('close', (code) => resolve(ok && code === 0))
|
|
|
|
resolve({ ok: false, detail: `spawn throw: ${err instanceof Error ? err.message : String(err)}` })
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
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: NodeJS.ErrnoException) => {
|
|
|
|
|
|
|
|
const code = err.code ? `${err.code} ` : ''
|
|
|
|
|
|
|
|
resolve({ ok: false, detail: `spawn error: ${code}${err.message}` })
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
child.on('close', (code, signal) => {
|
|
|
|
|
|
|
|
const out = stdout.trim()
|
|
|
|
|
|
|
|
if (out && code === 0) {
|
|
|
|
|
|
|
|
resolve({ ok: true })
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
|
|
parts.push(`exit=${code === null ? `signal:${signal}` : code}`)
|
|
|
|
|
|
|
|
if (!out) parts.push('stdout=(empty)')
|
|
|
|
|
|
|
|
const errLine = stderr.trim().split('\n')[0]
|
|
|
|
|
|
|
|
if (errLine) parts.push(`stderr="${errLine.slice(0, 200)}"`)
|
|
|
|
|
|
|
|
resolve({ ok: false, detail: parts.join(', ') })
|
|
|
|
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|