FFmpeg locator (TASK 4 ship step 2) + licensing: notices, About, LGPL-shared pin
IFfmpegLocator seam that resolves ffmpeg.exe (PATH -> cache -> pinned download), plus the licensing compliance that makes a paid GA product defensible. NOTE FOR USERS: this change shows NO difference in the app's behavior except a new top-bar "About" button (opens THIRD-PARTY-NOTICES.txt). It is scaffolding for the encoder/streaming work. - Services/Encoder/: IFfmpegLocator + FfmpegLocator. Pin is BtbN lgpl-shared autobuild-2026-08-09-13-03 (NOT gyan.dev/GPL or static: LGPLv2.1 §6 static relink material avoided by dynamic linking); extracts ffmpeg.exe + libav*.dll via a staging dir so a crash never leaves a partial cache - THIRD-PARTY-NOTICES.txt: LGPL/BSD/MIT notices + source offer, copied to the build output, surfaced by the About button; v1 gate = bundle full license texts (TASK 4 requirement 9) - ai.md "Licensing - do not violate" guardrails (never GPL/nonfree/static/latest- tag, never link FFmpeg in, never drop notices); TASKS.md + Services/index.md updated - Tests: FfmpegLocatorTests - hermetic decision-ladder integration test + shared- build DLL extraction + edge cases; docs updated (78 tests passing, 0 warnings)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using Xunit;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The FFmpeg locator (TASK 4 ship step 2): resolves ffmpeg.exe by probing PATH
|
||||
/// first, then the cache in the tools directory, then pulling the pinned BtbN
|
||||
/// LGPL-shared zip. The integration test drives the full ladder against a temp
|
||||
/// tools dir and a fake downloader that returns a real in-memory zip; the units
|
||||
/// pin down the failure and edge cases. No network, no real binary.
|
||||
/// </summary>
|
||||
public class FfmpegLocatorTests
|
||||
{
|
||||
private static byte[] MakeZip(
|
||||
string exePath = "ffmpeg-master-latest-win64-lgpl-shared/bin/ffmpeg.exe",
|
||||
string[]? dllPaths = null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(exePath);
|
||||
using (var writer = new StreamWriter(entry.Open()))
|
||||
writer.Write("dummy ffmpeg binary");
|
||||
foreach (var dll in dllPaths ?? Array.Empty<string>())
|
||||
{
|
||||
var dllEntry = archive.CreateEntry(dll);
|
||||
using var dllWriter = new StreamWriter(dllEntry.Open());
|
||||
dllWriter.Write("dummy dll");
|
||||
}
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static string TempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private sealed class RecordingDownloader
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public byte[] Payload { get; set; } = MakeZip();
|
||||
public Exception? Error { get; set; }
|
||||
|
||||
public Task<byte[]> DownloadAsync(string url, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
if (Error != null) throw Error;
|
||||
return Task.FromResult(Payload);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_Integration_FullDecisionLadder()
|
||||
{
|
||||
var toolsDir = TempDir();
|
||||
try
|
||||
{
|
||||
// 1. PATH hit wins, downloader never invoked.
|
||||
var pathDir = TempDir();
|
||||
var pathExe = Path.Combine(pathDir, FfmpegLocator.FileName);
|
||||
File.WriteAllText(pathExe, "user's ffmpeg");
|
||||
var downloader = new RecordingDownloader();
|
||||
|
||||
var fromPath = new FfmpegLocator([pathDir], toolsDir, downloader.DownloadAsync);
|
||||
Assert.Equal(pathExe, await fromPath.LocateAsync());
|
||||
Assert.Equal(0, downloader.Calls);
|
||||
|
||||
// 2. Cache hit skips the network.
|
||||
var cached = Path.Combine(toolsDir, FfmpegLocator.FileName);
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
File.WriteAllText(cached, "cached ffmpeg");
|
||||
var fromCache = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
|
||||
Assert.Equal(cached, await fromCache.LocateAsync());
|
||||
Assert.Equal(0, downloader.Calls);
|
||||
|
||||
// 3. Cold cache downloads exactly once, extracts ffmpeg.exe, and the
|
||||
// second call serves the cache without re-downloading.
|
||||
File.Delete(cached);
|
||||
var coldTools = TempDir();
|
||||
var cold = new FfmpegLocator([], coldTools, downloader.DownloadAsync);
|
||||
var resolved = await cold.LocateAsync();
|
||||
Assert.Equal(Path.Combine(coldTools, FfmpegLocator.FileName), resolved);
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
Assert.True(new FileInfo(resolved).Length > 0);
|
||||
Assert.Equal(resolved, await cold.LocateAsync());
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_ZeroByteCache_IsRefreshed()
|
||||
{
|
||||
var toolsDir = TempDir();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(toolsDir, FfmpegLocator.FileName), "");
|
||||
var downloader = new RecordingDownloader();
|
||||
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
|
||||
var resolved = await locator.LocateAsync();
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
Assert.True(new FileInfo(resolved).Length > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_SharedBuild_ExtractsDllsAlongsideExe()
|
||||
{
|
||||
var toolsDir = TempDir();
|
||||
try
|
||||
{
|
||||
var downloader = new RecordingDownloader
|
||||
{
|
||||
Payload = MakeZip(dllPaths:
|
||||
[
|
||||
"ffmpeg-master-latest-win64-lgpl-shared/bin/avcodec-61.dll",
|
||||
"ffmpeg-master-latest-win64-lgpl-shared/bin/avformat-61.dll",
|
||||
])
|
||||
};
|
||||
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
|
||||
var resolved = await locator.LocateAsync();
|
||||
Assert.True(File.Exists(Path.Combine(toolsDir, "avcodec-61.dll")));
|
||||
Assert.True(File.Exists(Path.Combine(toolsDir, "avformat-61.dll")));
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_EmptyPayload_Throws()
|
||||
{
|
||||
var downloader = new RecordingDownloader { Payload = Array.Empty<byte>() };
|
||||
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
|
||||
await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
|
||||
Assert.Equal(1, downloader.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_ZipWithoutFfmpegEntry_Throws()
|
||||
{
|
||||
var downloader = new RecordingDownloader { Payload = MakeZip(exePath: "readme.txt") };
|
||||
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => locator.LocateAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locate_DownloaderFailure_Propagates()
|
||||
{
|
||||
var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") };
|
||||
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => locator.LocateAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user