From 83063f594a9cd0fb01739e887bba7e9225a9eccd Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 2 Feb 2026 20:41:25 -0500 Subject: [PATCH] fix websocket session auth and header parsing for jellyfin dashboard --- allstarr/Controllers/JellyfinController.cs | 15 +++- .../Middleware/WebSocketProxyMiddleware.cs | 25 ++++-- .../Jellyfin/JellyfinSessionManager.cs | 87 +++++++++++++++---- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index c156ac3..accbf47 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -3308,9 +3308,20 @@ public class JellyfinController : ControllerBase string? device = null; string? version = null; - if (headers.TryGetValue("Authorization", out var authHeader)) + // Check X-Emby-Authorization FIRST (most Jellyfin clients use this) + // Then fall back to Authorization header + string? authStr = null; + if (headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader)) + { + authStr = embyAuthHeader.ToString(); + } + else if (headers.TryGetValue("Authorization", out var authHeader)) + { + authStr = authHeader.ToString(); + } + + if (!string.IsNullOrEmpty(authStr)) { - var authStr = authHeader.ToString(); // Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..." var parts = authStr.Replace("MediaBrowser ", "").Split(','); foreach (var part in parts) diff --git a/allstarr/Middleware/WebSocketProxyMiddleware.cs b/allstarr/Middleware/WebSocketProxyMiddleware.cs index cc75b78..d3a9cfc 100644 --- a/allstarr/Middleware/WebSocketProxyMiddleware.cs +++ b/allstarr/Middleware/WebSocketProxyMiddleware.cs @@ -89,17 +89,28 @@ public class WebSocketProxyMiddleware // Connect to Jellyfin WebSocket serverWebSocket = new ClientWebSocket(); - // Forward authentication headers - if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - serverWebSocket.Options.SetRequestHeader("Authorization", authHeader.ToString()); - _logger.LogWarning("🔑 WEBSOCKET: Forwarded Authorization header"); - } - else if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader)) + // Forward authentication headers - check X-Emby-Authorization FIRST + // Most Jellyfin clients use X-Emby-Authorization, not Authorization + if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader)) { serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString()); _logger.LogWarning("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header"); } + else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + var authValue = authHeader.ToString(); + // If it's a MediaBrowser auth header, use X-Emby-Authorization + if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) + { + serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue); + _logger.LogWarning("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header"); + } + else + { + serverWebSocket.Options.SetRequestHeader("Authorization", authValue); + _logger.LogWarning("🔑 WEBSOCKET: Forwarded Authorization header"); + } + } // Set user agent serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index 5505f5a..102d903 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -200,18 +200,16 @@ public class JellyfinSessionManager : IDisposable var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", ""); var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket"; - // Add API key if available - if (!string.IsNullOrEmpty(_settings.ApiKey)) - { - jellyfinWsUrl += $"?api_key={_settings.ApiKey}"; - } + // 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 _logger.LogWarning("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl); webSocket = new ClientWebSocket(); session.WebSocket = webSocket; - // Forward authentication headers + // Forward authentication headers from the CLIENT - this is critical for session to appear under the right user if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth)) { webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString()); @@ -231,6 +229,15 @@ public class JellyfinSessionManager : IDisposable _logger.LogWarning("🔑 WEBSOCKET: Forwarded Authorization for {DeviceId}", deviceId); } } + else + { + // No client auth found - fall back to server API key as last resort + if (!string.IsNullOrEmpty(_settings.ApiKey)) + { + jellyfinWsUrl += $"?api_key={_settings.ApiKey}"; + _logger.LogWarning("⚠️ WEBSOCKET: No client auth found, falling back to server API key for {DeviceId}", deviceId); + } + } // Set user agent webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}"); @@ -239,26 +246,70 @@ public class JellyfinSessionManager : IDisposable await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None); _logger.LogWarning("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId); - // Keep the WebSocket alive by reading messages + // 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 + var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}"; + var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage); + await webSocket.SendAsync(new ArraySegment(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None); + _logger.LogWarning("📤 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(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None); + _logger.LogWarning("📤 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(); + while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId)) { try { - var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - - if (result.MessageType == WebSocketMessageType.Close) + // Use a timeout so we can send keep-alive messages periodically + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + + try { - _logger.LogWarning("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId); - break; + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), timeoutCts.Token); + + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogWarning("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId); + break; + } + + // Log received messages for debugging + if (result.MessageType == WebSocketMessageType.Text) + { + var message = Encoding.UTF8.GetString(buffer, 0, result.Count); + _logger.LogWarning("📥 WEBSOCKET: Received from Jellyfin for {DeviceId}: {Message}", + deviceId, message.Length > 100 ? message[..100] + "..." : message); + + // Respond to KeepAlive requests from Jellyfin + if (message.Contains("\"MessageType\":\"KeepAlive\"")) + { + _logger.LogWarning("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId); + } + } } - - // Log received messages for debugging - if (result.MessageType == WebSocketMessageType.Text) + catch (OperationCanceledException) when (!cts.IsCancellationRequested) { - var message = Encoding.UTF8.GetString(buffer, 0, result.Count); - _logger.LogWarning("📥 WEBSOCKET: Received from Jellyfin for {DeviceId}: {Message}", - deviceId, message.Length > 100 ? message[..100] + "..." : message); + // 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\"}"; + var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg); + await webSocket.SendAsync(new ArraySegment(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None); + _logger.LogWarning("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId); + lastKeepAlive = DateTime.UtcNow; } } catch (WebSocketException wsEx)