diff --git a/src/ui/Features/Options/Settings/SettingsViewModel.cs b/src/ui/Features/Options/Settings/SettingsViewModel.cs index 4bc8761ad1..70206be5e7 100644 --- a/src/ui/Features/Options/Settings/SettingsViewModel.cs +++ b/src/ui/Features/Options/Settings/SettingsViewModel.cs @@ -29,6 +29,7 @@ using Nikse.SubtitleEdit.Features.Video.BurnIn; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; using Nikse.SubtitleEdit.Logic.Media; using Nikse.SubtitleEdit.Logic.VideoPlayers.LibMpvDynamic; using System; @@ -689,7 +690,7 @@ public SettingsViewModel(IWindowService windowService, IFolderHelper folderHelpe LibMpvPath = string.Empty; IsLibMpvDownloadVisible = OperatingSystem.IsWindows(); IsLibVlcDownloadVisible = OperatingSystem.IsWindows(); - IsFfmpegLibsDownloadVisible = OperatingSystem.IsWindows(); + IsFfmpegLibsDownloadVisible = FfmpegLibsDownloadService.IsDownloadSupportedOnCurrentPlatform(); MpvPreviewFontName = FontNames.First(); MpvPreviewSelectedBorderType = MpvPreviewBorderTypes.First(); diff --git a/src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs b/src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs index 58eef6f64d..1644a98e16 100644 --- a/src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs +++ b/src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs @@ -10,7 +10,6 @@ using System; using System.Globalization; using System.IO; -using System.IO.Compression; using System.Threading; using System.Threading.Tasks; using System.Timers; @@ -37,7 +36,8 @@ public partial class DownloadFfmpegLibsViewModel : ObservableObject, IClosingCle private readonly IFfmpegLibsDownloadService _downloadService; private Task? _downloadTask; private readonly Timer _timer; - private bool _done; + private volatile bool _done; + private int _cleanupStarted; private readonly CancellationTokenSource _cancellationTokenSource; private IndeterminateProgressHelper? _indeterminateProgressHelper; private readonly Lock _lockObj = new(); @@ -94,14 +94,7 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) } finally { - try - { - File.Delete(_tempFileName); - } - catch - { - // temp file, best effort - } + TryDeleteTempFile(_tempFileName); } StopIndeterminateProgress(); @@ -129,39 +122,21 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) } /// - /// Pulls the DLLs out of the build zip (ffmpeg-…/bin/*.dll) into a flat folder. Only the - /// libraries are taken: the zip also carries its own ffmpeg.exe, headers and import libs, - /// none of which the player needs. + /// Stages, validates and transactionally installs the DLLs from the reviewed FFmpeg build zip. + /// The active library folder is changed only after all required runtime libraries are present. /// internal static void ExtractLibraries(string zipFileName, string targetFolder, CancellationToken cancellationToken) { - Directory.CreateDirectory(targetFolder); - using var archive = ZipFile.OpenRead(zipFileName); - var count = 0; - foreach (var entry in archive.Entries) - { - cancellationToken.ThrowIfCancellationRequested(); - var name = entry.FullName.Replace('\\', '/'); - if (!name.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || - !name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrEmpty(entry.Name)) - { - continue; - } - - var target = Path.Combine(targetFolder, entry.Name); - entry.ExtractToFile(target, overwrite: true); - count++; - } - - if (count == 0) - { - throw new InvalidOperationException("No FFmpeg libraries found in the downloaded archive"); - } + FfmpegLibraryInstaller.Install(zipFileName, targetFolder, cancellationToken); } private void StartIndeterminateProgress() { + if (_cancellationTokenSource.IsCancellationRequested) + { + return; + } + _indeterminateProgressHelper?.Dispose(); _indeterminateProgressHelper = new IndeterminateProgressHelper( value => ProgressValue = value, @@ -183,14 +158,67 @@ private void Close() [RelayCommand] private void CommandCancel() { - _cancellationTokenSource.Cancel(); - _done = true; + CancelPendingWork(); Close(); } + private void CancelPendingWork() + { + _done = true; + _cancellationTokenSource.Cancel(); + StopIndeterminateProgress(); + } + public void OnClosingCleanup() { + if (Interlocked.Exchange(ref _cleanupStarted, 1) != 0) + { + return; + } + + // Closed can come from the Cancel button, Escape, the title-bar X, or normal success. + // Make the first three equivalent: stop all work before detaching the polling timer. + CancelPendingWork(); _timer.StopAndDispose(OnTimerOnElapsed); + _ = DeleteTempFileWhenTaskCompletesAsync(_downloadTask, _tempFileName); + } + + internal static async Task DeleteTempFileWhenTaskCompletesAsync(Task? downloadTask, string tempFileName) + { + if (downloadTask != null) + { + try + { + await downloadTask.ConfigureAwait(false); + } + catch + { + // Cancellation/download failure is already the reason cleanup is running. + } + } + + TryDeleteTempFile(tempFileName); + } + + private static void TryDeleteTempFile(string tempFileName) + { + if (string.IsNullOrWhiteSpace(tempFileName)) + { + return; + } + + try + { + if (File.Exists(tempFileName)) + { + File.Delete(tempFileName); + } + } + catch + { + // Best effort. During unpacking the timer callback may still own the ZIP and its + // finally block will retry deletion once that operation observes cancellation. + } } public void StartDownload() diff --git a/src/ui/Logic/Download/FfmpegLibraryInstaller.cs b/src/ui/Logic/Download/FfmpegLibraryInstaller.cs new file mode 100644 index 0000000000..1ca3066b5d --- /dev/null +++ b/src/ui/Logic/Download/FfmpegLibraryInstaller.cs @@ -0,0 +1,174 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; + +namespace Nikse.SubtitleEdit.Logic.Download; + +internal static class FfmpegLibraryInstaller +{ + internal static string[] RequiredWindowsLibraryNames => + [ + $"avcodec-{ffmpeg.LIBAVCODEC_VERSION_MAJOR}.dll", + $"avformat-{ffmpeg.LIBAVFORMAT_VERSION_MAJOR}.dll", + $"avutil-{ffmpeg.LIBAVUTIL_VERSION_MAJOR}.dll", + $"swscale-{ffmpeg.LIBSWSCALE_VERSION_MAJOR}.dll", + $"swresample-{ffmpeg.LIBSWRESAMPLE_VERSION_MAJOR}.dll", + ]; + + internal static void Install(string zipFileName, string targetFolder, CancellationToken cancellationToken) + { + Directory.CreateDirectory(targetFolder); + var transactionId = Guid.NewGuid().ToString("N"); + var stagingFolder = Path.Combine(targetFolder, ".ffmpeg-install-" + transactionId); + var backupFolder = Path.Combine(targetFolder, ".ffmpeg-backup-" + transactionId); + Directory.CreateDirectory(stagingFolder); + + try + { + ExtractToStaging(zipFileName, stagingFolder, cancellationToken); + ValidateStaging(stagingFolder); + Commit(stagingFolder, backupFolder, targetFolder, cancellationToken); + } + finally + { + TryDeleteDirectory(stagingFolder); + } + } + + private static void ExtractToStaging(string zipFileName, string stagingFolder, CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead(zipFileName); + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = entry.FullName.Replace('\\', '/'); + if (!name.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || + !name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrEmpty(entry.Name)) + { + continue; + } + + // Flatten exactly as the historical installer did, but do it only in staging. + // Duplicate leaf names are rejected rather than silently selecting one archive entry. + entry.ExtractToFile(Path.Combine(stagingFolder, entry.Name), overwrite: false); + } + } + + private static void ValidateStaging(string stagingFolder) + { + var missing = RequiredWindowsLibraryNames + .Where(name => !File.Exists(Path.Combine(stagingFolder, name))) + .ToArray(); + if (missing.Length > 0) + { + throw new InvalidOperationException( + "Downloaded FFmpeg archive is incomplete. Missing required libraries: " + string.Join(", ", missing)); + } + } + + private static void Commit(string stagingFolder, string backupFolder, string targetFolder, CancellationToken cancellationToken) + { + var stagedFiles = Directory.GetFiles(stagingFolder) + .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var installed = new List(); + var backedUp = new List(); + Directory.CreateDirectory(backupFolder); + + try + { + foreach (var stagedPath in stagedFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = Path.GetFileName(stagedPath); + var targetPath = Path.Combine(targetFolder, fileName); + var backupPath = Path.Combine(backupFolder, fileName); + + if (Directory.Exists(targetPath)) + { + throw new IOException($"Cannot install FFmpeg library '{fileName}' because a directory already exists at that path."); + } + + if (File.Exists(targetPath)) + { + File.Move(targetPath, backupPath); + backedUp.Add(fileName); + } + + File.Move(stagedPath, targetPath); + installed.Add(fileName); + } + } + catch (Exception installException) + { + try + { + RollBack(targetFolder, backupFolder, installed, backedUp); + } + catch (Exception rollbackException) + { + // Never hide that the active folder may now need manual repair. Keep the backup + // directory intact so the original bytes remain recoverable. + throw new AggregateException( + "FFmpeg library installation failed and rollback could not restore the previous installation.", + installException, + rollbackException); + } + + TryDeleteDirectory(backupFolder); + throw; + } + + TryDeleteDirectory(backupFolder); + } + + private static void RollBack(string targetFolder, string backupFolder, List installed, List backedUp) + { + for (var i = installed.Count - 1; i >= 0; i--) + { + var targetPath = Path.Combine(targetFolder, installed[i]); + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + } + + for (var i = backedUp.Count - 1; i >= 0; i--) + { + var fileName = backedUp[i]; + var backupPath = Path.Combine(backupFolder, fileName); + var targetPath = Path.Combine(targetFolder, fileName); + if (!File.Exists(backupPath)) + { + continue; + } + + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + + File.Move(backupPath, targetPath); + } + } + + private static void TryDeleteDirectory(string folder) + { + try + { + if (Directory.Exists(folder)) + { + Directory.Delete(folder, recursive: true); + } + } + catch + { + // Best effort cleanup. Installation/rollback success is determined before this point. + } + } +} diff --git a/src/ui/Logic/Download/FfmpegLibsDownloadService.cs b/src/ui/Logic/Download/FfmpegLibsDownloadService.cs index d4ebb23c7c..70e27a102b 100644 --- a/src/ui/Logic/Download/FfmpegLibsDownloadService.cs +++ b/src/ui/Logic/Download/FfmpegLibsDownloadService.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; +using Nikse.SubtitleEdit.UiLogic; using System; using System.IO; using System.Net.Http; @@ -21,17 +22,59 @@ public interface IFfmpegLibsDownloadService /// public class FfmpegLibsDownloadService(HttpClient httpClient) : IFfmpegLibsDownloadService { - private static readonly string WindowsX64Url = - $"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n{FfmpegLibraries.MajorVersion}-latest-win64-lgpl-shared-{FfmpegLibraries.MajorVersion}.zip"; + internal const string WindowsX64ReleaseTag = "autobuild-2026-09-15-13-18"; + internal const string WindowsX64AssetName = "ffmpeg-n9.0.1-30-g9258bacca5-win64-lgpl-shared-9.0.zip"; + internal const string WindowsX64Sha256 = "04d256aa477122949304a717bf61d8eec78e255c2c7c9be17fab1677227cb548"; + internal const string WindowsX64Url = + "https://github.com/BtbN/FFmpeg-Builds/releases/download/" + WindowsX64ReleaseTag + "/" + WindowsX64AssetName; public async Task DownloadFfmpegLibs(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(httpClient, GetUrl(), destinationFileName, progress, cancellationToken); + await VerifySha256Async(destinationFileName, WindowsX64Sha256, cancellationToken); + } + + internal static bool IsDownloadSupported(bool isWindows, Architecture processArchitecture) + { + return isWindows && processArchitecture == Architecture.X64; + } + + internal static bool IsDownloadSupportedOnCurrentPlatform() + { + return IsDownloadSupported(OperatingSystem.IsWindows(), RuntimeInformation.ProcessArchitecture); + } + + internal static async Task VerifySha256Async(string filePath, string expectedSha256, CancellationToken cancellationToken) + { + var actual = await Sha256Util.ComputeSha256Async(filePath, cancellationToken); + if (string.Equals(actual, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + TryDeleteFile(filePath); + throw new InvalidOperationException( + $"Downloaded FFmpeg library archive failed SHA-256 verification — expected {expectedSha256}, got {actual ?? ""}. The file has been removed."); + } + + private static void TryDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch + { + // Best effort: never treat a failed cleanup as successful verification. + } } private static string GetUrl() { - if (OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64) + if (IsDownloadSupportedOnCurrentPlatform()) { return WindowsX64Url; } diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegLibraries.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegLibraries.cs index 69a1d849eb..e07069494e 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegLibraries.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegLibraries.cs @@ -27,6 +27,22 @@ public static class FfmpegLibraries /// libavcodec major the bindings were generated for (63 for FFmpeg 9), as used in the library file names. public static int AvCodecMajor => ffmpeg.LIBAVCODEC_VERSION_MAJOR; + internal static int VersionMajor(uint version) => (int)(version >> 16); + + internal static bool RequiredLibraryVersionsMatch( + uint avcodec, + uint avformat, + uint avutil, + uint swscale, + uint swresample) + { + return VersionMajor(avcodec) == ffmpeg.LIBAVCODEC_VERSION_MAJOR && + VersionMajor(avformat) == ffmpeg.LIBAVFORMAT_VERSION_MAJOR && + VersionMajor(avutil) == ffmpeg.LIBAVUTIL_VERSION_MAJOR && + VersionMajor(swscale) == ffmpeg.LIBSWSCALE_VERSION_MAJOR && + VersionMajor(swresample) == ffmpeg.LIBSWRESAMPLE_VERSION_MAJOR; + } + /// /// Set this path (directory only) to override the default search paths - the same idea as /// LibVlcDynamicPlayer.LibVlcPath. @@ -163,6 +179,20 @@ private static bool TryInitialize() // An empty RootPath leaves the lookup to the system loader (PATH / LD_LIBRARY_PATH / // dyld), which is the normal case on Linux where FFmpeg is a distro package. ffmpeg.RootPath = _resolvedPath; + + // Probe every library the player actually uses. av_version_info() belongs to avutil, + // so probing only that function can report the player as available even when codec, + // demux, scaling or resampling libraries are missing. + var avcodec = ffmpeg.avcodec_version(); + var avformat = ffmpeg.avformat_version(); + var avutil = ffmpeg.avutil_version(); + var swscale = ffmpeg.swscale_version(); + var swresample = ffmpeg.swresample_version(); + if (!RequiredLibraryVersionsMatch(avcodec, avformat, avutil, swscale, swresample)) + { + return false; + } + var version = ffmpeg.av_version_info(); if (string.IsNullOrEmpty(version)) { diff --git a/tests/UI/Features/Shared/DownloadFfmpegLibsViewModelTests.cs b/tests/UI/Features/Shared/DownloadFfmpegLibsViewModelTests.cs new file mode 100644 index 0000000000..80c6fdcca4 --- /dev/null +++ b/tests/UI/Features/Shared/DownloadFfmpegLibsViewModelTests.cs @@ -0,0 +1,79 @@ +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Features.Shared; + +public class DownloadFfmpegLibsViewModelTests +{ + [Fact] + public void OnClosingCleanup_CancelsPendingDownload() + { + var service = new BlockingFfmpegLibsDownloadService(); + var vm = new DownloadFfmpegLibsViewModel(service); + + vm.StartDownload(); + Assert.True(service.Started.Wait(2000, TestContext.Current.CancellationToken)); + Assert.False(service.CapturedToken.IsCancellationRequested); + + vm.OnClosingCleanup(); + + Assert.True(service.CapturedToken.IsCancellationRequested); + } + + [Fact] + public async Task DeleteTempFileWhenTaskCompletesAsync_WaitsThenDeletes() + { + var fileName = Path.Combine(Path.GetTempPath(), "se-ffmpeg-close-" + Guid.NewGuid().ToString("N") + ".zip"); + File.WriteAllText(fileName, "partial"); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + var cleanup = DownloadFfmpegLibsViewModel.DeleteTempFileWhenTaskCompletesAsync(completion.Task, fileName); + Assert.False(cleanup.IsCompleted); + Assert.True(File.Exists(fileName)); + + completion.SetResult(); + await cleanup; + + Assert.False(File.Exists(fileName)); + } + finally + { + File.Delete(fileName); + } + } + + [Fact] + public async Task DeleteTempFileWhenTaskCompletesAsync_FaultStillDeletes() + { + var fileName = Path.Combine(Path.GetTempPath(), "se-ffmpeg-close-" + Guid.NewGuid().ToString("N") + ".zip"); + File.WriteAllText(fileName, "partial"); + + try + { + await DownloadFfmpegLibsViewModel.DeleteTempFileWhenTaskCompletesAsync( + Task.FromException(new IOException("download failed")), + fileName); + + Assert.False(File.Exists(fileName)); + } + finally + { + File.Delete(fileName); + } + } + + private sealed class BlockingFfmpegLibsDownloadService : IFfmpegLibsDownloadService + { + internal ManualResetEventSlim Started { get; } = new(false); + internal CancellationToken CapturedToken { get; private set; } + + public async Task DownloadFfmpegLibs(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) + { + CapturedToken = cancellationToken; + Started.Set(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } +} diff --git a/tests/UI/Logic/Download/FfmpegLibraryInstallerTests.cs b/tests/UI/Logic/Download/FfmpegLibraryInstallerTests.cs new file mode 100644 index 0000000000..daf9bd9021 --- /dev/null +++ b/tests/UI/Logic/Download/FfmpegLibraryInstallerTests.cs @@ -0,0 +1,155 @@ +using Nikse.SubtitleEdit.Logic.Download; +using System.IO.Compression; + +namespace UITests.Logic.Download; + +public class FfmpegLibraryInstallerTests +{ + [Fact] + public void Install_CompleteArchive_CommitsRequiredLibrariesAndPreservesUnrelatedFiles() + { + var root = MakeRoot(); + var target = Path.Combine(root, "libs"); + var zip = Path.Combine(root, "ffmpeg.zip"); + var required = FfmpegLibraryInstaller.RequiredWindowsLibraryNames; + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, required[0]), "old-first"); + File.WriteAllText(Path.Combine(target, "keep.txt"), "keep-me"); + CreateArchive(zip, includeAllRequired: true, includeExtraDll: true); + + try + { + FfmpegLibraryInstaller.Install(zip, target, CancellationToken.None); + + foreach (var name in required) + { + Assert.Equal("new:" + name, File.ReadAllText(Path.Combine(target, name))); + } + + Assert.True(File.Exists(Path.Combine(target, "avdevice-extra.dll"))); + Assert.Equal("keep-me", File.ReadAllText(Path.Combine(target, "keep.txt"))); + Assert.Empty(Directory.GetDirectories(target, ".ffmpeg-*")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Install_IncompleteArchive_LeavesExistingInstallationUntouched() + { + var root = MakeRoot(); + var target = Path.Combine(root, "libs"); + var zip = Path.Combine(root, "ffmpeg.zip"); + var required = FfmpegLibraryInstaller.RequiredWindowsLibraryNames; + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, required[0]), "old-first"); + CreateArchive(zip, includeAllRequired: false, includeExtraDll: false); + + try + { + var exception = Assert.Throws(() => + FfmpegLibraryInstaller.Install(zip, target, CancellationToken.None)); + + Assert.Contains("Missing required libraries", exception.Message); + Assert.Equal("old-first", File.ReadAllText(Path.Combine(target, required[0]))); + Assert.Empty(Directory.GetDirectories(target, ".ffmpeg-*")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Install_CommitFailure_RollsBackAlreadyReplacedLibrary() + { + var root = MakeRoot(); + var target = Path.Combine(root, "libs"); + var zip = Path.Combine(root, "ffmpeg.zip"); + var required = FfmpegLibraryInstaller.RequiredWindowsLibraryNames + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, required[0]), "old-first"); + + // Commit order is lexical. The first library is replaced; a directory at the second + // target then forces failure so rollback has to restore bytes already changed. + Directory.CreateDirectory(Path.Combine(target, required[1])); + CreateArchive(zip, includeAllRequired: true, includeExtraDll: false); + + try + { + Assert.Throws(() => + FfmpegLibraryInstaller.Install(zip, target, CancellationToken.None)); + + Assert.Equal("old-first", File.ReadAllText(Path.Combine(target, required[0]))); + Assert.True(Directory.Exists(Path.Combine(target, required[1]))); + Assert.Empty(Directory.GetDirectories(target, ".ffmpeg-*")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Install_CanceledBeforeCommit_LeavesExistingInstallationUntouched() + { + var root = MakeRoot(); + var target = Path.Combine(root, "libs"); + var zip = Path.Combine(root, "ffmpeg.zip"); + var required = FfmpegLibraryInstaller.RequiredWindowsLibraryNames; + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, required[0]), "old-first"); + CreateArchive(zip, includeAllRequired: true, includeExtraDll: false); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + try + { + Assert.Throws(() => + FfmpegLibraryInstaller.Install(zip, target, cts.Token)); + + Assert.Equal("old-first", File.ReadAllText(Path.Combine(target, required[0]))); + Assert.Empty(Directory.GetDirectories(target, ".ffmpeg-*")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string MakeRoot() + { + var root = Path.Combine(Path.GetTempPath(), "se-ffmpeg-install-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void CreateArchive(string path, bool includeAllRequired, bool includeExtraDll) + { + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var required = FfmpegLibraryInstaller.RequiredWindowsLibraryNames; + var count = includeAllRequired ? required.Length : 1; + for (var i = 0; i < count; i++) + { + AddEntry(archive, "ffmpeg/bin/" + required[i], "new:" + required[i]); + } + + if (includeExtraDll) + { + AddEntry(archive, "ffmpeg/bin/avdevice-extra.dll", "extra"); + } + + AddEntry(archive, "ffmpeg/bin/ffmpeg.exe", "ignored"); + AddEntry(archive, "ffmpeg/include/avcodec.h", "ignored"); + } + + private static void AddEntry(ZipArchive archive, string name, string content) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write(content); + } +} diff --git a/tests/UI/Logic/Download/FfmpegLibsDownloadServiceTests.cs b/tests/UI/Logic/Download/FfmpegLibsDownloadServiceTests.cs new file mode 100644 index 0000000000..641e1a3be2 --- /dev/null +++ b/tests/UI/Logic/Download/FfmpegLibsDownloadServiceTests.cs @@ -0,0 +1,73 @@ +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; +using System.Runtime.InteropServices; + +namespace UITests.Logic.Download; + +public class FfmpegLibsDownloadServiceTests +{ + [Fact] + public void WindowsArchive_IsPinnedToReviewedAutobuildAndDigest() + { + Assert.DoesNotContain("/releases/download/latest/", FfmpegLibsDownloadService.WindowsX64Url); + Assert.Contains(FfmpegLibsDownloadService.WindowsX64ReleaseTag, FfmpegLibsDownloadService.WindowsX64Url); + Assert.EndsWith("/" + FfmpegLibsDownloadService.WindowsX64AssetName, FfmpegLibsDownloadService.WindowsX64Url); + Assert.EndsWith("-" + FfmpegLibraries.MajorVersion + ".zip", FfmpegLibsDownloadService.WindowsX64AssetName); + Assert.Equal(64, FfmpegLibsDownloadService.WindowsX64Sha256.Length); + Assert.Matches("^[0-9a-f]{64}$", FfmpegLibsDownloadService.WindowsX64Sha256); + } + + [Theory] + [InlineData(true, Architecture.X64, true)] + [InlineData(true, Architecture.Arm64, false)] + [InlineData(true, Architecture.X86, false)] + [InlineData(false, Architecture.X64, false)] + public void IsDownloadSupported_MatchesPublishedWindowsX64Capability( + bool isWindows, + Architecture architecture, + bool expected) + { + Assert.Equal(expected, FfmpegLibsDownloadService.IsDownloadSupported(isWindows, architecture)); + } + + [Fact] + public async Task VerifySha256Async_MatchingDigest_KeepsFile() + { + var path = Path.Combine(Path.GetTempPath(), "FfmpegLibsHashMatch_" + Guid.NewGuid().ToString("N") + ".zip"); + await File.WriteAllTextAsync(path, "abc", TestContext.Current.CancellationToken); + try + { + await FfmpegLibsDownloadService.VerifySha256Async( + path, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + TestContext.Current.CancellationToken); + + Assert.True(File.Exists(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task VerifySha256Async_Mismatch_ThrowsAndDeletesFile() + { + var path = Path.Combine(Path.GetTempPath(), "FfmpegLibsHashMismatch_" + Guid.NewGuid().ToString("N") + ".zip"); + await File.WriteAllTextAsync(path, "not the reviewed FFmpeg archive", TestContext.Current.CancellationToken); + try + { + await Assert.ThrowsAsync(() => + FfmpegLibsDownloadService.VerifySha256Async( + path, + FfmpegLibsDownloadService.WindowsX64Sha256, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(path)); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/UI/Logic/FfmpegLibraryAvailabilityIntegrationTests.cs b/tests/UI/Logic/FfmpegLibraryAvailabilityIntegrationTests.cs new file mode 100644 index 0000000000..a1d2357ead --- /dev/null +++ b/tests/UI/Logic/FfmpegLibraryAvailabilityIntegrationTests.cs @@ -0,0 +1,32 @@ +using FFmpeg.AutoGen; +using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; + +namespace UITests.Logic; + +public class FfmpegLibraryAvailabilityIntegrationTests +{ + [Fact] + public void RequiredLibraryVersionsMatch_RequiresEveryBindingMajor() + { + static uint V(int major) => (uint)major << 16; + + var codec = V(ffmpeg.LIBAVCODEC_VERSION_MAJOR); + var format = V(ffmpeg.LIBAVFORMAT_VERSION_MAJOR); + var util = V(ffmpeg.LIBAVUTIL_VERSION_MAJOR); + var scale = V(ffmpeg.LIBSWSCALE_VERSION_MAJOR); + var resample = V(ffmpeg.LIBSWRESAMPLE_VERSION_MAJOR); + + Assert.True(FfmpegLibraries.RequiredLibraryVersionsMatch(codec, format, util, scale, resample)); + Assert.False(FfmpegLibraries.RequiredLibraryVersionsMatch(codec + (1u << 16), format, util, scale, resample)); + Assert.False(FfmpegLibraries.RequiredLibraryVersionsMatch(codec, format + (1u << 16), util, scale, resample)); + Assert.False(FfmpegLibraries.RequiredLibraryVersionsMatch(codec, format, util + (1u << 16), scale, resample)); + Assert.False(FfmpegLibraries.RequiredLibraryVersionsMatch(codec, format, util, scale + (1u << 16), resample)); + Assert.False(FfmpegLibraries.RequiredLibraryVersionsMatch(codec, format, util, scale, resample + (1u << 16))); + } + + [Fact] + public void VersionMajor_UsesFfmpegVersionEncoding() + { + Assert.Equal(63, FfmpegLibraries.VersionMajor((63u << 16) | (12u << 8) | 100u)); + } +} diff --git a/tests/UI/Logic/FfmpegPlayerTests.cs b/tests/UI/Logic/FfmpegPlayerTests.cs index 44ab73ef57..0fb46ef810 100644 --- a/tests/UI/Logic/FfmpegPlayerTests.cs +++ b/tests/UI/Logic/FfmpegPlayerTests.cs @@ -1,6 +1,7 @@ using Avalonia; using FFmpeg.AutoGen; using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Logic.Download; using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg.Audio; using System.IO.Compression; @@ -321,17 +322,26 @@ public void ExtractLibraries_TakesOnlyBinDlls_Flattened() { using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create)) { - AddEntry(archive, "ffmpeg-n9.0-latest-win64-lgpl-shared-9.0/bin/avcodec-63.dll"); - AddEntry(archive, "ffmpeg-n9.0-latest-win64-lgpl-shared-9.0/bin/ffmpeg.exe"); - AddEntry(archive, "ffmpeg-n9.0-latest-win64-lgpl-shared-9.0/lib/avcodec.lib"); - AddEntry(archive, "ffmpeg-n9.0-latest-win64-lgpl-shared-9.0/include/libavcodec/avcodec.h"); + foreach (var required in FfmpegLibraryInstaller.RequiredWindowsLibraryNames) + { + AddEntry(archive, "ffmpeg-n9.0-win64-lgpl-shared/bin/" + required); + } + + AddEntry(archive, "ffmpeg-n9.0-win64-lgpl-shared/bin/ffmpeg.exe"); + AddEntry(archive, "ffmpeg-n9.0-win64-lgpl-shared/lib/avcodec.lib"); + AddEntry(archive, "ffmpeg-n9.0-win64-lgpl-shared/include/libavcodec/avcodec.h"); } DownloadFfmpegLibsViewModel.ExtractLibraries(zip, folder, CancellationToken.None); - var files = Directory.GetFiles(folder).Select(Path.GetFileName).ToArray(); - Assert.Single(files); - Assert.Equal("avcodec-63.dll", files[0]); + var files = Directory.GetFiles(folder) + .Select(Path.GetFileName) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var expectedFiles = FfmpegLibraryInstaller.RequiredWindowsLibraryNames + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + Assert.Equal(expectedFiles, files); } finally {