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
11 changes: 10 additions & 1 deletion src/Sleezer/Core/Model/AlbumData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public partial class AlbumData(string name, string downloadProtocol)

public string ArtistName { get; set; } = string.Empty;
public string InfoUrl { get; set; } = string.Empty;

// Identity, not display: slskd matches an interactive grab back to its search.
public string CommentUrl { get; set; } = string.Empty;
public string ReleaseDate { get; set; } = string.Empty;
public DateTime ReleaseDateTime { get; set; }
public string ReleaseDatePrecision { get; set; } = string.Empty;
Expand Down Expand Up @@ -81,6 +84,7 @@ private ReleaseInfo FillReleaseInfo(ReleaseInfo release)
release.Album = AlbumName;
release.DownloadUrl = AlbumId;
release.InfoUrl = InfoUrl;
release.CommentUrl = CommentUrl;
// Only day-precision dates populate PublishDate; year/month are
// synthesized and would trip EarlyReleaseSpecification — use discovery time.
release.PublishDate = ReleaseDatePrecision == "day" && ReleaseDateTime != DateTime.MinValue
Expand Down Expand Up @@ -134,8 +138,13 @@ private string ConstructTitle()
else
title += $" [{Codec}]";

// An edition that repeats the source tag ("[CD] [CD]") reads as a bug;
// SourceTag is appended below, so it wins.
if (ExtraInfo?.Count > 0)
title += string.Concat(ExtraInfo.Where(info => !string.IsNullOrEmpty(info)).Select(info => $" [{info}]"));
title += string.Concat(ExtraInfo
.Where(info => !string.IsNullOrEmpty(info) && !string.Equals(info, SourceTag, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(info => $" [{info}]"));

title += $" [{SourceTag}]";
return title;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1665,7 +1665,7 @@ private ReleaseInfo CreateReleaseInfoFromDirectory(string username, SlskdDownloa
SlskdFolderData folderData = dir.CreateFolderData(username, _slskdItemsParser);
SlskdSearchData searchData = new(null, null, false, false, 1, null);
IGrouping<string, SlskdFileData> dirGroup = dir.ToSlskdFileDataList().GroupBy(_ => dir.Directory).First();
AlbumData albumData = _slskdItemsParser.CreateAlbumData(string.Empty, dirGroup, searchData, folderData, null, 0);
AlbumData albumData = _slskdItemsParser.CreateAlbumData(dirGroup, searchData, folderData, null, 0);
ReleaseInfo release = albumData.ToShareInfo();
release.DownloadProtocol = null;
return release;
Expand Down
2 changes: 1 addition & 1 deletion src/Sleezer/Indexers/Soulseek/ISlskdItemsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace NzbDrone.Plugin.Sleezer.Indexers.Soulseek
public interface ISlskdItemsParser
{
SlskdFolderData ParseFolderName(string folderPath);
AlbumData CreateAlbumData(string searchId, IGrouping<string, SlskdFileData> directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0);
AlbumData CreateAlbumData(IGrouping<string, SlskdFileData> directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0);
}
}
9 changes: 7 additions & 2 deletions src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ public IList<ReleaseInfo> ParseResponse(IndexerResponse indexerResponse)
}
}

AlbumData albumData = _itemsParser.CreateAlbumData(searchResponse.Id, finalGroup, searchTextData, folderData, Settings, searchTextData.TrackCount);
AlbumData albumData = _itemsParser.CreateAlbumData(finalGroup, searchTextData, folderData, Settings, searchTextData.TrackCount);

// Carries the search this release came from; the display link
// points at the peer, so grab cleanup matches on this instead.
albumData.CommentUrl = SlskdUrls.Search(Settings, searchResponse.Id);
List<SlskdFileData> downloadFiles = JsonSerializer.Deserialize<List<SlskdFileData>>(albumData.CustomString, IndexerParserHelper.StandardJsonOptions) ?? [];

// Per-directory single/EP decisions log at Debug (too many to surface
Expand Down Expand Up @@ -281,7 +285,8 @@ public void RemoveSearch(string searchId, bool delay = false)

public void Handle(AlbumGrabbedEvent message)
{
if (!_interactiveResults.TryGetValue(message.Album.Release.IndexerId, out string? selectedId) || !message.Album.Release.InfoUrl.EndsWith(selectedId))
if (!_interactiveResults.TryGetValue(message.Album.Release.IndexerId, out string? selectedId) ||
!SlskdUrls.IsFromSearch(message.Album.Release.CommentUrl, selectedId))
return;
ExecuteRemovalAsync((SlskdSettings)_indexerFactory.Value.Get(message.Album.Release.IndexerId).Settings, selectedId).GetAwaiter().GetResult();
_interactiveResults.TryRemove(message.Album.Release.IndexerId, out _);
Expand Down
7 changes: 5 additions & 2 deletions src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public SlskdFolderData ParseFolderName(string folderPath)
Files: []);
}

public AlbumData CreateAlbumData(string searchId, IGrouping<string, SlskdFileData> directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0)
public AlbumData CreateAlbumData(IGrouping<string, SlskdFileData> directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0)
{
string dirNameNorm = NormalizeString(directory.Key);
string searchArtistNorm = NormalizeString(searchData.Artist ?? "");
Expand Down Expand Up @@ -224,7 +224,10 @@ public AlbumData CreateAlbumData(string searchId, IGrouping<string, SlskdFileDat

_logger.Trace("Audio: {Codec}, BitRate: {BitRate}, BitDepth: {BitDepth}, Files: {TrackCount}", Codec, BitRate, BitDepth, actualTrackCount);

string infoUrl = settings != null ? $"{(string.IsNullOrEmpty(settings.ExternalUrl) ? settings.BaseUrl : settings.ExternalUrl)}/searches/{searchId}" : "";
// Points at the peer, not the search: Lidarr renders this as the title's
// href, so hovering a result reveals which user it came from. Searches are
// deleted after parsing, so a /searches/ link is usually dead by then.
string infoUrl = SlskdUrls.Peer(settings, folderData.Username);
string? edition = ExtractEdition(folderData.Path)?.ToUpper();

int priority = folderData.CalculatePriority(expectedTrackCount);
Expand Down
38 changes: 38 additions & 0 deletions src/Sleezer/Indexers/Soulseek/SlskdUrls.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace NzbDrone.Plugin.Sleezer.Indexers.Soulseek
{
/// <summary>
/// slskd URLs carried on a release. The peer link is what Lidarr displays;
/// the search link is identity, and interactive-grab cleanup matches on it.
/// </summary>
public static class SlskdUrls
{
/// <summary>slskd's browse page for a peer — the release's display link.</summary>
public static string Peer(SlskdSettings? settings, string? username) =>
Host(settings) is { Length: > 0 } host && !string.IsNullOrEmpty(username)
? $"{host}/browse?user={Uri.EscapeDataString(username)}"
: "";

/// <summary>The search a release came from.</summary>
public static string Search(SlskdSettings? settings, string? searchId) =>
Host(settings) is { Length: > 0 } host && !string.IsNullOrEmpty(searchId)
? $"{host}/searches/{searchId}"
: "";

/// <summary>True when a release came from the given search.</summary>
public static bool IsFromSearch(string? commentUrl, string? searchId) =>
!string.IsNullOrEmpty(commentUrl) &&
!string.IsNullOrEmpty(searchId) &&
commentUrl.EndsWith($"/searches/{searchId}", StringComparison.Ordinal);

// No host configured yields "" rather than a relative URL, which Lidarr
// would render against its own address.
private static string Host(SlskdSettings? settings)
{
if (settings == null)
return "";

string? host = string.IsNullOrEmpty(settings.ExternalUrl) ? settings.BaseUrl : settings.ExternalUrl;
return host?.TrimEnd('/') ?? "";
}
}
}
2 changes: 2 additions & 0 deletions tests/Sleezer.Tests/Sleezer.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Indexers\Soulseek\SlskdSettings.cs"
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Indexers\Soulseek\SlskdUrls.cs"
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Indexers\Soulseek\Search\Core\SearchContext.cs"
LinkBase="SourceUnderTest" />
<Compile Include="..\..\src\Sleezer\Indexers\Soulseek\Search\Templates\TemplateEngine.cs"
Expand Down
2 changes: 1 addition & 1 deletion tests/Sleezer.Tests/SlskdExtrasFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ private static AlbumData Build(string dir, SlskdFileData[] files, string artist,
};
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);
return Parser.CreateAlbumData(group, search, folder, null, expectedTrackCount);
}

[Fact]
Expand Down
3 changes: 0 additions & 3 deletions tests/Sleezer.Tests/SlskdPublishDateAndOwnershipTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ private static SlskdFolderData Folder(string path, string year = "") =>
public void A_folder_covering_one_of_two_tracks_is_not_a_match_when_coherence_is_required()
{
AlbumData album = Parser.CreateAlbumData(
"search1",
Group(
@"Music\Pilot Records\PILOT027 - GLXY - Proposition EP\2. GLXY - Mind Less (Radio Edit).flac",
@"Music\Pilot Records\PILOT027 - GLXY - Proposition EP\4. GLXY - Mind Less.flac"),
Expand All @@ -106,7 +105,6 @@ public void A_folder_covering_one_of_two_tracks_is_not_a_match_when_coherence_is
public void A_folder_covering_both_tracks_stays_a_match()
{
AlbumData album = Parser.CreateAlbumData(
"search1",
Group(
@"Music\Pilot Records\PILOT027 - GLXY - Proposition EP\1. GLXY - Proposition.flac",
@"Music\Pilot Records\PILOT027 - GLXY - Proposition EP\4. GLXY - Mind Less.flac"),
Expand All @@ -124,7 +122,6 @@ public void A_slskd_folder_year_is_year_precision_and_never_a_publish_date()
DateTime before = DateTime.UtcNow.AddSeconds(-5);

AlbumData album = Parser.CreateAlbumData(
"search1",
Group(
@"complete\Pilot\[PILOT027] GLXY - Proposition # Mind Less (2017)\1. GLXY - Proposition.flac",
@"complete\Pilot\[PILOT027] GLXY - Proposition # Mind Less (2017)\4. GLXY - Mind Less.flac"),
Expand Down
156 changes: 156 additions & 0 deletions tests/Sleezer.Tests/SlskdReleasePresentationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using NzbDrone.Core.Parser.Model;
using NzbDrone.Plugin.Sleezer.Core.Model;
using NzbDrone.Plugin.Sleezer.Core.Utilities;
using NzbDrone.Plugin.Sleezer.Indexers.Soulseek;
using Xunit;

namespace Sleezer.Tests;

// The title appends every ExtraInfo entry and then the source tag, so a CD rip
// whose edition is also "CD" rendered as "... [CD] [CD]".
public class AlbumTitleTagTests
{
private static string TitleOf(string sourceTag, params string[] extraInfo)
{
AlbumData album = new("Slskd", "SoulseekDownloadProtocol")
{
ArtistName = "Muse",
AlbumName = "The Resistance",
Codec = AudioFormat.MP3,
Bitrate = 320,
SourceTag = sourceTag,
ExtraInfo = [.. extraInfo]
};

return album.ToReleaseInfo().Title;
}

[Fact]
public void An_edition_matching_the_source_tag_is_not_repeated()
{
Assert.Equal("Muse - The Resistance [MP3 320kbps] [CD]", TitleOf("CD", "CD"));
}

[Fact]
public void The_match_ignores_case()
{
Assert.Equal("Muse - The Resistance [MP3 320kbps] [WEB]", TitleOf("WEB", "web"));
}

[Fact]
public void A_distinct_edition_is_still_shown()
{
Assert.Equal("Muse - The Resistance [MP3 320kbps] [DELUXE] [WEB]", TitleOf("WEB", "DELUXE"));
}

[Fact]
public void Repeated_editions_collapse_to_one()
{
Assert.Equal("Muse - The Resistance [MP3 320kbps] [DELUXE] [WEB]", TitleOf("WEB", "DELUXE", "deluxe"));
}

[Fact]
public void A_release_without_an_edition_is_unchanged()
{
Assert.Equal("Muse - The Resistance [MP3 320kbps] [WEB]", TitleOf("WEB"));
}
}

// Lidarr renders InfoUrl as the release title's href and has no column for the
// peer, so the link is the only place the username can surface in the grid.
public class SlskdPeerLinkTests
{
[Fact]
public void The_link_points_at_the_peers_browse_page()
{
SlskdSettings settings = new() { BaseUrl = "http://slskd:5030" };

Assert.Equal("http://slskd:5030/browse?user=tactleneckg", SlskdUrls.Peer(settings, "tactleneckg"));
}

[Fact]
public void The_browser_reachable_url_wins_over_the_container_one()
{
SlskdSettings settings = new() { BaseUrl = "http://slskd:5030", ExternalUrl = "http://10.0.20.11:5030" };

Assert.Equal("http://10.0.20.11:5030/browse?user=raydeeoo", SlskdUrls.Peer(settings, "raydeeoo"));
}

[Fact]
public void A_username_needing_escaping_survives_the_round_trip()
{
SlskdSettings settings = new() { BaseUrl = "http://slskd:5030/" };

Assert.Equal("http://slskd:5030/browse?user=vinyl%20%26%20celluloid", SlskdUrls.Peer(settings, "vinyl & celluloid"));
}

[Fact]
public void No_link_without_a_peer_or_settings()
{
Assert.Equal("", SlskdUrls.Peer(new SlskdSettings { BaseUrl = "http://slskd:5030" }, ""));
Assert.Equal("", SlskdUrls.Peer(null, "tactleneckg"));
}

// A relative URL would resolve against Lidarr's own address, not slskd's.
[Fact]
public void No_link_without_a_configured_host()
{
Assert.Equal("", SlskdUrls.Peer(new SlskdSettings { BaseUrl = "", ExternalUrl = "" }, "tactleneckg"));
Assert.Equal("", SlskdUrls.Search(new SlskdSettings { BaseUrl = "", ExternalUrl = "" }, "search-1"));
}
}

// The peer link is display; the search a release came from is identity. Grab
// cleanup matches on the identity, so it must not be read off the display link.
public class SlskdSearchIdentityTests
{
private static readonly SlskdSettings Settings = new() { BaseUrl = "http://slskd:5030" };

[Fact]
public void A_release_carries_the_search_it_came_from()
{
Assert.Equal("http://slskd:5030/searches/abc-123", SlskdUrls.Search(Settings, "abc-123"));
Assert.True(SlskdUrls.IsFromSearch(SlskdUrls.Search(Settings, "abc-123"), "abc-123"));
}

// The regression: cleanup used to match the display link, so pointing that
// at the peer silently stopped interactive searches being removed.
[Fact]
public void A_peer_link_never_identifies_a_search()
{
Assert.False(SlskdUrls.IsFromSearch(SlskdUrls.Peer(Settings, "tactleneckg"), "abc-123"));
}

[Fact]
public void Another_searchs_url_does_not_match()
{
Assert.False(SlskdUrls.IsFromSearch(SlskdUrls.Search(Settings, "abc-123"), "def-456"));
}

[Fact]
public void A_missing_url_or_id_never_matches()
{
Assert.False(SlskdUrls.IsFromSearch(null, "abc-123"));
Assert.False(SlskdUrls.IsFromSearch("", "abc-123"));
Assert.False(SlskdUrls.IsFromSearch("http://slskd:5030/searches/abc-123", ""));
}

// The identity has to survive onto the release, or grab cleanup has nothing
// to match: this is what broke when the display link stopped carrying it.
[Fact]
public void The_identity_reaches_the_release()
{
AlbumData album = new("Slskd", "SoulseekDownloadProtocol")
{
ArtistName = "Muse",
AlbumName = "The Resistance",
InfoUrl = SlskdUrls.Peer(Settings, "tactleneckg"),
CommentUrl = SlskdUrls.Search(Settings, "abc-123")
};

ReleaseInfo release = album.ToReleaseInfo();

Assert.True(SlskdUrls.IsFromSearch(release.CommentUrl, "abc-123"));
Assert.Equal("http://slskd:5030/browse?user=tactleneckg", release.InfoUrl);
}
}
6 changes: 3 additions & 3 deletions tests/Sleezer.Tests/SlskdSearchMatchingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ private static AlbumData Build(string dirKey, string[] files, string artist, str
};
SlskdSearchData search = new(artist, album, false, false, 1, null,
TrackCount: expectedTrackCount, Tracks: tracks.ToList(), AlbumType: albumType);
return Parser.CreateAlbumData("search1", group, search, folder, settings, expectedTrackCount);
return Parser.CreateAlbumData(group, search, folder, settings, expectedTrackCount);
}

private static int FlacCount(string customString) => customString.Split(".flac").Length - 1;
Expand Down Expand Up @@ -336,7 +336,7 @@ public void Audio_files_are_recognized_when_extension_metadata_is_empty()
}.GroupBy(_ => dir).Single();
SlskdFolderData folder = Parser.ParseFolderName(dir) with { Username = "user", HasFreeUploadSlot = true, FileCount = 3 };
SlskdSearchData search = new("Van Halen", "Dreams", false, false, 1, null, TrackCount: 1, Tracks: new() { "Dreams" });
AlbumData a = Parser.CreateAlbumData("s", group, search, folder, null, 1);
AlbumData a = Parser.CreateAlbumData(group, search, folder, null, 1);

Assert.True(a.MatchedSearchCriteria); // recognized as audio via filename fallback
Assert.Equal(1, FlacCount(a.CustomString)); // and the Dreams track is plucked
Expand All @@ -356,7 +356,7 @@ public void Single_target_pluck_ignores_a_same_named_non_audio_file()
}.GroupBy(_ => dir).Single();
SlskdFolderData folder = Parser.ParseFolderName(dir) with { Username = "user", HasFreeUploadSlot = true, FileCount = 4 };
SlskdSearchData search = new("Artist", "Dreams", false, false, 1, null, TrackCount: 1, Tracks: new() { "Dreams" });
AlbumData a = Parser.CreateAlbumData("s", group, search, folder, null, 1);
AlbumData a = Parser.CreateAlbumData(group, search, folder, null, 1);

Assert.True(a.MatchedSearchCriteria);
Assert.Equal(1, FlacCount(a.CustomString)); // the flac, not the cue, is plucked
Expand Down
Loading
Loading