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" />
+