fix(jellyfin): keep related tracks on chosen provider

This commit is contained in:
joshpatra committed 2026-08-05 05:54:01 -04:00
1 parent ac0a18f847
commit d1b3edf4f2
5 files changed
+113 -39

No files matched your search

@@ -16,7 +16,7 @@ public sealed class ProtocolProviderGatewayContractTests
Assert.Contains("_providerGateway.GetSongAsync", controller, StringComparison.Ordinal);
Assert.Contains("_providerGateway.GetArtistAlbumsAsync", controller, StringComparison.Ordinal);
Assert.Contains("_providerGateway.GetArtistTracksAsync", controller, StringComparison.Ordinal);
Assert.Contains("_providerGateway.SearchPlayableSongsAsync", controller, StringComparison.Ordinal);
Assert.Contains("_providerGateway.SearchAsync", controller, StringComparison.Ordinal);
Assert.Contains("_providerGateway.SearchAsync", search, StringComparison.Ordinal);
Assert.Contains("_providerGateway.SearchPlaylistsAsync", search, StringComparison.Ordinal);
Assert.Contains("_providerGateway.GetPlaylistAsync", File.ReadAllText(Path.Combine(
@@ -98,6 +98,36 @@ public sealed class ProtocolProviderStreamingGatewayTests
healthy.VerifyAll();
}
[Fact]
public async Task MetadataSearch_RestrictsTheRouteToTheRequestedProvider()
{
var deezer = new Mock<IProviderMetadataCapability>();
deezer.SetupGet(item => item.ProviderId).Returns("deezer");
deezer.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
var qobuz = new Mock<IProviderMetadataCapability>();
qobuz.SetupGet(item => item.ProviderId).Returns("qobuz");
qobuz.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
var registry = MetadataRegistry(deezer.Object, qobuz.Object);
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
It.Is<ProviderRouteRequest>(request =>
request.ProviderPriority.SequenceEqual(new[] { "deezer" }))))
.ReturnsAsync((ProviderRouteRequest request) =>
EmptyPlan<IProviderMetadataCapability>(request));
var gateway = new ProtocolProviderGateway(
router.Object,
registry,
Mock.Of<IProviderRouteAccountResolver>(),
Mock.Of<IMusicMetadataService>(),
new HttpClientFactory());
var result = await gateway.SearchAsync(
Context(), "Track Artist", 10, 0, 0, "deezer");
Assert.Empty(result.Songs);
router.VerifyAll();
}
[Fact]
public async Task MetadataRelationships_UseOnlyUniqueExactIdsFromTheSameProvider()
{
@@ -1280,11 +1280,11 @@ public sealed class ProtocolRouteFixtureTests
}
[Fact]
public async Task JellyfinExternalSimilar_UsesCompleteQueryResultEnvelope()
public async Task JellyfinExternalSimilarAndInstantMix_StayOnTheTypedProvider()
{
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
metadata.Setup(service => service.GetSongAsync(
"deezer", "42", It.IsAny<CancellationToken>()))
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
gateway.Setup(service => service.GetSongAsync(
It.IsAny<ProtocolExecutionContext>(), "deezer", "42"))
.ReturnsAsync(new Song
{
Id = "ext-deezer-song-42",
@@ -1293,26 +1293,39 @@ public sealed class ProtocolRouteFixtureTests
Title = "Seed",
Artist = "Fixture Artist"
});
metadata.Setup(service => service.SearchSongsAsync(
"Fixture Artist", 2, It.IsAny<CancellationToken>()))
.ReturnsAsync([
new Song
{
Id = "ext-deezer-song-42",
ExternalProvider = "deezer",
ExternalId = "42",
Title = "Seed",
Artist = "Fixture Artist"
},
new Song
{
Id = "ext-deezer-song-43",
ExternalProvider = "deezer",
ExternalId = "43",
Title = "Related",
Artist = "Fixture Artist"
}
]);
gateway.Setup(service => service.SearchAsync(
It.IsAny<ProtocolExecutionContext>(), "Fixture Artist", 2, 0, 0, "deezer"))
.ReturnsAsync(new SearchResult
{
Songs =
[
new Song
{
Id = "ext-deezer-song-42",
ExternalProvider = "deezer",
ExternalId = "42",
Title = "Seed",
Artist = "Fixture Artist"
},
new Song
{
Id = "ext-deezer-song-43",
ExternalProvider = "deezer",
ExternalId = "43",
Title = "Related",
Artist = "Fixture Artist"
}
]
});
var interaction = new Mock<IJellyfinInteractionProtocolAdapter>(MockBehavior.Strict);
var shaper = new JellyfinInteractionProtocolAdapter();
interaction.Setup(adapter => adapter.CanRunOptionalUserWork(
It.IsAny<ProtocolExecutionContext?>()))
.Returns(true);
interaction.Setup(adapter => adapter.ShapeInstantMix(
It.IsAny<IReadOnlyList<Dictionary<string, object?>>>()))
.Returns((IReadOnlyList<Dictionary<string, object?>> items) =>
shaper.ShapeInstantMix(items));
using var factory = new ProtocolFactory(
"Jellyfin",
request => request.RequestUri!.AbsolutePath == "/Users/Me"
@@ -1320,23 +1333,36 @@ public sealed class ProtocolRouteFixtureTests
: throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"),
services =>
{
services.RemoveAll<IMusicMetadataService>();
services.AddSingleton(metadata.Object);
services.RemoveAll<IProtocolProviderGateway>();
services.AddSingleton(gateway.Object);
services.RemoveAll<IJellyfinInteractionProtocolAdapter>();
services.AddSingleton(interaction.Object);
});
using var client = factory.CreateClient();
using var response = await client.GetAsync(
"/Items/ext-deezer-song-42/Similar?Limit=2&api_key=fixture-key");
using var mixResponse = await client.GetAsync(
"/Items/ext-deezer-song-42/InstantMix?Limit=2&api_key=fixture-key");
using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
using var mixBody = JsonDocument.Parse(await mixResponse.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(HttpStatusCode.OK, mixResponse.StatusCode);
Assert.Equal(0, body.RootElement.GetProperty("StartIndex").GetInt32());
Assert.Equal(1, body.RootElement.GetProperty("TotalRecordCount").GetInt32());
Assert.Equal(
"ext-deezer-song-43",
body.RootElement.GetProperty("Items")[0].GetProperty("Id").GetString());
metadata.VerifyAll();
Assert.Equal(1, mixBody.RootElement.GetProperty("TotalRecordCount").GetInt32());
Assert.Equal(
"ext-deezer-song-43",
mixBody.RootElement.GetProperty("Items")[0].GetProperty("Id").GetString());
gateway.Verify(service => service.SearchAsync(
It.IsAny<ProtocolExecutionContext>(), "Fixture Artist", 2, 0, 0, "deezer"),
Times.Exactly(2));
gateway.VerifyAll();
interaction.VerifyAll();
}
[Theory]
+10 -5
View File
@@ -177,12 +177,17 @@ public partial class JellyfinController : ControllerBase
: _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken);
private async Task<IReadOnlyList<Song>> SearchProviderSongsAsync(
string provider,
string query,
int limit,
CancellationToken cancellationToken = default) => _providerGateway != null
? await _providerGateway.SearchPlayableSongsAsync(
HttpContext.RequireProtocolExecutionContext(), query, limit)
: await _metadataService.SearchSongsAsync(query, limit, cancellationToken);
? (await _providerGateway.SearchAsync(
HttpContext.RequireProtocolExecutionContext(), query, limit, 0, 0, provider)).Songs
: (await _metadataService.SearchSongsAsync(query, limit, cancellationToken))
.Where(song => string.Equals(
song.ExternalProvider, provider, StringComparison.OrdinalIgnoreCase))
.Take(limit)
.ToArray();
private Task<Song?> GetProviderSongForImageAsync(
string provider,
@@ -1239,7 +1244,7 @@ public partial class JellyfinController : ControllerBase
// Search for similar songs using artist and genre
var searchQuery = $"{song.Artist}";
var searchResult = await SearchProviderSongsAsync(
searchQuery, limit, HttpContext.RequestAborted);
provider!, searchQuery, limit, HttpContext.RequestAborted);
// Filter out the original song and convert to Jellyfin format
var similarSongs = searchResult
@@ -1388,7 +1393,7 @@ public partial class JellyfinController : ControllerBase
if (mixSongs.Count < limit && !string.IsNullOrWhiteSpace(artistName))
{
var searchResult = await SearchProviderSongsAsync(
artistName, limit, HttpContext.RequestAborted);
provider!, artistName, limit, HttpContext.RequestAborted);
mixSongs.AddRange(searchResult.Where(song =>
mixSongs.All(existing => existing.Id != song.Id)));
}
@@ -26,7 +26,8 @@ public interface IProtocolProviderGateway
string query,
int songLimit,
int albumLimit,
int artistLimit);
int artistLimit,
string? providerId = null);
Task<IReadOnlyList<Song>> SearchPlayableSongsAsync(
ProtocolExecutionContext protocol,
@@ -111,23 +112,35 @@ public sealed class ProtocolProviderGateway(
string query,
int songLimit,
int albumLimit,
int artistLimit)
int artistLimit,
string? providerId = null)
{
ArgumentNullException.ThrowIfNull(protocol);
var requestedProviderId = string.IsNullOrWhiteSpace(providerId)
? null
: NormalizeProvider(providerId);
if (protocol.Actor is null)
{
var publicLegacy = await legacyMetadata.SearchAllAsync(
query, songLimit, albumLimit, artistLimit, protocol.CancellationToken);
return new SearchResult
{
Songs = publicLegacy.Songs.Where(item => IsPublicMetadataProvider(item.ExternalProvider)).Take(songLimit).ToList(),
Albums = publicLegacy.Albums.Where(item => IsPublicMetadataProvider(item.ExternalProvider)).Take(albumLimit).ToList(),
Artists = publicLegacy.Artists.Where(item => IsPublicMetadataProvider(item.ExternalProvider)).Take(artistLimit).ToList()
Songs = publicLegacy.Songs.Where(item => IsRequestedPublicProvider(item.ExternalProvider)).Take(songLimit).ToList(),
Albums = publicLegacy.Albums.Where(item => IsRequestedPublicProvider(item.ExternalProvider)).Take(albumLimit).ToList(),
Artists = publicLegacy.Artists.Where(item => IsRequestedPublicProvider(item.ExternalProvider)).Take(artistLimit).ToList()
};
bool IsRequestedPublicProvider(string? itemProvider) =>
IsPublicMetadataProvider(itemProvider) &&
(requestedProviderId == null ||
NormalizeProvider(itemProvider) == requestedProviderId);
}
var actor = protocol.RequireActor();
var fetchLimit = Math.Clamp(Math.Max(songLimit, Math.Max(albumLimit, artistLimit)), 1, 200);
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Metadata);
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Metadata)
.Where(item => requestedProviderId == null || item == requestedProviderId)
.ToArray();
if (providerOrder.Length == 0) return new SearchResult();
var plan = await router.PlanAsync<IProviderMetadataCapability>(Request(
protocol,
actor,