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

1071 lines
32 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 }