fix(deezer): improve search and playlist matching

This commit is contained in:
joshpatra committed 2026-05-22 15:31:02 -04:00
1 parent ec1b2b2b7a
commit 026a4097b5
11 files changed
+1002 -90

No files matched your search

+354 -1
View File
@@ -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<string>();
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<string>();
SetupHttpResponse(request =>
{
lock (requests)
{
requests.Add(request.RequestUri!.PathAndQuery);
}
return CreateJsonResponse(JsonSerializer.Serialize(new { data = Array.Empty<object>() }));
});
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<string>();
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<string>();
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<string>();
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<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<string>();
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<string>();
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()
{
@@ -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<string, object?>
{
["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<string, object?>
{
["Id"] = id,
["Name"] = name
};
Assert.False(InjectedPlaylistItemHelper.LooksLikeLegacyExternalSourceLabeledItem(item));
}
}
+95 -2
View File
@@ -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<string> { "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<string[]>(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<string[]>(result["Artists"]);
Assert.Equal(["Matched Artist [D]"], artists);
var artistItems = Assert.IsType<Dictionary<string, object?>[]>(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"]);
}
@@ -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();
}
@@ -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))
+1 -1
View File
@@ -107,7 +107,7 @@ public class Song
/// <summary>
/// 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
/// </summary>
public int? ExplicitContentLyrics { get; set; }
@@ -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
@@ -19,6 +19,11 @@ public static class InjectedPlaylistItemHelper
return items.Any(LooksLikeLocalItemMissingGenreMetadata);
}
public static bool ContainsLegacyExternalSourceLabels(IEnumerable<Dictionary<string, object?>> items)
{
return items.Any(LooksLikeLegacyExternalSourceLabeledItem);
}
public static bool LooksLikeSyntheticLocalItem(IReadOnlyDictionary<string, object?> 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<string, object?> 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<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
+423 -62
View File
@@ -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<SubsonicSettings> settings,
GenreEnrichmentService? genreEnrichment = null)
GenreEnrichmentService? genreEnrichment = null,
IOptions<DeezerSettings>? deezerSettings = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_genreEnrichment = genreEnrichment;
_minRequestIntervalMs = Math.Max(
0,
deezerSettings?.Value.MinRequestIntervalMs ?? new DeezerSettings().MinRequestIntervalMs);
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allSongs = new List<Song>();
var seenIds = new HashSet<string>(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<List<Song>> 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<Song>();
@@ -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<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allAlbums = new List<Album>();
var seenIds = new HashSet<string>(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<List<Album>> 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<Album>();
@@ -105,11 +204,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allArtists = new List<Artist>();
var seenIds = new HashSet<string>(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<List<Artist>> 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<Artist>();
@@ -133,6 +265,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
}
}
private static IReadOnlyList<string> BuildSearchQueryVariants(string query)
{
var variants = new List<string>();
AddQueryVariant(variants, query);
if (query.Contains('&'))
{
AddQueryVariant(variants, query.Replace("&", " and "));
}
return variants;
}
private static void AddQueryVariant(List<string> 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<SearchResult> 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<Song> 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<Song>();
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<Album>();
var url = $"{BaseUrl}/artist/{externalId}/albums";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
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<Song>();
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<Song>();
@@ -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<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allPlaylists = new List<ExternalPlaylist>();
var seenIds = new HashSet<string>(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<List<ExternalPlaylist>> 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<ExternalPlaylist>();
@@ -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<Song>();
@@ -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<Song> 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<Song>();
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<int> ReadPagedDataAsync(
Func<int, string> buildIndexedPageUrl,
Action<JsonElement> addItem,
CancellationToken cancellationToken)
{
string? pageUrl = buildIndexedPageUrl(0);
var itemCount = 0;
var seenPageUrls = new HashSet<string>(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<HttpResponseMessage> 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<int, string> 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();
@@ -134,7 +134,7 @@ public class JellyfinResponseBuilder
var albumItem = new Dictionary<string, object?>
{
["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<string, object?>
@@ -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"
};
}
/// <summary>
/// Converts an Album domain model to a Jellyfin item.
/// </summary>
public Dictionary<string, object?> 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<string, object?>
@@ -621,11 +644,10 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> 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<string, object?>
@@ -755,7 +777,7 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["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",
@@ -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();
}