From 7296cbae65b4a4fd2c4ee1ebd396dca9d7b1eaee Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:06:33 +0800 Subject: [PATCH 1/2] feat: link slskd releases to the peer and stop doubling the source tag Lidarr has no column for the Soulseek user and the title no longer carries it, so a grid of slskd results gives no way to tell who each one comes from. InfoUrl -- which Lidarr renders as the title's href -- now points at slskd's browse page for that peer, so hovering a release reveals the username. The old /searches/{id} target was usually dead by the time anyone clicked it, since the plugin deletes its searches once results are parsed. That URL was the only use of searchId, so the parameter is gone from the parser interface. An edition equal to the detected source tag also rendered twice ("[CD] [CD]"); ExtraInfo now drops entries matching SourceTag and collapses repeats. --- src/Sleezer/Core/Model/AlbumData.cs | 7 +- .../Clients/Soulseek/SlskdDownloadManager.cs | 2 +- .../Indexers/Soulseek/ISlskdItemsParser.cs | 2 +- .../Indexers/Soulseek/SlskdIndexerParser.cs | 2 +- .../Indexers/Soulseek/SlskdItemsParser.cs | 20 +++- tests/Sleezer.Tests/SlskdExtrasFlowTests.cs | 2 +- .../SlskdPublishDateAndOwnershipTests.cs | 3 - .../SlskdReleasePresentationTests.cs | 92 +++++++++++++++++++ .../Sleezer.Tests/SlskdSearchMatchingTests.cs | 6 +- .../SlskdVariantAndArtworkTests.cs | 7 -- 10 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 tests/Sleezer.Tests/SlskdReleasePresentationTests.cs diff --git a/src/Sleezer/Core/Model/AlbumData.cs b/src/Sleezer/Core/Model/AlbumData.cs index 9560a0e..6afe94e 100644 --- a/src/Sleezer/Core/Model/AlbumData.cs +++ b/src/Sleezer/Core/Model/AlbumData.cs @@ -134,8 +134,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; diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs index 4c4e6e1..3ebb7c5 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs @@ -1630,7 +1630,7 @@ private ReleaseInfo CreateReleaseInfoFromDirectory(string username, SlskdDownloa SlskdFolderData folderData = dir.CreateFolderData(username, _slskdItemsParser); SlskdSearchData searchData = new(null, null, false, false, 1, null); IGrouping 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; diff --git a/src/Sleezer/Indexers/Soulseek/ISlskdItemsParser.cs b/src/Sleezer/Indexers/Soulseek/ISlskdItemsParser.cs index 71205a1..27a0bc0 100644 --- a/src/Sleezer/Indexers/Soulseek/ISlskdItemsParser.cs +++ b/src/Sleezer/Indexers/Soulseek/ISlskdItemsParser.cs @@ -5,6 +5,6 @@ namespace NzbDrone.Plugin.Sleezer.Indexers.Soulseek public interface ISlskdItemsParser { SlskdFolderData ParseFolderName(string folderPath); - AlbumData CreateAlbumData(string searchId, IGrouping directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0); + AlbumData CreateAlbumData(IGrouping directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0); } } diff --git a/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs index d4b766c..87f2c72 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs @@ -149,7 +149,7 @@ public IList 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); List downloadFiles = JsonSerializer.Deserialize>(albumData.CustomString, IndexerParserHelper.StandardJsonOptions) ?? []; // Per-directory single/EP decisions log at Debug (too many to surface diff --git a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs index 060138a..f82e0ad 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs @@ -80,7 +80,7 @@ public SlskdFolderData ParseFolderName(string folderPath) Files: []); } - public AlbumData CreateAlbumData(string searchId, IGrouping directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0) + public AlbumData CreateAlbumData(IGrouping directory, SlskdSearchData searchData, SlskdFolderData folderData, SlskdSettings? settings = null, int expectedTrackCount = 0) { string dirNameNorm = NormalizeString(directory.Key); string searchArtistNorm = NormalizeString(searchData.Artist ?? ""); @@ -224,7 +224,10 @@ public AlbumData CreateAlbumData(string searchId, IGrouping + /// slskd's browse page for the peer. Lidarr has no column for the user, so + /// this link is where it surfaces: the browser shows the target on hover. + /// + internal static string BuildPeerUrl(SlskdSettings? settings, string? username) + { + if (settings == null || string.IsNullOrEmpty(username)) + return ""; + + string host = string.IsNullOrEmpty(settings.ExternalUrl) ? settings.BaseUrl : settings.ExternalUrl; + return $"{host?.TrimEnd('/')}/browse?user={Uri.EscapeDataString(username)}"; + } + private static string[] SplitPathIntoComponents(string path) => path.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); private static (string? artist, string? album, string? year) ParseFromRegexPatterns(string[] pathComponents) diff --git a/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs b/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs index 9fd991a..b103040 100644 --- a/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs +++ b/tests/Sleezer.Tests/SlskdExtrasFlowTests.cs @@ -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] diff --git a/tests/Sleezer.Tests/SlskdPublishDateAndOwnershipTests.cs b/tests/Sleezer.Tests/SlskdPublishDateAndOwnershipTests.cs index b54ebc4..7720616 100644 --- a/tests/Sleezer.Tests/SlskdPublishDateAndOwnershipTests.cs +++ b/tests/Sleezer.Tests/SlskdPublishDateAndOwnershipTests.cs @@ -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"), @@ -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"), @@ -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"), diff --git a/tests/Sleezer.Tests/SlskdReleasePresentationTests.cs b/tests/Sleezer.Tests/SlskdReleasePresentationTests.cs new file mode 100644 index 0000000..11d73fb --- /dev/null +++ b/tests/Sleezer.Tests/SlskdReleasePresentationTests.cs @@ -0,0 +1,92 @@ +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", SlskdItemsParser.BuildPeerUrl(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", SlskdItemsParser.BuildPeerUrl(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", SlskdItemsParser.BuildPeerUrl(settings, "vinyl & celluloid")); + } + + [Fact] + public void No_link_without_a_peer_or_settings() + { + Assert.Equal("", SlskdItemsParser.BuildPeerUrl(new SlskdSettings { BaseUrl = "http://slskd:5030" }, "")); + Assert.Equal("", SlskdItemsParser.BuildPeerUrl(null, "tactleneckg")); + } +} diff --git a/tests/Sleezer.Tests/SlskdSearchMatchingTests.cs b/tests/Sleezer.Tests/SlskdSearchMatchingTests.cs index aa1d6d0..e36d403 100644 --- a/tests/Sleezer.Tests/SlskdSearchMatchingTests.cs +++ b/tests/Sleezer.Tests/SlskdSearchMatchingTests.cs @@ -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; @@ -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 @@ -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 diff --git a/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs b/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs index 8d93253..9940fe8 100644 --- a/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs +++ b/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs @@ -108,7 +108,6 @@ private static SlskdSearchData Search(List tracks, string album = "Propo public void A_radio_edit_only_source_no_longer_satisfies_plain_wanted_tracks() { AlbumData album = Parser.CreateAlbumData( - "s1", Group( @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition (Radio Edit) [feat. James Robb].flac", @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less (Radio Edit) [feat. Blake].flac"), @@ -124,7 +123,6 @@ public void A_radio_edit_only_source_no_longer_satisfies_plain_wanted_tracks() public void The_plain_versions_of_the_same_release_still_match() { AlbumData album = Parser.CreateAlbumData( - "s1", Group( @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition.flac", @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less.flac"), @@ -142,7 +140,6 @@ public void The_plain_versions_of_the_same_release_still_match() public void A_target_that_wants_the_radio_edits_still_matches_them() { AlbumData album = Parser.CreateAlbumData( - "s1", Group( @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition (Radio Edit) [feat. James Robb].flac", @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less (Radio Edit) [feat. Blake].flac"), @@ -164,7 +161,6 @@ public void A_target_that_wants_the_radio_edits_still_matches_them() public void A_live_album_with_plain_track_titles_still_matches_live_files() { AlbumData album = Parser.CreateAlbumData( - "s1", Group( @"Music\Van Halen - Tokyo Dome\01. Unchained (live at the Tokyo Dome June 21, 2013).flac", @"Music\Van Halen - Tokyo Dome\02. Somebody Get Me a Doctor (live at the Tokyo Dome June 21, 2013).flac"), @@ -183,7 +179,6 @@ public void A_live_album_with_plain_track_titles_still_matches_live_files() public void A_live_single_with_plain_track_titles_still_covers_its_live_files() { AlbumData album = Parser.CreateAlbumData( - "s1", Group( @"Music\Van Halen - Tokyo Dome\01. Unchained (live at the Tokyo Dome June 21, 2013).flac", @"Music\Van Halen - Tokyo Dome\02. Somebody Get Me a Doctor (live at the Tokyo Dome June 21, 2013).flac"), @@ -212,7 +207,6 @@ public void Live_files_do_not_conflict_with_plain_titles_when_the_release_is_liv public void A_partly_radio_edit_source_is_only_partially_covered() { AlbumData album = Parser.CreateAlbumData( - "s1", Group( @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition.flac", @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less (Radio Edit).flac"), @@ -255,7 +249,6 @@ private static SlskdFolderData Folder(string path) => private static bool Matches(string album, string folder, string file, List? variantTypes = null) { AlbumData data = Parser.CreateAlbumData( - "s1", Group($@"{folder}\{file}"), new SlskdSearchData("Some Artist", album, Interactive: false, ExpandDirectory: false, MinimumFiles: 1, MaximumFiles: 40, TrackCount: 1, Tracks: ["Only Track"], From bb92df01754c395568f40443d630eaa08b52fb70 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:32:01 +0800 Subject: [PATCH 2/2] fix: keep the search identity off the display link Pointing InfoUrl at the peer broke interactive-grab cleanup: the grabbed handler matched the release back to its search with InfoUrl.EndsWith(searchId), so once the link stopped ending in the search id the search was never removed from slskd and lingered until shutdown. The two jobs InfoUrl was doing are now separate. CommentUrl carries the search a release came from -- it round-trips through an interactive grab resource, which a plugin-specific field would not -- and the grabbed handler matches on that, requiring the whole /searches/{id} suffix rather than a bare id. Both URL builders and the match rule live in SlskdUrls, which also returns nothing when no host is configured, so an unset base url can no longer produce a relative link resolved against Lidarr itself. --- src/Sleezer/Core/Model/AlbumData.cs | 4 + .../Indexers/Soulseek/SlskdIndexerParser.cs | 7 +- .../Indexers/Soulseek/SlskdItemsParser.cs | 15 +--- src/Sleezer/Indexers/Soulseek/SlskdUrls.cs | 38 ++++++++++ tests/Sleezer.Tests/Sleezer.Tests.csproj | 2 + .../SlskdReleasePresentationTests.cs | 74 +++++++++++++++++-- 6 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 src/Sleezer/Indexers/Soulseek/SlskdUrls.cs diff --git a/src/Sleezer/Core/Model/AlbumData.cs b/src/Sleezer/Core/Model/AlbumData.cs index 6afe94e..5241faf 100644 --- a/src/Sleezer/Core/Model/AlbumData.cs +++ b/src/Sleezer/Core/Model/AlbumData.cs @@ -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; @@ -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 diff --git a/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs index 87f2c72..29fc6bc 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs @@ -150,6 +150,10 @@ public IList ParseResponse(IndexerResponse indexerResponse) } 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 downloadFiles = JsonSerializer.Deserialize>(albumData.CustomString, IndexerParserHelper.StandardJsonOptions) ?? []; // Per-directory single/EP decisions log at Debug (too many to surface @@ -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 _); diff --git a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs index f82e0ad..b1e12e0 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs @@ -227,7 +227,7 @@ public AlbumData CreateAlbumData(IGrouping directory, Sls // 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 = BuildPeerUrl(settings, folderData.Username); + string infoUrl = SlskdUrls.Peer(settings, folderData.Username); string? edition = ExtractEdition(folderData.Path)?.ToUpper(); int priority = folderData.CalculatePriority(expectedTrackCount); @@ -289,19 +289,6 @@ public AlbumData CreateAlbumData(IGrouping directory, Sls }; } - /// - /// slskd's browse page for the peer. Lidarr has no column for the user, so - /// this link is where it surfaces: the browser shows the target on hover. - /// - internal static string BuildPeerUrl(SlskdSettings? settings, string? username) - { - if (settings == null || string.IsNullOrEmpty(username)) - return ""; - - string host = string.IsNullOrEmpty(settings.ExternalUrl) ? settings.BaseUrl : settings.ExternalUrl; - return $"{host?.TrimEnd('/')}/browse?user={Uri.EscapeDataString(username)}"; - } - private static string[] SplitPathIntoComponents(string path) => path.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); private static (string? artist, string? album, string? year) ParseFromRegexPatterns(string[] pathComponents) diff --git a/src/Sleezer/Indexers/Soulseek/SlskdUrls.cs b/src/Sleezer/Indexers/Soulseek/SlskdUrls.cs new file mode 100644 index 0000000..f43ec37 --- /dev/null +++ b/src/Sleezer/Indexers/Soulseek/SlskdUrls.cs @@ -0,0 +1,38 @@ +namespace NzbDrone.Plugin.Sleezer.Indexers.Soulseek +{ + /// + /// 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. + /// + public static class SlskdUrls + { + /// slskd's browse page for a peer — the release's display link. + public static string Peer(SlskdSettings? settings, string? username) => + Host(settings) is { Length: > 0 } host && !string.IsNullOrEmpty(username) + ? $"{host}/browse?user={Uri.EscapeDataString(username)}" + : ""; + + /// The search a release came from. + public static string Search(SlskdSettings? settings, string? searchId) => + Host(settings) is { Length: > 0 } host && !string.IsNullOrEmpty(searchId) + ? $"{host}/searches/{searchId}" + : ""; + + /// True when a release came from the given search. + 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('/') ?? ""; + } + } +} diff --git a/tests/Sleezer.Tests/Sleezer.Tests.csproj b/tests/Sleezer.Tests/Sleezer.Tests.csproj index 2f7b10d..fde6d50 100644 --- a/tests/Sleezer.Tests/Sleezer.Tests.csproj +++ b/tests/Sleezer.Tests/Sleezer.Tests.csproj @@ -89,6 +89,8 @@ LinkBase="SourceUnderTest" /> +