Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 3 additions & 27 deletions src/ui/Features/Shared/DownloadFfmpegLibsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,35 +128,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args)
}

/// <summary>
/// Pulls the DLLs out of the build zip (<c>ffmpeg-…/bin/*.dll</c>) 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.
/// </summary>
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()
Expand Down
174 changes: 174 additions & 0 deletions src/ui/Logic/Download/FfmpegLibraryInstaller.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
var backedUp = new List<string>();
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<string> installed, List<string> 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.
}
}
}
155 changes: 155 additions & 0 deletions tests/UI/Logic/Download/FfmpegLibraryInstallerTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
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<IOException>(() =>
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<OperationCanceledException>(() =>
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);
}
}
Loading
Loading