From b40349206df98af756e9a52aaa5c1576743b9180 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 2 Feb 2026 20:31:07 -0500 Subject: [PATCH] websocket on behalf of client --- .../Jellyfin/JellyfinSessionManager.cs | 174 +++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index 05f038b..5505f5a 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -1,31 +1,39 @@ using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; namespace allstarr.Services.Jellyfin; /// /// 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. /// -public class JellyfinSessionManager +public class JellyfinSessionManager : IDisposable { private readonly JellyfinProxyService _proxyService; + private readonly JellyfinSettings _settings; private readonly ILogger _logger; private readonly ConcurrentDictionary _sessions = new(); private readonly Timer _keepAliveTimer; public JellyfinSessionManager( JellyfinProxyService proxyService, + IOptions settings, ILogger logger) { _proxyService = proxyService; + _settings = settings.Value; _logger = logger; // Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity) _keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); - _logger.LogWarning("๐Ÿ”ง SESSION: JellyfinSessionManager initialized with 10-second keep-alive"); + _logger.LogWarning("๐Ÿ”ง SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support"); } /// @@ -70,6 +78,9 @@ public class JellyfinSessionManager Headers = CloneHeaders(headers) }; + // Start a WebSocket connection to Jellyfin on behalf of this client + _ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers)); + return true; } catch (Exception ex) @@ -136,6 +147,24 @@ public class JellyfinSessionManager { _logger.LogWarning("๐Ÿ—‘๏ธ SESSION: Removing session for device {DeviceId}", deviceId); + // Close WebSocket if it exists + if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open) + { + try + { + await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None); + _logger.LogWarning("๐Ÿ”Œ WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "โš ๏ธ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId); + } + finally + { + session.WebSocket?.Dispose(); + } + } + try { // Optionally notify Jellyfin that the session is ending @@ -149,6 +178,124 @@ public class JellyfinSessionManager } } + /// + /// Maintains a WebSocket connection to Jellyfin on behalf of a client session. + /// This allows the session to appear in Jellyfin's dashboard. + /// + private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers) + { + if (!_sessions.TryGetValue(deviceId, out var session)) + { + _logger.LogWarning("โš ๏ธ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId); + return; + } + + ClientWebSocket? webSocket = null; + + 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"; + + // Add API key if available + if (!string.IsNullOrEmpty(_settings.ApiKey)) + { + jellyfinWsUrl += $"?api_key={_settings.ApiKey}"; + } + + _logger.LogWarning("๐Ÿ”— WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl); + + webSocket = new ClientWebSocket(); + session.WebSocket = webSocket; + + // Forward authentication headers + if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth)) + { + webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString()); + _logger.LogWarning("๐Ÿ”‘ WEBSOCKET: Forwarded X-Emby-Authorization for {DeviceId}", deviceId); + } + else if (headers.TryGetValue("Authorization", out var auth)) + { + var authValue = auth.ToString(); + if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) + { + webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue); + _logger.LogWarning("๐Ÿ”‘ WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}", deviceId); + } + else + { + webSocket.Options.SetRequestHeader("Authorization", authValue); + _logger.LogWarning("๐Ÿ”‘ WEBSOCKET: Forwarded Authorization for {DeviceId}", deviceId); + } + } + + // Set user agent + webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}"); + + // Connect to Jellyfin + 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 + var buffer = new byte[1024 * 4]; + while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId)) + { + try + { + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + 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); + } + } + catch (WebSocketException wsEx) + { + _logger.LogWarning(wsEx, "โš ๏ธ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId); + break; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "โŒ WEBSOCKET: Failed to maintain WebSocket for device {DeviceId}", deviceId); + } + finally + { + if (webSocket != null) + { + if (webSocket.State == WebSocketState.Open) + { + try + { + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None); + } + catch { } + } + webSocket.Dispose(); + _logger.LogWarning("๐Ÿงน WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId); + } + + // Clear WebSocket reference from session + if (_sessions.TryGetValue(deviceId, out var sess)) + { + sess.WebSocket = null; + } + } + } + /// /// Periodically pings Jellyfin to keep sessions alive. /// @@ -205,5 +352,28 @@ public class JellyfinSessionManager public required string Version { get; init; } public DateTime LastActivity { get; set; } public required IHeaderDictionary Headers { get; init; } + public ClientWebSocket? WebSocket { get; set; } + } + + public void Dispose() + { + _keepAliveTimer?.Dispose(); + + // Close all WebSocket connections + foreach (var session in _sessions.Values) + { + if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open) + { + try + { + session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Service stopping", CancellationToken.None).Wait(TimeSpan.FromSeconds(5)); + } + catch { } + finally + { + session.WebSocket?.Dispose(); + } + } + } } }