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