From 4320b5c48cfa02ef6cca975ca7656643989868dc Mon Sep 17 00:00:00 2001 From: chodeus Date: Thu, 20 Aug 2026 18:59:57 +0800 Subject: [PATCH 1/4] feat: carry cue/log extras from slskd grabs into the library - GetFilteredFiles compares extensions dot-normalized, so the IncludeFileExtensions whitelist matches whether a peer reports "cue", ".cue", or no extension attribute at all - CreateAlbumData derives codec/bitrate from the audio subset; Size stays the full transfer - A terminally-failed non-audio file is abandoned instead of failing the item or blocking completion; derived from transfer state so it survives restarts and RetryAttempts=0, surfaced in the queue message and a Warn at retry exhaustion - On AlbumImportedEvent, copy the grab's own extras into the imported album folder: basename+size ownership check, confinement-guarded source, copy not move, overwrite on upgrade --- src/Sleezer/Core/Utilities/AudioFormat.cs | 4 + .../Clients/Soulseek/ISlskdDownloadManager.cs | 1 + .../Soulseek/Models/SlskdDownloadItem.cs | 41 +++++ .../Clients/Soulseek/SlskdDownloadManager.cs | 127 ++++++++++++--- .../Soulseek/SlskdExtrasImportService.cs | 35 ++++ .../Clients/Soulseek/SlskdPathResolver.cs | 32 ++++ .../Clients/Soulseek/SlskdRetryHandler.cs | 6 + .../Clients/Soulseek/SlskdStatusResolver.cs | 16 +- src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs | 8 +- .../Indexers/Soulseek/SlskdItemsParser.cs | 7 +- .../Indexers/Soulseek/SlskdSettings.cs | 2 +- tests/Sleezer.Tests/Sleezer.Tests.csproj | 2 + .../SlskdDestinationRecoveryTests.cs | 43 +++++ .../SlskdExtensionFilterTests.cs | 58 +++++++ tests/Sleezer.Tests/SlskdExtrasFlowTests.cs | 80 +++++++++ .../SlskdStatusResolverAbandonTests.cs | 153 ++++++++++++++++++ 16 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs create mode 100644 tests/Sleezer.Tests/SlskdExtensionFilterTests.cs create mode 100644 tests/Sleezer.Tests/SlskdExtrasFlowTests.cs create mode 100644 tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs diff --git a/src/Sleezer/Core/Utilities/AudioFormat.cs b/src/Sleezer/Core/Utilities/AudioFormat.cs index adbeb98..c7bb9d2 100644 --- a/src/Sleezer/Core/Utilities/AudioFormat.cs +++ b/src/Sleezer/Core/Utilities/AudioFormat.cs @@ -209,6 +209,10 @@ public static bool TryGetFileExtensionForCodec(string codec, out string extensio _ => AudioFormat.Unknown }; + /// True when the filename's extension maps to a known audio format. + public static bool IsAudioFilename(string? filename) => + GetAudioCodecFromExtension(Path.GetExtension(filename ?? string.Empty)) != AudioFormat.Unknown; + /// /// Returns the default bitrate for a given audio format. /// diff --git a/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs index 565a19b..a9aaa7a 100644 --- a/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs +++ b/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs @@ -9,4 +9,5 @@ public interface ISlskdDownloadManager Task DownloadAsync(RemoteAlbum remoteAlbum, int definitionId, SlskdProviderSettings settings); IEnumerable GetItems(int definitionId, SlskdProviderSettings settings, OsPath remotePath); void RemoveItem(DownloadClientItem clientItem, bool deleteData, int definitionId, SlskdProviderSettings settings); + void ImportExtrasForImportedAlbum(string downloadId, IReadOnlyCollection importedTrackPaths); } diff --git a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs index 37051e2..639b38a 100644 --- a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs +++ b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Music; using NzbDrone.Core.Parser.Model; using System.Text.Json; +using NzbDrone.Plugin.Sleezer.Core.Utilities; using NzbDrone.Plugin.Sleezer.Indexers.Soulseek; namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models; @@ -148,6 +149,46 @@ public void MarkEnqueueFailed(IEnumerable filenames) /// Files that were actually accepted by slskd. public int ExpectedFileCount => Math.Max(0, FileData.Count - _enqueueFailedFilenames.Count); + /// + /// A terminally-failed non-audio extra (cue/log) — skipped from status and + /// completion so a broken extra can never fail an otherwise-complete album. + /// + public static bool IsAbandonedExtra(SlskdFileState state) => + state.GetStatus() == DownloadItemStatus.Failed && + !AudioFormatHelper.IsAudioFilename(state.File.Filename); + + /// Every accepted file completed; abandoned extras don't block completion. + public bool AllAcceptedFilesCompleted() + { + IReadOnlyDictionary states = FileStates; + if (states.Count == 0) + return false; + + // Multi-disc: transfer state arrives per remote directory, so wait until + // every ACCEPTED file has reported (enqueue-rejected files never produce a transfer). + if (ExpectedFileCount > 0 && states.Count < ExpectedFileCount) + return false; + + foreach (SlskdFileState state in states.Values) + { + if (IsAbandonedExtra(state)) + continue; + if (state.GetStatus() != DownloadItemStatus.Completed) + return false; + } + + return true; + } + + /// Local basenames of the enqueued non-audio files (cue/log extras). + public IReadOnlyList NonAudioBasenames() => + FileData + .Where(f => !string.IsNullOrEmpty(f.Filename) && !AudioFormatHelper.IsAudioFilename(f.Filename)) + .Select(f => Path.GetFileName(f.Filename!.Replace('\\', '/'))) + .Where(n => !string.IsNullOrEmpty(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + /// True when this item tracks transfers for the given remote directory. public bool TracksRemoteDirectory(string? remoteDirectory) => !string.IsNullOrEmpty(remoteDirectory) && _remoteDirectories.ContainsKey(remoteDirectory); diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs index bfb79cb..1e95c77 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs @@ -352,6 +352,110 @@ public void RemoveItem(DownloadClientItem clientItem, bool deleteData, int defin _ = CleanStaleDirectoriesAsync(directory, ownedFileSizes, settings); } + /// + /// Copies this grab's cue/log extras into the album folder Lidarr just + /// imported into. Runs on AlbumImportedEvent, which fires before the + /// download folder is cleaned up, so the source is still on disk. + /// + public void ImportExtrasForImportedAlbum(string downloadId, IReadOnlyCollection importedTrackPaths) + { + try + { + // Most imports are other download clients' — miss quietly, before any disk work. + KeyValuePair, SlskdDownloadItem> tracked = _downloadMappings + .FirstOrDefault(kvp => string.Equals(kvp.Value.ID, downloadId, StringComparison.OrdinalIgnoreCase)); + SlskdDownloadItem? item = tracked.Value; + if (item == null) + { + _logger.Trace("No tracked slskd item for {DownloadId}; nothing to import", downloadId); + return; + } + + IReadOnlyList extras = item.NonAudioBasenames(); + if (extras.Count == 0) + { + _logger.Trace("[{ItemId}] Grab has no extra files to import", item.ID); + return; + } + + int definitionId = tracked.Key.OuterKey; + if (!_settingsCache.TryGetValue(definitionId, out SlskdProviderSettings? settings)) + { + _logger.Debug("[{ItemId}] No cached settings for definition {DefinitionId}; skipping extra import", item.ID, definitionId); + return; + } + + string root = GetRemoteDownloadPath(settings).FullPath.TrimEnd('/', '\\'); + string folder = item.GetFullFolderPath(new OsPath(root)).FullPath.TrimEnd('/', '\\'); + if (!IsStrictDescendantOfRoot(folder, root) || !_diskProvider.FolderExists(folder)) + folder = FindFolderOwningItemFiles(item, root) ?? string.Empty; + + // Defense in depth: the recovery path is basename-driven, so re-confine it. + if (folder.Length == 0 || !IsStrictDescendantOfRoot(folder, root)) + { + _logger.Warn("[{ItemId}] No download folder inside '{Root}' to read extras from; skipping extra import", item.ID, root); + return; + } + + string? destination = SlskdPathResolver.CommonParentDirectory(importedTrackPaths); + if (string.IsNullOrEmpty(destination) || !_diskProvider.FolderExists(destination)) + { + _logger.Warn("[{ItemId}] Imported tracks share no existing album folder; skipping extra import", item.ID); + return; + } + + CopyExtrasIntoAlbumFolder(item, extras, folder, destination); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to import extra files for {DownloadId}", downloadId); + } + } + + /// + /// Copies only extras this item conclusively owns (basename AND size), so a + /// foreign file in a shared download folder is never pulled into the library. + /// + private void CopyExtrasIntoAlbumFolder(SlskdDownloadItem item, IReadOnlyList extras, string folder, string destination) + { + Dictionary ownedFileSizes = item.BuildOwnedFileSizes(); + Dictionary onDisk = new(StringComparer.OrdinalIgnoreCase); + foreach (string file in _diskProvider.GetFiles(folder, recursive: true)) + onDisk.TryAdd(Path.GetFileName(file), file); + + int copied = 0; + foreach (string basename in extras) + { + if (!onDisk.TryGetValue(basename, out string? source)) + { + _logger.Debug("[{ItemId}] Extra '{Extra}' not downloaded — skipping", item.ID, basename); + continue; + } + + if (!IsConclusivelyOwned(source, ownedFileSizes)) + { + _logger.Debug("[{ItemId}] Extra '{Extra}' in '{Folder}' is not conclusively this download's — skipping", item.ID, basename, folder); + continue; + } + + try + { + // Overwrite mirrors track replacement on upgrade — a stale log + // from the previous rip must not survive. Copy, never move: + // Lidarr expects the download folder intact until it removes it. + _diskProvider.CopyFile(source, Path.Combine(destination, basename), true); + copied++; + } + catch (Exception ex) + { + _logger.Warn(ex, "[{ItemId}] Failed to copy extra '{Extra}' into '{Folder}'", item.ID, basename, destination); + } + } + + if (copied > 0) + _logger.Info("Imported {Count} extra file(s) into {Folder}", copied, destination); + } + private void TryDeleteOutputFolder(DownloadClientItem clientItem, Dictionary ownedFileSizes, SlskdProviderSettings settings) { if (clientItem.OutputPath.IsEmpty) @@ -805,7 +909,7 @@ private void ProcessUserTransfers( // DownloadDirectoryComplete event, enqueue here. Without this, Lidarr // can see status=Completed and start importing before the scan runs. // _postProcessed.TryAdd dedupes against the event-path trigger. - if (AllFilesCompleted(item)) + if (item.AllAcceptedFilesCompleted()) EnqueuePostProcess(item, settings); } } @@ -973,25 +1077,6 @@ private SlskdStatusResolver.DownloadStatus FailWhenCompletedFilesVanished( } } - private static bool AllFilesCompleted(SlskdDownloadItem item) - { - IReadOnlyDictionary states = item.FileStates; - if (states.Count == 0) - return false; - - // Multi-disc: transfer state arrives per remote directory, so wait until - // every ACCEPTED file has reported before declaring the album complete - // (enqueue-rejected files never produce a transfer). - if (item.ExpectedFileCount > 0 && states.Count < item.ExpectedFileCount) - return false; - - foreach (SlskdFileState state in states.Values) - if (state.GetStatus() != DownloadItemStatus.Completed) - return false; - - return true; - } - private async Task PollEventsAsync(int definitionId, SlskdProviderSettings settings, int offset) { (List events, _) = await _apiClient.GetEventsAsync(settings, offset, 50); @@ -1053,7 +1138,7 @@ private async Task HandleEventAsync(int definitionId, SlskdProviderSettings sett // Multi-disc items get one DownloadDirectoryComplete per disc; // only post-process once every enqueued file is done. - if (AllFilesCompleted(item)) + if (item.AllAcceptedFilesCompleted()) EnqueuePostProcess(item, settings); else _logger.Trace("[def={DefinitionId}] Directory {RemoteDir} complete but item {ItemId} still has pending files — waiting", definitionId, remoteDir, item.ID); diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs new file mode 100644 index 0000000..8f28a90 --- /dev/null +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs @@ -0,0 +1,35 @@ +using NLog; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek; + +/// +/// Copies a grab's non-audio extras (cue/log) into the imported album folder. +/// Lidarr's own ExtraService only imports per-track-basename extras, so +/// album-level rip artifacts never survive import without this. +/// +public class SlskdExtrasImportService(ISlskdDownloadManager downloadManager, Logger logger) : IHandle +{ + public void Handle(AlbumImportedEvent message) + { + if (string.IsNullOrEmpty(message.DownloadId)) + return; + + List trackPaths = message.ImportedTracks + .Select(t => t.Path) + .Where(p => !string.IsNullOrEmpty(p)) + .ToList(); + if (trackPaths.Count == 0) + return; + + try + { + downloadManager.ImportExtrasForImportedAlbum(message.DownloadId, trackPaths); + } + catch (Exception ex) + { + logger.Warn(ex, "Failed to import slskd extra files for download {DownloadId}", message.DownloadId); + } + } +} diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs index d139271..73753aa 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs @@ -36,6 +36,38 @@ public static partial class SlskdPathResolver return bestMatches * 2 > ownedFileCount ? best : null; } + /// Deepest directory containing every given file path; null when they share no root. + public static string? CommonParentDirectory(IReadOnlyCollection filePaths) + { + List segmentLists = filePaths + .Select(Path.GetDirectoryName) + .Where(d => !string.IsNullOrEmpty(d)) + .Select(d => d!.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])) + .ToList(); + + if (segmentLists.Count == 0) + return null; + + // A Unix absolute path splits to a leading "" segment — keep it so the + // rebuilt path keeps its leading separator. + string[] first = segmentLists[0]; + int common = first.Length; + foreach (string[] segments in segmentLists.Skip(1)) + { + common = Math.Min(common, segments.Length); + int i = 0; + while (i < common && string.Equals(first[i], segments[i], StringComparison.Ordinal)) + i++; + common = i; + } + + // Sharing only the leading "" (or the bare root) is not a usable parent. + if (!first.Take(common).Any(s => s.Length > 0)) + return null; + + return string.Join(Path.DirectorySeparatorChar, first.Take(common)); + } + public static string? ResolveSubdirectory( SlskdDestinationConfig config, string username, string remoteFilename, string? batchId = null, string? externalId = null) { diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs index 9b5f9c0..331ad2f 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs @@ -1,6 +1,7 @@ using NLog; using NzbDrone.Core.Download; using System.Text.Json; +using NzbDrone.Plugin.Sleezer.Core.Utilities; using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models; namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek; @@ -58,7 +59,12 @@ private async Task RetryDownloadAsync(SlskdDownloadItem item, SlskdFileState fil // GetStatus still lets a Completed/Downloading transport state win, // so a healthy retry that succeeds isn't cancelled. if (fileState.RetryCount >= fileState.MaxRetryCount) + { fileState.MarkRetriesExhausted(); + // Only log for the drop — the resolver skips abandoned extras silently. + if (!AudioFormatHelper.IsAudioFilename(fileState.File.Filename)) + _logger.Warn("Extra file {Filename} failed permanently; the album will complete without it", Path.GetFileName(fileState.File.Filename)); + } } } diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs index c4a450e..c3dc10c 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs @@ -26,6 +26,12 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, foreach (SlskdDownloadFile f in files) { + // An abandoned extra contributes nothing — not to totals, activity, + // nor the all-stuck check; it can never hold the album back. + if (item.FileStates.TryGetValue(f.Filename, out SlskdFileState? abandonCheck) && + SlskdDownloadItem.IsAbandonedExtra(abandonCheck)) + continue; + totalSize += f.Size; remainingSize += f.BytesRemaining; @@ -66,12 +72,18 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, bool allStuckInRemoteQueue = anyIncomplete && allIncompleteRemoteQueued; - int totalFileCount = 0, failedCount = 0, completedCount = 0; + int totalFileCount = 0, failedCount = 0, completedCount = 0, abandonedExtras = 0; bool anyWarning = false, anyPaused = false, anyDownloadingState = false; List failedFileNames = []; foreach (SlskdFileState fs in item.FileStates.Values) { + if (SlskdDownloadItem.IsAbandonedExtra(fs)) + { + abandonedExtras++; + continue; + } + totalFileCount++; DownloadItemStatus s = fs.GetStatus(); switch (s) @@ -115,6 +127,8 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, status = item.PostProcessTasks.Any(t => !t.IsCompleted) ? DownloadItemStatus.Downloading : DownloadItemStatus.Completed; + if (abandonedExtras > 0) + message = $"Completed; {abandonedExtras} extra file(s) failed and were skipped"; } else if (anyPaused) { diff --git a/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs b/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs index 7edb268..facdebd 100644 --- a/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs +++ b/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs @@ -38,9 +38,13 @@ public static IEnumerable GetFilteredFiles(List fi { string? extension = !string.IsNullOrWhiteSpace(file.Extension) ? file.Extension : Path.GetExtension(file.Filename); + // The whitelist is stored dotless but peers report either form (and + // the filename fallback always yields ".cue") — compare normalized. + string normalizedExtension = (extension ?? string.Empty).TrimStart('.'); + if (onlyIncludeAudio && - AudioFormatHelper.GetAudioCodecFromExtension(extension ?? "") == AudioFormat.Unknown && - !(includedFileExtensions?.Contains(extension, StringComparer.OrdinalIgnoreCase) ?? false)) + AudioFormatHelper.GetAudioCodecFromExtension(normalizedExtension) == AudioFormat.Unknown && + !(includedFileExtensions?.Contains(normalizedExtension, StringComparer.OrdinalIgnoreCase) ?? false)) { continue; } diff --git a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs index 3d5e659..060138a 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs @@ -216,7 +216,10 @@ public AlbumData CreateAlbumData(string searchId, IGrouping audioForQuality = filesToDownload.Where(IsAudioFile).ToList(); + (AudioFormat Codec, int? BitRate, int? BitDepth, int? SampleRate, long TotalSize, int TotalDuration) + = AnalyzeAudioQuality(audioForQuality.Count > 0 ? audioForQuality : filesToDownload); string qualityInfo = FormatQualityInfo(Codec, BitRate, BitDepth, SampleRate); _logger.Trace("Audio: {Codec}, BitRate: {BitRate}, BitDepth: {BitDepth}, Files: {TrackCount}", Codec, BitRate, BitDepth, actualTrackCount); @@ -266,7 +269,7 @@ public AlbumData CreateAlbumData(string searchId, IGrouping f.Size), InfoUrl = infoUrl, ExplicitContent = ExtractExplicitTag(folderData.Path), Priotity = priority, diff --git a/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs b/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs index 0ecc828..f5dc7c4 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs @@ -117,7 +117,7 @@ public class SlskdSettings : IIndexerSettings [FieldDefinition(3, Type = FieldType.Checkbox, Label = "Audio Files Only", HelpText = "Return only audio file types")] public bool OnlyAudioFiles { get; set; } = true; - [FieldDefinition(4, Type = FieldType.Tag, Label = "File Extensions", HelpText = "Additional extensions when Audio Files Only is enabled (without dots)", Advanced = true)] + [FieldDefinition(4, Type = FieldType.Tag, Label = "File Extensions", HelpText = "Additional extensions when Audio Files Only is enabled (without dots, e.g. cue, log)", Advanced = true)] public IEnumerable IncludeFileExtensions { get; set; } = []; [FieldDefinition(6, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Days before release to allow downloads", Advanced = true)] diff --git a/tests/Sleezer.Tests/Sleezer.Tests.csproj b/tests/Sleezer.Tests/Sleezer.Tests.csproj index da0f387..bea3bfc 100644 --- a/tests/Sleezer.Tests/Sleezer.Tests.csproj +++ b/tests/Sleezer.Tests/Sleezer.Tests.csproj @@ -107,6 +107,8 @@ LinkBase="SourceUnderTest" /> + diff --git a/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs b/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs index 160596c..19bafab 100644 --- a/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs +++ b/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs @@ -158,4 +158,47 @@ public void Relocation_relativizes_a_local_folder_against_the_local_root(string { Assert.Equal(expected, SlskdPathResolver.MakeRelativeToDownloads(root, folder)); } + + // Import destination for cue/log extras: the album folder Lidarr just wrote + // the tracks into, derived from the imported track paths alone. + private static string P(params string[] segments) => string.Join(Path.DirectorySeparatorChar, segments); + + [Fact] + public void CommonParentDirectory_is_the_folder_the_tracks_share() + { + Assert.Equal(P("", "music", "Artist", "Album"), SlskdPathResolver.CommonParentDirectory( + [P("", "music", "Artist", "Album", "01.flac"), P("", "music", "Artist", "Album", "02.flac")])); + } + + [Fact] + public void CommonParentDirectory_folds_disc_subfolders_into_the_album_folder() + { + Assert.Equal(P("", "music", "Album"), SlskdPathResolver.CommonParentDirectory( + [P("", "music", "Album", "CD1", "01.flac"), P("", "music", "Album", "CD2", "01.flac")])); + } + + [Fact] + public void CommonParentDirectory_of_one_path_is_its_own_directory() + { + Assert.Equal(P("", "music", "Album"), SlskdPathResolver.CommonParentDirectory([P("", "music", "Album", "01.flac")])); + } + + [Fact] + public void CommonParentDirectory_is_null_without_paths() + { + Assert.Null(SlskdPathResolver.CommonParentDirectory([])); + } + + [Fact] + public void CommonParentDirectory_is_null_when_nothing_past_the_root_is_shared() + { + Assert.Null(SlskdPathResolver.CommonParentDirectory( + [P("", "music", "A", "01.flac"), P("", "other", "B", "01.flac")])); + } + + [Fact] + public void CommonParentDirectory_is_null_for_bare_filenames() + { + Assert.Null(SlskdPathResolver.CommonParentDirectory(["01.flac", "02.flac"])); + } } diff --git a/tests/Sleezer.Tests/SlskdExtensionFilterTests.cs b/tests/Sleezer.Tests/SlskdExtensionFilterTests.cs new file mode 100644 index 0000000..d96beb9 --- /dev/null +++ b/tests/Sleezer.Tests/SlskdExtensionFilterTests.cs @@ -0,0 +1,58 @@ +using NzbDrone.Plugin.Sleezer.Indexers.Soulseek; +using Xunit; + +namespace Sleezer.Tests; + +// IncludeFileExtensions is stored dotless, but peers report ".log" with the dot +// and the filename fallback always yields ".cue" — both used to miss the +// whitelist and get dropped silently, so cue/log could never be downloaded. +public class SlskdExtensionFilterTests +{ + private static SlskdFileData F(string filename, string? extension) => new( + Filename: filename, BitRate: null, BitDepth: null, Size: 1000, Length: null, + Extension: extension, SampleRate: null, Code: 1, IsLocked: false); + + private static List Filter(List files, bool onlyAudio, params string[] whitelist) => + SlskdFileData.GetFilteredFiles(files, onlyAudio, whitelist).Select(f => f.Filename!).ToList(); + + [Fact] + public void A_dotless_extension_attribute_matches_the_dotless_whitelist() + { + Assert.Single(Filter([F(@"a\b\rip.cue", "cue")], true, "cue")); + } + + // Locks the dot-normalization fix: without it Path.GetExtension's ".cue" + // never matches the stored "cue". + [Fact] + public void A_missing_extension_attribute_falls_back_to_the_dotted_filename_extension() + { + Assert.Single(Filter([F(@"a\b\rip.cue", null)], true, "cue")); + } + + // Locks the dot-normalization fix: some clients report the extension dotted. + [Fact] + public void A_dotted_extension_attribute_still_matches_the_dotless_whitelist() + { + Assert.Single(Filter([F(@"a\b\rip.log", ".log")], true, "log")); + } + + [Fact] + public void A_non_whitelisted_extra_is_still_excluded() + { + Assert.Empty(Filter([F(@"a\b\folder.nfo", null)], true, "cue", "log")); + } + + [Fact] + public void Audio_is_always_included_with_an_empty_whitelist() + { + Assert.Single(Filter([F(@"a\b\01 - Track.flac", null)], true)); + } + + [Fact] + public void Nothing_is_filtered_when_audio_only_is_off() + { + List files = [F(@"a\b\01.flac", "flac"), F(@"a\b\rip.cue", null), F(@"a\b\folder.nfo", null)]; + + Assert.Equal(3, Filter(files, false).Count); + } +} diff --git a/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs b/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs new file mode 100644 index 0000000..9fd991a --- /dev/null +++ b/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs @@ -0,0 +1,80 @@ +using NLog; +using NzbDrone.Plugin.Sleezer.Core.Model; +using NzbDrone.Plugin.Sleezer.Core.Utilities; +using NzbDrone.Plugin.Sleezer.Indexers.Soulseek; +using Xunit; + +namespace Sleezer.Tests; + +// Whitelisted cue/log extras ride along in the download set but must not be +// treated as tracks: codec and bitrate come from the audio subset, while Size +// stays the full download so the queue reports what actually transfers. +public class SlskdExtrasFlowTests +{ + private static readonly SlskdItemsParser Parser = new(LogManager.GetCurrentClassLogger()); + + private static SlskdFileData F(string filename, string ext, long size, int? length) => new( + Filename: filename, BitRate: null, BitDepth: null, Size: size, Length: length, + Extension: ext, SampleRate: null, Code: 1, IsLocked: false); + + private static AlbumData Build(string dir, SlskdFileData[] files, string artist, string album, + string[] tracks, int expectedTrackCount, string albumType) + { + IGrouping group = files.GroupBy(_ => dir).Single(); + SlskdFolderData folder = Parser.ParseFolderName(dir) with + { + Username = "user", + HasFreeUploadSlot = true, + FileCount = files.Length + }; + SlskdSearchData search = new(artist, album, false, false, 1, null, + TrackCount: expectedTrackCount, Tracks: tracks.ToList(), AlbumType: albumType); + return Parser.CreateAlbumData("search1", group, search, folder, null, expectedTrackCount); + } + + [Fact] + public void Album_extras_ride_along_without_skewing_the_quality_analysis() + { + const string dir = @"@@u\Artist\Album (2001)"; + AlbumData a = Build(dir, + [ + F(dir + @"\01 - One.flac", "flac", 30_000_000, 300), + F(dir + @"\02 - Two.flac", "flac", 30_000_000, 300), + F(dir + @"\03 - Three.flac", "flac", 30_000_000, 300), + F(dir + @"\rip.cue", "cue", 2_000_000, null), + F(dir + @"\rip.log", "log", 1_000_000, null), + ], + artist: "Artist", album: "Album", tracks: ["One", "Two", "Three"], + expectedTrackCount: 3, albumType: "Album"); + + Assert.Contains("rip.cue", a.CustomString); + Assert.Contains("rip.log", a.CustomString); + Assert.Equal(AudioFormat.FLAC, a.Codec); + // Size is the whole transfer; the derived bitrate is audio-only (the + // extras' 3 MB would inflate it to 826 kbps). + Assert.Equal(93_000_000, a.Size); + Assert.Equal(800, a.Bitrate); + } + + [Fact] + public void A_single_target_pluck_drops_the_album_level_extras() + { + const string dir = @"@@u\Artist\5150"; + AlbumData a = Build(dir, + [ + F(dir + @"\01 - Panama.flac", "flac", 1000, 200), + F(dir + @"\02 - Jump.flac", "flac", 1000, 200), + F(dir + @"\04 - Dreams.flac", "flac", 1000, 200), + F(dir + @"\rip.cue", "cue", 500, null), + F(dir + @"\rip.log", "log", 500, null), + ], + artist: "Artist", album: "Dreams", tracks: ["Dreams"], + expectedTrackCount: 1, albumType: "Single"); + + Assert.True(a.MatchedSearchCriteria); + Assert.Contains("Dreams", a.CustomString); + Assert.DoesNotContain("rip.cue", a.CustomString); + Assert.DoesNotContain("rip.log", a.CustomString); + Assert.Equal(1000, a.Size); + } +} diff --git a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs new file mode 100644 index 0000000..0b96593 --- /dev/null +++ b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek; +using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models; +using Xunit; + +namespace Sleezer.Tests; + +// A permanently-failed cue/log used to fail the WHOLE album: the resolver +// counted it in failedCount and completion never turned true, so a flaky peer's +// 2 KB log blocklisted a perfect rip. "Abandoned extra" is derived state — no +// flags — so it survives restart rehydration and RetryAttempts=0. +public class SlskdStatusResolverAbandonTests +{ + private const string Dir = @"@@u\Artist\Album"; + + private static SlskdDownloadItem NewItem(params (string Name, long Size)[] files) + { + string source = "[" + string.Join(",", files.Select(f => + $"{{\"Filename\":{JsonSerializer.Serialize(Dir + "\\" + f.Name)},\"Size\":{f.Size}}}")) + "]"; + return new SlskdDownloadItem(new ReleaseInfo { Source = source, Title = "t", DownloadUrl = "u" }); + } + + private static SlskdDownloadFile Transfer(string name, string state, long size) => new( + Id: name, Username: "peer", Direction: "Download", Filename: Dir + "\\" + name, + Size: size, StartOffset: 0, State: state, + RequestedAt: DateTime.UtcNow, EnqueuedAt: DateTime.UtcNow, StartedAt: DateTime.UtcNow, + BytesTransferred: 0, AverageSpeed: 0, BytesRemaining: 0, + ElapsedTime: TimeSpan.Zero, PercentComplete: 0, RemainingTime: TimeSpan.Zero, EndedAt: null); + + private static void Transfers(SlskdDownloadItem item, params SlskdDownloadFile[] files) => + item.SlskdDownloadDirectory = new SlskdDownloadDirectory(Dir, files.Length, files.ToList()); + + private static SlskdFileState State(SlskdDownloadItem item, string name) => item.FileStates[Dir + "\\" + name]; + + private static SlskdStatusResolver.DownloadStatus Resolve(SlskdDownloadItem item) => + SlskdStatusResolver.Resolve(item, TimeSpan.FromMinutes(30), DateTime.UtcNow); + + [Fact] + public void A_terminally_errored_cue_still_completes_the_album() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("03.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Completed, Succeeded", 1000), + Transfer("03.flac", "Completed, Succeeded", 1000), + Transfer("rip.cue", "Completed, Errored", 2000)); + // RetryAttempts=0: the file is terminally failed with no retry ever fired. + State(item, "rip.cue").UpdateMaxRetryCount(0); + + SlskdStatusResolver.DownloadStatus resolved = Resolve(item); + + Assert.Equal(DownloadItemStatus.Completed, resolved.Status); + Assert.Contains("extra file", resolved.Message); + } + + [Fact] + public void A_cue_stuck_in_the_remote_queue_past_its_retries_still_completes_the_album() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Completed, Succeeded", 1000), + Transfer("rip.cue", "Queued, Remotely", 2000)); + State(item, "rip.cue").MarkRetriesExhausted(); + + Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status); + } + + [Fact] + public void A_terminally_failed_audio_file_still_fails_the_album() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Completed, Errored", 1000), + Transfer("rip.cue", "Completed, Succeeded", 2000)); + State(item, "02.flac").MarkRetriesExhausted(); + + SlskdStatusResolver.DownloadStatus resolved = Resolve(item); + + Assert.Equal(DownloadItemStatus.Failed, resolved.Status); + Assert.Contains("02.flac", resolved.Message); + } + + [Fact] + public void An_abandoned_extra_contributes_nothing_to_the_size_totals() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Completed, Succeeded", 1000), + Transfer("rip.cue", "Completed, Errored", 2000)); + State(item, "rip.cue").MarkRetriesExhausted(); + + Assert.Equal(2000, Resolve(item).TotalSize); + } + + [Fact] + public void AllAcceptedFilesCompleted_ignores_an_abandoned_extra() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("rip.cue", "Completed, Errored", 2000)); + State(item, "rip.cue").MarkRetriesExhausted(); + + Assert.True(item.AllAcceptedFilesCompleted()); + } + + [Fact] + public void AllAcceptedFilesCompleted_still_waits_on_a_queued_audio_file() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Queued, Remotely", 1000), + Transfer("rip.cue", "Completed, Errored", 2000)); + State(item, "rip.cue").MarkRetriesExhausted(); + + Assert.False(item.AllAcceptedFilesCompleted()); + } +} + +public class SlskdNonAudioBasenamesTests +{ + private static SlskdDownloadItem NewItem(params string[] filenames) + { + string source = "[" + string.Join(",", filenames.Select(f => + $"{{\"Filename\":{JsonSerializer.Serialize(f)},\"Size\":1000}}")) + "]"; + return new SlskdDownloadItem(new ReleaseInfo { Source = source, Title = "t", DownloadUrl = "u" }); + } + + [Fact] + public void Only_the_non_audio_basenames_are_returned() + { + SlskdDownloadItem item = NewItem( + @"@@u\Artist\Album\01 - Track.flac", + @"@@u\Artist\Album\rip.cue", + @"@@u\Artist\Album\rip.log"); + + Assert.Equal(new[] { "rip.cue", "rip.log" }, item.NonAudioBasenames()); + } + + [Fact] + public void A_pure_audio_grab_has_no_extras() + { + SlskdDownloadItem item = NewItem(@"@@u\A\B\01.flac", @"@@u\A\B\02.mp3", @"@@u\A\B\03.m4a"); + + Assert.Empty(item.NonAudioBasenames()); + } +} From 93a8af9a22364e285fb7e8f2cd663443370c3227 Mon Sep 17 00:00:00 2001 From: chodeus Date: Fri, 21 Aug 2026 05:13:04 +0800 Subject: [PATCH 2/4] fix: harden extras completion and destination guards - AllAcceptedFilesCompleted validates completion per accepted enqueued filename instead of by count, so foreign transfer records in a shared peer directory can't pad it while an accepted file is missing - BuildQueueMessage skips abandoned extras, so an exhausted extra never shows as queued - CommonParentDirectory returns null when any input has no parent directory instead of narrowing to the valid subset --- .../Soulseek/Models/SlskdDownloadItem.cs | 28 ++++++++--- .../Clients/Soulseek/SlskdPathResolver.cs | 16 ++++-- .../Clients/Soulseek/SlskdStatusResolver.cs | 10 +++- .../SlskdDestinationRecoveryTests.cs | 7 +++ .../SlskdStatusResolverAbandonTests.cs | 49 +++++++++++++++++++ 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs index 639b38a..efd9b31 100644 --- a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs +++ b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs @@ -160,24 +160,36 @@ public static bool IsAbandonedExtra(SlskdFileState state) => /// Every accepted file completed; abandoned extras don't block completion. public bool AllAcceptedFilesCompleted() { - IReadOnlyDictionary states = FileStates; - if (states.Count == 0) + if (_previousFileStates.Count == 0) return false; - // Multi-disc: transfer state arrives per remote directory, so wait until - // every ACCEPTED file has reported (enqueue-rejected files never produce a transfer). - if (ExpectedFileCount > 0 && states.Count < ExpectedFileCount) - return false; + // Case-insensitive to match OwnsFile; _previousFileStates is ordinal. + Dictionary statesByName = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair kvp in _previousFileStates) + statesByName.TryAdd(kvp.Key, kvp.Value); - foreach (SlskdFileState state in states.Values) + bool anyAccepted = false; + foreach (SlskdFileData file in FileData) { + if (file.Filename is not { Length: > 0 } filename || _enqueueFailedFilenames.Contains(filename)) + continue; + + // Identity, not count: an accepted file with no transfer yet blocks + // completion, so foreign records in a shared peer dir can't pad it. + if (!statesByName.TryGetValue(filename, out SlskdFileState? state)) + return false; + if (IsAbandonedExtra(state)) continue; + if (state.GetStatus() != DownloadItemStatus.Completed) return false; + + anyAccepted = true; } - return true; + // Nothing importable (every accepted file was an abandoned extra) never completes. + return anyAccepted; } /// Local basenames of the enqueued non-audio files (cue/log extras). diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs index 73753aa..5370479 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs @@ -39,11 +39,17 @@ public static partial class SlskdPathResolver /// Deepest directory containing every given file path; null when they share no root. public static string? CommonParentDirectory(IReadOnlyCollection filePaths) { - List segmentLists = filePaths - .Select(Path.GetDirectoryName) - .Where(d => !string.IsNullOrEmpty(d)) - .Select(d => d!.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])) - .ToList(); + List segmentLists = []; + foreach (string filePath in filePaths) + { + // Fail closed: dropping a parentless path would return a folder that + // doesn't contain it, and the caller copies extras into that folder. + string? directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory)) + return null; + + segmentLists.Add(directory.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])); + } if (segmentLists.Count == 0) return null; diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs index c3dc10c..ffb2588 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs @@ -152,7 +152,7 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, // the Lidarr UI a "queued at position X" summary instead of a blank. if (message == null && (status == DownloadItemStatus.Queued || status == DownloadItemStatus.Downloading)) { - message = BuildQueueMessage(files); + message = BuildQueueMessage(item, files); } TimeSpan? remainingTime = totalSpeed > 0 @@ -162,7 +162,7 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, return new(status, message, totalSize, remainingSize, remainingTime); } - private static string? BuildQueueMessage(IReadOnlyList files) + private static string? BuildQueueMessage(SlskdDownloadItem item, IReadOnlyList files) { int queuedCount = 0; int downloadingCount = 0; @@ -171,6 +171,12 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, foreach (SlskdDownloadFile f in files) { + // Same skip as the totals loop above — an extra that exhausted its + // retries is abandoned, so it must never be reported as queued. + if (item.FileStates.TryGetValue(f.Filename, out SlskdFileState? abandonCheck) && + SlskdDownloadItem.IsAbandonedExtra(abandonCheck)) + continue; + DownloadItemStatus fs = SlskdFileState.GetStatus(f.State); switch (fs) { diff --git a/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs b/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs index 19bafab..3b07ce0 100644 --- a/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs +++ b/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs @@ -201,4 +201,11 @@ public void CommonParentDirectory_is_null_for_bare_filenames() { Assert.Null(SlskdPathResolver.CommonParentDirectory(["01.flac", "02.flac"])); } + + [Fact] + public void CommonParentDirectory_is_null_when_one_path_has_no_parent() + { + Assert.Null(SlskdPathResolver.CommonParentDirectory( + [P("", "music", "Album", "01.flac"), "02.flac"])); + } } diff --git a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs index 0b96593..99a080c 100644 --- a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs +++ b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs @@ -121,6 +121,55 @@ public void AllAcceptedFilesCompleted_still_waits_on_a_queued_audio_file() Assert.False(item.AllAcceptedFilesCompleted()); } + + [Fact] + public void An_abandoned_extra_is_not_reported_as_queued_in_the_status_message() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "InProgress", 1000), + Transfer("rip.cue", "Queued, Remotely", 2000)); + State(item, "rip.cue").MarkRetriesExhausted(); + + SlskdStatusResolver.DownloadStatus resolved = Resolve(item); + + Assert.Contains("downloading", resolved.Message); + Assert.DoesNotContain("queued", resolved.Message); + } + + // slskd hands the whole per-directory transfer group to whichever item owns + // file[0], so a shared peer folder puts another item's files in FileStates. + [Fact] + public void A_foreign_transfer_cannot_pad_the_count_for_a_file_that_never_reported() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("rip.cue", "Completed, Succeeded", 2000), + Transfer("foreign.flac", "Completed, Succeeded", 1000)); + + Assert.False(item.AllAcceptedFilesCompleted()); + } + + [Fact] + public void AllAcceptedFilesCompleted_does_not_wait_on_an_enqueue_rejected_file() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000)); + item.MarkEnqueueFailed([Dir + @"\02.flac"]); + Transfers(item, Transfer("01.flac", "Completed, Succeeded", 1000)); + + Assert.True(item.AllAcceptedFilesCompleted()); + } + + [Fact] + public void An_item_whose_only_accepted_file_is_an_abandoned_extra_never_completes() + { + SlskdDownloadItem item = NewItem(("rip.cue", 2000)); + Transfers(item, Transfer("rip.cue", "Completed, Errored", 2000)); + State(item, "rip.cue").UpdateMaxRetryCount(0); + + Assert.False(item.AllAcceptedFilesCompleted()); + } } public class SlskdNonAudioBasenamesTests From bced2d544a4d02b6882fc9f662c0b5959ca35993 Mon Sep 17 00:00:00 2001 From: chodeus Date: Fri, 21 Aug 2026 05:46:37 +0800 Subject: [PATCH 3/4] fix: drive slskd item status from owned transfers only - Status aggregation, completion counting, and the queue message now skip transfers this item never enqueued; a shared peer directory can hand an item another download's files, and a failed foreign transfer could fail a healthy release - The retry handler ignores foreign file states, which its finally block used to mark retry-exhausted despite never retrying them - NonAudioBasenames excludes enqueue-rejected files, so the extras import never hunts the disk for a file slskd refused --- .../Soulseek/Models/SlskdDownloadItem.cs | 4 +- .../Clients/Soulseek/SlskdRetryHandler.cs | 4 ++ .../Clients/Soulseek/SlskdStatusResolver.cs | 13 +++-- .../SlskdStatusResolverAbandonTests.cs | 47 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs index efd9b31..27b03ea 100644 --- a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs +++ b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs @@ -195,7 +195,9 @@ public bool AllAcceptedFilesCompleted() /// Local basenames of the enqueued non-audio files (cue/log extras). public IReadOnlyList NonAudioBasenames() => FileData - .Where(f => !string.IsNullOrEmpty(f.Filename) && !AudioFormatHelper.IsAudioFilename(f.Filename)) + .Where(f => f.Filename is { Length: > 0 } filename && + !_enqueueFailedFilenames.Contains(filename) && + !AudioFormatHelper.IsAudioFilename(filename)) .Select(f => Path.GetFileName(f.Filename!.Replace('\\', '/'))) .Where(n => !string.IsNullOrEmpty(n)) .Distinct(StringComparer.OrdinalIgnoreCase) diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs index 331ad2f..66b5e3d 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs @@ -19,6 +19,10 @@ public void OnFileStateChanged(SlskdDownloadItem? item, SlskdFileState fileState return; if (item == null) return; + // A shared peer directory raises FileStateChanged for foreign transfers too; + // retrying one would mark it exhausted in the finally and fail this item. + if (!item.OwnsFile(fileState.File.Filename)) + return; _logger.Trace("Retry triggered: {Filename} | State: {State} | Attempt: {Attempt}/{Max}", Path.GetFileName(fileState.File.Filename), fileState.State, fileState.RetryCount + 1, fileState.MaxRetryCount); _ = RetryDownloadAsync(item, fileState, settings); diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs index ffb2588..b82b839 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs @@ -18,13 +18,17 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, if (item.SlskdDownloadDirectory?.Files == null) return new(DownloadItemStatus.Queued, null, 0, 0, null); - IReadOnlyList files = item.SlskdDownloadDirectory.Files; + // A shared peer directory can put another item's transfers in this view — + // only files this item enqueued may drive its status. + List ownedFiles = item.SlskdDownloadDirectory.Files + .Where(f => item.OwnsFile(f.Filename)) + .ToList(); long totalSize = 0, remainingSize = 0, totalSpeed = 0; bool anyActive = false, anyIncomplete = false, allIncompleteRemoteQueued = true; DateTime lastActivity = DateTime.MinValue; - foreach (SlskdDownloadFile f in files) + foreach (SlskdDownloadFile f in ownedFiles) { // An abandoned extra contributes nothing — not to totals, activity, // nor the all-stuck check; it can never hold the album back. @@ -78,6 +82,9 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, foreach (SlskdFileState fs in item.FileStates.Values) { + if (!item.OwnsFile(fs.File.Filename)) + continue; + if (SlskdDownloadItem.IsAbandonedExtra(fs)) { abandonedExtras++; @@ -152,7 +159,7 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, // the Lidarr UI a "queued at position X" summary instead of a blank. if (message == null && (status == DownloadItemStatus.Queued || status == DownloadItemStatus.Downloading)) { - message = BuildQueueMessage(item, files); + message = BuildQueueMessage(item, ownedFiles); } TimeSpan? remainingTime = totalSpeed > 0 diff --git a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs index 99a080c..e5554fb 100644 --- a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs +++ b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs @@ -151,6 +151,44 @@ public void A_foreign_transfer_cannot_pad_the_count_for_a_file_that_never_report Assert.False(item.AllAcceptedFilesCompleted()); } + [Fact] + public void A_foreign_failed_transfer_does_not_fail_the_album() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Completed, Succeeded", 1000), + Transfer("foreign.flac", "Completed, Errored", 1000)); + State(item, "foreign.flac").MarkRetriesExhausted(); + + Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status); + } + + [Fact] + public void A_foreign_queued_transfer_is_not_reported_in_the_status_message() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000)); + Transfers(item, + Transfer("01.flac", "InProgress", 1000), + Transfer("foreign.flac", "Queued, Remotely", 1000)); + + SlskdStatusResolver.DownloadStatus resolved = Resolve(item); + + Assert.Contains("downloading", resolved.Message); + Assert.DoesNotContain("queued", resolved.Message); + } + + [Fact] + public void A_foreign_queued_transfer_cannot_hold_the_album_incomplete() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000)); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("foreign.flac", "Queued, Remotely", 1000)); + + Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status); + } + [Fact] public void AllAcceptedFilesCompleted_does_not_wait_on_an_enqueue_rejected_file() { @@ -199,4 +237,13 @@ public void A_pure_audio_grab_has_no_extras() Assert.Empty(item.NonAudioBasenames()); } + + [Fact] + public void An_enqueue_rejected_extra_is_not_an_extra_to_import() + { + SlskdDownloadItem item = NewItem(@"@@u\A\B\01.flac", @"@@u\A\B\rip.cue"); + item.MarkEnqueueFailed([@"@@u\A\B\rip.cue"]); + + Assert.Empty(item.NonAudioBasenames()); + } } From b30aa39099ce60a1d21967a9b808c30bac3308f8 Mon Sep 17 00:00:00 2001 From: chodeus Date: Fri, 21 Aug 2026 07:48:04 +0800 Subject: [PATCH 4/4] fix: treat enqueue-rejected files as not owned for transfer state OwnsFile answers "did this item ask for the file", which is the wrong question for anything reading transfer state: slskd creates no transfer for a file it rejected, so a transfer under that name in a shared peer directory belongs to another item. Status aggregation counted it, and the retry handler re-enqueued a file slskd had already refused before marking it exhausted -- either one fails a healthy release. OwnsAcceptedFile now carries that meaning for the status resolver and the retry handler, and the completion and extras-import checks use it instead of testing the rejected set themselves. --- .../Soulseek/Models/SlskdDownloadItem.cs | 12 ++++++++++-- .../Clients/Soulseek/SlskdRetryHandler.cs | 6 +++--- .../Clients/Soulseek/SlskdStatusResolver.cs | 6 +++--- .../SlskdStatusResolverAbandonTests.cs | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs index 27b03ea..1337748 100644 --- a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs +++ b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs @@ -136,6 +136,14 @@ public SlskdDownloadItem(ReleaseInfo releaseInfo) public bool OwnsFile(string? remoteFilename) => !string.IsNullOrEmpty(remoteFilename) && _enqueuedFilenames.Contains(remoteFilename); + /// + /// True when this item enqueued the file AND slskd accepted it — the + /// ownership test for anything reading transfer state, since a rejected + /// file's only possible transfer belongs to another item. + /// + public bool OwnsAcceptedFile(string? remoteFilename) => + OwnsFile(remoteFilename) && !_enqueueFailedFilenames.Contains(remoteFilename!); + /// /// Records files slskd rejected at enqueue time. They will never produce a /// transfer, so completion tracking must not wait for them. @@ -171,7 +179,7 @@ public bool AllAcceptedFilesCompleted() bool anyAccepted = false; foreach (SlskdFileData file in FileData) { - if (file.Filename is not { Length: > 0 } filename || _enqueueFailedFilenames.Contains(filename)) + if (file.Filename is not { Length: > 0 } filename || !OwnsAcceptedFile(filename)) continue; // Identity, not count: an accepted file with no transfer yet blocks @@ -196,7 +204,7 @@ public bool AllAcceptedFilesCompleted() public IReadOnlyList NonAudioBasenames() => FileData .Where(f => f.Filename is { Length: > 0 } filename && - !_enqueueFailedFilenames.Contains(filename) && + OwnsAcceptedFile(filename) && !AudioFormatHelper.IsAudioFilename(filename)) .Select(f => Path.GetFileName(f.Filename!.Replace('\\', '/'))) .Where(n => !string.IsNullOrEmpty(n)) diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs index 66b5e3d..3c54509 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs @@ -19,9 +19,9 @@ public void OnFileStateChanged(SlskdDownloadItem? item, SlskdFileState fileState return; if (item == null) return; - // A shared peer directory raises FileStateChanged for foreign transfers too; - // retrying one would mark it exhausted in the finally and fail this item. - if (!item.OwnsFile(fileState.File.Filename)) + // Foreign transfers (shared peer directory) and files slskd rejected at + // enqueue both raise state changes; retrying either fails this item. + if (!item.OwnsAcceptedFile(fileState.File.Filename)) return; _logger.Trace("Retry triggered: {Filename} | State: {State} | Attempt: {Attempt}/{Max}", Path.GetFileName(fileState.File.Filename), fileState.State, fileState.RetryCount + 1, fileState.MaxRetryCount); diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs index b82b839..94421e2 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs @@ -19,9 +19,9 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, return new(DownloadItemStatus.Queued, null, 0, 0, null); // A shared peer directory can put another item's transfers in this view — - // only files this item enqueued may drive its status. + // only files slskd accepted for THIS item may drive its status. List ownedFiles = item.SlskdDownloadDirectory.Files - .Where(f => item.OwnsFile(f.Filename)) + .Where(f => item.OwnsAcceptedFile(f.Filename)) .ToList(); long totalSize = 0, remainingSize = 0, totalSpeed = 0; @@ -82,7 +82,7 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout, foreach (SlskdFileState fs in item.FileStates.Values) { - if (!item.OwnsFile(fs.File.Filename)) + if (!item.OwnsAcceptedFile(fs.File.Filename)) continue; if (SlskdDownloadItem.IsAbandonedExtra(fs)) diff --git a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs index e5554fb..e61a691 100644 --- a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs +++ b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs @@ -189,6 +189,24 @@ public void A_foreign_queued_transfer_cannot_hold_the_album_incomplete() Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status); } + // A file slskd rejected for THIS item produces no transfer of ours, so any + // transfer under that name belongs to another item sharing the peer folder. + [Fact] + public void A_rejected_files_transfer_cannot_fail_the_album() + { + SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000)); + item.MarkEnqueueFailed([Dir + @"\02.flac"]); + Transfers(item, + Transfer("01.flac", "Completed, Succeeded", 1000), + Transfer("02.flac", "Completed, Errored", 1000)); + State(item, "02.flac").MarkRetriesExhausted(); + + SlskdStatusResolver.DownloadStatus resolved = Resolve(item); + + Assert.Equal(DownloadItemStatus.Completed, resolved.Status); + Assert.Equal(1000, resolved.TotalSize); + } + [Fact] public void AllAcceptedFilesCompleted_does_not_wait_on_an_enqueue_rejected_file() {