diff --git a/src/Sleezer/Core/Utilities/AudioFormat.cs b/src/Sleezer/Core/Utilities/AudioFormat.cs
index adbeb98..c7bb9d2 100644
--- a/src/Sleezer/Core/Utilities/AudioFormat.cs
+++ b/src/Sleezer/Core/Utilities/AudioFormat.cs
@@ -209,6 +209,10 @@ public static bool TryGetFileExtensionForCodec(string codec, out string extensio
_ => AudioFormat.Unknown
};
+ /// True when the filename's extension maps to a known audio format.
+ public static bool IsAudioFilename(string? filename) =>
+ GetAudioCodecFromExtension(Path.GetExtension(filename ?? string.Empty)) != AudioFormat.Unknown;
+
///
/// Returns the default bitrate for a given audio format.
///
diff --git a/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs
index 565a19b..a9aaa7a 100644
--- a/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/ISlskdDownloadManager.cs
@@ -9,4 +9,5 @@ public interface ISlskdDownloadManager
Task DownloadAsync(RemoteAlbum remoteAlbum, int definitionId, SlskdProviderSettings settings);
IEnumerable GetItems(int definitionId, SlskdProviderSettings settings, OsPath remotePath);
void RemoveItem(DownloadClientItem clientItem, bool deleteData, int definitionId, SlskdProviderSettings settings);
+ void ImportExtrasForImportedAlbum(string downloadId, IReadOnlyCollection importedTrackPaths);
}
diff --git a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs
index 37051e2..1337748 100644
--- a/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs
@@ -6,6 +6,7 @@
using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model;
using System.Text.Json;
+using NzbDrone.Plugin.Sleezer.Core.Utilities;
using NzbDrone.Plugin.Sleezer.Indexers.Soulseek;
namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models;
@@ -135,6 +136,14 @@ public SlskdDownloadItem(ReleaseInfo releaseInfo)
public bool OwnsFile(string? remoteFilename) =>
!string.IsNullOrEmpty(remoteFilename) && _enqueuedFilenames.Contains(remoteFilename);
+ ///
+ /// True when this item enqueued the file AND slskd accepted it — the
+ /// ownership test for anything reading transfer state, since a rejected
+ /// file's only possible transfer belongs to another item.
+ ///
+ public bool OwnsAcceptedFile(string? remoteFilename) =>
+ OwnsFile(remoteFilename) && !_enqueueFailedFilenames.Contains(remoteFilename!);
+
///
/// Records files slskd rejected at enqueue time. They will never produce a
/// transfer, so completion tracking must not wait for them.
@@ -148,6 +157,60 @@ public void MarkEnqueueFailed(IEnumerable filenames)
/// Files that were actually accepted by slskd.
public int ExpectedFileCount => Math.Max(0, FileData.Count - _enqueueFailedFilenames.Count);
+ ///
+ /// A terminally-failed non-audio extra (cue/log) — skipped from status and
+ /// completion so a broken extra can never fail an otherwise-complete album.
+ ///
+ public static bool IsAbandonedExtra(SlskdFileState state) =>
+ state.GetStatus() == DownloadItemStatus.Failed &&
+ !AudioFormatHelper.IsAudioFilename(state.File.Filename);
+
+ /// Every accepted file completed; abandoned extras don't block completion.
+ public bool AllAcceptedFilesCompleted()
+ {
+ if (_previousFileStates.Count == 0)
+ return false;
+
+ // Case-insensitive to match OwnsFile; _previousFileStates is ordinal.
+ Dictionary statesByName = new(StringComparer.OrdinalIgnoreCase);
+ foreach (KeyValuePair kvp in _previousFileStates)
+ statesByName.TryAdd(kvp.Key, kvp.Value);
+
+ bool anyAccepted = false;
+ foreach (SlskdFileData file in FileData)
+ {
+ if (file.Filename is not { Length: > 0 } filename || !OwnsAcceptedFile(filename))
+ continue;
+
+ // Identity, not count: an accepted file with no transfer yet blocks
+ // completion, so foreign records in a shared peer dir can't pad it.
+ if (!statesByName.TryGetValue(filename, out SlskdFileState? state))
+ return false;
+
+ if (IsAbandonedExtra(state))
+ continue;
+
+ if (state.GetStatus() != DownloadItemStatus.Completed)
+ return false;
+
+ anyAccepted = true;
+ }
+
+ // Nothing importable (every accepted file was an abandoned extra) never completes.
+ return anyAccepted;
+ }
+
+ /// Local basenames of the enqueued non-audio files (cue/log extras).
+ public IReadOnlyList NonAudioBasenames() =>
+ FileData
+ .Where(f => f.Filename is { Length: > 0 } filename &&
+ OwnsAcceptedFile(filename) &&
+ !AudioFormatHelper.IsAudioFilename(filename))
+ .Select(f => Path.GetFileName(f.Filename!.Replace('\\', '/')))
+ .Where(n => !string.IsNullOrEmpty(n))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
/// True when this item tracks transfers for the given remote directory.
public bool TracksRemoteDirectory(string? remoteDirectory) =>
!string.IsNullOrEmpty(remoteDirectory) && _remoteDirectories.ContainsKey(remoteDirectory);
diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
index bfb79cb..1e95c77 100644
--- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
@@ -352,6 +352,110 @@ public void RemoveItem(DownloadClientItem clientItem, bool deleteData, int defin
_ = CleanStaleDirectoriesAsync(directory, ownedFileSizes, settings);
}
+ ///
+ /// Copies this grab's cue/log extras into the album folder Lidarr just
+ /// imported into. Runs on AlbumImportedEvent, which fires before the
+ /// download folder is cleaned up, so the source is still on disk.
+ ///
+ public void ImportExtrasForImportedAlbum(string downloadId, IReadOnlyCollection importedTrackPaths)
+ {
+ try
+ {
+ // Most imports are other download clients' — miss quietly, before any disk work.
+ KeyValuePair, SlskdDownloadItem> tracked = _downloadMappings
+ .FirstOrDefault(kvp => string.Equals(kvp.Value.ID, downloadId, StringComparison.OrdinalIgnoreCase));
+ SlskdDownloadItem? item = tracked.Value;
+ if (item == null)
+ {
+ _logger.Trace("No tracked slskd item for {DownloadId}; nothing to import", downloadId);
+ return;
+ }
+
+ IReadOnlyList extras = item.NonAudioBasenames();
+ if (extras.Count == 0)
+ {
+ _logger.Trace("[{ItemId}] Grab has no extra files to import", item.ID);
+ return;
+ }
+
+ int definitionId = tracked.Key.OuterKey;
+ if (!_settingsCache.TryGetValue(definitionId, out SlskdProviderSettings? settings))
+ {
+ _logger.Debug("[{ItemId}] No cached settings for definition {DefinitionId}; skipping extra import", item.ID, definitionId);
+ return;
+ }
+
+ string root = GetRemoteDownloadPath(settings).FullPath.TrimEnd('/', '\\');
+ string folder = item.GetFullFolderPath(new OsPath(root)).FullPath.TrimEnd('/', '\\');
+ if (!IsStrictDescendantOfRoot(folder, root) || !_diskProvider.FolderExists(folder))
+ folder = FindFolderOwningItemFiles(item, root) ?? string.Empty;
+
+ // Defense in depth: the recovery path is basename-driven, so re-confine it.
+ if (folder.Length == 0 || !IsStrictDescendantOfRoot(folder, root))
+ {
+ _logger.Warn("[{ItemId}] No download folder inside '{Root}' to read extras from; skipping extra import", item.ID, root);
+ return;
+ }
+
+ string? destination = SlskdPathResolver.CommonParentDirectory(importedTrackPaths);
+ if (string.IsNullOrEmpty(destination) || !_diskProvider.FolderExists(destination))
+ {
+ _logger.Warn("[{ItemId}] Imported tracks share no existing album folder; skipping extra import", item.ID);
+ return;
+ }
+
+ CopyExtrasIntoAlbumFolder(item, extras, folder, destination);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn(ex, "Failed to import extra files for {DownloadId}", downloadId);
+ }
+ }
+
+ ///
+ /// Copies only extras this item conclusively owns (basename AND size), so a
+ /// foreign file in a shared download folder is never pulled into the library.
+ ///
+ private void CopyExtrasIntoAlbumFolder(SlskdDownloadItem item, IReadOnlyList extras, string folder, string destination)
+ {
+ Dictionary ownedFileSizes = item.BuildOwnedFileSizes();
+ Dictionary onDisk = new(StringComparer.OrdinalIgnoreCase);
+ foreach (string file in _diskProvider.GetFiles(folder, recursive: true))
+ onDisk.TryAdd(Path.GetFileName(file), file);
+
+ int copied = 0;
+ foreach (string basename in extras)
+ {
+ if (!onDisk.TryGetValue(basename, out string? source))
+ {
+ _logger.Debug("[{ItemId}] Extra '{Extra}' not downloaded — skipping", item.ID, basename);
+ continue;
+ }
+
+ if (!IsConclusivelyOwned(source, ownedFileSizes))
+ {
+ _logger.Debug("[{ItemId}] Extra '{Extra}' in '{Folder}' is not conclusively this download's — skipping", item.ID, basename, folder);
+ continue;
+ }
+
+ try
+ {
+ // Overwrite mirrors track replacement on upgrade — a stale log
+ // from the previous rip must not survive. Copy, never move:
+ // Lidarr expects the download folder intact until it removes it.
+ _diskProvider.CopyFile(source, Path.Combine(destination, basename), true);
+ copied++;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn(ex, "[{ItemId}] Failed to copy extra '{Extra}' into '{Folder}'", item.ID, basename, destination);
+ }
+ }
+
+ if (copied > 0)
+ _logger.Info("Imported {Count} extra file(s) into {Folder}", copied, destination);
+ }
+
private void TryDeleteOutputFolder(DownloadClientItem clientItem, Dictionary ownedFileSizes, SlskdProviderSettings settings)
{
if (clientItem.OutputPath.IsEmpty)
@@ -805,7 +909,7 @@ private void ProcessUserTransfers(
// 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 (AllFilesCompleted(item))
+ if (item.AllAcceptedFilesCompleted())
EnqueuePostProcess(item, settings);
}
}
@@ -973,25 +1077,6 @@ private SlskdStatusResolver.DownloadStatus FailWhenCompletedFilesVanished(
}
}
- private static bool AllFilesCompleted(SlskdDownloadItem item)
- {
- IReadOnlyDictionary states = item.FileStates;
- if (states.Count == 0)
- return false;
-
- // Multi-disc: transfer state arrives per remote directory, so wait until
- // every ACCEPTED file has reported before declaring the album complete
- // (enqueue-rejected files never produce a transfer).
- if (item.ExpectedFileCount > 0 && states.Count < item.ExpectedFileCount)
- return false;
-
- foreach (SlskdFileState state in states.Values)
- if (state.GetStatus() != DownloadItemStatus.Completed)
- return false;
-
- return true;
- }
-
private async Task PollEventsAsync(int definitionId, SlskdProviderSettings settings, int offset)
{
(List events, _) = await _apiClient.GetEventsAsync(settings, offset, 50);
@@ -1053,7 +1138,7 @@ private async Task HandleEventAsync(int definitionId, SlskdProviderSettings sett
// Multi-disc items get one DownloadDirectoryComplete per disc;
// only post-process once every enqueued file is done.
- if (AllFilesCompleted(item))
+ if (item.AllAcceptedFilesCompleted())
EnqueuePostProcess(item, settings);
else
_logger.Trace("[def={DefinitionId}] Directory {RemoteDir} complete but item {ItemId} still has pending files — waiting", definitionId, remoteDir, item.ID);
diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs
new file mode 100644
index 0000000..8f28a90
--- /dev/null
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs
@@ -0,0 +1,35 @@
+using NLog;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Events;
+
+namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek;
+
+///
+/// Copies a grab's non-audio extras (cue/log) into the imported album folder.
+/// Lidarr's own ExtraService only imports per-track-basename extras, so
+/// album-level rip artifacts never survive import without this.
+///
+public class SlskdExtrasImportService(ISlskdDownloadManager downloadManager, Logger logger) : IHandle
+{
+ public void Handle(AlbumImportedEvent message)
+ {
+ if (string.IsNullOrEmpty(message.DownloadId))
+ return;
+
+ List trackPaths = message.ImportedTracks
+ .Select(t => t.Path)
+ .Where(p => !string.IsNullOrEmpty(p))
+ .ToList();
+ if (trackPaths.Count == 0)
+ return;
+
+ try
+ {
+ downloadManager.ImportExtrasForImportedAlbum(message.DownloadId, trackPaths);
+ }
+ catch (Exception ex)
+ {
+ logger.Warn(ex, "Failed to import slskd extra files for download {DownloadId}", message.DownloadId);
+ }
+ }
+}
diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs
index d139271..5370479 100644
--- a/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs
@@ -36,6 +36,44 @@ public static partial class SlskdPathResolver
return bestMatches * 2 > ownedFileCount ? best : null;
}
+ /// Deepest directory containing every given file path; null when they share no root.
+ public static string? CommonParentDirectory(IReadOnlyCollection filePaths)
+ {
+ List segmentLists = [];
+ foreach (string filePath in filePaths)
+ {
+ // Fail closed: dropping a parentless path would return a folder that
+ // doesn't contain it, and the caller copies extras into that folder.
+ string? directory = Path.GetDirectoryName(filePath);
+ if (string.IsNullOrEmpty(directory))
+ return null;
+
+ segmentLists.Add(directory.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]));
+ }
+
+ if (segmentLists.Count == 0)
+ return null;
+
+ // A Unix absolute path splits to a leading "" segment — keep it so the
+ // rebuilt path keeps its leading separator.
+ string[] first = segmentLists[0];
+ int common = first.Length;
+ foreach (string[] segments in segmentLists.Skip(1))
+ {
+ common = Math.Min(common, segments.Length);
+ int i = 0;
+ while (i < common && string.Equals(first[i], segments[i], StringComparison.Ordinal))
+ i++;
+ common = i;
+ }
+
+ // Sharing only the leading "" (or the bare root) is not a usable parent.
+ if (!first.Take(common).Any(s => s.Length > 0))
+ return null;
+
+ return string.Join(Path.DirectorySeparatorChar, first.Take(common));
+ }
+
public static string? ResolveSubdirectory(
SlskdDestinationConfig config, string username, string remoteFilename, string? batchId = null, string? externalId = null)
{
diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs
index 9b5f9c0..3c54509 100644
--- a/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs
@@ -1,6 +1,7 @@
using NLog;
using NzbDrone.Core.Download;
using System.Text.Json;
+using NzbDrone.Plugin.Sleezer.Core.Utilities;
using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models;
namespace NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek;
@@ -18,6 +19,10 @@ public void OnFileStateChanged(SlskdDownloadItem? item, SlskdFileState fileState
return;
if (item == null)
return;
+ // Foreign transfers (shared peer directory) and files slskd rejected at
+ // enqueue both raise state changes; retrying either fails this item.
+ if (!item.OwnsAcceptedFile(fileState.File.Filename))
+ return;
_logger.Trace("Retry triggered: {Filename} | State: {State} | Attempt: {Attempt}/{Max}", Path.GetFileName(fileState.File.Filename), fileState.State, fileState.RetryCount + 1, fileState.MaxRetryCount);
_ = RetryDownloadAsync(item, fileState, settings);
@@ -58,7 +63,12 @@ private async Task RetryDownloadAsync(SlskdDownloadItem item, SlskdFileState fil
// GetStatus still lets a Completed/Downloading transport state win,
// so a healthy retry that succeeds isn't cancelled.
if (fileState.RetryCount >= fileState.MaxRetryCount)
+ {
fileState.MarkRetriesExhausted();
+ // Only log for the drop — the resolver skips abandoned extras silently.
+ if (!AudioFormatHelper.IsAudioFilename(fileState.File.Filename))
+ _logger.Warn("Extra file {Filename} failed permanently; the album will complete without it", Path.GetFileName(fileState.File.Filename));
+ }
}
}
diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs
index c4a450e..94421e2 100644
--- a/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs
+++ b/src/Sleezer/Download/Clients/Soulseek/SlskdStatusResolver.cs
@@ -18,14 +18,24 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout,
if (item.SlskdDownloadDirectory?.Files == null)
return new(DownloadItemStatus.Queued, null, 0, 0, null);
- IReadOnlyList files = item.SlskdDownloadDirectory.Files;
+ // A shared peer directory can put another item's transfers in this view —
+ // only files slskd accepted for THIS item may drive its status.
+ List ownedFiles = item.SlskdDownloadDirectory.Files
+ .Where(f => item.OwnsAcceptedFile(f.Filename))
+ .ToList();
long totalSize = 0, remainingSize = 0, totalSpeed = 0;
bool anyActive = false, anyIncomplete = false, allIncompleteRemoteQueued = true;
DateTime lastActivity = DateTime.MinValue;
- foreach (SlskdDownloadFile f in files)
+ foreach (SlskdDownloadFile f in ownedFiles)
{
+ // An abandoned extra contributes nothing — not to totals, activity,
+ // nor the all-stuck check; it can never hold the album back.
+ if (item.FileStates.TryGetValue(f.Filename, out SlskdFileState? abandonCheck) &&
+ SlskdDownloadItem.IsAbandonedExtra(abandonCheck))
+ continue;
+
totalSize += f.Size;
remainingSize += f.BytesRemaining;
@@ -66,12 +76,21 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout,
bool allStuckInRemoteQueue = anyIncomplete && allIncompleteRemoteQueued;
- int totalFileCount = 0, failedCount = 0, completedCount = 0;
+ int totalFileCount = 0, failedCount = 0, completedCount = 0, abandonedExtras = 0;
bool anyWarning = false, anyPaused = false, anyDownloadingState = false;
List failedFileNames = [];
foreach (SlskdFileState fs in item.FileStates.Values)
{
+ if (!item.OwnsAcceptedFile(fs.File.Filename))
+ continue;
+
+ if (SlskdDownloadItem.IsAbandonedExtra(fs))
+ {
+ abandonedExtras++;
+ continue;
+ }
+
totalFileCount++;
DownloadItemStatus s = fs.GetStatus();
switch (s)
@@ -115,6 +134,8 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout,
status = item.PostProcessTasks.Any(t => !t.IsCompleted)
? DownloadItemStatus.Downloading
: DownloadItemStatus.Completed;
+ if (abandonedExtras > 0)
+ message = $"Completed; {abandonedExtras} extra file(s) failed and were skipped";
}
else if (anyPaused)
{
@@ -138,7 +159,7 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout,
// the Lidarr UI a "queued at position X" summary instead of a blank.
if (message == null && (status == DownloadItemStatus.Queued || status == DownloadItemStatus.Downloading))
{
- message = BuildQueueMessage(files);
+ message = BuildQueueMessage(item, ownedFiles);
}
TimeSpan? remainingTime = totalSpeed > 0
@@ -148,7 +169,7 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout,
return new(status, message, totalSize, remainingSize, remainingTime);
}
- private static string? BuildQueueMessage(IReadOnlyList files)
+ private static string? BuildQueueMessage(SlskdDownloadItem item, IReadOnlyList files)
{
int queuedCount = 0;
int downloadingCount = 0;
@@ -157,6 +178,12 @@ public static DownloadStatus Resolve(SlskdDownloadItem item, TimeSpan? timeout,
foreach (SlskdDownloadFile f in files)
{
+ // Same skip as the totals loop above — an extra that exhausted its
+ // retries is abandoned, so it must never be reported as queued.
+ if (item.FileStates.TryGetValue(f.Filename, out SlskdFileState? abandonCheck) &&
+ SlskdDownloadItem.IsAbandonedExtra(abandonCheck))
+ continue;
+
DownloadItemStatus fs = SlskdFileState.GetStatus(f.State);
switch (fs)
{
diff --git a/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs b/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
index 7edb268..facdebd 100644
--- a/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
+++ b/src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
@@ -38,9 +38,13 @@ public static IEnumerable GetFilteredFiles(List fi
{
string? extension = !string.IsNullOrWhiteSpace(file.Extension) ? file.Extension : Path.GetExtension(file.Filename);
+ // The whitelist is stored dotless but peers report either form (and
+ // the filename fallback always yields ".cue") — compare normalized.
+ string normalizedExtension = (extension ?? string.Empty).TrimStart('.');
+
if (onlyIncludeAudio &&
- AudioFormatHelper.GetAudioCodecFromExtension(extension ?? "") == AudioFormat.Unknown &&
- !(includedFileExtensions?.Contains(extension, StringComparer.OrdinalIgnoreCase) ?? false))
+ AudioFormatHelper.GetAudioCodecFromExtension(normalizedExtension) == AudioFormat.Unknown &&
+ !(includedFileExtensions?.Contains(normalizedExtension, StringComparer.OrdinalIgnoreCase) ?? false))
{
continue;
}
diff --git a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs
index 3d5e659..060138a 100644
--- a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs
+++ b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs
@@ -216,7 +216,10 @@ public AlbumData CreateAlbumData(string searchId, IGrouping audioForQuality = filesToDownload.Where(IsAudioFile).ToList();
+ (AudioFormat Codec, int? BitRate, int? BitDepth, int? SampleRate, long TotalSize, int TotalDuration)
+ = AnalyzeAudioQuality(audioForQuality.Count > 0 ? audioForQuality : filesToDownload);
string qualityInfo = FormatQualityInfo(Codec, BitRate, BitDepth, SampleRate);
_logger.Trace("Audio: {Codec}, BitRate: {BitRate}, BitDepth: {BitDepth}, Files: {TrackCount}", Codec, BitRate, BitDepth, actualTrackCount);
@@ -266,7 +269,7 @@ public AlbumData CreateAlbumData(string searchId, IGrouping f.Size),
InfoUrl = infoUrl,
ExplicitContent = ExtractExplicitTag(folderData.Path),
Priotity = priority,
diff --git a/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs b/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs
index 0ecc828..f5dc7c4 100644
--- a/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs
+++ b/src/Sleezer/Indexers/Soulseek/SlskdSettings.cs
@@ -117,7 +117,7 @@ public class SlskdSettings : IIndexerSettings
[FieldDefinition(3, Type = FieldType.Checkbox, Label = "Audio Files Only", HelpText = "Return only audio file types")]
public bool OnlyAudioFiles { get; set; } = true;
- [FieldDefinition(4, Type = FieldType.Tag, Label = "File Extensions", HelpText = "Additional extensions when Audio Files Only is enabled (without dots)", Advanced = true)]
+ [FieldDefinition(4, Type = FieldType.Tag, Label = "File Extensions", HelpText = "Additional extensions when Audio Files Only is enabled (without dots, e.g. cue, log)", Advanced = true)]
public IEnumerable IncludeFileExtensions { get; set; } = [];
[FieldDefinition(6, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Days before release to allow downloads", Advanced = true)]
diff --git a/tests/Sleezer.Tests/Sleezer.Tests.csproj b/tests/Sleezer.Tests/Sleezer.Tests.csproj
index da0f387..bea3bfc 100644
--- a/tests/Sleezer.Tests/Sleezer.Tests.csproj
+++ b/tests/Sleezer.Tests/Sleezer.Tests.csproj
@@ -107,6 +107,8 @@
LinkBase="SourceUnderTest" />
+
diff --git a/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs b/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs
index 160596c..3b07ce0 100644
--- a/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs
+++ b/tests/Sleezer.Tests/SlskdDestinationRecoveryTests.cs
@@ -158,4 +158,54 @@ public void Relocation_relativizes_a_local_folder_against_the_local_root(string
{
Assert.Equal(expected, SlskdPathResolver.MakeRelativeToDownloads(root, folder));
}
+
+ // Import destination for cue/log extras: the album folder Lidarr just wrote
+ // the tracks into, derived from the imported track paths alone.
+ private static string P(params string[] segments) => string.Join(Path.DirectorySeparatorChar, segments);
+
+ [Fact]
+ public void CommonParentDirectory_is_the_folder_the_tracks_share()
+ {
+ Assert.Equal(P("", "music", "Artist", "Album"), SlskdPathResolver.CommonParentDirectory(
+ [P("", "music", "Artist", "Album", "01.flac"), P("", "music", "Artist", "Album", "02.flac")]));
+ }
+
+ [Fact]
+ public void CommonParentDirectory_folds_disc_subfolders_into_the_album_folder()
+ {
+ Assert.Equal(P("", "music", "Album"), SlskdPathResolver.CommonParentDirectory(
+ [P("", "music", "Album", "CD1", "01.flac"), P("", "music", "Album", "CD2", "01.flac")]));
+ }
+
+ [Fact]
+ public void CommonParentDirectory_of_one_path_is_its_own_directory()
+ {
+ Assert.Equal(P("", "music", "Album"), SlskdPathResolver.CommonParentDirectory([P("", "music", "Album", "01.flac")]));
+ }
+
+ [Fact]
+ public void CommonParentDirectory_is_null_without_paths()
+ {
+ Assert.Null(SlskdPathResolver.CommonParentDirectory([]));
+ }
+
+ [Fact]
+ public void CommonParentDirectory_is_null_when_nothing_past_the_root_is_shared()
+ {
+ Assert.Null(SlskdPathResolver.CommonParentDirectory(
+ [P("", "music", "A", "01.flac"), P("", "other", "B", "01.flac")]));
+ }
+
+ [Fact]
+ public void CommonParentDirectory_is_null_for_bare_filenames()
+ {
+ Assert.Null(SlskdPathResolver.CommonParentDirectory(["01.flac", "02.flac"]));
+ }
+
+ [Fact]
+ public void CommonParentDirectory_is_null_when_one_path_has_no_parent()
+ {
+ Assert.Null(SlskdPathResolver.CommonParentDirectory(
+ [P("", "music", "Album", "01.flac"), "02.flac"]));
+ }
}
diff --git a/tests/Sleezer.Tests/SlskdExtensionFilterTests.cs b/tests/Sleezer.Tests/SlskdExtensionFilterTests.cs
new file mode 100644
index 0000000..d96beb9
--- /dev/null
+++ b/tests/Sleezer.Tests/SlskdExtensionFilterTests.cs
@@ -0,0 +1,58 @@
+using NzbDrone.Plugin.Sleezer.Indexers.Soulseek;
+using Xunit;
+
+namespace Sleezer.Tests;
+
+// IncludeFileExtensions is stored dotless, but peers report ".log" with the dot
+// and the filename fallback always yields ".cue" — both used to miss the
+// whitelist and get dropped silently, so cue/log could never be downloaded.
+public class SlskdExtensionFilterTests
+{
+ private static SlskdFileData F(string filename, string? extension) => new(
+ Filename: filename, BitRate: null, BitDepth: null, Size: 1000, Length: null,
+ Extension: extension, SampleRate: null, Code: 1, IsLocked: false);
+
+ private static List Filter(List files, bool onlyAudio, params string[] whitelist) =>
+ SlskdFileData.GetFilteredFiles(files, onlyAudio, whitelist).Select(f => f.Filename!).ToList();
+
+ [Fact]
+ public void A_dotless_extension_attribute_matches_the_dotless_whitelist()
+ {
+ Assert.Single(Filter([F(@"a\b\rip.cue", "cue")], true, "cue"));
+ }
+
+ // Locks the dot-normalization fix: without it Path.GetExtension's ".cue"
+ // never matches the stored "cue".
+ [Fact]
+ public void A_missing_extension_attribute_falls_back_to_the_dotted_filename_extension()
+ {
+ Assert.Single(Filter([F(@"a\b\rip.cue", null)], true, "cue"));
+ }
+
+ // Locks the dot-normalization fix: some clients report the extension dotted.
+ [Fact]
+ public void A_dotted_extension_attribute_still_matches_the_dotless_whitelist()
+ {
+ Assert.Single(Filter([F(@"a\b\rip.log", ".log")], true, "log"));
+ }
+
+ [Fact]
+ public void A_non_whitelisted_extra_is_still_excluded()
+ {
+ Assert.Empty(Filter([F(@"a\b\folder.nfo", null)], true, "cue", "log"));
+ }
+
+ [Fact]
+ public void Audio_is_always_included_with_an_empty_whitelist()
+ {
+ Assert.Single(Filter([F(@"a\b\01 - Track.flac", null)], true));
+ }
+
+ [Fact]
+ public void Nothing_is_filtered_when_audio_only_is_off()
+ {
+ List files = [F(@"a\b\01.flac", "flac"), F(@"a\b\rip.cue", null), F(@"a\b\folder.nfo", null)];
+
+ Assert.Equal(3, Filter(files, false).Count);
+ }
+}
diff --git a/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs b/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs
new file mode 100644
index 0000000..9fd991a
--- /dev/null
+++ b/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs
@@ -0,0 +1,80 @@
+using NLog;
+using NzbDrone.Plugin.Sleezer.Core.Model;
+using NzbDrone.Plugin.Sleezer.Core.Utilities;
+using NzbDrone.Plugin.Sleezer.Indexers.Soulseek;
+using Xunit;
+
+namespace Sleezer.Tests;
+
+// Whitelisted cue/log extras ride along in the download set but must not be
+// treated as tracks: codec and bitrate come from the audio subset, while Size
+// stays the full download so the queue reports what actually transfers.
+public class SlskdExtrasFlowTests
+{
+ private static readonly SlskdItemsParser Parser = new(LogManager.GetCurrentClassLogger());
+
+ private static SlskdFileData F(string filename, string ext, long size, int? length) => new(
+ Filename: filename, BitRate: null, BitDepth: null, Size: size, Length: length,
+ Extension: ext, SampleRate: null, Code: 1, IsLocked: false);
+
+ private static AlbumData Build(string dir, SlskdFileData[] files, string artist, string album,
+ string[] tracks, int expectedTrackCount, string albumType)
+ {
+ IGrouping group = files.GroupBy(_ => dir).Single();
+ SlskdFolderData folder = Parser.ParseFolderName(dir) with
+ {
+ Username = "user",
+ HasFreeUploadSlot = true,
+ FileCount = files.Length
+ };
+ SlskdSearchData search = new(artist, album, false, false, 1, null,
+ TrackCount: expectedTrackCount, Tracks: tracks.ToList(), AlbumType: albumType);
+ return Parser.CreateAlbumData("search1", group, search, folder, null, expectedTrackCount);
+ }
+
+ [Fact]
+ public void Album_extras_ride_along_without_skewing_the_quality_analysis()
+ {
+ const string dir = @"@@u\Artist\Album (2001)";
+ AlbumData a = Build(dir,
+ [
+ F(dir + @"\01 - One.flac", "flac", 30_000_000, 300),
+ F(dir + @"\02 - Two.flac", "flac", 30_000_000, 300),
+ F(dir + @"\03 - Three.flac", "flac", 30_000_000, 300),
+ F(dir + @"\rip.cue", "cue", 2_000_000, null),
+ F(dir + @"\rip.log", "log", 1_000_000, null),
+ ],
+ artist: "Artist", album: "Album", tracks: ["One", "Two", "Three"],
+ expectedTrackCount: 3, albumType: "Album");
+
+ Assert.Contains("rip.cue", a.CustomString);
+ Assert.Contains("rip.log", a.CustomString);
+ Assert.Equal(AudioFormat.FLAC, a.Codec);
+ // Size is the whole transfer; the derived bitrate is audio-only (the
+ // extras' 3 MB would inflate it to 826 kbps).
+ Assert.Equal(93_000_000, a.Size);
+ Assert.Equal(800, a.Bitrate);
+ }
+
+ [Fact]
+ public void A_single_target_pluck_drops_the_album_level_extras()
+ {
+ const string dir = @"@@u\Artist\5150";
+ AlbumData a = Build(dir,
+ [
+ F(dir + @"\01 - Panama.flac", "flac", 1000, 200),
+ F(dir + @"\02 - Jump.flac", "flac", 1000, 200),
+ F(dir + @"\04 - Dreams.flac", "flac", 1000, 200),
+ F(dir + @"\rip.cue", "cue", 500, null),
+ F(dir + @"\rip.log", "log", 500, null),
+ ],
+ artist: "Artist", album: "Dreams", tracks: ["Dreams"],
+ expectedTrackCount: 1, albumType: "Single");
+
+ Assert.True(a.MatchedSearchCriteria);
+ Assert.Contains("Dreams", a.CustomString);
+ Assert.DoesNotContain("rip.cue", a.CustomString);
+ Assert.DoesNotContain("rip.log", a.CustomString);
+ Assert.Equal(1000, a.Size);
+ }
+}
diff --git a/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs
new file mode 100644
index 0000000..e61a691
--- /dev/null
+++ b/tests/Sleezer.Tests/SlskdStatusResolverAbandonTests.cs
@@ -0,0 +1,267 @@
+using System.Text.Json;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek;
+using NzbDrone.Plugin.Sleezer.Download.Clients.Soulseek.Models;
+using Xunit;
+
+namespace Sleezer.Tests;
+
+// A permanently-failed cue/log used to fail the WHOLE album: the resolver
+// counted it in failedCount and completion never turned true, so a flaky peer's
+// 2 KB log blocklisted a perfect rip. "Abandoned extra" is derived state — no
+// flags — so it survives restart rehydration and RetryAttempts=0.
+public class SlskdStatusResolverAbandonTests
+{
+ private const string Dir = @"@@u\Artist\Album";
+
+ private static SlskdDownloadItem NewItem(params (string Name, long Size)[] files)
+ {
+ string source = "[" + string.Join(",", files.Select(f =>
+ $"{{\"Filename\":{JsonSerializer.Serialize(Dir + "\\" + f.Name)},\"Size\":{f.Size}}}")) + "]";
+ return new SlskdDownloadItem(new ReleaseInfo { Source = source, Title = "t", DownloadUrl = "u" });
+ }
+
+ private static SlskdDownloadFile Transfer(string name, string state, long size) => new(
+ Id: name, Username: "peer", Direction: "Download", Filename: Dir + "\\" + name,
+ Size: size, StartOffset: 0, State: state,
+ RequestedAt: DateTime.UtcNow, EnqueuedAt: DateTime.UtcNow, StartedAt: DateTime.UtcNow,
+ BytesTransferred: 0, AverageSpeed: 0, BytesRemaining: 0,
+ ElapsedTime: TimeSpan.Zero, PercentComplete: 0, RemainingTime: TimeSpan.Zero, EndedAt: null);
+
+ private static void Transfers(SlskdDownloadItem item, params SlskdDownloadFile[] files) =>
+ item.SlskdDownloadDirectory = new SlskdDownloadDirectory(Dir, files.Length, files.ToList());
+
+ private static SlskdFileState State(SlskdDownloadItem item, string name) => item.FileStates[Dir + "\\" + name];
+
+ private static SlskdStatusResolver.DownloadStatus Resolve(SlskdDownloadItem item) =>
+ SlskdStatusResolver.Resolve(item, TimeSpan.FromMinutes(30), DateTime.UtcNow);
+
+ [Fact]
+ public void A_terminally_errored_cue_still_completes_the_album()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("03.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Completed, Succeeded", 1000),
+ Transfer("03.flac", "Completed, Succeeded", 1000),
+ Transfer("rip.cue", "Completed, Errored", 2000));
+ // RetryAttempts=0: the file is terminally failed with no retry ever fired.
+ State(item, "rip.cue").UpdateMaxRetryCount(0);
+
+ SlskdStatusResolver.DownloadStatus resolved = Resolve(item);
+
+ Assert.Equal(DownloadItemStatus.Completed, resolved.Status);
+ Assert.Contains("extra file", resolved.Message);
+ }
+
+ [Fact]
+ public void A_cue_stuck_in_the_remote_queue_past_its_retries_still_completes_the_album()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Completed, Succeeded", 1000),
+ Transfer("rip.cue", "Queued, Remotely", 2000));
+ State(item, "rip.cue").MarkRetriesExhausted();
+
+ Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status);
+ }
+
+ [Fact]
+ public void A_terminally_failed_audio_file_still_fails_the_album()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Completed, Errored", 1000),
+ Transfer("rip.cue", "Completed, Succeeded", 2000));
+ State(item, "02.flac").MarkRetriesExhausted();
+
+ SlskdStatusResolver.DownloadStatus resolved = Resolve(item);
+
+ Assert.Equal(DownloadItemStatus.Failed, resolved.Status);
+ Assert.Contains("02.flac", resolved.Message);
+ }
+
+ [Fact]
+ public void An_abandoned_extra_contributes_nothing_to_the_size_totals()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Completed, Succeeded", 1000),
+ Transfer("rip.cue", "Completed, Errored", 2000));
+ State(item, "rip.cue").MarkRetriesExhausted();
+
+ Assert.Equal(2000, Resolve(item).TotalSize);
+ }
+
+ [Fact]
+ public void AllAcceptedFilesCompleted_ignores_an_abandoned_extra()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("rip.cue", "Completed, Errored", 2000));
+ State(item, "rip.cue").MarkRetriesExhausted();
+
+ Assert.True(item.AllAcceptedFilesCompleted());
+ }
+
+ [Fact]
+ public void AllAcceptedFilesCompleted_still_waits_on_a_queued_audio_file()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Queued, Remotely", 1000),
+ Transfer("rip.cue", "Completed, Errored", 2000));
+ State(item, "rip.cue").MarkRetriesExhausted();
+
+ Assert.False(item.AllAcceptedFilesCompleted());
+ }
+
+ [Fact]
+ public void An_abandoned_extra_is_not_reported_as_queued_in_the_status_message()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "InProgress", 1000),
+ Transfer("rip.cue", "Queued, Remotely", 2000));
+ State(item, "rip.cue").MarkRetriesExhausted();
+
+ SlskdStatusResolver.DownloadStatus resolved = Resolve(item);
+
+ Assert.Contains("downloading", resolved.Message);
+ Assert.DoesNotContain("queued", resolved.Message);
+ }
+
+ // slskd hands the whole per-directory transfer group to whichever item owns
+ // file[0], so a shared peer folder puts another item's files in FileStates.
+ [Fact]
+ public void A_foreign_transfer_cannot_pad_the_count_for_a_file_that_never_reported()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000), ("rip.cue", 2000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("rip.cue", "Completed, Succeeded", 2000),
+ Transfer("foreign.flac", "Completed, Succeeded", 1000));
+
+ Assert.False(item.AllAcceptedFilesCompleted());
+ }
+
+ [Fact]
+ public void A_foreign_failed_transfer_does_not_fail_the_album()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Completed, Succeeded", 1000),
+ Transfer("foreign.flac", "Completed, Errored", 1000));
+ State(item, "foreign.flac").MarkRetriesExhausted();
+
+ Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status);
+ }
+
+ [Fact]
+ public void A_foreign_queued_transfer_is_not_reported_in_the_status_message()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000));
+ Transfers(item,
+ Transfer("01.flac", "InProgress", 1000),
+ Transfer("foreign.flac", "Queued, Remotely", 1000));
+
+ SlskdStatusResolver.DownloadStatus resolved = Resolve(item);
+
+ Assert.Contains("downloading", resolved.Message);
+ Assert.DoesNotContain("queued", resolved.Message);
+ }
+
+ [Fact]
+ public void A_foreign_queued_transfer_cannot_hold_the_album_incomplete()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000));
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("foreign.flac", "Queued, Remotely", 1000));
+
+ Assert.Equal(DownloadItemStatus.Completed, Resolve(item).Status);
+ }
+
+ // A file slskd rejected for THIS item produces no transfer of ours, so any
+ // transfer under that name belongs to another item sharing the peer folder.
+ [Fact]
+ public void A_rejected_files_transfer_cannot_fail_the_album()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000));
+ item.MarkEnqueueFailed([Dir + @"\02.flac"]);
+ Transfers(item,
+ Transfer("01.flac", "Completed, Succeeded", 1000),
+ Transfer("02.flac", "Completed, Errored", 1000));
+ State(item, "02.flac").MarkRetriesExhausted();
+
+ SlskdStatusResolver.DownloadStatus resolved = Resolve(item);
+
+ Assert.Equal(DownloadItemStatus.Completed, resolved.Status);
+ Assert.Equal(1000, resolved.TotalSize);
+ }
+
+ [Fact]
+ public void AllAcceptedFilesCompleted_does_not_wait_on_an_enqueue_rejected_file()
+ {
+ SlskdDownloadItem item = NewItem(("01.flac", 1000), ("02.flac", 1000));
+ item.MarkEnqueueFailed([Dir + @"\02.flac"]);
+ Transfers(item, Transfer("01.flac", "Completed, Succeeded", 1000));
+
+ Assert.True(item.AllAcceptedFilesCompleted());
+ }
+
+ [Fact]
+ public void An_item_whose_only_accepted_file_is_an_abandoned_extra_never_completes()
+ {
+ SlskdDownloadItem item = NewItem(("rip.cue", 2000));
+ Transfers(item, Transfer("rip.cue", "Completed, Errored", 2000));
+ State(item, "rip.cue").UpdateMaxRetryCount(0);
+
+ Assert.False(item.AllAcceptedFilesCompleted());
+ }
+}
+
+public class SlskdNonAudioBasenamesTests
+{
+ private static SlskdDownloadItem NewItem(params string[] filenames)
+ {
+ string source = "[" + string.Join(",", filenames.Select(f =>
+ $"{{\"Filename\":{JsonSerializer.Serialize(f)},\"Size\":1000}}")) + "]";
+ return new SlskdDownloadItem(new ReleaseInfo { Source = source, Title = "t", DownloadUrl = "u" });
+ }
+
+ [Fact]
+ public void Only_the_non_audio_basenames_are_returned()
+ {
+ SlskdDownloadItem item = NewItem(
+ @"@@u\Artist\Album\01 - Track.flac",
+ @"@@u\Artist\Album\rip.cue",
+ @"@@u\Artist\Album\rip.log");
+
+ Assert.Equal(new[] { "rip.cue", "rip.log" }, item.NonAudioBasenames());
+ }
+
+ [Fact]
+ public void A_pure_audio_grab_has_no_extras()
+ {
+ SlskdDownloadItem item = NewItem(@"@@u\A\B\01.flac", @"@@u\A\B\02.mp3", @"@@u\A\B\03.m4a");
+
+ Assert.Empty(item.NonAudioBasenames());
+ }
+
+ [Fact]
+ public void An_enqueue_rejected_extra_is_not_an_extra_to_import()
+ {
+ SlskdDownloadItem item = NewItem(@"@@u\A\B\01.flac", @"@@u\A\B\rip.cue");
+ item.MarkEnqueueFailed([@"@@u\A\B\rip.cue"]);
+
+ Assert.Empty(item.NonAudioBasenames());
+ }
+}