UNPKG

ffmpeg-for-homebridge

Version:

Static FFmpeg binaries for Homebridge camera plugins to support HomeKit video streaming (AAC-ELD and H.264) and hardware-accelerated transcoding (QSV, V4L2M2M, VideoToolbox).

546 lines (392 loc) 21.1 kB
#!/usr/bin/env node /* Copyright(C) 2019-2025, The Homebridge Team. All rights reserved. * * install.js: Install platform-specific versions of FFmpeg that has been statically built. */ const os = require("node:os"); const fs = require("node:fs"); const path = require("node:path"); const https = require("node:https"); const { URL } = require("node:url"); const child_process = require("node:child_process"); const tar = require("tar"); // Define the number of times we'll retry a failed download before giving up entirely. const DOWNLOAD_RETRY_ATTEMPTS = 2; // Define the maximum number of HTTP redirects we'll follow before considering it an error. This prevents infinite redirect loops while still allowing for CDN redirects. const MAX_REDIRECTS = 5; // Define the network timeout in milliseconds. Connections that don't respond within this time will be aborted. const REQUEST_TIMEOUT_MS = 30000; // Define the minimum macOS version we support. Versions older than Sequoia (macOS 15) are not compatible with our precompiled FFmpeg binaries. const MACOS_MINIMUM_SUPPORTED_VERSION = 24; const MACOS_MINIMUM_SUPPORTED_RELEASE = "Sequoia"; // Define file system constants for better maintainability. const CACHE_DIR_NAME = ".build"; const TEMP_DOWNLOAD_FILENAME = ".download"; const FFMPEG_BINARY_NAME_UNIX = "ffmpeg"; const FFMPEG_BINARY_NAME_WINDOWS = "ffmpeg.exe"; // Define tar extraction constants. The tar.gz packages have FFmpeg nested four directories deep at /usr/local/bin/, so we need to strip these path components during // extraction. const TAR_STRIP_COMPONENTS = 4; // Define file permission constants for Unix-like systems. const EXECUTABLE_PERMISSIONS = 0o755; // Define HTTP status codes for better readability. const HTTP_STATUS_OK = 200; // Define GitHub release URL components. const GITHUB_RELEASE_BASE_URL = "https://github.com/homebridge/ffmpeg-for-homebridge/releases/download/"; // Define console output colors for better user feedback. const CONSOLE_COLOR_CYAN = "\x1b[36m"; const CONSOLE_COLOR_RESET = "\x1b[0m"; /** * Retrieves the target FFmpeg release version from the npm package version. This ensures that we download the correct FFmpeg binary version that matches the version of * this npm package being installed. * * @returns {string} The release version string prefixed with 'v'. */ function targetFfmpegRelease() { return "v" + process.env.npm_package_version; } /** * Determines the cache directory path where downloaded FFmpeg binaries will be stored. This provides a consistent location for caching downloads across multiple * installation attempts, preventing unnecessary re-downloads. * * @returns {string} The absolute path to the cache directory. */ function ffmpegCache() { return path.join(__dirname, CACHE_DIR_NAME); } /** * Creates the FFmpeg cache directory with all necessary parent directories. This ensures we have a place to store our downloaded binaries before extraction. * * @returns {void} */ function makeFfmpegCacheDir() { fs.mkdirSync(ffmpegCache(), { recursive: true }); } /** * Ensures the FFmpeg cache directory exists, creating it if necessary. This is a safety check that prevents file system errors when attempting to write downloaded files. * * @returns {void} */ function ensureFfmpegCacheDir() { // Check if the cache directory already exists. If it doesn't, we need to create it before proceeding with any download operations. if(!fs.existsSync(ffmpegCache())) { return makeFfmpegCacheDir(); } } /** * Determines the appropriate FFmpeg binary filename to download based on the current operating system and CPU architecture. This ensures we download a binary that will * actually run on the user's system. * * @returns {Promise<string|null>} The filename to download, or null if the platform is not supported. */ async function getDownloadFileName() { // The list of operating systems we support. switch(os.platform()) { case "darwin": // macOS systems need different binaries for Apple Silicon and Intel processors. switch(process.arch) { case "x64": return "ffmpeg-darwin-x86_64.tar.gz"; case "arm64": return "ffmpeg-darwin-arm64.tar.gz"; default: return null; } case "linux": // Linux systems have multiple architectures we need to support. We use Alpine Linux builds for their strong static-build compatibility. switch(process.arch) { case "x64": return "ffmpeg-alpine-x86_64.tar.gz"; case "arm": return "ffmpeg-alpine-arm32v7.tar.gz"; case "arm64": return "ffmpeg-alpine-aarch64.tar.gz"; default: return null; } case "freebsd": // FreeBSD support is x64-only. switch(process.arch) { case "x64": return "ffmpeg-freebsd-x86_64.tar.gz"; default: return null; } case "win32": // Windows only supports x64 architectures. The platform name "win32" is used for all Windows systems regardless of architecture...a historical anachronism. if(process.arch === "x64") { return FFMPEG_BINARY_NAME_WINDOWS; } return null; default: // Any other operating system is not supported by our precompiled binaries. return null; } } /** * Performs a single download attempt for the FFmpeg binary using native Node.js HTTPS module. This function handles the HTTP request, follows redirects, and manages the * file writing process. * * @param {string} downloadUrl - The complete URL to download the FFmpeg binary from. * @param {string} tempFile - The temporary file path to write the download to. * @param {string} ffmpegDownloadPath - The final destination path for the downloaded file. * @param {number} redirectCount - The number of redirects we've followed (to prevent infinite redirect loops). * @returns {Promise<void>} Resolves when the download completes successfully, rejects on error. */ function performDownload(downloadUrl, tempFile, ffmpegDownloadPath, redirectCount = 0) { // We use a promise here so we can tailor the retrieval to our needs and provide progress tracking, efficient streaming of our download to disk, custom error, redirect // and timeout handling. return new Promise((resolve, reject) => { // Parse the URL to extract the components needed for the HTTPS request. This gives us the hostname, path, and other URL components in a structured format. const urlParts = new URL(downloadUrl); // Configure the HTTPS request options. We include a user agent to identify our script and set a reasonable timeout to prevent hanging on slow connections. const options = { headers: { "User-Agent": "ffmpeg-for-homebridge-installer" }, hostname: urlParts.hostname, method: "GET", path: urlParts.pathname + urlParts.search, timeout: REQUEST_TIMEOUT_MS }; // Create a write stream for saving the downloaded file to disk. We'll pipe the response data directly to this stream for memory efficiency. const file = fs.createWriteStream(tempFile); // Keep track of whether we've already cleaned up resources. This prevents double-cleanup in error scenarios where multiple error events might fire. let cleaned = false; // Helper function to clean up resources when an error occurs. This ensures we don't leave file handles open or partial files on disk. const cleanup = () => { if(!cleaned) { cleaned = true; file.close(); if(fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } } }; // Initiate the request to download the FFmpeg binary from GitHub releases. const request = https.get(options, (response) => { // Handle redirect responses (3xx status codes). GitHub often redirects to CDN servers, so we need to follow these redirects to get to the actual file. if(response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { // Check if we've exceeded the maximum number of redirects. This prevents infinite redirect loops that could occur with misconfigured servers. if(redirectCount >= MAX_REDIRECTS) { cleanup(); reject(new Error("Too many redirects (maximum " + MAX_REDIRECTS + ").")); return; } // Close the current file stream and follow the redirect to the new location. file.close(); fs.unlinkSync(tempFile); // Handle both relative and absolute redirect URLs. Relative URLs need to be resolved against the current URL to get the complete path. const redirectUrl = response.headers.location.startsWith("http") ? response.headers.location : new URL(response.headers.location, downloadUrl).toString(); // Recursively call performDownload with the new URL and increment the redirect counter. performDownload(redirectUrl, tempFile, ffmpegDownloadPath, redirectCount + 1).then(resolve).catch(reject); return; } // Check if we received a successful response. Anything other than 200 OK is considered an error for our // purposes since we're expecting a direct file download. if(response.statusCode !== HTTP_STATUS_OK) { cleanup(); reject(new Error("HTTP " + response.statusCode + " response received.")); return; } // Calculate the total size of the file being downloaded. We default to 1 to avoid divide-by-zero errors if the content-length header is missing. const totalBytes = parseInt(response.headers["content-length"], 10) || 1; let downloadedBytes = 0; // Track download progress and update the console with the current percentage. This gives users feedback that the download is progressing. response.on("data", (chunk) => { downloadedBytes += chunk.length; process.stdout.write("\r" + Math.round((downloadedBytes / totalBytes) * 100).toString() + "%."); }); // Handle the end of the response stream. At this point, all data has been received from the server. response.on("end", () => file.end()); // Handle any errors that occur during the response stream. These could be network errors or timeouts. response.on("error", (error) => { console.log("Response stream error: ", error); cleanup(); reject(error); }); // Handle any file system errors that occur during the write process. These could be disk full errors or permission issues. file.on("error", (error) => { console.log("File system error: ", error); cleanup(); reject(error); }); // Handle the finish event, which fires when all data has been written to the file successfully. file.on("finish", () => { console.log(" - Download complete."); // Only rename if the file exists and the download completed successfully. This prevents errors if something went wrong during the download process. if(fs.existsSync(tempFile)) { fs.renameSync(tempFile, ffmpegDownloadPath); resolve(); } else { reject(new Error("Downloaded file does not exist.")); } }); // Pipe the response data directly to the file. This is more memory-efficient than buffering the entire file in memory, especially important for large binary files // like FFmpeg. response.pipe(file); }); // Handle request-level errors such as DNS failures, connection timeouts, or other network issues. These are different from HTTP errors and indicate problems // establishing the connection. request.on("error", (error) => { console.log("Network error: ", error); cleanup(); reject(error); }); // Handle timeout events. If the server doesn't respond within our timeout period, we abort the request to avoid hanging indefinitely. request.on("timeout", () => { console.log("Request timed out."); request.destroy(); cleanup(); reject(new Error("Request timed out after " + (REQUEST_TIMEOUT_MS / 1000) + " seconds.")); }); }); } /** * Downloads the FFmpeg binary from GitHub releases with automatic retry logic. This function handles network errors gracefully and provides progress feedback to the * user during the download process. * * @param {string} downloadUrl - The complete URL to download the FFmpeg binary from. * @param {string} ffmpegDownloadPath - The local file path where the downloaded file should be saved. * @param {number} retries - The number of retry attempts remaining if the download fails. * @returns {Promise<void>} Resolves when the download completes successfully. */ async function downloadFfmpeg(downloadUrl, ffmpegDownloadPath, retries = DOWNLOAD_RETRY_ATTEMPTS) { // Create a temporary file path for the download. We download to a temp file first to avoid leaving partial files if the download is interrupted. const tempFile = path.resolve(ffmpegCache(), TEMP_DOWNLOAD_FILENAME); console.log("Downloading FFmpeg from: " + downloadUrl); // Keep track of the current attempt number for error reporting. This helps users understand how many attempts have been made. let attemptNumber = 0; // Continue trying to download until we succeed or exhaust all retry attempts. while(attemptNumber <= retries) { try { // Attempt to perform the download. If this succeeds, we'll return immediately. If it fails, we'll catch the error and potentially retry. await performDownload(downloadUrl, tempFile, ffmpegDownloadPath); return; } catch(error) { attemptNumber++; // Check if we have more retry attempts available. If we do, inform the user and try again. If not, throw the error to fail the entire operation. if(attemptNumber <= retries) { console.log("Download failed on attempt " + attemptNumber + ". Retrying..."); } else { // We've exhausted all retry attempts, so we need to fail with an appropriate error message that includes the original error for debugging purposes. throw new Error("Failed to download after " + (retries + 1) + " attempts. Last error: " + error.message); } } } } /** * Verifies that the downloaded FFmpeg binary is functional by attempting to execute it with a simple command. This prevents us from installing a corrupted or * incompatible binary. * * @param {string} ffmpegTempPath - The path to the FFmpeg binary to test. * @returns {boolean} True if the binary executes successfully, false otherwise. */ function binaryOk(ffmpegTempPath) { try { // Attempt to run FFmpeg with the -buildconf flag, which simply outputs build configuration without processing any media files. This is a quick way to verify the binary works. child_process.execSync(ffmpegTempPath + " -buildconf"); return true; } catch (e) { // If the execution fails for any reason, the binary is not usable on this system. return false; } } /** * Displays a helpful error message to the user when FFmpeg installation fails. This ensures users understand that while the plugin installed successfully, they may need * to manually install FFmpeg. * * @returns {void} */ function displayErrorMessage() { console.log("\n" + CONSOLE_COLOR_CYAN + "The Homebridge plugin has been installed, however you may need to install FFmpeg separately." + CONSOLE_COLOR_RESET + "\n"); } /** * Main installation function that orchestrates the entire FFmpeg download and setup process. This function handles platform detection, downloading, extraction, and * verification of the FFmpeg binary. * * @returns {Promise<void>} Resolves when installation completes successfully. */ async function install() { // Ensure the FFmpeg cache directory exists before we attempt any file operations. This prevents errors when trying to save downloaded files. ensureFfmpegCacheDir(); // Check if we're running on a supported version of macOS. Older versions of macOS don't have the required libraries for our precompiled FFmpeg binaries. if((os.platform().toString() === "darwin") && (parseInt(os.release().split(".")[0]) < MACOS_MINIMUM_SUPPORTED_VERSION)) { console.error("ffmpeg-for-homebridge: macOS versions older than " + MACOS_MINIMUM_SUPPORTED_RELEASE + " are not supported, you will need to install a working version of FFmpeg manually."); process.exit(0); } // Determine which FFmpeg binary we need to download for the current platform and architecture. const ffmpegDownloadFileName = await getDownloadFileName(); if(!ffmpegDownloadFileName) { console.error("ffmpeg-for-homebridge: " + os.platform + " " + process.arch + " is not supported, you will need to install a working version of FFmpeg manually."); process.exit(0); } // Construct the full path where the downloaded file will be cached. We include the version in the filename to support multiple versions being cached. const ffmpegDownloadPath = path.resolve(ffmpegCache(), targetFfmpegRelease() + "-" + ffmpegDownloadFileName); // Build the complete URL for downloading the FFmpeg binary from GitHub releases. const downloadUrl = GITHUB_RELEASE_BASE_URL + targetFfmpegRelease() + "/" + ffmpegDownloadFileName; // Check if we've already downloaded this version. If not, download it now. This caching prevents unnecessary downloads when reinstalling the package. if(!fs.existsSync(ffmpegDownloadPath)) { await downloadFfmpeg(downloadUrl, ffmpegDownloadPath); } // Determine the paths for the temporary and final locations of the FFmpeg binary. Windows uses a different filename than Unix-like systems. const ffmpegTempPath = path.resolve(ffmpegCache(), (os.platform() === "win32") ? FFMPEG_BINARY_NAME_WINDOWS : FFMPEG_BINARY_NAME_UNIX); const ffmpegTargetPath = path.resolve(__dirname, (os.platform() === "win32") ? FFMPEG_BINARY_NAME_WINDOWS : FFMPEG_BINARY_NAME_UNIX); // Extract the FFmpeg binary from the tar.gz archive on Unix-like systems. Windows downloads are already executable files that don't need extraction. if(os.platform() !== "win32") { try { // Extract the FFmpeg binary from the tar.gz archive. The binary is nested several directories deep, so we strip those path components during extraction. await tar.x({ file: ffmpegDownloadPath, C: ffmpegCache(), strip: TAR_STRIP_COMPONENTS }); } catch (e) { console.error(e); console.error("An error occurred while extracting the downloaded FFmpeg binary."); displayErrorMessage(); // Delete the cached download since it appears to be corrupted or invalid. This allows a fresh download on the next installation attempt. fs.unlinkSync(ffmpegDownloadPath); process.exit(0); } // Set the execute permission on the extracted binary. Unix-like systems require this permission for the binary to be runnable. if(fs.existsSync(ffmpegTempPath)) { fs.chmodSync(ffmpegTempPath, EXECUTABLE_PERMISSIONS); } } else { // For Windows, the downloaded file is already an executable, so we just need to move it to the temp location. fs.renameSync(ffmpegDownloadPath, ffmpegTempPath); } // Verify that the downloaded binary actually works on this system. This catches issues with incompatible architectures or missing system libraries. if(!binaryOk(ffmpegTempPath)) { displayErrorMessage(); // Delete the cached download since it doesn't work on this system. This allows trying a different version or manual installation. fs.unlinkSync(ffmpegDownloadPath); process.exit(0); } // Move the verified binary to its final location in the npm package directory. This is where the main package code will look for it. fs.renameSync(ffmpegTempPath, ffmpegTargetPath); console.log(CONSOLE_COLOR_CYAN + "\nFFmpeg has been downloaded to " + ffmpegTargetPath + "." + CONSOLE_COLOR_RESET); } /** * Bootstrap function that initiates the installation process and handles top-level errors. This provides a clean entry point for the script and ensures proper error * handling. * * @returns {Promise<void>} Resolves when the bootstrap process completes. */ async function bootstrap() { console.log("Retrieving FFmpeg from ffmpeg-for-homebridge release: " + targetFfmpegRelease() + "."); try { await install(); } catch (e) { // Check if the error is due to permission issues. This commonly happens when installing global npm packages without proper permissions. if(e && e.code && e.code === "EACCES") { console.log("Unable to download FFmpeg."); console.log("If you are installing this plugin as a global module (-g), make sure you add the --unsafe-perm flag to the install command."); } displayErrorMessage(); // Use setTimeout to ensure all console output is flushed before the process exits. setTimeout(() => process.exit(0)); } } // Start the installation process when this script is executed. bootstrap();