using System.IO; using System.IO.Compression; using System.Net.Http; using ytLive.Helpers; namespace ytLive.Services.Encoder; /// /// The default : probe PATH first (the user's own /// install wins), then the cache in %APPDATA%\ytLlive\tools, then pull the /// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a /// deliberate licensing choice: dynamic linking means LGPL compliance is "license /// text + source offer", with no static-relink (LGPL §6) material required. The /// shared zip puts ffmpeg.exe plus the libav*.dll family in bin/, /// so both are extracted — Windows resolves the DLLs from the exe's own directory. /// Search dirs, tools dir, and the downloader are constructor-injected so tests /// fake the network and stay on a temp directory. /// public sealed class FfmpegLocator : IFfmpegLocator { public const string FileName = "ffmpeg.exe"; /// Sibling probe binary — extracted alongside ffmpeg so the media /// frame-rate probe () can /// derive its path from the located ffmpeg directory. public const string ProbeFileName = "ffprobe.exe"; /// /// Pinned BtbN LGPL-shared win64 build — autobuild-2026-08-31-13-27, the /// ffmpeg N-126342 build (immutable tag; recorded in TASKS.md per the licensing rule). /// Deliberately a MONTH-END tag: BtbN retains the last ~14 dailies but month-end /// builds for ~2 years — the previous pin (a 2026-08-09 daily) aged out of retention /// and 404'd on a cold cache exactly as ai.md predicted (2026-09-01, the first real /// recording attempt). Dated tags carry versioned asset names (ffmpeg-N-…-win64-lgpl-shared.zip); /// extraction is name-agnostic. /// public const string PinnedUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip"; private readonly string[] _searchDirs; private readonly string _toolsDir; private readonly Func> _downloader; public FfmpegLocator( string[]? searchDirs = null, string? toolsDir = null, Func>? downloader = null) { _searchDirs = searchDirs ?? ParsePath(); _toolsDir = toolsDir ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools"); _downloader = downloader ?? DefaultDownload; } public async Task LocateAsync(CancellationToken cancellationToken = default) { foreach (var dir in _searchDirs) { if (string.IsNullOrEmpty(dir)) continue; var candidate = Path.Combine(dir, FileName); if (File.Exists(candidate)) return candidate; } var cached = Path.Combine(_toolsDir, FileName); if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached; try { var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false); if (zip.Length == 0) throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload."); Directory.CreateDirectory(_toolsDir); ExtractBinaries(zip, _toolsDir); return cached; } catch (HttpRequestException ex) { // The old gap: this exception type escaped unwrapped, so a dead pin surfaced // as a raw "Response status code does not indicate success: 404" from the // frame pump. Locator failures are IOException with an actionable message. AppLog.Write(ex, "FFmpeg locator: download failed"); throw new IOException( $"FFmpeg download failed ({ex.Message}). The pinned build may have aged out of " + "retention — update FfmpegLocator.PinnedUrl, or install FFmpeg (LGPL-shared build) " + "and make sure 'ffmpeg' is on PATH.", ex); } catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException) { AppLog.Write(ex, "FFmpeg locator: download/extract failed"); throw; } } /// /// Extract ffmpeg.exe and every *.dll into toolsDir via a /// staging directory, so a failed extract never leaves a partially-populated /// cache behind (the previous good cache stays until every move succeeds). /// private static void ExtractBinaries(byte[] zip, string toolsDir) { var staging = toolsDir + ".stage"; if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true); Directory.CreateDirectory(staging); try { using var stream = new MemoryStream(zip, writable: false); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); var exe = archive.Entries.FirstOrDefault( e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}."); ExtractOne(exe, Path.Combine(staging, FileName)); var probe = archive.Entries.FirstOrDefault( e => e.FullName.EndsWith("/" + ProbeFileName, StringComparison.OrdinalIgnoreCase)); if (probe != null) ExtractOne(probe, Path.Combine(staging, ProbeFileName)); foreach (var entry in archive.Entries) { if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName))); } foreach (var file in Directory.GetFiles(staging)) File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true); } finally { if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true); } } private static void ExtractOne(ZipArchiveEntry entry, string destination) { var temp = destination + ".tmp"; try { using (var source = entry.Open()) using (var target = File.Create(temp)) source.CopyTo(target); File.Move(temp, destination); } finally { if (File.Exists(temp)) File.Delete(temp); } } private static async Task DefaultDownload(string url, CancellationToken ct) { using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false); } private static string[] ParsePath() { var raw = Environment.GetEnvironmentVariable("PATH") ?? ""; return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); } }