22b780e075
The 2026-08-09 pin was a DAILY build; BtbN retention aged it out and the cold-cache download 404'd on the creator's first native recording attempt (2026-09-01, startup.log). Re-pinned to the MONTH-END build autobuild-2026-08-31-13-27 (N-126342, lgpl-shared win64 — 2-year retention; tag+variant recorded in TASKS.md per the licensing rule). Verified alive: HEAD 200 + zip contents (bin/ffmpeg.exe, bin/ffprobe.exe, 7 libav DLLs) match the name-agnostic extractor. Also closes the wrap-gap: HttpRequestException escaped the locator untouched, surfacing a raw 'Response status code... 404' from the frame pump; now wrapped in IOException with the refresh-pin-or-install-ffmpeg message. FfmpegLocatorTests 7/7 (one updated, one added for the 404 case).
158 lines
7.0 KiB
C#
158 lines
7.0 KiB
C#
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Net.Http;
|
|
using ytLive.Helpers;
|
|
|
|
namespace ytLive.Services.Encoder;
|
|
|
|
/// <summary>
|
|
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
|
|
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, 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 <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class FfmpegLocator : IFfmpegLocator
|
|
{
|
|
public const string FileName = "ffmpeg.exe";
|
|
|
|
/// <summary>Sibling probe binary — extracted alongside ffmpeg so the media
|
|
/// frame-rate probe (<see cref="ytLive.Services.FfmpegFrameRateProbe"/>) can
|
|
/// derive its path from the located ffmpeg directory.</summary>
|
|
public const string ProbeFileName = "ffprobe.exe";
|
|
|
|
/// <summary>
|
|
/// Pinned BtbN LGPL-shared win64 build — <c>autobuild-2026-08-31-13-27</c>, 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.
|
|
/// </summary>
|
|
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<string, CancellationToken, Task<byte[]>> _downloader;
|
|
|
|
public FfmpegLocator(
|
|
string[]? searchDirs = null,
|
|
string? toolsDir = null,
|
|
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
|
|
{
|
|
_searchDirs = searchDirs ?? ParsePath();
|
|
_toolsDir = toolsDir ?? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
|
|
_downloader = downloader ?? DefaultDownload;
|
|
}
|
|
|
|
public async Task<string> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
|
|
/// staging directory, so a failed extract never leaves a partially-populated
|
|
/// cache behind (the previous good cache stays until every move succeeds).
|
|
/// </summary>
|
|
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<byte[]> 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);
|
|
}
|
|
}
|