Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/Sleezer/Download/Clients/Soulseek/SlskdDirectoryPartitioner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models;

namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek;

/// <summary>One item's share of a peer directory, and the files no tracked item claimed.</summary>
public record SlskdDirectoryPartition(
List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> Owners,
SlskdDownloadDirectory? Unclaimed);

/// <summary>
/// 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.
/// </summary>
public static class SlskdDirectoryPartitioner
{
/// <summary>Splits a peer directory into one slice per owning item, plus the rest.</summary>
public static SlskdDirectoryPartition Partition(
SlskdDownloadDirectory directory,
IEnumerable<SlskdDownloadItem> candidates,
string username)
{
List<SlskdDownloadFile> files = directory.Files ?? [];
if (files.Count == 0)
return new SlskdDirectoryPartition([], null);

List<(SlskdDownloadItem Item, SlskdDownloadDirectory Slice)> owners = [];
HashSet<string> 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<SlskdDownloadFile> 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<SlskdDownloadFile> unclaimed = files.Where(f => !claimed.Contains(f.Filename)).ToList();

return new SlskdDirectoryPartition(
owners,
unclaimed.Count > 0 ? new SlskdDownloadDirectory(directory.Directory, unclaimed.Count, unclaimed) : null);
}

/// <summary>Adds the item whose ID hashes the whole directory, if it isn't already an owner.</summary>
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;
}
}
183 changes: 109 additions & 74 deletions src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Re-attaches transfers no tracked item claimed to the grab that started
/// them, or (inclusive mode) builds an item from the directory itself.
/// </summary>
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private SlskdDownloadItem? FindItemOwningDirectory(int definitionId, string username, SlskdDownloadDirectory dir)
/// <summary>Attaches one item's share of a peer directory and its derived state.</summary>
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
Expand Down
2 changes: 2 additions & 0 deletions tests/Sleezer.Tests/Sleezer.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Download\Clients\Soulseek\SlskdPathResolver.cs"
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Download\Clients\Soulseek\SlskdDirectoryPartitioner.cs"
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Download\Clients\Soulseek\SlskdStatusResolver.cs"
LinkBase="SourceUnderTest" />
<!-- Watchdog cancel path. Only the API *interface* is pulled in (tests fake it),
Expand Down
Loading
Loading