mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 21:55:39 -04:00
CI / build-and-test (push) Canceled after 0s
CI / release-critical-tests (push) Canceled after 0s
CI / csharp-format (push) Canceled after 0s
CI / webui (push) Canceled after 0s
CI / apple-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s
CI / release-manifest (push) Canceled after 0s
1049 lines
49 KiB
C#
1049 lines
49 KiB
C#
using System.Net;
|
|
using allstarr.Core.Capabilities;
|
|
using allstarr.Core.Identity;
|
|
using allstarr.Core.Matching;
|
|
using allstarr.Core.Protocols;
|
|
using allstarr.Core.Routing;
|
|
using allstarr.Core.Storage;
|
|
using allstarr.Services;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Moq;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed partial class ProtocolProviderStreamingGatewayTests
|
|
{
|
|
[Fact]
|
|
public async Task OpenStream_ActorlessContextDefersToCompatibilityFallback()
|
|
{
|
|
var gateway = new ProtocolProviderGateway(
|
|
Mock.Of<IProviderRouter>(MockBehavior.Strict),
|
|
new ProviderRegistry([]),
|
|
Mock.Of<IProviderRouteAccountResolver>(MockBehavior.Strict),
|
|
Mock.Of<IMusicMetadataService>(MockBehavior.Strict),
|
|
new HttpClientFactory());
|
|
var context = new ProtocolExecutionContext(
|
|
ProtocolKind.Jellyfin,
|
|
"backend",
|
|
"api-key",
|
|
null,
|
|
"stream-test",
|
|
DateTimeOffset.UtcNow.AddMinutes(1),
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(await gateway.OpenStreamAsync(
|
|
context, "deezer", "track-1", ProviderAudioQuality.Any, null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayableSearch_OnlyQueriesTracksAndIsolatesProviderFailures()
|
|
{
|
|
var failing = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
failing.SetupGet(item => item.ProviderId).Returns("apple-download");
|
|
failing.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
failing.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Track Artist")))
|
|
.ThrowsAsync(new HttpRequestException("unavailable"));
|
|
var healthy = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
healthy.SetupGet(item => item.ProviderId).Returns("deezer");
|
|
healthy.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
healthy.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Track Artist")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(new(
|
|
"deezer",
|
|
[
|
|
new ProviderTrackMetadata(
|
|
new("deezer", ProviderResourceKind.Track, "track-1"),
|
|
"Track",
|
|
[new("Artist")],
|
|
bitrate: 320_000),
|
|
new ProviderTrackMetadata(
|
|
new("musicbrainz", ProviderResourceKind.Track, "metadata-only"),
|
|
"Metadata only",
|
|
[new("Artist")])
|
|
])));
|
|
var registry = MetadataRegistry(failing.Object, healthy.Object);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
EmptyPlan<IProviderStreamingCapability>(request));
|
|
router.Setup(item => item.PlanAsync<IProviderDownloadCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
EmptyPlan<IProviderDownloadCapability>(request));
|
|
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
|
|
It.Is<ProviderRouteRequest>(request =>
|
|
request.Capability == ProviderCapabilityKind.Metadata &&
|
|
request.ProviderPriority.SequenceEqual(new[] { "apple-download", "deezer" }))))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
MetadataPlan(request, registry, failing.Object, healthy.Object));
|
|
var legacy = new Mock<IMusicMetadataService>();
|
|
legacy.Setup(item => item.SearchPlayableSongsAsync(
|
|
"Track Artist", 10, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync([]);
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
legacy.Object,
|
|
new HttpClientFactory());
|
|
|
|
var songs = await gateway.SearchPlayableSongsAsync(Context(), "Track Artist", 10);
|
|
|
|
var song = Assert.Single(songs);
|
|
Assert.Equal("track-1", song.ExternalId);
|
|
Assert.Equal(320_000, song.Bitrate);
|
|
failing.VerifyAll();
|
|
healthy.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayableSearch_DoesNotTreatDownloadOnlyProviderAsStreamable()
|
|
{
|
|
var metadata = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
metadata.SetupGet(item => item.ProviderId).Returns("download-only");
|
|
metadata.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
var download = new Mock<IProviderDownloadCapability>(MockBehavior.Strict);
|
|
download.SetupGet(item => item.ProviderId).Returns("download-only");
|
|
download.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Download);
|
|
var registry = new ProviderRegistry([
|
|
new ProviderRegistration(
|
|
new ProviderDescriptor(
|
|
"download-only",
|
|
"Download only",
|
|
"Fixture provider",
|
|
ProviderOrigin.BuiltIn,
|
|
"1",
|
|
"1",
|
|
[
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Metadata,
|
|
ProviderCapabilitySupportState.Supported,
|
|
ProviderAccountRequirement.None,
|
|
"1",
|
|
["searchTracks", "getTrack"]),
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Download,
|
|
ProviderCapabilitySupportState.Supported,
|
|
ProviderAccountRequirement.None,
|
|
"1",
|
|
["checkAvailability", "download"])
|
|
],
|
|
new ProviderPermissionDescriptor()),
|
|
[metadata.Object, download.Object])
|
|
]);
|
|
var gateway = new ProtocolProviderGateway(
|
|
Mock.Of<IProviderRouter>(MockBehavior.Strict),
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(MockBehavior.Strict),
|
|
Mock.Of<IMusicMetadataService>(MockBehavior.Strict),
|
|
new HttpClientFactory());
|
|
|
|
Assert.Empty(await gateway.SearchPlayableSongsAsync(
|
|
Context(), "Track Artist", 10));
|
|
}
|
|
|
|
[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));
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
EmptyPlan<IProviderStreamingCapability>(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 MetadataSearch_DoesNotPublishSongsWithoutAPlayableRoute()
|
|
{
|
|
var metadata = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
metadata.SetupGet(item => item.ProviderId).Returns("metadata-only");
|
|
metadata.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
metadata.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(new(
|
|
"metadata-only",
|
|
[new ProviderTrackMetadata(
|
|
new("metadata-only", ProviderResourceKind.Track, "track-1"),
|
|
"Track",
|
|
[new("Artist")])])));
|
|
metadata.Setup(item => item.SearchAlbumsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderAlbumMetadata>>.Success(
|
|
new("metadata-only", [])));
|
|
metadata.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
|
new("metadata-only", [])));
|
|
var registry = new ProviderRegistry([
|
|
new ProviderRegistration(
|
|
new ProviderDescriptor(
|
|
"metadata-only",
|
|
"Metadata only",
|
|
"Fixture provider",
|
|
ProviderOrigin.BuiltIn,
|
|
"1",
|
|
"1",
|
|
[new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Metadata,
|
|
ProviderCapabilitySupportState.Supported,
|
|
ProviderAccountRequirement.None,
|
|
"1",
|
|
["searchTracks", "getTrack"])],
|
|
new ProviderPermissionDescriptor()),
|
|
[metadata.Object])
|
|
]);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
MetadataPlan(request, registry, metadata.Object));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory());
|
|
|
|
var result = await gateway.SearchAsync(Context(), "Track", 10, 0, 0);
|
|
|
|
Assert.Empty(result.Songs);
|
|
metadata.VerifyAll();
|
|
router.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MetadataSearch_ProviderAndCategoryFailuresPreserveSuccessfulSongs()
|
|
{
|
|
var failed = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
failed.SetupGet(item => item.ProviderId).Returns("apple-download");
|
|
failed.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
failed.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ThrowsAsync(new HttpRequestException("unavailable"));
|
|
failed.Setup(item => item.SearchAlbumsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ThrowsAsync(new TimeoutException());
|
|
failed.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ThrowsAsync(new TimeoutException());
|
|
|
|
var healthy = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
healthy.SetupGet(item => item.ProviderId).Returns("deezer");
|
|
healthy.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
healthy.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(new(
|
|
"deezer", [new ProviderTrackMetadata(
|
|
new("deezer", ProviderResourceKind.Track, "track-1"),
|
|
"Track", [new("Artist")])])));
|
|
healthy.Setup(item => item.SearchAlbumsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ThrowsAsync(new TimeoutException());
|
|
healthy.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
|
new("deezer", [])));
|
|
|
|
var registry = MetadataRegistry(failed.Object, healthy.Object);
|
|
var streaming = new Mock<IProviderStreamingCapability>(MockBehavior.Strict);
|
|
streaming.SetupGet(item => item.ProviderId).Returns("deezer");
|
|
streaming.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
Plan(request, registry, streaming.Object));
|
|
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
MetadataPlan(request, registry, failed.Object, healthy.Object));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object, registry, Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(), new HttpClientFactory());
|
|
|
|
var song = Assert.Single((await gateway.SearchAsync(Context(), "Track", 10, 10, 10)).Songs);
|
|
|
|
Assert.Equal("track-1", song.ExternalId);
|
|
failed.VerifyAll();
|
|
healthy.VerifyAll();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ProviderIdentityVerification.Verified, "automatic-match", 2, "apple-download", null)]
|
|
[InlineData(ProviderIdentityVerification.Verified, "automatic-match", 2, "deezer", "deezer,apple-download,qobuz")]
|
|
[InlineData(ProviderIdentityVerification.Verified, "automatic-suggestion", 3, null, null)]
|
|
[InlineData(ProviderIdentityVerification.Pinned, "manual", 3, null, null)]
|
|
public async Task MetadataSearch_CollapsesOnlyVerifiedRoutes(
|
|
ProviderIdentityVerification verification, string method,
|
|
int expectedCount, string? preferredProvider, string? streamingOrder)
|
|
{
|
|
var providerIds = new[] { "apple-download", "deezer", "qobuz" };
|
|
var metadata = providerIds.Select(providerId =>
|
|
{
|
|
var capability = new Mock<IProviderMetadataCapability>();
|
|
capability.SetupGet(item => item.ProviderId).Returns(providerId);
|
|
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
capability.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(new(
|
|
providerId, [new ProviderTrackMetadata(
|
|
new(providerId, ProviderResourceKind.Track, $"{providerId}-track"),
|
|
"Shared title", [new("Artist")])])));
|
|
capability.Setup(item => item.SearchAlbumsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderAlbumMetadata>>.Success(
|
|
new(providerId, [])));
|
|
capability.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderMetadataSearchRequest>()))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
|
new(providerId, [])));
|
|
return capability.Object;
|
|
}).ToArray();
|
|
var registry = MetadataRegistry(metadata);
|
|
var streaming = providerIds.Select(providerId =>
|
|
{
|
|
var capability = new Mock<IProviderStreamingCapability>();
|
|
capability.SetupGet(item => item.ProviderId).Returns(providerId);
|
|
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
|
|
return capability.Object;
|
|
}).ToArray();
|
|
var router = new Mock<IProviderRouter>();
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) => Plan(request, registry, streaming));
|
|
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) => MetadataPlan(request, registry, metadata));
|
|
var recordingId = Guid.CreateVersion7();
|
|
var identities = new Mock<ITrackIdentityService>();
|
|
identities.Setup(item => item.ResolveManyAsync(
|
|
It.IsAny<IReadOnlyList<TrackIdentityLookup>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((IReadOnlyList<TrackIdentityLookup> lookups, CancellationToken _) =>
|
|
(IReadOnlyList<TrackIdentityResolution?>)lookups.Select(lookup =>
|
|
lookup.ExternalId.ProviderId == "qobuz"
|
|
? null
|
|
: new TrackIdentityResolution(
|
|
recordingId, Guid.CreateVersion7(), lookup.ExternalId,
|
|
ProviderIdentityScope.Catalog, null,
|
|
verification, method, 1))
|
|
.ToArray());
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(
|
|
new Dictionary<string, string?> { ["Providers:StreamingOrder"] = streamingOrder }).Build();
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object, registry, Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(), new HttpClientFactory(), configuration,
|
|
identities: identities.Object);
|
|
|
|
var songs = (await gateway.SearchAsync(Context(), "Shared title", 10, 0, 0)).Songs;
|
|
|
|
Assert.Equal(expectedCount, songs.Count);
|
|
Assert.Contains(songs, song => song.ExternalProvider == "qobuz");
|
|
if (preferredProvider != null) Assert.Contains(songs, song => song.ExternalProvider == preferredProvider);
|
|
identities.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MetadataRelationships_UseOnlyUniqueExactIdsFromTheSameProvider()
|
|
{
|
|
const string providerId = "spotiflac-ytmusic-spotiflac";
|
|
var artistId = new ProviderExternalResourceId(providerId, ProviderResourceKind.Artist, "artist-1");
|
|
var albumId = new ProviderExternalResourceId(providerId, ProviderResourceKind.Album, "album-1");
|
|
var track = new ProviderTrackMetadata(
|
|
new(providerId, ProviderResourceKind.Track, "track-1"),
|
|
"Track",
|
|
[new("Artist"), new("Featured")],
|
|
albumTitle: "Album");
|
|
var album = new ProviderAlbumMetadata(
|
|
albumId,
|
|
"Album",
|
|
[new("Artist", artistId)]);
|
|
var artists = new[]
|
|
{
|
|
new ProviderArtistMetadata(artistId, "Artist"),
|
|
new ProviderArtistMetadata(
|
|
new(providerId, ProviderResourceKind.Artist, "featured-1"), "Featured"),
|
|
new ProviderArtistMetadata(
|
|
new(providerId, ProviderResourceKind.Artist, "featured-2"), "Featured")
|
|
};
|
|
var capability = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
|
capability.SetupGet(item => item.ProviderId).Returns(providerId);
|
|
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
capability.Setup(item => item.SearchTracksAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Track")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(
|
|
new(providerId, [track])));
|
|
capability.Setup(item => item.SearchAlbumsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request =>
|
|
request.Query == "Track" || request.Query == "Album")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderAlbumMetadata>>.Success(
|
|
new(providerId, [album])));
|
|
capability.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Track")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
|
new(providerId, artists)));
|
|
capability.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Artist")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
|
new(providerId, [artists[0]])));
|
|
capability.Setup(item => item.SearchArtistsAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Featured")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
|
new(providerId, artists[1..])));
|
|
capability.Setup(item => item.GetTrackAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderTrackLookupRequest>(request => request.Id.Value == "track-1")))
|
|
.ReturnsAsync(ProviderOutcome<ProviderTrackMetadata>.Success(track));
|
|
var registry = MetadataRegistry(capability.Object);
|
|
var streaming = new Mock<IProviderStreamingCapability>(MockBehavior.Strict);
|
|
streaming.SetupGet(item => item.ProviderId).Returns(providerId);
|
|
streaming.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
Plan(request, registry, streaming.Object));
|
|
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
MetadataPlan(request, registry, capability.Object));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory());
|
|
|
|
var searchSong = Assert.Single((await gateway.SearchAsync(Context(), "Track", 10, 10, 10)).Songs);
|
|
var detailSong = await gateway.GetSongAsync(Context(), providerId, "track-1");
|
|
Assert.NotNull(detailSong);
|
|
|
|
foreach (var song in new[] { searchSong, detailSong! })
|
|
{
|
|
Assert.Equal($"ext-{providerId}-artist-artist-1", song.ArtistId);
|
|
Assert.Equal([$"ext-{providerId}-artist-artist-1", string.Empty], song.ArtistIds);
|
|
Assert.Equal($"ext-{providerId}-album-album-1", song.AlbumId);
|
|
}
|
|
capability.VerifyAll();
|
|
router.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayableSearch_ExcludesProviderWithoutAUsablePlaybackAccount()
|
|
{
|
|
var metadata = new Mock<IProviderMetadataCapability>();
|
|
metadata.SetupGet(item => item.ProviderId).Returns("qobuz");
|
|
metadata.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
|
var requiredScopes = new[]
|
|
{
|
|
ProviderAccountScope.Global,
|
|
ProviderAccountScope.User,
|
|
ProviderAccountScope.Library
|
|
};
|
|
var registry = new ProviderRegistry(
|
|
[
|
|
new ProviderRegistration(
|
|
new ProviderDescriptor(
|
|
"qobuz",
|
|
"Qobuz",
|
|
"Test provider",
|
|
ProviderOrigin.BuiltIn,
|
|
"1",
|
|
"1",
|
|
[
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Metadata,
|
|
ProviderCapabilitySupportState.Supported,
|
|
ProviderAccountRequirement.None,
|
|
"1",
|
|
["searchTracks", "getTrack"]),
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Streaming,
|
|
ProviderCapabilitySupportState.ConfiguredOnly,
|
|
ProviderAccountRequirement.Required,
|
|
"1",
|
|
allowedAccountScopes: requiredScopes),
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Download,
|
|
ProviderCapabilitySupportState.ConfiguredOnly,
|
|
ProviderAccountRequirement.Required,
|
|
"1",
|
|
allowedAccountScopes: requiredScopes)
|
|
],
|
|
new ProviderPermissionDescriptor()),
|
|
[metadata.Object])
|
|
]);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
EmptyPlan<IProviderStreamingCapability>(request));
|
|
router.Setup(item => item.PlanAsync<IProviderDownloadCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
EmptyPlan<IProviderDownloadCapability>(request));
|
|
var accounts = new Mock<IProviderRouteAccountResolver>();
|
|
accounts.Setup(item => item.ResolveAsync(
|
|
It.IsAny<ProviderRouteAccountRequest>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((ProviderRouteAccountResolution?)null);
|
|
var legacy = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
accounts.Object,
|
|
legacy.Object,
|
|
new HttpClientFactory());
|
|
|
|
var songs = await gateway.SearchPlayableSongsAsync(
|
|
Context(), "Track Artist", 10);
|
|
|
|
Assert.Empty(songs);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenStream_UsesOnlyAnExactVerifiedFallbackTrack()
|
|
{
|
|
var first = Capability("deezer", ProviderOutcome<ProviderStreamLease>.Failure(
|
|
new ProviderError(ProviderErrorKind.TransientFailure)));
|
|
var second = Capability("qobuz", ProviderOutcome<ProviderStreamLease>.Success(new(
|
|
"qobuz-lease",
|
|
new Uri("https://media.example.test/track"),
|
|
DateTimeOffset.UtcNow.AddMinutes(1),
|
|
true,
|
|
true,
|
|
new ProviderMediaFormat("audio/flac", "flac", "flac"),
|
|
ProviderStreamRetryBehavior.DoNotRetry,
|
|
(_, _) => Task.FromResult(AudioResponse()))));
|
|
var registry = Registry(first.Object, second.Object);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.Is<ProviderRouteRequest>(request =>
|
|
request.Policy.AllowFallback &&
|
|
request.SourceTrackId!.ProviderId == "deezer" &&
|
|
request.SourceTrackId.Value == "source-track" &&
|
|
request.ProviderPriority.SequenceEqual(new[] { "deezer", "qobuz" }))))
|
|
.ReturnsAsync((ProviderRouteRequest request) =>
|
|
Plan(request, registry, first.Object, second.Object));
|
|
router.Setup(item => item.EvaluateFallback(
|
|
It.IsAny<ProviderRoutePlan<IProviderStreamingCapability>>(),
|
|
0,
|
|
It.Is<ProviderError>(error => error.Kind == ProviderErrorKind.TransientFailure)))
|
|
.Returns((ProviderRoutePlan<IProviderStreamingCapability> plan, int _, ProviderError _) =>
|
|
new ProviderFallbackDecision<IProviderStreamingCapability>(
|
|
ProviderFallbackDisposition.Advance,
|
|
"fallback-transient-failure",
|
|
plan.Candidates[1]));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory());
|
|
|
|
var stream = await gateway.OpenStreamAsync(
|
|
Context(), "deezer", "source-track", ProviderAudioQuality.Lossless, null);
|
|
|
|
Assert.NotNull(stream);
|
|
Assert.Equal("qobuz", stream.ServingProviderId);
|
|
first.Verify(item => item.GetStreamLeaseAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderStreamLeaseRequest>(request =>
|
|
request.TrackId.ProviderId == "deezer" &&
|
|
request.TrackId.Value == "source-track" &&
|
|
request.RequestedQuality == ProviderAudioQuality.Lossless)), Times.Once);
|
|
second.Verify(item => item.GetStreamLeaseAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderStreamLeaseRequest>(request =>
|
|
request.TrackId.ProviderId == "qobuz" &&
|
|
request.TrackId.Value == "qobuz-track" &&
|
|
request.RequestedQuality == ProviderAudioQuality.Lossless)), Times.Once);
|
|
stream.Response.Dispose();
|
|
first.VerifyAll();
|
|
second.VerifyAll();
|
|
router.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenStream_CachesABoundedExactTranslationMiss()
|
|
{
|
|
var capability = Capability("qobuz", ProviderOutcome<ProviderStreamLease>.Failure(
|
|
new ProviderError(ProviderErrorKind.TransientFailure)));
|
|
var registry = Registry(capability.Object);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) => TranslationMissPlan(request));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory(),
|
|
applicationCache: new TestMemoryApplicationCache());
|
|
var context = Context();
|
|
|
|
Assert.Null(await gateway.OpenStreamAsync(
|
|
context, "spotiflac-ytmusic-spotiflac", "private-track-id",
|
|
ProviderAudioQuality.Lossless, null));
|
|
Assert.Null(await gateway.OpenStreamAsync(
|
|
context, "spotiflac-ytmusic-spotiflac", "private-track-id",
|
|
ProviderAudioQuality.Lossless, null));
|
|
|
|
router.Verify(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenStream_ConcurrentTranslationMissesFailSafelyAndReuseTheCache()
|
|
{
|
|
var capability = Capability("qobuz", ProviderOutcome<ProviderStreamLease>.Failure(
|
|
new ProviderError(ProviderErrorKind.TransientFailure)));
|
|
var registry = Registry(capability.Object);
|
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var calls = 0;
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.Returns(async (ProviderRouteRequest request) =>
|
|
{
|
|
if (Interlocked.Increment(ref calls) == 2) bothStarted.SetResult();
|
|
await release.Task;
|
|
return TranslationMissPlan(request);
|
|
});
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory(),
|
|
applicationCache: new TestMemoryApplicationCache());
|
|
var context = Context();
|
|
|
|
var first = gateway.OpenStreamAsync(
|
|
context, "spotiflac-ytmusic-spotiflac", "private-track-id",
|
|
ProviderAudioQuality.DataSaver, null);
|
|
var second = gateway.OpenStreamAsync(
|
|
context, "spotiflac-ytmusic-spotiflac", "private-track-id",
|
|
ProviderAudioQuality.DataSaver, null);
|
|
await bothStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
|
release.SetResult();
|
|
|
|
Assert.All(await Task.WhenAll(first, second), Assert.Null);
|
|
Assert.Null(await gateway.OpenStreamAsync(
|
|
context, "spotiflac-ytmusic-spotiflac", "private-track-id",
|
|
ProviderAudioQuality.DataSaver, null));
|
|
Assert.Equal(2, calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true, HttpStatusCode.PartialContent, "bytes=10-19")]
|
|
[InlineData(false, HttpStatusCode.OK, null)]
|
|
public async Task OpenStream_ForwardsRangesOnlyWhenTheLeaseCanHonorThem(
|
|
bool supportsRanges,
|
|
HttpStatusCode expectedStatus,
|
|
string? expectedRange)
|
|
{
|
|
string? observedRange = null;
|
|
var lease = new ProviderStreamLease(
|
|
"lease",
|
|
new Uri("https://media.example.test/track"),
|
|
DateTimeOffset.UtcNow.AddMinutes(1),
|
|
supportsRanges,
|
|
supportsRanges,
|
|
new ProviderMediaFormat("audio/flac", "flac", "flac"),
|
|
ProviderStreamRetryBehavior.DoNotRetry,
|
|
(request, _) =>
|
|
{
|
|
observedRange = request.Headers.Range?.ToString();
|
|
return Task.FromResult(AudioResponse(
|
|
observedRange == null ? HttpStatusCode.OK : HttpStatusCode.PartialContent));
|
|
});
|
|
var capability = Capability("qobuz", ProviderOutcome<ProviderStreamLease>.Success(lease));
|
|
var registry = Registry(capability.Object);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) => Plan(request, registry, capability.Object));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory());
|
|
|
|
var stream = await gateway.OpenStreamAsync(
|
|
Context(), "qobuz", "source-track", ProviderAudioQuality.Lossless, "bytes=10-19");
|
|
|
|
Assert.NotNull(stream);
|
|
Assert.Equal(expectedStatus, stream.Response.StatusCode);
|
|
Assert.Equal(expectedRange, observedRange);
|
|
capability.Verify(item => item.GetStreamLeaseAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.Is<ProviderStreamLeaseRequest>(request => request.RangeStart == 10)), Times.Once);
|
|
stream.Response.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenStream_UsesHeadWithoutReadingProviderMedia()
|
|
{
|
|
HttpMethod? observedMethod = null;
|
|
var lease = new ProviderStreamLease(
|
|
"lease",
|
|
new Uri("https://media.example.test/track"),
|
|
DateTimeOffset.UtcNow.AddMinutes(1),
|
|
supportsByteRanges: false,
|
|
supportsSeeking: false,
|
|
new ProviderMediaFormat("audio/flac", "flac", "flac"),
|
|
ProviderStreamRetryBehavior.DoNotRetry,
|
|
(request, _) =>
|
|
{
|
|
observedMethod = request.Method;
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
|
});
|
|
var capability = Capability("apple-download", ProviderOutcome<ProviderStreamLease>.Success(lease));
|
|
var registry = Registry(capability.Object);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) => Plan(request, registry, capability.Object));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory());
|
|
|
|
var stream = await gateway.OpenStreamAsync(
|
|
Context(), "apple-download", "source-track", ProviderAudioQuality.DataSaver, null, headOnly: true);
|
|
|
|
Assert.NotNull(stream);
|
|
Assert.Equal(HttpMethod.Head, observedMethod);
|
|
stream.Response.Dispose();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ProviderStreamRetryBehavior.RetrySameLeaseOnce, 1)]
|
|
[InlineData(ProviderStreamRetryBehavior.RefreshLease, 2)]
|
|
public async Task OpenStream_RetriesOnceAccordingToTheLease(
|
|
ProviderStreamRetryBehavior retryBehavior,
|
|
int expectedLeaseCount)
|
|
{
|
|
var leaseCount = 0;
|
|
var openCount = 0;
|
|
var capability = new Mock<IProviderStreamingCapability>(MockBehavior.Strict);
|
|
capability.SetupGet(item => item.ProviderId).Returns("qobuz");
|
|
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
|
|
capability.Setup(item => item.GetStreamLeaseAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderStreamLeaseRequest>()))
|
|
.ReturnsAsync(() =>
|
|
{
|
|
var ordinal = ++leaseCount;
|
|
return ProviderOutcome<ProviderStreamLease>.Success(new(
|
|
$"lease-{ordinal}",
|
|
new Uri("https://media.example.test/track"),
|
|
DateTimeOffset.UtcNow.AddMinutes(1),
|
|
true,
|
|
true,
|
|
new ProviderMediaFormat("audio/flac", "flac", "flac"),
|
|
retryBehavior,
|
|
(_, _) =>
|
|
{
|
|
openCount++;
|
|
var status = retryBehavior == ProviderStreamRetryBehavior.RefreshLease
|
|
? ordinal == 1 ? HttpStatusCode.Forbidden : HttpStatusCode.OK
|
|
: openCount == 1 ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK;
|
|
return Task.FromResult(AudioResponse(status));
|
|
}));
|
|
});
|
|
var registry = Registry(capability.Object);
|
|
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
|
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
|
|
It.IsAny<ProviderRouteRequest>()))
|
|
.ReturnsAsync((ProviderRouteRequest request) => Plan(request, registry, capability.Object));
|
|
var gateway = new ProtocolProviderGateway(
|
|
router.Object,
|
|
registry,
|
|
Mock.Of<IProviderRouteAccountResolver>(),
|
|
Mock.Of<IMusicMetadataService>(),
|
|
new HttpClientFactory());
|
|
|
|
var stream = await gateway.OpenStreamAsync(
|
|
Context(), "qobuz", "source-track", ProviderAudioQuality.Lossless, null);
|
|
|
|
Assert.NotNull(stream);
|
|
Assert.Equal(HttpStatusCode.OK, stream.Response.StatusCode);
|
|
Assert.Equal(expectedLeaseCount, leaseCount);
|
|
Assert.Equal(2, openCount);
|
|
Assert.Equal($"lease-{expectedLeaseCount}", stream.Lease.LeaseId);
|
|
stream.Response.Dispose();
|
|
}
|
|
|
|
private static Mock<IProviderStreamingCapability> Capability(
|
|
string providerId,
|
|
ProviderOutcome<ProviderStreamLease> outcome)
|
|
{
|
|
var capability = new Mock<IProviderStreamingCapability>(MockBehavior.Strict);
|
|
capability.SetupGet(item => item.ProviderId).Returns(providerId);
|
|
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
|
|
capability.Setup(item => item.GetStreamLeaseAsync(
|
|
It.IsAny<ProviderExecutionContext>(),
|
|
It.IsAny<ProviderStreamLeaseRequest>()))
|
|
.ReturnsAsync(outcome);
|
|
return capability;
|
|
}
|
|
|
|
private static ProviderRegistry Registry(params IProviderStreamingCapability[] capabilities) => new(
|
|
capabilities.Select(capability =>
|
|
{
|
|
var descriptor = new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Streaming,
|
|
ProviderCapabilitySupportState.Supported,
|
|
ProviderAccountRequirement.None,
|
|
"1.0",
|
|
["getStreamLease"]);
|
|
return new ProviderRegistration(
|
|
new ProviderDescriptor(
|
|
capability.ProviderId,
|
|
capability.ProviderId,
|
|
"Test provider",
|
|
ProviderOrigin.BuiltIn,
|
|
"1",
|
|
"1.0",
|
|
[descriptor],
|
|
new ProviderPermissionDescriptor()),
|
|
[capability]);
|
|
}));
|
|
|
|
private static ProviderRegistry MetadataRegistry(params IProviderMetadataCapability[] capabilities) => new(
|
|
capabilities.Select(capability => new ProviderRegistration(
|
|
new ProviderDescriptor(
|
|
capability.ProviderId,
|
|
capability.ProviderId,
|
|
"Test provider",
|
|
ProviderOrigin.BuiltIn,
|
|
"1",
|
|
"1.0",
|
|
[
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Metadata,
|
|
ProviderCapabilitySupportState.Supported,
|
|
ProviderAccountRequirement.None,
|
|
"1.0",
|
|
["searchTracks", "getTrack"]),
|
|
new ProviderCapabilityDescriptor(
|
|
ProviderCapabilityKind.Streaming,
|
|
ProviderCapabilitySupportState.ConfiguredOnly,
|
|
ProviderAccountRequirement.None,
|
|
"1.0")
|
|
],
|
|
new ProviderPermissionDescriptor()),
|
|
[capability])));
|
|
|
|
private static ProviderRoutePlan<IProviderStreamingCapability> Plan(
|
|
ProviderRouteRequest request,
|
|
IProviderRegistry registry,
|
|
params IProviderStreamingCapability[] capabilities)
|
|
{
|
|
var candidates = capabilities.Select((capability, index) =>
|
|
{
|
|
var provider = registry.GetRequired(capability.ProviderId);
|
|
return new ProviderRouteCandidate<IProviderStreamingCapability>(
|
|
index,
|
|
provider,
|
|
provider.Capabilities.Single(item =>
|
|
item.Capability == ProviderCapabilityKind.Streaming),
|
|
capability,
|
|
new ProviderExecutionContext(
|
|
request.Actor,
|
|
capability.ProviderId,
|
|
null,
|
|
request.Library,
|
|
request.Policy,
|
|
request.OperationId,
|
|
request.CorrelationId,
|
|
request.Deadline,
|
|
request.CancellationToken),
|
|
new ProviderExternalResourceId(
|
|
capability.ProviderId,
|
|
ProviderResourceKind.Track,
|
|
request.SourceTrackId is { } source &&
|
|
source.ProviderId == capability.ProviderId
|
|
? source.Value
|
|
: $"{capability.ProviderId}-track"));
|
|
}).ToArray();
|
|
return new ProviderRoutePlan<IProviderStreamingCapability>(
|
|
request,
|
|
candidates,
|
|
new ProviderRouteDecisionRecord(
|
|
request.CorrelationId,
|
|
ProviderCapabilityKind.Streaming,
|
|
candidates[0].Provider.Id,
|
|
null,
|
|
candidates.Select(item => new ProviderRouteCandidateDecision(
|
|
item.Provider.Id, null, ProviderRouteDecisionStatus.Accepted,
|
|
item.Priority == 0 ? "selected" : "eligible-fallback", item.Priority)).ToArray()));
|
|
}
|
|
|
|
private static ProviderRoutePlan<IProviderMetadataCapability> MetadataPlan(
|
|
ProviderRouteRequest request,
|
|
IProviderRegistry registry,
|
|
params IProviderMetadataCapability[] capabilities)
|
|
{
|
|
var candidates = capabilities.Select((capability, index) =>
|
|
{
|
|
var provider = registry.GetRequired(capability.ProviderId);
|
|
return new ProviderRouteCandidate<IProviderMetadataCapability>(
|
|
index,
|
|
provider,
|
|
provider.Capabilities.Single(item =>
|
|
item.Capability == ProviderCapabilityKind.Metadata),
|
|
capability,
|
|
new ProviderExecutionContext(
|
|
request.Actor,
|
|
capability.ProviderId,
|
|
null,
|
|
request.Library,
|
|
request.Policy,
|
|
request.OperationId,
|
|
request.CorrelationId,
|
|
request.Deadline,
|
|
request.CancellationToken),
|
|
null);
|
|
}).ToArray();
|
|
return new ProviderRoutePlan<IProviderMetadataCapability>(
|
|
request,
|
|
candidates,
|
|
new ProviderRouteDecisionRecord(
|
|
request.CorrelationId,
|
|
ProviderCapabilityKind.Metadata,
|
|
candidates[0].Provider.Id,
|
|
null,
|
|
candidates.Select(item => new ProviderRouteCandidateDecision(
|
|
item.Provider.Id,
|
|
null,
|
|
ProviderRouteDecisionStatus.Accepted,
|
|
item.Priority == 0 ? "selected" : "eligible-fallback",
|
|
item.Priority)).ToArray()));
|
|
}
|
|
|
|
private static ProviderRoutePlan<TCapability> EmptyPlan<TCapability>(
|
|
ProviderRouteRequest request)
|
|
where TCapability : class, IProviderCapability =>
|
|
new(
|
|
request,
|
|
[],
|
|
new ProviderRouteDecisionRecord(
|
|
request.CorrelationId,
|
|
request.Capability,
|
|
null,
|
|
null,
|
|
[]));
|
|
|
|
private static ProviderRoutePlan<IProviderStreamingCapability> TranslationMissPlan(
|
|
ProviderRouteRequest request) => new(
|
|
request,
|
|
[],
|
|
new ProviderRouteDecisionRecord(
|
|
request.CorrelationId,
|
|
request.Capability,
|
|
null,
|
|
null,
|
|
[
|
|
new ProviderRouteCandidateDecision(
|
|
request.SourceTrackId!.ProviderId,
|
|
null,
|
|
ProviderRouteDecisionStatus.Rejected,
|
|
"capability-unavailable",
|
|
0),
|
|
new ProviderRouteCandidateDecision(
|
|
"qobuz",
|
|
null,
|
|
ProviderRouteDecisionStatus.Rejected,
|
|
"verified-identity-required",
|
|
1)
|
|
]));
|
|
|
|
private static ProtocolExecutionContext Context()
|
|
{
|
|
var tenant = Guid.CreateVersion7();
|
|
var user = Guid.CreateVersion7();
|
|
return new ProtocolExecutionContext(
|
|
ProtocolKind.Jellyfin,
|
|
"backend",
|
|
"principal",
|
|
new AllstarrPrincipal(
|
|
tenant, user, "jellyfin", "backend", "principal", "User", false),
|
|
"stream-test",
|
|
DateTimeOffset.UtcNow.AddMinutes(1),
|
|
CancellationToken.None,
|
|
new ProtocolClientDescriptor("client", "device"),
|
|
libraryScopeId: "music");
|
|
}
|
|
|
|
private sealed class HttpClientFactory : IHttpClientFactory
|
|
{
|
|
public string? Range { get; private set; }
|
|
|
|
public HttpClient CreateClient(string name) => new(new Handler(request =>
|
|
Range = request.Headers.Range?.ToString()));
|
|
}
|
|
|
|
private sealed class Handler(Action<HttpRequestMessage> inspect) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
inspect(request);
|
|
return Task.FromResult(AudioResponse());
|
|
}
|
|
}
|
|
}
|