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
{