fix(jellyfin): preserve native root and HEAD parity

This commit is contained in:
joshpatra committed 2026-08-15 15:38:18 -04:00
1 parent e76de8beea
commit 58c9e285d8
6 files changed
+56 -26

No files matched your search

@@ -20,7 +20,6 @@
{
"protocol": "jellyfin",
"method": "HEAD",
"upstreamMethod": "GET",
"path": "/Items/local-song/Download?api_key=fixture-key",
"verificationPath": "/Users/Me?api_key=fixture-key",
"streamPath": "/Items/local-song/Download?api_key=fixture-key",
@@ -501,7 +501,7 @@ public sealed class ProtocolRouteFixtureTests
}
[Fact]
public async Task JellyfinMusicRoot_UsesConfiguredUserScope()
public async Task JellyfinMusicRoot_UsesFullItemRouteAndCallerAuthentication()
{
var observedRequests = new List<string>();
using var factory = new ProtocolFactory(
@@ -511,7 +511,8 @@ public sealed class ProtocolRouteFixtureTests
observedRequests.Add(request.RequestUri!.PathAndQuery);
return request.RequestUri.AbsolutePath == "/Users/Me"
? Json(StatusCodes.Status200OK, """{"Id":"user-1","Name":"Fixture User"}""")
: ItemLookup("""{"Id":"music-1","Type":"CollectionFolder","CollectionType":"music"}""");
: Json(StatusCodes.Status200OK,
"""{"Id":"music-1","Type":"CollectionFolder","CollectionType":"music","Etag":"full-object"}""");
},
configuration: new Dictionary<string, string?>
{
@@ -521,13 +522,41 @@ public sealed class ProtocolRouteFixtureTests
using var client = factory.CreateClient();
using var response = await client.GetAsync("/Items/Root?api_key=fixture-key");
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?ids=music-1&limit=1&userId=user-1"],
["/Users/Me?api_key=fixture-key", "/Items/music-1?api_key=fixture-key"],
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 JellyfinSearchAdapter_PreservesFixtureStatusBodyAndPaging()
{
@@ -2318,6 +2347,8 @@ public sealed class ProtocolRouteFixtureTests
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",
@@ -38,7 +38,7 @@ public partial class JellyfinController
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId, useGetForHead: true);
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
@@ -84,17 +84,14 @@ public partial class JellyfinController
/// <summary>
/// Proxies a stream from Jellyfin with proper header forwarding.
/// </summary>
private async Task<IActionResult> ProxyJellyfinStream(
string path,
string itemId,
bool useGetForHead = false)
private async Task<IActionResult> ProxyJellyfinStream(string path, string itemId)
{
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
try
{
var request = new HttpRequestMessage(
HttpMethods.IsHead(Request.Method) && !useGetForHead ? HttpMethod.Head : HttpMethod.Get,
HttpMethods.IsHead(Request.Method) ? HttpMethod.Head : HttpMethod.Get,
jellyfinUrl);
// Forward auth headers
@@ -74,9 +74,8 @@ public partial class JellyfinController
});
}
var (body, statusCode) = await _proxyService.GetItemAsync(
Uri.EscapeDataString(musicLibraryId),
Request.Headers);
var endpoint = $"Items/{Uri.EscapeDataString(musicLibraryId)}{Request.QueryString.Value}";
var (body, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(body, statusCode);
}
@@ -1093,6 +1093,9 @@ public partial class JellyfinController
item["ChildCount"] = playlist.Tracks.Count;
item["RunTimeTicks"] = playlist.Tracks.Sum(track => track.DurationMilliseconds) *
TimeSpan.TicksPerMillisecond;
var providerIds = item["ProviderIds"] as JsonObject ?? [];
item["ProviderIds"] = providerIds;
providerIds["AllstarrSource"] = playlist.SourceProviderId;
}
}
+14 -13
View File
@@ -1621,6 +1621,7 @@ if [[ -s "$direct_playlists_file" && -s "$allstarr_playlists_file" ]]; then
first(.Items[] as $direct |
select(any($proxy[0].Items[];
.Id == $direct.Id and
.ProviderIds.AllstarrSource == null and
(.ChildCount // 0) == ($direct.ChildCount // 0))) |
$direct.Id) // empty' "$direct_playlists_file")"
fi
@@ -1691,28 +1692,28 @@ elif [[ -n "$INJECTED_PLAYLIST_ID" ]]; then
--slurpfile full "$external_search_file"
[[ -n "$virtual_playlist_id" ]] || virtual_playlist_id="$INJECTED_PLAYLIST_ID"
fi
alias_ids_json="$(jq -c '[.Items[] | select(.ProviderIds.AllstarrSource != null) | .Id]' \
"$allstarr_playlists_file" 2>/dev/null || printf '[]')"
native_direct_filter="$alias_ids_json as \$aliases | [.Items[] | select(.Id as \$id | \$aliases | index(\$id) | not)]"
native_allstarr_filter="$alias_ids_json as \$aliases | [.Items[] | select(
(.Id as \$id | \$aliases | index(\$id) | not) and
(((.Id // \"\") | startswith(\"allstarr-vpl-\")) | not) and
(((.Id // \"\") | test(\"^ext-.+-playlist-\"; \"i\")) | not))]"
compare_structure "native playlist structure parity" \
"$DIRECT_BASE/Users/$best_user_id/Items?$playlist_query" \
"$ALLSTARR_BASE/Users/$best_user_id/Items?$playlist_query" \
'.Items' \
'[.Items[] | select(
((((.Id // "") | startswith("allstarr-vpl-")) or
((.Id // "") | test("^ext-.+-playlist-"; "i"))) | not))]'
"$native_direct_filter" \
"$native_allstarr_filter"
compare_projection "native playlist stable data" \
"$DIRECT_BASE/Users/$best_user_id/Items?$playlist_query" \
"$ALLSTARR_BASE/Users/$best_user_id/Items?$playlist_query" \
'[.Items[] | {Id,Name,Type,ImageTags,ProviderIds}] | sort_by(.Id)' \
'[.Items[] | select(
((((.Id // "") | startswith("allstarr-vpl-")) or
((.Id // "") | test("^ext-.+-playlist-"; "i"))) | not)) |
{Id,Name,Type,ImageTags,ProviderIds}] | sort_by(.Id)'
"$native_direct_filter | [.[] | {Id,Name,Type,ImageTags,ProviderIds}] | sort_by(.Id)" \
"$native_allstarr_filter | [.[] | {Id,Name,Type,ImageTags,ProviderIds}] | sort_by(.Id)"
compare_projection "native playlist full objects" \
"$DIRECT_BASE/Users/$best_user_id/Items?$playlist_query" \
"$ALLSTARR_BASE/Users/$best_user_id/Items?$playlist_query" \
'[.Items[]] | sort_by(.Id)' \
'[.Items[] | select(
((((.Id // "") | startswith("allstarr-vpl-")) or
((.Id // "") | test("^ext-.+-playlist-"; "i"))) | not))] | sort_by(.Id)'
"$native_direct_filter | sort_by(.Id)" \
"$native_allstarr_filter | sort_by(.Id)"
if [[ -n "$playlist_id" ]]; then
check_json "playlist entries music only" "$ALLSTARR_BASE/Playlists/$playlist_id/Items?UserId=$best_user_id&Limit=100" \
'(.Items | type == "array") and all(.Items[]; .Type == "Audio")'