diff --git a/src/Sleezer/Core/Model/AlbumData.cs b/src/Sleezer/Core/Model/AlbumData.cs index 9560a0e..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 @@ -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; diff --git a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs index 1212cf4..0374bf0 100644 --- a/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs +++ b/src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs @@ -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 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..29fc6bc 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs @@ -149,7 +149,11 @@ 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); + + // 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 060138a..b1e12e0 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 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 1646202..16f0634 100644 --- a/tests/Sleezer.Tests/Sleezer.Tests.csproj +++ b/tests/Sleezer.Tests/Sleezer.Tests.csproj @@ -89,6 +89,8 @@ LinkBase="SourceUnderTest" /> + 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..7d9f76b --- /dev/null +++ b/tests/Sleezer.Tests/SlskdReleasePresentationTests.cs @@ -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); + } +} 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"],