项目文件夹

文件
wehub-resource-sync d18ada4ee7
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

1071 行
32 KiB
JavaScript
可执行文件

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/**
* Development environment setup script
* Automatically downloads yt-dlp and ffmpeg binaries based on the current system
*/
import { execSync, spawnSync } from 'node:child_process'
import fs from 'node:fs'
import http from 'node:http'
import https from 'node:https'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { YTDLP_PLATFORM_ASSETS } from './ytdlp-assets.js'
// Configuration
const currentFilePath = fileURLToPath(import.meta.url)
const currentDirPath = path.dirname(currentFilePath)
const RESOURCES_DIR = path.join(currentDirPath, '..', 'resources')
const FFMPEG_DIR = path.join(RESOURCES_DIR, 'ffmpeg')
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
const MAC_FFMPEG_MODE = (process.env.VIDBEE_MAC_FFMPEG_MODE || 'native').trim().toLowerCase()
const GITHUB_TOKEN =
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
const YTDLP_VERSION_CHECK_TIMEOUT_MS = 30_000
// Platform configuration
const PLATFORM_CONFIG = {
win32: {
ytdlp: YTDLP_PLATFORM_ASSETS.win32,
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe',
output: 'ffmpeg.exe',
ffprobeOutput: 'ffprobe.exe',
extract: 'unzip',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
binaryName: 'ffmpeg.exe'
}
}
},
darwin: {
ytdlp: YTDLP_PLATFORM_ASSETS.darwin,
ffmpeg: {
// For development, download only the architecture matching current system
arm64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip',
innerPath: 'ffmpeg/ffmpeg',
ffprobeInnerPath: 'ffmpeg/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
assetPattern: /ffmpeg-arm64.*\.zip$/i
}
},
x64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip',
innerPath: 'ffmpeg/ffmpeg',
ffprobeInnerPath: 'ffmpeg/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
assetPattern: /ffmpeg-x86_64.*\.zip$/i
}
}
}
},
linux: {
ytdlp: YTDLP_PLATFORM_ASSETS.linux,
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
ffprobeInnerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'tar',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
binaryName: 'ffmpeg'
}
}
}
}
// Utility functions
function log(message, type = 'info') {
const icons = {
info: '📦',
success: '✅',
error: '❌',
warn: '⚠️',
download: '⬇️'
}
console.log(`${icons[type] || '️'} ${message}`)
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function safeUnlink(filePath) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
function getDownloadHeaders(url) {
const headers = {
'User-Agent': 'vidbee-setup',
Accept: '*/*'
}
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
return headers
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
let downloadedBytes = 0
let isSettled = false
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
const settleRequest = () => {
if (isSettled) {
return false
}
isSettled = true
request.setTimeout(0)
return true
}
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
if (!settleRequest()) {
return
}
response.resume()
file.close()
safeUnlink(dest)
const redirectUrl = response.headers.location
if (!redirectUrl) {
return reject(new Error(`Redirect without location for ${url}`))
}
log(`Redirected to ${redirectUrl}`, 'info')
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
}
const contentLength = response.headers['content-length']
if (response.statusCode !== 200) {
if (!settleRequest()) {
return
}
response.resume()
file.close()
safeUnlink(dest)
return reject(
new Error(
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
)
)
}
response.on('data', (chunk) => {
downloadedBytes += chunk.length
})
response.pipe(file)
file.on('finish', () => {
if (!settleRequest()) {
return
}
file.close()
log(
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
downloadedBytes ? 'success' : 'warn'
)
resolve()
})
})
request.setTimeout(30_000, () => {
if (isSettled) {
return
}
request.destroy(new Error('Download timeout'))
})
request.on('error', (err) => {
if (isSettled) {
return
}
isSettled = true
request.setTimeout(0)
file.close()
safeUnlink(dest)
log(`Download error for ${url}: ${err.message}`, 'error')
reject(err)
})
})
}
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
await downloadFile(url, dest)
return
} catch (error) {
lastError = error
safeUnlink(dest)
if (attempt < retries) {
const backoff = delayMs * attempt
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
await new Promise((resolve) => setTimeout(resolve, backoff))
}
}
}
throw lastError
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const headers = {
'User-Agent': 'vidbee-setup',
Accept: 'application/vnd.github+json'
}
if (GITHUB_TOKEN) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
protocol
.get(url, { headers }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
return fetchJson(response.headers.location).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
}
let body = ''
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
try {
resolve(JSON.parse(body))
} catch (error) {
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
}
})
})
.on('error', (err) => {
reject(err)
})
})
}
function inferFfmpegInnerPath(assetName, binaryName) {
if (!assetName) {
return null
}
const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
if (!match) {
return null
}
return `${match[1]}/bin/${binaryName}`
}
async function resolveReleaseAsset(release) {
if (!release) {
return null
}
const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
if (repoCandidates.length === 0) {
return null
}
let lastError
for (const repo of repoCandidates) {
try {
const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
const assets = Array.isArray(data.assets) ? data.assets : []
const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
if (match?.browser_download_url) {
return { name: match.name, url: match.browser_download_url }
}
lastError = new Error(`No matching assets found in ${repo}`)
} catch (error) {
lastError = error
}
}
if (lastError) {
throw lastError
}
return null
}
function extractZip(zipPath, extractDir) {
const platform = os.platform()
ensureDir(extractDir)
if (platform === 'win32') {
// Use PowerShell Expand-Archive on Windows
try {
const zipAbsPath = path.resolve(zipPath)
const extractAbsDir = path.resolve(extractDir)
execSync(
`powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`,
{ stdio: 'inherit' }
)
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
} else {
// Use unzip command on macOS/Linux
try {
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' })
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
}
}
function extractTarXz(tarPath, extractDir) {
ensureDir(extractDir)
execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' })
}
function setExecutable(filePath) {
if (os.platform() !== 'win32') {
fs.chmodSync(filePath, 0o755)
}
}
function fileExists(filePath) {
return fs.existsSync(filePath)
}
function findFirstFileByName(dirPath, fileName) {
if (!fileExists(dirPath)) {
return null
}
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name)
if (entry.isFile() && entry.name === fileName) {
return fullPath
}
if (entry.isDirectory()) {
const found = findFirstFileByName(fullPath, fileName)
if (found) {
return found
}
}
}
return null
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) {
return 'unknown size'
}
if (bytes >= 1024 * 1024) {
return `${Math.round(bytes / (1024 * 1024))} MB`
}
return `${Math.round(bytes / 1024)} KB`
}
function checkBinary(filePath, args, label, options = {}) {
const timeoutMs =
typeof options.timeoutMs === 'number'
? options.timeoutMs
: os.platform() === 'win32'
? 20_000
: 8000
const result = spawnSync(filePath, args, {
encoding: 'utf8',
timeout: timeoutMs,
windowsHide: true
})
if (result.error) {
return { ok: false, message: result.error.message, code: result.error.code }
}
if (result.status !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
return { ok: false, message: output || `exit code ${result.status}` }
}
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
}
/**
* Checks the yt-dlp version with enough time for macOS first-launch validation.
*/
function checkYtDlpBinary(filePath) {
return checkBinary(filePath, ['--version'], 'yt-dlp', {
timeoutMs: YTDLP_VERSION_CHECK_TIMEOUT_MS
})
}
function logBinaryVersion(label, validation) {
if (!validation.ok) {
return
}
log(`${label} version: ${validation.message}`, 'info')
}
function getDenoAssetName(platform, arch) {
if (platform === 'win32') {
if (arch === 'arm64') {
return 'deno-aarch64-pc-windows-msvc.zip'
}
return 'deno-x86_64-pc-windows-msvc.zip'
}
if (platform === 'darwin') {
if (arch === 'arm64') {
return 'deno-aarch64-apple-darwin.zip'
}
return 'deno-x86_64-apple-darwin.zip'
}
if (platform === 'linux') {
if (arch === 'arm64') {
return 'deno-aarch64-unknown-linux-gnu.zip'
}
return 'deno-x86_64-unknown-linux-gnu.zip'
}
return null
}
function getDenoOutputName(platform) {
return platform === 'win32' ? 'deno.exe' : 'deno'
}
function getMacFfmpegMode() {
if (MAC_FFMPEG_MODE === 'native' || MAC_FFMPEG_MODE === 'universal') {
return MAC_FFMPEG_MODE
}
throw new Error(
`Unsupported VIDBEE_MAC_FFMPEG_MODE value "${MAC_FFMPEG_MODE}". Expected "native" or "universal".`
)
}
function hasRequiredMacArchitectures(filePath, expectedArchitectures) {
const result = spawnSync('lipo', ['-archs', filePath], {
encoding: 'utf8'
})
if (result.error) {
throw new Error(`Failed to inspect Mach-O architectures: ${result.error.message}`)
}
if (result.status !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
throw new Error(
`Failed to inspect Mach-O architectures: ${output || `exit code ${result.status}`}`
)
}
const availableArchitectures = result.stdout
.trim()
.split(/\s+/)
.filter((value) => value.length > 0)
return expectedArchitectures.every((architecture) =>
availableArchitectures.includes(architecture)
)
}
function runCommandOrThrow(command, args, label) {
const result = spawnSync(command, args, {
encoding: 'utf8'
})
if (result.error) {
throw new Error(`${label} failed: ${result.error.message}`)
}
if (result.status !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
throw new Error(`${label} failed: ${output || `exit code ${result.status}`}`)
}
}
function resolveMacExtractedBinary(extractDir, expectedInnerPath, binaryName) {
const expectedPath = path.join(extractDir, expectedInnerPath)
if (fileExists(expectedPath)) {
return expectedPath
}
const discoveredPath = findFirstFileByName(extractDir, binaryName)
if (discoveredPath) {
return discoveredPath
}
throw new Error(`${binaryName} binary not found under ${extractDir}`)
}
async function resolveMacFfmpegDownloadUrl(ffmpegConfig) {
let downloadUrl = ffmpegConfig.url
if (ffmpegConfig.release) {
try {
const resolved = await resolveReleaseAsset(ffmpegConfig.release)
if (resolved) {
downloadUrl = resolved.url
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
return downloadUrl
}
// Main download functions
async function downloadYtDlp(config) {
const { asset, output } = config.ytdlp
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkYtDlpBinary(outputPath)
if (validation.ok) {
logBinaryVersion('yt-dlp', validation)
} else {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ${asset}...`, 'download')
const url = `${YTDLP_BASE_URL}/${asset}`
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
try {
await downloadFileWithRetry(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
const validation = checkYtDlpBinary(outputPath)
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
logBinaryVersion('yt-dlp', validation)
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath)
}
throw error
}
}
async function downloadFfmpegWindows(config) {
const {
url: fallbackUrl,
innerPath: fallbackInnerPath,
ffprobeInnerPath: fallbackFfprobeInnerPath,
output,
ffprobeOutput,
release
} = config.ffmpeg
const outputPath = path.join(FFMPEG_DIR, output)
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
const ffmpegExists = fileExists(outputPath)
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
if (ffmpegExists && ffprobeExists) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
const ffprobeValidation = ffprobeOutputPath
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
: { ok: true }
if (validation.ok && ffprobeValidation.ok) {
logBinaryVersion('ffmpeg', validation)
if (ffprobeOutputPath) {
logBinaryVersion('ffprobe', ffprobeValidation)
}
log('ffmpeg and ffprobe already exist, skipping download', 'info')
return
}
log(
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
'warn'
)
}
log('Downloading ffmpeg for Windows...', 'download')
ensureDir(FFMPEG_DIR)
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
let ffprobeInnerPath = fallbackFfprobeInnerPath
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
if (inferred) {
innerPath = inferred
}
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe')
if (inferredFfprobe) {
ffprobeInnerPath = inferredFfprobe
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep))
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
if (ffprobeInnerPath && ffprobeOutputPath) {
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath.replace(/\\/g, path.sep))
if (!fileExists(ffprobeSourcePath)) {
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
}
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
}
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (validation.ok) {
logBinaryVersion('ffmpeg', validation)
if (ffprobeOutputPath) {
const ffprobeValidation = checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
if (ffprobeValidation.ok) {
logBinaryVersion('ffprobe', ffprobeValidation)
}
}
log(`Downloaded ${output} successfully`, 'success')
} else if (validation.code === 'ETIMEDOUT') {
log(`Downloaded ${output} version check timed out; keeping binary`, 'warn')
} else {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) {
fs.unlinkSync(tempZip)
}
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true })
}
throw error
}
}
async function downloadFfmpegMac(config) {
const mode = getMacFfmpegMode()
const currentArchitecture = os.arch() === 'arm64' ? 'arm64' : 'x64'
const targetArchitectures = mode === 'universal' ? ['arm64', 'x64'] : [currentArchitecture]
const ffmpegConfig = config.ffmpeg[targetArchitectures[0]]
if (!ffmpegConfig) {
throw new Error(`Unsupported architecture: ${currentArchitecture}`)
}
const { output, ffprobeOutput } = ffmpegConfig
const outputPath = path.join(FFMPEG_DIR, output)
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
const ffmpegExists = fileExists(outputPath)
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
if (ffmpegExists && ffprobeExists) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
const ffprobeValidation = ffprobeOutputPath
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
: { ok: true }
let hasExpectedArchitectures = true
if (mode === 'universal' && ffprobeOutputPath) {
try {
hasExpectedArchitectures =
hasRequiredMacArchitectures(outputPath, ['arm64', 'x86_64']) &&
hasRequiredMacArchitectures(ffprobeOutputPath, ['arm64', 'x86_64'])
} catch (error) {
hasExpectedArchitectures = false
log(`Failed to validate existing universal ffmpeg binaries: ${error.message}`, 'warn')
}
}
if (validation.ok && ffprobeValidation.ok && hasExpectedArchitectures) {
logBinaryVersion('ffmpeg', validation)
if (ffprobeOutputPath) {
logBinaryVersion('ffprobe', ffprobeValidation)
}
log(
`ffmpeg and ffprobe already exist for macOS (${mode === 'universal' ? 'universal' : currentArchitecture}), skipping download`,
'info'
)
return
}
log(
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
'warn'
)
}
log(
`Downloading ffmpeg for macOS (${mode === 'universal' ? 'universal' : currentArchitecture})...`,
'download'
)
ensureDir(FFMPEG_DIR)
const tempArtifacts = targetArchitectures.map((targetArchitecture) => ({
key: targetArchitecture,
tempZip: path.join(RESOURCES_DIR, `ffmpeg-${targetArchitecture}.zip`),
extractDir: path.join(RESOURCES_DIR, `ffmpeg-${targetArchitecture}`)
}))
try {
const resolvedBinaries = []
for (const targetArchitecture of targetArchitectures) {
const targetConfig = config.ffmpeg[targetArchitecture]
if (!targetConfig) {
throw new Error(`Unsupported macOS ffmpeg architecture: ${targetArchitecture}`)
}
const tempArtifact = tempArtifacts.find((artifact) => artifact.key === targetArchitecture)
if (!tempArtifact) {
throw new Error(`Temporary artifact not configured for ${targetArchitecture}`)
}
const downloadUrl = await resolveMacFfmpegDownloadUrl(targetConfig)
await downloadFileWithRetry(downloadUrl, tempArtifact.tempZip)
log(`Extracting ffmpeg for macOS (${targetArchitecture})...`, 'info')
extractZip(tempArtifact.tempZip, tempArtifact.extractDir)
resolvedBinaries.push({
ffmpegPath: resolveMacExtractedBinary(
tempArtifact.extractDir,
targetConfig.innerPath,
'ffmpeg'
),
ffprobePath: resolveMacExtractedBinary(
tempArtifact.extractDir,
targetConfig.ffprobeInnerPath,
'ffprobe'
)
})
}
if (mode === 'universal') {
if (!ffprobeOutputPath) {
throw new Error('Universal macOS ffprobe output path is required.')
}
runCommandOrThrow(
'lipo',
['-create', ...resolvedBinaries.map((binary) => binary.ffmpegPath), '-output', outputPath],
'Creating universal ffmpeg binary'
)
runCommandOrThrow(
'lipo',
[
'-create',
...resolvedBinaries.map((binary) => binary.ffprobePath),
'-output',
ffprobeOutputPath
],
'Creating universal ffprobe binary'
)
} else {
fs.copyFileSync(resolvedBinaries[0].ffmpegPath, outputPath)
if (ffprobeOutputPath) {
fs.copyFileSync(resolvedBinaries[0].ffprobePath, ffprobeOutputPath)
}
}
setExecutable(outputPath)
if (ffprobeOutputPath) {
setExecutable(ffprobeOutputPath)
}
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
safeUnlink(ffprobeOutputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
if (mode === 'universal' && ffprobeOutputPath) {
const isUniversal =
hasRequiredMacArchitectures(outputPath, ['arm64', 'x86_64']) &&
hasRequiredMacArchitectures(ffprobeOutputPath, ['arm64', 'x86_64'])
if (!isUniversal) {
safeUnlink(outputPath)
safeUnlink(ffprobeOutputPath)
throw new Error(
'Created macOS ffmpeg binaries are missing required universal architectures.'
)
}
}
logBinaryVersion('ffmpeg', validation)
if (ffprobeOutputPath) {
const ffprobeValidation = checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
logBinaryVersion('ffprobe', ffprobeValidation)
}
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
safeUnlink(outputPath)
safeUnlink(ffprobeOutputPath)
throw error
} finally {
for (const tempArtifact of tempArtifacts) {
safeUnlink(tempArtifact.tempZip)
if (fs.existsSync(tempArtifact.extractDir)) {
fs.rmSync(tempArtifact.extractDir, { recursive: true, force: true })
}
}
}
}
async function downloadFfmpegLinux(config) {
const {
url: fallbackUrl,
innerPath: fallbackInnerPath,
ffprobeInnerPath: fallbackFfprobeInnerPath,
output,
ffprobeOutput,
release
} = config.ffmpeg
const outputPath = path.join(FFMPEG_DIR, output)
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
const ffmpegExists = fileExists(outputPath)
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
if (ffmpegExists && ffprobeExists) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
const ffprobeValidation = ffprobeOutputPath
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
: { ok: true }
if (validation.ok && ffprobeValidation.ok) {
logBinaryVersion('ffmpeg', validation)
if (ffprobeOutputPath) {
logBinaryVersion('ffprobe', ffprobeValidation)
}
log('ffmpeg and ffprobe already exist, skipping download', 'info')
return
}
log(
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
'warn'
)
}
log('Downloading ffmpeg for Linux...', 'download')
ensureDir(FFMPEG_DIR)
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
let ffprobeInnerPath = fallbackFfprobeInnerPath
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
if (inferred) {
innerPath = inferred
}
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe')
if (inferredFfprobe) {
ffprobeInnerPath = inferredFfprobe
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFileWithRetry(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
if (ffprobeInnerPath && ffprobeOutputPath) {
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath)
if (!fileExists(ffprobeSourcePath)) {
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
}
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
setExecutable(ffprobeOutputPath)
}
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
logBinaryVersion('ffmpeg', validation)
if (ffprobeOutputPath) {
const ffprobeValidation = checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
logBinaryVersion('ffprobe', ffprobeValidation)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempTar)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempTar)) {
fs.unlinkSync(tempTar)
}
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true })
}
throw error
}
}
async function downloadDenoRuntime() {
const platform = os.platform()
const arch = os.arch()
const assetName = getDenoAssetName(platform, arch)
if (!assetName) {
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
return
}
const outputName = getDenoOutputName(platform)
const outputPath = path.join(RESOURCES_DIR, outputName)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (validation.ok) {
logBinaryVersion('deno', validation)
} else {
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
}
log(`${outputName} already exists, skipping download`, 'info')
return
}
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting Deno runtime...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, outputName)
if (!fileExists(sourcePath)) {
throw new Error(`Deno binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
}
logBinaryVersion('deno', validation)
log(`Downloaded ${outputName} successfully`, 'success')
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) {
fs.unlinkSync(tempZip)
}
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true })
}
throw error
}
}
// Main setup function
async function setup() {
const platform = os.platform()
const config = PLATFORM_CONFIG[platform]
if (!config) {
log(`Unsupported platform: ${platform}`, 'error')
process.exit(1)
}
log(`Setting up development binaries for ${platform}...`, 'info')
ensureDir(RESOURCES_DIR)
try {
// Download yt-dlp
await downloadYtDlp(config)
// Download JS runtime (Deno)
await downloadDenoRuntime()
// Download ffmpeg
if (platform === 'win32') {
await downloadFfmpegWindows(config)
} else if (platform === 'darwin') {
await downloadFfmpegMac(config)
} else if (platform === 'linux') {
await downloadFfmpegLinux(config)
}
log('Development environment setup completed!', 'success')
} catch (error) {
log(`Setup failed: ${error.message}`, 'error')
process.exit(1)
}
}
// Run setup when executed directly
const isDirectExecution =
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(currentFilePath)
if (isDirectExecution) {
setup()
}
export { checkYtDlpBinary, setup }