diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs index 1e95c77..4c4e6e1 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs @@ -1511,7 +1511,12 @@ private async Task CleanupSupersededAttemptsAsync(List stale, private async Task RemoveItemFilesAsync(SlskdDownloadItem item, SlskdProviderSettings settings, HashSet? protectBasenames = null) { - List files = item.SlskdDownloadDirectory?.Files ?? []; + // The merged directory view can carry another item's transfers from a shared + // peer directory; cancelling/removing those would kill a live download. + List files = (item.SlskdDownloadDirectory?.Files ?? []) + .Where(f => item.OwnsAcceptedFile(f.Filename)) + .ToList(); + if (files.Count == 0 || item.Username == null) { _logger.Debug("No slskd transfers to cancel for {ItemId} (directory not populated); relying on local folder deletion", item.ID); diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdWatchdog.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdWatchdog.cs index e193588..7f6270e 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdWatchdog.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdWatchdog.cs @@ -41,6 +41,11 @@ public async Task InspectAsync(SlskdDownloadItem item, SlskdProviderSettings set foreach (SlskdFileState fileState in item.FileStates.Values) { + // A shared peer directory can put another item's transfers in FileStates — + // never cancel a transfer slskd didn't accept for this item. + if (!item.OwnsAcceptedFile(fileState.File.Filename)) + continue; + if (fileState.WatchdogCancelled) continue; diff --git a/tests/Sleezer.Tests/Sleezer.Tests.csproj b/tests/Sleezer.Tests/Sleezer.Tests.csproj index bea3bfc..c1d6c57 100644 --- a/tests/Sleezer.Tests/Sleezer.Tests.csproj +++ b/tests/Sleezer.Tests/Sleezer.Tests.csproj @@ -109,6 +109,14 @@ LinkBase="SourceUnderTest" /> + + + + diff --git a/tests/Sleezer.Tests/SlskdWatchdogTests.cs b/tests/Sleezer.Tests/SlskdWatchdogTests.cs new file mode 100644 index 0000000..c6363c3 --- /dev/null +++ b/tests/Sleezer.Tests/SlskdWatchdogTests.cs @@ -0,0 +1,126 @@ +using FluentValidation.Results; +using NLog; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek; +using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models; +using NzbDrone.Plugin.Sleezer.Indexers.Soulseek; +using Xunit; + +namespace Sleezer.Tests; + +// ProcessUserTransfers assigns a whole per-peer-directory transfer group to the +// item owning the group's FIRST file, so two items sharing a peer directory can +// see each other's transfers in FileStates. The watchdog cancels at slskd, so an +// unfiltered sweep here kills the other item's live download. +public class SlskdWatchdogTests +{ + private sealed class RecordingApiClient : ISlskdApiClient + { + public List Deleted { get; } = []; + + public Task DeleteTransferAsync(SlskdProviderSettings settings, string username, string fileId, bool remove = false) + { + Deleted.Add(fileId); + return Task.CompletedTask; + } + + public Task EnqueueDownloadAsync(SlskdProviderSettings settings, string username, IEnumerable<(string Filename, long Size)> files, string? externalId = null, string? destination = null) => + Task.FromResult(new SlskdEnqueueResult(null, [], [])); + public Task> GetAllTransfersAsync(SlskdProviderSettings settings, bool includeRemoved = false) => + Task.FromResult(new List()); + public Task GetUserTransfersAsync(SlskdProviderSettings settings, string username) => + Task.FromResult(null); + public Task GetTransferAsync(SlskdProviderSettings settings, string username, string fileId) => + Task.FromResult(null); + public Task GetQueuePositionAsync(SlskdProviderSettings settings, string username, string fileId) => + Task.FromResult(null); + public Task DeleteAllCompletedAsync(SlskdProviderSettings settings) => Task.CompletedTask; + public Task GetDownloadPathAsync(SlskdProviderSettings settings) => + Task.FromResult(null); + public Task GetDestinationConfigAsync(SlskdProviderSettings settings) => + Task.FromResult(null); + public Task TestConnectionAsync(SlskdProviderSettings settings) => + Task.FromResult(null); + public Task<(List Events, int TotalCount)> GetEventsAsync(SlskdProviderSettings settings, int offset, int limit) => + Task.FromResult((new List(), 0)); + } + + private const string OwnedFile = @"@@peer\Artist\Album\01.flac"; + private const string ForeignFile = @"@@peer\Artist\Album\02.flac"; + + // Queued past the position threshold is the one bailout that needs no aged + // timestamps — FirstQueuedAt is only set on a second UpdateFile. + private static SlskdDownloadFile StuckFile(string id, string filename) => new( + Id: id, + Username: "peer", + Direction: "Download", + Filename: filename, + Size: 1000, + StartOffset: 0, + State: "Queued, Remotely", + RequestedAt: DateTime.UtcNow, + EnqueuedAt: DateTime.UtcNow, + StartedAt: DateTime.MinValue, + BytesTransferred: 0, + AverageSpeed: 0, + BytesRemaining: 1000, + ElapsedTime: TimeSpan.Zero, + PercentComplete: 0, + RemainingTime: TimeSpan.Zero, + EndedAt: null, + PlaceInQueue: 9999); + + private static SlskdDownloadItem NewItem(params string[] filenames) + { + string source = "[" + string.Join(",", filenames.Select(f => + $"{{\"Filename\":{System.Text.Json.JsonSerializer.Serialize(f)},\"Size\":1000}}")) + "]"; + return new SlskdDownloadItem(new ReleaseInfo { Source = source, Title = "t", DownloadUrl = "u" }) + { + Username = "peer" + }; + } + + [Fact] + public async Task Watchdog_cancels_only_the_files_this_item_enqueued() + { + SlskdDownloadItem item = NewItem(OwnedFile); + + // Shared peer directory: slskd reports both items' transfers in one group. + item.SlskdDownloadDirectory = new SlskdDownloadDirectory( + @"@@peer\Artist\Album", + 2, + [StuckFile("owned-id", OwnedFile), StuckFile("foreign-id", ForeignFile)]); + + Assert.Equal(2, item.FileStates.Count); + + RecordingApiClient api = new(); + SlskdProviderSettings settings = new() { MaxQueuePositionBeforeCancel = 500 }; + + await new SlskdWatchdog(api, LogManager.GetLogger("tests")) + .InspectAsync(item, settings, CancellationToken.None); + + Assert.Equal(["owned-id"], api.Deleted); + } + + // slskd creates no transfer for a file it rejected, so one under that name + // is another item's — cancelling it would kill a live download. + [Fact] + public async Task Watchdog_leaves_a_transfer_slskd_rejected_for_this_item_alone() + { + SlskdDownloadItem item = NewItem(OwnedFile, ForeignFile); + item.MarkEnqueueFailed([ForeignFile]); + + item.SlskdDownloadDirectory = new SlskdDownloadDirectory( + @"@@peer\Artist\Album", + 2, + [StuckFile("owned-id", OwnedFile), StuckFile("rejected-id", ForeignFile)]); + + RecordingApiClient api = new(); + SlskdProviderSettings settings = new() { MaxQueuePositionBeforeCancel = 500 }; + + await new SlskdWatchdog(api, LogManager.GetLogger("tests")) + .InspectAsync(item, settings, CancellationToken.None); + + Assert.Equal(["owned-id"], api.Deleted); + } +}