diff --git a/allstarr.Tests/DeezerMetadataServiceTests.cs b/allstarr.Tests/DeezerMetadataServiceTests.cs index d8a22fdb..49e3f5e8 100644 --- a/allstarr.Tests/DeezerMetadataServiceTests.cs +++ b/allstarr.Tests/DeezerMetadataServiceTests.cs @@ -34,7 +34,8 @@ public class DeezerMetadataServiceTests private DeezerMetadataService CreateService(SubsonicSettings settings) { var options = Options.Create(settings); - return new DeezerMetadataService(_httpClientFactoryMock.Object, options); + var deezerOptions = Options.Create(new DeezerSettings { MinRequestIntervalMs = 0 }); + return new DeezerMetadataService(_httpClientFactoryMock.Object, options, deezerSettings: deezerOptions); } [Fact] @@ -49,6 +50,7 @@ public class DeezerMetadataServiceTests { id = 123456, title = "Test Song", + isrc = "TESTISRC1234", duration = 180, track_position = 1, artist = new { id = 789, name = "Test Artist" }, @@ -70,10 +72,49 @@ public class DeezerMetadataServiceTests Assert.Equal("Test Artist", result[0].Artist); Assert.Equal("Test Album", result[0].Album); Assert.Equal(180, result[0].Duration); + Assert.Equal("TESTISRC1234", result[0].Isrc); Assert.False(result[0].IsLocal); Assert.Equal("deezer", result[0].ExternalProvider); } + [Fact] + public async Task SearchSongsAsync_AmpersandVariant_PreservesProviderOrderAndDeduplicates() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + var response = pathAndQuery.Contains("q=love%20and%20hyperbole", StringComparison.Ordinal) + ? new + { + data = new object[] + { + CreateTrackSearchResult(2, "Shared Result"), + CreateTrackSearchResult(3, "Variant Result") + } + } + : new + { + data = new object[] + { + CreateTrackSearchResult(1, "Original Result"), + CreateTrackSearchResult(2, "Shared Result") + } + }; + + return CreateJsonResponse(JsonSerializer.Serialize(response)); + }); + + var result = await _service.SearchSongsAsync("love & hyperbole", 3); + + Assert.Equal(["Original Result", "Shared Result", "Variant Result"], result.Select(song => song.Title)); + Assert.Equal(2, requests.Count); + Assert.Contains("q=love%20%26%20hyperbole&limit=3&order=RANKING", requests[0]); + Assert.Contains("q=love%20and%20hyperbole&limit=3&order=RANKING", requests[1]); + } + [Fact] public async Task SearchAlbumsAsync_ReturnsListOfAlbums() { @@ -159,6 +200,70 @@ public class DeezerMetadataServiceTests Assert.NotNull(result.Artists); } + [Fact] + public async Task SearchAllAsync_AmpersandQuery_UsesVariantsForEachRequestedBucket() + { + var requests = new List(); + SetupHttpResponse(request => + { + lock (requests) + { + requests.Add(request.RequestUri!.PathAndQuery); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new { data = Array.Empty() })); + }); + + await _service.SearchAllAsync("love & hyperbole", songLimit: 1, albumLimit: 1, artistLimit: 1); + + Assert.Contains(requests, request => + request.Contains("/search/track?q=love%20%26%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/track?q=love%20and%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/album?q=love%20%26%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/album?q=love%20and%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/artist?q=love%20%26%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/artist?q=love%20and%20hyperbole&limit=1", StringComparison.Ordinal)); + } + + [Fact] + public async Task FindSongByIsrcAsync_UsesExactTrackEndpoint() + { + var requests = new List(); + SetupHttpResponse(request => + { + requests.Add(request.RequestUri!.PathAndQuery); + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + id = 116348632, + title = "Hey Jude", + isrc = "GBUM71505902", + duration = 429, + track_position = 21, + disk_number = 1, + artist = new { id = 1, name = "The Beatles" }, + album = new + { + id = 12047956, + title = "1", + cover_medium = "https://example.com/cover.jpg" + } + })); + }); + + var result = await _service.FindSongByIsrcAsync(" GBUM71505902 "); + + Assert.NotNull(result); + Assert.Equal("ext-deezer-song-116348632", result.Id); + Assert.Equal("GBUM71505902", result.Isrc); + Assert.Equal(["/track/isrc:GBUM71505902"], requests); + } + [Fact] public async Task GetSongAsync_WithDeezerProvider_ReturnsSong() { @@ -285,6 +390,111 @@ public class DeezerMetadataServiceTests Assert.Null(result); } + [Fact] + public async Task GetAlbumAsync_PaginatesTracklistWhenAlbumDetailIsPartial() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + if (pathAndQuery.Contains("index=1", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(222, "Track 2") + } + })); + } + + if (pathAndQuery.Contains("/tracks", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + }, + next = "https://api.deezer.com/album/456789/tracks?limit=100&index=1" + })); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + id = 456789, + title = "Paged Album", + nb_tracks = 2, + artist = new { id = 123, name = "Test Artist" }, + tracks = new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + } + } + })); + }); + + var result = await _service.GetAlbumAsync("deezer", "456789"); + + Assert.NotNull(result); + Assert.Equal(["Track 1", "Track 2"], result.Songs.Select(song => song.Title)); + Assert.Contains("/album/456789/tracks?index=0&limit=100", requests); + Assert.Contains("/album/456789/tracks?limit=100&index=1", requests); + } + + [Fact] + public async Task GetArtistAlbumsAsync_FallsBackToDocumentedIndexPagination() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + if (pathAndQuery.Contains("index=1", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + new + { + id = 2002, + title = "Second Album", + nb_tracks = 8, + artist = new { id = 27, name = "Artist" } + } + } + })); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + new + { + id = 2001, + title = "First Album", + nb_tracks = 10, + artist = new { id = 27, name = "Artist" } + } + }, + total = 2 + })); + }); + + var result = await _service.GetArtistAlbumsAsync("deezer", "27"); + + Assert.Equal(["First Album", "Second Album"], result.Select(album => album.Title)); + Assert.Contains("/artist/27/albums?index=0&limit=100", requests); + Assert.Contains("/artist/27/albums?index=1&limit=100", requests); + } + private void SetupHttpResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK) { _httpMessageHandlerMock @@ -300,6 +510,51 @@ public class DeezerMetadataServiceTests }); } + private void SetupHttpResponse(Func responseFactory) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((HttpRequestMessage request, CancellationToken _) => + Task.FromResult(responseFactory(request))); + } + + private static HttpResponseMessage CreateJsonResponse(string content) + { + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(content) + }; + } + + private static object CreateTrackSearchResult(long id, string title) + { + return new + { + id, + title, + duration = 180, + artist = new { id = 789, name = "Test Artist" }, + album = new { id = 456, title = "Test Album", cover_medium = "https://example.com/cover.jpg" } + }; + } + + private static object CreatePlaylistSearchResult(long id, string title) + { + return new + { + id, + title, + nb_tracks = 10, + picture_medium = "https://example.com/playlist.jpg", + user = new { name = "Playlist User" } + }; + } + #region Explicit Filter Tests [Fact] @@ -622,6 +877,44 @@ public class DeezerMetadataServiceTests Assert.Equal("ext-deezer-playlist-12345", result[0].Id); } + [Fact] + public async Task SearchPlaylistsAsync_AmpersandVariant_PreservesProviderOrderAndDeduplicates() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + var response = pathAndQuery.Contains("q=love%20and%20hyperbole", StringComparison.Ordinal) + ? new + { + data = new object[] + { + CreatePlaylistSearchResult(2, "Shared Playlist"), + CreatePlaylistSearchResult(3, "Variant Playlist") + } + } + : new + { + data = new object[] + { + CreatePlaylistSearchResult(1, "Original Playlist"), + CreatePlaylistSearchResult(2, "Shared Playlist") + } + }; + + return CreateJsonResponse(JsonSerializer.Serialize(response)); + }); + + var result = await _service.SearchPlaylistsAsync("love & hyperbole", 3); + + Assert.Equal(["Original Playlist", "Shared Playlist", "Variant Playlist"], result.Select(playlist => playlist.Name)); + Assert.Equal(2, requests.Count); + Assert.Contains("q=love%20%26%20hyperbole&limit=3&order=RANKING", requests[0]); + Assert.Contains("q=love%20and%20hyperbole&limit=3&order=RANKING", requests[1]); + } + [Fact] public async Task SearchPlaylistsAsync_WithLimit_RespectsLimit() { @@ -718,6 +1011,7 @@ public class DeezerMetadataServiceTests { id = 111, title = "Track 1", + isrc = "TESTISRC0001", duration = 200, track_position = 1, disk_number = 1, @@ -738,6 +1032,7 @@ public class DeezerMetadataServiceTests { id = 222, title = "Track 2", + isrc = "TESTISRC0002", duration = 180, track_position = 2, disk_number = 1, @@ -768,6 +1063,7 @@ public class DeezerMetadataServiceTests Assert.Equal("Track 1", result[0].Title); Assert.Equal("Artist A", result[0].Artist); Assert.Equal("ext-deezer-song-111", result[0].Id); + Assert.Equal("TESTISRC0001", result[0].Isrc); } [Fact] @@ -780,6 +1076,63 @@ public class DeezerMetadataServiceTests Assert.Empty(result); } + [Fact] + public async Task GetPlaylistTracksAsync_PaginatesTracklistWhenPlaylistDetailIsPartial() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + if (pathAndQuery.Contains("index=1", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(222, "Track 2") + } + })); + } + + if (pathAndQuery.Contains("/tracks", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + }, + next = "https://api.deezer.com/playlist/12345/tracks?limit=100&index=1" + })); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + id = 12345, + title = "Paged Playlist", + nb_tracks = 2, + tracks = new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + } + } + })); + }); + + var result = await _service.GetPlaylistTracksAsync("deezer", "12345"); + + Assert.Equal(["Track 1", "Track 2"], result.Select(song => song.Title)); + Assert.All(result, song => Assert.Equal("Paged Playlist", song.Album)); + Assert.Equal(1, result[0].Track); + Assert.Equal(2, result[1].Track); + Assert.Contains("/playlist/12345/tracks?index=0&limit=100", requests); + Assert.Contains("/playlist/12345/tracks?limit=100&index=1", requests); + } + [Fact] public async Task GetPlaylistTracksAsync_WithEmptyPlaylist_ReturnsEmptyList() { diff --git a/allstarr.Tests/InjectedPlaylistItemHelperTests.cs b/allstarr.Tests/InjectedPlaylistItemHelperTests.cs index fbb79fdb..4cdbd76e 100644 --- a/allstarr.Tests/InjectedPlaylistItemHelperTests.cs +++ b/allstarr.Tests/InjectedPlaylistItemHelperTests.cs @@ -82,4 +82,39 @@ public class InjectedPlaylistItemHelperTests Assert.False(InjectedPlaylistItemHelper.LooksLikeLocalItemMissingGenreMetadata(item)); } + + [Theory] + [InlineData("ext-deezer-song-123", "Track [S]")] + [InlineData("ext-deezer-song-123", "Track [S] [E]")] + [InlineData("ext-qobuz-song-123", "Track [S]")] + public void LooksLikeLegacyExternalSourceLabeledItem_ReturnsTrue_ForRelabeledProviders( + string id, + string name) + { + var item = new Dictionary + { + ["Id"] = id, + ["Name"] = name + }; + + Assert.True(InjectedPlaylistItemHelper.LooksLikeLegacyExternalSourceLabeledItem(item)); + } + + [Theory] + [InlineData("ext-deezer-song-123", "Track [D]")] + [InlineData("ext-qobuz-song-123", "Track [Q]")] + [InlineData("ext-squidwtf-song-123", "Track [S]")] + [InlineData("local-song-123", "Track [S]")] + public void LooksLikeLegacyExternalSourceLabeledItem_ReturnsFalse_ForCurrentLabels( + string id, + string name) + { + var item = new Dictionary + { + ["Id"] = id, + ["Name"] = name + }; + + Assert.False(InjectedPlaylistItemHelper.LooksLikeLegacyExternalSourceLabeledItem(item)); + } } diff --git a/allstarr.Tests/JellyfinResponseBuilderTests.cs b/allstarr.Tests/JellyfinResponseBuilderTests.cs index baf2cf78..3eb597c8 100644 --- a/allstarr.Tests/JellyfinResponseBuilderTests.cs +++ b/allstarr.Tests/JellyfinResponseBuilderTests.cs @@ -115,6 +115,56 @@ public class JellyfinResponseBuilderTests Assert.Equal("Sunflower [S]", result["Name"]); } + [Theory] + [InlineData("deezer", "[D]")] + [InlineData("qobuz", "[Q]")] + [InlineData("squidwtf", "[S]")] + public void ConvertSongToJellyfinItem_ExternalSong_UsesProviderSourceLabel(string provider, string label) + { + var song = new Song + { + Id = $"ext-{provider}-song-12345", + Title = "External Track", + Artist = "External Artist", + Artists = new List { "External Artist" }, + Album = "External Album", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "12345" + }; + + var result = _builder.ConvertSongToJellyfinItem(song); + + Assert.Equal($"External Track {label}", result["Name"]); + Assert.Equal($"External Album {label}", result["Album"]); + var artists = Assert.IsType(result["Artists"]); + Assert.Equal(new[] { $"External Artist {label}" }, artists); + } + + [Fact] + public void ConvertSongToJellyfinItem_DeezerPlaylistMatch_LabelsFallbackArtist() + { + var matchedSong = new Song + { + Id = "ext-deezer-song-12345", + Title = "Matched Track", + Artist = "Matched Artist", + Album = "Matched Album", + IsLocal = false, + ExternalProvider = "deezer", + ExternalId = "12345" + }; + + var result = _builder.ConvertSongToJellyfinItem(matchedSong); + + Assert.Equal("Matched Track [D]", result["Name"]); + Assert.Equal("Matched Album [D]", result["Album"]); + var artists = Assert.IsType(result["Artists"]); + Assert.Equal(["Matched Artist [D]"], artists); + var artistItems = Assert.IsType[]>(result["ArtistItems"]); + Assert.Equal("Matched Artist [D]", artistItems[0]["Name"]); + } + [Theory] [InlineData("deezer")] [InlineData("qobuz")] @@ -199,6 +249,28 @@ public class JellyfinResponseBuilderTests Assert.NotNull(result["BasicSyncInfo"]); } + [Theory] + [InlineData("deezer", "[D]")] + [InlineData("qobuz", "[Q]")] + [InlineData("squidwtf", "[S]")] + public void ConvertAlbumToJellyfinItem_ExternalAlbum_UsesProviderSourceLabel(string provider, string label) + { + var album = new Album + { + Id = $"ext-{provider}-album-456", + Title = "External Album", + Artist = "External Artist", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "456" + }; + + var result = _builder.ConvertAlbumToJellyfinItem(album); + + Assert.Equal($"External Album {label}", result["Name"]); + Assert.Equal($"External Album {label}", result["SortName"]); + } + [Fact] public void ConvertArtistToJellyfinItem_SetsCorrectFields() { @@ -225,6 +297,27 @@ public class JellyfinResponseBuilderTests Assert.NotNull(result["BasicSyncInfo"]); } + [Theory] + [InlineData("deezer", "[D]")] + [InlineData("qobuz", "[Q]")] + [InlineData("squidwtf", "[S]")] + public void ConvertArtistToJellyfinItem_ExternalArtist_UsesProviderSourceLabel(string provider, string label) + { + var artist = new Artist + { + Id = $"ext-{provider}-artist-789", + Name = "External Artist", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "789" + }; + + var result = _builder.ConvertArtistToJellyfinItem(artist); + + Assert.Equal($"External Artist {label}", result["Name"]); + Assert.Equal($"External Artist {label}", result["SortName"]); + } + [Fact] public void ConvertPlaylistToAlbumItem_SetsPlaylistType() { @@ -246,12 +339,12 @@ public class JellyfinResponseBuilderTests // Assert Assert.Equal("ext-playlist-deezer-999", result["Id"]); - Assert.Equal("Summer Vibes [S/P]", result["Name"]); + Assert.Equal("Summer Vibes [D/P]", result["Name"]); Assert.Equal("MusicAlbum", result["Type"]); Assert.Equal("DJ Cool", result["AlbumArtist"]); Assert.Equal(50, result["ChildCount"]); Assert.Equal(2023, result["ProductionYear"]); - Assert.Equal("Summer Vibes [S/P]", result["SortName"]); + Assert.Equal("Summer Vibes [D/P]", result["SortName"]); Assert.NotNull(result["DateCreated"]); Assert.NotNull(result["BasicSyncInfo"]); } diff --git a/allstarr/Controllers/JellyfinController.Lyrics.cs b/allstarr/Controllers/JellyfinController.Lyrics.cs index 3787a9ae..befb8a4a 100644 --- a/allstarr/Controllers/JellyfinController.Lyrics.cs +++ b/allstarr/Controllers/JellyfinController.Lyrics.cs @@ -475,6 +475,8 @@ public partial class JellyfinController return value .Replace(" [S]", "", StringComparison.Ordinal) + .Replace(" [D]", "", StringComparison.Ordinal) + .Replace(" [Q]", "", StringComparison.Ordinal) .Replace(" [E]", "", StringComparison.Ordinal) .Trim(); } diff --git a/allstarr/Controllers/JellyfinController.Spotify.cs b/allstarr/Controllers/JellyfinController.Spotify.cs index deae44c3..a3b698d2 100644 --- a/allstarr/Controllers/JellyfinController.Spotify.cs +++ b/allstarr/Controllers/JellyfinController.Spotify.cs @@ -81,6 +81,16 @@ public partial class JellyfinController cachedItems = null; } + if (cachedItems != null && cachedItems.Count > 0 && + InjectedPlaylistItemHelper.ContainsLegacyExternalSourceLabels(cachedItems)) + { + _logger.LogInformation( + "Ignoring Redis playlist cache for {Playlist}: external items still use legacy source labels", + spotifyPlaylistName); + await _cache.DeleteAsync(cacheKey); + cachedItems = null; + } + if (cachedItems != null && cachedItems.Count > 0 && requestNeedsGenreMetadata && InjectedPlaylistItemHelper.ContainsLocalItemsMissingGenreMetadata(cachedItems)) @@ -122,6 +132,15 @@ public partial class JellyfinController fileItems = null; } + if (fileItems != null && fileItems.Count > 0 && + InjectedPlaylistItemHelper.ContainsLegacyExternalSourceLabels(fileItems)) + { + _logger.LogInformation( + "Ignoring file playlist cache for {Playlist}: external items still use legacy source labels", + spotifyPlaylistName); + fileItems = null; + } + if (fileItems != null && fileItems.Count > 0 && requestNeedsGenreMetadata && InjectedPlaylistItemHelper.ContainsLocalItemsMissingGenreMetadata(fileItems)) diff --git a/allstarr/Models/Domain/Song.cs b/allstarr/Models/Domain/Song.cs index c77fc29f..80705611 100644 --- a/allstarr/Models/Domain/Song.cs +++ b/allstarr/Models/Domain/Song.cs @@ -107,7 +107,7 @@ public class Song /// /// Deezer explicit content lyrics value - /// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown + /// 0 = Naturally clean, 1 = Explicit, 2 = Unknown, 3 = Clean/edited version, 6/7 = No advice /// public int? ExplicitContentLyrics { get; set; } diff --git a/allstarr/Services/Common/ExplicitContentFilter.cs b/allstarr/Services/Common/ExplicitContentFilter.cs index b54df99d..07529652 100644 --- a/allstarr/Services/Common/ExplicitContentFilter.cs +++ b/allstarr/Services/Common/ExplicitContentFilter.cs @@ -27,7 +27,7 @@ public static class ExplicitContentFilter ExplicitFilter.All => true, // ExplicitOnly: Exclude clean/edited versions (value 3) - // Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown) + // Include: 0 (naturally clean), 1 (explicit), 2 (unknown), 6/7 (no advice) ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3, // CleanOnly: Only show clean content diff --git a/allstarr/Services/Common/InjectedPlaylistItemHelper.cs b/allstarr/Services/Common/InjectedPlaylistItemHelper.cs index eaf0dc91..c651e5b4 100644 --- a/allstarr/Services/Common/InjectedPlaylistItemHelper.cs +++ b/allstarr/Services/Common/InjectedPlaylistItemHelper.cs @@ -19,6 +19,11 @@ public static class InjectedPlaylistItemHelper return items.Any(LooksLikeLocalItemMissingGenreMetadata); } + public static bool ContainsLegacyExternalSourceLabels(IEnumerable> items) + { + return items.Any(LooksLikeLegacyExternalSourceLabeledItem); + } + public static bool LooksLikeSyntheticLocalItem(IReadOnlyDictionary item) { var id = GetString(item, "Id"); @@ -42,11 +47,31 @@ public static class InjectedPlaylistItemHelper return !HasNonNullValue(item, "Genres") || !HasNonNullValue(item, "GenreItems"); } + public static bool LooksLikeLegacyExternalSourceLabeledItem(IReadOnlyDictionary item) + { + var id = GetString(item, "Id"); + if (!NeedsProviderSpecificSourceLabel(id)) + { + return false; + } + + var name = GetString(item, "Name"); + return name?.EndsWith(" [S]", StringComparison.Ordinal) == true || + name?.EndsWith(" [S] [E]", StringComparison.Ordinal) == true; + } + private static bool IsExternalItemId(string itemId) { return itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase); } + private static bool NeedsProviderSpecificSourceLabel(string? itemId) + { + return !string.IsNullOrWhiteSpace(itemId) && + (itemId.StartsWith("ext-deezer-", StringComparison.OrdinalIgnoreCase) || + itemId.StartsWith("ext-qobuz-", StringComparison.OrdinalIgnoreCase)); + } + private static bool HasNonNullValue(IReadOnlyDictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index 484562a1..cfce1a16 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -17,24 +17,66 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService private readonly HttpClient _httpClient; private readonly SubsonicSettings _settings; private readonly GenreEnrichmentService? _genreEnrichment; + private readonly SemaphoreSlim _requestLock = new(1, 1); + private readonly int _minRequestIntervalMs; + private DateTime _lastRequestTime = DateTime.MinValue; private const string BaseUrl = "https://api.deezer.com"; + private const string DeezerApiHost = "api.deezer.com"; + private const int MetadataPageSize = 100; public DeezerMetadataService( IHttpClientFactory httpClientFactory, IOptions settings, - GenreEnrichmentService? genreEnrichment = null) + GenreEnrichmentService? genreEnrichment = null, + IOptions? deezerSettings = null) { _httpClient = httpClientFactory.CreateClient(); _settings = settings.Value; _genreEnrichment = genreEnrichment; + _minRequestIntervalMs = Math.Max( + 0, + deezerSettings?.Value.MinRequestIntervalMs ?? new DeezerSettings().MinRequestIntervalMs); } public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allSongs = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var songs = await SearchSongsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var song in songs) + { + var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allSongs.Add(song); + if (allSongs.Count >= normalizedLimit) + { + break; + } + } + + if (allSongs.Count >= normalizedLimit) + { + break; + } + } + + return allSongs; + } + + private async Task> SearchSongsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("track", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -69,18 +111,75 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService return null; } - var results = await SearchSongsAsync(isrc, limit: 5, cancellationToken); - return results.FirstOrDefault(song => - !string.IsNullOrWhiteSpace(song.Isrc) && - song.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase)); + try + { + var normalizedIsrc = isrc.Trim(); + var url = $"{BaseUrl}/track/isrc:{Uri.EscapeDataString(normalizedIsrc)}"; + var response = await GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("error", out _) || + !result.RootElement.TryGetProperty("id", out _)) + { + return null; + } + + var song = ParseDeezerTrackFull(result.RootElement); + return string.Equals(song.Isrc, normalizedIsrc, StringComparison.OrdinalIgnoreCase) + ? song + : null; + } + catch + { + return null; + } } public async Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allAlbums = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var albums = await SearchAlbumsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var album in albums) + { + var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allAlbums.Add(album); + if (allAlbums.Count >= normalizedLimit) + { + break; + } + } + + if (allAlbums.Count >= normalizedLimit) + { + break; + } + } + + return allAlbums; + } + + private async Task> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("album", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -105,11 +204,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } public async Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allArtists = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var artists = await SearchArtistsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var artist in artists) + { + var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allArtists.Add(artist); + if (allArtists.Count >= normalizedLimit) + { + break; + } + } + + if (allArtists.Count >= normalizedLimit) + { + break; + } + } + + return allArtists; + } + + private async Task> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("artist", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -133,6 +265,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } } + private static IReadOnlyList BuildSearchQueryVariants(string query) + { + var variants = new List(); + + AddQueryVariant(variants, query); + + if (query.Contains('&')) + { + AddQueryVariant(variants, query.Replace("&", " and ")); + } + + return variants; + } + + private static void AddQueryVariant(List variants, string candidate) + { + var normalized = System.Text.RegularExpressions.Regex.Replace(candidate, @"\s+", " ").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + if (!variants.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + { + variants.Add(normalized); + } + } + + private static int NormalizeSearchLimit(int limit) + { + return Math.Max(1, limit); + } + + private static string BuildRankedSearchUrl(string searchType, string query, int limit) + { + return $"{BaseUrl}/search/{searchType}?q={Uri.EscapeDataString(query)}&limit={limit}&order=RANKING"; + } + public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) { var songsTask = songLimit > 0 @@ -160,7 +330,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/track/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; @@ -180,7 +350,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService try { var albumUrl = $"{BaseUrl}/album/{albumId}"; - var albumResponse = await _httpClient.GetAsync(albumUrl, cancellationToken); + var albumResponse = await GetAsync(albumUrl, cancellationToken); if (albumResponse.IsSuccessStatusCode) { var albumJson = await albumResponse.Content.ReadAsStringAsync(cancellationToken); @@ -252,37 +422,64 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/album/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(cancellationToken); - var albumElement = JsonDocument.Parse(json).RootElement; + using var albumDocument = JsonDocument.Parse(json); + var albumElement = albumDocument.RootElement; if (albumElement.TryGetProperty("error", out _)) return null; var album = ParseDeezerAlbum(albumElement); - // Get album songs + var trackIndex = 1; + var embeddedTrackCount = 0; + + void AddTrack(JsonElement track, List songs) + { + // Pass the album artist to ensure proper folder organization + var song = ParseDeezerTrack(track, trackIndex, album.Artist); + + // Ensure album metadata is set (tracks in album response may not have full album object) + song.Album = album.Title; + song.AlbumId = album.Id; + song.AlbumArtist = album.Artist; + + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + songs.Add(song); + } + + trackIndex++; + } + + // Deezer album details embed the first page of tracks. if (albumElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("data", out var tracksData)) { - int trackIndex = 1; foreach (var track in tracksData.EnumerateArray()) { - // Pass the album artist to ensure proper folder organization - var song = ParseDeezerTrack(track, trackIndex, album.Artist); + embeddedTrackCount++; + AddTrack(track, album.Songs); + } + } - // Ensure album metadata is set (tracks in album response may not have full album object) - song.Album = album.Title; - song.AlbumId = album.Id; - song.AlbumArtist = album.Artist; + if (album.SongCount.HasValue && embeddedTrackCount < album.SongCount.Value) + { + var pagedSongs = new List(); + trackIndex = 1; - if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) - { - album.Songs.Add(song); - } - trackIndex++; + var pagedTrackCount = await ReadPagedDataAsync( + index => BuildMetadataPageUrl($"album/{Uri.EscapeDataString(externalId)}/tracks", index), + track => AddTrack(track, pagedSongs), + cancellationToken); + + if (pagedTrackCount > embeddedTrackCount) + { + album.Songs.Clear(); + album.Songs.AddRange(pagedSongs); } } @@ -294,7 +491,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/artist/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; @@ -310,22 +507,11 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService { if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); - var url = $"{BaseUrl}/artist/{externalId}/albums"; - var response = await _httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonDocument.Parse(json); - var albums = new List(); - if (result.RootElement.TryGetProperty("data", out var data)) - { - foreach (var album in data.EnumerateArray()) - { - albums.Add(ParseDeezerAlbum(album)); - } - } + await ReadPagedDataAsync( + index => BuildMetadataPageUrl($"artist/{Uri.EscapeDataString(externalId)}/albums", index), + album => albums.Add(ParseDeezerAlbum(album)), + cancellationToken); return albums; } @@ -335,7 +521,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); var url = $"{BaseUrl}/artist/{externalId}/top?limit=50"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -393,6 +579,9 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService ? cover.GetString() : null, AlbumArtist = albumArtist, + Isrc = track.TryGetProperty("isrc", out var isrc) + ? isrc.GetString() + : null, IsLocal = false, ExternalProvider = "deezer", ExternalId = externalId, @@ -583,11 +772,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } public async Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allPlaylists = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var playlists = await SearchPlaylistsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var playlist in playlists) + { + var key = !string.IsNullOrWhiteSpace(playlist.ExternalId) ? playlist.ExternalId : playlist.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allPlaylists.Add(playlist); + if (allPlaylists.Count >= normalizedLimit) + { + break; + } + } + + if (allPlaylists.Count >= normalizedLimit) + { + break; + } + } + + return allPlaylists; + } + + private async Task> SearchPlaylistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/playlist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("playlist", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -618,7 +840,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService try { var url = $"{BaseUrl}/playlist/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; @@ -642,7 +864,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService try { var url = $"{BaseUrl}/playlist/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -657,28 +879,55 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService var playlistName = playlistElement.TryGetProperty("title", out var titleEl) ? titleEl.GetString() ?? "Unknown Playlist" : "Unknown Playlist"; + var trackIndex = 1; + var embeddedTrackCount = 0; + + void AddTrack(JsonElement track, List tracks) + { + // For playlists, use the track's own artist (not a single album artist) + var song = ParseDeezerTrack(track, trackIndex); + + // Override album name to be the playlist name + song.Album = playlistName; + + // Playlists should not have disc numbers - always set to null. + // This prevents Jellyfin from splitting the playlist into multiple "discs". + song.DiscNumber = null; + + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + tracks.Add(song); + } + + trackIndex++; + } if (playlistElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("data", out var tracksData)) { - int trackIndex = 1; foreach (var track in tracksData.EnumerateArray()) { - // For playlists, use the track's own artist (not a single album artist) - var song = ParseDeezerTrack(track, trackIndex); + embeddedTrackCount++; + AddTrack(track, songs); + } + } - // Override album name to be the playlist name - song.Album = playlistName; + if (playlistElement.TryGetProperty("nb_tracks", out var trackCountElement) && + trackCountElement.TryGetInt32(out var trackCount) && + embeddedTrackCount < trackCount) + { + var pagedSongs = new List(); + trackIndex = 1; - // Playlists should not have disc numbers - always set to null - // This prevents Jellyfin from splitting the playlist into multiple "discs" - song.DiscNumber = null; + var pagedTrackCount = await ReadPagedDataAsync( + index => BuildMetadataPageUrl($"playlist/{Uri.EscapeDataString(externalId)}/tracks", index), + track => AddTrack(track, pagedSongs), + cancellationToken); - if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) - { - songs.Add(song); - } - trackIndex++; + if (pagedTrackCount > embeddedTrackCount) + { + songs.Clear(); + songs.AddRange(pagedSongs); } } @@ -690,6 +939,118 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } } + private async Task ReadPagedDataAsync( + Func buildIndexedPageUrl, + Action addItem, + CancellationToken cancellationToken) + { + string? pageUrl = buildIndexedPageUrl(0); + var itemCount = 0; + var seenPageUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (IsOfficialDeezerApiUrl(pageUrl) && seenPageUrls.Add(pageUrl!)) + { + var response = await GetAsync(pageUrl!, cancellationToken); + if (!response.IsSuccessStatusCode) + { + break; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("error", out _) || + !result.RootElement.TryGetProperty("data", out var data) || + data.ValueKind != JsonValueKind.Array) + { + break; + } + + var pageItemCount = 0; + foreach (var item in data.EnumerateArray()) + { + addItem(item); + itemCount++; + pageItemCount++; + } + + pageUrl = GetNextPageUrl(result.RootElement) ?? + GetIndexedNextPageUrl(result.RootElement, buildIndexedPageUrl, itemCount, pageItemCount); + } + + return itemCount; + } + + private async Task GetAsync(string url, CancellationToken cancellationToken) + { + await _requestLock.WaitAsync(cancellationToken); + try + { + if (_lastRequestTime != DateTime.MinValue && _minRequestIntervalMs > 0) + { + var elapsedMs = (DateTime.UtcNow - _lastRequestTime).TotalMilliseconds; + if (elapsedMs < _minRequestIntervalMs) + { + await Task.Delay((int)(_minRequestIntervalMs - elapsedMs), cancellationToken); + } + } + + _lastRequestTime = DateTime.UtcNow; + return await _httpClient.GetAsync(url, cancellationToken); + } + finally + { + _requestLock.Release(); + } + } + + private static string BuildMetadataPageUrl(string endpoint, int index) + { + return $"{BaseUrl}/{endpoint.TrimStart('/')}?index={index}&limit={MetadataPageSize}"; + } + + private static string? GetNextPageUrl(JsonElement result) + { + if (!result.TryGetProperty("next", out var next) || + next.ValueKind != JsonValueKind.String) + { + return null; + } + + var pageUrl = next.GetString(); + return IsOfficialDeezerApiUrl(pageUrl) ? pageUrl : null; + } + + private static string? GetIndexedNextPageUrl( + JsonElement result, + Func buildIndexedPageUrl, + int itemCount, + int pageItemCount) + { + if (pageItemCount == 0) + { + return null; + } + + if (result.TryGetProperty("total", out var total) && + total.TryGetInt32(out var totalCount)) + { + return itemCount < totalCount + ? buildIndexedPageUrl(itemCount) + : null; + } + + return pageItemCount >= MetadataPageSize + ? buildIndexedPageUrl(itemCount) + : null; + } + + private static bool IsOfficialDeezerApiUrl(string? url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) && + string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(uri.Host, DeezerApiHost, StringComparison.OrdinalIgnoreCase); + } + private ExternalPlaylist ParseDeezerPlaylist(JsonElement playlist) { var externalId = playlist.GetProperty("id").GetInt64().ToString(); diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index a01abb1b..a46b2b18 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -134,7 +134,7 @@ public class JellyfinResponseBuilder var albumItem = new Dictionary { ["Id"] = playlist.Id, - ["Name"] = $"{playlist.Name} [S/P]", // Label as playlist + ["Name"] = BuildExternalPlaylistName(playlist.Name, playlist.Provider), ["Type"] = "MusicAlbum", // Must be MusicAlbum for Jellyfin clients ["ServerId"] = "allstarr", ["ChannelId"] = null, @@ -304,21 +304,11 @@ public class JellyfinResponseBuilder { songTitle = BuildExternalSongTitle(song); - // Also add [S] to artist and album names for consistency - if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]")) - { - artistName = $"{artistName} [S]"; - } - - if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]")) - { - albumName = $"{albumName} [S]"; - } - - // Add [S] to all artist names in the list - artistNames = artistNames.Select(a => - !string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a - ).ToList(); + artistName = AppendExternalSourceLabel(artistName, song.ExternalProvider); + albumName = AppendExternalSourceLabel(albumName, song.ExternalProvider); + artistNames = artistNames + .Select(a => AppendExternalSourceLabel(a, song.ExternalProvider)) + .ToList(); } var item = new Dictionary @@ -506,7 +496,7 @@ public class JellyfinResponseBuilder private static string BuildExternalSongTitle(Song song) { - var title = $"{song.Title} [S]"; + var title = AppendExternalSourceLabel(song.Title, song.ExternalProvider); if (song.ExplicitContentLyrics == 1) { @@ -523,16 +513,49 @@ public class JellyfinResponseBuilder provider.Equals("squidwtf", StringComparison.OrdinalIgnoreCase); } + private static string AppendExternalSourceLabel(string value, string? provider) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var label = GetExternalSourceLabel(provider); + return value.EndsWith($" {label}", StringComparison.Ordinal) + ? value + : $"{value} {label}"; + } + + private static string BuildExternalPlaylistName(string playlistName, string? provider) + { + return $"{playlistName} [{GetExternalSourceCode(provider)}/P]"; + } + + private static string GetExternalSourceLabel(string? provider) + { + return $"[{GetExternalSourceCode(provider)}]"; + } + + private static string GetExternalSourceCode(string? provider) + { + return provider?.ToLowerInvariant() switch + { + "deezer" => "D", + "qobuz" => "Q", + "squidwtf" => "S", + _ => "S" + }; + } + /// /// Converts an Album domain model to a Jellyfin item. /// public Dictionary ConvertAlbumToJellyfinItem(Album album) { - // Add " [S]" suffix to external album names (S = streaming source) var albumName = album.Title; if (!album.IsLocal) { - albumName = $"{album.Title} [S]"; + albumName = AppendExternalSourceLabel(album.Title, album.ExternalProvider); } var item = new Dictionary @@ -621,11 +644,10 @@ public class JellyfinResponseBuilder /// public Dictionary ConvertArtistToJellyfinItem(Artist artist) { - // Add " [S]" suffix to external artist names (S = streaming source) var artistName = artist.Name; if (!artist.IsLocal) { - artistName = $"{artist.Name} [S]"; + artistName = AppendExternalSourceLabel(artist.Name, artist.ExternalProvider); } var item = new Dictionary @@ -755,7 +777,7 @@ public class JellyfinResponseBuilder var item = new Dictionary { - ["Name"] = $"{playlist.Name} [S/P]", + ["Name"] = BuildExternalPlaylistName(playlist.Name, playlist.Provider), ["ServerId"] = "allstarr", ["Id"] = playlist.Id, ["ChannelId"] = (object?)null, @@ -763,7 +785,7 @@ public class JellyfinResponseBuilder ["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond, ["IsFolder"] = true, ["Type"] = "MusicAlbum", - ["SortName"] = $"{playlist.Name} [S/P]", + ["SortName"] = BuildExternalPlaylistName(playlist.Name, playlist.Provider), ["DateCreated"] = playlist.CreatedDate.HasValue ? playlist.CreatedDate.Value.ToString("o") : "1970-01-01T00:00:00.0000000Z", diff --git a/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs b/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs index e261981e..aadb5861 100644 --- a/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs +++ b/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs @@ -305,6 +305,8 @@ public class KeptLyricsSidecarService : IKeptLyricsSidecarService return value .Replace(" [S]", "", StringComparison.Ordinal) + .Replace(" [D]", "", StringComparison.Ordinal) + .Replace(" [Q]", "", StringComparison.Ordinal) .Replace(" [E]", "", StringComparison.Ordinal) .Trim(); }