diff --git a/Services/Encoder/FfmpegLocator.cs b/Services/Encoder/FfmpegLocator.cs index d756ccc..a214530 100644 --- a/Services/Encoder/FfmpegLocator.cs +++ b/Services/Encoder/FfmpegLocator.cs @@ -25,9 +25,17 @@ public sealed class FfmpegLocator : IFfmpegLocator /// derive its path from the located ffmpeg directory. public const string ProbeFileName = "ffprobe.exe"; - /// Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md). + /// + /// 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-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip"; + "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; @@ -65,6 +73,17 @@ public sealed class FfmpegLocator : IFfmpegLocator 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"); diff --git a/TASKS.md b/TASKS.md index 9ab2605..7b357dd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -342,10 +342,15 @@ provides one. compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and `ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile. -2. **Pinned URL** — `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip` - (~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention +2. **Pinned URL** — **RE-PINNED 2026-09-01**: `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip` + (BtbN **lgpl-shared** variant, ffmpeg N-126342; ~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention - expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once + expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). The + first pin (`autobuild-2026-08-09-13-03`, a daily) aged out on 2026-09-01 — the first real recording + attempt — proving the rule; the new pin is deliberately the **month-end** build (2-year retention). + Dated tags carry versioned asset names (`ffmpeg-N-…-win64-lgpl-shared.zip`), extraction is + name-agnostic; download failures now wrap in `IOException` with an actionable message + ("refresh `FfmpegLocator.PinnedUrl` or install ffmpeg on PATH") instead of leaking a raw 404. Once cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing). 3. **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached diff --git a/ytLive.Tests/FfmpegLocatorTests.cs b/ytLive.Tests/FfmpegLocatorTests.cs index c7767f7..a626e18 100644 --- a/ytLive.Tests/FfmpegLocatorTests.cs +++ b/ytLive.Tests/FfmpegLocatorTests.cs @@ -164,6 +164,23 @@ public class FfmpegLocatorTests { var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") }; var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync); - await Assert.ThrowsAsync(() => locator.LocateAsync()); + var ex = await Assert.ThrowsAsync(() => locator.LocateAsync()); + Assert.IsType(ex.InnerException); + Assert.Contains("pinned build", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Locate_DeadPin404_ThrowsActionableLocatorError() + { + // 2026-09-01 incident: the pinned autobuild tag aged out of BtbN retention; + // the raw HttpRequestException must never surface from the pump again. + var downloader = new RecordingDownloader + { + Error = new HttpRequestException("Response status code does not indicate success: 404 (Not Found)."), + }; + var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync); + var ex = await Assert.ThrowsAsync(() => locator.LocateAsync()); + Assert.Contains("PATH", ex.Message); + Assert.Contains("PinnedUrl", ex.Message); } }