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
1071 lines
32 KiB
JavaScript
Executable File
1071 lines
32 KiB
JavaScript
Executable File
#!/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 }
|