mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
4068 lines
195 KiB
C#
4068 lines
195 KiB
C#
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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<IMusicMetadataService>(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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IJellyfinInteractionProtocolAdapter>();
|
|
services.AddSingleton<IJellyfinInteractionProtocolAdapter>(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<IJellyfinInteractionProtocolAdapter>();
|
|
services.AddSingleton<IJellyfinInteractionProtocolAdapter>(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<string>();
|
|
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata.Setup(service => service.SearchAllAsync(
|
|
"fixture", 2, 2, 2, It.IsAny<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
});
|
|
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<string>();
|
|
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
var downloads = new Mock<IDownloadService>(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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IDownloadService>();
|
|
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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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<string, string?>
|
|
{
|
|
["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<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata
|
|
.Setup(service => service.SearchAllAsync(
|
|
"fixture",
|
|
20,
|
|
0,
|
|
0,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new SearchResult());
|
|
metadata
|
|
.Setup(service => service.SearchPlaylistsAsync(
|
|
"fixture",
|
|
20,
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
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<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task JellyfinItemAndImageAdapters_PreserveExternalShapingPlaceholderAndConditionalResponse()
|
|
{
|
|
using var fixture = ReadFixture("jellyfin-item-image-shaping.json");
|
|
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata.Setup(service => service.GetSongAsync("deezer", "42", It.IsAny<CancellationToken>()))
|
|
.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<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
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<string>();
|
|
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
|
|
gateway.Setup(service => service.GetAlbumAsync(
|
|
It.Is<ProtocolExecutionContext>(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<IProtocolProviderGateway>();
|
|
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<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata.Setup(service => service.GetAlbumAsync(
|
|
"deezer", "42", It.IsAny<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
});
|
|
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<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata.Setup(service => service.GetSongAsync(
|
|
"applemusic",
|
|
"6768469976",
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
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<IPlaylistVirtualizationService>(MockBehavior.Strict);
|
|
virtualization.Setup(service => service.ResolvePublicArtworkSourceAsync(
|
|
virtualId,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VirtualPlaylistArtworkSource("deezer", "source-list"));
|
|
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata.Setup(service => service.GetPlaylistAsync(
|
|
"deezer",
|
|
"source-list",
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IPlaylistVirtualizationService>();
|
|
services.AddSingleton(virtualization.Object);
|
|
services.RemoveAll<IMusicMetadataService>();
|
|
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<IPlaylistVirtualizationService>(MockBehavior.Strict);
|
|
virtualization.Setup(service => service.ResolvePublicArtworkSourceAsync(
|
|
virtualId,
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IPlaylistVirtualizationService>();
|
|
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<IMusicMetadataService>(MockBehavior.Strict);
|
|
metadata.Setup(service => service.GetSongAsync(
|
|
"applemusic",
|
|
"6768469976",
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
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<byte>() : 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<string>();
|
|
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
if (fixture.TryGetProperty("external", out var external) && external.GetBoolean())
|
|
{
|
|
metadata.Setup(service => service.GetSongAsync("deezer", "missing", It.IsAny<CancellationToken>()))
|
|
.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<IMusicMetadataService>();
|
|
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<string>();
|
|
var metadata = new Mock<IMusicMetadataService>(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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IJellyfinInteractionProtocolAdapter>();
|
|
services.AddSingleton<IJellyfinInteractionProtocolAdapter>(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<string>();
|
|
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<string>();
|
|
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<IMusicMetadataService>(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<IMusicMetadataService>();
|
|
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<IProtocolProviderGateway>(MockBehavior.Strict);
|
|
gateway.Setup(service => service.GetSongAsync(
|
|
It.IsAny<ProtocolExecutionContext>(), "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<ProtocolExecutionContext>(), "Fixture Artist", 2, 0, 0, "deezer"))
|
|
.ReturnsAsync(new SearchResult
|
|
{
|
|
Songs =
|
|
[
|
|
new Song
|
|
{
|
|
Id = "ext-deezer-song-42",
|
|
ExternalProvider = "deezer",
|
|
ExternalId = "42",
|
|
Title = "Seed",
|
|
Artist = "Fixture Artist"
|
|
},
|
|
new Song
|
|
{
|
|
Id = "ext-deezer-song-43",
|
|
ExternalProvider = "deezer",
|
|
ExternalId = "43",
|
|
Title = "Related",
|
|
Artist = "Fixture Artist"
|
|
}
|
|
]
|
|
});
|
|
var interaction = new Mock<IJellyfinInteractionProtocolAdapter>(MockBehavior.Strict);
|
|
var shaper = new JellyfinInteractionProtocolAdapter();
|
|
interaction.Setup(adapter => adapter.CanRunOptionalUserWork(
|
|
It.IsAny<ProtocolExecutionContext?>()))
|
|
.Returns(true);
|
|
interaction.Setup(adapter => adapter.ShapeInstantMix(
|
|
It.IsAny<IReadOnlyList<Dictionary<string, object?>>>()))
|
|
.Returns((IReadOnlyList<Dictionary<string, object?>> items) =>
|
|
shaper.ShapeInstantMix(items));
|
|
using var factory = new ProtocolFactory(
|
|
"Jellyfin",
|
|
request => request.RequestUri!.AbsolutePath == "/Users/Me"
|
|
? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""")
|
|
: throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"),
|
|
services =>
|
|
{
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
services.AddSingleton(gateway.Object);
|
|
services.RemoveAll<IJellyfinInteractionProtocolAdapter>();
|
|
services.AddSingleton(interaction.Object);
|
|
});
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.GetAsync(
|
|
"/Items/ext-deezer-song-42/Similar?Limit=2&api_key=fixture-key");
|
|
using var mixResponse = await client.GetAsync(
|
|
"/Items/ext-deezer-song-42/InstantMix?Limit=2&api_key=fixture-key");
|
|
using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
|
using var mixBody = JsonDocument.Parse(await mixResponse.Content.ReadAsStringAsync());
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, mixResponse.StatusCode);
|
|
Assert.Equal(0, body.RootElement.GetProperty("StartIndex").GetInt32());
|
|
Assert.Equal(1, body.RootElement.GetProperty("TotalRecordCount").GetInt32());
|
|
Assert.Equal(
|
|
"ext-deezer-song-43",
|
|
body.RootElement.GetProperty("Items")[0].GetProperty("Id").GetString());
|
|
Assert.Equal(1, mixBody.RootElement.GetProperty("TotalRecordCount").GetInt32());
|
|
Assert.Equal(
|
|
"ext-deezer-song-43",
|
|
mixBody.RootElement.GetProperty("Items")[0].GetProperty("Id").GetString());
|
|
gateway.Verify(service => service.SearchAsync(
|
|
It.IsAny<ProtocolExecutionContext>(), "Fixture Artist", 2, 0, 0, "deezer"),
|
|
Times.Exactly(2));
|
|
gateway.VerifyAll();
|
|
interaction.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task JellyfinExternalInstantMix_ProviderFailureKeepsTheStandardEnvelope()
|
|
{
|
|
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
|
|
gateway.Setup(service => service.GetSongAsync(
|
|
It.IsAny<ProtocolExecutionContext>(), "deezer", "42"))
|
|
.ThrowsAsync(new HttpRequestException("provider unavailable"));
|
|
var interaction = new Mock<IJellyfinInteractionProtocolAdapter>(MockBehavior.Strict);
|
|
interaction.Setup(adapter => adapter.CanRunOptionalUserWork(
|
|
It.IsAny<ProtocolExecutionContext?>()))
|
|
.Returns(true);
|
|
interaction.Setup(adapter => adapter.ShapeInstantMix(
|
|
It.Is<IReadOnlyList<Dictionary<string, object?>>>(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<IProtocolProviderGateway>();
|
|
services.AddSingleton(gateway.Object);
|
|
services.RemoveAll<IJellyfinInteractionProtocolAdapter>();
|
|
services.AddSingleton(interaction.Object);
|
|
});
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.GetAsync(
|
|
"/Items/ext-deezer-song-42/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<IMusicMetadataService>(MockBehavior.Strict);
|
|
var songs = new List<Song>
|
|
{
|
|
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<CancellationToken>()))
|
|
.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<CancellationToken>()))
|
|
.ReturnsAsync(new Artist
|
|
{
|
|
Id = "ext-deezer-artist-42",
|
|
ExternalProvider = "deezer",
|
|
ExternalId = "42",
|
|
Name = "Fixture Artist"
|
|
});
|
|
metadata.Setup(service => service.GetArtistAlbumsAsync(
|
|
"deezer", "42", It.IsAny<CancellationToken>()))
|
|
.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<CancellationToken>()))
|
|
.ReturnsAsync(new Album
|
|
{
|
|
Id = "ext-deezer-album-a1",
|
|
ExternalProvider = "deezer",
|
|
ExternalId = "a1",
|
|
Title = "Fixture Album",
|
|
Artist = "Fixture Artist",
|
|
Songs = songs
|
|
});
|
|
}
|
|
|
|
var interaction = new Mock<IJellyfinInteractionProtocolAdapter>(MockBehavior.Strict);
|
|
var shaper = new JellyfinInteractionProtocolAdapter();
|
|
interaction.Setup(adapter => adapter.CanRunOptionalUserWork(
|
|
It.IsAny<ProtocolExecutionContext?>()))
|
|
.Returns(true);
|
|
interaction.Setup(adapter => adapter.ShapeInstantMix(
|
|
It.IsAny<IReadOnlyList<Dictionary<string, object?>>>()))
|
|
.Returns((IReadOnlyList<Dictionary<string, object?>> items) =>
|
|
shaper.ShapeInstantMix(items));
|
|
using var factory = new ProtocolFactory(
|
|
"Jellyfin",
|
|
request => request.RequestUri!.AbsolutePath == "/Users/Me"
|
|
? Json(StatusCodes.Status200OK, """{"Id":"user-1"}""")
|
|
: throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}"),
|
|
services =>
|
|
{
|
|
services.RemoveAll<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
services.RemoveAll<IJellyfinInteractionProtocolAdapter>();
|
|
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<IAudioMuseRecommendationClient>(MockBehavior.Strict);
|
|
audioMuse.SetupGet(client => client.IsAvailable).Returns(true);
|
|
audioMuse.Setup(client => client.FindSimilarAsync(
|
|
It.Is<IntelligenceScope>(scope => scope.Protocol == "jellyfin" && scope.LibraryScopeId == "music"),
|
|
It.Is<IReadOnlyList<string>>(ids => ids.SequenceEqual(new[] { "seed" })),
|
|
2,
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IAudioMuseRecommendationClient>(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<IntelligenceScope>(),
|
|
It.IsAny<IReadOnlyList<string>>(),
|
|
It.IsAny<int>(),
|
|
It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenSubsonicSonicSimilarTracks_PreserveNativeJsonAndScope()
|
|
{
|
|
var audioMuse = new Mock<IAudioMuseRecommendationClient>(MockBehavior.Strict);
|
|
audioMuse.SetupGet(client => client.IsAvailable).Returns(true);
|
|
audioMuse.Setup(client => client.FindSimilarAsync(
|
|
It.Is<IntelligenceScope>(scope => scope.Protocol == "subsonic" && scope.LibraryScopeId == "music"),
|
|
It.Is<IReadOnlyList<string>>(ids => ids.SequenceEqual(new[] { "seed" })),
|
|
2,
|
|
It.IsAny<CancellationToken>()))
|
|
.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<IAudioMuseRecommendationClient>(MockBehavior.Strict);
|
|
audioMuse.SetupGet(client => client.IsAvailable).Returns(true);
|
|
audioMuse.Setup(client => client.FindPathAsync(
|
|
It.Is<IntelligenceScope>(scope => scope.Protocol == "subsonic" && scope.LibraryScopeId == "music"),
|
|
"start", "end", 3, It.IsAny<CancellationToken>()))
|
|
.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<IAudioMuseRecommendationClient>(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<IntelligenceScope>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<int>(),
|
|
It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenSubsonicExtensionDiscovery_IsPublicAndMergesSonicCapability()
|
|
{
|
|
var audioMuse = new Mock<IAudioMuseRecommendationClient>(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<IAudioMuseRecommendationClient>(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<IMusicMetadataService>(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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
});
|
|
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<IProtocolProviderGateway>(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<ProtocolExecutionContext>(),
|
|
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<IProtocolProviderGateway>();
|
|
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<IProtocolProviderGateway>(MockBehavior.Strict);
|
|
gateway.Setup(service => service.GetArtistAlbumsAsync(
|
|
It.IsAny<ProtocolExecutionContext>(), "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<ProtocolExecutionContext>(), "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<ProtocolExecutionContext>(), "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<ProtocolExecutionContext>(), "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<IProtocolProviderGateway>();
|
|
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<string, object>
|
|
{
|
|
["subsonic-response"] = new Dictionary<string, object>
|
|
{
|
|
["status"] = "ok",
|
|
["version"] = "1.16.1",
|
|
[itemType] = new Dictionary<string, string>
|
|
{
|
|
["id"] = itemId,
|
|
["name"] = "Native Item",
|
|
["unknownNativeField"] = "preserved"
|
|
}
|
|
}
|
|
});
|
|
var observed = new List<ObservedRequest>();
|
|
var metadata = new Mock<IMusicMetadataService>(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<IMusicMetadataService>();
|
|
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<IProtocolProviderGateway>(MockBehavior.Strict);
|
|
gateway.Setup(service => service.GetArtistAsync(
|
|
It.IsAny<ProtocolExecutionContext>(), "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<ProtocolExecutionContext>(), "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<IMusicMetadataService>(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<IProtocolProviderGateway>();
|
|
services.AddSingleton(gateway.Object);
|
|
services.RemoveAll<IMusicMetadataService>();
|
|
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<ObservedRequest>();
|
|
var metadata = new Mock<IMusicMetadataService>(MockBehavior.Strict);
|
|
var downloads = new Mock<IDownloadService>(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<IMusicMetadataService>();
|
|
services.AddSingleton(metadata.Object);
|
|
services.RemoveAll<IDownloadService>();
|
|
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<ObservedRequest>();
|
|
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<ObservedRequest>();
|
|
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<ISubsonicPlaylistMutationResolver>();
|
|
services.AddSingleton<ISubsonicPlaylistMutationResolver>(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<ObservedRequest>();
|
|
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<ISubsonicPlaylistMutationResolver>();
|
|
services.AddSingleton<ISubsonicPlaylistMutationResolver>(
|
|
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<IPlaylistVirtualizationService>(MockBehavior.Strict);
|
|
virtualization.Setup(service => service.ListAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync([model]);
|
|
virtualization.Setup(service => service.ReadBySourceAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
"spotify",
|
|
sourceId,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(model);
|
|
var observed = new List<string>();
|
|
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<IPlaylistVirtualizationService>();
|
|
services.AddSingleton(virtualization.Object);
|
|
},
|
|
new Dictionary<string, string?>
|
|
{
|
|
["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<IPlaylistVirtualizationService>(MockBehavior.Strict);
|
|
virtualization.Setup(service => service.ReadAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
virtualId,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(model);
|
|
var observed = new List<string>();
|
|
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<IPlaylistVirtualizationService>();
|
|
services.AddSingleton(virtualization.Object);
|
|
services.RemoveAll<IJellyfinPlaylistMutationResolver>();
|
|
services.AddSingleton<IJellyfinPlaylistMutationResolver>(
|
|
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<string>());
|
|
Assert.Equal(virtualId, actual["ParentId"]!.GetValue<string>());
|
|
Assert.Equal("spotify", actual["ProviderIds"]!["AllstarrSource"]!.GetValue<string>());
|
|
Assert.Equal("local-song-a", actual["Id"]!.GetValue<string>());
|
|
Assert.Equal("local-song-a", actual["PlaylistItemId"]!.GetValue<string>());
|
|
Assert.Equal("album-original", actual["AlbumId"]!.GetValue<string>());
|
|
Assert.Equal("media-original", actual["MediaSources"]![0]!["Id"]!.GetValue<string>());
|
|
Assert.Equal("artist-original", actual["ArtistItems"]![0]!["Id"]!.GetValue<string>());
|
|
Assert.Equal("artist-original", actual["AlbumArtists"]![0]!["Id"]!.GetValue<string>());
|
|
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<ObservedRequest>();
|
|
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<IJellyfinPlaylistMutationResolver>();
|
|
services.AddSingleton<IJellyfinPlaylistMutationResolver>(
|
|
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<ObservedRequest>();
|
|
using var factory = new ProtocolFactory(
|
|
"Jellyfin",
|
|
request =>
|
|
{
|
|
observed.Add(Observe(request));
|
|
return Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""");
|
|
},
|
|
services =>
|
|
{
|
|
services.RemoveAll<IJellyfinPlaylistMutationResolver>();
|
|
services.AddSingleton<IJellyfinPlaylistMutationResolver>(
|
|
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<ObservedRequest>();
|
|
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<ObservedRequest>();
|
|
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<ObservedRequest>();
|
|
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<ObservedRequest>();
|
|
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<IPlaylistVirtualizationService>(MockBehavior.Strict);
|
|
virtualization.Setup(service => service.ReadAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
virtualId,
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<ProtocolExecutionContext, string, CancellationToken>((context, _, _) =>
|
|
observedContext = context)
|
|
.ReturnsAsync(model);
|
|
var observedRequests = new List<ObservedRequest>();
|
|
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<IPlaylistVirtualizationService>();
|
|
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<IPlaylistVirtualizationService>(MockBehavior.Strict);
|
|
virtualization.Setup(service => service.ListAsync(
|
|
It.IsAny<ProtocolExecutionContext>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync([model]);
|
|
var observedRequests = new List<ObservedRequest>();
|
|
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(
|
|
"""<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" /></playlists></subsonic-response>""",
|
|
Encoding.UTF8,
|
|
"application/xml")
|
|
};
|
|
},
|
|
services =>
|
|
{
|
|
services.RemoveAll<IPlaylistVirtualizationService>();
|
|
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<ObservedRequest>();
|
|
var external = fixture.GetProperty("external").GetBoolean();
|
|
var lookup = new Mock<ISubsonicLyricsLookup>(MockBehavior.Strict);
|
|
if (external)
|
|
{
|
|
var expectedLookup = fixture.GetProperty("lookup");
|
|
lookup.Setup(service => service.FindAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
expectedLookup.GetProperty("provider").GetString()!,
|
|
expectedLookup.GetProperty("externalId").GetString()!,
|
|
It.IsAny<CancellationToken>()))
|
|
.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<ISubsonicLyricsLookup>();
|
|
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<ObservedRequest>();
|
|
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
|
|
gateway.Setup(service => service.SearchAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
"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 xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1"><searchResult3 /></subsonic-response>"""
|
|
: """{"subsonic-response":{"status":"ok","version":"1.16.1","searchResult3":{}}}""",
|
|
Encoding.UTF8,
|
|
xml ? "application/xml" : "application/json")
|
|
};
|
|
},
|
|
services =>
|
|
{
|
|
services.RemoveAll<IProtocolProviderGateway>();
|
|
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<ObservedRequest>();
|
|
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<string>();
|
|
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<ObservedRequest>();
|
|
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<ObservedRequest>();
|
|
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<object>(value.GetRawText()));
|
|
|
|
private sealed record ObservedRequest(
|
|
string Method,
|
|
string PathAndQuery,
|
|
string? Body,
|
|
string? ContentType = null);
|
|
|
|
private sealed class FixedPlaylistMutationResolver(SubsonicPlaylistMutationRoute? route)
|
|
: ISubsonicPlaylistMutationResolver
|
|
{
|
|
public Task<SubsonicPlaylistMutationRoute?> ResolveAsync(
|
|
ProtocolExecutionContext context,
|
|
string protocolId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult(route);
|
|
}
|
|
}
|
|
|
|
private sealed class FixedJellyfinPlaylistMutationResolver(JellyfinPlaylistMutationRoute? route)
|
|
: IJellyfinPlaylistMutationResolver
|
|
{
|
|
public Task<JellyfinPlaylistMutationRoute?> 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<Dictionary<string, object?>> 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<IServiceCollection> SonicServices(
|
|
IAudioMuseRecommendationClient audioMuse,
|
|
bool selected = true,
|
|
string? otherLibraryItem = null)
|
|
{
|
|
var scopes = new Mock<IProtocolLibraryScopeResolver>(MockBehavior.Strict);
|
|
scopes.Setup(service => service.ResolveAsync(
|
|
It.IsAny<ProtocolExecutionContext>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((ProtocolExecutionContext context, string itemId, CancellationToken _) =>
|
|
context.WithLibraryScope(itemId == otherLibraryItem ? "other" : "music"));
|
|
var policies = new Mock<IIntelligencePolicyService>(MockBehavior.Strict);
|
|
policies.Setup(service => service.GetAsync(
|
|
It.IsAny<IntelligenceScope>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new IntelligencePolicyRecord
|
|
{
|
|
Enabled = true,
|
|
EnabledProvidersJson = selected ? "[\"audiomuse-ai\"]" : "[]"
|
|
});
|
|
return services =>
|
|
{
|
|
services.AddSingleton<IStartupFilter, SonicPrincipalStartupFilter>();
|
|
services.RemoveAll<IAudioMuseRecommendationClient>();
|
|
services.AddSingleton(audioMuse);
|
|
services.RemoveAll<IProtocolLibraryScopeResolver>();
|
|
services.AddSingleton(scopes.Object);
|
|
services.RemoveAll<IIntelligencePolicyService>();
|
|
services.AddSingleton(policies.Object);
|
|
};
|
|
}
|
|
|
|
private sealed class SonicPrincipalStartupFilter : IStartupFilter
|
|
{
|
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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<string, object>
|
|
{
|
|
["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<Program>
|
|
{
|
|
private readonly string _backend;
|
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
|
private readonly Action<IServiceCollection>? _configureServices;
|
|
private readonly IReadOnlyDictionary<string, string?>? _configuration;
|
|
private readonly string _stateRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"allstarr-tests",
|
|
Guid.NewGuid().ToString("N"));
|
|
|
|
public ProtocolFactory(
|
|
string backend,
|
|
Func<HttpRequestMessage, HttpResponseMessage> responder,
|
|
Action<IServiceCollection>? configureServices = null,
|
|
IReadOnlyDictionary<string, string?>? 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<string, string?>
|
|
{
|
|
["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<IHostedService>();
|
|
services.RemoveAll<IHttpClientFactory>();
|
|
services.RemoveAll<IApplicationCache>();
|
|
services.AddSingleton<IApplicationCache, TestMemoryApplicationCache>();
|
|
services.AddSingleton<IHttpClientFactory>(
|
|
new StubHttpClientFactory(new StubHttpMessageHandler(_responder)));
|
|
_configureServices?.Invoke(services);
|
|
if (!services.Any(service => service.ServiceType == typeof(IProtocolProviderGateway)))
|
|
{
|
|
services.RemoveAll<IProtocolLyricsResolver>();
|
|
services.AddSingleton(
|
|
new Mock<IProtocolLyricsResolver>(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<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken) => Task.FromResult(responder(request));
|
|
}
|
|
}
|