diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs
new file mode 100644
index 0000000..1878d9a
--- /dev/null
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs
@@ -0,0 +1,70 @@
+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
+{
+ /// Splits a peer directory into one slice per owning item, plus the rest.
+ 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);
+ }
+
+ /// Adds the item whose ID hashes the whole directory, if it isn't already an owner.
+ public static List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> WithKeyedOwner(
+ SlskdDirectoryPartition partition,
+ SlskdDownloadItem? keyed,
+ SlskdDownloadDirectory directory)
+ {
+ List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> owners = [.. partition.Owners];
+
+ // Returning early on the hash match would starve a co-owner that enqueued
+ // only part of the directory — the same starvation this class exists to fix.
+ if (keyed != null && !owners.Any(owner => ReferenceEquals(owner.Item, keyed)))
+ owners.Add((keyed, directory));
+
+ return owners;
+ }
+}
diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
index 4c4e6e1..1212cf4 100644
--- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
@@ -837,92 +837,127 @@ 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)
+ {
+ SlskdDirectoryPartition partition = SlskdDirectoryPartitioner.Partition(dir, GetItemsForDef(definitionId), username);
- if (item == null)
- continue;
+ // 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.
+ SlskdDownloadItem? keyed = GetItem(definitionId, SlskdDownloadItem.GetStableMD5Id(dir.Files?.Select(f => f.Filename) ?? []));
+ List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> owners = SlskdDirectoryPartitioner.WithKeyedOwner(partition, keyed, dir);
- 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;
+
+ // AddItem overwrites by ID, and the history probe matches on the enqueued
+ // set — so a live item's own rejected file would rebuild it here and drop
+ // the transfer state it has accumulated.
+ if (GetItem(definitionId, item.ID) != null)
+ return null;
+
+ SubscribeStateChanges(item, definitionId);
+ AddItem(definitionId, item);
+ return item;
}
- private SlskdDownloadItem? FindItemOwningDirectory(int definitionId, string username, SlskdDownloadDirectory dir)
+ /// Attaches one item's share of a peer directory and its derived state.
+ 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" />
+