using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Xml.Linq; using allstarr.Services; using allstarr.Services.Common; using allstarr.Models.Search; using allstarr.Models.Domain; using allstarr.Models.Subsonic; using allstarr.Core.Protocols.Subsonic; using allstarr.Core.Protocols; using allstarr.Core.Protocols.Jellyfin; using allstarr.Core.Matching; using allstarr.Core.Playlists; using allstarr.Core.Storage; using allstarr.Core.Intelligence; using allstarr.Core.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Moq; using SkiaSharp; namespace allstarr.Tests; public sealed class ProtocolRouteFixtureTests { [Fact] public void JellyfinExternalStream_ReportsUnavailableAccountAsForbidden() { var (status, message) = allstarr.Controllers.JellyfinController.MapExternalStreamException( new UnauthorizedAccessException("do not expose provider details")); Assert.Equal(StatusCodes.Status403Forbidden, status); Assert.Equal("External provider account is unavailable", message); } [Theory] [InlineData("u=fixture&t=hash&s=salt&v=1.16.1&c=fixture&f=json", "application/json")] [InlineData("u=fixture&p=secret&v=1.16.1&c=fixture&f=xml", "application/xml")] public async Task SubsonicPing_PostFormCredentialsReachBackend( string body, string responseContentType) { ObservedRequest? observed = null; using var factory = new ProtocolFactory("Subsonic", request => { observed = Observe(request); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("response", Encoding.UTF8, responseContentType) }; }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Post, "/rest/ping.view") { Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded") }; using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(observed); Assert.Equal("POST", observed.Method); Assert.Equal("/rest/ping.view", observed.PathAndQuery); Assert.Equal(body, observed.Body); } [Fact] public async Task JellyfinAuthentication_PreservesEveryFixtureStatusAndBody() { using var fixtures = ReadFixture("jellyfin-authentication.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var upstreamStatus = fixture.GetProperty("upstreamStatus").GetInt32(); var upstreamBody = fixture.GetProperty("upstreamBody").GetRawText(); var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.AbsolutePath); return Json(upstreamStatus, upstreamBody); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Post, "/Users/AuthenticateByName") { Content = new StringContent( """{"Username":"fixture","Pw":"wrong"}""", Encoding.UTF8, "application/json") }; using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(fixture.GetProperty("expectedStatus").GetInt32(), (int)response.StatusCode); Assert.Equal( JsonDocument.Parse(upstreamBody).RootElement.GetRawText(), JsonDocument.Parse(body).RootElement.GetRawText()); Assert.Equal(["/Users/AuthenticateByName"], observedRequests); } } [Fact] public async Task JellyfinAuthBoundary_MissingCredentialsStopsBeforeBackendAndAction() { var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return Json(StatusCodes.Status200OK, """{"Id":"unexpected"}"""); }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/Items?SearchTerm=fixture&IncludeItemTypes=Audio"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Empty(observedRequests); } [Fact] public async Task JellyfinPublicSystemInfo_BypassesCurrentUserVerification() { var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.AbsolutePath); return Json( StatusCodes.Status200OK, """{"ServerName":"Fixture Server","Version":"12.0.0"}"""); }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/System/Info/Public"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(["/System/Info/Public"], observedRequests); } [Fact] public async Task JellyfinAuthenticatedSystemInfo_VerifiesAndRelaysWithoutReshaping() { const string systemInfo = """{"Id":"server-1","ServerName":"Fixture Server","Version":"12.0.0"}"""; var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : Json(StatusCodes.Status200OK, systemInfo); }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/System/Info?api_key=fixture-key"); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( ["/Users/Me?api_key=fixture-key", "/System/Info?api_key=fixture-key"], observedRequests); Assert.Equal( JsonDocument.Parse(systemInfo).RootElement.GetRawText(), JsonDocument.Parse(body).RootElement.GetRawText()); } [Fact] public async Task JellyfinLocalArtistDetail_PreservesTheEntireUpstreamObject() { const string artistId = "00112233445566778899aabbccddeeff"; const string artist = """ { "Id":"00112233445566778899aabbccddeeff", "Name":"Fixture Artist", "Type":"MusicArtist", "ProviderIds":{"MusicBrainzArtist":"artist-1"}, "UserData":{"IsFavorite":true,"PlayCount":7}, "ImageBlurHashes":{"Primary":{"tag":"hash"}}, "UnknownFutureField":{"Keep":[1,2,3]} } """; var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : Json(StatusCodes.Status200OK, artist); }); using var client = factory.CreateClient(); using var response = await client.GetAsync($"/Artists/{artistId}?api_key=fixture-key"); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(JsonNode.DeepEquals(JsonNode.Parse(artist), JsonNode.Parse(body))); Assert.Equal(2, observedRequests.Count); Assert.Equal("/Users/Me?api_key=fixture-key", observedRequests[0]); Assert.Equal($"/Items/{artistId}", observedRequests[1]); } [Fact] public async Task JellyfinArtistSearch_PreservesTheEntireLocalArtistObject() { const string artist = """ { "Id":"00112233445566778899aabbccddeeff", "Name":"Fixture Artist", "Type":"MusicArtist", "ProviderIds":{"MusicBrainzArtist":"artist-1"}, "UserData":{"IsFavorite":true,"PlayCount":7}, "UnknownFutureField":{"Keep":[1,2,3]} } """; using var factory = new ProtocolFactory("Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : Json(StatusCodes.Status200OK, $$"""{"Items":[{{artist}}],"TotalRecordCount":1,"StartIndex":0}""")); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Artists?SearchTerm=Fixture%20Artist&Limit=10&api_key=fixture-key"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(JsonNode.DeepEquals( JsonNode.Parse(artist), JsonNode.Parse(body.RootElement.GetProperty("Items")[0].GetRawText()))); } [Fact] public async Task JellyfinAuthenticatedSystemInfo_FallsBackForUnboundApiKeyOnPreTwelveServers() { var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath switch { "/Users/Me" => Json(StatusCodes.Status400BadRequest, """{"error":"API key has no current user"}"""), "/Users/user-1" => Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""), _ => Json(StatusCodes.Status200OK, """{"Id":"server-1","Version":"10.11.11"}""") }; }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Get, "/System/Info"); request.Headers.TryAddWithoutValidation( "X-Emby-Authorization", """MediaBrowser Client="Fixture", Device="Tests", DeviceId="test-1", Version="1", UserId="user-1", Token="fixture-token" """); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(["/Users/Me", "/Users/user-1", "/System/Info"], observedRequests); } [Fact] public async Task JellyfinApiKeyFallback_DoesNotBindDeclaredUserToProviderActor() { var interaction = new RecordingInteractionAdapter(); var metadata = new Mock(MockBehavior.Strict); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath switch { "/Users/Me" => Json(StatusCodes.Status400BadRequest, """{"error":"API key has no current user"}"""), "/Users/user-1" => Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""), _ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}") }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); services.AddSingleton(interaction); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, "/Items/ext-deezer-song-1/InstantMix"); request.Headers.TryAddWithoutValidation( "X-Emby-Authorization", """MediaBrowser Client="Fixture", Device="Tests", DeviceId="test-1", Version="1", UserId="user-1", Token="fixture-token" """); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(interaction.LastContext); Assert.Equal("user-1", interaction.LastContext!.VerifiedBackendPrincipalId); Assert.Null(interaction.LastContext.Actor); metadata.VerifyNoOtherCalls(); } [Fact] public async Task JellyfinAuthorizationHeader_UsesUsersMeInsteadOfDeclaredVictim() { var interaction = new RecordingInteractionAdapter(); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"attacker","Name":"Current User"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(interaction); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, "/Items/ext-deezer-song-1/InstantMix"); request.Headers.TryAddWithoutValidation( "X-Emby-Authorization", """MediaBrowser Client="Fixture", Device="Tests", DeviceId="test-1", Version="1", UserId="victim", Token="fixture-token" """); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("attacker", interaction.LastContext?.VerifiedBackendPrincipalId); Assert.NotEqual("victim", interaction.LastContext?.VerifiedBackendPrincipalId); } [Theory] [InlineData("/Search/Hints?SearchTerm=fixture&Limit=2&api_key=fixture-key", false)] [InlineData("/Users/user-1/Search/Hints?SearchTerm=fixture&Limit=2&api_key=fixture-key", true)] public async Task JellyfinSearchHints_AppliesOneGlobalLimitAfterMerging(string path, bool userScoped) { var observedRequests = new List(); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.SearchAllAsync( "fixture", 2, 2, 2, It.IsAny())) .ReturnsAsync(new SearchResult { Songs = [new Song { Id = "external-song", Title = "Song", Artist = "Artist" }], Albums = [new Album { Id = "external-album", Title = "Album", Artist = "Artist" }], Artists = [new Artist { Id = "external-artist", Name = "Artist" }] }); using var factory = new ProtocolFactory( "Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : Json(StatusCodes.Status200OK, """{"SearchHints":[{"Id":"native-1","Type":"Audio"},{"Id":"native-2","Type":"Audio"}],"TotalRecordCount":2}"""); }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); }); using var client = factory.CreateClient(); using var response = await client.GetAsync(path); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, body.RootElement.GetProperty("SearchHints").GetArrayLength()); Assert.Equal(2, body.RootElement.GetProperty("TotalRecordCount").GetInt32()); Assert.Contains(observedRequests, request => request.StartsWith("/Search/Hints?", StringComparison.Ordinal) && (!userScoped || request.Contains("UserId=user-1", StringComparison.Ordinal))); Assert.DoesNotContain(observedRequests, request => request.StartsWith("/Users/user-1/Search/Hints", StringComparison.Ordinal)); metadata.VerifyAll(); } [Fact] public async Task JellyfinAuthBoundary_RejectsBeforeSearchCacheProviderOrExternalActions() { using var fixtures = ReadFixture("jellyfin-auth-boundary.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var verification = fixture.GetProperty("verification"); var expected = fixture.GetProperty("expected"); var observedRequests = new List(); var metadata = new Mock(MockBehavior.Strict); var downloads = new Mock(MockBehavior.Strict); using var factory = new ProtocolFactory( "Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return Json( verification.GetProperty("status").GetInt32(), verification.GetProperty("body").GetRawText()); }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); services.AddSingleton(downloads.Object); }); using var client = factory.CreateClient(); var requestFixture = fixture.GetProperty("request"); using var request = new HttpRequestMessage( new HttpMethod(requestFixture.GetProperty("method").GetString()!), requestFixture.GetProperty("path").GetString()); if (requestFixture.TryGetProperty("header", out var header)) { request.Headers.TryAddWithoutValidation( header.GetString()!, requestFixture.GetProperty("credential").GetString()); } using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(expected.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.Equal( expected.GetProperty("body").GetRawText(), JsonDocument.Parse(body).RootElement.GetRawText()); Assert.Equal( [verification.GetProperty("pathAndQuery").GetString()!], observedRequests); metadata.VerifyNoOtherCalls(); downloads.VerifyNoOtherCalls(); } } [Fact] public async Task JellyfinAuthBoundary_AllowsVerifiedClientAndForwardsOnlyCredentialQuery() { var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : Json(StatusCodes.Status200OK, """{"Items":[],"TotalRecordCount":0,"StartIndex":0}"""); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, "/Items?IncludeItemTypes=Audio&api_key=fixture-valid-key"); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( [ "/Users/Me?api_key=fixture-valid-key", "/Items?IncludeItemTypes=Audio&api_key=fixture-valid-key&Fields=MediaSources" ], observedRequests); } [Fact] public async Task JellyfinApiKey_UserPathFallsBackBeforeNativeRelay() { var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status400BadRequest, """{"error":"API key has no current user"}""") : Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User","Policy":{"IsDisabled":false}}"""); }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/Users/user-1?api_key=fixture-key"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( [ "/Users/Me?api_key=fixture-key", "/Users/user-1?api_key=fixture-key", "/Users/user-1?api_key=fixture-key" ], observedRequests); } [Fact] public async Task JellyfinStaticMusicRoute_BeatsParameterizedItemRoute() { var observedRequests = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : Json(StatusCodes.Status200OK, """[{"Id":"album-1","Type":"MusicAlbum"}]"""); }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/Items/Latest?api_key=fixture-key&Limit=1"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, observedRequests.Count); Assert.StartsWith("/Items/Latest?", observedRequests[1], StringComparison.Ordinal); Assert.Contains("IncludeItemTypes=", observedRequests[1], StringComparison.Ordinal); } [Fact] public async Task JellyfinMusicRoot_UsesFullItemRouteAndCallerAuthentication() { var observedRequests = new List(); using var factory = new ProtocolFactory( "Jellyfin", request => { observedRequests.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : Json(StatusCodes.Status200OK, """{"Id":"music-1","Type":"CollectionFolder","CollectionType":"music","Etag":"full-object"}"""); }, configuration: new Dictionary { ["Jellyfin:LibraryId"] = "music-1", ["Jellyfin:UserId"] = "user-1" }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/Items/Root?api_key=fixture-key&UserId=user-1"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("full-object", body.RootElement.GetProperty("Etag").GetString()); Assert.Equal( ["/Users/Me?api_key=fixture-key", "/Items/music-1?api_key=fixture-key&UserId=user-1"], observedRequests); } [Theory] [InlineData("Download")] [InlineData("File")] public async Task JellyfinNativeFileHead_PreservesMethodAndStatus(string route) { HttpMethod? observedMethod = null; using var factory = new ProtocolFactory("Jellyfin", request => { if (request.RequestUri!.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); if (request.RequestUri.AbsolutePath == "/Items") return ItemLookup("""{"Id":"0123456789abcdef0123456789abcdef","Type":"Audio"}"""); observedMethod = request.Method; return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Head, $"/Items/0123456789abcdef0123456789abcdef/{route}?api_key=fixture-key"); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); Assert.Equal(HttpMethod.Head, observedMethod); } [Fact] public async Task JellyfinNativePlaylistItems_PreserveUpstreamTotalWhenEveryRowIsAudio() { const string playlistId = "0123456789abcdef0123456789abcdef"; using var factory = new ProtocolFactory("Jellyfin", request => { if (request.RequestUri!.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); if (request.RequestUri.AbsolutePath == "/Items") return ItemLookup($$"""{"Id":"{{playlistId}}","Type":"Playlist"}"""); return Json(StatusCodes.Status200OK, """{"Items":[{"Id":"song-1","Type":"Audio","Sentinel":"complete"}],"TotalRecordCount":903,"StartIndex":0}"""); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( $"/Playlists/{playlistId}/Items?Limit=100&api_key=fixture-key"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(903, body.RootElement.GetProperty("TotalRecordCount").GetInt32()); Assert.Equal("complete", body.RootElement.GetProperty("Items")[0].GetProperty("Sentinel").GetString()); } [Fact] public async Task JellyfinSearchAdapter_PreservesFixtureStatusBodyAndPaging() { using var fixture = ReadFixture("jellyfin-search-shaping.json"); var metadata = new Mock(MockBehavior.Strict); metadata .Setup(service => service.SearchAllAsync( "fixture", 20, 0, 0, It.IsAny())) .ReturnsAsync(new SearchResult()); metadata .Setup(service => service.SearchPlaylistsAsync( "fixture", 20, It.IsAny())) .ReturnsAsync([]); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : Json(StatusCodes.Status200OK, fixture.RootElement.GetProperty("upstream").GetProperty("body").GetRawText()), services => { services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( fixture.RootElement.GetProperty("request").GetProperty("path").GetString()); var body = await response.Content.ReadAsStringAsync(); var expected = fixture.RootElement.GetProperty("expected"); Assert.Equal(expected.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.Equal( expected.GetProperty("contentType").GetString(), response.Content.Headers.ContentType?.MediaType); Assert.Equal( CanonicalJson(expected.GetProperty("body")), CanonicalJson(JsonDocument.Parse(body).RootElement)); metadata.Verify(service => service.SearchAllAsync( "fixture", 20, 0, 0, It.IsAny()), Times.Once); } [Fact] public async Task JellyfinItemAndImageAdapters_PreserveExternalShapingPlaceholderAndConditionalResponse() { using var fixture = ReadFixture("jellyfin-item-image-shaping.json"); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.GetSongAsync("deezer", "42", It.IsAny())) .ReturnsAsync(new Song { Id = "ext-deezer-song-42", ExternalId = "42", ExternalProvider = "deezer", IsLocal = false, Title = "Fixture Song", Artist = "Fixture Artist", Album = "Fixture Album", Duration = 123 }); metadata.Setup(service => service.GetSongAsync("deezer", "no-art", It.IsAny())) .ReturnsAsync(new Song { Id = "ext-deezer-song-no-art", ExternalId = "no-art", ExternalProvider = "deezer", IsLocal = false, Title = "No Art" }); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); var itemFixture = fixture.RootElement.GetProperty("item"); using var itemResponse = await client.GetAsync(itemFixture.GetProperty("requestPath").GetString()); using var itemBody = JsonDocument.Parse(await itemResponse.Content.ReadAsStringAsync()); var expectedItem = itemFixture.GetProperty("expected"); Assert.Equal(expectedItem.GetProperty("status").GetInt32(), (int)itemResponse.StatusCode); Assert.Equal(expectedItem.GetProperty("contentType").GetString(), itemResponse.Content.Headers.ContentType?.MediaType); Assert.Equal(expectedItem.GetProperty("id").GetString(), itemBody.RootElement.GetProperty("Id").GetString()); Assert.Equal(expectedItem.GetProperty("name").GetString(), itemBody.RootElement.GetProperty("Name").GetString()); Assert.Equal(expectedItem.GetProperty("type").GetString(), itemBody.RootElement.GetProperty("Type").GetString()); Assert.Equal(expectedItem.GetProperty("providerId").GetString(), itemBody.RootElement.GetProperty("ProviderIds").GetProperty("deezer").GetString()); var imageFixture = fixture.RootElement.GetProperty("image"); using var imageResponse = await client.GetAsync(imageFixture.GetProperty("requestPath").GetString()); var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync(); var expectedImage = imageFixture.GetProperty("expected"); Assert.Equal(expectedImage.GetProperty("status").GetInt32(), (int)imageResponse.StatusCode); Assert.Equal(expectedImage.GetProperty("contentType").GetString(), imageResponse.Content.Headers.ContentType?.MediaType); Assert.Equal(expectedImage.GetProperty("length").GetInt32(), imageBytes.Length); Assert.NotNull(imageResponse.Headers.ETag); using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, imageFixture.GetProperty("requestPath").GetString()); conditionalRequest.Headers.IfNoneMatch.Add(imageResponse.Headers.ETag); using var conditionalResponse = await client.SendAsync(conditionalRequest); Assert.Equal(expectedImage.GetProperty("conditionalStatus").GetInt32(), (int)conditionalResponse.StatusCode); Assert.Empty(await conditionalResponse.Content.ReadAsByteArrayAsync()); using var headRequest = new HttpRequestMessage( HttpMethod.Head, imageFixture.GetProperty("requestPath").GetString()); using var headResponse = await client.SendAsync(headRequest); Assert.Equal(HttpStatusCode.OK, headResponse.StatusCode); Assert.Equal(expectedImage.GetProperty("contentType").GetString(), headResponse.Content.Headers.ContentType?.MediaType); Assert.Empty(await headResponse.Content.ReadAsByteArrayAsync()); using var pathImageResponse = await client.GetAsync( "/Items/ext-deezer-song-no-art/Images/Primary/0/no-art/png/300/300/0/0?api_key=fixture-key"); Assert.Equal(HttpStatusCode.OK, pathImageResponse.StatusCode); Assert.Equal( expectedImage.GetProperty("length").GetInt32(), (await pathImageResponse.Content.ReadAsByteArrayAsync()).Length); } [Fact] public async Task JellyfinExternalAlbumImage_ParsesHyphenatedProviderAndReturnsPositiveArtwork() { var artworkBytes = new byte[] { 10, 20, 30, 40 }; var observedPaths = new List(); var gateway = new Mock(MockBehavior.Strict); gateway.Setup(service => service.GetAlbumAsync( It.Is(context => context.Protocol == ProtocolKind.Jellyfin), "apple-musickit", "i.album")) .ReturnsAsync(new Album { Id = "ext-apple-musickit-album-i.album", ExternalProvider = "apple-musickit", ExternalId = "i.album", Title = "Library Album", Artist = "Library Artist", CoverArtUrl = "https://is1-ssl.mzstatic.com/image/thumb/library/1024x1024bb.jpg", IsLocal = false }); using var factory = new ProtocolFactory( "Jellyfin", request => { observedPaths.Add(request.RequestUri!.PathAndQuery); if (request.RequestUri.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status200OK, """{"Id":"verified-user"}"""); if (request.RequestUri.Host == "is1-ssl.mzstatic.com") { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(artworkBytes) { Headers = { ContentType = new("image/jpeg") } } }; } throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); }, services => { services.RemoveAll(); services.AddSingleton(gateway.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Items/ext-apple-musickit-album-i.album/Images/Primary?api_key=fixture-key&tag=revision-1"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync()); Assert.NotNull(response.Headers.ETag); Assert.Contains(observedPaths, path => path == "/Users/Me?api_key=fixture-key"); Assert.Contains(observedPaths, path => path == "/image/thumb/library/1024x1024bb.jpg"); gateway.VerifyAll(); } [Fact] public async Task JellyfinSynthesizedLongImageRoute_HonorsSizeAndFormat() { using var bitmap = new SKBitmap(new SKImageInfo( 8, 4, SKColorType.Rgba8888, SKAlphaType.Opaque)); bitmap.Erase(SKColors.Red); using var sourceImage = SKImage.FromBitmap(bitmap); using var sourceData = sourceImage.Encode(SKEncodedImageFormat.Png, 100); var sourceBytes = sourceData.ToArray(); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.GetAlbumAsync( "deezer", "42", It.IsAny())) .ReturnsAsync(new Album { Id = "ext-deezer-album-42", ExternalProvider = "deezer", ExternalId = "42", Title = "Fixture Album", CoverArtUrl = "https://images.example.test/cover.png" }); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath switch { "/Users/Me" => Json(StatusCodes.Status200OK, """{"Id":"user-1"}"""), "/cover.png" => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(sourceBytes) { Headers = { ContentType = new("image/png") } } }, _ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}") }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Items/ext-deezer-album-42/Images/Primary/0/revision/jpg/2/2/0/0?api_key=fixture-key"); var resultBytes = await response.Content.ReadAsByteArrayAsync(); using var resultBitmap = SKBitmap.Decode(resultBytes); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.NotNull(resultBitmap); Assert.Equal(2, resultBitmap.Width); Assert.Equal(1, resultBitmap.Height); metadata.VerifyAll(); } [Fact] public async Task JellyfinExternalSongImage_WithoutPlayerToken_UsesMetadataFallback() { var artworkBytes = new byte[] { 0xFF, 0xD8, 0x01, 0x02, 0xFF, 0xD9 }; var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.GetSongAsync( "applemusic", "6768469976", It.IsAny())) .ReturnsAsync(new Song { Id = "ext-applemusic-song-6768469976", ExternalProvider = "applemusic", ExternalId = "6768469976", Title = "Artwork Track", CoverArtUrl = "https://is1-ssl.mzstatic.com/image/thumb/song/1024x1024bb.jpg", IsLocal = false }); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.Host == "is1-ssl.mzstatic.com" ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(artworkBytes) { Headers = { ContentType = new("image/jpeg") } } } : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Items/ext-applemusic-song-6768469976/Images/Primary?fillHeight=600&fillWidth=600"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync()); metadata.VerifyAll(); } [Fact] public async Task JellyfinVirtualPlaylistImage_WithoutPlayerToken_UsesPublicArtworkSource() { const string virtualId = "allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0"; var artworkBytes = new byte[] { 0xFF, 0xD8, 0x01, 0x02, 0xFF, 0xD9 }; var virtualization = new Mock(MockBehavior.Strict); virtualization.Setup(service => service.ResolvePublicArtworkSourceAsync( virtualId, It.IsAny())) .ReturnsAsync(new VirtualPlaylistArtworkSource("deezer", "source-list")); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.GetPlaylistAsync( "deezer", "source-list", It.IsAny())) .ReturnsAsync(new ExternalPlaylist { Id = "ext-deezer-playlist-source-list", ExternalId = "source-list", Provider = "deezer", Name = "Source", CoverUrl = "https://fixture-cdn.example/playlist.jpg" }); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.Host == "fixture-cdn.example" ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(artworkBytes) { Headers = { ContentType = new("image/jpeg") } } } : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(virtualization.Object); services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync($"/Items/{virtualId}/Images/Primary"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync()); virtualization.VerifyAll(); metadata.VerifyAll(); } [Fact] public async Task JellyfinTargetPlaylistImage_RelaysNativeTargetWithoutPlayerToken() { const string virtualId = "allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0"; var artworkBytes = new byte[] { 0xFF, 0xD8, 0x03, 0x04, 0xFF, 0xD9 }; var virtualization = new Mock(MockBehavior.Strict); virtualization.Setup(service => service.ResolvePublicArtworkSourceAsync( virtualId, It.IsAny())) .ReturnsAsync(new VirtualPlaylistArtworkSource( "spotify", "source-list", "native-target")); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Items/native-target/Images/Primary" ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(artworkBytes) { Headers = { ContentType = new("image/jpeg") } } } : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(virtualization.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync($"/Items/{virtualId}/Images/Primary"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync()); virtualization.VerifyAll(); } [Fact] public async Task JellyfinApplePlaybackInfo_AdvertisesImmediateFlacStream() { var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.GetSongAsync( "applemusic", "6768469976", It.IsAny())) .ReturnsAsync(new Song { Id = "ext-applemusic-song-6768469976", ExternalProvider = "applemusic", ExternalId = "6768469976", Title = "Playback Track", Artist = "Fixture Artist", Album = "Fixture Album", Duration = 180, IsLocal = false }); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Items/ext-applemusic-song-6768469976/PlaybackInfo?api_key=fixture-key"); var responseBody = await response.Content.ReadAsStringAsync(); Assert.True(response.StatusCode == HttpStatusCode.OK, responseBody); using var document = JsonDocument.Parse(responseBody); var source = document.RootElement.GetProperty("MediaSources")[0]; Assert.Equal("flac", source.GetProperty("Container").GetString()); Assert.Equal( "/Audio/ext-applemusic-song-6768469976/stream?static=true", source.GetProperty("DirectStreamUrl").GetString()); Assert.False(source.GetProperty("RequiresOpening").GetBoolean()); metadata.VerifyAll(); } [Fact] public async Task JellyfinLocalImage_ForwardsPlayerTokenAndReturnsArtwork() { var artworkBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; var playerTokenReachedVerification = false; var playerTokenReachedArtwork = false; using var factory = new ProtocolFactory("Jellyfin", request => { var hasPlayerToken = request.Headers.TryGetValues("X-Emby-Token", out var values) && values.Contains("fixture-player-token", StringComparer.Ordinal); if (request.RequestUri!.AbsolutePath == "/Users/Me") { playerTokenReachedVerification = hasPlayerToken; return Json(StatusCodes.Status200OK, """{"Id":"verified-user"}"""); } if (request.RequestUri.AbsolutePath == "/Items/local-song/Images/Primary") { playerTokenReachedArtwork = hasPlayerToken; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(artworkBytes) { Headers = { ContentType = new("image/jpeg") } } }; } if (IsItemLookup(request, "local-song")) { return ItemLookup("""{"Id":"local-song","Type":"Audio"}"""); } throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, "/Items/local-song/Images/Primary?maxWidth=300&maxHeight=300&tag=art-v1"); request.Headers.TryAddWithoutValidation("X-Emby-Token", "fixture-player-token"); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync()); Assert.True(playerTokenReachedVerification); Assert.True(playerTokenReachedArtwork); } [Theory] [InlineData("GET")] [InlineData("HEAD")] public async Task JellyfinNativeLongImageRelay_PreservesRangeValidatorsAndDecimalPath(string method) { const string imagePath = "/Items/local-song/Images/Primary/0/art-v1/jpg/300/300/12.5/0?quality=90"; var observed = new List<(string Method, string PathAndQuery, string? Range, string? IfRange)>(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(( request.Method.Method, request.RequestUri!.PathAndQuery, request.Headers.TryGetValues("Range", out var ranges) ? ranges.Single() : null, request.Headers.TryGetValues("If-Range", out var validators) ? validators.Single() : null)); if (IsItemLookup(request, "local-song")) return ItemLookup("""{"Id":"local-song","Type":"Audio"}"""); if (request.RequestUri.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status400BadRequest, """{"error":"API key has no current user"}"""); if (request.RequestUri.AbsolutePath == "/Users/user-1") return Json(StatusCodes.Status200OK, """{"Id":"user-1"}"""); Assert.Equal(imagePath, request.RequestUri.PathAndQuery); var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent([8, 9, 10, 11]) }; response.Content.Headers.ContentType = new("image/jpeg"); response.Content.Headers.ContentRange = new(8, 11, 32); response.Headers.AcceptRanges.Add("bytes"); response.Headers.ETag = new("\"art-v1\""); return response; }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(new HttpMethod(method), imagePath); request.Headers.TryAddWithoutValidation( "X-Emby-Authorization", """MediaBrowser Client="Fixture", Device="Tests", DeviceId="test-1", Version="1", UserId="user-1", Token="fixture-token" """); request.Headers.TryAddWithoutValidation("Range", "bytes=8-11"); request.Headers.TryAddWithoutValidation("If-Range", "\"art-v1\""); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); Assert.Equal("bytes 8-11/32", response.Content.Headers.ContentRange?.ToString()); Assert.Equal("\"art-v1\"", response.Headers.ETag?.Tag); Assert.Equal("bytes", response.Headers.AcceptRanges.Single()); Assert.Equal(method == "HEAD" ? Array.Empty() : new byte[] { 8, 9, 10, 11 }, await response.Content.ReadAsByteArrayAsync()); Assert.Equal(["/Items?ids=local-song&limit=1", "/Users/Me", "/Users/user-1", imagePath], observed.Select(item => item.PathAndQuery)); Assert.Equal(method, observed[^1].Method); Assert.Equal("bytes=8-11", observed[^1].Range); Assert.Equal("\"art-v1\"", observed[^1].IfRange); } [Fact] public async Task JellyfinLyrics_PreservesLocalFirstFallbackAndNotFoundFixtures() { using var fixtures = ReadFixture("jellyfin-lyrics.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedPaths = new List(); var metadata = new Mock(MockBehavior.Strict); if (fixture.TryGetProperty("external", out var external) && external.GetBoolean()) { metadata.Setup(service => service.GetSongAsync("deezer", "missing", It.IsAny())) .ReturnsAsync((Song?)null); } using var factory = new ProtocolFactory( "Jellyfin", request => { observedPaths.Add(request.RequestUri!.AbsolutePath); if (request.RequestUri.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal)) { return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); } if (request.RequestUri.AbsolutePath.StartsWith("/Audio/", StringComparison.Ordinal) && request.RequestUri.AbsolutePath.EndsWith("/Lyrics", StringComparison.Ordinal)) { return Json( fixture.GetProperty("upstreamLyricsStatus").GetInt32(), fixture.GetProperty("upstreamLyricsBody").GetRawText()); } if (request.RequestUri.AbsolutePath == "/Items" && fixture.TryGetProperty("itemBody", out var itemBody)) { return ItemLookup(itemBody.GetRawText()); } if (request.RequestUri.AbsolutePath == "/Items") { var id = QueryHelpers.ParseQuery(request.RequestUri.Query)["ids"].ToString(); return ItemLookup($$"""{"Id":"{{id}}","Type":"Audio"}"""); } throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync(fixture.GetProperty("requestPath").GetString()); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(fixture.GetProperty("expectedStatus").GetInt32(), (int)response.StatusCode); if (fixture.TryGetProperty("expectedBodyKind", out var expectedBodyKind) && expectedBodyKind.GetString() == "upstream") { Assert.Equal( CanonicalJson(fixture.GetProperty("upstreamLyricsBody")), CanonicalJson(JsonDocument.Parse(body).RootElement)); Assert.Equal( ["/Items", "/Users/Me", "/Audio/local-song/Lyrics"], observedPaths); } else { Assert.Equal( CanonicalJson(fixture.GetProperty("expectedBody")), CanonicalJson(JsonDocument.Parse(body).RootElement)); } metadata.VerifyAll(); } } [Fact] public async Task JellyfinFavorite_PreservesBackendResultAndRejectsRouteUserAuthorizationForOptionalWork() { using var fixture = ReadFixture("jellyfin-favorite-playback.json"); var observedPaths = new List(); var metadata = new Mock(MockBehavior.Strict); var interaction = new RecordingInteractionAdapter(); var local = fixture.RootElement.GetProperty("favorite").GetProperty("local"); var upstream = local.GetProperty("upstream"); using var factory = new ProtocolFactory( "Jellyfin", request => { observedPaths.Add(request.RequestUri!.PathAndQuery); if (request.RequestUri.AbsolutePath.StartsWith("/Users/", StringComparison.Ordinal)) { return Json(StatusCodes.Status200OK, """{"Id":"verified-user","Name":"Fixture User"}"""); } if (IsItemLookup(request, "local-song")) { return ItemLookup("""{"Id":"local-song","Type":"Audio"}"""); } Assert.Equal(upstream.GetProperty("pathAndQuery").GetString(), request.RequestUri.PathAndQuery); return Json(upstream.GetProperty("status").GetInt32(), upstream.GetProperty("body").GetRawText()); }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); services.AddSingleton(interaction); }); using var client = factory.CreateClient(); using var localRequest = new HttpRequestMessage( HttpMethod.Post, local.GetProperty("request").GetProperty("path").GetString()); using var localResponse = await client.SendAsync(localRequest); Assert.Equal(upstream.GetProperty("status").GetInt32(), (int)localResponse.StatusCode); Assert.Equal( CanonicalJson(upstream.GetProperty("body")), CanonicalJson(JsonDocument.Parse(await localResponse.Content.ReadAsStringAsync()).RootElement)); var external = fixture.RootElement.GetProperty("favorite").GetProperty("external"); using var externalRequest = new HttpRequestMessage( HttpMethod.Post, external.GetProperty("request").GetProperty("path").GetString()); using var externalResponse = await client.SendAsync(externalRequest); var expected = external.GetProperty("expected"); Assert.Equal(expected.GetProperty("status").GetInt32(), (int)externalResponse.StatusCode); Assert.Equal(expected.GetProperty("contentType").GetString(), externalResponse.Content.Headers.ContentType?.MediaType); Assert.Equal( CanonicalJson(expected.GetProperty("body")), CanonicalJson(JsonDocument.Parse(await externalResponse.Content.ReadAsStringAsync()).RootElement)); Assert.NotNull(interaction.LastContext); Assert.Equal("verified-user", interaction.LastContext!.VerifiedBackendPrincipalId); Assert.Null(interaction.LastContext.Actor); metadata.VerifyNoOtherCalls(); } [Fact] public async Task JellyfinPlayback_PreservesCapabilitiesAndProgressCompatibilityFixtures() { using var fixture = ReadFixture("jellyfin-favorite-playback.json"); foreach (var capability in fixture.RootElement.GetProperty("capabilities").EnumerateArray()) { var observedPaths = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedPaths.Add(request.RequestUri!.AbsolutePath); return request.RequestUri.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : Json(capability.GetProperty("upstreamStatus").GetInt32(), "{}"); }); using var client = factory.CreateClient(); using var response = await client.PostAsync( "/Sessions/Capabilities?api_key=fixture-key", new StringContent("{}", Encoding.UTF8, "application/json")); Assert.Equal(capability.GetProperty("expectedStatus").GetInt32(), (int)response.StatusCode); Assert.Equal(["/Users/Me", "/Sessions/Capabilities"], observedPaths); } var progress = fixture.RootElement.GetProperty("progress"); using var progressFactory = new ProtocolFactory("Jellyfin", request => request.RequestUri!.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : Json(progress.GetProperty("upstreamStatus").GetInt32(), "{}")); using var progressClient = progressFactory.CreateClient(); using var progressResponse = await progressClient.PostAsync( "/Sessions/Playing/Progress?api_key=fixture-key", new StringContent(progress.GetProperty("requestBody").GetRawText(), Encoding.UTF8, "application/json")); Assert.Equal(progress.GetProperty("expectedStatus").GetInt32(), (int)progressResponse.StatusCode); } [Fact] public async Task JellyfinInstantMix_PreservesPinnedRouteClassesAcrossSupportedVersions() { using var fixtures = ReadFixture("jellyfin-instant-mix-paths.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedPaths = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observedPaths.Add(request.RequestUri!.PathAndQuery); if (IsItemLookup(request, "item-1")) { return ItemLookup("""{"Id":"item-1","Type":"Audio"}"""); } return request.RequestUri.AbsolutePath.Equals("/Users/Me", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : Json(fixture.GetProperty("status").GetInt32(), fixture.GetProperty("body").GetRawText()); }); using var client = factory.CreateClient(); var path = fixture.GetProperty("path").GetString()!; var query = fixture.TryGetProperty("query", out var fixtureQuery) ? fixtureQuery.GetString() + "&" : string.Empty; var requestPath = $"{path}?{query}api_key=fixture-key&Limit=2"; using var response = await client.GetAsync(requestPath); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(fixture.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.Equal( CanonicalJson(fixture.GetProperty("body")), CanonicalJson(JsonDocument.Parse(body).RootElement)); var expectedPaths = path.Equals("/Items/item-1/InstantMix", StringComparison.Ordinal) ? new[] { "/Items?ids=item-1&limit=1", "/Users/Me?api_key=fixture-key", requestPath } : new[] { "/Users/Me?api_key=fixture-key", requestPath }; Assert.Equal(expectedPaths, observedPaths); } var metadata = new Mock(MockBehavior.Strict); using var unresolvedFactory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath.StartsWith("/Users/", StringComparison.Ordinal) ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var unresolvedClient = unresolvedFactory.CreateClient(); using var unresolvedResponse = await unresolvedClient.GetAsync( "/Songs/ext-deezer-song-42/InstantMix?api_key=fixture-key&userId=spoofed-route-user"); Assert.Equal(HttpStatusCode.OK, unresolvedResponse.StatusCode); Assert.Equal( "{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}", await unresolvedResponse.Content.ReadAsStringAsync()); metadata.VerifyNoOtherCalls(); } [Fact] public async Task JellyfinExternalSimilarAndInstantMix_StayOnTheTypedProvider() { var gateway = new Mock(MockBehavior.Strict); gateway.Setup(service => service.GetSongAsync( It.IsAny(), "deezer", "42")) .ReturnsAsync(new Song { Id = "ext-deezer-song-42", ExternalProvider = "deezer", ExternalId = "42", Title = "Seed", Artist = "Fixture Artist" }); gateway.Setup(service => service.SearchAsync( It.IsAny(), "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(MockBehavior.Strict); var shaper = new JellyfinInteractionProtocolAdapter(); interaction.Setup(adapter => adapter.CanRunOptionalUserWork( It.IsAny())) .Returns(true); interaction.Setup(adapter => adapter.ShapeInstantMix( It.IsAny>>())) .Returns((IReadOnlyList> items) => shaper.ShapeInstantMix(items)); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(gateway.Object); services.RemoveAll(); 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()); 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(), "Fixture Artist", 2, 0, 0, "deezer"), Times.Exactly(2)); gateway.VerifyAll(); interaction.VerifyAll(); } [Fact] public async Task JellyfinExternalInstantMix_ProviderFailureKeepsTheStandardEnvelope() { var gateway = new Mock(MockBehavior.Strict); gateway.Setup(service => service.GetSongAsync( It.IsAny(), "deezer", "42")) .ThrowsAsync(new HttpRequestException("provider unavailable")); var interaction = new Mock(MockBehavior.Strict); interaction.Setup(adapter => adapter.CanRunOptionalUserWork( It.IsAny())) .Returns(true); interaction.Setup(adapter => adapter.ShapeInstantMix( It.Is>>(items => items.Count == 0))) .Returns(new JellyfinInteractionProtocolAdapter().ShapeInstantMix([])); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(gateway.Object); services.RemoveAll(); services.AddSingleton(interaction.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Items/ext-deezer-song-42/InstantMix?Limit=2&api_key=fixture-key"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( "{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}", await response.Content.ReadAsStringAsync()); gateway.VerifyAll(); interaction.VerifyAll(); } [Theory] [InlineData("/Albums/ext-deezer-album-42/InstantMix?Limit=2&api_key=fixture-key", "album")] [InlineData("/Artists/ext-deezer-artist-42/InstantMix?Limit=2&api_key=fixture-key", "artist")] [InlineData("/Artists/InstantMix?id=ext-deezer-artist-42&Limit=2&api_key=fixture-key", "artist")] public async Task JellyfinExternalInstantMix_UsesTheTypedProviderResource( string path, string resourceType) { var metadata = new Mock(MockBehavior.Strict); var songs = new List { new() { Id = "mix-1", Title = "First", Artist = "Fixture Artist" }, new() { Id = "mix-2", Title = "Second", Artist = "Fixture Artist" } }; if (resourceType == "album") { metadata.Setup(service => service.GetAlbumAsync( "deezer", "42", It.IsAny())) .ReturnsAsync(new Album { Id = "ext-deezer-album-42", ExternalProvider = "deezer", ExternalId = "42", Title = "Fixture Album", Artist = "Fixture Artist", Songs = songs }); } else { metadata.Setup(service => service.GetArtistAsync( "deezer", "42", It.IsAny())) .ReturnsAsync(new Artist { Id = "ext-deezer-artist-42", ExternalProvider = "deezer", ExternalId = "42", Name = "Fixture Artist" }); metadata.Setup(service => service.GetArtistAlbumsAsync( "deezer", "42", It.IsAny())) .ReturnsAsync([ new Album { Id = "ext-deezer-album-a1", ExternalProvider = "deezer", ExternalId = "a1", Title = "Fixture Album", Artist = "Fixture Artist" } ]); metadata.Setup(service => service.GetAlbumAsync( "deezer", "a1", It.IsAny())) .ReturnsAsync(new Album { Id = "ext-deezer-album-a1", ExternalProvider = "deezer", ExternalId = "a1", Title = "Fixture Album", Artist = "Fixture Artist", Songs = songs }); } var interaction = new Mock(MockBehavior.Strict); var shaper = new JellyfinInteractionProtocolAdapter(); interaction.Setup(adapter => adapter.CanRunOptionalUserWork( It.IsAny())) .Returns(true); interaction.Setup(adapter => adapter.ShapeInstantMix( It.IsAny>>())) .Returns((IReadOnlyList> items) => shaper.ShapeInstantMix(items)); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(interaction.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync(path); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(0, body.RootElement.GetProperty("StartIndex").GetInt32()); Assert.Equal(2, body.RootElement.GetProperty("Items").GetArrayLength()); Assert.Equal( ["mix-1", "mix-2"], body.RootElement.GetProperty("Items").EnumerateArray() .Select(item => item.GetProperty("Id").GetString()!).Order().ToArray()); metadata.VerifyAll(); interaction.VerifyAll(); } [Fact] public async Task JellyfinLocalInstantMix_UsesSelectedAudioMuseAndPreservesNativeItemOrder() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); audioMuse.Setup(client => client.FindSimilarAsync( It.Is(scope => scope.Protocol == "jellyfin" && scope.LibraryScopeId == "music"), It.Is>(ids => ids.SequenceEqual(new[] { "seed" })), 2, It.IsAny())) .ReturnsAsync([ SonicTrack("mix-2", .9, "Second"), SonicTrack("mix-1", .8, "First") ]); using var factory = new ProtocolFactory( "Jellyfin", request => { if (request.RequestUri!.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); if (request.RequestUri.AbsolutePath == "/Items" && QueryHelpers.ParseQuery(request.RequestUri.Query)["Ids"] == "mix-2,mix-1") return Json(StatusCodes.Status200OK, """{"Items":[{"Id":"mix-1","Name":"First","RunTimeTicks":100},{"Id":"mix-2","Name":"Second","RunTimeTicks":200}]}"""); throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); }, SonicServices(audioMuse.Object)); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Songs/seed/InstantMix?Limit=2&api_key=fixture-key"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var items = body.RootElement.GetProperty("Items"); Assert.Equal(["mix-2", "mix-1"], items.EnumerateArray() .Select(item => item.GetProperty("Id").GetString()!).ToArray()); Assert.Equal(200, items[0].GetProperty("RunTimeTicks").GetInt64()); audioMuse.VerifyAll(); } [Fact] public async Task JellyfinLocalInstantMix_StaysNativeWhenAudioMuseIsNotSelected() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath switch { "/Users/Me" => Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""), "/Songs/seed/InstantMix" => Json(StatusCodes.Status200OK, """{"Items":[{"Id":"native-1","Name":"Native mix"}],"TotalRecordCount":1}"""), _ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}") }, SonicServices(audioMuse.Object, selected: false)); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Songs/seed/InstantMix?Limit=2&api_key=fixture-key"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("native-1", body.RootElement.GetProperty("Items")[0].GetProperty("Id").GetString()); audioMuse.Verify(client => client.FindSimilarAsync( It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task OpenSubsonicSonicSimilarTracks_PreserveNativeJsonAndScope() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); audioMuse.Setup(client => client.FindSimilarAsync( It.Is(scope => scope.Protocol == "subsonic" && scope.LibraryScopeId == "music"), It.Is>(ids => ids.SequenceEqual(new[] { "seed" })), 2, It.IsAny())) .ReturnsAsync([ SonicTrack("song-2", .9, "Second"), SonicTrack("song-1", .8, "First") ]); using var factory = new ProtocolFactory( "Subsonic", request => SubsonicSonicBackend(request), SonicServices(audioMuse.Object)); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/rest/getSonicSimilarTracks.view?u=fixture&p=fixture-password&v=1.16.1&c=fixture&f=json&id=seed&count=2"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var matches = body.RootElement.GetProperty("subsonic-response").GetProperty("sonicMatch"); Assert.Equal(["song-2", "song-1"], matches.EnumerateArray() .Select(item => item.GetProperty("entry").GetProperty("id").GetString()!).ToArray()); Assert.Equal(202, matches[0].GetProperty("entry").GetProperty("duration").GetInt32()); Assert.Equal(.9, matches[0].GetProperty("similarity").GetDouble(), 5); audioMuse.VerifyAll(); } [Fact] public async Task OpenSubsonicSonicPath_PreservesNativeXmlEndpointOrder() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); audioMuse.Setup(client => client.FindPathAsync( It.Is(scope => scope.Protocol == "subsonic" && scope.LibraryScopeId == "music"), "start", "end", 3, It.IsAny())) .ReturnsAsync(new AudioMusePathResult([ SonicTrack("start", .7, "Start"), SonicTrack("bridge", .6, "Bridge"), SonicTrack("end", .5, "End") ], 1.2)); using var factory = new ProtocolFactory( "Subsonic", request => SubsonicSonicBackend(request), SonicServices(audioMuse.Object)); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/rest/findSonicPath.view?u=fixture&p=fixture-password&v=1.16.1&c=fixture&startSongId=start&endSongId=end&count=3"); var document = XDocument.Parse(await response.Content.ReadAsStringAsync()); XNamespace ns = "http://subsonic.org/restapi"; var matches = document.Root!.Elements(ns + "sonicMatch").ToArray(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(["start", "bridge", "end"], matches .Select(item => item.Element(ns + "entry")!.Attribute("id")!.Value).ToArray()); Assert.Equal("1", matches[0].Attribute("similarity")!.Value); Assert.All(matches, item => Assert.NotNull(item.Element(ns + "entry")!.Attribute("duration"))); audioMuse.VerifyAll(); } [Fact] public async Task OpenSubsonicSonicPath_RejectsEndpointsFromDifferentLibraries() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); using var factory = new ProtocolFactory( "Subsonic", request => request.RequestUri!.AbsolutePath == "/rest/ping.view" ? Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), SonicServices(audioMuse.Object, otherLibraryItem: "end")); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/rest/findSonicPath.view?u=fixture&p=fixture-password&v=1.16.1&c=fixture&f=json&startSongId=start&endSongId=end&count=3"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(70, body.RootElement.GetProperty("subsonic-response") .GetProperty("error").GetProperty("code").GetInt32()); audioMuse.Verify(client => client.FindPathAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task OpenSubsonicExtensionDiscovery_IsPublicAndMergesSonicCapability() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); using var factory = new ProtocolFactory( "Subsonic", request => request.RequestUri!.AbsolutePath == "/rest/getOpenSubsonicExtensions.view" ? Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1","openSubsonicExtensions":[{"name":"songLyrics","versions":[1]}]}}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), SonicServices(audioMuse.Object)); using var client = factory.CreateClient(); using var response = await client.GetAsync("/rest/getOpenSubsonicExtensions.view?f=json"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(["songLyrics", "sonicSimilarity"], body.RootElement .GetProperty("subsonic-response").GetProperty("openSubsonicExtensions") .EnumerateArray().Select(item => item.GetProperty("name").GetString()!).ToArray()); audioMuse.VerifyAll(); } [Fact] public async Task OpenSubsonicExtensionDiscovery_AdvertisesSonicWhenBackendDiscoveryIsDown() { var audioMuse = new Mock(MockBehavior.Strict); audioMuse.SetupGet(client => client.IsAvailable).Returns(true); using var factory = new ProtocolFactory( "Subsonic", _ => throw new HttpRequestException("Backend discovery unavailable"), SonicServices(audioMuse.Object)); using var client = factory.CreateClient(); using var response = await client.GetAsync("/rest/getOpenSubsonicExtensions.view?f=json"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("sonicSimilarity", body.RootElement .GetProperty("subsonic-response").GetProperty("openSubsonicExtensions")[0] .GetProperty("name").GetString()); audioMuse.VerifyAll(); } [Theory] [InlineData("/Artists/InstantMix?id=ext-deezer-album-42&api_key=fixture-key")] [InlineData("/MusicGenres/InstantMix?id=ext-deezer-song-42&api_key=fixture-key")] [InlineData("/Artists/InstantMix?id=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0&api_key=fixture-key")] [InlineData("/MusicGenres/InstantMix?id=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0&api_key=fixture-key")] public async Task JellyfinQueryInstantMix_RejectsMismatchedSynthesizedResourceTypes(string path) { var metadata = new Mock(MockBehavior.Strict); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); }); using var client = factory.CreateClient(); using var response = await client.GetAsync(path); var responseBody = await response.Content.ReadAsStringAsync(); Assert.True( response.StatusCode == HttpStatusCode.Forbidden, $"Expected 403, got {(int)response.StatusCode}: {responseBody}"); metadata.VerifyNoOtherCalls(); } [Fact] public async Task JellyfinExternalPlaylist_PreservesSourceOrderAndPlaylistParentIdentity() { using var fixture = ReadFixture("jellyfin-playlist-order.json"); var gateway = new Mock(MockBehavior.Strict); var tracks = fixture.RootElement.GetProperty("sourceTrackIds") .EnumerateArray() .Select((id, index) => new allstarr.Models.Domain.Song { Id = $"ext-deezer-{id.GetString()}", ExternalProvider = "deezer", ExternalId = id.GetString(), Title = $"Fixture {index}", Artist = "Fixture Artist", Album = "Fixture Album" }) .ToList(); gateway.Setup(service => service.GetPlaylistTracksAsync( It.IsAny(), fixture.RootElement.GetProperty("provider").GetString()!, fixture.RootElement.GetProperty("externalPlaylistId").GetString()!)) .ReturnsAsync(tracks); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(gateway.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( fixture.RootElement.GetProperty("requestPath").GetString()); var rawBody = await response.Content.ReadAsStringAsync(); Assert.True( response.StatusCode == HttpStatusCode.OK, $"Playlist fixture returned {(int)response.StatusCode}: {rawBody}"); using var body = JsonDocument.Parse(rawBody); var items = body.RootElement.GetProperty("Items").EnumerateArray().ToList(); Assert.Equal( fixture.RootElement.GetProperty("expectedItemIds").EnumerateArray().Select(id => id.GetString()), items.Select(item => item.GetProperty("Id").GetString())); Assert.All(items, item => Assert.Equal( fixture.RootElement.GetProperty("expectedParentId").GetString(), item.GetProperty("ParentId").GetString())); Assert.Equal(items.Count, body.RootElement.GetProperty("TotalRecordCount").GetInt32()); using var definitionResponse = await client.GetAsync( "/Playlists/ext-deezer-playlist-list-7?api_key=fixture-key"); using var definition = JsonDocument.Parse(await definitionResponse.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, definitionResponse.StatusCode); Assert.Empty(definition.RootElement.GetProperty("Shares").EnumerateArray()); Assert.Equal( fixture.RootElement.GetProperty("expectedItemIds").EnumerateArray().Select(id => id.GetString()), definition.RootElement.GetProperty("ItemIds").EnumerateArray().Select(id => id.GetString())); gateway.VerifyAll(); } [Fact] public async Task JellyfinExternalArtist_NasAmazonLabelRoutesToProviderAlbumsAndTracks() { var gateway = new Mock(MockBehavior.Strict); gateway.Setup(service => service.GetArtistAlbumsAsync( It.IsAny(), "spotiflac-amazon", "artist-1")) .ReturnsAsync([ new Album { Id = "ext-spotiflac-amazon-album-album-1", ExternalProvider = "spotiflac-amazon", ExternalId = "album-1", Title = "Illmatic", Artist = "NAS", ArtistId = "ext-spotiflac-amazon-artist-artist-1", IsLocal = false } ]); gateway.Setup(service => service.GetArtistTracksAsync( It.IsAny(), "spotiflac-amazon", "artist-1")) .ReturnsAsync([ new Song { Id = "ext-spotiflac-amazon-song-track-1", ExternalProvider = "spotiflac-amazon", ExternalId = "track-1", Title = "N.Y. State of Mind", Artist = "NAS", Artists = ["NAS"], ArtistId = "ext-spotiflac-amazon-artist-artist-1", ArtistIds = ["ext-spotiflac-amazon-artist-artist-1"], Album = "Illmatic", AlbumId = "ext-spotiflac-amazon-album-album-1", Duration = 210, IsLocal = false }, new Song { Id = "ext-spotiflac-amazon-song-guest-track", ExternalProvider = "spotiflac-amazon", ExternalId = "guest-track", Title = "Guest Track", Artist = "NAS", Artists = ["NAS"], ArtistId = "ext-spotiflac-amazon-artist-artist-1", ArtistIds = ["ext-spotiflac-amazon-artist-artist-1"], Album = "Guest Appearance", AlbumId = "ext-spotiflac-amazon-album-appearance", IsLocal = false } ]); gateway.Setup(service => service.GetArtistAsync( It.IsAny(), "spotiflac-amazon", "artist-1")) .ReturnsAsync(new Artist { Id = "ext-spotiflac-amazon-artist-artist-1", ExternalProvider = "spotiflac-amazon", ExternalId = "artist-1", Name = "NAS", IsLocal = false }); gateway.Setup(service => service.GetAlbumAsync( It.IsAny(), "spotiflac-amazon", "appearance")) .ReturnsAsync(new Album { Id = "ext-spotiflac-amazon-album-appearance", ExternalProvider = "spotiflac-amazon", ExternalId = "appearance", Title = "Guest Appearance", Artist = "Other Album Artist", ArtistId = "ext-spotiflac-amazon-artist-other", IsLocal = false }); using var factory = new ProtocolFactory( "Jellyfin", request => request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(gateway.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/Items?ParentId=ext-spotiflac-amazon-artist-artist-1&IncludeItemTypes=MusicAlbum,Audio&Limit=10&api_key=fixture-key"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var items = body.RootElement.GetProperty("Items").EnumerateArray().ToArray(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(3, body.RootElement.GetProperty("TotalRecordCount").GetInt32()); var album = Assert.Single(items, item => item.GetProperty("Type").GetString() == "MusicAlbum"); Assert.Equal("NAS [AmM]", album.GetProperty("AlbumArtist").GetString()); var track = Assert.Single(items, item => item.GetProperty("Id").GetString() == "ext-spotiflac-amazon-song-track-1"); Assert.Equal(210 * TimeSpan.TicksPerSecond, track.GetProperty("RunTimeTicks").GetInt64()); Assert.Equal("NAS [AmM]", track.GetProperty("Artists")[0].GetString()); Assert.Equal("ext-spotiflac-amazon-artist-artist-1", track.GetProperty("ArtistItems")[0].GetProperty("Id").GetString()); using var albumsResponse = await client.GetAsync( "/Items?AlbumArtistIds=ext-spotiflac-amazon-artist-artist-1&IncludeItemTypes=MusicAlbum&Fields=AlbumArtist,ArtistItems&api_key=fixture-key"); using var albumsBody = JsonDocument.Parse(await albumsResponse.Content.ReadAsStringAsync()); var primaryAlbum = Assert.Single(albumsBody.RootElement.GetProperty("Items").EnumerateArray()); using var appearancesResponse = await client.GetAsync( "/Items?ContributingArtistIds=ext-spotiflac-amazon-artist-artist-1&IncludeItemTypes=MusicAlbum&Fields=AlbumArtist,ArtistItems&api_key=fixture-key"); using var appearancesBody = JsonDocument.Parse(await appearancesResponse.Content.ReadAsStringAsync()); var appearance = Assert.Single(appearancesBody.RootElement.GetProperty("Items").EnumerateArray()); Assert.Equal(HttpStatusCode.OK, albumsResponse.StatusCode); Assert.Equal("ext-spotiflac-amazon-album-album-1", primaryAlbum.GetProperty("Id").GetString()); Assert.Equal(HttpStatusCode.OK, appearancesResponse.StatusCode); Assert.Equal("ext-spotiflac-amazon-album-appearance", appearance.GetProperty("Id").GetString()); Assert.Equal("Other Album Artist [AmM]", appearance.GetProperty("AlbumArtist").GetString()); gateway.VerifyAll(); } [Fact] public async Task SubsonicEmptySearch_PreservesCurrentFormAndResponseFixture() { using var fixture = ReadFixture("subsonic-empty-search.json"); var currentRelay = fixture.RootElement.GetProperty("currentRelay"); var expected = fixture.RootElement.GetProperty("expected"); var observedMethod = string.Empty; using var factory = new ProtocolFactory("Subsonic", request => { observedMethod = request.Method.Method; return Json(StatusCodes.Status200OK, currentRelay.GetProperty("body").GetRawText()); }); using var client = factory.CreateClient(); var requestFixture = fixture.RootElement.GetProperty("request"); var uri = $"{requestFixture.GetProperty("path").GetString()}?{requestFixture.GetProperty("query").GetString()}"; using var content = new StringContent( requestFixture.GetProperty("body").GetString()!, Encoding.UTF8, requestFixture.GetProperty("contentType").GetString()); using var response = await client.PostAsync(uri, content); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(expected.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.StartsWith(expected.GetProperty("contentType").GetString(), response.Content.Headers.ContentType?.MediaType); Assert.Equal(currentRelay.GetProperty("upstreamMethod").GetString(), observedMethod); Assert.Equal( currentRelay.GetProperty("body").GetRawText(), JsonDocument.Parse(body).RootElement.GetRawText()); } [Theory] [InlineData("getArtist", "artist", "local-artist")] [InlineData("getAlbum", "album", "local-album")] public async Task SubsonicNativeMusicDetail_IsRelayedWithoutProviderEnrichment( string endpoint, string itemType, string itemId) { var itemBody = JsonSerializer.Serialize(new Dictionary { ["subsonic-response"] = new Dictionary { ["status"] = "ok", ["version"] = "1.16.1", [itemType] = new Dictionary { ["id"] = itemId, ["name"] = "Native Item", ["unknownNativeField"] = "preserved" } } }); var observed = new List(); var metadata = new Mock(MockBehavior.Strict); using var factory = new ProtocolFactory( "Subsonic", request => { observed.Add(Observe(request)); return request.RequestUri!.AbsolutePath == "/rest/ping.view" ? Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}""") : Json(StatusCodes.Status200OK, itemBody); }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( $"/rest/{endpoint}.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json&id={itemId}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(itemBody, await response.Content.ReadAsStringAsync()); Assert.Equal(2, observed.Count); Assert.Equal( $"/rest/{endpoint}.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json&id={itemId}", observed[1].PathAndQuery); metadata.VerifyNoOtherCalls(); } [Fact] public async Task SubsonicExternalArtist_UsesTheSharedProviderGateway() { var gateway = new Mock(MockBehavior.Strict); gateway.Setup(service => service.GetArtistAsync( It.IsAny(), "spotiflac-amazon", "artist-1")) .ReturnsAsync(new Artist { Id = "ext-spotiflac-amazon-artist-artist-1", ExternalProvider = "spotiflac-amazon", ExternalId = "artist-1", Name = "NAS", IsLocal = false }); gateway.Setup(service => service.GetArtistAlbumsAsync( It.IsAny(), "spotiflac-amazon", "artist-1")) .ReturnsAsync([ new Album { Id = "ext-spotiflac-amazon-album-album-1", ExternalProvider = "spotiflac-amazon", ExternalId = "album-1", Title = "Illmatic", Artist = "NAS", ArtistId = "ext-spotiflac-amazon-artist-artist-1", IsLocal = false } ]); var metadata = new Mock(MockBehavior.Strict); using var factory = new ProtocolFactory( "Subsonic", request => request.RequestUri!.AbsolutePath == "/rest/ping.view" ? Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}""") : throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"), services => { services.RemoveAll(); services.AddSingleton(gateway.Object); services.RemoveAll(); services.AddSingleton(metadata.Object); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/rest/getArtist.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json&id=ext-spotiflac-amazon-artist-artist-1"); using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var artist = body.RootElement.GetProperty("subsonic-response").GetProperty("artist"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("ext-spotiflac-amazon-artist-artist-1", artist.GetProperty("id").GetString()); Assert.Equal("ext-spotiflac-amazon-album-album-1", artist.GetProperty("album")[0].GetProperty("id").GetString()); gateway.VerifyAll(); metadata.VerifyNoOtherCalls(); } [Fact] public async Task SubsonicAuthBoundary_RejectsBeforeBackendActionsAndPreservesVerificationResponse() { using var fixtures = ReadFixture("subsonic-auth-boundary.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedRequests = new List(); var metadata = new Mock(MockBehavior.Strict); var downloads = new Mock(MockBehavior.Strict); var verification = fixture.GetProperty("verification"); using var factory = new ProtocolFactory( "Subsonic", request => { observedRequests.Add(Observe(request)); Assert.NotEqual(JsonValueKind.Null, verification.ValueKind); return FixtureResponse(verification); }, services => { services.RemoveAll(); services.AddSingleton(metadata.Object); services.RemoveAll(); services.AddSingleton(downloads.Object); }); using var client = factory.CreateClient(); using var request = CreateFixtureRequest(fixture.GetProperty("request")); using var response = await client.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); var expected = fixture.GetProperty("expected"); Assert.Equal(expected.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.StartsWith( expected.GetProperty("contentType").GetString(), response.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); if (expected.TryGetProperty("body", out var expectedJson)) { Assert.Equal( CanonicalJson(expectedJson), CanonicalJson(JsonDocument.Parse(responseBody).RootElement)); } else { Assert.Equal(expected.GetProperty("rawBody").GetString(), responseBody); } if (verification.ValueKind == JsonValueKind.Null) { Assert.Empty(observedRequests); } else { var observed = Assert.Single(observedRequests); Assert.Equal(verification.GetProperty("method").GetString(), observed.Method); Assert.Equal(verification.GetProperty("pathAndQuery").GetString(), observed.PathAndQuery); Assert.Equal( verification.TryGetProperty("requestBody", out var verificationBody) ? verificationBody.GetString() : null, observed.Body); } metadata.VerifyNoOtherCalls(); downloads.VerifyNoOtherCalls(); } } [Fact] public async Task SubsonicRelay_PreservesMethodSourcesOrderingRepetitionStatusAndBody() { using var fixtures = ReadFixture("subsonic-relay-fidelity.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedRequests = new List(); var relay = fixture.GetProperty("relay"); using var factory = new ProtocolFactory("Subsonic", request => { observedRequests.Add(Observe(request)); if (observedRequests.Count == 1) { return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); } return new HttpResponseMessage((HttpStatusCode)relay.GetProperty("status").GetInt32()) { Content = new StringContent( relay.GetProperty("responseBody").GetRawText(), Encoding.UTF8, relay.GetProperty("contentType").GetString()!) }; }); using var client = factory.CreateClient(); using var request = CreateFixtureRequest(fixture.GetProperty("request")); using var response = await client.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); Assert.True( relay.GetProperty("status").GetInt32() == (int)response.StatusCode, $"{fixture.GetProperty("name").GetString()} returned {(int)response.StatusCode}: {responseBody}; " + $"observed={string.Join(" | ", observedRequests)}"); Assert.StartsWith( relay.GetProperty("contentType").GetString(), response.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); Assert.Equal( CanonicalJson(relay.GetProperty("responseBody")), CanonicalJson(JsonDocument.Parse(responseBody).RootElement)); Assert.Equal(2, observedRequests.Count); AssertObservedRequest(fixture.GetProperty("verification"), observedRequests[0]); AssertObservedRequest(relay, observedRequests[1]); } } [Theory] [InlineData( "GET", "/rest/updatePlaylist.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json&playlistId=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0&songIdToAdd=song-a&songIdToAdd=song-b&songIndexToRemove=2&songIndexToRemove=0", null, null, "/rest/updatePlaylist.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json&playlistId=backend-target&songIdToAdd=song-a&songIdToAdd=song-b&songIndexToRemove=2&songIndexToRemove=0", null)] [InlineData( "POST", "/rest/updatePlaylist.view?v=1.16.1&c=fixture&f=json", "application/x-www-form-urlencoded", "u=fixture&p=secret&playlistId=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0&songIdToAdd=song-a&songIdToAdd=song-b&songIndexToRemove=1&songIndexToRemove=0", "/rest/updatePlaylist.view?v=1.16.1&c=fixture&f=json", "u=fixture&p=secret&playlistId=backend-target&songIdToAdd=song-a&songIdToAdd=song-b&songIndexToRemove=1&songIndexToRemove=0")] public async Task SubsonicUpdatePlaylist_RewritesOnlyScopedVirtualTargetAndPreservesRelayFidelity( string method, string path, string? contentType, string? body, string expectedPath, string? expectedBody) { var observedRequests = new List(); var resolver = new FixedPlaylistMutationResolver( new SubsonicPlaylistMutationRoute(true, "backend-target")); using var factory = new ProtocolFactory( "Subsonic", request => { observedRequests.Add(Observe(request)); return Json( observedRequests.Count == 1 ? StatusCodes.Status200OK : StatusCodes.Status202Accepted, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); }, services => { services.RemoveAll(); services.AddSingleton(resolver); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(new HttpMethod(method), path); if (body != null) { request.Content = new StringContent(body, Encoding.UTF8, contentType!); } using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.Equal(2, observedRequests.Count); Assert.Equal(expectedPath, observedRequests[1].PathAndQuery); Assert.Equal(expectedBody, observedRequests[1].Body); } [Fact] public async Task SubsonicUpdatePlaylist_ReadOnlyVirtualLinkReturnsProtocolErrorWithoutMutation() { var observedRequests = new List(); using var factory = new ProtocolFactory( "Subsonic", request => { observedRequests.Add(Observe(request)); return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); }, services => { services.RemoveAll(); services.AddSingleton( new FixedPlaylistMutationResolver( new SubsonicPlaylistMutationRoute(false, null))); }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/rest/updatePlaylist.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json&playlistId=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0&songIdToAdd=song-a"); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var error = document.RootElement.GetProperty("subsonic-response").GetProperty("error"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(50, error.GetProperty("code").GetInt32()); Assert.Equal("Playlist is read-only", error.GetProperty("message").GetString()); Assert.Single(observedRequests); Assert.Equal("/rest/ping.view?u=fixture&p=secret&v=1.16.1&c=fixture&f=json", observedRequests[0].PathAndQuery); } [Fact] public async Task JellyfinConfiguredNativePlaylist_UsesCompleteDurableProjection() { const string nativeId = "ddc3db277be524ad6f54e4b276cc619a"; const string sourceId = "37i9dQZEVXbLRQDuF5jeBp"; const string virtualId = "allstarr-vpl-019fa3ec414873ec9239a8114469c608"; var tracks = Enumerable.Range(0, 50) .Select(index => index == 0 ? new VirtualPlaylistTrack( index, "local-song-a", "Babydoll", "Dominic Fike", "Don't Forget About Me, Demos", "Dominic Fike", 102_000, "cover-a", allstarr.Core.Storage.TrackMatchState.Accepted, SourceProviderId: "spotify") : new VirtualPlaylistTrack( index, $"allstarr-unresolved-source-{index}", $"Source Track {index + 1}", $"Source Artist {index + 1}", $"Source Album {index + 1}", null, 180_000, null, allstarr.Core.Storage.TrackMatchState.Unresolved, SourceProviderId: "spotify", RouteKind: TrackRouteKind.Unresolved)) .ToArray(); var model = new VirtualPlaylistReadModel( virtualId, Guid.Parse("019fa3ec-4148-73ec-9239-a8114469c608"), Guid.CreateVersion7(), "Top 50 - USA", "Spotify chart", "artwork-key", "spotify", sourceId, "revision-24", allstarr.Core.Storage.PlaylistLinkMode.Virtual, tracks); var virtualization = new Mock(MockBehavior.Strict); virtualization.Setup(service => service.ListAsync( It.IsAny(), It.IsAny())) .ReturnsAsync([model]); virtualization.Setup(service => service.ReadBySourceAsync( It.IsAny(), "spotify", sourceId, It.IsAny())) .ReturnsAsync(model); var observed = new List(); using var factory = new ProtocolFactory( "Jellyfin", request => { observed.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath switch { "/Users/Me" => Json( StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""), "/Users/user-1/Items" => Json( StatusCodes.Status200OK, $$""" { "Items":[{ "Id":"{{nativeId}}", "Name":"Top 50 - USA", "Type":"Playlist", "MediaType":"Audio", "ChildCount":5, "CanDelete":true, "DateCreated":"2026-07-31T02:11:00Z", "ImageTags":{"Primary":"native-artwork"}, "ExtraBrowseData":{"Sentinel":"keep-native-fields"} }], "TotalRecordCount":1, "StartIndex":0 } """), $"/Items/{nativeId}" => Json( StatusCodes.Status200OK, $$"""{"Id":"{{nativeId}}","Name":"Top 50 - USA","Type":"Playlist","ChildCount":5}"""), "/Items" => Json( StatusCodes.Status200OK, """ { "Items":[{ "Id":"local-song-a", "Name":"Babydoll", "ServerId":"server-1", "Type":"Audio", "MediaType":"Audio", "AlbumId":"album-a", "MediaSources":[{"Id":"source-a","Path":"/music/babydoll.flac"}], "ExtraNestedData":{"Sentinel":[{"Keep":"every-field"}]} }], "TotalRecordCount":1, "StartIndex":0 } """), _ => throw new InvalidOperationException( $"Unexpected upstream request: {request.RequestUri}") }; }, services => { services.RemoveAll(); services.AddSingleton(virtualization.Object); }, new Dictionary { ["SpotifyImport:Enabled"] = "true", ["SpotifyImport:Playlists:0:Name"] = "Top 50 - USA", ["SpotifyImport:Playlists:0:Id"] = sourceId, ["SpotifyImport:Playlists:0:JellyfinId"] = nativeId, ["SpotifyImport:Playlists:0:UserId"] = "user-1" }); using var client = factory.CreateClient(); using var browseResponse = await client.GetAsync( "/Users/user-1/Items?includeItemTypes=Playlist&recursive=true&startIndex=0&limit=200&api_key=fixture-key"); using var browse = JsonDocument.Parse(await browseResponse.Content.ReadAsStringAsync()); using var tracksResponse = await client.GetAsync( $"/Playlists/{nativeId}/Items?fields=SortName%2CCanDelete%2CMediaSources%2CDateCreated%2CCanDelete&userId=user-1&startIndex=0&limit=200&api_key=fixture-key"); using var playlist = JsonDocument.Parse(await tracksResponse.Content.ReadAsStringAsync()); using var parentResponse = await client.GetAsync( $"/Users/user-1/Items?parentId={nativeId}&includeItemTypes=Audio&startIndex=0&limit=200&api_key=fixture-key"); using var parentItems = JsonDocument.Parse(await parentResponse.Content.ReadAsStringAsync()); using var detailResponse = await client.GetAsync( $"/Items/{nativeId}?userId=user-1&api_key=fixture-key"); using var detail = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync()); using var definitionResponse = await client.GetAsync( $"/Playlists/{nativeId}?userId=user-1&api_key=fixture-key"); using var definition = JsonDocument.Parse(await definitionResponse.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, browseResponse.StatusCode); Assert.Equal(1, browse.RootElement.GetProperty("TotalRecordCount").GetInt32()); var browseItem = Assert.Single(browse.RootElement.GetProperty("Items").EnumerateArray()); Assert.Equal(nativeId, browseItem.GetProperty("Id").GetString()); Assert.Equal(50, browseItem.GetProperty("ChildCount").GetInt32()); Assert.Equal("spotify", browseItem.GetProperty("ProviderIds").GetProperty("AllstarrSource").GetString()); Assert.True(browseItem.GetProperty("CanDelete").GetBoolean()); Assert.Equal( "keep-native-fields", browseItem.GetProperty("ExtraBrowseData").GetProperty("Sentinel").GetString()); Assert.Equal(HttpStatusCode.OK, tracksResponse.StatusCode); Assert.Equal(50, playlist.RootElement.GetProperty("TotalRecordCount").GetInt32()); Assert.Equal(50, playlist.RootElement.GetProperty("Items").GetArrayLength()); Assert.Equal(HttpStatusCode.OK, parentResponse.StatusCode); Assert.Equal(50, parentItems.RootElement.GetProperty("TotalRecordCount").GetInt32()); Assert.Equal(50, parentItems.RootElement.GetProperty("Items").GetArrayLength()); Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); Assert.Equal(nativeId, detail.RootElement.GetProperty("Id").GetString()); Assert.Equal(50, detail.RootElement.GetProperty("ChildCount").GetInt32()); Assert.Equal(HttpStatusCode.OK, definitionResponse.StatusCode); Assert.Equal(50, definition.RootElement.GetProperty("ItemIds").GetArrayLength()); Assert.All(playlist.RootElement.GetProperty("Items").EnumerateArray(), item => Assert.False(string.IsNullOrWhiteSpace(item.GetProperty("Id").GetString()))); var first = playlist.RootElement.GetProperty("Items")[0]; Assert.Equal(nativeId, first.GetProperty("ParentId").GetString()); Assert.Equal("source-a", first.GetProperty("MediaSources")[0].GetProperty("Id").GetString()); Assert.Equal( "every-field", first.GetProperty("ExtraNestedData").GetProperty("Sentinel")[0].GetProperty("Keep").GetString()); Assert.DoesNotContain( observed, path => path.StartsWith($"/Playlists/{nativeId}/Items", StringComparison.Ordinal)); virtualization.VerifyAll(); } [Fact] public async Task JellyfinWritableHybrid_ReadsInjectedProjectionInsteadOfNativeTarget() { const string virtualId = "allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0"; const string originalItem = """ { "Name": "Original Track", "ServerId": "original-server", "Id": "local-song-a", "ParentId": "original-parent", "AlbumId": "album-original", "Album": "Original Album", "AlbumArtist": "Original Artist", "Artists": ["Original Artist"], "ArtistItems": [{"Name": "Original Artist", "Id": "artist-original"}], "AlbumArtists": [{"Name": "Original Artist", "Id": "artist-original"}], "RunTimeTicks": 1800000000, "IndexNumber": 7, "Type": "Audio", "MediaType": "Audio", "CanDelete": true, "CanDownload": true, "MediaSources": [{ "Id": "media-original", "Path": "/music/original.flac", "MediaStreams": [{"Index": 0, "Codec": "flac"}] }], "ImageTags": {"Primary": "image-original"}, "ProviderIds": {"MusicBrainzTrack": "mb-original"}, "UserData": { "ItemId": "local-song-a", "Key": "Audio-local-song-a", "IsFavorite": true }, "ExtraNestedData": {"Sentinel": [{"Keep": "every-field"}]} } """; var model = new VirtualPlaylistReadModel( virtualId, Guid.Parse("0198a537-719c-7ea8-9e5a-17e1f2f963f0"), Guid.CreateVersion7(), "Injected Mix", "Projected playlist", "artwork-key", "spotify", "source-list", "revision-1", allstarr.Core.Storage.PlaylistLinkMode.Hybrid, [ new VirtualPlaylistTrack( 0, "local-song-a", "Injected Track", "Injected Artist", "Injected Album", "Injected Artist", 180_000, "cover-a", allstarr.Core.Storage.TrackMatchState.Accepted, SourceProviderId: "spotify"), new VirtualPlaylistTrack( 1, "allstarr-unresolved-source-hash", "Unmatched Track", "Unmatched Artist", "Unmatched Album", null, 200_000, null, allstarr.Core.Storage.TrackMatchState.Unresolved, SourceProviderId: "spotify", RouteKind: TrackRouteKind.Unresolved), new VirtualPlaylistTrack( 2, "stale-local-song", "Stale Track", "Stale Artist", "Stale Album", null, 210_000, null, allstarr.Core.Storage.TrackMatchState.Accepted, SourceProviderId: "spotify") ]); var virtualization = new Mock(MockBehavior.Strict); virtualization.Setup(service => service.ReadAsync( It.IsAny(), virtualId, It.IsAny())) .ReturnsAsync(model); var observed = new List(); using var factory = new ProtocolFactory( "Jellyfin", request => { observed.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath switch { "/Users/Me" => Json( StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""), "/Items" => Json( StatusCodes.Status200OK, $$"""{"Items":[{{originalItem}}],"TotalRecordCount":1,"StartIndex":0}"""), _ => throw new InvalidOperationException( $"Unexpected upstream request: {request.RequestUri}") }; }, services => { services.RemoveAll(); services.AddSingleton(virtualization.Object); services.RemoveAll(); services.AddSingleton( new FixedJellyfinPlaylistMutationResolver( new JellyfinPlaylistMutationRoute(true, "backend-target"))); }); using var client = factory.CreateClient(); using var itemResponse = await client.GetAsync($"/Items/{virtualId}?api_key=fixture-key"); using var definitionResponse = await client.GetAsync($"/Playlists/{virtualId}?api_key=fixture-key"); using var tracksResponse = await client.GetAsync($"/Playlists/{virtualId}/Items?api_key=fixture-key"); using var unresolvedFile = await client.GetAsync( "/Items/allstarr-unresolved-source-hash/File?api_key=fixture-key"); using var unresolvedStream = await client.GetAsync( "/Audio/allstarr-unresolved-source-hash/stream?api_key=fixture-key"); using var unresolvedUniversal = await client.GetAsync( "/Audio/allstarr-unresolved-source-hash/universal?api_key=fixture-key"); using var unresolvedPlayback = await client.GetAsync( "/Items/allstarr-unresolved-source-hash/PlaybackInfo?api_key=fixture-key"); using var item = JsonDocument.Parse(await itemResponse.Content.ReadAsStringAsync()); using var definition = JsonDocument.Parse(await definitionResponse.Content.ReadAsStringAsync()); using var tracks = JsonDocument.Parse(await tracksResponse.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, itemResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, definitionResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, tracksResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, unresolvedFile.StatusCode); Assert.Equal(HttpStatusCode.NotFound, unresolvedStream.StatusCode); Assert.Equal(HttpStatusCode.NotFound, unresolvedUniversal.StatusCode); Assert.Equal(HttpStatusCode.NotFound, unresolvedPlayback.StatusCode); Assert.Equal(virtualId, item.RootElement.GetProperty("Id").GetString()); Assert.Equal("local-song-a", definition.RootElement.GetProperty("ItemIds")[0].GetString()); var actual = JsonNode.Parse( tracks.RootElement.GetProperty("Items")[0].GetRawText())!.AsObject(); var expected = JsonNode.Parse(originalItem)!.AsObject(); expected["ProviderIds"]!["AllstarrSource"] = "spotify"; expected["ParentId"] = virtualId; expected["PlaylistItemId"] = "local-song-a"; Assert.True( JsonNode.DeepEquals(expected, actual), $"Expected full source DTO with playlist overlays.\nExpected: {expected}\nActual: {actual}"); Assert.Equal("Original Track", actual["Name"]!.GetValue()); Assert.Equal(virtualId, actual["ParentId"]!.GetValue()); Assert.Equal("spotify", actual["ProviderIds"]!["AllstarrSource"]!.GetValue()); Assert.Equal("local-song-a", actual["Id"]!.GetValue()); Assert.Equal("local-song-a", actual["PlaylistItemId"]!.GetValue()); Assert.Equal("album-original", actual["AlbumId"]!.GetValue()); Assert.Equal("media-original", actual["MediaSources"]![0]!["Id"]!.GetValue()); Assert.Equal("artist-original", actual["ArtistItems"]![0]!["Id"]!.GetValue()); Assert.Equal("artist-original", actual["AlbumArtists"]![0]!["Id"]!.GetValue()); var unresolved = tracks.RootElement.GetProperty("Items")[1]; Assert.Equal("allstarr-unresolved-source-hash", unresolved.GetProperty("Id").GetString()); Assert.Equal("None", unresolved.GetProperty("PlayAccess").GetString()); Assert.False(unresolved.GetProperty("CanDownload").GetBoolean()); Assert.Empty(unresolved.GetProperty("MediaSources").EnumerateArray()); var stale = tracks.RootElement.GetProperty("Items")[2]; Assert.Equal("allstarr-unresolved-stale-local-song", stale.GetProperty("Id").GetString()); Assert.Equal("Stale Track [S]", stale.GetProperty("Name").GetString()); Assert.Equal("None", stale.GetProperty("PlayAccess").GetString()); Assert.False(stale.GetProperty("CanDownload").GetBoolean()); Assert.Empty(stale.GetProperty("MediaSources").EnumerateArray()); Assert.Equal(3, observed.Count(path => path == "/Users/Me?api_key=fixture-key")); var hydration = Assert.Single(observed, path => path.StartsWith("/Items?", StringComparison.Ordinal)); Assert.Contains("Ids=local-song-a", hydration, StringComparison.Ordinal); Assert.Contains("stale-local-song", hydration, StringComparison.Ordinal); Assert.Contains("UserId=user-1", hydration, StringComparison.Ordinal); Assert.Contains("api_key=fixture-key", hydration, StringComparison.Ordinal); Assert.Contains("MediaSources", Uri.UnescapeDataString(hydration), StringComparison.Ordinal); virtualization.VerifyAll(); } [Theory] [InlineData("POST", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0?api_key=fixture-key", """{"Name":"Renamed"}""", "/Playlists/backend-target?api_key=fixture-key")] [InlineData("POST", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/Items?ids=song-a&ids=song-b&api_key=fixture-key", null, "/Playlists/backend-target/Items?ids=song-a&ids=song-b&api_key=fixture-key")] [InlineData("DELETE", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/Items?entryIds=entry-a&api_key=fixture-key", null, "/Playlists/backend-target/Items?entryIds=entry-a&api_key=fixture-key")] [InlineData("POST", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/Items/entry-a/Move/2?api_key=fixture-key", null, "/Playlists/backend-target/Items/entry-a/Move/2?api_key=fixture-key")] [InlineData("GET", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/Users?api_key=fixture-key", null, "/Playlists/backend-target/Users?api_key=fixture-key")] [InlineData("POST", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/Users/user-2?api_key=fixture-key", """{"CanEdit":true}""", "/Playlists/backend-target/Users/user-2?api_key=fixture-key")] [InlineData("DELETE", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/Users/user-2?api_key=fixture-key", null, "/Playlists/backend-target/Users/user-2?api_key=fixture-key")] [InlineData("GET", "/Playlists/allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0/InstantMix?Limit=12&api_key=fixture-key", null, "/Playlists/backend-target/InstantMix?Limit=12&api_key=fixture-key")] public async Task JellyfinLinkedPlaylistOperations_RewriteOnlyTheScopedTargetAndPreserveRelay( string method, string path, string? body, string expectedPath) { var observed = new List(); using var factory = new ProtocolFactory( "Jellyfin", request => { observed.Add(Observe(request)); return request.RequestUri!.AbsolutePath == "/Users/Me" ? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""") : Json(StatusCodes.Status202Accepted, """{"accepted":true}"""); }, services => { services.RemoveAll(); services.AddSingleton( new FixedJellyfinPlaylistMutationResolver( new JellyfinPlaylistMutationRoute(true, "backend-target"))); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(new HttpMethod(method), path); if (body != null) { request.Content = new StringContent(body, Encoding.UTF8, "application/json"); } using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.Equal(2, observed.Count); Assert.Equal(method, observed[1].Method); Assert.Equal(expectedPath, observed[1].PathAndQuery); Assert.Equal(body ?? (method is "GET" or "HEAD" ? null : string.Empty), observed[1].Body); } [Theory] [InlineData("allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0")] [InlineData("ext-spotify-playlist-source-1")] public async Task JellyfinReadOnlyPlaylistMutation_ReturnsConflictWithoutBackendMutation( string playlistId) { var observed = new List(); using var factory = new ProtocolFactory( "Jellyfin", request => { observed.Add(Observe(request)); return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); }, services => { services.RemoveAll(); services.AddSingleton( new FixedJellyfinPlaylistMutationResolver( new JellyfinPlaylistMutationRoute(false, null))); }); using var client = factory.CreateClient(); using var response = await client.PostAsync( $"/Playlists/{playlistId}/Items?ids=song-a&api_key=fixture-key", content: null); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Single(observed); Assert.Equal("/Users/Me?api_key=fixture-key", observed[0].PathAndQuery); } [Theory] [InlineData("DELETE", "Playlist", HttpStatusCode.NoContent, 3)] [InlineData("DELETE", "Audio", HttpStatusCode.Forbidden, 1)] [InlineData("DELETE", "Movie", HttpStatusCode.Forbidden, 1)] [InlineData("POST", "Playlist", HttpStatusCode.NoContent, 3)] [InlineData("POST", "Audio", HttpStatusCode.Forbidden, 1)] [InlineData("POST", "Movie", HttpStatusCode.Forbidden, 1)] public async Task JellyfinItemMutation_RelaysOnlyNativePlaylists( string method, string itemType, HttpStatusCode expectedStatus, int expectedRequestCount) { var observed = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(Observe(request)); if (request.RequestUri!.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); if (request.Method == HttpMethod.Get) return Json(StatusCodes.Status200OK, $$"""{"Items":[{"Id":"item-1","Type":"{{itemType}}"}],"TotalRecordCount":1,"StartIndex":0}"""); return new HttpResponseMessage(HttpStatusCode.NoContent); }); using var client = factory.CreateClient(); var itemId = $"{itemType.ToLowerInvariant()}-item"; using var request = new HttpRequestMessage( new HttpMethod(method), $"/Items/{itemId}?api_key=fixture-key") { Content = method == "POST" ? new StringContent( $$"""{"Id":"{{itemId}}","Name":"Playlist","Type":"Playlist"}""", Encoding.UTF8, "application/json") : null }; using var response = await client.SendAsync(request); Assert.Equal(expectedStatus, response.StatusCode); Assert.Equal(expectedRequestCount, observed.Count); Assert.Equal($"/Items?ids={itemId}&limit=1", observed[0].PathAndQuery); if (itemType == "Playlist") { Assert.Equal("/Users/Me?api_key=fixture-key", observed[1].PathAndQuery); Assert.Equal(method, observed[2].Method); Assert.Equal($"/Items/{itemId}?api_key=fixture-key", observed[2].PathAndQuery); if (method == "POST") Assert.Equal( $$"""{"Id":"{{itemId}}","Name":"Playlist","Type":"Playlist"}""", observed[2].Body); } } [Fact] public async Task JellyfinPlaylistMutation_ClassifiesWithTheLoggedInUserToken() { var observed = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(Observe(request)); if (request.RequestUri!.AbsolutePath == "/Items") { var hasClientToken = request.Headers.TryGetValues( "X-Emby-Authorization", out var values) && values.Any(value => value.Contains("caller-token", StringComparison.Ordinal)); return Json(StatusCodes.Status200OK, hasClientToken ? """{"Items":[{"Id":"user-playlist","Type":"Playlist"}],"TotalRecordCount":1}""" : """{"Items":[],"TotalRecordCount":0}"""); } if (request.RequestUri.AbsolutePath == "/Users/Me") return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); return new HttpResponseMessage(HttpStatusCode.NoContent); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Post, "/Items/user-playlist") { Content = new StringContent( """{"Id":"user-playlist","Name":"Renamed","Type":"Playlist"}""", Encoding.UTF8, "application/json") }; request.Headers.TryAddWithoutValidation( "X-Emby-Authorization", "MediaBrowser Client=\"Fixture\", Device=\"Tests\", DeviceId=\"test-1\", Version=\"1\", UserId=\"user-1\", Token=\"caller-token\""); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); Assert.Equal(3, observed.Count); Assert.Equal("/Items?ids=user-playlist&limit=1&userId=user-1", observed[0].PathAndQuery); Assert.Equal("/Users/Me", observed[1].PathAndQuery); Assert.Equal("POST", observed[2].Method); Assert.Equal("/Items/user-playlist", observed[2].PathAndQuery); } [Fact] public async Task JellyfinMediaFolders_FallsBackToNonAdminUserViews() { var observed = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(Observe(request)); return request.RequestUri!.AbsolutePath switch { "/Users/Me" => Json( StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""), "/Library/MediaFolders" => Json( StatusCodes.Status403Forbidden, """{"error":"administrator access required"}"""), "/UserViews" => Json( StatusCodes.Status200OK, """{"Items":[{"Id":"music","CollectionType":"music"},{"Id":"movies","CollectionType":"movies"}],"TotalRecordCount":2}"""), _ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}") }; }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, "/Library/MediaFolders?UserId=user-1"); request.Headers.TryAddWithoutValidation("X-Emby-Token", "caller-token"); using var response = await client.SendAsync(request); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var item = Assert.Single(document.RootElement.GetProperty("Items").EnumerateArray()); Assert.Equal("music", item.GetProperty("CollectionType").GetString()); Assert.Equal(1, document.RootElement.GetProperty("TotalRecordCount").GetInt32()); Assert.Contains(observed, candidate => candidate.PathAndQuery == "/Library/MediaFolders?UserId=user-1"); Assert.Contains(observed, candidate => candidate.PathAndQuery == "/UserViews?UserId=user-1"); } [Fact] public async Task SubsonicApiKey_ResolvesPrincipalBeforeRelaying() { var observedRequests = new List(); using var factory = new ProtocolFactory("Subsonic", request => { observedRequests.Add(Observe(request)); return request.RequestUri!.AbsolutePath switch { "/rest/ping.view" => Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""), "/rest/tokenInfo.view" => Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1","tokenInfo":{"username":"fixture-user"}}}"""), _ => Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1","license":{"valid":true}}}""") }; }); using var client = factory.CreateClient(); using var response = await client.GetAsync( "/rest/getLicense.view?apiKey=valid-key&v=1.16.1&c=fixture&f=json"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( [ "/rest/ping.view?apiKey=valid-key&v=1.16.1&c=fixture&f=json", "/rest/tokenInfo.view?apiKey=valid-key&v=1.16.1&c=fixture&f=json", "/rest/getLicense.view?apiKey=valid-key&v=1.16.1&c=fixture&f=json" ], observedRequests.Select(request => request.PathAndQuery).ToList()); } [Theory] [InlineData( "GET", "/rest/getPlaylist?u=password-user&p=secret-pass&v=1.16.1&c=fixture&f=json&id=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0", null, "password-user", "json", 1)] [InlineData( "POST", "/rest/getPlaylist.view?v=1.16.1&c=fixture", "u=token-user&t=token-hash&s=token-salt&f=xml&id=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0", "token-user", "xml", 1)] [InlineData( "GET", "/rest/getPlaylist.view?apiKey=valid-key&v=1.16.1&c=fixture&f=json&id=allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0", null, "api-user", "json", 2)] public async Task SubsonicGetPlaylist_CoversSuccessfulAuthAliasesMethodsFormatsAndNativeFields( string method, string path, string? formBody, string expectedPrincipal, string format, int expectedAuthCalls) { const string virtualId = "allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0"; var model = new VirtualPlaylistReadModel( virtualId, Guid.Parse("0198a537-719c-7ea8-9e5a-17e1f2f963f0"), Guid.CreateVersion7(), "Native Target", "Target description", "playlist-cover", "spotify", "source-list", "revision-1", allstarr.Core.Storage.PlaylistLinkMode.Hybrid, [ new(0, "native-b", "ignored", "ignored", null, null, 2_000, null, allstarr.Core.Storage.TrackMatchState.Unresolved, RouteKind: TrackRouteKind.Local, NativeEntryJson: "{\"id\":\"native-b\",\"title\":\"Native B\",\"duration\":2,\"artistId\":\"artist-b\",\"albumId\":\"album-b\",\"coverArt\":\"cover-b\",\"provider\":\"navidrome\"}"), new(1, "native-a", "ignored", "ignored", null, null, 1_000, null, allstarr.Core.Storage.TrackMatchState.Unresolved, RouteKind: TrackRouteKind.Local, NativeEntryJson: "{\"id\":\"native-a\",\"title\":\"Native A\",\"duration\":1,\"artistId\":\"artist-a\",\"albumId\":\"album-a\",\"coverArt\":\"cover-a\",\"provider\":\"navidrome\"}") ], allstarr.Core.Storage.PlaylistProjectionMode.Target, "backend-target"); ProtocolExecutionContext? observedContext = null; var virtualization = new Mock(MockBehavior.Strict); virtualization.Setup(service => service.ReadAsync( It.IsAny(), virtualId, It.IsAny())) .Callback((context, _, _) => observedContext = context) .ReturnsAsync(model); var observedRequests = new List(); using var factory = new ProtocolFactory( "Subsonic", request => { observedRequests.Add(Observe(request)); return request.RequestUri!.AbsolutePath switch { "/rest/ping.view" => Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""), "/rest/tokenInfo.view" => Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1","tokenInfo":{"username":"api-user"}}}"""), _ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}") }; }, services => { services.RemoveAll(); services.AddSingleton(virtualization.Object); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(new HttpMethod(method), path) { Content = formBody == null ? null : new StringContent(formBody, Encoding.UTF8, "application/x-www-form-urlencoded") }; using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(expectedPrincipal, observedContext?.VerifiedBackendPrincipalId); Assert.Equal(expectedAuthCalls, observedRequests.Count); Assert.All(observedRequests, item => Assert.True( item.PathAndQuery.StartsWith("/rest/ping.view?", StringComparison.Ordinal) || item.PathAndQuery.StartsWith("/rest/tokenInfo.view?", StringComparison.Ordinal), item.PathAndQuery)); Assert.DoesNotContain("secret-pass", body, StringComparison.Ordinal); Assert.DoesNotContain("token-hash", body, StringComparison.Ordinal); Assert.DoesNotContain("valid-key", body, StringComparison.Ordinal); if (format == "json") { using var document = JsonDocument.Parse(body); var playlist = document.RootElement.GetProperty("subsonic-response").GetProperty("playlist"); var entries = playlist.GetProperty("entry"); Assert.Equal(2, playlist.GetProperty("songCount").GetInt32()); Assert.Equal(3, playlist.GetProperty("duration").GetInt64()); Assert.Equal(["native-b", "native-a"], entries.EnumerateArray().Select(item => item.GetProperty("id").GetString())); Assert.Equal("artist-b", entries[0].GetProperty("artistId").GetString()); Assert.Equal("album-b", entries[0].GetProperty("albumId").GetString()); Assert.Equal("cover-b", entries[0].GetProperty("coverArt").GetString()); Assert.Equal("navidrome", entries[0].GetProperty("provider").GetString()); } else { var document = System.Xml.Linq.XDocument.Parse(body); var ns = document.Root!.Name.Namespace; var playlist = document.Descendants(ns + "playlist").Single(); var entries = playlist.Elements(ns + "entry").ToArray(); Assert.Equal("2", playlist.Attribute("songCount")!.Value); Assert.Equal("3", playlist.Attribute("duration")!.Value); Assert.Equal(["native-b", "native-a"], entries.Select(item => item.Attribute("id")!.Value)); Assert.Equal("artist-b", entries[0].Attribute("artistId")!.Value); Assert.Equal("album-b", entries[0].Attribute("albumId")!.Value); Assert.Equal("cover-b", entries[0].Attribute("coverArt")!.Value); Assert.Equal("navidrome", entries[0].Attribute("provider")!.Value); } virtualization.VerifyAll(); } [Theory] [InlineData( "GET", "/rest/getPlaylists?u=password-user&p=secret-pass&v=1.16.1&c=fixture&f=json", null, "json", "/rest/getPlaylists")] [InlineData( "POST", "/rest/getPlaylists.view?v=1.16.1&c=fixture", "u=token-user&t=token-hash&s=token-salt&v=1.16.1&c=fixture&f=xml", "xml", "/rest/getPlaylists.view")] public async Task SubsonicGetPlaylists_MergesNativeAndVirtualSummariesWithoutCredentialEcho( string method, string path, string? formBody, string format, string expectedRelayPath) { const string virtualId = "allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0"; var model = new VirtualPlaylistReadModel( virtualId, Guid.Parse("0198a537-719c-7ea8-9e5a-17e1f2f963f0"), Guid.CreateVersion7(), "Virtual Mix", "Virtual comment", "virtual-cover", "spotify", "source-list", "revision-1", PlaylistLinkMode.Virtual, [new(0, "song-1", "Song", "Artist", "Album", null, 4_000, null, TrackMatchState.Accepted)]); var virtualization = new Mock(MockBehavior.Strict); virtualization.Setup(service => service.ListAsync( It.IsAny(), It.IsAny())) .ReturnsAsync([model]); var observedRequests = new List(); using var factory = new ProtocolFactory( "Subsonic", request => { var observed = Observe(request); observedRequests.Add(observed); if (request.RequestUri!.AbsolutePath == "/rest/ping.view") return Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); if (request.RequestUri.AbsolutePath != expectedRelayPath) throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); return format == "json" ? Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1","playlists":{"playlist":[{"id":"native-id","name":"Native","owner":"backend","public":true,"songCount":1,"duration":2,"coverArt":"native-cover","unknownField":"preserved"}]}}}""") : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """""", Encoding.UTF8, "application/xml") }; }, services => { services.RemoveAll(); services.AddSingleton(virtualization.Object); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(new HttpMethod(method), path) { Content = formBody == null ? null : new StringContent(formBody, Encoding.UTF8, "application/x-www-form-urlencoded") }; using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, observedRequests.Count); Assert.Equal(expectedRelayPath, observedRequests[1].PathAndQuery.Split('?')[0]); Assert.DoesNotContain("secret-pass", body, StringComparison.Ordinal); Assert.DoesNotContain("token-hash", body, StringComparison.Ordinal); if (format == "json") { using var document = JsonDocument.Parse(body); var playlists = document.RootElement.GetProperty("subsonic-response") .GetProperty("playlists").GetProperty("playlist"); Assert.Equal(["native-id", virtualId], playlists.EnumerateArray().Select(item => item.GetProperty("id").GetString())); Assert.Equal("preserved", playlists[0].GetProperty("unknownField").GetString()); Assert.Equal(1, playlists[1].GetProperty("songCount").GetInt32()); Assert.Equal(4, playlists[1].GetProperty("duration").GetInt64()); Assert.Equal("Virtual comment", playlists[1].GetProperty("comment").GetString()); Assert.Equal("allstarr", playlists[1].GetProperty("owner").GetString()); Assert.False(playlists[1].GetProperty("public").GetBoolean()); Assert.Equal("virtual-cover", playlists[1].GetProperty("coverArt").GetString()); } else { var document = XDocument.Parse(body); var ns = document.Root!.Name.Namespace; var playlists = document.Descendants(ns + "playlist").ToArray(); Assert.Equal(["native-id", virtualId], playlists.Select(item => item.Attribute("id")!.Value)); Assert.Equal("preserved", playlists[0].Attribute("unknownField")!.Value); Assert.Equal("1", playlists[1].Attribute("songCount")!.Value); Assert.Equal("4", playlists[1].Attribute("duration")!.Value); Assert.Equal("Virtual comment", playlists[1].Attribute("comment")!.Value); Assert.Equal("allstarr", playlists[1].Attribute("owner")!.Value); Assert.Equal("false", playlists[1].Attribute("public")!.Value); Assert.Equal("virtual-cover", playlists[1].Attribute("coverArt")!.Value); } virtualization.VerifyAll(); } [Fact] public async Task SubsonicStructuredLyrics_CoversExternalAndRelayGetPostXmlJsonFixtures() { using var fixtures = ReadFixture("subsonic-structured-lyrics.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedRequests = new List(); var external = fixture.GetProperty("external").GetBoolean(); var lookup = new Mock(MockBehavior.Strict); if (external) { var expectedLookup = fixture.GetProperty("lookup"); lookup.Setup(service => service.FindAsync( It.IsAny(), expectedLookup.GetProperty("provider").GetString()!, expectedLookup.GetProperty("externalId").GetString()!, It.IsAny())) .ReturnsAsync(new SubsonicStructuredLyrics( "Fixture Artist", "Fixture Title", "eng", 0, true, [ new SubsonicLyricLine(1_000, "first"), new SubsonicLyricLine(2_000, "second") ])); } using var factory = new ProtocolFactory( "Subsonic", request => { observedRequests.Add(Observe(request)); if (observedRequests.Count == 1) { return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); } var relay = fixture.GetProperty("relay"); return new HttpResponseMessage((HttpStatusCode)relay.GetProperty("status").GetInt32()) { Content = new StringContent( relay.GetProperty("responseBody").GetString()!, Encoding.UTF8, relay.GetProperty("contentType").GetString()!) }; }, services => { services.RemoveAll(); services.AddSingleton(lookup.Object); }); using var client = factory.CreateClient(); using var request = CreateFixtureRequest(fixture.GetProperty("request")); using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); AssertObservedRequest(fixture.GetProperty("verification"), observedRequests[0]); if (external) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Single(observedRequests); AssertStructuredLyrics(body, fixture.GetProperty("format").GetString()!); lookup.VerifyAll(); } else { var relay = fixture.GetProperty("relay"); Assert.Equal(relay.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.Equal(relay.GetProperty("responseBody").GetString(), body); Assert.Equal(2, observedRequests.Count); AssertObservedRequest(relay, observedRequests[1]); lookup.VerifyNoOtherCalls(); } } } [Fact] public async Task SubsonicSearch3_AppliesIndependentExternalWindowsForGetPostJsonXml() { using var fixtures = ReadFixture("subsonic-search-windows.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedRequests = new List(); var gateway = new Mock(MockBehavior.Strict); gateway.Setup(service => service.SearchAsync( It.IsAny(), "window", 2, 2, 2)) .ReturnsAsync(new SearchResult { Songs = [ new() { Id = "song-1", Title = "one", ExternalProvider = "deezer" }, new() { Id = "song-2", Title = "two", ExternalProvider = "deezer" } ], Albums = [ new() { Id = "album-1", Title = "one", ExternalProvider = "deezer" }, new() { Id = "album-2", Title = "two", ExternalProvider = "deezer" } ], Artists = [ new() { Id = "artist-1", Name = "one", ExternalProvider = "deezer" }, new() { Id = "artist-2", Name = "two", ExternalProvider = "deezer" } ] }); using var factory = new ProtocolFactory( "Subsonic", request => { observedRequests.Add(Observe(request)); if (request.RequestUri!.AbsolutePath is "/rest/ping.view") { return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); } var xml = fixture.GetProperty("format").GetString() == "xml"; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( xml ? """""" : """{"subsonic-response":{"status":"ok","version":"1.16.1","searchResult3":{}}}""", Encoding.UTF8, xml ? "application/xml" : "application/json") }; }, services => { services.RemoveAll(); services.AddSingleton(gateway.Object); }); using var client = factory.CreateClient(); using var request = CreateFixtureRequest(fixture.GetProperty("request")); using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.True( response.StatusCode == HttpStatusCode.OK, $"{fixture.GetProperty("name").GetString()} returned {(int)response.StatusCode}: {body}"); Assert.Equal(2, observedRequests.Count); Assert.Equal(fixture.GetProperty("expectedMethod").GetString(), observedRequests[1].Method); if (fixture.GetProperty("format").GetString() == "json") { using var document = JsonDocument.Parse(body); var search = document.RootElement.GetProperty("subsonic-response").GetProperty("searchResult3"); Assert.Equal(fixture.GetProperty("expectedSong").GetString(), search.GetProperty("song")[0].GetProperty("id").GetString()); Assert.Equal(fixture.GetProperty("expectedAlbum").GetString(), search.GetProperty("album")[0].GetProperty("id").GetString()); Assert.Equal(fixture.GetProperty("expectedArtist").GetString(), search.GetProperty("artist")[0].GetProperty("id").GetString()); } else { var document = System.Xml.Linq.XDocument.Parse(body); var search = document.Root!.Elements().Single(); Assert.Equal(fixture.GetProperty("expectedSong").GetString(), search.Elements().Single(element => element.Name.LocalName == "song").Attribute("id")?.Value); Assert.Equal(fixture.GetProperty("expectedAlbum").GetString(), search.Elements().Single(element => element.Name.LocalName == "album").Attribute("id")?.Value); Assert.Equal(fixture.GetProperty("expectedArtist").GetString(), search.Elements().Single(element => element.Name.LocalName == "artist").Attribute("id")?.Value); } gateway.VerifyAll(); } } [Fact] public async Task SubsonicRelay_PreservesConditionalRequestsResponseHeadersAndErrors() { using var fixtures = ReadFixture("subsonic-item-relay.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observed = new List(); string? conditional = null; using var factory = new ProtocolFactory("Subsonic", request => { observed.Add(Observe(request)); if (request.RequestUri!.AbsolutePath is "/rest/ping.view") { return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); } conditional = request.Headers.TryGetValues("If-None-Match", out var values) ? values.Single() : null; var status = fixture.GetProperty("status").GetInt32(); var response = new HttpResponseMessage((HttpStatusCode)status) { Content = status == StatusCodes.Status304NotModified ? new ByteArrayContent([]) : new StringContent( fixture.GetProperty("body").GetString()!, Encoding.UTF8, fixture.GetProperty("contentType").GetString()!) }; response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"cover-v2\""); response.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }; response.Headers.TryAddWithoutValidation("X-Subsonic-Revision", "fixture-r2"); return response; }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, fixture.GetProperty("requestPath").GetString()); request.Headers.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"cover-v1\"")); using var response = await client.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); Assert.Equal(fixture.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.Equal(fixture.GetProperty("body").GetString(), responseBody); Assert.Equal("\"cover-v1\"", conditional); Assert.Equal("\"cover-v2\"", response.Headers.ETag?.Tag); Assert.True(response.Headers.CacheControl?.NoCache); Assert.Equal("fixture-r2", response.Headers.GetValues("X-Subsonic-Revision").Single()); Assert.Equal(2, observed.Count); Assert.Equal(fixture.GetProperty("upstreamPath").GetString(), observed[1].PathAndQuery); } } [Fact] public async Task StreamingAdapters_PreserveGetHeadRangeValidatorsStatusAndHeaders() { using var fixtures = ReadFixture("protocol-streaming-ranges.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observedRequests = new List<(string Method, string PathAndQuery, string? Range, string? IfRange)>(); using var factory = new ProtocolFactory( fixture.GetProperty("protocol").GetString()!, request => { observedRequests.Add(( request.Method.Method, request.RequestUri!.PathAndQuery, request.Headers.TryGetValues("Range", out var ranges) ? ranges.Single() : null, request.Headers.TryGetValues("If-Range", out var validators) ? validators.Single() : null)); if (request.RequestUri.AbsolutePath is "/Users/Me") { if (fixture.TryGetProperty("verificationFallbackPath", out _)) { return Json( StatusCodes.Status400BadRequest, """{"error":"API key has no current user"}"""); } return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); } if (request.RequestUri.AbsolutePath is "/System/Info") { return Json(StatusCodes.Status200OK, """{"Id":"server-1","Version":"10.11.11"}"""); } if (IsItemLookup(request, "local-song")) { return ItemLookup("""{"Id":"local-song","Type":"Audio"}"""); } if (request.RequestUri.AbsolutePath is "/rest/ping.view") { return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); } var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent([8, 9, 10, 11, 12, 13, 14, 15]) }; response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/flac"); response.Content.Headers.ContentRange = new System.Net.Http.Headers.ContentRangeHeaderValue(8, 15, 32); response.Headers.AcceptRanges.Add("bytes"); response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"fixture-etag\""); return response; }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage( new HttpMethod(fixture.GetProperty("method").GetString()!), fixture.GetProperty("path").GetString()); request.Headers.TryAddWithoutValidation("Range", fixture.GetProperty("range").GetString()); request.Headers.TryAddWithoutValidation("If-Range", fixture.GetProperty("ifRange").GetString()); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); Assert.Equal("audio/flac", response.Content.Headers.ContentType?.MediaType); Assert.Equal("bytes", response.Headers.AcceptRanges.Single()); Assert.Equal("\"fixture-etag\"", response.Headers.ETag?.Tag); Assert.Equal("bytes 8-15/32", response.Content.Headers.ContentRange?.ToString()); var streamIndex = fixture.TryGetProperty("verificationFallbackPath", out _) ? 3 : fixture.GetProperty("protocol").GetString() == "jellyfin" ? 2 : 1; Assert.Equal(streamIndex + 1, observedRequests.Count); if (streamIndex == 2) Assert.Equal("/Items?ids=local-song&limit=1", observedRequests[0].PathAndQuery); if (streamIndex == 3) { Assert.Equal("/Items?ids=local-song&limit=1", observedRequests[0].PathAndQuery); Assert.Equal(fixture.GetProperty("verificationPath").GetString(), observedRequests[1].PathAndQuery); Assert.Equal( fixture.GetProperty("verificationFallbackPath").GetString(), observedRequests[2].PathAndQuery); } else { Assert.Equal( fixture.GetProperty("verificationPath").GetString(), observedRequests[streamIndex - 1].PathAndQuery); } Assert.Equal( fixture.TryGetProperty("upstreamMethod", out var upstreamMethod) ? upstreamMethod.GetString() : fixture.GetProperty("method").GetString(), observedRequests[streamIndex].Method); Assert.Equal(fixture.GetProperty("streamPath").GetString(), observedRequests[streamIndex].PathAndQuery); Assert.Equal(fixture.GetProperty("range").GetString(), observedRequests[streamIndex].Range); Assert.Equal(fixture.GetProperty("ifRange").GetString(), observedRequests[streamIndex].IfRange); } } [Fact] public async Task JellyfinUnboundFileApiKeyFallback_RejectsInvalidCredentialBeforeRelay() { var observed = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(request.RequestUri!.PathAndQuery); return request.RequestUri.AbsolutePath switch { "/Items" when IsItemLookup(request, "local-song") => ItemLookup("""{"Id":"local-song","Type":"Audio"}"""), "/Users/Me" => Json(StatusCodes.Status400BadRequest, """{"error":"API key has no current user"}"""), "/System/Info" => Json(StatusCodes.Status401Unauthorized, """{"error":"invalid token"}"""), _ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}") }; }); using var client = factory.CreateClient(); using var response = await client.GetAsync("/Items/local-song/File?ApiKey=invalid-key"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal( [ "/Items?ids=local-song&limit=1", "/Users/Me?ApiKey=invalid-key", "/System/Info?ApiKey=invalid-key" ], observed); } [Fact] public async Task JellyfinGenericRelay_PreservesMethodQueryBodyStatusHeadersAndMediaType() { using var fixtures = ReadFixture("jellyfin-generic-relay.json"); foreach (var fixture in fixtures.RootElement.EnumerateArray()) { var observed = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(Observe(request)); if (request.RequestUri!.AbsolutePath == "/Users/Me") { return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}"""); } var upstream = fixture.GetProperty("upstream"); var response = new HttpResponseMessage( (HttpStatusCode)upstream.GetProperty("status").GetInt32()) { Content = new StringContent( upstream.GetProperty("body").GetString()!, Encoding.UTF8, upstream.GetProperty("contentType").GetString()!) }; if (upstream.TryGetProperty("etag", out var etag)) { response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(etag.GetString()!); } if (upstream.TryGetProperty("location", out var location)) { response.Headers.Location = new Uri(location.GetString()!, UriKind.Relative); } return response; }); using var client = factory.CreateClient(); using var request = CreateFixtureRequest(fixture.GetProperty("request")); using var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); var upstreamFixture = fixture.GetProperty("upstream"); if (fixture.TryGetProperty("blocked", out var blocked) && blocked.GetBoolean()) { Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Empty(observed); continue; } Assert.Equal(upstreamFixture.GetProperty("status").GetInt32(), (int)response.StatusCode); Assert.Equal( upstreamFixture.GetProperty("contentType").GetString(), response.Content.Headers.ContentType?.MediaType); Assert.Equal(fixture.GetProperty("expectedBody").GetString(), body); Assert.Equal(2, observed.Count); Assert.Equal(fixture.GetProperty("verificationPath").GetString(), observed[0].PathAndQuery); Assert.Equal( fixture.GetProperty("request").GetProperty("method").GetString(), observed[1].Method); Assert.Equal(fixture.GetProperty("upstreamPath").GetString(), observed[1].PathAndQuery); var requestFixture = fixture.GetProperty("request"); var requestMethod = requestFixture.GetProperty("method").GetString(); Assert.Equal( requestFixture.TryGetProperty("body", out var expectedRequestBody) ? expectedRequestBody.GetString() : requestMethod is "GET" or "HEAD" ? null : string.Empty, observed[1].Body); if (upstreamFixture.TryGetProperty("etag", out var expectedEtag)) { Assert.Equal(expectedEtag.GetString(), response.Headers.ETag?.Tag); } if (upstreamFixture.TryGetProperty("location", out var expectedLocation)) { Assert.Equal(expectedLocation.GetString(), response.Headers.Location?.ToString()); } } } [Fact] public async Task JellyfinSessionControlRelay_PreservesFullResponseAndCommandBody() { const string sessionsBody = """ [ { "Id": "session-1", "UserId": "user-1", "UnknownFutureField": { "Keep": true } } ] """; const string commandBody = """{ "Name": "DisplayMessage", "Arguments": { "Text": "hello" } }"""; var observed = new List(); using var factory = new ProtocolFactory("Jellyfin", request => { observed.Add(Observe(request)); if (request.RequestUri!.AbsolutePath == "/Users/Me") { return Json(StatusCodes.Status200OK, """{"Id":"user-1"}"""); } if (request.RequestUri.AbsolutePath == "/Sessions") { var content = new ByteArrayContent(Encoding.UTF8.GetBytes(sessionsBody)); content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( "application/json; charset=utf-8"); var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"sessions-1\""); return response; } if (request.RequestUri.AbsolutePath == "/Sessions/session-1/Command") { return new HttpResponseMessage(HttpStatusCode.NoContent); } throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); }); using var client = factory.CreateClient(); using var sessionsRequest = new HttpRequestMessage( HttpMethod.Get, "/Sessions?ControllableByUserId=user-1"); sessionsRequest.Headers.TryAddWithoutValidation( "X-Emby-Authorization", "MediaBrowser Client=\"Fixture\", Device=\"Tests\", DeviceId=\"test-1\", Version=\"1\", UserId=\"user-1\", Token=\"fixture-token\""); using var sessionsResponse = await client.SendAsync(sessionsRequest); Assert.Equal(HttpStatusCode.OK, sessionsResponse.StatusCode); Assert.Equal(sessionsBody, await sessionsResponse.Content.ReadAsStringAsync()); Assert.Equal("application/json", sessionsResponse.Content.Headers.ContentType?.MediaType); Assert.Equal("\"sessions-1\"", sessionsResponse.Headers.ETag?.Tag); using var commandRequest = new HttpRequestMessage(HttpMethod.Post, "/Sessions/session-1/Command") { Content = new StringContent(commandBody, Encoding.UTF8, "application/json") }; commandRequest.Headers.TryAddWithoutValidation( "X-Emby-Authorization", "MediaBrowser Client=\"Fixture\", Device=\"Tests\", DeviceId=\"test-1\", Version=\"1\", UserId=\"user-1\", Token=\"fixture-token\""); using var commandResponse = await client.SendAsync(commandRequest); Assert.Equal(HttpStatusCode.NoContent, commandResponse.StatusCode); Assert.Equal( ["/Users/Me", "/Sessions?ControllableByUserId=user-1", "/Users/Me", "/Sessions/session-1/Command"], observed.Select(item => item.PathAndQuery)); Assert.Equal(commandBody, observed[^1].Body); Assert.Equal("application/json; charset=utf-8", observed[^1].ContentType); } [Theory] [InlineData("Jellyfin", "/Audio/local-song/stream?api_key=fixture-key")] [InlineData("Subsonic", "/rest/stream.view?u=fixture&p=fixture-password&v=1.16.1&c=fixture&id=local-song")] public async Task StreamingAdapters_PreserveUpstreamRangeErrors( string protocol, string path) { using var factory = new ProtocolFactory(protocol, request => { if (request.RequestUri!.AbsolutePath is "/Users/Me") { return Json(StatusCodes.Status200OK, """{"Id":"user-1"}"""); } if (IsItemLookup(request, "local-song")) { return ItemLookup("""{"Id":"local-song","Type":"Audio"}"""); } if (request.RequestUri.AbsolutePath is "/rest/ping.view") { return Json( StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); } return new HttpResponseMessage(HttpStatusCode.RequestedRangeNotSatisfiable); }); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(99, 100); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); } private static void AssertStructuredLyrics(string body, string format) { if (format == "json") { using var document = JsonDocument.Parse(body); var structured = document.RootElement.GetProperty("subsonic-response") .GetProperty("lyricsList").GetProperty("structuredLyrics")[0]; Assert.Equal("Fixture Artist", structured.GetProperty("displayArtist").GetString()); Assert.Equal(1_000, structured.GetProperty("line")[0].GetProperty("start").GetInt64()); return; } var documentXml = System.Xml.Linq.XDocument.Parse(body); System.Xml.Linq.XNamespace ns = "http://subsonic.org/restapi"; var structuredXml = documentXml.Root!.Element(ns + "lyricsList")! .Element(ns + "structuredLyrics")!; Assert.Equal("Fixture Artist", structuredXml.Attribute("displayArtist")?.Value); Assert.Equal("1000", structuredXml.Elements(ns + "line").First().Attribute("start")?.Value); } private static HttpRequestMessage CreateFixtureRequest(JsonElement fixture) { var request = new HttpRequestMessage( new HttpMethod(fixture.GetProperty("method").GetString()!), fixture.GetProperty("path").GetString()); if (fixture.TryGetProperty("body", out var body)) { request.Content = new StringContent( body.GetString()!, Encoding.UTF8, fixture.GetProperty("contentType").GetString()); } return request; } private static ObservedRequest Observe(HttpRequestMessage request) => new( request.Method.Method, request.RequestUri!.PathAndQuery, request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(), request.Content?.Headers.ContentType?.ToString()); private static void AssertObservedRequest(JsonElement expected, ObservedRequest actual) { Assert.Equal(expected.GetProperty("method").GetString(), actual.Method); Assert.Equal(expected.GetProperty("pathAndQuery").GetString(), actual.PathAndQuery); Assert.Equal( expected.TryGetProperty("body", out var body) ? body.GetString() : expected.TryGetProperty("requestBody", out var requestBody) ? requestBody.GetString() : null, actual.Body); } private static HttpResponseMessage FixtureResponse(JsonElement fixture) { var content = fixture.TryGetProperty("responseBody", out var body) ? body.GetRawText() : fixture.GetProperty("rawBody").GetString()!; return new HttpResponseMessage((HttpStatusCode)fixture.GetProperty("status").GetInt32()) { Content = new StringContent( content, Encoding.UTF8, fixture.GetProperty("contentType").GetString()!) }; } private static string CanonicalJson(JsonElement value) => JsonSerializer.Serialize(JsonSerializer.Deserialize(value.GetRawText())); private sealed record ObservedRequest( string Method, string PathAndQuery, string? Body, string? ContentType = null); private sealed class FixedPlaylistMutationResolver(SubsonicPlaylistMutationRoute? route) : ISubsonicPlaylistMutationResolver { public Task ResolveAsync( ProtocolExecutionContext context, string protocolId, CancellationToken cancellationToken = default) { return Task.FromResult(route); } } private sealed class FixedJellyfinPlaylistMutationResolver(JellyfinPlaylistMutationRoute? route) : IJellyfinPlaylistMutationResolver { public Task ResolveAsync( ProtocolExecutionContext context, string protocolId, CancellationToken cancellationToken = default) => Task.FromResult(route); } private sealed class RecordingInteractionAdapter : IJellyfinInteractionProtocolAdapter { private readonly JellyfinInteractionProtocolAdapter _inner = new(); public ProtocolExecutionContext? LastContext { get; private set; } public bool CanRunOptionalUserWork(ProtocolExecutionContext? context) { LastContext = context; return _inner.CanRunOptionalUserWork(context); } public JellyfinProtocolResponse ShapeFavorite(string itemId, bool isFavorite) => _inner.ShapeFavorite(itemId, isFavorite); public int ShapeCapabilitiesStatus(int upstreamStatusCode) => _inner.ShapeCapabilitiesStatus(upstreamStatusCode); public JellyfinProtocolResponse ShapeInstantMix(IReadOnlyList> items) => _inner.ShapeInstantMix(items); } private static HttpResponseMessage Json(int status, string body) => new((HttpStatusCode)status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; private static bool IsItemLookup(HttpRequestMessage request, string itemId) => request.RequestUri!.AbsolutePath == "/Items" && QueryHelpers.ParseQuery(request.RequestUri.Query)["ids"] == itemId; private static HttpResponseMessage ItemLookup(string item) => Json( StatusCodes.Status200OK, $$"""{"Items":[{{item}}],"TotalRecordCount":1,"StartIndex":0}"""); private static RecommendationSourceItem SonicTrack(string id, double score, string title) => new( id, score, [new RecommendationSignal("sonic", score, "Similar sound")], new RecommendationTrackIdentity(Title: title, Artist: "Fixture Artist", BackendItemId: id)); private static Action SonicServices( IAudioMuseRecommendationClient audioMuse, bool selected = true, string? otherLibraryItem = null) { var scopes = new Mock(MockBehavior.Strict); scopes.Setup(service => service.ResolveAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ProtocolExecutionContext context, string itemId, CancellationToken _) => context.WithLibraryScope(itemId == otherLibraryItem ? "other" : "music")); var policies = new Mock(MockBehavior.Strict); policies.Setup(service => service.GetAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(new IntelligencePolicyRecord { Enabled = true, EnabledProvidersJson = selected ? "[\"audiomuse-ai\"]" : "[]" }); return services => { services.AddSingleton(); services.RemoveAll(); services.AddSingleton(audioMuse); services.RemoveAll(); services.AddSingleton(scopes.Object); services.RemoveAll(); services.AddSingleton(policies.Object); }; } private sealed class SonicPrincipalStartupFilter : IStartupFilter { public Action Configure(Action next) => app => { app.Use(async (context, continuePipeline) => { var subsonic = context.Request.Path.StartsWithSegments("/rest"); context.Items[BackendIdentityResolver.HttpContextPrincipalItemKey] = new AllstarrPrincipal( Guid.Parse("018f1f6e-7db7-7ab0-8b32-f26f12ff6d6a"), Guid.Parse("018f1f6e-8e9c-77f5-9a79-3d8a494d60cd"), subsonic ? "subsonic" : "jellyfin", "primary", subsonic ? "fixture" : "user-1", "Fixture User", false); await continuePipeline(); }); next(app); }; } private static HttpResponseMessage SubsonicSonicBackend(HttpRequestMessage request) { if (request.RequestUri!.AbsolutePath == "/rest/ping.view") return Json(StatusCodes.Status200OK, """{"subsonic-response":{"status":"ok","version":"1.16.1"}}"""); if (request.RequestUri.AbsolutePath != "/rest/getSong.view") throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"); var query = QueryHelpers.ParseQuery(request.RequestUri.Query); var id = query["id"].ToString(); var duration = id switch { "song-2" => 202, "song-1" => 101, "bridge" => 150, _ => 100 }; if (query["f"] == "json") return Json(StatusCodes.Status200OK, JsonSerializer.Serialize(new Dictionary { ["subsonic-response"] = new { status = "ok", version = "1.16.1", song = new { id, title = id, artist = "Fixture Artist", isDir = false, type = "music", duration } } })); XNamespace ns = "http://subsonic.org/restapi"; var xml = new XDocument(new XElement(ns + "subsonic-response", new XAttribute("status", "ok"), new XAttribute("version", "1.16.1"), new XElement(ns + "song", new XAttribute("id", id), new XAttribute("title", id), new XAttribute("artist", "Fixture Artist"), new XAttribute("isDir", "false"), new XAttribute("type", "music"), new XAttribute("duration", duration)))); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(xml.ToString(), Encoding.UTF8, "application/xml") }; } private static JsonDocument ReadFixture(string fileName) { var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Protocols", fileName); return JsonDocument.Parse(File.ReadAllText(path)); } private sealed class ProtocolFactory : WebApplicationFactory { private readonly string _backend; private readonly Func _responder; private readonly Action? _configureServices; private readonly IReadOnlyDictionary? _configuration; private readonly string _stateRoot = Path.Combine( Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N")); public ProtocolFactory( string backend, Func responder, Action? configureServices = null, IReadOnlyDictionary? configuration = null) { _backend = backend; _responder = responder; _configureServices = configureServices; _configuration = configuration; } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.UseSetting("Backend:Type", _backend); builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["Backend:Type"] = _backend, ["SpotifyApi:Enabled"] = "false", ["SpotifyImport:Enabled"] = "false", ["Storage:EnforceMutationGuard"] = "false", ["Extensions:Directory"] = Path.Combine(_stateRoot, "extensions"), ["Cache:GenreDirectory"] = Path.Combine(_stateRoot, "genres"), ["MULTI_PROVIDER_DISABLED_PROVIDERS"] = "applemusic,deezer,qobuz,spotify" }); if (_configuration != null) configuration.AddInMemoryCollection(_configuration); }); builder.ConfigureServices(services => { services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(); services.AddSingleton( new StubHttpClientFactory(new StubHttpMessageHandler(_responder))); _configureServices?.Invoke(services); if (!services.Any(service => service.ServiceType == typeof(IProtocolProviderGateway))) { services.RemoveAll(); services.AddSingleton( new Mock(MockBehavior.Strict).Object); } }); } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing && Directory.Exists(_stateRoot)) { Directory.Delete(_stateRoot, recursive: true); } } } private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); } private sealed class StubHttpMessageHandler( Func responder) : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(responder(request)); } }