#!/usr/bin/env node /** * Development environment setup script * Automatically downloads yt-dlp and ffmpeg binaries based on the current system */ const fs = require('node:fs') const path = require('node:path') const os = require('node:os') const { execSync, spawnSync } = require('node:child_process') const https = require('node:https') const http = require('node:http') // Configuration const RESOURCES_DIR = path.join(__dirname, '..', 'resources') 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 GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN // Platform configuration const PLATFORM_CONFIG = { win32: { ytdlp: { asset: 'yt-dlp.exe', output: 'yt-dlp.exe' }, 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', output: 'ffmpeg.exe', extract: 'unzip', release: { repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i, binaryName: 'ffmpeg.exe' } } }, darwin: { ytdlp: { asset: 'yt-dlp_macos', output: 'yt-dlp_macos' }, ffmpeg: { // For development, download only the architecture matching current system arm64: { url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip', innerPath: 'ffmpeg/ffmpeg', output: 'ffmpeg_macos', extract: 'unzip', release: { repo: 'eko5624/mpv-mac', assetPattern: /ffmpeg-arm64.*\.zip$/i } }, x64: { url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip', innerPath: 'ffmpeg/ffmpeg', output: 'ffmpeg_macos', extract: 'unzip', release: { repo: 'eko5624/mpv-mac', assetPattern: /ffmpeg-x86_64.*\.zip$/i } } } }, linux: { ytdlp: { asset: 'yt-dlp', output: 'yt-dlp_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', output: 'ffmpeg_linux', extract: 'tar', release: { repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/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 const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => { if (response.statusCode === 302 || response.statusCode === 301) { // Handle redirect 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) { 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', () => { file.close() log( `Downloaded ${formatBytes(downloadedBytes)} from ${url}`, downloadedBytes ? 'success' : 'warn' ) resolve() }) }) request.setTimeout(30000, () => { request.destroy(new Error('Download timeout')) }) request.on('error', (err) => { 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 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) { const result = spawnSync(filePath, args, { encoding: 'utf8', timeout: 8000, windowsHide: true }) if (result.error) { return { ok: false, message: result.error.message } } 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` } } 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' } // Main download functions async function downloadYtDlp(config) { const { asset, output } = config.ytdlp const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { const validation = checkBinary(outputPath, ['--version'], 'yt-dlp') if (!validation.ok) { 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 = checkBinary(outputPath, ['--version'], 'yt-dlp') if (!validation.ok) { safeUnlink(outputPath) throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) } 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, output, release } = config.ffmpeg const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { log(`Existing ${output} failed version check: ${validation.message}`, 'warn') } log(`${output} already exists, skipping download`, 'info') return } log(`Downloading ffmpeg for Windows...`, 'download') const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') let downloadUrl = fallbackUrl let innerPath = fallbackInnerPath 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 } } } 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) const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { safeUnlink(outputPath) throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) } log(`Downloaded ${output} successfully`, 'success') // 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 arch = os.arch() const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64'] if (!ffmpegConfig) { throw new Error(`Unsupported architecture: ${arch}`) } const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { log(`Existing ${output} failed version check: ${validation.message}`, 'warn') } log(`${output} already exists, skipping download`, 'info') return } log(`Downloading ffmpeg for macOS (${arch})...`, 'download') const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') let downloadUrl = fallbackUrl if (release) { try { const resolved = await resolveReleaseAsset(release) if (resolved) { downloadUrl = resolved.url } } 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) if (!fileExists(sourcePath)) { throw new Error(`ffmpeg binary not found at ${sourcePath}`) } fs.copyFileSync(sourcePath, outputPath) setExecutable(outputPath) const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { safeUnlink(outputPath) throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) } log(`Downloaded ${output} successfully`, 'success') // 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 downloadFfmpegLinux(config) { const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { log(`Existing ${output} failed version check: ${validation.message}`, 'warn') } log(`${output} already exists, skipping download`, 'info') return } log(`Downloading ffmpeg for Linux...`, 'download') const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') let downloadUrl = fallbackUrl let innerPath = fallbackInnerPath 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 } } } 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) const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { safeUnlink(outputPath) throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) } 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) { 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}`) } 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 if (require.main === module) { setup() } module.exports = { setup }