diff --git a/README.md b/README.md index 0811c7c1f..54b2a38b3 100644 --- a/README.md +++ b/README.md @@ -382,12 +382,15 @@ Docker users: the provided `docker-compose.yml` includes an example `LISTENARR_L ### Download Clients +Native Slskd (Soulseek) setup, portable path mapping, and troubleshooting are documented in [docs/download-clients/slskd.md](docs/download-clients/slskd.md). A reusable deployment example is available at [docker-compose.slskd.example.yml](docker-compose.slskd.example.yml). + Supported download clients: - **qBittorrent** - Popular torrent client with web UI - **Transmission** - Cross-platform torrent client - **SABnzbd** - Usenet downloader - **NZBGet** - Efficient usenet client +- **Slskd** - Native Soulseek search, batch monitoring, and safe staged import ### Application Settings diff --git a/docker-compose.slskd.example.yml b/docker-compose.slskd.example.yml new file mode 100644 index 000000000..07d9963dc --- /dev/null +++ b/docker-compose.slskd.example.yml @@ -0,0 +1,28 @@ +services: + listenarr: + image: ghcr.io/listenarrs/listenarr:canary + ports: + - "4545:4545" + volumes: + - ./data/listenarr:/config + - ./data/library:/audiobooks + - ./data/slskd/complete:/slskd-downloads + depends_on: + - slskd + + slskd: + image: slskd/slskd:latest + ports: + - "5030:5030" + environment: + SLSKD_REMOTE_CONFIGURATION: "true" + volumes: + - ./data/slskd/app:/app + - ./data/slskd/complete:/downloads/complete + - ./data/slskd/incomplete:/downloads/incomplete + +# Configure slskd's completed-download directory as /downloads/complete. +# Listenarr submits isolated destinations under listenarr/ and +# sees the same files beneath /slskd-downloads/listenarr/. +# Supply credentials/API keys through your secret-management mechanism rather +# than committing them to this file. diff --git a/docs/download-clients/slskd.md b/docs/download-clients/slskd.md new file mode 100644 index 000000000..1e1861e56 --- /dev/null +++ b/docs/download-clients/slskd.md @@ -0,0 +1,58 @@ +# Native Slskd download client + +Listenarr can submit audiobook searches directly to [slskd](https://github.com/slskd/slskd), monitor the returned batch, import every successful audio transfer into the canonical library directory, and remove only the verified isolated staging batch after import. + +## Path contract + +Use an isolated completion destination outside every Listenarr library root: + +| Namespace | Example | +| --- | --- | +| slskd batch destination | `listenarr/` | +| slskd completion root | `/downloads/complete` | +| Listenarr-visible completion root | `/slskd-downloads` | + +The value entered as **Listenarr source root** must be an absolute path in the Listenarr runtime namespace. `/slskd-downloads` is the portable default, not a required host path. Listenarr rejects relative roots and validates reconstructed files beneath the configured root and isolated batch destination. + +## Docker Compose + +See [`docker-compose.slskd.example.yml`](../../docker-compose.slskd.example.yml). Create `./data/slskd/complete` on the host and mount it read/write into both containers under their respective namespaces. Do not mount this directory inside a library directory. + +On Linux, the example relative bind paths work directly. On Windows Docker Desktop, replace `./data/slskd/complete` with a generic Docker Desktop shared path such as `D:/containers/listenarr/slskd-complete`; keep the container destinations unchanged. + +For native Listenarr, set **Listenarr source root** to the absolute local directory where the same slskd completion files are visible. If slskd and Listenarr run on separate hosts, use a shared filesystem and point each application at its own absolute mount path. + +## Configure + +1. Create an API key in slskd and keep it in an environment secret or secret manager. +2. In Listenarr, open **Settings → Download Clients → Add → Slskd**. +3. Set the slskd host, port, TLS option, and API key. +4. Set **Listenarr source root** (default `/slskd-downloads`). +5. Choose priority and optionally mark the client as default. +6. Leave protocol fallback disabled unless torrent/Usenet fallback is explicitly desired. +7. Test the connection, then save. + +API keys are carried in the standard secret settings dictionary and are redacted by Listenarr's existing API response redactor. Avoid putting keys in Compose files or logs. Non-loopback deployments should expose slskd through HTTPS. + +## Semantics + +- The configured default enabled client wins; otherwise lower priority wins, with creation time as a stable tie-break. +- Native Slskd routing runs before torrent/NZB indexer search. +- One durable Listenarr download is reserved before external HTTP effects; active or imported duplicates are rejected and never fall back. +- A batch completes only when every non-removed transfer reports both `Completed` and `Succeeded`. +- Multi-chapter transfers remain one batch and import into one canonical author/title directory. +- Missing author metadata imports beneath `Unknown Author/`. +- Cleanup runs only after successful import. Partial, failed, unsafe, or unverifiable staging is preserved. + +## Troubleshooting + +- **401/403:** recreate or re-enter the slskd API key and test again. +- **429:** Slskd is rate limiting. Listenarr honors retry timing within its bounded search deadline; retry later if it expires. +- **Search timeout/no safe result:** verify Soulseek connectivity and query metadata. Only safe audio filenames from one coherent response are selected. +- **Queued remotely:** confirm the batch UUID is visible in both slskd and Listenarr activity. +- **Unsafe path:** ensure the source root is absolute and the shared completion mount matches it. Do not use `..`, drive-qualified remote filenames, or a library root as staging. +- **Import/finalization preserved staging:** inspect failed transfers and unexpected files. Listenarr deliberately refuses broad deletion. + +Before upgrading, back up Listenarr's database/config and slskd configuration. To roll back, stop both services, restore those backups, and use the previous image digest; preserve staging until imports are reconciled. + +Automated tests use deterministic HTTP fixtures. CI and normal tests do not contact public Soulseek peers. 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 @@ <option value="transmission">Transmission</option> <option value="sabnzbd">SABnzbd</option> <option value="nzbget">NZBGet</option> + <option value="slskd">Slskd (Soulseek)</option> </select> </div> @@ -208,6 +209,27 @@ <!-- Priority --> <FormSection title="Priority" :icon="PhSortAscending"> + <div class="form-group"> + <label for="clientPriority">Client Priority</label> + <input id="clientPriority" v-model.number="formData.priority" type="number" min="0" /> + <small>Lower numbers are preferred for normal audiobook downloads.</small> + </div> + <div class="checkbox-group"> + <Checkbox v-model="formData.isDefault"> + <strong>Default download client</strong> + <small>Prefer this client ahead of priority ordering.</small> + </Checkbox> + </div> + <div class="checkbox-group" v-if="formData.type === 'slskd'"> + <Checkbox v-model="formData.allowProtocolFallback"> + <strong>Allow torrent/NZB fallback</strong> + <small>Off by default; native Slskd failures will not silently grab a torrent.</small> + </Checkbox> + </div> + <div class="form-group" v-if="formData.type === 'slskd'"> + <label for="listenarrSourceRoot">Listenarr-visible source root *</label> + <input id="listenarrSourceRoot" v-model="formData.listenarrSourceRoot" type="text" required placeholder="/slskd-downloads" /> + </div> <div class="form-group"> <label for="recentPriority">Recent Priority</label> <select id="recentPriority" v-model="formData.recentPriority"> @@ -404,7 +426,7 @@ const testing = ref(false) const defaultFormData = { name: '', - type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget', + type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget' | 'slskd', host: '', port: 8080, username: '', @@ -427,6 +449,10 @@ const defaultFormData = { urlBase: '', settings: {}, remotePathMappingIds: [] as number[], + priority: 50, + isDefault: false, + allowProtocolFallback: false, + listenarrSourceRoot: '/slskd-downloads', } const formData = ref({ ...defaultFormData }) @@ -462,7 +488,7 @@ const requiresAuth = computed(() => { }) const requiresApiKey = computed(() => { - return formData.value.type === 'sabnzbd' + return formData.value.type === 'sabnzbd' || formData.value.type === 'slskd' }) const getHostPlaceholder = () => { @@ -513,7 +539,7 @@ const onTypeChange = () => { } formData.value.port = defaultPorts[formData.value.type] || 8080 - if (formData.value.type === 'sabnzbd') { + if (formData.value.type === 'sabnzbd' || formData.value.type === 'slskd') { formData.value.username = '' formData.value.password = '' } else { @@ -556,6 +582,10 @@ watch( settings: newClient.settings || {}, remotePathMappingIds: settings && settings.remotePathMappingIds ? settings.remotePathMappingIds : [], + priority: Number(settings?.priority ?? 50), + isDefault: Boolean(settings?.isDefault ?? false), + allowProtocolFallback: Boolean(settings?.allowProtocolFallback ?? false), + listenarrSourceRoot: (settings?.listenarrSourceRoot as string) || '/slskd-downloads', } // Load available mappings when editing a client so the dropdown can show options void loadRemotePathMappings() @@ -590,7 +620,7 @@ const testConnection = async () => { isEnabled: formData.value.isEnabled, removeCompletedDownloads: formData.value.removeCompletedDownloads, settings: { - ...(formData.value.type === 'sabnzbd' && formData.value.apiKey + ...((formData.value.type === 'sabnzbd' || formData.value.type === 'slskd') && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), ...(formData.value.type === 'transmission' && formData.value.urlBase @@ -606,6 +636,10 @@ const testConnection = async () => { sequentialOrder: formData.value.sequentialOrder, firstAndLastFirst: formData.value.firstAndLastFirst, contentLayout: formData.value.contentLayout, + priority: formData.value.priority, + isDefault: formData.value.isDefault, + allowProtocolFallback: formData.value.allowProtocolFallback, + ...(formData.value.type === 'slskd' ? { listenarrSourceRoot: formData.value.listenarrSourceRoot } : {}), ...(formData.value.remotePathMappingIds && formData.value.remotePathMappingIds.length > 0 ? { remotePathMappingIds: formData.value.remotePathMappingIds } : {}), @@ -650,7 +684,7 @@ const handleSubmit = async () => { isEnabled: formData.value.isEnabled, removeCompletedDownloads: formData.value.removeCompletedDownloads, settings: { - ...(formData.value.type === 'sabnzbd' && formData.value.apiKey + ...((formData.value.type === 'sabnzbd' || formData.value.type === 'slskd') && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), ...(formData.value.type === 'transmission' && formData.value.urlBase @@ -666,6 +700,10 @@ const handleSubmit = async () => { sequentialOrder: formData.value.sequentialOrder, firstAndLastFirst: formData.value.firstAndLastFirst, contentLayout: formData.value.contentLayout, + priority: formData.value.priority, + isDefault: formData.value.isDefault, + allowProtocolFallback: formData.value.allowProtocolFallback, + ...(formData.value.type === 'slskd' ? { listenarrSourceRoot: formData.value.listenarrSourceRoot } : {}), ...(formData.value.remotePathMappingIds && formData.value.remotePathMappingIds.length > 0 ? { remotePathMappingIds: formData.value.remotePathMappingIds } : {}), diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 596251121..a19284427 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -241,7 +241,7 @@ export interface ApiConfiguration { export interface DownloadClientConfiguration { id: string name: string - type: 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget' + type: 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget' | 'slskd' host: string port: number username: string @@ -268,6 +268,10 @@ export interface DownloadClientSettings { initialState?: string sequentialOrder?: boolean firstAndLastFirst?: boolean + priority?: number + isDefault?: boolean + allowProtocolFallback?: boolean + listenarrSourceRoot?: string contentLayout?: string // Optional mapping to one or more remote path mapping IDs remotePathMappingIds?: number[] diff --git a/listenarr.api/Features/Downloads/DownloadController.cs b/listenarr.api/Features/Downloads/DownloadController.cs index 78766c74e..1e25e6009 100644 --- a/listenarr.api/Features/Downloads/DownloadController.cs +++ b/listenarr.api/Features/Downloads/DownloadController.cs @@ -32,6 +32,7 @@ public class DownloadController : ControllerBase private readonly ILogger<DownloadController> _logger; private readonly IDownloadReferenceService? _downloadReferenceService; + public DownloadController( IDownloadService downloadService, IDownloadQueueService downloadQueueService, @@ -44,6 +45,7 @@ public DownloadController( _downloadProcessingJobService = downloadProcessingJobService; _logger = logger; _downloadReferenceService = downloadReferenceService; + } /// <summary> @@ -65,6 +67,7 @@ public async Task<ActionResult<SearchAndDownloadResult>> SearchAndDownload([From } } + /// <summary> /// Send a specific search result to a download client (torrent or NZB). /// </summary> @@ -338,6 +341,15 @@ public class SearchAndDownloadRequest public int AudiobookId { get; set; } } + public class SlskdDownloadRequest + { + public int AudiobookId { get; set; } + public string DownloadClientId { get; set; } = string.Empty; + public string SearchQuery { get; set; } = string.Empty; + public string? Title { get; set; } + public string? Author { get; set; } + } + public class SendDownloadRequest { public string DownloadReference { get; set; } = string.Empty; diff --git a/listenarr.application/Common/DuplicateDownloadSubmissionException.cs b/listenarr.application/Common/DuplicateDownloadSubmissionException.cs new file mode 100644 index 000000000..e60c67b10 --- /dev/null +++ b/listenarr.application/Common/DuplicateDownloadSubmissionException.cs @@ -0,0 +1,24 @@ +/* + * 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. + */ + +namespace Listenarr.Application.Common +{ + /// <summary> + /// Represents an expected rejection because the audiobook already has an active or imported download. + /// Callers must not retry through another protocol because the submission guard runs before side effects. + /// </summary> + public sealed class DuplicateDownloadSubmissionException : Exception + { + public DuplicateDownloadSubmissionException(string message, Exception? innerException = null) + : base(message, innerException) + { + } + } +} diff --git a/listenarr.application/Downloads/Contracts/ISlskdDownloadService.cs b/listenarr.application/Downloads/Contracts/ISlskdDownloadService.cs new file mode 100644 index 000000000..b7cb81b93 --- /dev/null +++ b/listenarr.application/Downloads/Contracts/ISlskdDownloadService.cs @@ -0,0 +1,16 @@ +namespace Listenarr.Application.Downloads.Contracts; + +/// <summary> +/// Executes the native slskd search, exact-file batch submission, and batch-status poll. +/// This is intentionally separate from torrent/NZB submission contracts. +/// </summary> +public interface ISlskdDownloadService +{ + Task<SlskdSubmissionResult> SearchSubmitAndPollAsync( + DownloadClientConfiguration client, + SlskdSubmissionRequest request, + CancellationToken ct = default); +} + +public sealed record SlskdSubmissionRequest(int AudiobookId, string SearchQuery, string? Title = null, string? Author = null); +public sealed record SlskdSubmissionResult(string BatchId, string Destination, IReadOnlyList<string> CompletedFiles, string? State); 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<DownloadImportService> logger) : IDownloadImportService @@ -43,6 +44,11 @@ public async Task<List<ImportResult>> 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<List<ImportResult>> ImportDownloadFilesAsync( var isMultiFileBatch = plannedAudioFiles.Count > 1; var sourceRootPath = FileUtils.GetCommonDirectory(sourceFiles); var usedDestinations = new HashSet<string>(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<List<ImportResult>> 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<List<ImportResult>> 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<List<ImportResult>> 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<List<ImportResult>> 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/listenarr.application/Downloads/Submission/DownloadService.cs b/listenarr.application/Downloads/Submission/DownloadService.cs index 5746081d0..31d8b9fa7 100644 --- a/listenarr.application/Downloads/Submission/DownloadService.cs +++ b/listenarr.application/Downloads/Submission/DownloadService.cs @@ -37,7 +37,8 @@ public class DownloadService( DownloadCachedTorrentStore cachedTorrentStore, IDownloadSubmissionPreparer submissionPreparer, DirectDownloadWorkflow directDownloadWorkflow, - DownloadRemovalWorkflow downloadRemovalWorkflow) : IDownloadService + DownloadRemovalWorkflow downloadRemovalWorkflow, + NativeSlskdDownloadRouter nativeSlskdDownloadRouter) : IDownloadService { // Cache expiration constants private const int QueueCacheExpirationSeconds = 10; @@ -130,6 +131,12 @@ public async Task<SearchAndDownloadResult> SearchAndDownloadAsync(int audiobookI }; } + var nativeSlskdResult = await nativeSlskdDownloadRouter.TryRouteAsync(audiobook); + if (nativeSlskdResult is not null) + { + return nativeSlskdResult; + } + if (audiobook.QualityProfile == null) { logger.LogWarning("Audiobook '{Title}' has no quality profile assigned", audiobook.Title); @@ -225,6 +232,7 @@ public async Task<SearchAndDownloadResult> SearchAndDownloadAsync(int audiobookI }; } + public async Task<string> SendToDownloadClientAsync(SearchResult searchResult, string? downloadClientId = null, int? audiobookId = null) { return await SendToDownloadClientAsync( @@ -431,12 +439,6 @@ public async Task<bool> RemoveFromQueueAsync(string downloadId, string? download { return await downloadRemovalWorkflow.RemoveAsync(downloadId, downloadClientId, force); } - - // - // Helper stubs added to satisfy callers while refactor completes. - // These are conservative, safe no-op / simple implementations. - // - private static SearchResult ToSearchResult( TrustedDownloadCandidate candidate, PreparedDownloadSubmission prepared) diff --git a/listenarr.application/Downloads/Submission/NativeSlskdDownloadRouter.cs b/listenarr.application/Downloads/Submission/NativeSlskdDownloadRouter.cs new file mode 100644 index 000000000..fd1865015 --- /dev/null +++ b/listenarr.application/Downloads/Submission/NativeSlskdDownloadRouter.cs @@ -0,0 +1,106 @@ +/* + * 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. + */ +using Listenarr.Application.Common; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads.Submission; + +/// <summary> +/// Selects and submits audiobook-level downloads to the native Slskd workflow. +/// A null result tells the caller to continue with torrent/NZB search. +/// </summary> +public sealed class NativeSlskdDownloadRouter( + IConfigurationService configurationService, + ISlskdDownloadService slskdDownloadService, + ILogger<NativeSlskdDownloadRouter> logger) +{ + public async Task<SearchAndDownloadResult?> TryRouteAsync(Audiobook audiobook, CancellationToken cancellationToken = default) + { + var clients = await configurationService.GetDownloadClientConfigurationsAsync(); + var slskdClient = SelectClient(clients); + if (slskdClient is null) + { + return null; + } + + try + { + var native = await slskdDownloadService.SearchSubmitAndPollAsync( + slskdClient, + new SlskdSubmissionRequest( + audiobook.Id, + DownloadSearchQueryBuilder.Build(audiobook), + audiobook.Title, + audiobook.Authors?.FirstOrDefault()), + cancellationToken); + + return new SearchAndDownloadResult + { + Success = true, + Message = "Successfully submitted native Slskd audiobook download", + DownloadId = native.BatchId, + IndexerUsed = "Slskd", + DownloadClientUsed = slskdClient.Id + }; + } + catch (DuplicateDownloadSubmissionException ex) + { + logger.LogInformation( + "Native Slskd submission rejected as a duplicate for audiobook {AudiobookId}", + audiobook.Id); + return new SearchAndDownloadResult + { + Success = false, + Message = ex.Message, + IndexerUsed = "Slskd", + DownloadClientUsed = slskdClient.Id + }; + } + catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException or StackOverflowException) + && GetBooleanSetting(slskdClient, "allowProtocolFallback")) + { + logger.LogWarning( + ex, + "Native Slskd submission failed; explicit protocol fallback is enabled for {ClientId}", + slskdClient.Id); + return null; + } + } + + internal static DownloadClientConfiguration? SelectClient(IEnumerable<DownloadClientConfiguration> clients) + { + var first = clients + .Where(client => client.IsEnabled) + .OrderByDescending(client => GetBooleanSetting(client, "isDefault")) + .ThenBy(client => GetIntegerSetting(client, "priority", 50)) + .ThenBy(client => client.CreatedAt) + .FirstOrDefault(); + + return first is not null && string.Equals(first.Type, "slskd", StringComparison.OrdinalIgnoreCase) + ? first + : null; + } + + private static bool GetBooleanSetting(DownloadClientConfiguration client, string key) + { + if (!client.Settings.TryGetValue(key, out var value) || value is null) return false; + return value is System.Text.Json.JsonElement element + ? element.ValueKind == System.Text.Json.JsonValueKind.True || + (element.ValueKind == System.Text.Json.JsonValueKind.String && + bool.TryParse(element.GetString(), out var parsedElement) && parsedElement) + : bool.TryParse(value.ToString(), out var parsedValue) && parsedValue; + } + + private static int GetIntegerSetting(DownloadClientConfiguration client, string key, int fallback) + { + if (!client.Settings.TryGetValue(key, out var value) || value is null) return fallback; + return int.TryParse(value.ToString(), out var parsed) ? parsed : fallback; + } +} diff --git a/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs index eb931dfe7..e81ebff9a 100644 --- a/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs @@ -9,6 +9,7 @@ */ using System.Net; using Listenarr.Infrastructure.Factories; +using Listenarr.Infrastructure.DownloadClients.Slskd; using Listenarr.Infrastructure.Torrents; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -39,6 +40,12 @@ public static IServiceCollection AddDownloadClientHttpClients(this IServiceColle AddAdapterClient(services, DownloadClientTypes.Transmission, useCookies: false, retryPolicy, circuitBreakerPolicy); AddAdapterClient(services, DownloadClientTypes.Sabnzbd, useCookies: false, retryPolicy, circuitBreakerPolicy); AddAdapterClient(services, DownloadClientTypes.Nzbget, useCookies: false, retryPolicy, circuitBreakerPolicy); + // Slskd performs non-idempotent search and batch POSTs. Retrying inside the + // handler can duplicate an accepted batch when its response is lost. + services.AddHttpClient(DownloadClientTypes.Slskd) + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30)) + .ConfigurePrimaryHttpMessageHandler(CreateHandler) + .AddPolicyHandler(circuitBreakerPolicy); return services; } @@ -51,6 +58,7 @@ public static IServiceCollection AddDownloadClientAdapters( .ValidateOnStart(); services.AddSingleton<IValidateOptions<DownloadClientsOptions>, DownloadClientsOptionsValidator>(); services.TryAddSingleton(TimeProvider.System); + services.AddScoped<ISlskdDownloadService, SlskdDownloadService>(); services.AddScoped<INzbUrlResolver, NzbUrlResolver>(); services.AddScoped<ITorrentFileDownloader, TorrentFileDownloader>(); @@ -90,6 +98,10 @@ public static IServiceCollection AddDownloadClientAdapters( sp.GetRequiredService<NzbgetHistoryFetchWorkflow>(), sp.GetRequiredService<NzbgetItemFetchWorkflow>(), sp.GetRequiredService<NzbgetImportItemResolver>())); + services.AddScoped<IDownloadClientAdapter>(sp => new SlskdAdapter( + sp.GetRequiredService<IHttpClientFactory>(), + sp.GetRequiredService<ILogger<SlskdAdapter>>(), + sp.GetRequiredService<IFileSystem>())); services.AddScoped<IDownloadClientAdapterFactory, DownloadClientAdapterFactory>(); services.AddScoped<IDownloadItemService, DownloadItemService>(); return services; diff --git a/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs index 61de88d60..b20ee0465 100644 --- a/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs @@ -61,6 +61,7 @@ public static IServiceCollection AddDownloadServices( services.AddScoped<DownloadTypeResolver>(); services.AddScoped<DownloadClientSelector>(); services.AddScoped<DownloadCachedTorrentStore>(); + services.AddScoped<NativeSlskdDownloadRouter>(); services.AddSingleton<IDownloadReferenceService, DownloadReferenceService>(); services.AddScoped<DirectDownloadWorkflow>(); services.AddScoped<DownloadRemovalWorkflow>(); diff --git a/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs b/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs index 685f4598e..02458f315 100644 --- a/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs +++ b/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs @@ -16,5 +16,6 @@ internal static class DownloadClientTypes public const string Transmission = "transmission"; public const string Sabnzbd = "sabnzbd"; public const string Nzbget = "nzbget"; + public const string Slskd = "slskd"; } } diff --git a/listenarr.infrastructure/DownloadClients/Slskd/SlskdAdapter.cs b/listenarr.infrastructure/DownloadClients/Slskd/SlskdAdapter.cs new file mode 100644 index 000000000..6e7081970 --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Slskd/SlskdAdapter.cs @@ -0,0 +1,333 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Slskd; + +/// <summary> +/// Polling/import adapter for batches created by the native Slskd submission service. +/// It never submits generic torrent/NZB payloads and removes an isolated native batch +/// only after the framework has proved its canonical import succeeded. +/// </summary> +public sealed class SlskdAdapter : IDownloadClientAdapter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<SlskdAdapter> _logger; + private readonly IFileSystem _fileSystem; + + public SlskdAdapter(IHttpClientFactory httpClientFactory, ILogger<SlskdAdapter> logger, IFileSystem fileSystem) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _fileSystem = fileSystem; + } + + public string ClientType => DownloadClientTypes.Slskd; + public DownloadProtocol Protocol => DownloadProtocol.Unknown; + + public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) + { + try + { + using var http = CreateClient(client); + using var response = await http.GetAsync("/api/v0/application", ct); + return response.IsSuccessStatusCode + ? (true, "Connected to slskd") + : (false, $"slskd returned HTTP {(int)response.StatusCode}"); + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException) + { + _logger.LogWarning(ex, "Unable to test native slskd connection for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + return (false, "Unable to connect to slskd"); + } + } + + public Task<DownloadClientSubmissionResult> AddAsync(DownloadClientConfiguration client, PreparedDownloadSubmission submission, CancellationToken ct = default) + => throw new DownloadClientSubmissionException("Native slskd submissions must use the Listenarr Slskd search-and-download workflow."); + + public async Task<bool> RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) + { + try + { + using var http = CreateClient(client); + var batch = await GetBatchAsync(http, id, ct); + if (batch is null) return true; + + var transfers = (batch.Transfers ?? []).Where(transfer => !transfer.Removed).ToList(); + if (transfers.Any(transfer => string.IsNullOrWhiteSpace(transfer.Id) || string.IsNullOrWhiteSpace(transfer.Username))) + { + _logger.LogWarning("Unable to remove native slskd batch {BatchId}: one or more transfers have no id or username", LogRedaction.SanitizeText(id)); + return false; + } + + if (deleteFiles) + { + if (transfers.Count == 0 || transfers.Any(transfer => !IsSucceeded(transfer.State))) + { + _logger.LogWarning("Refusing destructive cleanup of native slskd batch {BatchId}: not every transfer succeeded", LogRedaction.SanitizeText(id)); + return false; + } + + var stageDirectory = GetStageDirectory(client, batch); + if (stageDirectory is null) + { + _logger.LogWarning("Refusing destructive cleanup of native slskd batch {BatchId}: the isolated stage path is unsafe", LogRedaction.SanitizeText(id)); + return false; + } + + if (_fileSystem.DirectoryExists(stageDirectory)) + { + var actualFiles = _fileSystem.EnumerateFiles(stageDirectory).ToList(); + var stageFiles = actualFiles.Count == 0 + ? actualFiles + : ResolveCompletedStageFiles(client, batch, transfers); + if (stageFiles is null) + { + _logger.LogWarning("Refusing destructive cleanup of native slskd batch {BatchId}: non-empty stage content could not be verified", LogRedaction.SanitizeText(id)); + return false; + } + + foreach (var file in stageFiles) + { + if (!_fileSystem.TryValidateMutationTarget(file, [stageDirectory], out var safeFile, out var reason)) + { + _logger.LogWarning("Refusing destructive cleanup of native slskd batch {BatchId}: {Reason}", LogRedaction.SanitizeText(id), LogRedaction.SanitizeText(reason)); + return false; + } + if (_fileSystem.FileExists(file)) _fileSystem.DeleteFile(file); + } + _fileSystem.DeleteEmptyDirectories(stageDirectory); + } + } + + // slskd exposes transfer removal rather than batch removal. Remove every transfer + // in this one batch; legitimate chapter files remain distinct operations. + foreach (var transfer in transfers) + { + var uri = $"/api/v0/transfers/downloads/{Uri.EscapeDataString(transfer.Username!)}/{Uri.EscapeDataString(transfer.Id!)}?remove=true"; + using var response = await http.DeleteAsync(uri, ct); + if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound) + { + _logger.LogWarning("Unable to remove transfer {TransferId} from native slskd batch {BatchId}: HTTP {StatusCode}", + LogRedaction.SanitizeText(transfer.Id), LogRedaction.SanitizeText(id), (int)response.StatusCode); + return false; + } + } + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException) + { + _logger.LogWarning(ex, "Unable to remove native slskd batch {BatchId}", LogRedaction.SanitizeText(id)); + return false; + } + } + + public async Task<List<QueueItem>> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + { + try + { + using var http = CreateClient(client); + using var response = await http.GetAsync("/api/v0/transfers/downloads", ct); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct)); + var batchIds = FindBatchIds(document.RootElement).Distinct(StringComparer.OrdinalIgnoreCase); + var items = new List<QueueItem>(); + foreach (var batchId in batchIds) + { + var batch = await GetBatchAsync(http, batchId, ct); + if (batch is not null) + { + items.Add(MapQueueItem(client, batch)); + } + } + + return items; + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException) + { + _logger.LogWarning(ex, "Unable to fetch native slskd queue snapshot for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + throw new DownloadClientAdapterPollingException("Error fetching native slskd queue snapshot.", ex); + } + } + + public async Task<List<QueueItem>> GetQueueAsync(DownloadClientConfiguration client, List<string> ids, CancellationToken ct = default) + { + if (ids.Count == 0) return []; + var items = new List<QueueItem>(); + try + { + using var http = CreateClient(client); + foreach (var id in ids.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + var batch = await GetBatchAsync(http, id, ct); + if (batch is not null) items.Add(MapQueueItem(client, batch)); + } + return items; + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException) + { + _logger.LogWarning(ex, "Unable to poll native slskd batch(es) for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + throw new DownloadClientAdapterPollingException("Error polling native slskd batch status.", ex); + } + } + + public Task<List<DownloadClientItem>> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) + => Task.FromResult(new List<DownloadClientItem>()); + + public async Task<QueueItem> GetImportItemAsync(DownloadClientConfiguration client, Download download, QueueItem queueItem, QueueItem? previousAttempt = null, CancellationToken ct = default) + { + var batchId = download.GetExternalId() ?? queueItem.Id; + if (string.IsNullOrWhiteSpace(batchId)) return queueItem; + using var http = CreateClient(client); + var batch = await GetBatchAsync(http, batchId, ct); + return batch is null ? queueItem : MapQueueItem(client, batch); + } + + public Task<bool> MarkItemAsImportedAsync(DownloadClientConfiguration client, string id, CancellationToken ct = default) + // Called by the framework only after every canonical import result succeeded. + // This client destination is staging, not user-retained download data. + => RemoveAsync(client, id, deleteFiles: true, ct); + + private HttpClient CreateClient(DownloadClientConfiguration client) + { + var http = _httpClientFactory.CreateClient(DownloadClientTypes.Slskd); + http.BaseAddress = SlskdRequestBuilder.BuildBaseUri(client); + SlskdRequestBuilder.ApplyOptionalApiKey(http, client); + return http; + } + + private static async Task<SlskdBatch?> GetBatchAsync(HttpClient http, string batchId, CancellationToken ct) + { + using var response = await http.GetAsync($"/api/v0/transfers/downloads/batches/{Uri.EscapeDataString(batchId)}", ct); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync<SlskdBatch>(JsonOptions, ct); + } + + private QueueItem MapQueueItem(DownloadClientConfiguration client, SlskdBatch batch) + { + var transfers = (batch.Transfers ?? []).Where(t => !t.Removed).ToList(); + var total = transfers.Sum(t => Math.Max(0, t.Size)); + var downloaded = transfers.Sum(t => Math.Min(Math.Max(0, t.Size), Math.Max(0, t.BytesTransferred))); + var allSucceeded = transfers.Count > 0 && transfers.All(t => IsSucceeded(t.State)); + var failure = transfers.FirstOrDefault(t => IsFailure(t.State)); + var destination = batch.Options?.Destination ?? string.Empty; + var sourceFiles = allSucceeded ? ResolveCompletedStageFiles(client, batch, transfers) : null; + var hasSafeCompletionPaths = allSucceeded && sourceFiles is { Count: > 0 } && sourceFiles.Count == transfers.Count; + var contentPath = hasSafeCompletionPaths + ? Path.GetDirectoryName(sourceFiles![0]) + : null; + var state = failure is not null || (allSucceeded && !hasSafeCompletionPaths) + ? "failed" + : allSucceeded + ? "completed" + : transfers.All(t => IsQueued(t.State)) + ? "queued" + : "downloading"; + return new QueueItem + { + Id = batch.Id, + Title = destination, + Status = state, + Progress = total > 0 ? Math.Min(100d, downloaded * 100d / total) : 0d, + Size = total, + Downloaded = downloaded, + DownloadSpeed = transfers.Sum(t => Math.Max(0, t.AverageSpeed)), + DownloadClient = client.Name ?? DownloadClientTypes.Slskd, + DownloadClientId = client.Id, + DownloadClientType = DownloadClientTypes.Slskd, + RemotePath = contentPath, + ContentPath = contentPath, + SourceFiles = hasSafeCompletionPaths ? sourceFiles : null, + CompletionTime = hasSafeCompletionPaths ? DateTime.UtcNow : null, + CanPause = false, + CanRemove = allSucceeded, + ErrorMessage = failure?.Exception ?? (allSucceeded && !hasSafeCompletionPaths ? "slskd reported an unsafe or incomplete completion path." : null), + ClientFailureReason = failure?.Exception ?? (allSucceeded && !hasSafeCompletionPaths ? "slskd reported an unsafe or incomplete completion path." : null) + }; + } + + private string? GetStageDirectory(DownloadClientConfiguration client, SlskdBatch batch) + { + var mapped = SlskdRequestBuilder.MapCompletedFiles( + SlskdRequestBuilder.GetListenarrVisibleSourceRoot(client), + batch.Options?.Destination ?? string.Empty, + [new SlskdRemoteFile("stage.m4b", 1)]); + return mapped.Count == 1 ? Path.GetDirectoryName(mapped[0]) : null; + } + + private List<string>? ResolveCompletedStageFiles( + DownloadClientConfiguration client, + SlskdBatch batch, + IReadOnlyCollection<SlskdTransfer> transfers) + { + if (transfers.Count == 0 || transfers.Any(t => !IsSucceeded(t.State))) return null; + var stageDirectory = GetStageDirectory(client, batch); + if (stageDirectory is null || !_fileSystem.DirectoryExists(stageDirectory)) return null; + + var actual = _fileSystem.EnumerateFiles(stageDirectory).ToList(); + if (actual.Count != transfers.Count) return null; + var remaining = new List<string>(actual); + foreach (var transfer in transfers) + { + var expectedName = Path.GetFileName(transfer.Filename.Replace('\\', '/')); + var expectedStem = Path.GetFileNameWithoutExtension(expectedName); + var expectedExtension = Path.GetExtension(expectedName); + var match = remaining.FirstOrDefault(path => + string.Equals(Path.GetFileName(path), expectedName, StringComparison.OrdinalIgnoreCase) && + _fileSystem.GetFileLength(path) == transfer.Size); + match ??= remaining.FirstOrDefault(path => + _fileSystem.GetFileLength(path) == transfer.Size && + string.Equals(Path.GetExtension(path), expectedExtension, StringComparison.OrdinalIgnoreCase) && + IsCollisionRename(Path.GetFileNameWithoutExtension(path), expectedStem)); + if (match is null) return null; + remaining.Remove(match); + } + return actual; + } + + private static bool IsCollisionRename(string actualStem, string expectedStem) + { + if (!actualStem.StartsWith(expectedStem + "_", StringComparison.OrdinalIgnoreCase)) return false; + var suffix = actualStem[(expectedStem.Length + 1)..]; + return suffix.Length > 0 && suffix.All(char.IsAsciiDigit); + } + + private static IEnumerable<string> FindBatchIds(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, "batchId", StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.String && + Guid.TryParse(property.Value.GetString(), out var batchId)) + { + yield return batchId.ToString(); + } + else + { + foreach (var id in FindBatchIds(property.Value)) yield return id; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var child in element.EnumerateArray()) + { + foreach (var id in FindBatchIds(child)) yield return id; + } + } + } + + private static bool HasStateFlag(string? state, string flag) => state?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Any(value => string.Equals(value, flag, StringComparison.OrdinalIgnoreCase)) == true; + + private static bool IsSucceeded(string? state) => HasStateFlag(state, "Completed") && HasStateFlag(state, "Succeeded"); + + private static bool IsFailure(string? state) => HasStateFlag(state, "Completed") && + (HasStateFlag(state, "Cancelled") || HasStateFlag(state, "TimedOut") || HasStateFlag(state, "Errored") || HasStateFlag(state, "Rejected") || HasStateFlag(state, "Aborted")); + + private static bool IsQueued(string? state) => HasStateFlag(state, "Requested") || HasStateFlag(state, "Queued"); +} diff --git a/listenarr.infrastructure/DownloadClients/Slskd/SlskdDownloadService.cs b/listenarr.infrastructure/DownloadClients/Slskd/SlskdDownloadService.cs new file mode 100644 index 000000000..4d44d6bdb --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Slskd/SlskdDownloadService.cs @@ -0,0 +1,258 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Slskd; + +/// <summary> +/// Native slskd API integration. It deliberately submits only exact server-returned audio files; +/// it does not translate Soulseek responses into torrent or NZB payloads. +/// </summary> +public sealed class SlskdDownloadService : ISlskdDownloadService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<SlskdDownloadService> _logger; + private readonly IDownloadRepository _downloadRepository; + private readonly SlskdSearchPollingOptions _searchPolling; + + public SlskdDownloadService( + IHttpClientFactory httpClientFactory, + ILogger<SlskdDownloadService> logger, + IDownloadRepository downloadRepository, + SlskdSearchPollingOptions? searchPolling = null) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _downloadRepository = downloadRepository; + _searchPolling = searchPolling ?? SlskdSearchPollingOptions.Default; + _searchPolling.Validate(); + } + + public async Task<SlskdSubmissionResult> SearchSubmitAndPollAsync( + DownloadClientConfiguration client, + SlskdSubmissionRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(request); + if (!string.Equals(client.Type, "slskd", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("The selected download client is not an slskd client.", nameof(client)); + if (string.IsNullOrWhiteSpace(request.SearchQuery)) + throw new ArgumentException("A Soulseek search query is required.", nameof(request)); + + var existing = await _downloadRepository.GetByAudiobookIdAsync(request.AudiobookId, ct); + if (existing?.Any(download => download.Status is DownloadStatus.Queued + or DownloadStatus.Downloading or DownloadStatus.Paused or DownloadStatus.Completed + or DownloadStatus.Processing or DownloadStatus.Ready or DownloadStatus.ImportPending + or DownloadStatus.Moved || download.LastImportedAt.HasValue) == true) + throw new DuplicateDownloadSubmissionException("An audiobook download is already active or imported."); + + var sourceRoot = SlskdRequestBuilder.GetListenarrVisibleSourceRoot(client); + var tracked = new Download + { + AudiobookId = request.AudiobookId, + Title = string.IsNullOrWhiteSpace(request.Title) ? request.SearchQuery : request.Title, + Artist = request.Author ?? string.Empty, + Status = DownloadStatus.Queued, + DownloadClientId = client.Id, + DownloadPath = sourceRoot, + StartedAt = DateTime.UtcNow, + Metadata = new Dictionary<string, object>() + }; + try + { + // Reserve the audiobook before any external side effect. The repository's unique + // active-audiobook key is the cross-request concurrency gate. + await _downloadRepository.AddAsync(tracked); + } + catch (UniqueConstraintViolationException ex) + { + throw new DuplicateDownloadSubmissionException("An active Listenarr download already exists for this audiobook.", ex); + } + + var destinationKey = $"{request.AudiobookId.ToString(System.Globalization.CultureInfo.InvariantCulture)}-{tracked.Id}"; + var destination = SlskdRequestBuilder.BuildDestination(destinationKey); + using var http = _httpClientFactory.CreateClient(DownloadClientTypes.Slskd); + http.BaseAddress = SlskdRequestBuilder.BuildBaseUri(client); + SlskdRequestBuilder.ApplyOptionalApiKey(http, client); + + try + { + var search = await CreateSearchAsync(http, request.SearchQuery, ct); + var selected = await GetFirstSafeResponseAsync(http, search.Id, ct); + using var batchRequest = SlskdRequestBuilder.BuildBatchRequest( + destinationKey, + selected.Username, + selected.Files); + var batchResponse = await SendJsonAsync<SlskdBatchCreateResponse>(http, batchRequest, ct); + var batch = batchResponse.Batch; + if (string.IsNullOrWhiteSpace(batch?.Id)) + throw new DownloadClientSubmissionException("slskd did not return a batch identifier."); + + // Persist the native batch identifier as soon as Slskd accepts the batch so the + // standard monitor/import pipeline owns the rest of the lifecycle. + tracked.SetExternalId(batch.Id); + await _downloadRepository.UpdateAsync(tracked); + + if (batchResponse.Failures is { Count: > 0 }) + { + tracked.Failed("slskd accepted only part of the requested batch; manual reconciliation is required."); + await _downloadRepository.UpdateAsync(tracked); + throw new DownloadClientSubmissionException("slskd accepted only part of the requested batch; the partial batch was preserved for reconciliation."); + } + + var polled = await PollBatchAsync(http, batch.Id, ct); + var completedTransfers = polled.Transfers + .Where(transfer => IsSuccessfulTerminal(transfer.State)) + .Select(transfer => new SlskdRemoteFile(transfer.Filename, transfer.Size)); + var completedFiles = SlskdRequestBuilder.MapCompletedFiles(sourceRoot, destination, completedTransfers); + _logger.LogInformation("Submitted slskd batch {BatchId} for audiobook {AudiobookId}; mapped {FileCount} completed audio file(s)", + LogRedaction.SanitizeText(batch.Id), request.AudiobookId, completedFiles.Count); + var state = polled.Transfers.Count > 0 && completedFiles.Count == polled.Transfers.Count + ? "Completed" + : "Pending"; + return new SlskdSubmissionResult(batch.Id, destination, completedFiles, state); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + // Fail only reservations that never acquired a client ID. Once accepted by Slskd, + // keeping the active record prevents a retry from creating a second external batch. + if (string.IsNullOrWhiteSpace(tracked.GetExternalId())) + { + tracked.Failed(ex.Message); + await _downloadRepository.UpdateAsync(tracked); + } + + throw; + } + } + + private static async Task<SlskdSearch> CreateSearchAsync(HttpClient http, string query, CancellationToken ct) + { + using var response = await http.PostAsJsonAsync("/api/v0/searches", new { searchText = query.Trim() }, JsonOptions, ct); + response.EnsureSuccessStatusCode(); + var search = await response.Content.ReadFromJsonAsync<SlskdSearch>(JsonOptions, ct); + return search is { Id.Length: > 0 } + ? search + : throw new DownloadClientSubmissionException("slskd did not return a search identifier."); + } + + private async Task<SlskdSearchResponse> GetFirstSafeResponseAsync(HttpClient http, string searchId, CancellationToken ct) + { + using var timeout = new CancellationTokenSource(_searchPolling.Timeout); + using var pollingCt = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + var responseUri = $"/api/v0/searches/{Uri.EscapeDataString(searchId)}/responses"; + + try + { + while (true) + { + using var response = await http.GetAsync(responseUri, pollingCt.Token); + TimeSpan? retryAfter = null; + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + retryAfter = GetRetryAfter(response); + _logger.LogWarning("slskd rate-limited search {SearchId}; retrying within the {TimeoutSeconds}s search-result window", searchId, _searchPolling.Timeout.TotalSeconds); + } + else + { + response.EnsureSuccessStatusCode(); + var responses = await response.Content.ReadFromJsonAsync<List<SlskdSearchResponse>>(JsonOptions, pollingCt.Token) ?? []; + // Prefer a peer that reports an immediately available upload slot. The + // former first-response policy could select a remotely queued source even + // when an equally safe source was available immediately. + var selected = responses + .Select(candidate => candidate with + { + Files = candidate.Files + .Where(file => SlskdRequestBuilder.IsSafeAudioFile(file.Filename, file.Size, file.Extension)) + .GroupBy(file => file.Filename, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList() + }) + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Username) && candidate.Files.Count > 0) + .OrderByDescending(candidate => candidate.HasFreeUploadSlot) + .ThenBy(candidate => candidate.Files.Count) + .ThenBy(candidate => candidate.QueueLength) + .FirstOrDefault(); + if (selected is not null) + return selected; + } + + var delay = retryAfter is { } rateLimitDelay && rateLimitDelay > TimeSpan.Zero + ? rateLimitDelay + : _searchPolling.PollInterval; + await Task.Delay(delay, pollingCt.Token); + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested && timeout.IsCancellationRequested) + { + throw new DownloadClientSubmissionException( + $"slskd did not return a safe audio result for search '{searchId}' within {_searchPolling.Timeout.TotalSeconds:0} seconds. Verify the query or try again."); + } + } + + private static TimeSpan? GetRetryAfter(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + return retryAfter?.Delta ?? (retryAfter?.Date - DateTimeOffset.UtcNow); + } + + private static async Task<T> SendJsonAsync<T>(HttpClient http, HttpRequestMessage request, CancellationToken ct) where T : class + { + using var response = await http.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync<T>(JsonOptions, ct) + ?? throw new DownloadClientSubmissionException("slskd returned an empty batch response."); + } + + private static async Task<SlskdBatch> PollBatchAsync(HttpClient http, string batchId, CancellationToken ct) + { + // One explicit poll keeps request handling bounded; the normal Listenarr queue poller can invoke this workflow again. + using var response = await http.GetAsync($"/api/v0/transfers/downloads/batches/{Uri.EscapeDataString(batchId)}", ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync<SlskdBatch>(JsonOptions, ct) + ?? throw new DownloadClientSubmissionException("slskd returned an empty batch status response."); + } + + private static bool IsSuccessfulTerminal(string? state) + { + var flags = (state ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return flags.Contains("Completed", StringComparer.OrdinalIgnoreCase) && + flags.Contains("Succeeded", StringComparer.OrdinalIgnoreCase); + } +} + +public sealed record SlskdSearchPollingOptions(TimeSpan Timeout, TimeSpan PollInterval) +{ + public static SlskdSearchPollingOptions Default { get; } = new(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(1)); + + public void Validate() + { + if (Timeout <= TimeSpan.Zero || PollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(Timeout), "slskd search polling timeout and interval must be positive."); + } +} + +public sealed record SlskdSearch(string Id); +public sealed record SlskdSearchResponse( + string Username, + List<SlskdRemoteFile> Files, + bool HasFreeUploadSlot = false, + int QueueLength = int.MaxValue); +public sealed record SlskdRemoteFile(string Filename, long Size, string? Extension = null); +public sealed record SlskdBatchCreateResponse(SlskdBatch? Batch, List<SlskdBatchFailure>? Failures); +public sealed record SlskdBatchFailure(string Filename, string Message); +public sealed record SlskdBatch(string Id, List<SlskdTransfer> Transfers, SlskdBatchOptions? Options = null); +public sealed record SlskdBatchOptions(string? Destination); +public sealed record SlskdTransfer( + string Filename, + long Size, + string? State, + long BytesTransferred = 0, + double AverageSpeed = 0, + string? Exception = null, + bool Removed = false, + string? Id = null, + string? Username = null); diff --git a/listenarr.infrastructure/DownloadClients/Slskd/SlskdRequestBuilder.cs b/listenarr.infrastructure/DownloadClients/Slskd/SlskdRequestBuilder.cs new file mode 100644 index 000000000..1f1a46ff6 --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Slskd/SlskdRequestBuilder.cs @@ -0,0 +1,115 @@ +using System.Net.Http.Json; +using System.Text.RegularExpressions; + +namespace Listenarr.Infrastructure.DownloadClients.Slskd; + +internal static partial class SlskdRequestBuilder +{ + // Portable default for Compose examples. Native and Windows deployments may + // configure another absolute path in Listenarr's own runtime namespace. + public const string NativeDownloadRoot = "/slskd-downloads"; + private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp3", ".m4a", ".m4b", ".aac", ".flac", ".ogg", ".opus", ".wav", ".wma" + }; + + public static Uri BuildBaseUri(DownloadClientConfiguration client) + { + if (string.IsNullOrWhiteSpace(client.Host)) + throw new ArgumentException("slskd host is required.", nameof(client)); + var scheme = client.UseSSL ? "https" : "http"; + var host = client.Host.Trim(); + if (host.Contains("://", StringComparison.Ordinal)) + { + if (!Uri.TryCreate(host, UriKind.Absolute, out var supplied) || !string.IsNullOrEmpty(supplied.Query) || !string.IsNullOrEmpty(supplied.Fragment)) + throw new ArgumentException("slskd host must be an absolute host without query or fragment.", nameof(client)); + return new UriBuilder(supplied.Scheme, supplied.Host, client.Port > 0 ? client.Port : supplied.Port).Uri; + } + return new UriBuilder(scheme, host, client.Port > 0 ? client.Port : -1).Uri; + } + + public static void ApplyOptionalApiKey(HttpClient http, DownloadClientConfiguration client) + { + if (client.Settings.TryGetValue("apiKey", out var value) && value?.ToString() is { Length: > 0 } apiKey) + http.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", apiKey); + } + + public static string GetListenarrVisibleSourceRoot(DownloadClientConfiguration client) + { + var configured = client.Settings.TryGetValue("listenarrSourceRoot", out var value) + ? value?.ToString()?.Trim() + : null; + var root = string.IsNullOrWhiteSpace(configured) ? NativeDownloadRoot : configured; + if (!Path.IsPathFullyQualified(root)) + throw new ArgumentException("slskd listenarrSourceRoot must be an absolute path in Listenarr's runtime namespace.", nameof(client)); + return Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + public static string BuildDestination(string audiobookId) + { + if (string.IsNullOrWhiteSpace(audiobookId) || !SafeIdRegex().IsMatch(audiobookId)) + throw new ArgumentException("Audiobook ID must contain only letters, digits, underscore, or hyphen.", nameof(audiobookId)); + return $"listenarr/{audiobookId}"; + } + + public static bool IsSafeAudioFile(string? filename, long size, string? extension = null) + { + if (size <= 0 || string.IsNullOrWhiteSpace(filename) || + filename.StartsWith("/", StringComparison.Ordinal) || filename.StartsWith("\\", StringComparison.Ordinal)) + return false; + // Soulseek remote paths use backslashes. Normalize for validation only, while + // retaining the original server-returned filename for the slskd batch API. + var normalized = filename.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar); + var parts = normalized.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0 || parts.Any(part => part is "." or ".." || part.Contains(':'))) + return false; + + // slskd 0.26 returns its candidate extension separately (for example "mp3") + // while Filename may not contain a suffix. Preserve the exact returned Filename + // for the batch request, but use the separately-returned extension for validation. + var suffix = Path.GetExtension(parts[^1]); + if (string.IsNullOrWhiteSpace(suffix) && !string.IsNullOrWhiteSpace(extension)) + suffix = extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension; + return AudioExtensions.Contains(suffix); + } + + public static HttpRequestMessage BuildBatchRequest(string audiobookId, string username, IReadOnlyCollection<SlskdRemoteFile> files) + { + var destination = BuildDestination(audiobookId); + if (string.IsNullOrWhiteSpace(username) || files.Count == 0 || + files.Any(file => !IsSafeAudioFile(file.Filename, file.Size, file.Extension))) + throw new ArgumentException("The slskd batch request contains unsafe data."); + + return new HttpRequestMessage(HttpMethod.Post, "/api/v0/transfers/downloads/batches") + { + // Send the exact server-returned names/sizes; Extension is metadata used only for + // safety validation and is not part of slskd's batch request schema. + Content = JsonContent.Create(new + { + username, + files = files.Select(file => new { filename = file.Filename, size = file.Size }).ToArray(), + options = new { destination } + }) + }; + } + + public static IReadOnlyList<string> MapCompletedFiles(string sourceRoot, string destination, IEnumerable<SlskdRemoteFile>? files) + { + var root = Path.GetFullPath(sourceRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var safeDestination = BuildDestination(destination.Split('/').Last()); + if (!string.Equals(destination, safeDestination, StringComparison.Ordinal)) + return []; + + return (files ?? []) + .Where(file => IsSafeAudioFile(file.Filename, file.Size)) + // slskd moves a completed transfer into the batch destination using + // the remote filename's basename; remote directory components are not + // a local directory contract and must never be replayed into imports. + .Select(file => Path.GetFullPath(Path.Combine(root, safeDestination.Replace('/', Path.DirectorySeparatorChar), Path.GetFileName(file.Filename.Replace('\\', '/'))))) + .Where(path => path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + .ToList(); + } + + [GeneratedRegex("^[A-Za-z0-9_-]+$", RegexOptions.CultureInvariant)] + private static partial Regex SafeIdRegex(); +} 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<IDownloadImportService>(); + 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<IDownloadImportService>() + .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<IDownloadImportService>() + .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<IDownloadImportService>() + .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/Application/Downloads/Submission/DownloadServiceTests.cs b/tests/Features/Application/Downloads/Submission/DownloadServiceTests.cs index 38c5250f5..a8c723a8a 100644 --- a/tests/Features/Application/Downloads/Submission/DownloadServiceTests.cs +++ b/tests/Features/Application/Downloads/Submission/DownloadServiceTests.cs @@ -44,6 +44,70 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() await _downloadRepository.AddAsync(_download); } + [Fact] + public async Task SearchAndDownloadAsync_WhenSlskdIsDefault_UsesNativeSearchWithoutQueryingTorrentIndexers() + { + var slskdService = new Mock<ISlskdDownloadService>(MockBehavior.Strict); + var searchService = new Mock<ISearchService>(MockBehavior.Strict); + _services.AddSingleton(slskdService.Object); + _services.AddSingleton(searchService.Object); + Init(); + await InitData(); + _audiobook.Title = "Dune"; + _audiobook.Authors = ["Frank Herbert"]; + await _audiobookRepository.UpdateAsync(_audiobook); + await _downloadRepository.RemoveAsync(_download.Id); + var slskd = await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfiguration + { + Id = "slskd-1", Name = "Soulseek", Type = "slskd", Host = "slskd", Port = 5030, IsEnabled = true, + Settings = new Dictionary<string, object> { ["isDefault"] = true, ["priority"] = 0 } + }); + slskdService.Setup(service => service.SearchSubmitAndPollAsync( + It.Is<DownloadClientConfiguration>(client => client.Id == slskd.Id), + It.Is<SlskdSubmissionRequest>(request => request.AudiobookId == _audiobook.Id && !string.IsNullOrWhiteSpace(request.SearchQuery)), + It.IsAny<CancellationToken>())) + .ReturnsAsync(new SlskdSubmissionResult("batch-1", "listenarr/book", [], "Pending")); + + var result = await _provider.GetRequiredService<DownloadService>().SearchAndDownloadAsync(_audiobook.Id); + + Assert.True(result.Success); + Assert.Equal("batch-1", result.DownloadId); + Assert.Equal("slskd-1", result.DownloadClientUsed); + slskdService.VerifyAll(); + searchService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SearchAndDownloadAsync_WhenNativeSlskdRejectsDuplicate_ReturnsActionableFailureWithoutFallback() + { + var slskdService = new Mock<ISlskdDownloadService>(MockBehavior.Strict); + var searchService = new Mock<ISearchService>(MockBehavior.Strict); + _services.AddSingleton(slskdService.Object); + _services.AddSingleton(searchService.Object); + Init(); + await InitData(); + await _downloadRepository.RemoveAsync(_download.Id); + var slskd = await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfiguration + { + Id = "slskd-1", Name = "Soulseek", Type = "slskd", Host = "slskd", Port = 5030, IsEnabled = true, + Settings = new Dictionary<string, object> { ["isDefault"] = true, ["priority"] = 0, ["allowProtocolFallback"] = true } + }); + slskdService.Setup(service => service.SearchSubmitAndPollAsync( + It.Is<DownloadClientConfiguration>(client => client.Id == slskd.Id), + It.IsAny<SlskdSubmissionRequest>(), + It.IsAny<CancellationToken>())) + .ThrowsAsync(new DuplicateDownloadSubmissionException("An audiobook download is already active or imported.")); + + var result = await _provider.GetRequiredService<DownloadService>().SearchAndDownloadAsync(_audiobook.Id); + + Assert.False(result.Success); + Assert.Contains("already active or imported", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Slskd", result.IndexerUsed); + Assert.Equal(slskd.Id, result.DownloadClientUsed); + slskdService.VerifyAll(); + searchService.VerifyNoOtherCalls(); + } + [Fact] public async Task SendToDownloadClientAsync_StoresMagnetHashFallback_WhenClientReturnsNoId() { diff --git a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs index 7a7221b18..b35a64448 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs @@ -33,6 +33,7 @@ public void AllDownloadClientAdapters_ResolveFromDi() Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Transmission); Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Sabnzbd); Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Nzbget); + Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Slskd); } [Fact] diff --git a/tests/Features/Infrastructure/DownloadClients/Slskd/SlskdAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Slskd/SlskdAdapterTests.cs new file mode 100644 index 000000000..a48591d6d --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Slskd/SlskdAdapterTests.cs @@ -0,0 +1,337 @@ +using System.Net; +using System.Text; +using Listenarr.Infrastructure.DownloadClients.Slskd; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Slskd; + +public sealed class SlskdAdapterTests +{ + [Fact] + public async Task GetQueueAsync_AllSuccessfulTerminalTransfers_MapsOnlyBasenamesUnderSlskdDownloads() + { + var adapter = CreateAdapter(""" + { + "id": "batch-1", + "transfers": [ + { "filename": "Author\\Book\\Chapter 01.m4b", "size": 12, "bytesTransferred": 12, "state": "Completed, Succeeded" }, + { "filename": "Author/Book/Chapter 02.mp3", "size": 8, "bytesTransferred": 8, "state": "Completed, Succeeded" } + ], + "options": { "destination": "listenarr/42" } + } + """); + + var item = Assert.Single(await adapter.GetQueueAsync(CreateClient(), ["batch-1"])); + + Assert.Equal("batch-1", item.Id); + Assert.Equal("completed", item.Status); + Assert.True(item.CanRemove); + Assert.Equal(100d, item.Progress); + Assert.Equal( + ["/slskd-downloads/listenarr/42/Chapter 01.m4b", "/slskd-downloads/listenarr/42/Chapter 02.mp3"], + item.SourceFiles); + Assert.All(item.SourceFiles!, path => Assert.StartsWith("/slskd-downloads/", path, StringComparison.Ordinal)); + } + + [Fact] + public async Task GetQueueAsync_FullSnapshot_ResolvesNativeBatchIdsForOrphanSafety() + { + const string batchId = "11111111-1111-1111-1111-111111111111"; + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new RouteHandler(request => request.RequestUri!.AbsolutePath switch + { + "/api/v0/transfers/downloads" => """[{"username":"alice","directories":[{"files":[{"batchId":"11111111-1111-1111-1111-111111111111"}]}]}]""", + _ => """{"id":"11111111-1111-1111-1111-111111111111","transfers":[{"filename":"Chapter.m4b","size":12,"bytesTransferred":0,"state":"Queued, Remotely"}],"options":{"destination":"listenarr/42"}}""" + }))), + NullLogger<SlskdAdapter>.Instance, + Moq.Mock.Of<IFileSystem>()); + + var item = Assert.Single(await adapter.GetQueueAsync(CreateClient())); + + Assert.Equal(batchId, item.Id, ignoreCase: true); + Assert.Equal("queued", item.Status); + } + + [Fact] + public async Task RemoveAsync_RemovesEveryTransferInTheBatchWithoutCollapsingChapters() + { + var calls = new List<string>(); + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new RouteResponseHandler(request => + { + calls.Add($"{request.Method} {request.RequestUri!.PathAndQuery}"); + return request.Method == HttpMethod.Get + ? (HttpStatusCode.OK, """ + {"id":"batch-1","transfers":[ + {"id":"11111111-1111-1111-1111-111111111111","username":"alice","filename":"Book/Chapter 01.m4b","size":12,"state":"Completed, Succeeded"}, + {"id":"22222222-2222-2222-2222-222222222222","username":"alice","filename":"Book/Chapter 02.m4b","size":13,"state":"Completed, Succeeded"} + ],"options":{"destination":"listenarr/42-reservation"}} + """) + : (HttpStatusCode.NoContent, string.Empty); + }))), + NullLogger<SlskdAdapter>.Instance, + Moq.Mock.Of<IFileSystem>()); + + var removed = await adapter.RemoveAsync(CreateClient(), "batch-1", deleteFiles: false); + + Assert.True(removed); + Assert.Equal( + [ + "GET /api/v0/transfers/downloads/batches/batch-1", + "DELETE /api/v0/transfers/downloads/alice/11111111-1111-1111-1111-111111111111?remove=true", + "DELETE /api/v0/transfers/downloads/alice/22222222-2222-2222-2222-222222222222?remove=true" + ], + calls); + } + + [Fact] + public async Task GetQueueAsync_PartiallyQueuedBatch_DoesNotExposeImportPathsOrCompletion() + { + var adapter = CreateAdapter(""" + { + "id": "batch-1", + "transfers": [ + { "filename": "Book/Chapter 01.m4b", "size": 12, "bytesTransferred": 12, "state": "Completed, Succeeded" }, + { "filename": "Book/Chapter 02.m4b", "size": 12, "bytesTransferred": 0, "state": "Queued, Remotely" } + ], + "options": { "destination": "listenarr/42" } + } + """); + + var item = Assert.Single(await adapter.GetQueueAsync(CreateClient(), ["batch-1"])); + + Assert.NotEqual("completed", item.Status); + Assert.Null(item.ContentPath); + Assert.Null(item.RemotePath); + Assert.Null(item.SourceFiles); + } + + [Fact] + public async Task GetQueueAsync_CollisionRenamedChapters_UsesActualFilesFromTheIsolatedStage() + { + var fileSystem = new Moq.Mock<IFileSystem>(); + const string stage = "/slskd-downloads/listenarr/42-reservation"; + fileSystem.Setup(fs => fs.DirectoryExists(stage)).Returns(true); + fileSystem.Setup(fs => fs.EnumerateFiles(stage)).Returns( + [stage + "/Chapter.m4b", stage + "/Chapter_638900000000000000.m4b"]); + fileSystem.Setup(fs => fs.GetFileLength(Moq.It.IsAny<string>())).Returns(12); + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new StaticHandler(""" + {"id":"batch-1","transfers":[ + {"filename":"Disc 1/Chapter.m4b","size":12,"bytesTransferred":12,"state":"Completed, Succeeded"}, + {"filename":"Disc 2/Chapter.m4b","size":12,"bytesTransferred":12,"state":"Completed, Succeeded"} + ],"options":{"destination":"listenarr/42-reservation"}} + """))), + NullLogger<SlskdAdapter>.Instance, + fileSystem.Object); + + var item = Assert.Single(await adapter.GetQueueAsync(CreateClient(), ["batch-1"])); + + Assert.Equal("completed", item.Status); + Assert.Equal( + [stage + "/Chapter.m4b", stage + "/Chapter_638900000000000000.m4b"], + item.SourceFiles); + } + + [Fact] + public async Task RemoveAsync_DeleteFiles_RemovesAllActualFilesAndTheIsolatedStageDirectory() + { + const string stage = "/slskd-downloads/listenarr/42-reservation"; + var fileSystem = new Moq.Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.DirectoryExists(stage)).Returns(true); + fileSystem.Setup(fs => fs.EnumerateFiles(stage)).Returns( + [stage + "/Chapter.m4b", stage + "/Chapter_638900000000000000.m4b"]); + fileSystem.Setup(fs => fs.GetFileLength(Moq.It.IsAny<string>())).Returns(12); + fileSystem.Setup(fs => fs.FileExists(Moq.It.IsAny<string>())).Returns(true); + string normalized = string.Empty; + string reason = string.Empty; + fileSystem.Setup(fs => fs.TryValidateMutationTarget( + Moq.It.IsAny<string>(), + Moq.It.IsAny<IEnumerable<string?>>(), + out normalized, + out reason)) + .Returns(true); + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new RouteResponseHandler(request => + request.Method == HttpMethod.Get + ? (HttpStatusCode.OK, """ + {"id":"batch-1","transfers":[ + {"id":"11111111-1111-1111-1111-111111111111","username":"alice","filename":"Disc 1/Chapter.m4b","size":12,"state":"Completed, Succeeded"}, + {"id":"22222222-2222-2222-2222-222222222222","username":"alice","filename":"Disc 2/Chapter.m4b","size":12,"state":"Completed, Succeeded"} + ],"options":{"destination":"listenarr/42-reservation"}} + """) + : (HttpStatusCode.NoContent, string.Empty)))), + NullLogger<SlskdAdapter>.Instance, + fileSystem.Object); + + Assert.True(await adapter.RemoveAsync(CreateClient(), "batch-1", deleteFiles: true)); + fileSystem.Verify(fs => fs.DeleteFile(stage + "/Chapter.m4b"), Moq.Times.Once); + fileSystem.Verify(fs => fs.DeleteFile(stage + "/Chapter_638900000000000000.m4b"), Moq.Times.Once); + fileSystem.Verify(fs => fs.DeleteEmptyDirectories(stage), Moq.Times.Once); + } + + [Fact] + public async Task MarkItemAsImportedAsync_PartialBatchLeavesStageAndTransfersUntouched() + { + const string stage = "/slskd-downloads/listenarr/42-reservation"; + var fileSystem = new Moq.Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.DirectoryExists(stage)).Returns(true); + fileSystem.Setup(fs => fs.EnumerateFiles(stage)).Returns([stage + "/Chapter 01.m4b", stage + "/Chapter 02.m4b"]); + var calls = new List<HttpMethod>(); + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new RouteResponseHandler(request => + { + calls.Add(request.Method); + return (HttpStatusCode.OK, """ + {"id":"batch-1","transfers":[ + {"id":"11111111-1111-1111-1111-111111111111","username":"alice","filename":"Chapter 01.m4b","size":12,"state":"Completed, Succeeded"}, + {"id":"22222222-2222-2222-2222-222222222222","username":"alice","filename":"Chapter 02.m4b","size":12,"state":"Queued, Remotely"} + ],"options":{"destination":"listenarr/42-reservation"}} + """); + }))), + NullLogger<SlskdAdapter>.Instance, + fileSystem.Object); + + Assert.False(await adapter.MarkItemAsImportedAsync(CreateClient(), "batch-1")); + Assert.Equal([HttpMethod.Get], calls); + fileSystem.Verify(fs => fs.DeleteFile(Moq.It.IsAny<string>()), Moq.Times.Never); + fileSystem.Verify(fs => fs.DeleteEmptyDirectories(Moq.It.IsAny<string>()), Moq.Times.Never); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MarkItemAsImportedAsync_AfterFilesWereMoved_RemovesSuccessfulTransfers(bool stageDirectoryExists) + { + const string stage = "/slskd-downloads/listenarr/42-reservation"; + var fileSystem = new Moq.Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.DirectoryExists(stage)).Returns(stageDirectoryExists); + fileSystem.Setup(fs => fs.EnumerateFiles(stage)).Returns([]); + var calls = new List<HttpMethod>(); + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new RouteResponseHandler(request => + { + calls.Add(request.Method); + return request.Method == HttpMethod.Get + ? (HttpStatusCode.OK, """ + {"id":"batch-1","transfers":[ + {"id":"11111111-1111-1111-1111-111111111111","username":"alice","filename":"Chapter.m4b","size":12,"state":"Completed, Succeeded"} + ],"options":{"destination":"listenarr/42-reservation"}} + """) + : (HttpStatusCode.NoContent, string.Empty); + }))), + NullLogger<SlskdAdapter>.Instance, + fileSystem.Object); + + Assert.True(await adapter.MarkItemAsImportedAsync(CreateClient(), "batch-1")); + Assert.Equal([HttpMethod.Get, HttpMethod.Delete], calls); + fileSystem.Verify(fs => fs.DeleteFile(Moq.It.IsAny<string>()), Moq.Times.Never); + fileSystem.Verify(fs => fs.DeleteEmptyDirectories(stage), + stageDirectoryExists ? Moq.Times.Once() : Moq.Times.Never()); + } + + [Fact] + public async Task MarkItemAsImportedAsync_UnverifiedNonEmptyStageLeavesTransfersUntouched() + { + const string stage = "/slskd-downloads/listenarr/42-reservation"; + var fileSystem = new Moq.Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.DirectoryExists(stage)).Returns(true); + fileSystem.Setup(fs => fs.EnumerateFiles(stage)).Returns([stage + "/unexpected.txt"]); + fileSystem.Setup(fs => fs.GetFileLength(stage + "/unexpected.txt")).Returns(99); + var calls = new List<HttpMethod>(); + var adapter = new SlskdAdapter( + new SingleClientFactory(new HttpClient(new RouteResponseHandler(request => + { + calls.Add(request.Method); + return (HttpStatusCode.OK, """ + {"id":"batch-1","transfers":[ + {"id":"11111111-1111-1111-1111-111111111111","username":"alice","filename":"Chapter.m4b","size":12,"state":"Completed, Succeeded"} + ],"options":{"destination":"listenarr/42-reservation"}} + """); + }))), + NullLogger<SlskdAdapter>.Instance, + fileSystem.Object); + + Assert.False(await adapter.MarkItemAsImportedAsync(CreateClient(), "batch-1")); + Assert.Equal([HttpMethod.Get], calls); + fileSystem.Verify(fs => fs.DeleteFile(Moq.It.IsAny<string>()), Moq.Times.Never); + } + + [Fact] + public async Task GetQueueAsync_UnexpectedDestination_DoesNotMarkTerminalBatchComplete() + { + var adapter = CreateAdapter(""" + { + "id": "batch-1", + "transfers": [ + { "filename": "Chapter 01.m4b", "size": 12, "bytesTransferred": 12, "state": "Completed, Succeeded" } + ], + "options": { "destination": "../../outside" } + } + """); + + var item = Assert.Single(await adapter.GetQueueAsync(CreateClient(), ["batch-1"])); + + Assert.NotEqual("completed", item.Status); + Assert.Null(item.SourceFiles); + Assert.Null(item.ContentPath); + } + + private static SlskdAdapter CreateAdapter(string response) + { + const string stage = "/slskd-downloads/listenarr/42"; + var fileSystem = new Moq.Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.DirectoryExists(stage)).Returns(true); + fileSystem.Setup(fs => fs.EnumerateFiles(stage)).Returns([stage + "/Chapter 01.m4b", stage + "/Chapter 02.mp3"]); + fileSystem.Setup(fs => fs.GetFileLength(stage + "/Chapter 01.m4b")).Returns(12); + fileSystem.Setup(fs => fs.GetFileLength(stage + "/Chapter 02.mp3")).Returns(8); + return new SlskdAdapter( + new SingleClientFactory(new HttpClient(new StaticHandler(response))), + NullLogger<SlskdAdapter>.Instance, + fileSystem.Object); + } + + private static DownloadClientConfiguration CreateClient(string sourceRoot = "/slskd-downloads") => new() + { + Id = "slskd-client", + Type = "slskd", + Host = "slskd.internal", + Port = 5030, + Settings = new Dictionary<string, object> { ["listenarrSourceRoot"] = sourceRoot } + }; + + private sealed class SingleClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class RouteHandler(Func<HttpRequestMessage, string> responseFactory) : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseFactory(request), Encoding.UTF8, "application/json") + }); + } + + private sealed class RouteResponseHandler(Func<HttpRequestMessage, (HttpStatusCode Status, string Body)> responseFactory) : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = responseFactory(request); + return Task.FromResult(new HttpResponseMessage(response.Status) + { + Content = new StringContent(response.Body, Encoding.UTF8, "application/json") + }); + } + } + + private sealed class StaticHandler(string response) : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(response, Encoding.UTF8, "application/json") + }); + } +} diff --git a/tests/Features/Infrastructure/DownloadClients/Slskd/SlskdRequestBuilderTests.cs b/tests/Features/Infrastructure/DownloadClients/Slskd/SlskdRequestBuilderTests.cs new file mode 100644 index 000000000..bd30cb39b --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Slskd/SlskdRequestBuilderTests.cs @@ -0,0 +1,361 @@ +using System.Text.Json; +using Listenarr.Infrastructure.DownloadClients.Slskd; + +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Slskd; + +public class SlskdRequestBuilderTests +{ + [Fact] + public void GetListenarrVisibleSourceRoot_UsesConfiguredAbsoluteRoot() + { + var root = Path.Combine(Path.GetPathRoot(Environment.CurrentDirectory)!, "mnt", "slskd-complete"); + var client = new DownloadClientConfiguration + { + Settings = new Dictionary<string, object> { ["listenarrSourceRoot"] = root } + }; + + Assert.Equal(Path.GetFullPath(root), SlskdRequestBuilder.GetListenarrVisibleSourceRoot(client)); + } + + [Fact] + public void GetListenarrVisibleSourceRoot_RejectsRelativeRoot() + { + var client = new DownloadClientConfiguration + { + Settings = new Dictionary<string, object> { ["listenarrSourceRoot"] = "relative/downloads" } + }; + + Assert.Throws<ArgumentException>(() => SlskdRequestBuilder.GetListenarrVisibleSourceRoot(client)); + } + + [Theory] + [InlineData("42", "listenarr/42")] + [InlineData("book_01", "listenarr/book_01")] + public void BuildDestination_UsesOnlyASafeListenarrRelativeId(string audiobookId, string expected) + { + Assert.Equal(expected, SlskdRequestBuilder.BuildDestination(audiobookId)); + } + + [Theory] + [InlineData("../escape")] + [InlineData("book/one")] + [InlineData("book\\one")] + [InlineData("")] + public void BuildDestination_RejectsUnsafeIds(string audiobookId) + { + Assert.Throws<ArgumentException>(() => SlskdRequestBuilder.BuildDestination(audiobookId)); + } + + [Theory] + [InlineData("Chapter 01.m4b", 123L, true)] + [InlineData("Part 01/Chapter.m4b", 123L, true)] + [InlineData("book.exe", 123L, false)] + [InlineData("empty.mp3", 0L, false)] + public void IsSafeAudioFile_RequiresFlatAudioFilenameAndPositiveSize(string fileName, long size, bool expected) + { + Assert.Equal(expected, SlskdRequestBuilder.IsSafeAudioFile(fileName, size)); + } + + [Fact] + public void MapCompletedFiles_OnlyMapsSafeAudioUnderConfiguredRoot() + { + var paths = SlskdRequestBuilder.MapCompletedFiles( + "/slskd-downloads", + "listenarr/42", + [ + new SlskdRemoteFile("The Book/Chapter 01.m4b", 10), + new SlskdRemoteFile("../../outside.mp3", 10), + new SlskdRemoteFile("notes.txt", 10) + ]); + + Assert.Equal([Path.Combine("/slskd-downloads", "listenarr", "42", "Chapter 01.m4b")], paths); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_UsesNativeSlskdEndpointsAndExactServerFile() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + "[{\"username\":\"alice\",\"files\":[{\"filename\":\"Book/Chapter.m4b\",\"size\":12},{\"filename\":\"Book/evil.exe\",\"size\":13}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[{\"filename\":\"Book/Chapter.m4b\",\"size\":12,\"state\":\"Completed, Succeeded\"}]}" + ); + var service = CreateService(handler); + var client = new DownloadClientConfiguration + { + Type = "slskd", + Host = "slskd.internal", + Port = 5030, + Settings = new Dictionary<string, object> + { + ["apiKey"] = "test-key", + ["listenarrSourceRoot"] = "/slskd-downloads" + } + }; + + var result = await service.SearchSubmitAndPollAsync(client, new SlskdSubmissionRequest(42, "Author Book")); + + Assert.Equal("batch-1", result.BatchId); + Assert.StartsWith("listenarr/42-", result.Destination, StringComparison.Ordinal); + Assert.Equal( + [Path.Combine("/slskd-downloads", result.Destination.Replace('/', Path.DirectorySeparatorChar), "Chapter.m4b")], + result.CompletedFiles); + Assert.Equal( + ["POST /api/v0/searches", "GET /api/v0/searches/search-1/responses", "POST /api/v0/transfers/downloads/batches", "GET /api/v0/transfers/downloads/batches/batch-1"], + handler.Calls); + Assert.All(handler.ApiKeys, key => Assert.Equal("test-key", key)); + Assert.Contains("\"filename\":\"Book/Chapter.m4b\"", handler.RequestBodies[2]); + Assert.DoesNotContain("evil.exe", handler.RequestBodies[2]); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_ReservesAudiobookBeforeCallingSlskd() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + "[{\"username\":\"alice\",\"files\":[{\"filename\":\"Book/Chapter.m4b\",\"size\":12}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[]}" + ); + var repository = new Moq.Mock<IDownloadRepository>(); + repository.Setup(repo => repo.AddAsync(Moq.It.IsAny<Download>())) + .ThrowsAsync(new UniqueConstraintViolationException("duplicate", new InvalidOperationException())); + var service = CreateService(handler, repository: repository); + + await Assert.ThrowsAsync<DuplicateDownloadSubmissionException>(() => + service.SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book"))); + + Assert.Empty(handler.Calls); + } + + [Theory] + [InlineData(DownloadStatus.Moved)] + [InlineData(DownloadStatus.Ready)] + [InlineData(DownloadStatus.Completed)] + public async Task SearchSubmitAndPollAsync_RejectsAlreadyImportedOrCompletedAudiobookBeforeCallingSlskd(DownloadStatus status) + { + var handler = new RecordingHandler(); + var repository = new Moq.Mock<IDownloadRepository>(); + repository.Setup(repo => repo.GetByAudiobookIdAsync(42, Moq.It.IsAny<CancellationToken>())) + .ReturnsAsync([new Download { AudiobookId = 42, Status = status, LastImportedAt = status == DownloadStatus.Moved ? DateTime.UtcNow : null }]); + var service = CreateService(handler, repository: repository); + + var exception = await Assert.ThrowsAsync<DuplicateDownloadSubmissionException>(() => + service.SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book"))); + + Assert.Contains("already active or imported", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(handler.Calls); + repository.Verify(repo => repo.AddAsync(Moq.It.IsAny<Download>()), Moq.Times.Never); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_SubmitsOneDeduplicatedBatchThatKeepsDistinctChapters() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + "[{\"username\":\"alice\",\"files\":[{\"filename\":\"Book/Chapter 01.m4b\",\"size\":12},{\"filename\":\"Book/Chapter 01.m4b\",\"size\":12},{\"filename\":\"Book/Chapter 02.m4b\",\"size\":13}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[]}" + ); + + await CreateService(handler).SearchSubmitAndPollAsync( + CreateSlskdClient(), + new SlskdSubmissionRequest(42, "Author Book")); + + Assert.Single(handler.Calls, call => call == "POST /api/v0/transfers/downloads/batches"); + using var request = JsonDocument.Parse(handler.RequestBodies[2]); + var files = request.RootElement.GetProperty("files").EnumerateArray().ToList(); + Assert.Equal(2, files.Count); + Assert.Equal(["Book/Chapter 01.m4b", "Book/Chapter 02.m4b"], + files.Select(file => file.GetProperty("filename").GetString()).ToList()); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_UsesAnIsolatedStagingDestinationPerReservation() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + "[{\"username\":\"alice\",\"files\":[{\"filename\":\"Book/Chapter.m4b\",\"size\":12}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[]}" + ); + + await CreateService(handler).SearchSubmitAndPollAsync( + CreateSlskdClient(), + new SlskdSubmissionRequest(42, "Author Book")); + + using var request = JsonDocument.Parse(handler.RequestBodies[2]); + var destination = request.RootElement.GetProperty("options").GetProperty("destination").GetString(); + Assert.StartsWith("listenarr/42-", destination, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_PollsUntilASafeAudioResponseIsAvailable() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + "[]", + "[{\"username\":\"alice\",\"files\":[{\"filename\":\"Book/Chapter.m4b\",\"size\":12}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[]}" + ); + var service = CreateService(handler); + + var result = await service.SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book")); + + Assert.Equal("batch-1", result.BatchId); + Assert.Equal( + ["POST /api/v0/searches", "GET /api/v0/searches/search-1/responses", "GET /api/v0/searches/search-1/responses", "POST /api/v0/transfers/downloads/batches", "GET /api/v0/transfers/downloads/batches/batch-1"], + handler.Calls); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_PrefersSafeCandidateWithFreeUploadSlot() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + "[{\"username\":\"queued\",\"hasFreeUploadSlot\":false,\"queueLength\":0,\"files\":[{\"filename\":\"Book/One.m4b\",\"size\":12}]},{\"username\":\"ready\",\"hasFreeUploadSlot\":true,\"queueLength\":5,\"files\":[{\"filename\":\"Book/One.m4b\",\"size\":12}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[]}" + ); + + await CreateService(handler).SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book")); + + using var request = JsonDocument.Parse(handler.RequestBodies[2]); + Assert.Equal("ready", request.RootElement.GetProperty("username").GetString()); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_RetriesRateLimitedResponsePollingWithinTheBoundedSearch() + { + var handler = new RecordingHandler( + "{\"id\":\"search-1\"}", + (System.Net.HttpStatusCode.TooManyRequests, "", TimeSpan.FromMilliseconds(1)), + "[{\"username\":\"alice\",\"files\":[{\"filename\":\"Book/Chapter.m4b\",\"size\":12}]}]", + "{\"batch\":{\"id\":\"batch-1\",\"transfers\":[]}}", + "{\"id\":\"batch-1\",\"transfers\":[]}" + ); + var service = CreateService(handler); + + var result = await service.SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book")); + + Assert.Equal("batch-1", result.BatchId); + Assert.Equal(2, handler.Calls.Count(call => call == "GET /api/v0/searches/search-1/responses")); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_TimesOutWithActionableErrorWhenNoSafeAudioResponseArrives() + { + var service = CreateService(new SearchWithoutResultsHandler(), new SlskdSearchPollingOptions(TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(1))); + + var exception = await Assert.ThrowsAsync<DownloadClientSubmissionException>(() => + service.SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book"))); + + Assert.Contains("did not return a safe audio result", exception.Message); + Assert.Contains("Verify the query or try again", exception.Message); + } + + [Fact] + public async Task SearchSubmitAndPollAsync_PreservesCallerCancellationWhilePolling() + { + var service = CreateService(new SearchWithoutResultsHandler(), new SlskdSearchPollingOptions(TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(50))); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(10)); + + await Assert.ThrowsAnyAsync<OperationCanceledException>(() => + service.SearchSubmitAndPollAsync(CreateSlskdClient(), new SlskdSubmissionRequest(42, "Author Book"), cancellation.Token)); + } + + private static DownloadClientConfiguration CreateSlskdClient() => new() + { + Type = "slskd", + Host = "slskd.internal", + Port = 5030, + Settings = new Dictionary<string, object> + { + ["apiKey"] = "test-key", + ["listenarrSourceRoot"] = "/slskd-downloads" + } + }; + + private static SlskdDownloadService CreateService( + HttpMessageHandler handler, + SlskdSearchPollingOptions? polling = null, + Moq.Mock<IDownloadRepository>? repository = null) + { + if (repository is null) + { + repository = new Moq.Mock<IDownloadRepository>(); + repository.Setup(repo => repo.AddAsync(Moq.It.IsAny<Download>())).ReturnsAsync((Download download) => download); + } + return new SlskdDownloadService( + new SingleClientFactory(new HttpClient(handler)), + Microsoft.Extensions.Logging.Abstractions.NullLogger<SlskdDownloadService>.Instance, + repository.Object, + polling ?? new SlskdSearchPollingOptions(TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(1))); + } + + private sealed class SingleClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class RecordingHandler(params object[] responses) : HttpMessageHandler + { + private readonly Queue<object> _responses = new(responses); + public List<string> Calls { get; } = []; + public List<string> ApiKeys { get; } = []; + public List<string> RequestBodies { get; } = []; + + protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls.Add($"{request.Method} {request.RequestUri!.AbsolutePath}"); + ApiKeys.Add(request.Headers.TryGetValues("X-API-Key", out var apiKeys) ? apiKeys.Single() : string.Empty); + RequestBodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)); + var next = _responses.Dequeue(); + var (statusCode, body, retryAfter) = next switch + { + string response => (System.Net.HttpStatusCode.OK, response, (TimeSpan?)null), + ValueTuple<System.Net.HttpStatusCode, string> response => (response.Item1, response.Item2, null), + ValueTuple<System.Net.HttpStatusCode, string, TimeSpan> response => (response.Item1, response.Item2, (TimeSpan?)response.Item3), + _ => throw new InvalidOperationException("Unexpected test response.") + }; + var message = new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + if (retryAfter is not null) + message.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(retryAfter.Value); + return message; + } + } + + private sealed class SearchWithoutResultsHandler : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent( + request.Method == HttpMethod.Post && request.RequestUri!.AbsolutePath == "/api/v0/searches" ? "{\"id\":\"search-1\"}" : "[]", + System.Text.Encoding.UTF8, + "application/json") + }); + } + + [Fact] + public async Task BuildBatchRequest_UsesExactValidatedRemoteFileAndControlledDestination() + { + using var request = SlskdRequestBuilder.BuildBatchRequest( + "42", + "alice", + new[] { new SlskdRemoteFile("The Book/Chapter 01.m4b", 123456) }); + + using var json = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()); + Assert.Equal("alice", json.RootElement.GetProperty("username").GetString()); + Assert.Equal("listenarr/42", json.RootElement.GetProperty("options").GetProperty("destination").GetString()); + var file = Assert.Single(json.RootElement.GetProperty("files").EnumerateArray()); + Assert.Equal("The Book/Chapter 01.m4b", file.GetProperty("filename").GetString()); + Assert.Equal(123456, file.GetProperty("size").GetInt64()); + Assert.Equal("/api/v0/transfers/downloads/batches", request.RequestUri!.OriginalString); + Assert.Equal(System.Net.Http.HttpMethod.Post, request.Method); + } +} 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"))); } } }