mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 13:55:39 -04:00
fix(jellyfin): support modern server authorization
This commit is contained in:
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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user