fix(jellyfin): support modern server authorization

This commit is contained in:
joshpatra committed 2026-09-09 07:14:03 -04:00
1 parent 8781bd56ff
commit 1d11bcd35c
11 files changed
+362 -569

No files matched your search

@@ -7,7 +7,7 @@ namespace allstarr.Tests;
public class AuthHeaderHelperTests
{
[Fact]
public void ForwardAuthHeaders_ShouldPreferXEmbyAuthorization()
public void ForwardAuthHeaders_ShouldPreferModernAuthorization()
{
var headers = new HeaderDictionary
{
@@ -19,13 +19,13 @@ public class AuthHeaderHelperTests
var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
Assert.True(forwarded);
Assert.True(request.Headers.TryGetValues("X-Emby-Authorization", out var values));
Assert.Contains("MediaBrowser Token=\"abc\"", values);
Assert.False(request.Headers.Contains("Authorization"));
Assert.True(request.Headers.TryGetValues("Authorization", out var values));
Assert.Contains("Bearer xyz", values);
Assert.False(request.Headers.Contains("X-Emby-Authorization"));
}
[Fact]
public void ForwardAuthHeaders_ShouldMapMediaBrowserAuthorizationToXEmby()
public void ForwardAuthHeaders_ShouldPreserveMediaBrowserAuthorization()
{
var headers = new HeaderDictionary
{
@@ -36,9 +36,10 @@ public class AuthHeaderHelperTests
var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
Assert.True(forwarded);
Assert.True(request.Headers.Contains("X-Emby-Authorization"));
Assert.True(request.Headers.TryGetValues("X-Emby-Token", out var tokens));
Assert.Contains("abc", tokens);
Assert.True(request.Headers.TryGetValues("Authorization", out var values));
Assert.Contains("MediaBrowser Client=\"Feishin\", Token=\"abc\"", values);
Assert.False(request.Headers.Contains("X-Emby-Authorization"));
Assert.False(request.Headers.Contains("X-Emby-Token"));
}
[Fact]
@@ -53,7 +54,7 @@ public class AuthHeaderHelperTests
}
[Fact]
public void ForwardAuthHeaders_ShouldForwardXEmbyToken()
public void ForwardAuthHeaders_ShouldUpgradeXEmbyToken()
{
var headers = new HeaderDictionary
{
@@ -64,8 +65,9 @@ public class AuthHeaderHelperTests
var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
Assert.True(forwarded);
Assert.True(request.Headers.TryGetValues("X-Emby-Token", out var values));
Assert.Contains("abc", values);
Assert.True(request.Headers.TryGetValues("Authorization", out var values));
Assert.Contains(values, value => value.Contains("Token=\"abc\"", StringComparison.Ordinal));
Assert.False(request.Headers.Contains("X-Emby-Token"));
}
[Fact]
@@ -18,7 +18,7 @@ public sealed class JellyfinPlaybackMetadataResolverTests
var resolver = CreateResolver(request =>
{
requestCount++;
Assert.Equal("server-api-key", request.Headers.GetValues("X-Emby-Token").Single());
Assert.Contains("Token=\"server-api-key\"", request.Headers.GetValues("Authorization").Single());
Assert.Equal("user-1", ParseQuery(request.RequestUri!).GetValueOrDefault("userId"));
return Json("""
{
@@ -72,7 +72,7 @@ public sealed class JellyfinPlaybackMetadataResolverTests
{
var resolver = CreateResolver(request =>
{
Assert.Equal("server-api-key", request.Headers.GetValues("X-Emby-Token").Single());
Assert.Contains("Token=\"server-api-key\"", request.Headers.GetValues("Authorization").Single());
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent([1, 2, 3, 4])
@@ -68,14 +68,11 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetJsonAsync_ValidResponse_ReturnsJsonDocument()
{
// Arrange
var jsonResponse = "{\"Items\":[{\"Id\":\"123\",\"Name\":\"Test Song\"}],\"TotalRecordCount\":1}";
SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json");
// Act
var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
Assert.NotNull(body);
Assert.Equal(200, statusCode);
Assert.True(body.RootElement.TryGetProperty("Items", out var items));
@@ -85,13 +82,10 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetJsonAsync_ServerError_ReturnsNull()
{
// Arrange
SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain");
// Act
var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
Assert.Null(body);
Assert.Equal(500, statusCode);
}
@@ -132,7 +126,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -144,19 +137,16 @@ public class JellyfinProxyServiceTests
Content = new StringContent("{}")
});
// Act
await _service.GetJsonAsync("Items");
// Assert - Should NOT include auth when no client headers provided
Assert.NotNull(captured);
Assert.False(captured!.Headers.Contains("Authorization"));
Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
}
[Fact]
public async Task GetJsonAsync_WithXEmbyToken_ForwardsTokenHeader()
public async Task GetJsonAsync_WithXEmbyToken_UsesModernAuthorization()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -173,19 +163,17 @@ public class JellyfinProxyServiceTests
["X-Emby-Token"] = "token-123"
};
// Act
await _service.GetJsonAsync("Items", null, headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("X-Emby-Token", out var values));
Assert.Contains("token-123", values);
Assert.True(captured!.Headers.TryGetValues("Authorization", out var values));
Assert.Contains(values, value => value.Contains("Token=\"token-123\"", StringComparison.Ordinal));
Assert.False(captured.Headers.Contains("X-Emby-Token"));
}
[Fact]
public async Task GetBytesAsync_ReturnsBodyAndContentType()
{
// Arrange
var imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
@@ -199,10 +187,8 @@ public class JellyfinProxyServiceTests
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
var (body, contentType) = await _service.GetBytesAsync("Items/123/Images/Primary");
// Assert
Assert.Equal(imageBytes, body);
Assert.Equal("image/png", contentType);
}
@@ -210,17 +196,14 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetBytesSafeAsync_OnError_ReturnsSuccessFalse()
{
// Arrange
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
// Act
var (body, contentType, success) = await _service.GetBytesSafeAsync("Items/123/Images/Primary");
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
@@ -229,7 +212,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task SearchAsync_BuildsCorrectQueryParams()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -241,10 +223,8 @@ public class JellyfinProxyServiceTests
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
});
// Act
await _service.SearchAsync("test query", new[] { "Audio", "MusicAlbum" }, 25);
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
@@ -267,7 +247,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetItemAsync_RequestsCorrectEndpoint()
{
// Arrange
HttpRequestMessage? captured = null;
var itemJson = "{\"Items\":[{\"Id\":\"abc-123\",\"Name\":\"My Song\",\"Type\":\"Audio\"}],\"TotalRecordCount\":1}";
_mockHandler.Protected()
@@ -280,16 +259,14 @@ public class JellyfinProxyServiceTests
Content = new StringContent(itemJson)
});
// Act
var (body, statusCode) = await _service.GetItemAsync("abc-123");
// Assert
Assert.NotNull(captured);
Assert.Contains("/Items?", captured!.RequestUri!.ToString());
Assert.Contains("ids=abc-123", captured.RequestUri.ToString());
Assert.Contains("limit=1", captured.RequestUri.ToString());
Assert.Contains("userId=user-guid-here", captured.RequestUri.ToString());
Assert.Equal("test-api-key-12345", captured.Headers.GetValues("X-Emby-Token").Single());
Assert.Contains("Token=\"test-api-key-12345\"", captured.Headers.GetValues("Authorization").Single());
Assert.NotNull(body);
Assert.Equal("abc-123", body.RootElement.GetProperty("Id").GetString());
Assert.Equal(200, statusCode);
@@ -320,7 +297,7 @@ public class JellyfinProxyServiceTests
Assert.NotNull(body);
Assert.NotNull(captured);
Assert.DoesNotContain("userId=", captured!.RequestUri!.Query);
Assert.Equal("caller-token", captured.Headers.GetValues("X-Emby-Token").Single());
Assert.Contains("Token=\"caller-token\"", captured.Headers.GetValues("Authorization").Single());
}
[Fact]
@@ -342,13 +319,12 @@ public class JellyfinProxyServiceTests
Assert.NotNull(captured);
Assert.DoesNotContain("userId=", captured!.RequestUri!.Query);
Assert.Equal("caller-token", captured.Headers.GetValues("X-Emby-Token").Single());
Assert.Contains("Token=\"caller-token\"", captured.Headers.GetValues("Authorization").Single());
}
[Fact]
public async Task GetJsonAsync_WithEndpointQuery_PreservesCallerParameters()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -360,24 +336,22 @@ public class JellyfinProxyServiceTests
Content = new StringContent("{\"Id\":\"abc-123\"}")
});
// Act
await _service.GetJsonAsync(
"Users/user-abc/Items/abc-123?api_key=query-token&Fields=DateCreated,PremiereDate,ProductionYear");
// Assert
Assert.NotNull(captured);
var requestUri = captured!.RequestUri!;
Assert.Contains("/Users/user-abc/Items/abc-123", requestUri.ToString());
var query = System.Web.HttpUtility.ParseQueryString(requestUri.Query);
Assert.Equal("query-token", query.Get("api_key"));
Assert.Equal("query-token", query.Get("ApiKey"));
Assert.DoesNotContain("api_key=", requestUri.Query, StringComparison.Ordinal);
Assert.Equal("DateCreated,PremiereDate,ProductionYear", query.Get("Fields"));
}
[Fact]
public async Task GetJsonAsync_WithRepeatedFields_PreservesAllFieldParameters()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -389,11 +363,9 @@ public class JellyfinProxyServiceTests
Content = new StringContent("{\"Items\":[]}")
});
// Act
await _service.GetJsonAsync(
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
// Assert
Assert.NotNull(captured);
var query = captured!.RequestUri!.Query;
Assert.Contains("Fields=Genres", query);
@@ -405,7 +377,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -419,14 +390,12 @@ public class JellyfinProxyServiceTests
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var (_, statusCode) = await _service.SendAsync(
HttpMethod.Post,
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
null,
headers);
// Assert
Assert.Equal(204, statusCode);
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Post, captured!.Method);
@@ -436,7 +405,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -450,7 +418,6 @@ public class JellyfinProxyServiceTests
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
await _service.SendAsync(
HttpMethod.Put,
"Sessions/session-123/Command/DisplayMessage",
@@ -458,7 +425,6 @@ public class JellyfinProxyServiceTests
headers,
"application/json; charset=utf-8");
// Assert
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Put, captured!.Method);
Assert.NotNull(captured.Content);
@@ -468,7 +434,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -480,7 +445,6 @@ public class JellyfinProxyServiceTests
Content = new StringContent("{\"Items\":[]}")
});
// Act
await _service.GetJsonAsync(
"Items/abc-123?api_key=endpoint-token&Fields=DateCreated",
new Dictionary<string, string>
@@ -489,10 +453,10 @@ public class JellyfinProxyServiceTests
["UserId"] = "route-user"
});
// Assert
Assert.NotNull(captured);
var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query);
Assert.Equal("explicit-token", query.Get("api_key"));
Assert.Equal("explicit-token", query.Get("ApiKey"));
Assert.DoesNotContain("api_key=", captured.RequestUri.Query, StringComparison.Ordinal);
Assert.Equal("DateCreated", query.Get("Fields"));
Assert.Equal("route-user", query.Get("UserId"));
}
@@ -500,7 +464,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetArtistsAsync_WithSearchTerm_IncludesInQuery()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -512,10 +475,8 @@ public class JellyfinProxyServiceTests
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
});
// Act
await _service.GetArtistsAsync("Beatles", 10);
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
Assert.Contains("/Artists", url);
@@ -526,7 +487,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetImageAsync_WithDimensions_IncludesMaxWidthHeight()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -538,10 +498,8 @@ public class JellyfinProxyServiceTests
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
});
// Act
await _service.GetImageAsync("item-123", "Primary", maxWidth: 300, maxHeight: 300);
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
Assert.Contains("/Items/item-123/Images/Primary", url);
@@ -552,7 +510,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task GetImageAsync_WithTag_IncludesTagInQuery()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
@@ -564,10 +521,8 @@ public class JellyfinProxyServiceTests
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
});
// Act
await _service.GetImageAsync("item-123", "Primary", imageTag: "playlist-art-v2");
// Assert
Assert.NotNull(captured);
var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query);
Assert.Equal("playlist-art-v2", query.Get("tag"));
@@ -578,14 +533,11 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task TestConnectionAsync_ValidServer_ReturnsSuccess()
{
// Arrange
var serverInfo = "{\"ServerName\":\"My Jellyfin\",\"Version\":\"10.8.0\"}";
SetupMockResponse(HttpStatusCode.OK, serverInfo, "application/json");
// Act
var (success, serverName, version) = await _service.TestConnectionAsync();
// Assert
Assert.True(success);
Assert.Equal("My Jellyfin", serverName);
Assert.Equal("10.8.0", version);
@@ -594,17 +546,14 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task TestConnectionAsync_ServerDown_ReturnsFalse()
{
// Arrange
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
// Act
var (success, serverName, version) = await _service.TestConnectionAsync();
// Assert
Assert.False(success);
Assert.Null(serverName);
Assert.Null(version);
@@ -615,7 +564,6 @@ public class JellyfinProxyServiceTests
[Fact]
public async Task StreamAudioAsync_NullContext_ReturnsError()
{
// Arrange
var httpContextAccessor = new HttpContextAccessor { HttpContext = null };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var cache = new DisabledApplicationCache();
@@ -631,10 +579,8 @@ public class JellyfinProxyServiceTests
new Mock<ILogger<MediaAssetResolver>>().Object),
new ConfigurationBuilder().Build());
// Act
var result = await service.StreamAudioAsync("song-123", CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
@@ -660,7 +606,7 @@ public class JellyfinProxyServiceTests
observedBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult();
observedContentType = request.Content.Headers.ContentType?.ToString();
observedIfMatch = request.Headers.GetValues("If-Match").ToArray();
observedAuth = request.Headers.GetValues("X-Emby-Authorization").ToArray();
observedAuth = request.Headers.GetValues("Authorization").ToArray();
observedClientHeader = request.Headers.GetValues("X-Jellyfin-Client-Capability").ToArray();
})
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Accepted)
@@ -683,7 +629,7 @@ public class JellyfinProxyServiceTests
"Items/item-1?api_key=client-token&Fields=Name");
Assert.Equal("PATCH", observedMethod);
Assert.Equal("/Items/item-1?api_key=client-token&Fields=Name", observedPathAndQuery);
Assert.Equal("/Items/item-1?ApiKey=client-token&Fields=Name", observedPathAndQuery);
Assert.Equal("{\"Name\":\"Updated\"}", observedBody);
Assert.Equal("application/merge-patch+json", observedContentType);
Assert.Equal(["\"one\"", "\"two\""], observedIfMatch);
@@ -692,6 +638,15 @@ public class JellyfinProxyServiceTests
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
[Theory]
[InlineData("http://jellyfin/Items/1?api_key=legacy&Fields=Name", "http://jellyfin/Items/1?ApiKey=legacy&Fields=Name")]
[InlineData("http://jellyfin/Items/1?access_token=legacy", "http://jellyfin/Items/1?ApiKey=legacy")]
[InlineData("http://jellyfin/Items/1?api_key=old&ApiKey=modern", "http://jellyfin/Items/1?ApiKey=modern")]
public void NormalizeQueryCredentials_UpgradesLegacyJellyfinTokens(string input, string expected)
{
Assert.Equal(expected, JellyfinProxyService.NormalizeQueryCredentials(input));
}
[Fact]
public async Task SendPassthroughResponseAsync_ForwardsDetectedBodyWithoutLength()
{
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
using allstarr.Models.Search;
using allstarr.Models.Domain;
using allstarr.Models.Subsonic;
using allstarr.Core.Capabilities;
using allstarr.Core.Protocols.Subsonic;
using allstarr.Core.Protocols;
using allstarr.Core.Protocols.Jellyfin;
@@ -162,7 +163,7 @@ public sealed class ProtocolRouteFixtureTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(
["/Users/Me?api_key=fixture-key", "/System/Info?api_key=fixture-key"],
["/Users/Me?ApiKey=fixture-key", "/System/Info?ApiKey=fixture-key"],
observedRequests);
Assert.Equal(
JsonDocument.Parse(systemInfo).RootElement.GetRawText(),
@@ -200,7 +201,7 @@ public sealed class ProtocolRouteFixtureTests
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("/Users/Me?ApiKey=fixture-key", observedRequests[0]);
Assert.Equal($"/Items/{artistId}", observedRequests[1]);
}
@@ -420,7 +421,7 @@ public sealed class ProtocolRouteFixtureTests
expected.GetProperty("body").GetRawText(),
JsonDocument.Parse(body).RootElement.GetRawText());
Assert.Equal(
[verification.GetProperty("pathAndQuery").GetString()!],
[ModernJellyfinPath(verification.GetProperty("pathAndQuery").GetString()!)],
observedRequests);
metadata.VerifyNoOtherCalls();
downloads.VerifyNoOtherCalls();
@@ -448,8 +449,8 @@ public sealed class ProtocolRouteFixtureTests
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"
"/Users/Me?ApiKey=fixture-valid-key",
"/Items?IncludeItemTypes=Audio&ApiKey=fixture-valid-key&Fields=MediaSources"
],
observedRequests);
}
@@ -472,9 +473,9 @@ public sealed class ProtocolRouteFixtureTests
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"
"/Users/Me?ApiKey=fixture-key",
"/Users/user-1?ApiKey=fixture-key",
"/Users/user-1?ApiKey=fixture-key"
],
observedRequests);
}
@@ -527,10 +528,50 @@ public sealed class ProtocolRouteFixtureTests
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"],
[
"/Users/Me?ApiKey=fixture-key",
"/UserViews?ApiKey=fixture-key&UserId=user-1",
"/Items/music-1?ApiKey=fixture-key&UserId=user-1"
],
observedRequests);
}
[Fact]
public async Task JellyfinMusicRoot_UsesLibraryVisibleToCaller()
{
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.Status200OK, """{"Id":"user-1"}"""),
"/UserViews" => Json(StatusCodes.Status200OK,
"""{"Items":[{"Id":"visible-music","CollectionType":"music"}]}"""),
"/Items/visible-music" => Json(StatusCodes.Status200OK,
"""{"Id":"visible-music","Type":"CollectionFolder","CollectionType":"music"}"""),
_ => throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}")
};
},
configuration: new Dictionary<string, string?>
{
["Jellyfin:LibraryId"] = "global-music",
["Jellyfin:UserId"] = "admin-user"
});
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("visible-music", body.RootElement.GetProperty("Id").GetString());
Assert.Contains("/UserViews?ApiKey=fixture-key&UserId=user-1", observedRequests);
Assert.Contains("/Items/visible-music?ApiKey=fixture-key&UserId=user-1", observedRequests);
Assert.DoesNotContain(observedRequests, request => request.Contains("global-music", StringComparison.Ordinal));
}
[Theory]
[InlineData("Download")]
[InlineData("File")]
@@ -770,11 +811,43 @@ public sealed class ProtocolRouteFixtureTests
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 == "/Users/Me?ApiKey=fixture-key");
Assert.Contains(observedPaths, path => path == "/image/thumb/library/1024x1024bb.jpg");
gateway.VerifyAll();
}
[Fact]
public async Task JellyfinSpotifyPlaylistImage_UsesAccountScopedTypedArtwork()
{
var artworkBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
gateway.Setup(service => service.ResolvePlaylistArtworkAsync(
It.Is<ProtocolExecutionContext>(context => context.Protocol == ProtocolKind.Jellyfin),
"spotify",
"playlist-1",
10 * 1024 * 1024))
.ReturnsAsync(new ProviderPlaylistArtwork(artworkBytes, "image/jpeg"));
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/ext-spotify-playlist-playlist-1/Images/Primary?api_key=fixture-key");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType);
Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync());
gateway.VerifyAll();
}
[Fact]
public async Task JellyfinSynthesizedLongImageRoute_HonorsSizeAndFormat()
{
@@ -1019,8 +1092,9 @@ public sealed class ProtocolRouteFixtureTests
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);
var hasPlayerToken = request.Headers.TryGetValues("Authorization", out var values) &&
values.Any(value => value.Contains(
"Token=\"fixture-player-token\"", StringComparison.Ordinal));
if (request.RequestUri!.AbsolutePath == "/Users/Me")
{
playerTokenReachedVerification = hasPlayerToken;
@@ -1327,8 +1401,8 @@ public sealed class ProtocolRouteFixtureTests
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 };
? new[] { "/Items?ids=item-1&limit=1", "/Users/Me?ApiKey=fixture-key", ModernJellyfinPath(requestPath) }
: new[] { "/Users/Me?ApiKey=fixture-key", ModernJellyfinPath(requestPath) };
Assert.Equal(expectedPaths, observedPaths);
}
@@ -2137,6 +2211,49 @@ public sealed class ProtocolRouteFixtureTests
metadata.VerifyNoOtherCalls();
}
[Fact]
public async Task SubsonicSpotifyPlaylistCover_UsesAccountScopedTypedArtwork()
{
var artworkBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
gateway.Setup(service => service.GetPlaylistAsync(
It.Is<ProtocolExecutionContext>(context => context.Protocol == ProtocolKind.Subsonic),
"spotify",
"playlist-1"))
.ReturnsAsync(new ExternalPlaylist
{
Id = "ext-spotify-playlist-playlist-1",
Provider = "spotify",
ExternalId = "playlist-1",
Name = "Road mix"
});
gateway.Setup(service => service.ResolvePlaylistArtworkAsync(
It.Is<ProtocolExecutionContext>(context => context.Protocol == ProtocolKind.Subsonic),
"spotify",
"playlist-1",
10 * 1024 * 1024))
.ReturnsAsync(new ProviderPlaylistArtwork(artworkBytes, "image/jpeg"));
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);
});
using var client = factory.CreateClient();
using var response = await client.GetAsync(
"/rest/getCoverArt.view?u=fixture&p=secret&v=1.16.1&c=fixture&id=ext-spotify-playlist-playlist-1");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType);
Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync());
gateway.VerifyAll();
}
[Fact]
public async Task SubsonicAuthBoundary_RejectsBeforeBackendActionsAndPreservesVerificationResponse()
{
@@ -2684,13 +2801,13 @@ public sealed class ProtocolRouteFixtureTests
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"));
Assert.Equal(3, observed.Count(path => path == "/Users/Me?ApiKey=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("ApiKey=fixture-key", hydration, StringComparison.Ordinal);
Assert.Contains("MediaSources", Uri.UnescapeDataString(hydration), StringComparison.Ordinal);
virtualization.VerifyAll();
}
@@ -2747,7 +2864,7 @@ public sealed class ProtocolRouteFixtureTests
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(ModernJellyfinPath(expectedPath), observed[1].PathAndQuery);
Assert.Equal(body ?? (method is "GET" or "HEAD" ? null : string.Empty), observed[1].Body);
}
@@ -2780,7 +2897,7 @@ public sealed class ProtocolRouteFixtureTests
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Single(observed);
Assert.Equal("/Users/Me?api_key=fixture-key", observed[0].PathAndQuery);
Assert.Equal("/Users/Me?ApiKey=fixture-key", observed[0].PathAndQuery);
}
[Theory]
@@ -2828,9 +2945,9 @@ public sealed class ProtocolRouteFixtureTests
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("/Users/Me?ApiKey=fixture-key", observed[1].PathAndQuery);
Assert.Equal(method, observed[2].Method);
Assert.Equal($"/Items/{itemId}?api_key=fixture-key", observed[2].PathAndQuery);
Assert.Equal($"/Items/{itemId}?ApiKey=fixture-key", observed[2].PathAndQuery);
if (method == "POST")
Assert.Equal(
$$"""{"Id":"{{itemId}}","Name":"Playlist","Type":"Playlist"}""",
@@ -2848,7 +2965,7 @@ public sealed class ProtocolRouteFixtureTests
if (request.RequestUri!.AbsolutePath == "/Items")
{
var hasClientToken = request.Headers.TryGetValues(
"X-Emby-Authorization", out var values) &&
"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}"""
@@ -3507,15 +3624,15 @@ public sealed class ProtocolRouteFixtureTests
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(ModernJellyfinPath(fixture.GetProperty("verificationPath").GetString()!), observedRequests[1].PathAndQuery);
Assert.Equal(
fixture.GetProperty("verificationFallbackPath").GetString(),
ModernJellyfinPath(fixture.GetProperty("verificationFallbackPath").GetString()!),
observedRequests[2].PathAndQuery);
}
else
{
Assert.Equal(
fixture.GetProperty("verificationPath").GetString(),
ModernJellyfinPath(fixture.GetProperty("verificationPath").GetString()!),
observedRequests[streamIndex - 1].PathAndQuery);
}
Assert.Equal(
@@ -3523,7 +3640,7 @@ public sealed class ProtocolRouteFixtureTests
? upstreamMethod.GetString()
: fixture.GetProperty("method").GetString(),
observedRequests[streamIndex].Method);
Assert.Equal(fixture.GetProperty("streamPath").GetString(), observedRequests[streamIndex].PathAndQuery);
Assert.Equal(ModernJellyfinPath(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);
}
@@ -3616,11 +3733,11 @@ public sealed class ProtocolRouteFixtureTests
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(ModernJellyfinPath(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);
Assert.Equal(ModernJellyfinPath(fixture.GetProperty("upstreamPath").GetString()!), observed[1].PathAndQuery);
var requestFixture = fixture.GetProperty("request");
var requestMethod = requestFixture.GetProperty("method").GetString();
Assert.Equal(
@@ -3787,7 +3904,7 @@ public sealed class ProtocolRouteFixtureTests
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(ModernJellyfinPath(expected.GetProperty("pathAndQuery").GetString()!), actual.PathAndQuery);
Assert.Equal(
expected.TryGetProperty("body", out var body)
? body.GetString()
@@ -3797,6 +3914,10 @@ public sealed class ProtocolRouteFixtureTests
actual.Body);
}
private static string ModernJellyfinPath(string path) =>
path.Replace("api_key=", "ApiKey=", StringComparison.Ordinal)
.Replace("access_token=", "ApiKey=", StringComparison.Ordinal);
private static HttpResponseMessage FixtureResponse(JsonElement fixture)
{
var content = fixture.TryGetProperty("responseBody", out var body)
@@ -3,6 +3,7 @@ using allstarr.Core.Protocols;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using allstarr.Core.Capabilities;
using allstarr.Services.Jellyfin;
namespace allstarr.Controllers;
@@ -10,9 +11,6 @@ public partial class JellyfinController
{
#region Audio Streaming
/// <summary>
/// Downloads/streams audio. Works with local and external content.
/// </summary>
[HttpGet("Items/{itemId}/Download")]
[HttpGet("Items/{itemId}/File")]
[HttpHead("Items/{itemId}/Download")]
@@ -28,7 +26,6 @@ public partial class JellyfinController
if (!isExternal)
{
// Build path for Jellyfin download/file endpoint
var endpoint = Request.Path.Value?.Contains("/File", StringComparison.OrdinalIgnoreCase) == true
? "File"
: "Download";
@@ -41,13 +38,9 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!, asDownload: true);
}
/// <summary>
/// Streams audio for a given item. Downloads on-demand for external content.
/// </summary>
[HttpGet("Audio/{itemId}/stream")]
[HttpGet("Audio/{itemId}/stream.{container}")]
[HttpHead("Audio/{itemId}/stream")]
@@ -63,7 +56,6 @@ public partial class JellyfinController
if (!isExternal)
{
// Build path for Jellyfin stream
var fullPath = string.IsNullOrEmpty(container)
? $"Audio/{itemId}/stream"
: $"Audio/{itemId}/stream.{container}";
@@ -76,17 +68,14 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content with quality override from client transcoding params
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
return await StreamExternalContent(provider!, externalId!, quality);
}
/// <summary>
/// Proxies a stream from Jellyfin with proper header forwarding.
/// </summary>
private async Task<IActionResult> ProxyJellyfinStream(string path, string itemId)
{
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
var jellyfinUrl = JellyfinProxyService.NormalizeQueryCredentials(
$"{_settings.Url?.TrimEnd('/')}/{path}");
try
{
@@ -94,7 +83,6 @@ public partial class JellyfinController
HttpMethods.IsHead(Request.Method) ? HttpMethod.Head : HttpMethod.Get,
jellyfinUrl);
// Forward auth headers
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
_streamingResponseAdapter.ForwardRangeRequestHeaders(Request.Headers, request);
@@ -127,10 +115,6 @@ public partial class JellyfinController
}
}
/// <summary>
/// Streams external content, using cache if available or downloading on-demand.
/// Supports quality override for client-requested "transcoding" of external tracks.
/// </summary>
private async Task<IActionResult> StreamExternalContent(
string provider,
string externalId,
@@ -144,7 +128,7 @@ public partial class JellyfinController
if (localPath != null && System.IO.File.Exists(localPath))
{
// Update last write time for cache cleanup (extends cache lifetime)
// A cache hit renews the artifact's cleanup lease.
try
{
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
@@ -210,7 +194,6 @@ public partial class JellyfinController
}
}
// Download and stream on-demand
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(
@@ -315,10 +298,7 @@ public partial class JellyfinController
return (StatusCodes.Status502BadGateway, "External stream failed");
}
/// <summary>
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
/// </summary>
// Jellyfin Web and most clients negotiate adaptive playback through this route.
[HttpGet("Audio/{itemId}/universal")]
[HttpHead("Audio/{itemId}/universal")]
public async Task<IActionResult> UniversalAudio(string itemId)
@@ -332,7 +312,6 @@ public partial class JellyfinController
if (!isExternal)
{
// For local content, proxy the universal endpoint with all query parameters
var fullPath = $"Audio/{itemId}/universal";
if (Request.QueryString.HasValue)
{
@@ -342,7 +321,6 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, parse quality override from client transcoding params
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
return await StreamExternalContent(provider!, externalId!, quality);
}
@@ -9,17 +9,12 @@ namespace allstarr.Controllers;
public partial class JellyfinController
{
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
{
return ProxyResponseResultFactory.Create(result, statusCode, fallbackValue);
}
/// <summary>
/// Records retention-bounded endpoint usage without query strings.
/// </summary>
// Persist bounded route usage only; query strings may contain secrets.
private async Task LogEndpointUsageAsync(string path, string method)
{
if (HttpContext.RequestServices.GetRequiredService<IHostEnvironment>()
@@ -39,7 +34,7 @@ public partial class JellyfinController
}
catch (Exception ex)
{
// Don't let logging failures break the request
// Observability must not break the protocol request.
_logger.LogWarning(ex, "Failed to record endpoint usage");
}
}
@@ -59,7 +54,8 @@ public partial class JellyfinController
{
var key = kv.Key;
var value = kv.Value.ToString();
if (string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
if (string.Equals(key, "ApiKey", StringComparison.Ordinal) ||
string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase) ||
@@ -139,11 +135,7 @@ public partial class JellyfinController
return null;
}
/// <summary>
/// Determines whether Spotify playlist count enrichment should run for a response.
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
/// (for example, album browse responses requested by clients like Finer).
/// </summary>
// Finer requests unrelated item lists through this path; mutate playlist payloads only.
private bool ShouldProcessSpotifyPlaylistCounts(JsonDocument response, string? includeItemTypes)
{
if (!_spotifySettings.Enabled)
@@ -164,7 +156,6 @@ public partial class JellyfinController
return requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase);
}
// If the request did not explicitly constrain types, inspect payload types.
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("Type", out var typeProp))
@@ -181,10 +172,7 @@ public partial class JellyfinController
return false;
}
/// <summary>
/// Recovers SearchTerm directly from raw query string.
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
/// </summary>
// Some clients leave '&' unescaped inside SearchTerm, defeating model binding.
internal static string? RecoverSearchTermFromRawQuery(string? rawQueryString)
{
if (string.IsNullOrWhiteSpace(rawQueryString))
@@ -240,9 +228,6 @@ public partial class JellyfinController
return Uri.UnescapeDataString(plusAsSpace);
}
/// <summary>
/// Uses model-bound SearchTerm when valid; falls back to raw query recovery when needed.
/// </summary>
internal static string? GetEffectiveSearchTerm(string? boundSearchTerm, string? rawQueryString)
{
var recovered = RecoverSearchTermFromRawQuery(rawQueryString);
@@ -256,7 +241,6 @@ public partial class JellyfinController
return recovered;
}
// Prefer recovered when it is meaningfully longer (common malformed '&' case).
var boundTrimmed = boundSearchTerm.Trim();
var recoveredTrimmed = recovered.Trim();
return recoveredTrimmed.Length > boundTrimmed.Length
@@ -279,11 +263,6 @@ public partial class JellyfinController
};
}
/// <summary>
/// Scores search results based on fuzzy matching against the query.
/// Returns items with their relevance scores.
/// External results get a small boost to prioritize the larger catalog.
/// </summary>
private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query,
List<T> items,
@@ -298,7 +277,6 @@ public partial class JellyfinController
var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Token-based fuzzy matching: split query and fields into words
var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
@@ -310,29 +288,23 @@ public partial class JellyfinController
if (queryTokens.Count == 0) return (item, 0);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
return similarity >= 70;
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore);
+33 -113
View File
@@ -1,12 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace allstarr.Services.Common;
/// <summary>
/// Utility class for handling Jellyfin/Emby authentication headers.
/// Centralizes logic for extracting and forwarding authentication headers.
/// </summary>
public static class AuthHeaderHelper
{
public static bool HasAuthentication(IHeaderDictionary headers) =>
@@ -14,95 +9,37 @@ public static class AuthHeaderHelper
headers.ContainsKey("X-Emby-Token") ||
headers.ContainsKey("Authorization");
/// <summary>
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
/// Handles both X-Emby-Authorization and Authorization headers.
/// </summary>
/// <param name="sourceHeaders">Source headers (from HttpRequest or IHeaderDictionary)</param>
/// <param name="targetRequest">Target HttpRequestMessage</param>
/// <returns>True if auth header was added, false otherwise</returns>
public static bool ForwardAuthHeaders(IHeaderDictionary sourceHeaders, HttpRequestMessage targetRequest)
{
// Try X-Emby-Authorization first (case-insensitive)
foreach (var header in sourceHeaders)
if (sourceHeaders.TryGetValue("Authorization", out var authorization))
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
return true;
}
targetRequest.Headers.TryAddWithoutValidation("Authorization", authorization.ToString());
return true;
}
// Some Jellyfin clients send the raw token separately instead of a MediaBrowser auth header.
foreach (var header in sourceHeaders)
if (sourceHeaders.TryGetValue("X-Emby-Authorization", out var mediaBrowser))
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
return true;
}
targetRequest.Headers.TryAddWithoutValidation("Authorization", mediaBrowser.ToString());
return true;
}
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
foreach (var header in sourceHeaders)
if (sourceHeaders.TryGetValue("X-Emby-Token", out var directToken))
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
var token = directToken.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
if (!string.IsNullOrWhiteSpace(token))
{
var headerValue = header.Value.ToString();
// Check if it's a MediaBrowser/Jellyfin auth header
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
{
// Forward both forms. Some native players put the token only in
// Authorization, while Jellyfin's Users/Me route is most reliable
// when the token is also supplied through X-Emby-Token.
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
if (ExtractTokenFromAuthorizationValue(headerValue) is { Length: > 0 } token)
{
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Token", token);
}
return true;
}
else
{
// Standard Bearer token
targetRequest.Headers.TryAddWithoutValidation("Authorization", headerValue);
return true;
}
targetRequest.Headers.TryAddWithoutValidation(
"Authorization",
CreateAuthHeader(token, "Allstarr", "Proxy", "allstarr-proxy", "1"));
return true;
}
}
return false;
}
/// <summary>
/// Extracts device ID from X-Emby-Authorization header.
/// </summary>
/// <param name="headers">Request headers</param>
/// <returns>Device ID if found, null otherwise</returns>
public static string? ExtractDeviceId(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var authValue = authHeader.ToString();
return ExtractDeviceIdFromAuthString(authValue);
}
if (headers.TryGetValue("Authorization", out var authHeader2))
{
var authValue = authHeader2.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
return ExtractDeviceIdFromAuthString(authValue);
}
}
return null;
}
public static string? ExtractDeviceId(IHeaderDictionary headers) =>
MediaBrowserAuthorization(headers) is { } value ? ExtractDeviceIdFromAuthString(value) : null;
public static string? ExtractUserId(IHeaderDictionary headers)
{
@@ -122,10 +59,6 @@ public static class AuthHeaderHelper
return null;
}
/// <summary>
/// Extracts device ID from MediaBrowser auth string.
/// Format: MediaBrowser Client="...", Device="...", DeviceId="...", Version="...", Token="..."
/// </summary>
private static string? ExtractDeviceIdFromAuthString(string authValue)
{
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
@@ -141,32 +74,9 @@ public static class AuthHeaderHelper
return null;
}
/// <summary>
/// Extracts client name from MediaBrowser auth string.
/// </summary>
public static string? ExtractClientName(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var authValue = authHeader.ToString();
return ExtractClientNameFromAuthString(authValue);
}
public static string? ExtractClientName(IHeaderDictionary headers) =>
MediaBrowserAuthorization(headers) is { } value ? ExtractClientNameFromAuthString(value) : null;
if (headers.TryGetValue("Authorization", out var authHeader2))
{
var authValue = authHeader2.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
return ExtractClientNameFromAuthString(authValue);
}
}
return null;
}
/// <summary>
/// Extracts the Jellyfin access token regardless of which supported client header carried it.
/// </summary>
public static string? ExtractToken(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Token", out var directToken))
@@ -209,9 +119,6 @@ public static class AuthHeaderHelper
: null;
}
/// <summary>
/// Extracts client name from MediaBrowser auth string.
/// </summary>
private static string? ExtractClientNameFromAuthString(string authValue)
{
var clientMatch = System.Text.RegularExpressions.Regex.Match(
@@ -227,9 +134,6 @@ public static class AuthHeaderHelper
return null;
}
/// <summary>
/// Creates a MediaBrowser auth header string.
/// </summary>
public static string CreateAuthHeader(string token, string? client = null, string? device = null, string? deviceId = null, string? version = null)
{
var parts = new List<string>();
@@ -250,4 +154,20 @@ public static class AuthHeaderHelper
return $"MediaBrowser {string.Join(", ", parts)}";
}
private static string? MediaBrowserAuthorization(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Authorization", out var native))
{
return native.ToString();
}
if (headers.TryGetValue("Authorization", out var authorization) &&
authorization.ToString().Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
return authorization.ToString();
}
return null;
}
}
@@ -9,14 +9,12 @@ using Microsoft.Extensions.Options;
namespace allstarr.Services.Deezer;
/// <summary>
/// Metadata service implementation using the Deezer API (free, no key required)
/// </summary>
public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
{
public string ProviderId => "deezer";
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
private readonly GenreEnrichmentService? _genreEnrichment;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly int _minRequestIntervalMs;
private DateTime _lastRequestTime = DateTime.MinValue;
@@ -27,12 +25,10 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
public DeezerMetadataService(
IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> settings,
GenreEnrichmentService? genreEnrichment = null,
IOptions<DeezerSettings>? deezerSettings = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_genreEnrichment = genreEnrichment;
_minRequestIntervalMs = Math.Max(
0,
deezerSettings?.Value.MinRequestIntervalMs ?? new DeezerSettings().MinRequestIntervalMs);
@@ -399,23 +395,6 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
}
}
// Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
// Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
catch
{
// Silently ignore genre enrichment failures
}
});
}
return song;
}
@@ -244,7 +244,14 @@ public sealed class JellyfinPlaybackMetadataResolver : IPlaybackMetadataResolver
private HttpRequestMessage CreateRequest(Uri uri, string accept)
{
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.TryAddWithoutValidation("X-Emby-Token", _settings.ApiKey);
request.Headers.TryAddWithoutValidation(
"Authorization",
AuthHeaderHelper.CreateAuthHeader(
_settings.ApiKey!,
_settings.ClientName,
_settings.DeviceName,
_settings.DeviceId,
_settings.ClientVersion));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
return request;
}
@@ -10,17 +10,8 @@ using System.Text.Json;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Handles proxying requests to the Jellyfin server and authentication.
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
/// TCP connection pooling across scoped instances.
/// </summary>
public class JellyfinProxyService
{
/// <summary>
/// The IHttpClientFactory registration name for the Jellyfin backend client.
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
/// </summary>
public const string HttpClientName = "JellyfinBackend";
private readonly HttpClient _httpClient;
@@ -33,7 +24,6 @@ public class JellyfinProxyService
private string? _cachedMusicLibraryId;
private bool _libraryIdDetected = false;
// Expose HttpClient for direct streaming scenarios
public HttpClient HttpClient => _httpClient;
public JellyfinProxyService(
@@ -54,24 +44,18 @@ public class JellyfinProxyService
_configuration = configuration;
}
/// <summary>
/// Gets the music library ID, auto-detecting it if not configured.
/// </summary>
private async Task<string?> GetMusicLibraryIdAsync()
{
// Return configured library ID if set
if (!string.IsNullOrEmpty(_settings.LibraryId))
{
return _settings.LibraryId;
}
// Return cached value if already detected
if (_libraryIdDetected)
{
return _cachedMusicLibraryId;
}
// Auto-detect music library ID
try
{
_logger.LogInformation("Auto-detecting music library ID...");
@@ -92,22 +76,28 @@ public class JellyfinProxyService
catch (Exception ex)
{
_logger.LogError(ex, "Failed to auto-detect music library ID");
_libraryIdDetected = true; // Don't keep trying
_libraryIdDetected = true;
return null;
}
}
/// <summary>
/// Public method for controllers to get the music library ID for filtering.
/// </summary>
public async Task<string?> GetMusicLibraryIdForFilteringAsync()
public async Task<string?> GetMusicLibraryIdForFilteringAsync(
string? callerQuery = null,
IHeaderDictionary? clientHeaders = null)
{
if (!string.IsNullOrWhiteSpace(callerQuery))
{
var (views, _) = await GetJsonAsync($"UserViews{callerQuery}", null, clientHeaders);
using (views)
{
var visibleLibraryId = FindMusicLibraryId(views);
if (!string.IsNullOrWhiteSpace(visibleLibraryId)) return visibleLibraryId;
}
}
return await GetMusicLibraryIdAsync();
}
/// <summary>
/// Gets the authorization header value for Jellyfin API requests.
/// </summary>
private string GetAuthorizationHeader()
{
return $"MediaBrowser Client=\"{_settings.ClientName}\", " +
@@ -117,14 +107,8 @@ public class JellyfinProxyService
$"Token=\"{_settings.ApiKey}\"";
}
/// <summary>
/// Sends a GET request to the Jellyfin server.
/// If endpoint already contains query parameters, they will be preserved and merged with queryParams.
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null)
{
// If endpoint contains query string, parse and merge with queryParams
if (endpoint.Contains('?'))
{
var parts = endpoint.Split('?', 2);
@@ -160,7 +144,7 @@ public class JellyfinProxyService
var mergedQuery = string.Join("&", preservedParams.Concat(explicitParams));
var url = string.IsNullOrEmpty(mergedQuery)
? BuildUrl(baseEndpoint)
: $"{BuildUrl(baseEndpoint)}?{mergedQuery}";
: NormalizeQueryCredentials($"{BuildUrl(baseEndpoint)}?{mergedQuery}");
return await GetJsonAsyncInternal(url, clientHeaders);
}
@@ -169,9 +153,7 @@ public class JellyfinProxyService
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
}
/// <summary>
/// Relays an unhandled client request without assuming a JSON body or replacing client authentication.
/// </summary>
// Catch-all relays must preserve arbitrary bodies and client-owned authentication.
public async Task<HttpResponseMessage> SendPassthroughResponseAsync(
HttpRequest incoming,
string endpoint,
@@ -238,8 +220,7 @@ public class JellyfinProxyService
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate
// Preserve JSON error bodies so clients can respond to authentication failures.
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
@@ -249,7 +230,6 @@ public class JellyfinProxyService
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
// Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
{
try
@@ -259,7 +239,6 @@ public class JellyfinProxyService
}
catch
{
// Not valid JSON, return null
}
}
@@ -277,7 +256,6 @@ public class JellyfinProxyService
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
@@ -288,7 +266,6 @@ public class JellyfinProxyService
}
}
// Check if this is a browser request for static assets (favicon, etc.)
isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
@@ -297,14 +274,12 @@ public class JellyfinProxyService
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Check if this is a public endpoint that doesn't require authentication
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
var authHeaderAdded = false;
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
{
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
@@ -314,15 +289,14 @@ public class JellyfinProxyService
_logger.LogTrace("Forwarded authentication headers");
}
// Check for api_key query parameter (some clients use this)
// Some Jellyfin clients authenticate through the query string.
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
{
authHeaderAdded = true; // It's in the URL, no need to add header
authHeaderAdded = true;
_logger.LogTrace("Using api_key from query string");
}
}
// Only log warnings for non-public, non-browser requests without auth
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogDebug(
@@ -371,11 +345,6 @@ public class JellyfinProxyService
method != HttpMethod.Get &&
method != HttpMethod.Head;
/// <summary>
/// Sends a POST request to the Jellyfin server with JSON body.
/// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
{
var bodyToSend = body;
@@ -388,10 +357,6 @@ public class JellyfinProxyService
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
}
/// <summary>
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
/// Intended for transparent proxy scenarios such as session control routes.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
HttpMethod method,
string endpoint,
@@ -404,7 +369,6 @@ public class JellyfinProxyService
using var request = new HttpRequestMessage(method, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
@@ -482,7 +446,6 @@ public class JellyfinProxyService
}
catch
{
// Not valid JSON, return null
}
}
@@ -508,10 +471,7 @@ public class JellyfinProxyService
return (JsonDocument.Parse(responseContent), statusCode);
}
/// <summary>
/// Sends a GET request and returns raw bytes (for images, audio streams).
/// WARNING: This loads the entire response into memory and is intended only for bounded assets.
/// </summary>
// Buffers the full response; callers must restrict this to bounded assets.
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
@@ -530,19 +490,11 @@ public class JellyfinProxyService
return (body, contentType);
}
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
}
/// <summary>
/// Safely sends a GET request to the Jellyfin server, returning null on failure.
/// </summary>
public async Task<(byte[]? Body, string? ContentType, bool Success)> GetBytesSafeAsync(
string endpoint,
Dictionary<string, string>? queryParams = null,
@@ -568,16 +520,12 @@ public class JellyfinProxyService
}
catch (Exception ex)
{
// Actual errors should still be logged
_logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint);
return (null, null, false);
}
}
/// <summary>
/// Reads only the first bounded range of a media stream. Diagnostics use this to
/// prove that an authenticated player can receive audio without downloading a song.
/// </summary>
// Diagnostics prove authenticated playback with a bounded range, not a full download.
public async Task<(int StatusCode, int BytesRead, string? ContentType, bool Success)> ProbeAudioStreamAsync(
string itemId,
IHeaderDictionary clientHeaders,
@@ -625,10 +573,6 @@ public class JellyfinProxyService
}
}
/// <summary>
/// Searches for items in Jellyfin.
/// Does not force any library filtering - clients can specify parentId if they want.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync(
string searchTerm,
string[]? includeItemTypes = null,
@@ -645,8 +589,7 @@ public class JellyfinProxyService
};
AddEffectiveUserId(queryParams, clientHeaders);
// Note: We don't force parentId here - let clients specify which library to search
// The controller will detect music library searches and add external results
// The controller, not this transparent backend query, decides when to merge providers.
if (includeItemTypes != null && includeItemTypes.Length > 0)
{
@@ -672,9 +615,6 @@ public class JellyfinProxyService
return (body, statusCode);
}
/// <summary>
/// Gets items from a specific parent (album, artist, playlist).
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetItemsAsync(
string? parentId = null,
string[]? includeItemTypes = null,
@@ -724,9 +664,6 @@ public class JellyfinProxyService
return await GetJsonAsync("Items", queryParams, clientHeaders);
}
/// <summary>
/// Gets a single item by ID.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>
@@ -760,9 +697,6 @@ public class JellyfinProxyService
}
}
/// <summary>
/// Gets artists from the library.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetArtistsAsync(
string? searchTerm = null,
int? limit = null,
@@ -793,21 +727,16 @@ public class JellyfinProxyService
return await GetJsonAsync("Artists", queryParams, clientHeaders);
}
/// <summary>
/// Gets an artist by name or ID.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>();
AddEffectiveUserId(queryParams, clientHeaders);
// Try to get by ID first
if (Guid.TryParse(artistIdOrName, out _))
{
return await GetJsonAsync($"Items/{artistIdOrName}", queryParams, clientHeaders);
}
// Otherwise search by name
return await GetJsonAsync($"Artists/{Uri.EscapeDataString(artistIdOrName)}", queryParams, clientHeaders);
}
@@ -829,9 +758,6 @@ public class JellyfinProxyService
}
}
/// <summary>
/// Streams audio from Jellyfin with range support.
/// </summary>
public async Task<IActionResult> StreamAudioAsync(
string itemId,
CancellationToken cancellationToken)
@@ -850,7 +776,6 @@ public class JellyfinProxyService
var incomingRequest = httpContext.Request;
var outgoingResponse = httpContext.Response;
// Build the stream URL - use static streaming for simplicity
var queryParams = new Dictionary<string, string>
{
["static"] = "true",
@@ -864,7 +789,6 @@ public class JellyfinProxyService
LogOutboundRequest(HttpMethod.Get, url);
// Forward Range headers for progressive streaming
if (incomingRequest.Headers.TryGetValue("Range", out var range))
{
request.Headers.TryAddWithoutValidation("Range", range.ToArray());
@@ -885,10 +809,8 @@ public class JellyfinProxyService
return new StatusCodeResult((int)response.StatusCode);
}
// Forward HTTP status code
outgoingResponse.StatusCode = (int)response.StatusCode;
// Forward streaming headers
var streamingHeaders = new[] { "Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Last-Modified" };
foreach (var header in streamingHeaders)
{
@@ -917,9 +839,6 @@ public class JellyfinProxyService
}
}
/// <summary>
/// Gets the image for an item.
/// </summary>
public async Task<(byte[]? Body, string? ContentType)> GetImageAsync(
string itemId,
string imageType = "Primary",
@@ -972,9 +891,6 @@ public class JellyfinProxyService
return (asset?.Bytes, asset?.ContentType);
}
/// <summary>
/// Tests connection to the Jellyfin server.
/// </summary>
public async Task<(bool Success, string? ServerName, string? Version)> TestConnectionAsync()
{
try
@@ -1001,9 +917,6 @@ public class JellyfinProxyService
}
}
/// <summary>
/// Gets the music library ID from Jellyfin by querying media folders.
/// </summary>
private async Task<string?> GetMusicLibraryIdInternalAsync()
{
try
@@ -1020,24 +933,7 @@ public class JellyfinProxyService
return null;
}
if (result.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var collectionType = item.TryGetProperty("CollectionType", out var ct)
? ct.GetString()
: null;
if (collectionType == "music")
{
return item.TryGetProperty("Id", out var id)
? id.GetString()
: null;
}
}
}
return null;
using (result) return FindMusicLibraryId(result);
}
catch (Exception ex)
{
@@ -1046,6 +942,24 @@ public class JellyfinProxyService
}
}
private static string? FindMusicLibraryId(JsonDocument? document)
{
if (document == null ||
!document.RootElement.TryGetProperty("Items", out var items) ||
items.ValueKind != JsonValueKind.Array)
return null;
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("CollectionType", out var collectionType) &&
string.Equals(collectionType.GetString(), "music", StringComparison.OrdinalIgnoreCase) &&
item.TryGetProperty("Id", out var id))
return id.GetString();
}
return null;
}
private string BuildUrl(string endpoint, Dictionary<string, string>? queryParams = null)
{
var baseUrl = _settings.Url?.TrimEnd('/') ?? "";
@@ -1058,7 +972,55 @@ public class JellyfinProxyService
url = $"{url}?{query}";
}
return url;
return NormalizeQueryCredentials(url);
}
internal static string NormalizeQueryCredentials(string url)
{
var queryStart = url.IndexOf('?');
if (queryStart < 0) return url;
var fragmentStart = url.IndexOf('#', queryStart);
var queryEnd = fragmentStart >= 0 ? fragmentStart : url.Length;
var query = url[(queryStart + 1)..queryEnd];
var parameters = query.Split('&', StringSplitOptions.RemoveEmptyEntries);
var selectedCredentialIndex = Array.FindIndex(parameters, parameter =>
Uri.UnescapeDataString(parameter.Split('=', 2)[0]).Equals("ApiKey", StringComparison.Ordinal));
if (selectedCredentialIndex < 0)
{
selectedCredentialIndex = Array.FindIndex(parameters, parameter =>
{
var key = Uri.UnescapeDataString(parameter.Split('=', 2)[0]);
return key.Equals("api_key", StringComparison.OrdinalIgnoreCase) ||
key.Equals("access_token", StringComparison.OrdinalIgnoreCase);
});
}
var preserved = new List<string>(parameters.Length);
for (var index = 0; index < parameters.Length; index++)
{
var parameter = parameters[index];
var keyValue = parameter.Split('=', 2);
var key = Uri.UnescapeDataString(keyValue[0]);
if (key.Equals("ApiKey", StringComparison.Ordinal) ||
key.Equals("api_key", StringComparison.OrdinalIgnoreCase) ||
key.Equals("access_token", StringComparison.OrdinalIgnoreCase))
{
if (index == selectedCredentialIndex)
{
preserved.Add($"ApiKey={(keyValue.Length == 2 ? keyValue[1] : string.Empty)}");
}
continue;
}
preserved.Add(parameter);
}
var normalizedQuery = string.Join('&', preserved);
var fragment = fragmentStart >= 0 ? url[fragmentStart..] : string.Empty;
return normalizedQuery.Length == 0
? $"{url[..queryStart]}{fragment}"
: $"{url[..queryStart]}?{normalizedQuery}{fragment}";
}
private void LogOutboundRequest(HttpMethod method, string url)
@@ -1103,7 +1065,8 @@ public class JellyfinProxyService
private static bool IsSensitiveQueryKey(string key)
{
return string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
return string.Equals(key, "ApiKey", StringComparison.Ordinal) ||
string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase) ||
@@ -1150,19 +1113,15 @@ public class JellyfinProxyService
method.Method, statusCode, url);
}
/// <summary>
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
/// This should only be used for server-side operations, not for proxying client requests.
/// </summary>
// The server API key is restricted to internal operations; client proxying retains client auth.
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Use server's API key for authentication
var authHeader = GetAuthorizationHeader();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader);
request.Headers.TryAddWithoutValidation("Authorization", authHeader);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -10,11 +10,6 @@ using allstarr.Services.Common;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Manages Jellyfin sessions for connected clients.
/// Creates sessions on first playback and keeps them alive with periodic pings.
/// Also maintains server-side WebSocket connections to Jellyfin on behalf of clients.
/// </summary>
public class JellyfinSessionManager : IDisposable
{
private readonly JellyfinProxyService _proxyService;
@@ -35,16 +30,12 @@ public class JellyfinSessionManager : IDisposable
_settings = settings.Value;
_logger = logger;
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
// Jellyfin considers inactive sessions stale after roughly 15 seconds.
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
_logger.LogInformation("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
}
/// <summary>
/// Ensures a session exists for the given device. Creates one if needed.
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
/// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
=> await EnsureSessionAsync(deviceId, client, device, version, headers, null);
@@ -68,7 +59,6 @@ public class JellyfinSessionManager : IDisposable
{
var hasProxiedWebSocket = HasProxiedWebSocket(deviceId);
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
@@ -81,13 +71,10 @@ public class JellyfinSessionManager : IDisposable
if (!hasProxiedWebSocket)
{
// Refresh capabilities to keep session alive only for sessions that Allstarr
// is synthesizing itself. Native proxied websocket sessions should be left
// entirely under Jellyfin's control.
// Native proxied websocket sessions remain entirely under Jellyfin's control.
var refreshResult = await PostCapabilitiesAsync(headers);
if (refreshResult == CapabilitiesPostResult.Unauthorized)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
@@ -106,9 +93,7 @@ public class JellyfinSessionManager : IDisposable
if (!hasProxiedWebSocket)
{
// Post session capabilities to Jellyfin only when Allstarr is creating a
// synthetic session. If the real client already has a proxied websocket,
// re-posting capabilities can overwrite its remote-control state.
// Re-posting capabilities can overwrite a native client's remote-control state.
var createResult = await PostCapabilitiesAsync(headers);
if (createResult != CapabilitiesPostResult.Success)
{
@@ -124,7 +109,6 @@ public class JellyfinSessionManager : IDisposable
deviceId);
}
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? headers["X-Real-IP"].FirstOrDefault()
?? "Unknown";
@@ -145,8 +129,6 @@ public class JellyfinSessionManager : IDisposable
UserName = principal?.DisplayName
};
// Start a synthetic WebSocket connection only when the client itself does not
// already have a proxied Jellyfin socket through Allstarr.
if (!hasProxiedWebSocket)
{
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
@@ -203,10 +185,6 @@ public class JellyfinSessionManager : IDisposable
return !string.IsNullOrWhiteSpace(deviceId) && _proxiedWebSocketConnections.ContainsKey(deviceId);
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Distinguishes an expired token from a transient upstream failure.
/// </summary>
private async Task<CapabilitiesPostResult> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
@@ -233,7 +211,6 @@ public class JellyfinSessionManager : IDisposable
}
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogWarning("Capabilities returned 401 (token expired) - client should re-authenticate");
return CapabilitiesPostResult.Unauthorized;
}
@@ -244,9 +221,6 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Updates session activity timestamp.
/// </summary>
public void UpdateActivity(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -260,9 +234,6 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Updates the currently playing item for a session (for scrobbling on cleanup).
/// </summary>
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -275,10 +246,7 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Marks that an explicit playback stop was received for this device+item.
/// Used to suppress duplicate inferred stop forwarding from progress transitions.
/// </summary>
// Explicit stops suppress duplicate stops inferred from progress transitions.
public void MarkExplicitStop(string deviceId, string itemId)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -291,9 +259,6 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Returns true when an explicit stop for this device+item was recorded within the given time window.
/// </summary>
public bool WasRecentlyExplicitlyStopped(string deviceId, string itemId, TimeSpan within)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -317,9 +282,6 @@ public class JellyfinSessionManager : IDisposable
return false;
}
/// <summary>
/// Returns true if a local played-signal was already sent for this device+item.
/// </summary>
public bool HasSentLocalPlayedSignal(string deviceId, string itemId)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -333,9 +295,6 @@ public class JellyfinSessionManager : IDisposable
return false;
}
/// <summary>
/// Marks that a local played-signal was sent for this device+item.
/// </summary>
public void MarkLocalPlayedSignalSent(string deviceId, string itemId)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -347,17 +306,11 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Returns true when a tracked session exists for this device.
/// </summary>
public bool HasSession(string deviceId)
{
return !string.IsNullOrWhiteSpace(deviceId) && _sessions.ContainsKey(deviceId);
}
/// <summary>
/// Gets the last playing item id for a tracked session, if present.
/// </summary>
public string? GetLastPlayingItemId(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -368,9 +321,6 @@ public class JellyfinSessionManager : IDisposable
return null;
}
/// <summary>
/// Gets last tracked playing item and position for a device, if present.
/// </summary>
public (string? ItemId, long? PositionTicks) GetLastPlayingState(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
@@ -381,9 +331,6 @@ public class JellyfinSessionManager : IDisposable
return (null, null);
}
/// <summary>
/// Returns current active playback states for tracked sessions.
/// </summary>
public IReadOnlyList<ActivePlaybackState> GetActivePlaybackStates(TimeSpan maxAge)
{
var cutoff = DateTime.UtcNow - maxAge;
@@ -406,10 +353,7 @@ public class JellyfinSessionManager : IDisposable
.ToList();
}
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// Jellyfin should decide when the upstream playback session expires.
/// </summary>
// Jellyfin, not local playback cleanup, owns the upstream session lifetime.
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out _))
@@ -421,9 +365,6 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Gets information about current active sessions for debugging.
/// </summary>
public object GetSessionsInfo()
{
var now = DateTime.UtcNow;
@@ -451,9 +392,6 @@ public class JellyfinSessionManager : IDisposable
};
}
/// <summary>
/// Removes a session when the client disconnects.
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
@@ -462,7 +400,6 @@ public class JellyfinSessionManager : IDisposable
{
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
{
try
@@ -482,7 +419,6 @@ public class JellyfinSessionManager : IDisposable
try
{
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new
@@ -496,7 +432,7 @@ public class JellyfinSessionManager : IDisposable
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
// Let Jellyfin retire the session naturally; internal cleanup must not revoke the user's token.
// Internal cleanup must never revoke the user's token.
}
catch (Exception ex)
{
@@ -505,10 +441,6 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Maintains a WebSocket connection to Jellyfin on behalf of a client session.
/// This allows the session to appear in Jellyfin's dashboard.
/// </summary>
private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers)
{
if (!_sessions.TryGetValue(deviceId, out var session))
@@ -527,64 +459,50 @@ public class JellyfinSessionManager : IDisposable
try
{
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
// IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server
// The client's token is passed via X-Emby-Authorization header
// Using api_key would create a session for the server/admin, not the actual user's client
// Server credentials here would attribute the session to the server/admin, not the client.
webSocket = new ClientWebSocket();
session.WebSocket = webSocket;
// Use stored session headers instead of parameter (parameter might be disposed)
// The request-scoped header collection may already be disposed.
var sessionHeaders = session.Headers;
// Log available headers for debugging
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
deviceId, string.Join(", ", sessionHeaders.Keys));
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
bool authFound = false;
if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}", deviceId);
webSocket.Options.SetRequestHeader("Authorization", auth.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}", deviceId);
authFound = true;
}
else if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
webSocket.Options.SetRequestHeader("Authorization", embyAuth.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Upgraded legacy authorization for {DeviceId}", deviceId);
authFound = true;
}
else if (sessionHeaders.TryGetValue("X-Emby-Token", out var token))
{
webSocket.Options.SetRequestHeader("X-Emby-Token", token.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Token for {DeviceId}", deviceId);
webSocket.Options.SetRequestHeader(
"Authorization",
AuthHeaderHelper.CreateAuthHeader(
token.ToString(), session.Client, session.Device, deviceId, session.Version));
_logger.LogDebug("🔑 WEBSOCKET: Upgraded legacy token for {DeviceId}", deviceId);
authFound = true;
}
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}",
deviceId);
authFound = true;
}
else
{
webSocket.Options.SetRequestHeader("Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}", deviceId);
authFound = true;
}
}
if (!authFound)
{
// No client auth found - fall back to server API key as last resort
if (!string.IsNullOrEmpty(_settings.ApiKey))
{
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
jellyfinWsUrl += $"?ApiKey={Uri.EscapeDataString(_settings.ApiKey)}";
_logger.LogWarning("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
}
else
@@ -596,28 +514,22 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId,
jellyfinWsUrl.Split('?')[0]);
// Set user agent
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
// Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard
// Without this message, the WebSocket is connected but no session appears
// Jellyfin does not expose the connected session until ForceKeepAlive arrives.
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogInformation("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
// Also send SessionsStart to subscribe to session updates
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
var buffer = new byte[1024 * 4];
var lastKeepAlive = DateTime.UtcNow;
using var cts = new CancellationTokenSource();
@@ -633,7 +545,7 @@ public class JellyfinSessionManager : IDisposable
break;
}
// Use a timeout so we can send keep-alive messages periodically
// Bound the receive so an idle socket cannot starve outbound keep-alives.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
@@ -647,24 +559,20 @@ public class JellyfinSessionManager : IDisposable
break;
}
// Log received messages for debugging (only non-routine messages)
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
// Respond to KeepAlive requests from Jellyfin
if (message.Contains("\"MessageType\":\"KeepAlive\""))
{
_logger.LogDebug("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
}
else if (message.Contains("\"MessageType\":\"Sessions\""))
{
// Session updates are routine, log at debug level
_logger.LogDebug("📥 WEBSOCKET: Session update for {DeviceId}", deviceId);
}
else
{
// Log other message types at trace level
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message);
}
@@ -672,10 +580,8 @@ public class JellyfinSessionManager : IDisposable
}
catch (OperationCanceledException) when (!cts.IsCancellationRequested)
{
// Timeout - this is expected, send keep-alive if needed
}
// Send periodic keep-alive every 30 seconds
if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30))
{
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
@@ -712,7 +618,6 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
}
// Clear WebSocket reference from session
if (_sessions.TryGetValue(deviceId, out var sess))
{
sess.WebSocket = null;
@@ -720,11 +625,7 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Periodically pings Jellyfin to keep sessions alive.
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary>
// Capabilities are a fallback keep-alive; native or synthetic WebSockets are primary.
private void KeepSessionsAlive(object? state) => _ = RunKeepAlivePassAsync();
internal async Task RunKeepAlivePassAsync()
@@ -781,7 +682,7 @@ public class JellyfinSessionManager : IDisposable
await RemoveSessionAsync(deviceId);
}
// This balances cleaning up finished sessions with allowing brief pauses/network issues.
// Three minutes tolerates brief pauses and network interruptions before cleanup.
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions)
{
@@ -861,7 +762,6 @@ public class JellyfinSessionManager : IDisposable
initLock.Dispose();
}
// Close all WebSocket connections
foreach (var session in _sessions.Values)
{
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)