diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 44e8977..9fce43c 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -30,6 +30,7 @@ public class JellyfinController : ControllerBase private readonly JellyfinResponseBuilder _responseBuilder; private readonly JellyfinModelMapper _modelMapper; private readonly JellyfinProxyService _proxyService; + private readonly JellyfinSessionManager _sessionManager; private readonly PlaylistSyncService? _playlistSyncService; private readonly RedisCacheService _cache; private readonly ILogger _logger; @@ -43,6 +44,7 @@ public class JellyfinController : ControllerBase JellyfinResponseBuilder responseBuilder, JellyfinModelMapper modelMapper, JellyfinProxyService proxyService, + JellyfinSessionManager sessionManager, RedisCacheService cache, ILogger logger, PlaylistSyncService? playlistSyncService = null) @@ -55,6 +57,7 @@ public class JellyfinController : ControllerBase _responseBuilder = responseBuilder; _modelMapper = modelMapper; _proxyService = proxyService; + _sessionManager = sessionManager; _playlistSyncService = playlistSyncService; _cache = cache; _logger = logger; @@ -1780,95 +1783,13 @@ public class JellyfinController : ControllerBase } Request.Body.Position = 0; - _logger.LogInformation("📻 Playback START reported - Body: {Body}", body); - _logger.LogInformation("Auth headers: {Headers}", - string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)).Select(h => $"{h.Key}={h.Value}"))); + _logger.LogInformation("📻 Playback START reported"); - // Extract device info from auth headers for session initialization - string? deviceId = null; - string? client = null; - string? device = null; - string? version = null; - - if (Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - var authStr = authHeader.ToString(); - // Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..." - var parts = authStr.Replace("MediaBrowser ", "").Split(','); - foreach (var part in parts) - { - var kv = part.Trim().Split('='); - if (kv.Length == 2) - { - var key = kv[0].Trim(); - var value = kv[1].Trim('"'); - if (key == "DeviceId") deviceId = value; - else if (key == "Client") client = value; - else if (key == "Device") device = value; - else if (key == "Version") version = value; - } - } - } - - // Ensure session capabilities are posted to Jellyfin (if not already done) - // Jellyfin automatically creates a session when the client authenticates, but we need to - // post capabilities so the session shows up in the dashboard with proper device info + // Extract device info and ensure session exists + var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers); if (!string.IsNullOrEmpty(deviceId)) { - _logger.LogInformation("🔧 Ensuring session exists for device: {DeviceId} ({Client} {Version})", deviceId, client, version); - - // First, check if a session exists for this device - try - { - var (sessionsResult, sessionsStatus) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers); - if (sessionsResult != null && sessionsStatus == 200) - { - var sessions = sessionsResult.RootElement; - _logger.LogInformation("📊 Jellyfin sessions for device {DeviceId}: {Sessions}", deviceId, sessions.GetRawText()); - } - else - { - _logger.LogWarning("⚠ Could not query sessions ({StatusCode})", sessionsStatus); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to query sessions"); - } - - // Post capabilities - Jellyfin will match this to the authenticated session by device ID - // The query parameters tell Jellyfin what this device can do - var capabilitiesEndpoint = $"Sessions/Capabilities/Full"; - try - { - // Send full capabilities as JSON body (more reliable than query params) - var capabilities = new - { - PlayableMediaTypes = new[] { "Audio" }, - SupportedCommands = Array.Empty(), - SupportsMediaControl = false, - SupportsPersistentIdentifier = true, - SupportsSync = false, - DeviceProfile = (object?)null // Let Jellyfin use defaults - }; - - var capabilitiesJson = JsonSerializer.Serialize(capabilities); - _logger.LogInformation("📤 Posting capabilities: {Json}", capabilitiesJson); - var (capResult, capStatus) = await _proxyService.PostJsonAsync(capabilitiesEndpoint, capabilitiesJson, Request.Headers); - _logger.LogInformation("✓ Session capabilities posted ({StatusCode})", capStatus); - - // Check sessions again after posting capabilities - var (sessionsResult2, sessionsStatus2) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers); - if (sessionsResult2 != null && sessionsStatus2 == 200) - { - var sessions2 = sessionsResult2.RootElement; - _logger.LogInformation("📊 Jellyfin sessions AFTER capabilities: {Sessions}", sessions2.GetRawText()); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to post session capabilities, continuing anyway"); - } + await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers); } // Parse the body to check if it's an external track @@ -1986,6 +1907,13 @@ public class JellyfinController : ControllerBase } Request.Body.Position = 0; + // Update session activity + var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers); + if (!string.IsNullOrEmpty(deviceId)) + { + _sessionManager.UpdateActivity(deviceId); + } + // Parse the body to check if it's an external track var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) @@ -3337,5 +3265,38 @@ public class JellyfinController : ControllerBase return avgScore; } + + /// + /// Extracts device information from Authorization header. + /// + private (string? deviceId, string? client, string? device, string? version) ExtractDeviceInfo(IHeaderDictionary headers) + { + string? deviceId = null; + string? client = null; + string? device = null; + string? version = null; + + if (headers.TryGetValue("Authorization", out var authHeader)) + { + var authStr = authHeader.ToString(); + // Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..." + var parts = authStr.Replace("MediaBrowser ", "").Split(','); + foreach (var part in parts) + { + var kv = part.Trim().Split('='); + if (kv.Length == 2) + { + var key = kv[0].Trim(); + var value = kv[1].Trim('"'); + if (key == "DeviceId") deviceId = value; + else if (key == "Client") client = value; + else if (key == "Device") device = value; + else if (key == "Version") version = value; + } + } + } + + return (deviceId, client, device, version); + } } // force rebuild Sun Jan 25 13:22:47 EST 2026 diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 7541ca3..4174848 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -177,6 +177,7 @@ if (backendType == BackendType.Jellyfin) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); } diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs new file mode 100644 index 0000000..5fad725 --- /dev/null +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -0,0 +1,201 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace allstarr.Services.Jellyfin; + +/// +/// Manages Jellyfin sessions for connected clients. +/// Creates sessions on first playback and keeps them alive with periodic pings. +/// +public class JellyfinSessionManager +{ + private readonly JellyfinProxyService _proxyService; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessions = new(); + private readonly Timer _keepAliveTimer; + + public JellyfinSessionManager( + JellyfinProxyService proxyService, + ILogger logger) + { + _proxyService = proxyService; + _logger = logger; + + // Keep sessions alive every 30 seconds + _keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + } + + /// + /// Ensures a session exists for the given device. Creates one if needed. + /// + public async Task EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers) + { + if (string.IsNullOrEmpty(deviceId)) + { + _logger.LogWarning("Cannot create session - no device ID"); + return false; + } + + // Check if we already have this session tracked + if (_sessions.TryGetValue(deviceId, out var existingSession)) + { + existingSession.LastActivity = DateTime.UtcNow; + _logger.LogDebug("Session already exists for device {DeviceId}", deviceId); + return true; + } + + _logger.LogInformation("🔧 Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); + + try + { + // Post session capabilities to Jellyfin - this creates the session + var capabilities = new + { + PlayableMediaTypes = new[] { "Audio" }, + SupportedCommands = new[] + { + "Play", + "Playstate", + "PlayNext" + }, + SupportsMediaControl = true, + SupportsPersistentIdentifier = true, + SupportsSync = false + }; + + var json = JsonSerializer.Serialize(capabilities); + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogInformation("✓ Session created for {DeviceId}", deviceId); + + // Track this session + _sessions[deviceId] = new SessionInfo + { + DeviceId = deviceId, + Client = client, + Device = device, + Version = version, + LastActivity = DateTime.UtcNow, + Headers = CloneHeaders(headers) + }; + + return true; + } + else + { + _logger.LogWarning("Failed to create session for {DeviceId} - status {StatusCode}", deviceId, statusCode); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating session for {DeviceId}", deviceId); + return false; + } + } + + /// + /// Updates session activity timestamp. + /// + public void UpdateActivity(string deviceId) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + session.LastActivity = DateTime.UtcNow; + } + } + + /// + /// Removes a session when the client disconnects. + /// + public async Task RemoveSessionAsync(string deviceId) + { + if (_sessions.TryRemove(deviceId, out var session)) + { + _logger.LogInformation("Removing session for device {DeviceId}", deviceId); + + try + { + // Optionally notify Jellyfin that the session is ending + // (Jellyfin will auto-cleanup inactive sessions anyway) + await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error removing session for {DeviceId}", deviceId); + } + } + } + + /// + /// Periodically pings Jellyfin to keep sessions alive. + /// + private async void KeepSessionsAlive(object? state) + { + var now = DateTime.UtcNow; + var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList(); + + if (activeSessions.Count == 0) + { + return; + } + + _logger.LogDebug("Keeping {Count} sessions alive", activeSessions.Count); + + foreach (var session in activeSessions) + { + try + { + // Post capabilities again to keep session alive + var capabilities = new + { + PlayableMediaTypes = new[] { "Audio" }, + SupportedCommands = new[] { "Play", "Playstate", "PlayNext" }, + SupportsMediaControl = true, + SupportsPersistentIdentifier = true, + SupportsSync = false + }; + + var json = JsonSerializer.Serialize(capabilities); + await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, session.Headers); + + _logger.LogDebug("✓ Kept session alive for {DeviceId}", session.DeviceId); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId); + } + } + + // Clean up stale sessions (inactive for > 10 minutes) + var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList(); + foreach (var stale in staleSessions) + { + _logger.LogInformation("Removing stale session for {DeviceId}", stale.Key); + _sessions.TryRemove(stale.Key, out _); + } + } + + private static IHeaderDictionary CloneHeaders(IHeaderDictionary headers) + { + var cloned = new HeaderDictionary(); + foreach (var header in headers) + { + cloned[header.Key] = header.Value; + } + return cloned; + } + + private class SessionInfo + { + public required string DeviceId { get; init; } + public required string Client { get; init; } + public required string Device { get; init; } + public required string Version { get; init; } + public DateTime LastActivity { get; set; } + public required IHeaderDictionary Headers { get; init; } + } +} diff --git a/test-websocket.html b/test-websocket.html new file mode 100644 index 0000000..acee765 --- /dev/null +++ b/test-websocket.html @@ -0,0 +1,50 @@ + + + + WebSocket Test + + +

Jellyfin WebSocket Test

+
Connecting...
+
+ + + +