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 {