Release 0.3.4: Jellyfin 12 stable support and audit fixes

Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
This commit is contained in:
ajp_anton
2026-09-09 01:18:43 +00:00
parent 8f7462fd3c
commit 096c6be8c7
50 changed files with 2126 additions and 1784 deletions
@@ -16,7 +16,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?Recursive=true&api_key=old",
"token",
UserId,
multilangEnabled: true);
@@ -36,7 +35,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
$"/Users/{OtherUserId}/Items?Limit=5",
"token",
UserId,
multilangEnabled: true);
@@ -52,7 +50,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L",
"token",
UserId,
multilangEnabled: true);
@@ -65,7 +62,7 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Equal("fi-FI", result.Controls.ClientLocale);
Assert.DoesNotContain("SortBy=", result.Upstream.Query);
Assert.DoesNotContain("Limit=", result.Upstream.Query);
Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey);
Assert.DoesNotContain("SortBy=", result.CacheKey);
}
[Fact]
@@ -76,20 +73,18 @@ public sealed class ItemsProxyRequestBuilderTests
var first = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&StartIndex=0&Limit=100",
"token",
UserId,
multilangEnabled: true);
var second = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&StartIndex=100&Limit=100",
"token",
UserId,
multilangEnabled: true);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache);
Assert.NotEqual(first.CacheKey, second.CacheKey);
Assert.Equal(first.CacheKey, second.CacheKey);
}
[Fact]
@@ -100,7 +95,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&Limit=10",
"token",
UserId,
multilangEnabled: false);
@@ -119,7 +113,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"http://other-host:8096/Items",
"token",
UserId,
multilangEnabled: true);
@@ -132,6 +125,18 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Equal([1, 2], ItemsProxyRequestBuilder.ParseLocalGenreIds("tmdb-1,abc,2,tmdb-2,tmdb-x,1"));
}
[Fact]
public void GenreFilteringRetainsTheFieldsNeededForLocalFiltering()
{
var result = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?GenreIds=tmdb-18&Fields=Overview", UserId, true);
Assert.NotNull(result);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(result.Upstream.Query);
Assert.False(query.ContainsKey("GenreIds"));
Assert.Equal(1, query["Fields"].Count);
Assert.Contains("GenreItems", query["Fields"].ToString());
Assert.Contains("Overview", query["Fields"].ToString());
}
private static HttpRequest HttpRequest(string query = "")
{
var context = new DefaultHttpContext();
@@ -140,4 +145,40 @@ public sealed class ItemsProxyRequestBuilderTests
context.Request.QueryString = new QueryString(query);
return context.Request;
}
[Theory]
[InlineData("//other-host:8096/Items")]
[InlineData("/\\other-host/Items")]
[InlineData("https://server.local:8096/Items")]
[InlineData("/Multilang/ItemsProxy")]
[InlineData("/System/Configuration")]
[InlineData("/Items/Filters")]
[InlineData("/Items/../System/Info")]
public void RejectsUnsupportedDestinations(string url)
=> Assert.Null(ItemsProxyRequestBuilder.Build(HttpRequest(), url, UserId, true));
[Fact]
public void EquivalentQueryOrderSharesCacheAndInFlightKeys()
{
var first = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?Fields=Overview&Recursive=true", UserId, true)!;
var second = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?Recursive=true&Fields=Overview", UserId, true)!;
Assert.Equal(first.CacheKey, second.CacheKey);
Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache);
}
[Fact]
public void BasePathAndDashedUserIdsAreNormalized()
{
var request = HttpRequest();
request.PathBase = "/jellyfin";
var result = ItemsProxyRequestBuilder.Build(request, "/jellyfin/Users/22222222-2222-2222-2222-222222222222/Items", UserId, true)!;
Assert.Equal($"/jellyfin/Users/{UserId}/Items", result.Upstream.AbsolutePath);
Assert.Null(ItemsProxyRequestBuilder.Build(request, "/Items", UserId, true));
}
[Theory]
[InlineData("/Items/Resume?StartIndex=100&Limit=20")]
[InlineData("/Shows/NextUp?StartIndex=100&Limit=20")]
public void BoundedListsKeepUpstreamPaging(string url)
=> Assert.False(ItemsProxyRequestBuilder.Build(HttpRequest(), url, UserId, true)!.Controls.ApplyLocalPaging);
}