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();
+ }
+ }
+ }
}
}