diff --git a/UniversalDownloaderPlatform.Common.Tests/FileExistsActionHelperTests.cs b/UniversalDownloaderPlatform.Common.Tests/FileExistsActionHelperTests.cs
new file mode 100644
index 0000000..b454225
--- /dev/null
+++ b/UniversalDownloaderPlatform.Common.Tests/FileExistsActionHelperTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/UniversalDownloaderPlatform.Common.Tests/UniversalDownloaderPlatform.Common.Tests.csproj b/UniversalDownloaderPlatform.Common.Tests/UniversalDownloaderPlatform.Common.Tests.csproj
new file mode 100644
index 0000000..944fc2f
--- /dev/null
+++ b/UniversalDownloaderPlatform.Common.Tests/UniversalDownloaderPlatform.Common.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UniversalDownloaderPlatform.Common/Helpers/FileExistsActionHelper.cs b/UniversalDownloaderPlatform.Common/Helpers/FileExistsActionHelper.cs
index aba56da..6fddf76 100644
--- a/UniversalDownloaderPlatform.Common/Helpers/FileExistsActionHelper.cs
+++ b/UniversalDownloaderPlatform.Common/Helpers/FileExistsActionHelper.cs
@@ -11,21 +11,82 @@ namespace UniversalDownloaderPlatform.Common.Helpers
{
public static class FileExistsActionHelper
{
+ ///
+ /// 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.
+ ///
+ /// Requested output path
+ /// Existing file path or null when not found
+ 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;
+ }
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
+ {
+ return null;
+ }
+
+ return null;
+ }
+
///
/// 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 is a same-basename converted equivalent of
+ /// (different extension), the download is skipped unless is ,
+ /// because remote size/hash comparison is not meaningful across formats.
///
- /// The path to the file already existing on the disk
- /// The size of the remote file (supply -1 if not available)
+ /// The path to the file already existing on the disk
+ /// The originally requested output path
+ /// The size of the remote file. Values less than or equal to 0 are treated as unavailable/unknown.
/// Should the remote file size check be performed at all
/// Action to perform
/// Logging function
/// True if should continue the download, false if should stop download process for the file
- public static bool DoFileExistsActionBeforeDownload(string path,
+ public static bool DoFileExistsActionBeforeDownload(string existingPath,
+ string requestedPath,
long remoteFileSize,
bool isCheckRemoteFileSize,
FileExistsAction fileExistsAction,
Action 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;
@@ -33,32 +94,31 @@ public static bool DoFileExistsActionBeforeDownload(string path,
{
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;
}
}
diff --git a/UniversalDownloaderPlatform.DefaultImplementations/WebDownloader.cs b/UniversalDownloaderPlatform.DefaultImplementations/WebDownloader.cs
index d8cca60..c6b7af9 100644
--- a/UniversalDownloaderPlatform.DefaultImplementations/WebDownloader.cs
+++ b/UniversalDownloaderPlatform.DefaultImplementations/WebDownloader.cs
@@ -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)
{
diff --git a/UniversalDownloaderPlatform.GoogleDriveDownloader/GoogleDriveEngine.cs b/UniversalDownloaderPlatform.GoogleDriveDownloader/GoogleDriveEngine.cs
index 3eeb611..89bcf38 100644
--- a/UniversalDownloaderPlatform.GoogleDriveDownloader/GoogleDriveEngine.cs
+++ b/UniversalDownloaderPlatform.GoogleDriveDownloader/GoogleDriveEngine.cs
@@ -97,17 +97,6 @@ private void DownloadFileResource(File fileResource, string path, FileExistsActi
{
long? remoteFileSize = fileResource.Size;
- if (System.IO.File.Exists(path))
- {
- if (!FileExistsActionHelper.DoFileExistsActionBeforeDownload(path, remoteFileSize ?? 0, isCheckRemoteFileSize, fileExistsAction, LoggingFunction))
- return;
- }
-
- if (!Directory.Exists(path))
- {
- Directory.CreateDirectory(new FileInfo(path).DirectoryName);
- }
-
//todo: allow choosing which format to use: pdf or office
bool isGoogleDocument = false;
string mimeType = null;
@@ -144,6 +133,19 @@ private void DownloadFileResource(File fileResource, string path, FileExistsActi
isGoogleDocument = true;
}
+ string existingPath = FileExistsActionHelper.ResolveExistingFilePath(path);
+ if (existingPath != null)
+ {
+ if (!FileExistsActionHelper.DoFileExistsActionBeforeDownload(existingPath, path, remoteFileSize ?? 0, isCheckRemoteFileSize, fileExistsAction, LoggingFunction))
+ return;
+ }
+
+ string directoryPath = new FileInfo(path).DirectoryName;
+ if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
+ {
+ Directory.CreateDirectory(directoryPath);
+ }
+
using (FileStream file = new FileStream(temporaryFilePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
if (isGoogleDocument)
diff --git a/UniversalDownloaderPlatform.MegaDownloader/MegaDownloader.cs b/UniversalDownloaderPlatform.MegaDownloader/MegaDownloader.cs
index 55fd6c4..129fc5a 100644
--- a/UniversalDownloaderPlatform.MegaDownloader/MegaDownloader.cs
+++ b/UniversalDownloaderPlatform.MegaDownloader/MegaDownloader.cs
@@ -209,17 +209,19 @@ private async Task DownloadFileAsync(INode fileNode, Uri fileUri, INode fileNode
long remoteFileSize = nodeInfo.Size;
- 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)
{
diff --git a/UniversalDownloaderPlatform.sln b/UniversalDownloaderPlatform.sln
index de16554..44049a1 100644
--- a/UniversalDownloaderPlatform.sln
+++ b/UniversalDownloaderPlatform.sln
@@ -15,6 +15,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniversalDownloaderPlatform
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniversalDownloaderPlatform.PuppeteerEngine", "UniversalDownloaderPlatform.PuppeteerEngine\UniversalDownloaderPlatform.PuppeteerEngine.csproj", "{92153E73-466A-4620-B7B3-2082E061B1DB}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniversalDownloaderPlatform.Common.Tests", "UniversalDownloaderPlatform.Common.Tests\UniversalDownloaderPlatform.Common.Tests.csproj", "{5A414D51-D4A3-4E3C-A1B3-9E069DA44533}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -45,6 +47,10 @@ Global
{92153E73-466A-4620-B7B3-2082E061B1DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92153E73-466A-4620-B7B3-2082E061B1DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92153E73-466A-4620-B7B3-2082E061B1DB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5A414D51-D4A3-4E3C-A1B3-9E069DA44533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5A414D51-D4A3-4E3C-A1B3-9E069DA44533}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5A414D51-D4A3-4E3C-A1B3-9E069DA44533}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5A414D51-D4A3-4E3C-A1B3-9E069DA44533}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE