mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
fix(deezer): improve search and playlist matching
This commit is contained in:
11 files changed
+1002
-90
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user