Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/Sleezer/Core/Utilities/AudioFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ public static bool TryGetFileExtensionForCodec(string codec, out string extensio
_ => AudioFormat.Unknown
};

/// <summary>True when the filename's extension maps to a known audio format.</summary>
public static bool IsAudioFilename(string? filename) =>
GetAudioCodecFromExtension(Path.GetExtension(filename ?? string.Empty)) != AudioFormat.Unknown;

/// <summary>
/// Returns the default bitrate for a given audio format.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public interface ISlskdDownloadManager
Task<string> DownloadAsync(RemoteAlbum remoteAlbum, int definitionId, SlskdProviderSettings settings);
IEnumerable<DownloadClientItem> GetItems(int definitionId, SlskdProviderSettings settings, OsPath remotePath);
void RemoveItem(DownloadClientItem clientItem, bool deleteData, int definitionId, SlskdProviderSettings settings);
void ImportExtrasForImportedAlbum(string downloadId, IReadOnlyCollection<string> importedTrackPaths);
}
63 changes: 63 additions & 0 deletions src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,6 +136,14 @@ public SlskdDownloadItem(ReleaseInfo releaseInfo)
public bool OwnsFile(string? remoteFilename) =>
!string.IsNullOrEmpty(remoteFilename) && _enqueuedFilenames.Contains(remoteFilename);

/// <summary>
/// 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.
/// </summary>
public bool OwnsAcceptedFile(string? remoteFilename) =>
OwnsFile(remoteFilename) && !_enqueueFailedFilenames.Contains(remoteFilename!);

/// <summary>
/// Records files slskd rejected at enqueue time. They will never produce a
/// transfer, so completion tracking must not wait for them.
Expand All @@ -148,6 +157,60 @@ public void MarkEnqueueFailed(IEnumerable<string> filenames)
/// <summary>Files that were actually accepted by slskd.</summary>
public int ExpectedFileCount => Math.Max(0, FileData.Count - _enqueueFailedFilenames.Count);

/// <summary>
/// A terminally-failed non-audio extra (cue/log) — skipped from status and
/// completion so a broken extra can never fail an otherwise-complete album.
/// </summary>
public static bool IsAbandonedExtra(SlskdFileState state) =>
state.GetStatus() == DownloadItemStatus.Failed &&
!AudioFormatHelper.IsAudioFilename(state.File.Filename);

/// <summary>Every accepted file completed; abandoned extras don't block completion.</summary>
public bool AllAcceptedFilesCompleted()
{
if (_previousFileStates.Count == 0)
return false;

// Case-insensitive to match OwnsFile; _previousFileStates is ordinal.
Dictionary<string, SlskdFileState> statesByName = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, SlskdFileState> 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;
}

/// <summary>Local basenames of the enqueued non-audio files (cue/log extras).</summary>
public IReadOnlyList<string> 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();

/// <summary>True when this item tracks transfers for the given remote directory.</summary>
public bool TracksRemoteDirectory(string? remoteDirectory) =>
!string.IsNullOrEmpty(remoteDirectory) && _remoteDirectories.ContainsKey(remoteDirectory);
Expand Down
127 changes: 106 additions & 21 deletions src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,110 @@ public void RemoveItem(DownloadClientItem clientItem, bool deleteData, int defin
_ = CleanStaleDirectoriesAsync(directory, ownedFileSizes, settings);
}

/// <summary>
/// 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.
/// </summary>
public void ImportExtrasForImportedAlbum(string downloadId, IReadOnlyCollection<string> importedTrackPaths)
{
try
{
// Most imports are other download clients' — miss quietly, before any disk work.
KeyValuePair<DownloadKey<int, string>, 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<string> 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);
}
}

/// <summary>
/// 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.
/// </summary>
private void CopyExtrasIntoAlbumFolder(SlskdDownloadItem item, IReadOnlyList<string> extras, string folder, string destination)
{
Dictionary<string, long> ownedFileSizes = item.BuildOwnedFileSizes();
Dictionary<string, string> 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<string, long> ownedFileSizes, SlskdProviderSettings settings)
{
if (clientItem.OutputPath.IsEmpty)
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -973,25 +1077,6 @@ private SlskdStatusResolver.DownloadStatus FailWhenCompletedFilesVanished(
}
}

private static bool AllFilesCompleted(SlskdDownloadItem item)
{
IReadOnlyDictionary<string, SlskdFileState> 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<SlskdEventRecord> events, _) = await _apiClient.GetEventsAsync(settings, offset, 50);
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions src/Sleezer/Download/Clients/Soulseek/SlskdExtrasImportService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using NLog;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging.Events;

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

/// <summary>
/// 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.
/// </summary>
public class SlskdExtrasImportService(ISlskdDownloadManager downloadManager, Logger logger) : IHandle<AlbumImportedEvent>
{
public void Handle(AlbumImportedEvent message)
{
if (string.IsNullOrEmpty(message.DownloadId))
return;

List<string> 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);
}
}
}
38 changes: 38 additions & 0 deletions src/Sleezer/Download/Clients/Soulseek/SlskdPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ public static partial class SlskdPathResolver
return bestMatches * 2 > ownedFileCount ? best : null;
}

/// <summary>Deepest directory containing every given file path; null when they share no root.</summary>
public static string? CommonParentDirectory(IReadOnlyCollection<string> filePaths)
{
List<string[]> 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)
{
Expand Down
10 changes: 10 additions & 0 deletions src/Sleezer/Download/Clients/Soulseek/SlskdRetryHandler.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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));
}
}
}

Expand Down
Loading
Loading