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
3 changes: 2 additions & 1 deletion src/ui/Features/Options/Settings/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
104 changes: 66 additions & 38 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 All @@ -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();
Expand Down Expand Up @@ -94,14 +94,7 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args)
}
finally
{
try
{
File.Delete(_tempFileName);
}
catch
{
// temp file, best effort
}
TryDeleteTempFile(_tempFileName);
}

StopIndeterminateProgress();
Expand Down Expand Up @@ -129,39 +122,21 @@ 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()
{
if (_cancellationTokenSource.IsCancellationRequested)
{
return;
}

_indeterminateProgressHelper?.Dispose();
_indeterminateProgressHelper = new IndeterminateProgressHelper(
value => ProgressValue = value,
Expand All @@ -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()
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.
}
}
}
Loading
Loading