mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
fix(jellyfin): keep related tracks on chosen provider
This commit is contained in:
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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user