diff --git a/src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs b/src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs
index 58eef6f64d..dc50c534cc 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;
@@ -129,35 +128,12 @@ 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()
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/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/FfmpegPlayerTests.cs b/tests/UI/Logic/FfmpegPlayerTests.cs
index fcca6e1559..e19394646b 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;
@@ -164,17 +165,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/avdevice-extra.dll");
+ 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(x => x).ToArray();
+ Assert.Equal(FfmpegLibraryInstaller.RequiredWindowsLibraryNames.Length + 1, files.Length);
+ Assert.Contains("avdevice-extra.dll", files);
+ foreach (var required in FfmpegLibraryInstaller.RequiredWindowsLibraryNames)
+ {
+ Assert.Contains(required, files);
+ }
}
finally
{