diff --git a/listenarr.application/Audiobooks/AuthorMonitoringService.cs b/listenarr.application/Audiobooks/AuthorMonitoringService.cs index abc61d26d..928fc7975 100644 --- a/listenarr.application/Audiobooks/AuthorMonitoringService.cs +++ b/listenarr.application/Audiobooks/AuthorMonitoringService.cs @@ -20,6 +20,7 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; +using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; @@ -234,7 +235,7 @@ private async Task SyncAuthorInternalAsync( { result.Succeeded = false; result.ErrorMessage = "Author catalog could not be loaded."; - monitoredAuthor.LastError = TruncateError(result.ErrorMessage); + monitoredAuthor.LastError = StringUtils.Truncate(result.ErrorMessage, 2048); monitoredAuthor.LastCheckedAt = DateTime.UtcNow; monitoredAuthor.UpdatedAt = DateTime.UtcNow; await _authors.UpsertAsync(monitoredAuthor, cancellationToken); @@ -318,7 +319,7 @@ private async Task SyncAuthorInternalAsync( result.ErrorMessage = ex.Message; result.FailedCount++; monitoredAuthor.LastCheckedAt = DateTime.UtcNow; - monitoredAuthor.LastError = TruncateError(ex.Message); + monitoredAuthor.LastError = StringUtils.Truncate(ex.Message, 2048); monitoredAuthor.UpdatedAt = DateTime.UtcNow; await _authors.UpsertAsync(monitoredAuthor, cancellationToken); return result; @@ -514,15 +515,5 @@ private static string BuildTitleAuthorKey(string? title, IEnumerable? au var match = System.Text.RegularExpressions.Regex.Match(value, "\\d{4}"); return match.Success ? match.Value : null; } - - private static string? TruncateError(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } - - return value.Length <= 2048 ? value : value[..2048]; - } } } diff --git a/listenarr.application/Audiobooks/SeriesMonitoringService.cs b/listenarr.application/Audiobooks/SeriesMonitoringService.cs index bd32e59ee..4008859a3 100644 --- a/listenarr.application/Audiobooks/SeriesMonitoringService.cs +++ b/listenarr.application/Audiobooks/SeriesMonitoringService.cs @@ -20,6 +20,7 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; +using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; @@ -233,7 +234,7 @@ private async Task SyncSeriesInternalAsync( { result.Succeeded = false; result.ErrorMessage = "Series catalog could not be loaded."; - monitoredSeries.LastError = TruncateError(result.ErrorMessage); + monitoredSeries.LastError = StringUtils.Truncate(result.ErrorMessage, 2048); monitoredSeries.LastCheckedAt = DateTime.UtcNow; monitoredSeries.UpdatedAt = DateTime.UtcNow; await _series.UpsertAsync(monitoredSeries, cancellationToken); @@ -317,7 +318,7 @@ private async Task SyncSeriesInternalAsync( result.ErrorMessage = ex.Message; result.FailedCount++; monitoredSeries.LastCheckedAt = DateTime.UtcNow; - monitoredSeries.LastError = TruncateError(ex.Message); + monitoredSeries.LastError = StringUtils.Truncate(ex.Message, 2048); monitoredSeries.UpdatedAt = DateTime.UtcNow; await _series.UpsertAsync(monitoredSeries, cancellationToken); return result; @@ -513,15 +514,5 @@ private static string BuildTitleAuthorKey(string? title, IEnumerable? au var match = System.Text.RegularExpressions.Regex.Match(value, "\\d{4}"); return match.Success ? match.Value : null; } - - private static string? TruncateError(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } - - return value.Length <= 2048 ? value : value[..2048]; - } } } diff --git a/listenarr.application/Common/FileNamingService.cs b/listenarr.application/Common/FileNamingService.cs index 2fd97f5e1..2bd2ef0cb 100644 --- a/listenarr.application/Common/FileNamingService.cs +++ b/listenarr.application/Common/FileNamingService.cs @@ -467,7 +467,7 @@ private static HashSet BuildPortableInvalidFileNameChars() /// /// Ensure the generated path does not exceed platform limits. /// On Windows: total path ≤ 259 chars, each component ≤ 255 chars. - /// Truncates the longest non-root components first while preserving the file extension. + /// StringUtils.Truncates the longest non-root components first while preserving the file extension. /// public string EnsurePathWithinLimits(string fullPath) { @@ -553,7 +553,7 @@ public string EnsurePathWithinLimits(string fullPath) if (result != originalPath) { - _logger.LogWarning("Path truncated to fit Windows MAX_PATH limit ({Limit} chars). Original length: {OriginalLength}, New length: {NewLength}. Truncated path: {Path}", + _logger.LogWarning("Path truncated to fit Windows MAX_PATH limit ({Limit} chars). Original length: {OriginalLength}, New length: {NewLength}. StringUtils.Truncated path: {Path}", WindowsMaxPath, originalPath.Length, result.Length, result); } diff --git a/listenarr.application/Interfaces/IFileMover.cs b/listenarr.application/Interfaces/IFileMover.cs index d4d1367b8..c2c0275b5 100644 --- a/listenarr.application/Interfaces/IFileMover.cs +++ b/listenarr.application/Interfaces/IFileMover.cs @@ -26,8 +26,6 @@ public interface IFileMover { Task MoveDirectoryAsync(string source, string destination); - Task CopyDirectoryAsync(string source, string destination); - /// /// Perform the given action on the given file /// diff --git a/listenarr.application/Notification/NotificationPayloadBuilder.cs b/listenarr.application/Notification/NotificationPayloadBuilder.cs index f866484f5..556ac99b3 100644 --- a/listenarr.application/Notification/NotificationPayloadBuilder.cs +++ b/listenarr.application/Notification/NotificationPayloadBuilder.cs @@ -19,6 +19,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Listenarr.Application.Common; +using Listenarr.Domain.Common; using Microsoft.AspNetCore.Http; namespace Listenarr.Application.Notification @@ -95,16 +96,8 @@ public static JsonNode CreateDiscordPayload(string trigger, object data, string? narrators = DecodeHtml(narrators); description = DecodeHtml(description); - // Use centralized constants declared at class scope - - static string Truncate(string? value, int max) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - return value.Length <= max ? value : value.Substring(0, max); - } - var embed = new JsonObject(); - if (!string.IsNullOrWhiteSpace(title)) embed["title"] = Truncate(title, MAX_TITLE); + if (!string.IsNullOrWhiteSpace(title)) embed["title"] = StringUtils.Truncate(title, MAX_TITLE); string? absoluteImageUrl = null; string? thumbnailUrl = null; @@ -131,7 +124,7 @@ static string Truncate(string? value, int max) } else if (!string.IsNullOrWhiteSpace(absoluteImageUrl)) { - embed["thumbnail"] = new JsonObject { ["url"] = Truncate(absoluteImageUrl, 2000) }; + embed["thumbnail"] = new JsonObject { ["url"] = StringUtils.Truncate(absoluteImageUrl, 2000) }; } var embeds = new JsonArray(); @@ -140,8 +133,8 @@ static string Truncate(string? value, int max) if (!string.IsNullOrWhiteSpace(author)) { var fa = new JsonObject(); - fa["name"] = Truncate("Author", MAX_FIELD_NAME); - fa["value"] = Truncate(author, MAX_FIELD_VALUE); + fa["name"] = StringUtils.Truncate("Author", MAX_FIELD_NAME); + fa["value"] = StringUtils.Truncate(author, MAX_FIELD_VALUE); fa["inline"] = false; fields.Add(fa); } @@ -149,33 +142,33 @@ static string Truncate(string? value, int max) if (!string.IsNullOrWhiteSpace(publisher)) { var f = new JsonObject(); - f["name"] = Truncate("Publisher", MAX_FIELD_NAME); - f["value"] = Truncate(publisher, MAX_FIELD_VALUE); + f["name"] = StringUtils.Truncate("Publisher", MAX_FIELD_NAME); + f["value"] = StringUtils.Truncate(publisher, MAX_FIELD_VALUE); f["inline"] = true; fields.Add(f); } if (!string.IsNullOrWhiteSpace(year)) { var f = new JsonObject(); - f["name"] = Truncate("Year", MAX_FIELD_NAME); - f["value"] = Truncate(year, MAX_FIELD_VALUE); + f["name"] = StringUtils.Truncate("Year", MAX_FIELD_NAME); + f["value"] = StringUtils.Truncate(year, MAX_FIELD_VALUE); f["inline"] = true; fields.Add(f); } if (!string.IsNullOrWhiteSpace(narrators)) { var f = new JsonObject(); - f["name"] = Truncate("Narrated by", MAX_FIELD_NAME); - f["value"] = Truncate(narrators, MAX_FIELD_VALUE); + f["name"] = StringUtils.Truncate("Narrated by", MAX_FIELD_NAME); + f["value"] = StringUtils.Truncate(narrators, MAX_FIELD_VALUE); f["inline"] = false; fields.Add(f); } if (!string.IsNullOrWhiteSpace(description)) { var cleanedDescription = CleanHtml(description); - var truncatedDesc = Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500)); + var truncatedDesc = StringUtils.Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500)); var f = new JsonObject(); - f["name"] = Truncate("Description", MAX_FIELD_NAME); + f["name"] = StringUtils.Truncate("Description", MAX_FIELD_NAME); f["value"] = truncatedDesc; f["inline"] = false; fields.Add(f); @@ -226,7 +219,7 @@ static string Truncate(string? value, int max) { int reduce = Math.Min(excess, descriptionText.Length); descriptionText = descriptionText.Substring(0, Math.Max(0, descriptionText.Length - reduce)); - e["description"] = Truncate(descriptionText, MAX_DESCRIPTION); + e["description"] = StringUtils.Truncate(descriptionText, MAX_DESCRIPTION); excess = excess - reduce; } @@ -241,7 +234,7 @@ static string Truncate(string? value, int max) { int reduce = Math.Min(excess, v.Length); var newVal = v.Substring(0, Math.Max(0, v.Length - reduce)); - fo["value"] = Truncate(newVal, MAX_FIELD_VALUE); + fo["value"] = StringUtils.Truncate(newVal, MAX_FIELD_VALUE); excess -= reduce; } } @@ -312,14 +305,8 @@ static string Truncate(string? value, int max) // Use centralized constants declared at class scope - static string Truncate(string? value, int max) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - return value.Length <= max ? value : value.Substring(0, max); - } - var embed = new JsonObject(); - if (!string.IsNullOrWhiteSpace(title)) embed["title"] = Truncate(title, MAX_TITLE); + if (!string.IsNullOrWhiteSpace(title)) embed["title"] = StringUtils.Truncate(title, MAX_TITLE); string? absoluteImageUrl = null; string? thumbnailUrl = null; @@ -404,7 +391,7 @@ static string Truncate(string? value, int max) } else if (!string.IsNullOrWhiteSpace(absoluteImageUrl)) { - embed["thumbnail"] = new JsonObject { ["url"] = Truncate(absoluteImageUrl, 2000) }; + embed["thumbnail"] = new JsonObject { ["url"] = StringUtils.Truncate(absoluteImageUrl, 2000) }; thumbnailSet = true; } else if (!string.IsNullOrWhiteSpace(thumbnailUrl)) @@ -424,8 +411,8 @@ static string Truncate(string? value, int max) if (!string.IsNullOrWhiteSpace(author)) { var fa = new JsonObject(); - fa["name"] = Truncate("Author", MAX_FIELD_NAME); - fa["value"] = Truncate(author, MAX_FIELD_VALUE); + fa["name"] = StringUtils.Truncate("Author", MAX_FIELD_NAME); + fa["value"] = StringUtils.Truncate(author, MAX_FIELD_VALUE); fa["inline"] = false; fields.Add(fa); } @@ -433,33 +420,33 @@ static string Truncate(string? value, int max) if (!string.IsNullOrWhiteSpace(publisher)) { var f = new JsonObject(); - f["name"] = Truncate("Publisher", MAX_FIELD_NAME); - f["value"] = Truncate(publisher, MAX_FIELD_VALUE); + f["name"] = StringUtils.Truncate("Publisher", MAX_FIELD_NAME); + f["value"] = StringUtils.Truncate(publisher, MAX_FIELD_VALUE); f["inline"] = true; fields.Add(f); } if (!string.IsNullOrWhiteSpace(year)) { var f = new JsonObject(); - f["name"] = Truncate("Year", MAX_FIELD_NAME); - f["value"] = Truncate(year, MAX_FIELD_VALUE); + f["name"] = StringUtils.Truncate("Year", MAX_FIELD_NAME); + f["value"] = StringUtils.Truncate(year, MAX_FIELD_VALUE); f["inline"] = true; fields.Add(f); } if (!string.IsNullOrWhiteSpace(narrators)) { var f = new JsonObject(); - f["name"] = Truncate("Narrated by", MAX_FIELD_NAME); - f["value"] = Truncate(narrators, MAX_FIELD_VALUE); + f["name"] = StringUtils.Truncate("Narrated by", MAX_FIELD_NAME); + f["value"] = StringUtils.Truncate(narrators, MAX_FIELD_VALUE); f["inline"] = false; fields.Add(f); } if (!string.IsNullOrWhiteSpace(description)) { var cleanedDescription = CleanHtml(description); - var truncatedDesc = Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500)); + var truncatedDesc = StringUtils.Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500)); var f = new JsonObject(); - f["name"] = Truncate("Description", MAX_FIELD_NAME); + f["name"] = StringUtils.Truncate("Description", MAX_FIELD_NAME); f["value"] = truncatedDesc; f["inline"] = false; fields.Add(f); diff --git a/listenarr.domain/Common/StringUtils.cs b/listenarr.domain/Common/StringUtils.cs index 5cbd0ed24..6a9533241 100644 --- a/listenarr.domain/Common/StringUtils.cs +++ b/listenarr.domain/Common/StringUtils.cs @@ -44,5 +44,12 @@ public static int LevenshteinDistance(string s, string t) } return d[n, m]; } + + public static string Truncate(string? s, int max) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + if (s.Length <= max) return s; + return s.Substring(0, max - 3) + "..."; + } } } diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 47f34ceb3..93bd28673 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -38,6 +38,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Listenarr.Infrastructure.Extensions { @@ -107,6 +108,8 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); // Bind FileMover options from configuration (optional) services.Configure(config.GetSection("FileMover")); + services.AddSingleton(resolver => + resolver.GetRequiredService>().Value); // Gateway that wraps adapters for higher-level orchestration services.AddScoped(); // Process runner for external process execution (robocopy, ffprobe, playwright installer) diff --git a/listenarr.infrastructure/FileSystem/FileMover.cs b/listenarr.infrastructure/FileSystem/FileMover.cs index ed039c589..3fc85567b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.cs @@ -19,7 +19,6 @@ using System.Runtime.InteropServices; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Options; using Listenarr.Application.Interfaces; using Microsoft.Extensions.Logging; using Listenarr.Domain.Common; @@ -29,7 +28,10 @@ namespace Listenarr.Infrastructure.FileSystem { - public partial class FileMover : IFileMover + public partial class FileMover( + ILogger logger, + IProcessRunner processRunner, + FileMoverOptions options) : IFileMover { // .NET 8 has no managed BCL equivalent for hardlink creation. // LibraryImport (source-generated P/Invoke, .NET 7+) is used instead of the legacy @@ -43,24 +45,13 @@ public partial class FileMover : IFileMover [SuppressMessage("Interoperability", "SYSLIB1054", Justification = "No managed BCL equivalent for hardlink creation exists in .NET 8.")] private static partial int LinkNative(string oldpath, string newpath); - private readonly ILogger _logger; - private readonly IProcessRunner? _processRunner; - private readonly FileMoverOptions _options; - - public FileMover(ILogger logger, IProcessRunner? processRunner = null, IOptions? options = null) - { - _logger = logger; - _processRunner = processRunner; - _options = options?.Value ?? new FileMoverOptions(); - } - public async Task MoveDirectoryAsync(string sourceDir, string destDir) { // Try move with retries var attempt = 0; var delay = 1000; - for (; attempt < _options.MaxRetries; attempt++) + for (; attempt < options.MaxRetries; attempt++) { try { @@ -69,15 +60,15 @@ public async Task MoveDirectoryAsync(string sourceDir, string destDir) } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogWarning(ex, "Directory.Move attempt {Attempt} failed: {Source} -> {Dest}", attempt + 1, sourceDir, destDir); + logger.LogWarning(ex, "Directory.Move attempt {Attempt} failed: {Source} -> {Dest}", attempt + 1, sourceDir, destDir); try { var files = Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories); - _logger.LogWarning("Directory listing sample: {Sample}", string.Join(", ", files.Take(5).Select(f => Path.GetFileName(f)))); + logger.LogWarning("Directory listing sample: {Sample}", string.Join(", ", files.Take(5).Select(f => Path.GetFileName(f)))); } catch (Exception diagEx) when (diagEx is not OperationCanceledException && diagEx is not OutOfMemoryException && diagEx is not StackOverflowException) { - _logger.LogDebug(diagEx, "Failed to collect directory listing diagnostics for {Source}", sourceDir); + logger.LogDebug(diagEx, "Failed to collect directory listing diagnostics for {Source}", sourceDir); } try @@ -86,18 +77,18 @@ public async Task MoveDirectoryAsync(string sourceDir, string destDir) { var dirSec = new DirectoryInfo(sourceDir).GetAccessControl(); var owner = dirSec.GetOwner(typeof(NTAccount))?.ToString() ?? "unknown"; - _logger.LogWarning("Directory owner: {Owner}", owner); + logger.LogWarning("Directory owner: {Owner}", owner); } } catch (Exception ownerEx) when (ownerEx is not OperationCanceledException && ownerEx is not OutOfMemoryException && ownerEx is not StackOverflowException) { - _logger.LogDebug(ownerEx, "Failed to resolve directory owner diagnostics for {Source}", sourceDir); + logger.LogDebug(ownerEx, "Failed to resolve directory owner diagnostics for {Source}", sourceDir); } - if (attempt < _options.MaxRetries - 1) + if (attempt < options.MaxRetries - 1) { - await Task.Delay(Math.Min(delay, _options.MaxBackoffMs)); - delay = Math.Min(delay * 2, _options.MaxBackoffMs); + await Task.Delay(Math.Min(delay, options.MaxBackoffMs)); + delay = Math.Min(delay * 2, options.MaxBackoffMs); } } } @@ -109,48 +100,15 @@ public async Task MoveDirectoryAsync(string sourceDir, string destDir) try { Directory.Delete(sourceDir, true); } catch (Exception deleteEx) when (deleteEx is not OperationCanceledException && deleteEx is not OutOfMemoryException && deleteEx is not StackOverflowException) { - _logger.LogDebug(deleteEx, "Failed deleting source directory after copy fallback for {Source}", sourceDir); + logger.LogDebug(deleteEx, "Failed deleting source directory after copy fallback for {Source}", sourceDir); } return true; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogError(ex, "Copy+delete fallback failed for directory {Source} -> {Dest}", sourceDir, destDir); - - // On Windows attempt robocopy as a final-resort atomic-ish fallback - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && _options.EnableRobocopy && _processRunner != null) - { - _logger.LogWarning("Attempting robocopy fallback for directory move: {Source} -> {Dest}", sourceDir, destDir); - var startInfo = CreateRobocopyStartInfo( - sourceDir, - destDir, - "/MOVE", - "/E", - "/NFL", - "/NDL", - "/NJH", - "/NJS", - "/NP"); - - var pr = await _processRunner.RunAsync(startInfo, _options.RobocopyTimeoutMs); - if (!pr.TimedOut && pr.ExitCode <= 7 && pr.ExitCode >= 0) - { - _logger.LogInformation("Robocopy fallback succeeded with exit code {Code}", pr.ExitCode); - _logger.LogDebug("Robocopy stdout: {Out}", LogRedaction.RedactText(Truncate(pr.Stdout, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); - return true; - } - - _logger.LogWarning("Robocopy fallback failed or returned non-success code: {Code}. Stderr: {Err}", pr.ExitCode, LogRedaction.RedactText(Truncate(pr.Stderr, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); - } - } - catch (Exception rex) when (rex is not OperationCanceledException && rex is not OutOfMemoryException && rex is not StackOverflowException) - { - _logger.LogWarning(rex, "Robocopy fallback threw an exception"); - } + logger.LogError(ex, "Copy+delete fallback failed for directory {Source} -> {Dest}", sourceDir, destDir); - return false; + return await MoveWithRobocopy(sourceDir, destDir); } } @@ -159,7 +117,7 @@ public async Task MoveFileAsync(string sourceFile, string destFile) var attempt = 0; var delay = 1000; - for (; attempt < _options.MaxRetries; attempt++) + for (; attempt < options.MaxRetries; attempt++) { try { @@ -168,15 +126,15 @@ public async Task MoveFileAsync(string sourceFile, string destFile) } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogWarning(ex, "File.Move attempt {Attempt} failed: {Source} -> {Dest}", attempt + 1, sourceFile, destFile); + logger.LogWarning(ex, "File.Move attempt {Attempt} failed: {Source} -> {Dest}", attempt + 1, sourceFile, destFile); try { using var stream = File.Open(sourceFile, FileMode.Open, FileAccess.Read, FileShare.Read); - _logger.LogDebug("Able to open source file for read during diagnostic: {File}", sourceFile); + logger.LogDebug("Able to open source file for read during diagnostic: {File}", sourceFile); } catch (Exception diagEx) when (diagEx is not OperationCanceledException && diagEx is not OutOfMemoryException && diagEx is not StackOverflowException) { - _logger.LogDebug(diagEx, "Failed to collect file diagnostics for {Source}", sourceFile); + logger.LogDebug(diagEx, "Failed to collect file diagnostics for {Source}", sourceFile); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -185,18 +143,18 @@ public async Task MoveFileAsync(string sourceFile, string destFile) { var fileSec = new FileInfo(sourceFile).GetAccessControl(); var owner = fileSec.GetOwner(typeof(NTAccount))?.ToString() ?? "unknown"; - _logger.LogWarning("File owner for {File}: {Owner}", sourceFile, owner); + logger.LogWarning("File owner for {File}: {Owner}", sourceFile, owner); } catch (Exception ownerEx) when (ownerEx is not OperationCanceledException && ownerEx is not OutOfMemoryException && ownerEx is not StackOverflowException) { - _logger.LogDebug(ownerEx, "Failed to resolve file owner diagnostics for {Source}", sourceFile); + logger.LogDebug(ownerEx, "Failed to resolve file owner diagnostics for {Source}", sourceFile); } } - if (attempt < _options.MaxRetries - 1) + if (attempt < options.MaxRetries - 1) { - await Task.Delay(Math.Min(delay, _options.MaxBackoffMs)); - delay = Math.Min(delay * 2, _options.MaxBackoffMs); + await Task.Delay(Math.Min(delay, options.MaxBackoffMs)); + delay = Math.Min(delay * 2, options.MaxBackoffMs); } } } @@ -208,66 +166,15 @@ public async Task MoveFileAsync(string sourceFile, string destFile) try { File.Delete(sourceFile); } catch (Exception deleteEx) when (deleteEx is not OperationCanceledException && deleteEx is not OutOfMemoryException && deleteEx is not StackOverflowException) { - _logger.LogDebug(deleteEx, "Failed deleting source file after copy fallback for {Source}", sourceFile); + logger.LogDebug(deleteEx, "Failed deleting source file after copy fallback for {Source}", sourceFile); } return true; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogError(ex, "Copy+delete fallback failed for file {Source} -> {Dest}", sourceFile, destFile); - - // On Windows attempt robocopy for single-file move as a last resort - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && _options.EnableRobocopy && _processRunner != null) - { - _logger.LogWarning("Attempting robocopy fallback for file move: {Source} -> {Dest}", sourceFile, destFile); - var srcDir = Path.GetDirectoryName(sourceFile) ?? string.Empty; - var dstDir = Path.GetDirectoryName(destFile) ?? string.Empty; - var fileName = Path.GetFileName(sourceFile); - var startInfo = CreateRobocopyStartInfo( - srcDir, - dstDir, - fileName, - "/MOV", - "/E", - "/NFL", - "/NDL", - "/NJH", - "/NJS", - "/NP"); - - var pr = await _processRunner.RunAsync(startInfo, _options.RobocopyTimeoutMs); - if (!pr.TimedOut && pr.ExitCode == 1) - { - _logger.LogInformation("Robocopy fallback succeeded with exit code {Code}", pr.ExitCode); - _logger.LogDebug("Robocopy stdout: {Out}", LogRedaction.RedactText(Truncate(pr.Stdout, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); - return true; - } - - _logger.LogWarning("Robocopy fallback failed or returned non-success code: {Code}. Stderr: {Err}", pr.ExitCode, LogRedaction.RedactText(Truncate(pr.Stderr, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); - } - } - catch (Exception rex) when (rex is not OperationCanceledException && rex is not OutOfMemoryException && rex is not StackOverflowException) - { - _logger.LogWarning(rex, "Robocopy fallback threw an exception"); - } - - return false; - } - } + logger.LogError(ex, "Copy+delete fallback failed for file {Source} -> {Dest}", sourceFile, destFile); - public Task CopyDirectoryAsync(string sourceDir, string destDir) - { - try - { - CopyDirRecursive(sourceDir, destDir); - return Task.FromResult(true); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Copy directory failed: {Source} -> {Dest}", sourceDir, destDir); - return Task.FromResult(false); + return await MoveWithRobocopy(sourceFile, destFile); } } @@ -280,12 +187,12 @@ public async Task CopyFileAsync(string sourceFile, string destFile) } catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { - _logger.LogError(exception, $"Copy file failed: {sourceFile} -> {destFile}"); + logger.LogError(exception, $"Copy file failed: {sourceFile} -> {destFile}"); return false; } } - public Task HardlinkFileAsync(string sourceFile, string destFile) + public async Task HardlinkFileAsync(string sourceFile, string destFile) { try { @@ -326,8 +233,8 @@ public Task HardlinkFileAsync(string sourceFile, string destFile) // Hardlink succeeded — atomically replace destination File.Move(tempDest, destFile, overwrite: true); - _logger.LogInformation("Hardlinked file: {Source} -> {Dest}", sourceFile, destFile); - return Task.FromResult(true); + logger.LogInformation("Hardlinked file: {Source} -> {Dest}", sourceFile, destFile); + return true; } catch (Exception linkEx) when (linkEx is not OperationCanceledException && linkEx is not OutOfMemoryException && linkEx is not StackOverflowException) { @@ -346,64 +253,54 @@ public Task HardlinkFileAsync(string sourceFile, string destFile) isCrossDevice = linkEx is IOException ioEx2 && ioEx2.Message.Contains("error code 18"); // Unix EXDEV if (isCrossDevice) - _logger.LogInformation("Hardlink not possible (source and destination are on different drives), falling back to copy: {Source} -> {Dest}", sourceFile, destFile); + logger.LogInformation("Hardlink not possible (source and destination are on different drives), falling back to copy: {Source} -> {Dest}", sourceFile, destFile); else - _logger.LogWarning(linkEx, "Hardlink failed, falling back to copy: {Source} -> {Dest}", sourceFile, destFile); - - // Fallback to copy — copy to a temp file first, then atomically rename onto destination - // so the existing file is never overwritten until a complete replacement is confirmed. - // Use Path.GetFileName to strip any separators from GetRandomFileName (satisfies static analysis). - // Use Path.Join (not Path.Combine) to prevent rooted second arg from silently discarding destDir. - var tempCopyName = Path.GetFileName(Path.GetRandomFileName()) + ".tmp"; - var tempCopyPath = Path.Join(destDir, tempCopyName); - try - { - File.Copy(sourceFile, tempCopyPath, overwrite: true); - File.Move(tempCopyPath, destFile, overwrite: true); - _logger.LogInformation("Copied file (hardlink fallback): {Source} -> {Dest}", sourceFile, destFile); - return Task.FromResult(true); - } - finally + logger.LogWarning(linkEx, "Hardlink failed, falling back to copy: {Source} -> {Dest}", sourceFile, destFile); + + // Copy fallback + if (await CopyFileAsync(sourceFile, destFile)) { - // Best-effort cleanup of temp copy if something went wrong before/after the move - try { if (File.Exists(tempCopyPath)) File.Delete(tempCopyPath); } - catch (Exception cleanupEx) when (cleanupEx is not OperationCanceledException - && cleanupEx is not OutOfMemoryException - && cleanupEx is not StackOverflowException) - { - // best-effort cleanup; ignore non-critical failures - } + return true; } + + logger.LogError("Hardlink/Copy failed: {Source} -> {Dest}", sourceFile, destFile); } } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { - _logger.LogError(ex, "Hardlink/Copy failed: {Source} -> {Dest}", sourceFile, destFile); - return Task.FromResult(false); + logger.LogError(exception, "Hardlink/Copy failed: {Source} -> {Dest}", sourceFile, destFile); } + + return false; } - private void CopyDirRecursive(string src, string dst) + internal void CopyDirRecursive(string src, string dst) { - Directory.CreateDirectory(dst); - foreach (var dir in Directory.GetDirectories(src, "*", SearchOption.TopDirectoryOnly)) + src = FileUtils.NormalizeStoredPath(src); + dst = FileUtils.NormalizeStoredPath(dst); + + if (dst.Equals(src, StringComparison.OrdinalIgnoreCase)) { - var sub = Path.Join(dst, Path.GetFileName(dir)); - CopyDirRecursive(dir, sub); + return; } - foreach (var file in Directory.GetFiles(src, "*.*", SearchOption.TopDirectoryOnly)) + Directory.CreateDirectory(dst); + foreach (var sourceSubdirectory in Directory.GetDirectories(src, "*", SearchOption.TopDirectoryOnly)) { - var destFile = Path.Join(dst, Path.GetFileName(file)); - File.Copy(file, destFile, true); + var destinationSubdirectory = Path.Join(dst, Path.GetFileName(sourceSubdirectory)); + if (destinationSubdirectory.StartsWith(src, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + CopyDirRecursive(sourceSubdirectory, destinationSubdirectory); } - } - private static string Truncate(string? s, int max) - { - if (string.IsNullOrEmpty(s)) return string.Empty; - if (s.Length <= max) return s; - return s.Substring(0, max) + "..."; + foreach (var sourceFile in Directory.GetFiles(src, "*.*", SearchOption.TopDirectoryOnly)) + { + var destinationFile = Path.Join(dst, Path.GetFileName(sourceFile)); + File.Copy(sourceFile, destinationFile, overwrite: true); + } } private static ProcessStartInfo CreateRobocopyStartInfo(params string[] arguments) @@ -465,6 +362,49 @@ public async Task PerformActionOn(FileAction action, string source, string throw new InvalidOperationException($"Unable to perform {action} on {source} to {destination}", exception); } } + + private async Task MoveWithRobocopy(string source, string destination) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || !options.EnableRobocopy) + { + return false; + } + + try + { + logger.LogInformation($"Attempting robocopy fallback for file move: {source} -> {destination}"); + var srcDir = Path.GetDirectoryName(source) ?? string.Empty; + var dstDir = Path.GetDirectoryName(destination) ?? string.Empty; + var fileName = Path.GetFileName(source); + var startInfo = CreateRobocopyStartInfo( + srcDir, + dstDir, + fileName, + "/MOV", + "/E", + "/NFL", + "/NDL", + "/NJH", + "/NJS", + "/NP"); + + var pr = await processRunner.RunAsync(startInfo, options.RobocopyTimeoutMs); + if (!pr.TimedOut && pr.ExitCode == 1) + { + logger.LogInformation($"Robocopy fallback succeeded with exit code {pr.ExitCode}"); + logger.LogDebug("Robocopy stdout: {Out}", LogRedaction.RedactText(StringUtils.Truncate(pr.Stdout, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); + return true; + } + + logger.LogWarning("Robocopy fallback failed or returned non-success code: {Code}. Stderr: {Err}", pr.ExitCode, LogRedaction.RedactText(StringUtils.Truncate(pr.Stderr, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + logger.LogWarning(exception, "Robocopy fallback threw an exception"); + } + + return false; + } } } diff --git a/tests/Features/Api/Controllers/ManualImportControllerTests.cs b/tests/Features/Api/Controllers/ManualImportControllerTests.cs index 0b479c691..6e9622a3f 100644 --- a/tests/Features/Api/Controllers/ManualImportControllerTests.cs +++ b/tests/Features/Api/Controllers/ManualImportControllerTests.cs @@ -27,6 +27,8 @@ using Listenarr.Application.Common; using Listenarr.Infrastructure.FileSystem; using Listenarr.Api.Dtos.ManualImport; +using Listenarr.Infrastructure.Platform; +using Microsoft.Extensions.Logging; namespace Listenarr.Tests.Features.Api.Controllers { @@ -118,17 +120,25 @@ public static ManualImportController GetController(Audiobook book, ApplicationSe configMock.Setup(c => c.GetApplicationSettingsAsync()).ReturnsAsync(settings); var rootFolderMock = new Mock(); - rootFolderMock.Setup(r => r.GetAllAsync()).ReturnsAsync(new System.Collections.Generic.List()); + rootFolderMock.Setup(r => r.GetAllAsync()).ReturnsAsync([]); + + var runner = new SystemProcessRunner(Mock.Of>()); + var options = new FileMoverOptions + { + EnableRobocopy = true, + MaxRetries = 1, + RobocopyTimeoutMs = 1000, + }; return new ManualImportController( - Mock.Of>(), + Mock.Of>(), repoMock.Object, metadataMock.Object, new FileNamingService(configMock.Object, NullLogger.Instance), configMock.Object, scanMock.Object, rootFolderMock.Object, - new FileMover(Mock.Of>()) + new FileMover(Mock.Of>(), runner, options) ); } diff --git a/tests/Features/Api/Services/FileMoverFallbackTests.cs b/tests/Features/Api/Services/FileMoverFallbackTests.cs deleted file mode 100644 index b13246bb5..000000000 --- a/tests/Features/Api/Services/FileMoverFallbackTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -using System.Diagnostics; -using System.Runtime.InteropServices; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; -using Listenarr.Infrastructure.FileSystem; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Listenarr.Tests.Features.Api.Services -{ - public class FileMoverFallbackTests : IDisposable - { - private readonly string _root; - - public FileMoverFallbackTests() - { - _root = Path.Join(Path.GetTempPath(), "listenarr_test_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_root); - } - - public void Dispose() - { - try - { - Directory.Delete(_root, true); - } - catch (IOException ex) - { - Debug.WriteLine($"Ignoring cleanup failure for '{_root}': {ex.Message}"); - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Ignoring cleanup failure for '{_root}': {ex.Message}"); - } - } - - [Fact] - public async Task MoveDirectoryAsync_WhenDestinationExists_UsesCopyAndDeleteFallback() - { - var source = Path.Join(_root, "sourceDir"); - var dest = Path.Join(_root, "destDir"); - Directory.CreateDirectory(source); - Directory.CreateDirectory(dest); // cause Directory.Move to throw (destination exists) - - var fileInSource = Path.Join(source, "track1.mp3"); - await File.WriteAllTextAsync(fileInSource, "dummy"); - - var mover = new FileMover(new NullLogger()); - - var result = await mover.MoveDirectoryAsync(source, dest); - - Assert.True(result, "MoveDirectoryAsync should succeed via fallback"); - // Source should be removed - Assert.False(Directory.Exists(source)); - // Destination should contain the file - var copied = Path.Join(dest, "track1.mp3"); - Assert.True(File.Exists(copied)); - } - - [Fact] - public async Task MoveFileAsync_MovesFileSuccessfully() - { - var sourceFile = Path.Join(_root, "a.mp3"); - var destFile = Path.Join(_root, "b.mp3"); - await File.WriteAllTextAsync(sourceFile, "content"); - - var mover = new FileMover(new NullLogger()); - var ok = await mover.MoveFileAsync(sourceFile, destFile); - - Assert.True(ok); - Assert.False(File.Exists(sourceFile)); - Assert.True(File.Exists(destFile)); - } - - [Fact] - public async Task MoveDirectoryAsync_RobocopyFallback_UsesArgumentList() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; - } - - var runner = new RecordingProcessRunner(); - var mover = new FileMover( - new NullLogger(), - runner, - Options.Create(new FileMoverOptions - { - EnableRobocopy = true, - MaxRetries = 1, - RobocopyTimeoutMs = 1000, - })); - - var source = Path.Join(_root, "missing-dir"); - var dest = Path.Join(_root, "dest-dir"); - - var ok = await mover.MoveDirectoryAsync(source, dest); - - Assert.True(ok); - Assert.NotNull(runner.LastStartInfo); - Assert.Equal("robocopy", runner.LastStartInfo!.FileName); - Assert.True(string.IsNullOrEmpty(runner.LastStartInfo.Arguments)); - Assert.Equal(source, runner.LastStartInfo.ArgumentList[0]); - Assert.Equal(dest, runner.LastStartInfo.ArgumentList[1]); - Assert.Contains("/MOVE", runner.LastStartInfo.ArgumentList); - Assert.All(runner.LastStartInfo.ArgumentList, argument => - { - Assert.False(argument.StartsWith("\"", StringComparison.Ordinal)); - Assert.False(argument.EndsWith("\"", StringComparison.Ordinal)); - }); - } - - [Fact] - public async Task MoveFileAsync_RobocopyFallback_UsesArgumentList() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; - } - - var runner = new RecordingProcessRunner(); - var mover = new FileMover( - new NullLogger(), - runner, - Options.Create(new FileMoverOptions - { - EnableRobocopy = true, - MaxRetries = 1, - RobocopyTimeoutMs = 1000, - })); - - var sourceFile = Path.Join(_root, "missing-file.mp3"); - var destFile = Path.Join(_root, "dest", "missing-file.mp3"); - - var ok = await mover.MoveFileAsync(sourceFile, destFile); - - Assert.True(ok); - Assert.NotNull(runner.LastStartInfo); - Assert.Equal("robocopy", runner.LastStartInfo!.FileName); - Assert.True(string.IsNullOrEmpty(runner.LastStartInfo.Arguments)); - Assert.Equal(Path.GetDirectoryName(sourceFile) ?? string.Empty, runner.LastStartInfo.ArgumentList[0]); - Assert.Equal(Path.GetDirectoryName(destFile) ?? string.Empty, runner.LastStartInfo.ArgumentList[1]); - Assert.Equal(Path.GetFileName(sourceFile), runner.LastStartInfo.ArgumentList[2]); - Assert.Contains("/MOV", runner.LastStartInfo.ArgumentList); - } - - private sealed class RecordingProcessRunner : IProcessRunner - { - public ProcessStartInfo? LastStartInfo { get; private set; } - - public Task RunAsync(ProcessStartInfo startInfo, int timeoutMs = 60000, System.Threading.CancellationToken cancellationToken = default) - { - LastStartInfo = startInfo; - return Task.FromResult(new ProcessResult(1, string.Empty, string.Empty, false)); - } - - public Process StartProcess(ProcessStartInfo startInfo) => throw new NotSupportedException(); - - public IDisposable RegisterTransientSensitive(IEnumerable values) => new NoopDisposable(); - - private sealed class NoopDisposable : IDisposable - { - public void Dispose() - { - } - } - } - } -} diff --git a/tests/Features/Api/Services/FileMoverHardlinkTests.cs b/tests/Features/Api/Services/FileMoverHardlinkTests.cs deleted file mode 100644 index 4bec19fb8..000000000 --- a/tests/Features/Api/Services/FileMoverHardlinkTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -using Listenarr.Infrastructure.FileSystem; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Listenarr.Tests.Features.Api.Services -{ - public class FileMoverHardlinkTests : IDisposable - { - private readonly string _root; - private readonly FileMover _mover; - - public FileMoverHardlinkTests() - { - _root = Path.Join(Path.GetTempPath(), "listenarr_hardlink_test_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_root); - _mover = new FileMover(new NullLogger()); - } - - public void Dispose() - { - try { Directory.Delete(_root, true); } catch (IOException ex) { _ = ex; } catch (UnauthorizedAccessException ex) { _ = ex; } - } - - [Fact] - public async Task HardlinkFileAsync_CreatesHardlink_WhenBothFilesOnSameVolume() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - var destFile = Path.Join(_root, "dest.mp3"); - await File.WriteAllTextAsync(sourceFile, "audio content"); - - // Act - var result = await _mover.HardlinkFileAsync(sourceFile, destFile); - - // Assert - Assert.True(result, "HardlinkFileAsync should succeed"); - Assert.True(File.Exists(sourceFile), "Source file should still exist"); - Assert.True(File.Exists(destFile), "Destination file should exist"); - - // Both files should have same content - var sourceContent = await File.ReadAllTextAsync(sourceFile); - var destContent = await File.ReadAllTextAsync(destFile); - Assert.Equal(sourceContent, destContent); - } - - [Fact] - public async Task HardlinkFileAsync_CreatesDestinationDirectory_WhenMissing() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - var destDir = Path.Join(_root, "subdir"); - var destFile = Path.Join(destDir, "dest.mp3"); - await File.WriteAllTextAsync(sourceFile, "audio content"); - - Assert.False(Directory.Exists(destDir), "Destination directory should not exist initially"); - - // Act - var result = await _mover.HardlinkFileAsync(sourceFile, destFile); - - // Assert - Assert.True(result, "HardlinkFileAsync should succeed"); - Assert.True(Directory.Exists(destDir), "Destination directory should be created"); - Assert.True(File.Exists(destFile), "Destination file should exist"); - } - - [Fact] - public async Task HardlinkFileAsync_OverwritesDestination_WhenDestinationExists() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - var destFile = Path.Join(_root, "dest.mp3"); - await File.WriteAllTextAsync(sourceFile, "new content"); - await File.WriteAllTextAsync(destFile, "old content"); - - // Act - var result = await _mover.HardlinkFileAsync(sourceFile, destFile); - - // Assert - Assert.True(result, "HardlinkFileAsync should succeed"); - var destContent = await File.ReadAllTextAsync(destFile); - Assert.Equal("new content", destContent); - } - - [Fact] - public async Task HardlinkFileAsync_FallbacksToCopy_WhenHardlinkFails() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - await File.WriteAllTextAsync(sourceFile, "content"); - - // Create a path that would cause hardlink to fail (different volume simulation via invalid path) - // On some systems, hardlink may fail for various reasons - we want to test fallback behavior - var destFile = Path.Join(_root, "dest.mp3"); - - // Act - even if hardlink fails internally, the method should fallback to copy - var result = await _mover.HardlinkFileAsync(sourceFile, destFile); - - // Assert - should succeed via fallback - Assert.True(result, "HardlinkFileAsync should succeed via copy fallback"); - Assert.True(File.Exists(destFile), "Destination file should exist"); - } - - [Fact] - public async Task HardlinkFileAsync_ReturnsFalse_WhenSourceDoesNotExist() - { - // Arrange - var sourceFile = Path.Join(_root, "nonexistent.mp3"); - var destFile = Path.Join(_root, "dest.mp3"); - - // Act - var result = await _mover.HardlinkFileAsync(sourceFile, destFile); - - // Assert - // Method gracefully returns false when source doesn't exist (exception is caught internally) - Assert.False(result, "HardlinkFileAsync should return false when source doesn't exist"); - Assert.False(File.Exists(destFile), "Destination file should not be created"); - } - - [Fact] - public async Task CopyFileAsync_CreatesIndependentCopy() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - var destFile = Path.Join(_root, "dest.mp3"); - await File.WriteAllTextAsync(sourceFile, "original content"); - - // Act - var result = await _mover.CopyFileAsync(sourceFile, destFile); - - // Assert - Assert.True(result, "CopyFileAsync should succeed"); - Assert.True(File.Exists(sourceFile), "Source should still exist"); - Assert.True(File.Exists(destFile), "Destination should exist"); - - // Modify destination to verify independence - await File.WriteAllTextAsync(destFile, "modified content"); - var sourceContent = await File.ReadAllTextAsync(sourceFile); - Assert.Equal("original content", sourceContent); - } - - [Fact] - public async Task MoveFileAsync_RemovesSource_AfterMove() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - var destFile = Path.Join(_root, "dest.mp3"); - await File.WriteAllTextAsync(sourceFile, "content"); - - // Act - var result = await _mover.MoveFileAsync(sourceFile, destFile); - - // Assert - Assert.True(result, "MoveFileAsync should succeed"); - Assert.False(File.Exists(sourceFile), "Source should be removed"); - Assert.True(File.Exists(destFile), "Destination should exist"); - } - - [Fact] - public async Task HardlinkFileAsync_PreservesFileSize() - { - // Arrange - var sourceFile = Path.Join(_root, "source.mp3"); - var largeContent = new string('x', 10000); - await File.WriteAllTextAsync(sourceFile, largeContent); - var sourceInfo = new FileInfo(sourceFile); - - // Act - var destFile = Path.Join(_root, "dest.mp3"); - await _mover.HardlinkFileAsync(sourceFile, destFile); - - // Assert - var destInfo = new FileInfo(destFile); - Assert.Equal(sourceInfo.Length, destInfo.Length); - } - } -} diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverTests.cs new file mode 100644 index 000000000..6c20d47cb --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverTests.cs @@ -0,0 +1,356 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models.Configurations; +using Listenarr.Domain.Models.Enumerations; +using Listenarr.Infrastructure.FileSystem; +using Listenarr.Tests.Common; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem +{ + public class FileMoverTests : BaseTests + { + private readonly string _root; + private IFileMover _mover; + + public FileMoverTests() + { + _root = FileService.GetTempDirectory("root"); + _mover = _provider.GetRequiredService(); + } + + [Fact] + public async Task MoveDirectoryAsync_WhenDestinationExists_UsesCopyAndDeleteFallback() + { + var source = Path.Join(_root, "sourceDir"); + var dest = Path.Join(_root, "destDir"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(dest); // cause Directory.Move to throw (destination exists) + + var fileInSource = Path.Join(source, "track1.mp3"); + await File.WriteAllTextAsync(fileInSource, "dummy"); + + var result = await _mover.MoveDirectoryAsync(source, dest); + + Assert.True(result, "MoveDirectoryAsync should succeed via fallback"); + // Source should be removed + Assert.False(Directory.Exists(source)); + // Destination should contain the file + var copied = Path.Join(dest, "track1.mp3"); + Assert.True(File.Exists(copied)); + } + + [Fact] + public async Task MoveFileAsync_MovesFileSuccessfully() + { + var sourceFile = Path.Join(_root, "a.mp3"); + var destFile = Path.Join(_root, "b.mp3"); + await File.WriteAllTextAsync(sourceFile, "content"); + + var ok = await _mover.PerformActionOn(FileAction.Move, sourceFile, destFile); + + Assert.True(ok); + Assert.False(File.Exists(sourceFile)); + Assert.True(File.Exists(destFile)); + } + + [Fact] + public async Task MoveDirectoryAsync_RobocopyFallback_UsesArgumentList() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + _services.AddSingleton(new RecordingProcessRunner()); + _services.AddSingleton(new FileMoverOptions + { + EnableRobocopy = true, + MaxRetries = 1, + RobocopyTimeoutMs = 1000, + }); + Init(); + + var runner = (RecordingProcessRunner)_provider.GetRequiredService(); + _mover = _provider.GetRequiredService(); + + var source = Path.Join(_root, "missing-dir"); + var dest = Path.Join(_root, "dest-dir"); + + var ok = await _mover.MoveDirectoryAsync(source, dest); + + Assert.True(ok); + Assert.NotNull(runner.LastStartInfo); + Assert.Equal("robocopy", runner.LastStartInfo!.FileName); + Assert.True(string.IsNullOrEmpty(runner.LastStartInfo.Arguments)); + Assert.Equal(source, runner.LastStartInfo.ArgumentList[0]); + Assert.Equal(dest, runner.LastStartInfo.ArgumentList[1]); + Assert.Contains("/MOVE", runner.LastStartInfo.ArgumentList); + Assert.All(runner.LastStartInfo.ArgumentList, argument => + { + Assert.False(argument.StartsWith("\"", StringComparison.Ordinal)); + Assert.False(argument.EndsWith("\"", StringComparison.Ordinal)); + }); + } + + [Fact] + public async Task MoveFileAsync_RobocopyFallback_UsesArgumentList() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + _services.AddSingleton(new RecordingProcessRunner()); + _services.AddSingleton(new FileMoverOptions + { + EnableRobocopy = true, + MaxRetries = 1, + RobocopyTimeoutMs = 1000, + }); + Init(); + + var runner = (RecordingProcessRunner)_provider.GetRequiredService(); + _mover = _provider.GetRequiredService(); + + var sourceFile = Path.Join(_root, "missing-file.mp3"); + var destFile = Path.Join(_root, "dest", "missing-file.mp3"); + + var ok = await _mover.PerformActionOn(FileAction.Move, sourceFile, destFile); + + Assert.True(ok); + Assert.NotNull(runner.LastStartInfo); + Assert.Equal("robocopy", runner.LastStartInfo!.FileName); + Assert.True(string.IsNullOrEmpty(runner.LastStartInfo.Arguments)); + Assert.Equal(Path.GetDirectoryName(sourceFile) ?? string.Empty, runner.LastStartInfo.ArgumentList[0]); + Assert.Equal(Path.GetDirectoryName(destFile) ?? string.Empty, runner.LastStartInfo.ArgumentList[1]); + Assert.Equal(Path.GetFileName(sourceFile), runner.LastStartInfo.ArgumentList[2]); + Assert.Contains("/MOV", runner.LastStartInfo.ArgumentList); + } + + private sealed class RecordingProcessRunner : IProcessRunner + { + public ProcessStartInfo? LastStartInfo { get; private set; } + + public Task RunAsync(ProcessStartInfo startInfo, int timeoutMs = 60000, System.Threading.CancellationToken cancellationToken = default) + { + LastStartInfo = startInfo; + return Task.FromResult(new ProcessResult(1, string.Empty, string.Empty, false)); + } + + public Process StartProcess(ProcessStartInfo startInfo) => throw new NotSupportedException(); + + public IDisposable RegisterTransientSensitive(IEnumerable values) => new NoopDisposable(); + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() + { + } + } + } + + [Fact] + public async Task HardlinkFileAsync_CreatesHardlink_WhenBothFilesOnSameVolume() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + var destFile = Path.Join(_root, "dest.mp3"); + await File.WriteAllTextAsync(sourceFile, "audio content"); + + // Act + var result = await _mover.PerformActionOn(FileAction.HardlinkCopy, sourceFile, destFile); + + // Assert + Assert.True(result, "HardlinkFileAsync should succeed"); + Assert.True(File.Exists(sourceFile), "Source file should still exist"); + Assert.True(File.Exists(destFile), "Destination file should exist"); + + // Modify source + await File.WriteAllTextAsync(sourceFile, "updated content"); + + // Check destination reflect those changes + var sourceContent = await File.ReadAllTextAsync(sourceFile); + var destContent = await File.ReadAllTextAsync(destFile); + Assert.Equal("updated content", sourceContent); + Assert.Equal("updated content", destContent); + } + + [Fact] + public async Task HardlinkFileAsync_CreatesDestinationDirectory_WhenMissing() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + var destDir = Path.Join(_root, "subdir"); + var destFile = Path.Join(destDir, "dest.mp3"); + await File.WriteAllTextAsync(sourceFile, "audio content"); + + Assert.False(Directory.Exists(destDir), "Destination directory should not exist initially"); + + // Act + var result = await _mover.PerformActionOn(FileAction.HardlinkCopy, sourceFile, destFile); + + // Assert + Assert.True(result, "HardlinkFileAsync should succeed"); + Assert.True(Directory.Exists(destDir), "Destination directory should be created"); + Assert.True(File.Exists(destFile), "Destination file should exist"); + } + + [Fact] + public async Task HardlinkFileAsync_OverwritesDestination_WhenDestinationExists() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + var destFile = Path.Join(_root, "dest.mp3"); + await File.WriteAllTextAsync(sourceFile, "new content"); + await File.WriteAllTextAsync(destFile, "old content"); + + // Act + var result = await _mover.PerformActionOn(FileAction.HardlinkCopy, sourceFile, destFile); + + // Assert + Assert.True(result, "HardlinkFileAsync should succeed"); + var destContent = await File.ReadAllTextAsync(destFile); + Assert.Equal("new content", destContent); + } + + [Fact] + public async Task HardlinkFileAsync_FallbacksToCopy_WhenHardlinkFails() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + await File.WriteAllTextAsync(sourceFile, "content"); + + // Create a path that would cause hardlink to fail (different volume simulation via invalid path) + // On some systems, hardlink may fail for various reasons - we want to test fallback behavior + var destFile = Path.Join(_root, "dest.mp3"); + + // Act - even if hardlink fails internally, the method should fallback to copy + var result = await _mover.PerformActionOn(FileAction.HardlinkCopy, sourceFile, destFile); + + // Assert - should succeed via fallback + Assert.True(result, "HardlinkFileAsync should succeed via copy fallback"); + Assert.True(File.Exists(destFile), "Destination file should exist"); + } + + [Fact] + public async Task HardlinkFileAsync_ReturnsFalse_WhenSourceDoesNotExist() + { + // Arrange + var sourceFile = Path.Join(_root, "nonexistent.mp3"); + var destFile = Path.Join(_root, "dest.mp3"); + + // Act + var result = await _mover.PerformActionOn(FileAction.HardlinkCopy, sourceFile, destFile); + + // Assert + // Method gracefully returns false when source doesn't exist (exception is caught internally) + Assert.False(result, "HardlinkFileAsync should return false when source doesn't exist"); + Assert.False(File.Exists(destFile), "Destination file should not be created"); + } + + [Fact] + public async Task CopyFileAsync_CreatesIndependentCopy() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + var destFile = Path.Join(_root, "dest.mp3"); + await File.WriteAllTextAsync(sourceFile, "original content"); + + // Act + var result = await _mover.PerformActionOn(FileAction.Copy, sourceFile, destFile); + + // Assert + Assert.True(result, "CopyFileAsync should succeed"); + Assert.True(File.Exists(sourceFile), "Source should still exist"); + Assert.True(File.Exists(destFile), "Destination should exist"); + + // Modify destination to verify independence + await File.WriteAllTextAsync(destFile, "modified content"); + var sourceContent = await File.ReadAllTextAsync(sourceFile); + Assert.Equal("original content", sourceContent); + } + + [Fact] + public async Task MoveFileAsync_RemovesSource_AfterMove() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + var destFile = Path.Join(_root, "dest.mp3"); + await File.WriteAllTextAsync(sourceFile, "content"); + + // Act + var result = await _mover.PerformActionOn(FileAction.Move, sourceFile, destFile); + + // Assert + Assert.True(result, "MoveFileAsync should succeed"); + Assert.False(File.Exists(sourceFile), "Source should be removed"); + Assert.True(File.Exists(destFile), "Destination should exist"); + } + + [Fact] + public async Task HardlinkFileAsync_PreservesFileSize() + { + // Arrange + var sourceFile = Path.Join(_root, "source.mp3"); + var largeContent = new string('x', 10000); + await File.WriteAllTextAsync(sourceFile, largeContent); + var sourceInfo = new FileInfo(sourceFile); + + // Act + var destFile = Path.Join(_root, "dest.mp3"); + await _mover.PerformActionOn(FileAction.HardlinkCopy, sourceFile, destFile); + + // Assert + var destInfo = new FileInfo(destFile); + Assert.Equal(sourceInfo.Length, destInfo.Length); + } + + [Fact] + public async Task CopyDir_NoInfiniteLoop() + { + // Arrange + var sourceDirectory = FileService.GetTempDirectory("source"); + var sourceSubDirectory = FileService.GetTempDirectory(FileUtils.GetAbsolutePath("source", "test")); + + await FileService.GetFileAsync(sourceDirectory, "source.mp3"); + await FileService.GetFileAsync(sourceSubDirectory, "subsource.mp3"); + + var destinationDirectory = FileService.GetTempDirectory(FileUtils.GetAbsolutePath("source", "test", "destination")); + + // Act + try + { + ((FileMover)_mover).CopyDirRecursive(sourceDirectory, destinationDirectory); + } + catch (Exception exception) + { + Assert.Fail(exception.Message); + } + + // Assert + var files = Directory.GetFiles(sourceDirectory, "*.*", SearchOption.AllDirectories); + Assert.Equal(3, files.Length); + } + } +}