Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
using System;
using System.IO;
using UniversalDownloaderPlatform.Common.Enums;
using UniversalDownloaderPlatform.Common.Helpers;
using Xunit;

namespace UniversalDownloaderPlatform.Common.Tests
{
public class FileExistsActionHelperTests : IDisposable
{
private readonly string _temporaryDirectoryPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));

public FileExistsActionHelperTests()
{
Directory.CreateDirectory(_temporaryDirectoryPath);
}

[Fact]
public void ResolveExistingFilePath_ReturnsRequestedPath_WhenExactFileExists()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
File.WriteAllText(requestedPath, "test");

string existingPath = FileExistsActionHelper.ResolveExistingFilePath(requestedPath);

Assert.Equal(requestedPath, existingPath);
}

[Fact]
public void ResolveExistingFilePath_ReturnsSameBasenameFile_WhenDifferentExtensionExists()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
string existingPath = Path.Combine(_temporaryDirectoryPath, "media_123.mp3");
File.WriteAllText(existingPath, "test");

string resolvedPath = FileExistsActionHelper.ResolveExistingFilePath(requestedPath);

Assert.Equal(existingPath, resolvedPath);
}

[Fact]
public void ResolveExistingFilePath_ReturnsNull_WhenOnlyPartialNameMatches()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
string partialMatchPath = Path.Combine(_temporaryDirectoryPath, "media_123_extra.mp3");
File.WriteAllText(partialMatchPath, "test");

string existingPath = FileExistsActionHelper.ResolveExistingFilePath(requestedPath);

Assert.Null(existingPath);
}

[Fact]
public void ResolveExistingFilePath_ReturnsNull_WhenDirectoryDoesNotExist()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "missing", "media_123.png");

string existingPath = FileExistsActionHelper.ResolveExistingFilePath(requestedPath);

Assert.Null(existingPath);
}

[Theory]
[InlineData(FileExistsAction.BackupIfDifferent)]
[InlineData(FileExistsAction.ReplaceIfDifferent)]
[InlineData(FileExistsAction.KeepExisting)]
public void DoFileExistsActionBeforeDownload_SkipsConvertedEquivalent_WhenNotAlwaysReplace(FileExistsAction fileExistsAction)
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
string existingPath = Path.Combine(_temporaryDirectoryPath, "media_123.mp3");
File.WriteAllText(existingPath, "converted-content");

bool shouldContinue = FileExistsActionHelper.DoFileExistsActionBeforeDownload(
existingPath,
requestedPath,
remoteFileSize: 9999,
isCheckRemoteFileSize: true,
fileExistsAction,
(_, _, _) => { });

Assert.False(shouldContinue);
}

[Fact]
public void DoFileExistsActionBeforeDownload_ContinuesConvertedEquivalent_WhenAlwaysReplace()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
string existingPath = Path.Combine(_temporaryDirectoryPath, "media_123.mp3");
File.WriteAllText(existingPath, "converted-content");

bool shouldContinue = FileExistsActionHelper.DoFileExistsActionBeforeDownload(
existingPath,
requestedPath,
remoteFileSize: 9999,
isCheckRemoteFileSize: true,
FileExistsAction.AlwaysReplace,
(_, _, _) => { });

Assert.True(shouldContinue);
}

[Fact]
public void DoFileExistsActionBeforeDownload_UsesSizeCheck_ForExactPathMatch()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
File.WriteAllText(requestedPath, "12345");

bool shouldContinueWhenDifferent = FileExistsActionHelper.DoFileExistsActionBeforeDownload(
requestedPath,
requestedPath,
remoteFileSize: 9999,
isCheckRemoteFileSize: true,
FileExistsAction.BackupIfDifferent,
(_, _, _) => { });

bool shouldSkipWhenIdentical = FileExistsActionHelper.DoFileExistsActionBeforeDownload(
requestedPath,
requestedPath,
remoteFileSize: new FileInfo(requestedPath).Length,
isCheckRemoteFileSize: true,
FileExistsAction.BackupIfDifferent,
(_, _, _) => { });

Assert.True(shouldContinueWhenDifferent);
Assert.False(shouldSkipWhenIdentical);
}

[Theory]
[InlineData(FileExistsAction.BackupIfDifferent)]
[InlineData(FileExistsAction.ReplaceIfDifferent)]
public void DoFileExistsActionBeforeDownload_Continues_WhenRemoteSizeUnknown(FileExistsAction fileExistsAction)
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
File.WriteAllText(requestedPath, "12345");

bool shouldContinue = FileExistsActionHelper.DoFileExistsActionBeforeDownload(
requestedPath,
requestedPath,
remoteFileSize: -1,
isCheckRemoteFileSize: true,
fileExistsAction,
(_, _, _) => { });

Assert.True(shouldContinue);
}

[Fact]
public void DoFileExistsActionBeforeDownload_Skips_WhenRemoteSizeUnknownAndKeepExisting()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media_123.png");
File.WriteAllText(requestedPath, "12345");

bool shouldContinue = FileExistsActionHelper.DoFileExistsActionBeforeDownload(
requestedPath,
requestedPath,
remoteFileSize: 0,
isCheckRemoteFileSize: true,
FileExistsAction.KeepExisting,
(_, _, _) => { });

Assert.False(shouldContinue);
}

[Fact]
public void ResolveExistingFilePath_MatchesBasename_IgnoringCase_WhenFilesystemSurfacesCandidate()
{
// Create both casings when the filesystem allows it. On case-insensitive systems
// only one file entry exists and Directory.EnumerateFiles returns it for either casing.
string lowerExistingPath = Path.Combine(_temporaryDirectoryPath, "media_123.mp3");
string mixedRequestedPath = Path.Combine(_temporaryDirectoryPath, "Media_123.png");
File.WriteAllText(lowerExistingPath, "test");

string resolvedPath = FileExistsActionHelper.ResolveExistingFilePath(mixedRequestedPath);

// On case-insensitive filesystems the candidate is found and basename compare must ignore case.
// On case-sensitive filesystems only an exact casing match is expected from the filesystem listing.
if (resolvedPath != null)
{
Assert.Equal(Path.GetFileNameWithoutExtension(lowerExistingPath), Path.GetFileNameWithoutExtension(resolvedPath), ignoreCase: true);
Assert.Equal(Path.GetExtension(lowerExistingPath), Path.GetExtension(resolvedPath), ignoreCase: true);
Assert.False(string.Equals(Path.GetExtension(mixedRequestedPath), Path.GetExtension(resolvedPath), StringComparison.OrdinalIgnoreCase));
}
}

[Fact]
public void ResolveExistingFilePath_DoesNotTreatWildcardCharactersInBasenameAsGlobs()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media*123.png");
string unrelatedMatchPath = Path.Combine(_temporaryDirectoryPath, "mediaX123.mp3");
string exactConvertedPath = Path.Combine(_temporaryDirectoryPath, "media*123.mp3");
File.WriteAllText(unrelatedMatchPath, "unrelated");
File.WriteAllText(exactConvertedPath, "converted");

string resolvedPath = FileExistsActionHelper.ResolveExistingFilePath(requestedPath);

Assert.Equal(exactConvertedPath, resolvedPath);
}

[Fact]
public void ResolveExistingFilePath_ReturnsNull_WhenWildcardBasenameHasOnlyUnrelatedMatches()
{
string requestedPath = Path.Combine(_temporaryDirectoryPath, "media?123.png");
string unrelatedMatchPath = Path.Combine(_temporaryDirectoryPath, "mediaA123.mp3");
File.WriteAllText(unrelatedMatchPath, "unrelated");

string resolvedPath = FileExistsActionHelper.ResolveExistingFilePath(requestedPath);

Assert.Null(resolvedPath);
}

public void Dispose()
{
if (Directory.Exists(_temporaryDirectoryPath))
Directory.Delete(_temporaryDirectoryPath, true);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\UniversalDownloaderPlatform.Common\UniversalDownloaderPlatform.Common.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -11,54 +11,114 @@ namespace UniversalDownloaderPlatform.Common.Helpers
{
public static class FileExistsActionHelper
{
/// <summary>
/// Resolves an already existing file path for the requested output path.
/// Returns the exact path when it exists, otherwise looks for a file with the same basename and a different extension in the same directory.
/// Returns null when no matching file exists.
/// </summary>
/// <param name="path">Requested output path</param>
/// <returns>Existing file path or null when not found</returns>
public static string ResolveExistingFilePath(string path)
{
if (File.Exists(path))
return path;

try
{
string directoryPath = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(directoryPath))
directoryPath = Directory.GetCurrentDirectory();
if (!Directory.Exists(directoryPath))
return null;

string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrEmpty(fileNameWithoutExtension))
return null;

string requestedExtension = Path.GetExtension(path);
// Enumerate all files and compare basenames in code so characters like * or ?
// in the requested name are not treated as filesystem wildcards.
foreach (string filePath in Directory.EnumerateFiles(directoryPath))
{
if (string.Equals(Path.GetFileNameWithoutExtension(filePath), fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(Path.GetExtension(filePath), requestedExtension, StringComparison.OrdinalIgnoreCase))
return filePath;
Comment thread
ProtagNeptune marked this conversation as resolved.
}
Comment thread
ProtagNeptune marked this conversation as resolved.
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException
or System.Security.SecurityException)
{
return null;
}

return null;
}

/// <summary>
/// Performs all required actions based on the FileExistsAction value. Should be called before downloading the file when the file already exists on the disk.
/// When <paramref name="existingPath"/> is a same-basename converted equivalent of <paramref name="requestedPath"/>
/// (different extension), the download is skipped unless <paramref name="fileExistsAction"/> is <see cref="FileExistsAction.AlwaysReplace"/>,
/// because remote size/hash comparison is not meaningful across formats.
/// </summary>
/// <param name="path">The path to the file already existing on the disk</param>
/// <param name="remoteFileSize">The size of the remote file (supply -1 if not available)</param>
/// <param name="existingPath">The path to the file already existing on the disk</param>
/// <param name="requestedPath">The originally requested output path</param>
/// <param name="remoteFileSize">The size of the remote file. Values less than or equal to 0 are treated as unavailable/unknown.</param>
/// <param name="isCheckRemoteFileSize">Should the remote file size check be performed at all</param>
/// <param name="fileExistsAction">Action to perform</param>
/// <param name="loggingFunction">Logging function</param>
/// <returns>True if should continue the download, false if should stop download process for the file</returns>
public static bool DoFileExistsActionBeforeDownload(string path,
public static bool DoFileExistsActionBeforeDownload(string existingPath,
string requestedPath,
long remoteFileSize,
bool isCheckRemoteFileSize,
FileExistsAction fileExistsAction,
Action<LogMessageLevel, string, Exception> loggingFunction)
{
bool isConvertedEquivalent = !string.Equals(existingPath, requestedPath, StringComparison.OrdinalIgnoreCase);
if (isConvertedEquivalent)
{
if (fileExistsAction == FileExistsAction.AlwaysReplace)
return true;

loggingFunction(LogMessageLevel.Warning, $"Converted file {existingPath} already exists for requested path {requestedPath}, download will be skipped.", null);
return false;
}

if (fileExistsAction != FileExistsAction.AlwaysReplace)
{
bool isFilesIdentical = false;
if (isCheckRemoteFileSize)
{
if (remoteFileSize > 0)
{
loggingFunction(LogMessageLevel.Debug, $"File {path} exists, size will be checked", null);
loggingFunction(LogMessageLevel.Debug, $"File {existingPath} exists, size will be checked", null);
try
{
if (new FileInfo(path).Length != remoteFileSize)
if (new FileInfo(existingPath).Length != remoteFileSize)
{
loggingFunction(LogMessageLevel.Warning, $"Local and remote file sizes does not match, file {path} will be redownloaded.", null);
loggingFunction(LogMessageLevel.Warning, $"Local and remote file sizes do not match, file {existingPath} will be redownloaded.", null);
}
else
{
loggingFunction(LogMessageLevel.Debug, $"File size for {path} matches", null);
loggingFunction(LogMessageLevel.Debug, $"File size for {existingPath} matches", null);
isFilesIdentical = true;
}
}
catch (Exception ex)
{
loggingFunction(LogMessageLevel.Error, $"Error during file comparison: {ex}", ex);
isFilesIdentical = true; //we assume that local file is identical if we can't check remote file size
// Leave isFilesIdentical false so ReplaceIfDifferent/BackupIfDifferent can fall back to after-download hash comparison.
}
}
else
isFilesIdentical = true; //assume that 0kb files and failed checks are always identical
// remoteFileSize <= 0 means unavailable/unknown; do not assume identity so after-download hash comparison can decide.
}

if (isFilesIdentical || fileExistsAction == FileExistsAction.KeepExisting)
{
loggingFunction(LogMessageLevel.Warning, $"File {path} already exists, will be skipped because of identical size to the remote file or because of file exists setting being set to keep existing file even on different remote size.", null);
loggingFunction(LogMessageLevel.Warning, $"File {existingPath} already exists, will be skipped because of identical size to the remote file or because of file exists setting being set to keep existing file even on different remote size.", null);
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,17 +131,19 @@ private async Task DownloadFileInternal(string url, string path, string refererU
_logger.Error(ex, $"Unable to retrieve remote file size, size check will be skipped: {ex}");
}

if (File.Exists(path))
string existingPath = FileExistsActionHelper.ResolveExistingFilePath(path);
if (existingPath != null)
{
if (!FileExistsActionHelper.DoFileExistsActionBeforeDownload(path, remoteFileSize, _isCheckRemoteFileSize, _fileExistsAction, LoggingFunction))
if (!FileExistsActionHelper.DoFileExistsActionBeforeDownload(existingPath, path, remoteFileSize, _isCheckRemoteFileSize, _fileExistsAction, LoggingFunction))
return;
}

try
{
//warning: returns '' in drive's root
if (!Directory.Exists(path))
Directory.CreateDirectory(new FileInfo(path).DirectoryName);
string directoryPath = new FileInfo(path).DirectoryName;
if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
Directory.CreateDirectory(directoryPath);
}
catch (Exception ex)
{
Expand Down
Loading