From d78c8dc553ad1adfb8e0f2aa263597f240511a9f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 28 Jul 2026 15:09:17 +0000 Subject: [PATCH 1/7] fix(import): resolve canonical folder beneath configured root --- .../Downloads/Import/DownloadImportService.cs | 32 ++++- .../Import/DownloadImportServiceTests.cs | 118 ++++++++++++++++++ ...dProcessingJobProcessorIntegrationTests.cs | 9 +- 3 files changed, 151 insertions(+), 8 deletions(-) diff --git a/listenarr.application/Downloads/Import/DownloadImportService.cs b/listenarr.application/Downloads/Import/DownloadImportService.cs index d6cc98272..41fb6f14e 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.cs @@ -27,6 +27,7 @@ public class DownloadImportService( IAudiobookFileService audiobookFileService, IArchiveExtractor archiveExtractor, IConfigurationService configurationService, + IRootFolderRepository rootFolderRepository, ImportDestinationPlanner destinationPlanner, ArchiveImportExtractor archiveImportExtractor, ILogger logger) : IDownloadImportService @@ -43,6 +44,11 @@ public async Task> ImportDownloadFilesAsync( } var settings = await configurationService.GetApplicationSettingsAsync(); + var configuredRootPaths = (await rootFolderRepository.GetAllAsync()) + .Where(root => !string.IsNullOrWhiteSpace(root.Path)) + .Select(root => root.Path) + .Append(settings.OutputPath) + .ToList(); try { @@ -85,6 +91,7 @@ public async Task> ImportDownloadFilesAsync( var isMultiFileBatch = plannedAudioFiles.Count > 1; var sourceRootPath = FileUtils.GetCommonDirectory(sourceFiles); var usedDestinations = new HashSet(StringComparer.OrdinalIgnoreCase); + string? resolvedBatchDestinationDirectory = null; // Order audio files before companion files var orderedFiles = plannedAudioFiles.Select(p => p.FullPath) @@ -146,9 +153,10 @@ public async Task> ImportDownloadFilesAsync( ? Path.GetRelativePath(sourceRootPath, file) : Path.GetFileName(file); - if (!destinationPlanner.TryResolve(audiobook.BasePath, relativePath, out var destination)) + var companionBasePath = resolvedBatchDestinationDirectory ?? audiobook.BasePath; + if (!destinationPlanner.TryResolve(companionBasePath, relativePath, out var destination)) { - results.Add(ImportResult.ImportFailure(completedFileAction, file, audiobook.BasePath)); + results.Add(ImportResult.ImportFailure(completedFileAction, file, companionBasePath)); logger.LogWarning( "Blocked companion import outside audiobook base path. Audiobook {AudiobookId}, Source {Source}, Relative {Relative}, BasePath {BasePath}", audiobook.Id, @@ -205,7 +213,7 @@ public async Task> ImportDownloadFilesAsync( } // Determine destination directory (prefer audiobook basepath) - string destDirForFile = audiobook.BasePath; + string destDirForFile = resolvedBatchDestinationDirectory ?? audiobook.BasePath; // Build naming metadata: prefer audiobook metadata when available, otherwise use extracted candidate metadata var namingMetadata = BuildNamingMetadata(audiobook, candidateMetadata, Path.GetFileNameWithoutExtension(file)); @@ -238,7 +246,9 @@ public async Task> ImportDownloadFilesAsync( }; var folderRelative = fileNamingService.ApplyNamingPattern(folderPattern, variablesForFile, treatAsFilename: false); - if (string.IsNullOrEmpty(audiobook.BasePath) && !string.IsNullOrWhiteSpace(folderRelative)) + if (resolvedBatchDestinationDirectory == null + && configuredRootPaths.Any(rootPath => PathsEqual(audiobook.BasePath, rootPath)) + && !string.IsNullOrWhiteSpace(folderRelative)) { if (!destinationPlanner.TryResolve(destDirForFile, folderRelative, out destDirForFile)) { @@ -252,6 +262,7 @@ public async Task> ImportDownloadFilesAsync( continue; } } + resolvedBatchDestinationDirectory ??= destDirForFile; var baseFilePattern = isMultiFileBatch ? settings.MultiFileNamingPattern : settings.FileNamingPattern; @@ -452,6 +463,19 @@ private static string NonNarratorAuthorCandidate(string? candidate, string? narr return trimmedCandidate; } + private static bool PathsEqual(string? left, string? right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + return string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + private static string FirstNonEmpty(params string?[] candidates) { foreach (var candidate in candidates.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) diff --git a/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs b/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs index a2f88a315..898b96ef0 100644 --- a/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs +++ b/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs @@ -102,6 +102,124 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() Assert.Empty(filepaths.FindAll(path => path.Contains("unknown author", StringComparison.OrdinalIgnoreCase))); } + [Fact] + public async Task SlskdImport_WhenAuthorFolderDoesNotExist_UsesCanonicalAuthorTitleFolder() + { + var libraryRoot = FileService.GetTempDirectory("slskd-missing-author-library"); + var sourceRoot = FileService.GetTempDirectory("slskd-missing-author-stage"); + var sourceFile = await FileService.GetFileAsync(sourceRoot, "remote-release.mp3"); + + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Missing Author Folder Book") + .WithAuthor("New Author") + .WithBasePath(libraryRoot) + .Build()); + + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(libraryRoot) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("{Author}/{Title}") + .WithFileNamingPattern("{Title}") + .WithMultiFileNamingPattern("{Title}") + .Build()); + + var service = _provider.GetRequiredService(); + var results = await service.ImportDownloadFilesAsync(audiobook, [sourceFile]); + + var expected = Path.Join(libraryRoot, "New Author", "Missing Author Folder Book", "Missing Author Folder Book.mp3"); + Assert.All(results, result => Assert.True(result.Success, result.Message)); + Assert.True(File.Exists(expected)); + Assert.False(File.Exists(Path.Join(libraryRoot, "Missing Author Folder Book.mp3"))); + Assert.False(File.Exists(sourceFile)); + Assert.Single(await _audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id), file => file.Path == expected); + } + + [Fact] + public async Task Import_ConfiguredRootFolder_UsesCanonicalAuthorTitleFolder() + { + var legacyOutput = FileService.GetTempDirectory("legacy-output"); + var libraryRoot = FileService.GetTempDirectory("configured-library"); + var sourceFile = await FileService.GetTempFileAsync("configured-root.mp3"); + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Audiobooks") + .WithPath(libraryRoot) + .Build()); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Configured Root Book") + .WithAuthor("Root Author") + .WithBasePath(libraryRoot) + .Build()); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(legacyOutput) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("{Author}/{Title}") + .WithFileNamingPattern("{Title}") + .Build()); + + var results = await _provider.GetRequiredService() + .ImportDownloadFilesAsync(audiobook, [sourceFile]); + + var expected = Path.Join(libraryRoot, "Root Author", "Configured Root Book", "Configured Root Book.mp3"); + Assert.All(results, result => Assert.True(result.Success, result.Message)); + Assert.True(File.Exists(expected)); + } + + [Fact] + public async Task Import_RootWithMissingAuthor_UsesUnknownAuthorAndSanitizedTitle() + { + var libraryRoot = FileService.GetTempDirectory("unknown-author-library"); + var sourceFile = await FileService.GetTempFileAsync("unknown-author.mp3"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Unsafe: Title") + .WithBasePath(libraryRoot) + .Build()); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(libraryRoot) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("{Author}/{Title}") + .WithFileNamingPattern("{Title}") + .Build()); + + var results = await _provider.GetRequiredService() + .ImportDownloadFilesAsync(audiobook, [sourceFile]); + + var expected = Path.Join(libraryRoot, "Unknown Author", "Unsafe - Title", "Unsafe - Title.mp3"); + Assert.All(results, result => Assert.True(result.Success, result.Message)); + Assert.True(File.Exists(expected)); + } + + [Fact] + public async Task Import_MultipleChaptersAtRoot_SharesCanonicalDirectory() + { + var libraryRoot = FileService.GetTempDirectory("chapter-root-library"); + var sourceRoot = FileService.GetTempDirectory("chapter-root-stage"); + var chapter1 = await FileService.GetFileAsync(sourceRoot, "Chapter 1.mp3"); + var chapter2 = await FileService.GetFileAsync(sourceRoot, "Chapter 2.mp3"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Chapter Book") + .WithAuthor("Chapter Author") + .WithBasePath(libraryRoot) + .Build()); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(libraryRoot) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("{Author}/{Title}") + .WithMultiFileNamingPattern("{Title}-{ChapterNumber:00}") + .Build()); + + var results = await _provider.GetRequiredService() + .ImportDownloadFilesAsync(audiobook, [chapter1, chapter2]); + + var expectedDirectory = Path.Join(libraryRoot, "Chapter Author", "Chapter Book"); + Assert.All(results, result => Assert.True(result.Success, result.Message)); + Assert.Single(results.Select(result => Path.GetDirectoryName(result.FinalPath)).Distinct()); + Assert.All(results, result => Assert.Equal(expectedDirectory, Path.GetDirectoryName(result.FinalPath))); + } + [Fact] public async Task Import_WithMove() { diff --git a/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorIntegrationTests.cs b/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorIntegrationTests.cs index ba609fe42..9f7e06341 100644 --- a/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorIntegrationTests.cs +++ b/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorIntegrationTests.cs @@ -128,10 +128,11 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() await downloadProcessingJobProcessor.ProcessQueueAsync(CancellationToken.None); - Assert.True(File.Exists(Path.Join(destRoot, "book.m4b"))); - Assert.True(File.Exists(Path.Join(destRoot, "cover.jpg"))); - Assert.True(File.Exists(Path.Join(destRoot, "book.txt"))); - Assert.False(File.Exists(Path.Join(destRoot, "unrelated.txt"))); + var canonicalDirectory = Path.Join(destRoot, "Unknown Author", "book"); + Assert.True(File.Exists(Path.Join(canonicalDirectory, "book.m4b"))); + Assert.True(File.Exists(Path.Join(canonicalDirectory, "cover.jpg"))); + Assert.True(File.Exists(Path.Join(canonicalDirectory, "book.txt"))); + Assert.False(File.Exists(Path.Join(canonicalDirectory, "unrelated.txt"))); } } } From 173e0aa868c1f2e38976301dd98f329ce7ea68a9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 28 Jul 2026 15:09:40 +0000 Subject: [PATCH 2/7] feat(download-clients): add native slskd contracts and configuration --- .../download/DownloadClientFormModal.vue | 48 +++++++++++++++++-- fe/src/types/index.ts | 6 ++- .../Contracts/ISlskdDownloadService.cs | 16 +++++++ .../Common/DownloadClientTypes.cs | 1 + 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 listenarr.application/Downloads/Contracts/ISlskdDownloadService.cs diff --git a/fe/src/components/domain/download/DownloadClientFormModal.vue b/fe/src/components/domain/download/DownloadClientFormModal.vue index baa7df91a..f1526f334 100644 --- a/fe/src/components/domain/download/DownloadClientFormModal.vue +++ b/fe/src/components/domain/download/DownloadClientFormModal.vue @@ -67,6 +67,7 @@ + @@ -208,6 +209,27 @@ +
+ + + Lower numbers are preferred for normal audiobook downloads. +
+
+ + Default download client + Prefer this client ahead of priority ordering. + +
+
+ + Allow torrent/NZB fallback + Off by default; native Slskd failures will not silently grab a torrent. + +
+
+ + +