From 96de9685d1148ae5bd12e0b8fc0ed01518915e57 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:02:11 +0800 Subject: [PATCH 1/3] fix: give every item its own share of a shared peer directory slskd groups transfers by peer directory, and the manager handed each directory to a single item found by probing only the group's first file. Two albums can legitimately download from one directory -- the single/EP pluck takes one track out of a compilation folder -- and then the item that lost the probe was never given any transfers at all: it sat at Queued until it timed out and blocklisted a release that was downloading fine. The winner meanwhile saw the other item's files, could adopt a foreign batch id along with DiscFoldersMerged, and could derive its subdirectory from a foreign filename. A directory is now partitioned per owning item, so each item sees only the transfers slskd accepted for it, with the leftovers still offered to the history/inclusive adoption path. FindItemOwningDirectory is gone -- the partitioner subsumes it -- and the adoption and per-item application that used to be inline are now their own methods. --- .../Soulseek/SlskdDirectoryPartitioner.cs | 53 ++++++ .../Clients/Soulseek/SlskdDownloadManager.cs | 177 ++++++++++-------- tests/Sleezer.Tests/Sleezer.Tests.csproj | 2 + .../SlskdDirectoryPartitionerTests.cs | 153 +++++++++++++++ 4 files changed, 311 insertions(+), 74 deletions(-) create mode 100644 src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs create mode 100644 tests/Sleezer.Tests/SlskdDirectoryPartitionerTests.cs diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs new file mode 100644 index 0000000..793b643 --- /dev/null +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs @@ -0,0 +1,53 @@ +using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models; + +namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek; + +/// One item's share of a peer directory, and the files no tracked item claimed. +public record SlskdDirectoryPartition( + List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> Owners, + SlskdDownloadDirectory? Unclaimed); + +/// +/// Splits one peer directory's transfers into per-item slices. Two Lidarr albums +/// can share a peer directory, so the group belongs to every item that enqueued +/// part of it — not to whichever item happens to own the first file. +/// +public static class SlskdDirectoryPartitioner +{ + public static SlskdDirectoryPartition Partition( + SlskdDownloadDirectory directory, + IEnumerable candidates, + string username) + { + List files = directory.Files ?? []; + if (files.Count == 0) + return new SlskdDirectoryPartition([], null); + + List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> owners = []; + HashSet claimed = new(StringComparer.OrdinalIgnoreCase); + + foreach (SlskdDownloadItem candidate in candidates) + { + // An item with no username yet is still a candidate for any peer. + if (candidate.Username != null && + !string.Equals(candidate.Username, username, StringComparison.OrdinalIgnoreCase)) + continue; + + // Deliberately not deduped against `claimed`: two items can legitimately + // enqueue the same remote file, and one transfer serves both. + List owned = files.Where(f => candidate.OwnsAcceptedFile(f.Filename)).ToList(); + if (owned.Count == 0) + continue; + + owners.Add((candidate, new SlskdDownloadDirectory(directory.Directory, owned.Count, owned))); + foreach (SlskdDownloadFile file in owned) + claimed.Add(file.Filename); + } + + List unclaimed = files.Where(f => !claimed.Contains(f.Filename)).ToList(); + + return new SlskdDirectoryPartition( + owners, + unclaimed.Count > 0 ? new SlskdDownloadDirectory(directory.Directory, unclaimed.Count, unclaimed) : null); + } +} diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs index 4c4e6e1..d2296f4 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs @@ -837,92 +837,121 @@ private void ProcessUserTransfers( string hash = SlskdDownloadItem.GetStableMD5Id(dir.Files?.Select(f => f.Filename) ?? []); currentIdSet.TryAdd(hash, true); - // A multi-disc item's ID hashes ALL its files, but slskd reports - // transfers per remote directory — the per-directory hash never - // matches, so fall back to matching by enqueued-file membership. - SlskdDownloadItem? item = GetItem(definitionId, hash) - ?? FindItemOwningDirectory(definitionId, userTransfers.Username, dir); - if (item == null) + foreach ((SlskdDownloadItem item, SlskdDownloadDirectory slice) in ResolveDirectoryOwners(definitionId, settings, userTransfers.Username, dir)) { - _logger.Trace("[def={DefinitionId}] Unknown item {Hash}: checking history", definitionId, hash); - DownloadHistory? history = FindGrabOwningDirectory(hash, dir); + currentIdSet.TryAdd(item.ID, true); + ApplyDirectoryToItem(settings, userTransfers.Username, item, slice); + } + } + } - if (history != null) - { - item = new SlskdDownloadItem(history.Release) - { - // The grab's id, not the recomputed content hash: a - // retry grab carries an -rN suffix and a multi-disc - // grab hashes ALL discs — Lidarr tracks both under - // the id it was handed at grab time. - ID = history.DownloadId - }; - } - else if (settings.Inclusive) - { - item = new SlskdDownloadItem(CreateReleaseInfoFromDirectory(userTransfers.Username, dir)); - } + /// + /// Every tracked item with a stake in one peer directory, each paired with + /// just its own transfers — two Lidarr albums can share a peer directory. + /// + private List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> ResolveDirectoryOwners( + int definitionId, + SlskdProviderSettings settings, + string username, + SlskdDownloadDirectory dir) + { + // A multi-disc item's ID hashes ALL its files, but slskd reports transfers + // per remote directory — that hash only hits for a single-directory item. + if (GetItem(definitionId, SlskdDownloadItem.GetStableMD5Id(dir.Files?.Select(f => f.Filename) ?? [])) is { } keyed) + return [(keyed, dir)]; - if (item == null) - continue; + SlskdDirectoryPartition partition = SlskdDirectoryPartitioner.Partition(dir, GetItemsForDef(definitionId), username); + List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> owners = [.. partition.Owners]; - SubscribeStateChanges(item, definitionId); - AddItem(definitionId, item); - } - else - { - currentIdSet.TryAdd(item.ID, true); - } + if (partition.Unclaimed is { } unclaimed && + AdoptUnknownDirectory(definitionId, settings, username, unclaimed) is { } adopted) + owners.Add((adopted, unclaimed)); - item.Username ??= userTransfers.Username; - item.SlskdDownloadDirectory = dir; - - // slskd echoes the batch id on every transfer — the only copy that - // survives a restart. A batch always carried a destination, so slskd - // placed the discs pre-merged: restoring both stops a needless merge - // from moving a same-named root folder belonging to another download, - // and lets the ConfirmedSubdirectory gate in HandleEventAsync trust a - // multi-disc completion. - if (item.BatchId == null && - dir.Files?.Select(f => f.BatchId).FirstOrDefault(id => !string.IsNullOrEmpty(id)) is { } recoveredBatchId) - { - item.BatchId = recoveredBatchId; - item.DiscFoldersMerged = true; - } + return owners; + } - // With a non-default subdirectory pattern, slskd places transfers - // somewhere the leaf-name guess can't predict — derive it. - if (item.DerivedSubdirectory == null && item.ConfirmedSubdirectory == null && - settings.GetDestinationConfig() is { UsesDefaultPattern: false } destinationConfig && - dir.Files?.FirstOrDefault()?.Filename is { Length: > 0 } firstFile) - { - item.DerivedSubdirectory = SlskdPathResolver.ResolveSubdirectory( - destinationConfig, - userTransfers.Username, - firstFile, - item.BatchId, - item.BatchId != null ? item.ID : null); - } + /// + /// Re-attaches transfers no tracked item claimed to the grab that started + /// them, or (inclusive mode) builds an item from the directory itself. + /// + private SlskdDownloadItem? AdoptUnknownDirectory( + int definitionId, + SlskdProviderSettings settings, + string username, + SlskdDownloadDirectory dir) + { + string hash = SlskdDownloadItem.GetStableMD5Id(dir.Files?.Select(f => f.Filename) ?? []); + _logger.Trace("[def={DefinitionId}] Unknown item {Hash}: checking history", definitionId, hash); + DownloadHistory? history = FindGrabOwningDirectory(hash, dir); - // Fallback trigger for post-process: if the transfer poll sees all - // files completed before the event poll has caught up with the matching - // 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 (item.AllAcceptedFilesCompleted()) - EnqueuePostProcess(item, settings); + SlskdDownloadItem? item = null; + if (history != null) + { + item = new SlskdDownloadItem(history.Release) + { + // The grab's id, not the recomputed content hash: a + // retry grab carries an -rN suffix and a multi-disc + // grab hashes ALL discs — Lidarr tracks both under + // the id it was handed at grab time. + ID = history.DownloadId + }; + } + else if (settings.Inclusive) + { + item = new SlskdDownloadItem(CreateReleaseInfoFromDirectory(username, dir)); } + + if (item == null) + return null; + + SubscribeStateChanges(item, definitionId); + AddItem(definitionId, item); + return item; } - private SlskdDownloadItem? FindItemOwningDirectory(int definitionId, string username, SlskdDownloadDirectory dir) + private void ApplyDirectoryToItem( + SlskdProviderSettings settings, + string username, + SlskdDownloadItem item, + SlskdDownloadDirectory dir) { - string? probeFile = dir.Files?.FirstOrDefault()?.Filename; - if (string.IsNullOrEmpty(probeFile)) - return null; + item.Username ??= username; + item.SlskdDownloadDirectory = dir; + + // slskd echoes the batch id on every transfer — the only copy that + // survives a restart. A batch always carried a destination, so slskd + // placed the discs pre-merged: restoring both stops a needless merge + // from moving a same-named root folder belonging to another download, + // and lets the ConfirmedSubdirectory gate in HandleEventAsync trust a + // multi-disc completion. + if (item.BatchId == null && + dir.Files?.Select(f => f.BatchId).FirstOrDefault(id => !string.IsNullOrEmpty(id)) is { } recoveredBatchId) + { + item.BatchId = recoveredBatchId; + item.DiscFoldersMerged = true; + } + + // With a non-default subdirectory pattern, slskd places transfers + // somewhere the leaf-name guess can't predict — derive it. + if (item.DerivedSubdirectory == null && item.ConfirmedSubdirectory == null && + settings.GetDestinationConfig() is { UsesDefaultPattern: false } destinationConfig && + dir.Files?.FirstOrDefault()?.Filename is { Length: > 0 } firstFile) + { + item.DerivedSubdirectory = SlskdPathResolver.ResolveSubdirectory( + destinationConfig, + username, + firstFile, + item.BatchId, + item.BatchId != null ? item.ID : null); + } - return GetItemsForDef(definitionId).FirstOrDefault(i => - (i.Username == null || string.Equals(i.Username, username, StringComparison.OrdinalIgnoreCase)) && - i.OwnsFile(probeFile)); + // Fallback trigger for post-process: if the transfer poll sees all + // files completed before the event poll has caught up with the matching + // 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 (item.AllAcceptedFilesCompleted()) + EnqueuePostProcess(item, settings); } // Restart re-attach: -rN retry and multi-disc grab ids never equal the diff --git a/tests/Sleezer.Tests/Sleezer.Tests.csproj b/tests/Sleezer.Tests/Sleezer.Tests.csproj index 2f7b10d..1646202 100644 --- a/tests/Sleezer.Tests/Sleezer.Tests.csproj +++ b/tests/Sleezer.Tests/Sleezer.Tests.csproj @@ -107,6 +107,8 @@ LinkBase="SourceUnderTest" /> +