From 56bc9d4ea9241a85f027df2af6e3688514a58f95 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 7 Feb 2026 23:25:14 -0500 Subject: [PATCH] fix: transparent proxy authentication and token expiration handling - Remove broken JellyfinAuthFilter that was checking non-existent CLIENT_USERNAME - Clients now authenticate directly with Jellyfin (transparent proxy model) - Improved token expiration detection and session cleanup - Better logging with reduced verbosity (removed emoji spam) - Added support for X-Emby-Token header format - Added detection of public endpoints that don't require auth - SessionManager now properly detects 401 responses and removes expired sessions - Clarified .env.example comments about server-side vs client-side auth - All functionality preserved: Spotify injection, external providers, playback tracking --- .env.example | 8 +- allstarr/Filters/JellyfinAuthFilter.cs | 241 ++---------------- .../Services/Jellyfin/JellyfinProxyService.cs | 125 +++++---- .../Jellyfin/JellyfinSessionManager.cs | 76 ++++-- 4 files changed, 163 insertions(+), 287 deletions(-) diff --git a/.env.example b/.env.example index a628af9..9939008 100644 --- a/.env.example +++ b/.env.example @@ -18,13 +18,17 @@ SUBSONIC_URL=http://localhost:4533 # Server URL (required if using Jellyfin backend) JELLYFIN_URL=http://localhost:8096 -# API key for authentication (get from Jellyfin Dashboard > API Keys) +# API key for SERVER-SIDE operations only (get from Jellyfin Dashboard > API Keys) +# This is used by Allstarr to query Jellyfin's library on behalf of the server +# CLIENT authentication is handled transparently - clients authenticate directly with Jellyfin JELLYFIN_API_KEY= -# User ID (get from Jellyfin Dashboard > Users > click user > check URL) +# User ID for SERVER-SIDE library queries (get from Jellyfin Dashboard > Users > click user > check URL) +# This determines which user's library Allstarr queries when searching/browsing JELLYFIN_USER_ID= # Music library ID (optional, auto-detected if not set) +# If you have multiple libraries, set this to filter to music only JELLYFIN_LIBRARY_ID= # ===== MUSIC SOURCE SELECTION ===== diff --git a/allstarr/Filters/JellyfinAuthFilter.cs b/allstarr/Filters/JellyfinAuthFilter.cs index be8702b..8e7b143 100644 --- a/allstarr/Filters/JellyfinAuthFilter.cs +++ b/allstarr/Filters/JellyfinAuthFilter.cs @@ -2,239 +2,44 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using allstarr.Models.Settings; -using System.Text.Json; -using System.Text.RegularExpressions; namespace allstarr.Filters; /// -/// Authentication filter for Jellyfin API endpoints. -/// Validates client credentials against configured username and API key. -/// Clients can authenticate via: -/// - Authorization header: MediaBrowser Token="apikey" -/// - X-Emby-Token header -/// - Query parameter: api_key -/// - JSON body (for login endpoints): Username/Pw fields +/// REMOVED: Authentication filter for Jellyfin API endpoints. +/// +/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY. +/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself. +/// +/// Authentication flow: +/// 1. Client sends credentials to /Users/AuthenticateByName +/// 2. Proxy forwards request to Jellyfin (no validation) +/// 3. Jellyfin validates credentials and returns AccessToken +/// 4. Client uses AccessToken in subsequent requests +/// 5. Proxy forwards token to Jellyfin for validation +/// +/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job. +/// The proxy only forwards authentication headers transparently. +/// +/// If you need to restrict access to the proxy itself, use network-level controls +/// (firewall, VPN, reverse proxy with auth) instead of application-level auth. /// -public partial class JellyfinAuthFilter : IAsyncActionFilter +public class JellyfinAuthFilter : IAsyncActionFilter { - private readonly JellyfinSettings _settings; private readonly ILogger _logger; - public JellyfinAuthFilter( - IOptions settings, - ILogger logger) + public JellyfinAuthFilter(ILogger logger) { - _settings = settings.Value; _logger = logger; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - // Skip auth if no credentials configured (open mode) - if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey)) - { - _logger.LogDebug("Auth skipped - no client credentials configured"); - await next(); - return; - } - - var request = context.HttpContext.Request; + // This filter is now a no-op - all authentication is handled by Jellyfin + // Keeping the class for backwards compatibility but it does nothing + + _logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check"); - // Try to extract credentials from various sources - var (username, token) = await ExtractCredentialsAsync(request); - - // Validate credentials - if (!ValidateCredentials(username, token)) - { - _logger.LogWarning("Authentication failed for user '{Username}' from {IP}", - username ?? "unknown", - context.HttpContext.Connection.RemoteIpAddress); - - context.Result = new UnauthorizedObjectResult(new - { - error = "Invalid credentials", - message = "Authentication required. Provide valid username and API key." - }); - return; - } - - _logger.LogDebug("Authentication successful for user '{Username}'", username); await next(); } - - private async Task<(string? username, string? token)> ExtractCredentialsAsync(HttpRequest request) - { - string? username = null; - string? token = null; - - // 1. Check Authorization header (MediaBrowser format) - if (request.Headers.TryGetValue("Authorization", out var authHeader)) - { - var authValue = authHeader.ToString(); - - // Parse MediaBrowser auth header: MediaBrowser Client="...", Token="..." - if (authValue.StartsWith("MediaBrowser", StringComparison.OrdinalIgnoreCase)) - { - token = ExtractTokenFromMediaBrowser(authValue); - username = ExtractUserIdFromMediaBrowser(authValue); - } - // Basic auth: Basic base64(username:password) - else if (authValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) - { - (username, token) = ParseBasicAuth(authValue); - } - } - - // 2. Check X-Emby-Token header - if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Token", out var embyToken)) - { - token = embyToken.ToString(); - } - - // 3. Check X-MediaBrowser-Token header - if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-MediaBrowser-Token", out var mbToken)) - { - token = mbToken.ToString(); - } - - // 4. Check X-Emby-Authorization header (alternative format) - if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth)) - { - token = ExtractTokenFromMediaBrowser(embyAuth.ToString()); - if (string.IsNullOrEmpty(username)) - { - username = ExtractUserIdFromMediaBrowser(embyAuth.ToString()); - } - } - - // 5. Check query parameters - if (string.IsNullOrEmpty(token)) - { - token = request.Query["api_key"].FirstOrDefault() - ?? request.Query["ApiKey"].FirstOrDefault() - ?? request.Query["X-Emby-Token"].FirstOrDefault(); - } - - if (string.IsNullOrEmpty(username)) - { - username = request.Query["userId"].FirstOrDefault() - ?? request.Query["UserId"].FirstOrDefault() - ?? request.Query["u"].FirstOrDefault(); - } - - // 6. Check JSON body for login endpoints (Jellyfin: Username/Pw, Navidrome: username/password) - if ((string.IsNullOrEmpty(username) || string.IsNullOrEmpty(token)) && - request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true && - request.ContentLength > 0) - { - var (bodyUsername, bodyPassword) = await ExtractCredentialsFromBodyAsync(request); - if (string.IsNullOrEmpty(username)) username = bodyUsername; - if (string.IsNullOrEmpty(token)) token = bodyPassword; - } - - return (username, token); - } - - private async Task<(string? username, string? password)> ExtractCredentialsFromBodyAsync(HttpRequest request) - { - try - { - request.EnableBuffering(); - request.Body.Position = 0; - - using var reader = new StreamReader(request.Body, leaveOpen: true); - var body = await reader.ReadToEndAsync(); - request.Body.Position = 0; - - if (string.IsNullOrEmpty(body)) return (null, null); - - using var doc = JsonDocument.Parse(body); - var root = doc.RootElement; - - // Try Jellyfin format: Username, Pw - string? username = null; - string? password = null; - - if (root.TryGetProperty("Username", out var usernameProp)) - username = usernameProp.GetString(); - else if (root.TryGetProperty("username", out var usernameLowerProp)) - username = usernameLowerProp.GetString(); - - if (root.TryGetProperty("Pw", out var pwProp)) - password = pwProp.GetString(); - else if (root.TryGetProperty("pw", out var pwLowerProp)) - password = pwLowerProp.GetString(); - else if (root.TryGetProperty("Password", out var passwordProp)) - password = passwordProp.GetString(); - else if (root.TryGetProperty("password", out var passwordLowerProp)) - password = passwordLowerProp.GetString(); - - return (username, password); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to parse credentials from request body"); - return (null, null); - } - } - - private string? ExtractTokenFromMediaBrowser(string header) - { - var match = TokenRegex().Match(header); - return match.Success ? match.Groups[1].Value : null; - } - - private string? ExtractUserIdFromMediaBrowser(string header) - { - var match = UserIdRegex().Match(header); - return match.Success ? match.Groups[1].Value : null; - } - - private static (string? username, string? password) ParseBasicAuth(string authHeader) - { - try - { - var base64 = authHeader["Basic ".Length..].Trim(); - var bytes = Convert.FromBase64String(base64); - var credentials = System.Text.Encoding.UTF8.GetString(bytes); - var parts = credentials.Split(':', 2); - - return parts.Length == 2 ? (parts[0], parts[1]) : (null, null); - } - catch - { - return (null, null); - } - } - - private bool ValidateCredentials(string? username, string? token) - { - // Must have token (API key used as password) - if (string.IsNullOrEmpty(token)) - { - return false; - } - - // Token must match API key - if (!string.Equals(token, _settings.ApiKey, StringComparison.Ordinal)) - { - return false; - } - - // If username provided, it must match configured client username - if (!string.IsNullOrEmpty(username) && - !string.Equals(username, _settings.ClientUsername, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return true; - } - - [GeneratedRegex(@"Token=""([^""]+)""", RegexOptions.IgnoreCase)] - private static partial Regex TokenRegex(); - - [GeneratedRegex(@"UserId=""([^""]+)""", RegexOptions.IgnoreCase)] - private static partial Regex UserIdRegex(); } diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index aa0d02c..17d05df 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -168,6 +168,11 @@ 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 + bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) || + url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) || + url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase); + // Forward authentication headers from client if provided if (clientHeaders != null && clientHeaders.Count > 0) { @@ -179,11 +184,27 @@ public class JellyfinProxyService var headerValue = header.Value.ToString(); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); authHeaderAdded = true; - _logger.LogDebug("✓ Forwarded X-Emby-Authorization: {Value}", headerValue); + _logger.LogTrace("Forwarded X-Emby-Authorization header"); break; } } + // Try X-Emby-Token (simpler format used by some clients) + if (!authHeaderAdded) + { + foreach (var header in clientHeaders) + { + if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase)) + { + var headerValue = header.Value.ToString(); + request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue); + authHeaderAdded = true; + _logger.LogTrace("Forwarded X-Emby-Token header"); + break; + } + } + } + // If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format // Some clients send it as "Authorization" instead of "X-Emby-Authorization" if (!authHeaderAdded) @@ -201,37 +222,32 @@ public class JellyfinProxyService // Forward as X-Emby-Authorization (Jellyfin's expected header) request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); authHeaderAdded = true; - _logger.LogDebug("✓ Converted Authorization to X-Emby-Authorization: {Value}", headerValue); + _logger.LogTrace("Converted Authorization to X-Emby-Authorization"); } else { // Standard Bearer token - forward as-is request.Headers.TryAddWithoutValidation("Authorization", headerValue); authHeaderAdded = true; - _logger.LogDebug("✓ Forwarded Authorization (Bearer): {Value}", headerValue); + _logger.LogTrace("Forwarded Authorization header"); } break; } } } - // Only log warnings for non-browser static requests - if (!authHeaderAdded && !isBrowserStaticRequest) + // Check for api_key query parameter (some clients use this) + if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("✗ No auth header found. Available headers: {Headers}", - string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}"))); + authHeaderAdded = true; // It's in the URL, no need to add header + _logger.LogTrace("Using api_key from query string"); } } - else if (!isBrowserStaticRequest) - { - _logger.LogWarning("✗ No client headers provided for {Url}", url); - } - // DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests - // If client doesn't provide auth, they get what they deserve (401 from Jellyfin) - if (!authHeaderAdded && !isBrowserStaticRequest) + // Only log warnings for non-public, non-browser requests without auth + if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint) { - _logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url); + _logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url); } request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); @@ -248,14 +264,28 @@ public class JellyfinProxyService { if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - _logger.LogDebug("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url); + // 401 means token expired or invalid - client needs to re-authenticate + _logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url); } - else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests + else if (!isBrowserStaticRequest && !isPublicEndpoint) { _logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url); } - // Return null body with the actual status code + // Try to parse error response to pass through to client + if (!string.IsNullOrWhiteSpace(content)) + { + try + { + var errorDoc = JsonDocument.Parse(content); + return (errorDoc, statusCode); + } + catch + { + // Not valid JSON, return null + } + } + return (null, statusCode); } @@ -297,8 +327,10 @@ public class JellyfinProxyService request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); bool authHeaderAdded = false; + bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase); // Forward authentication headers from client (case-insensitive) + // Try X-Emby-Authorization first foreach (var header in clientHeaders) { if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) @@ -306,11 +338,28 @@ public class JellyfinProxyService var headerValue = header.Value.ToString(); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); authHeaderAdded = true; - _logger.LogDebug("Forwarded X-Emby-Authorization from client"); + _logger.LogTrace("Forwarded X-Emby-Authorization header"); break; } } + // Try X-Emby-Token + if (!authHeaderAdded) + { + foreach (var header in clientHeaders) + { + if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase)) + { + var headerValue = header.Value.ToString(); + request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue); + authHeaderAdded = true; + _logger.LogTrace("Forwarded X-Emby-Token header"); + break; + } + } + } + + // Try Authorization header if (!authHeaderAdded) { foreach (var header in clientHeaders) @@ -325,13 +374,13 @@ public class JellyfinProxyService { // Forward as X-Emby-Authorization request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - _logger.LogDebug("Converted Authorization to X-Emby-Authorization"); + _logger.LogTrace("Converted Authorization to X-Emby-Authorization"); } else { // Standard Bearer token request.Headers.TryAddWithoutValidation("Authorization", headerValue); - _logger.LogDebug("Forwarded Authorization header"); + _logger.LogTrace("Forwarded Authorization header"); } authHeaderAdded = true; break; @@ -339,30 +388,23 @@ public class JellyfinProxyService } } - // DO NOT use server credentials as fallback - // Exception: For auth endpoints, client provides their own credentials in the body - // For all other endpoints, if client doesn't provide auth, let Jellyfin reject it - if (!authHeaderAdded) + // For authentication endpoints, credentials are in the body, not headers + // For other endpoints without auth, let Jellyfin reject the request + if (!authHeaderAdded && !isAuthEndpoint) { - _logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url); + _logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url); } request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); // DO NOT log the body for auth endpoints - it contains passwords! - if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase)) + if (isAuthEndpoint) { _logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url); } else { - _logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); - - // Log body content for playback endpoints to debug - if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend); - } + _logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); } var response = await _httpClient.SendAsync(request); @@ -376,12 +418,12 @@ public class JellyfinProxyService // 401 is expected when tokens expire - don't spam logs if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - _logger.LogDebug("SESSION: Jellyfin POST request returned 401 for {Url} (token expired)", url); + _logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url); } else { - _logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", - response.StatusCode, url, errorContent); + _logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", + response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent); } // Try to parse error response as JSON to pass through to client @@ -404,7 +446,7 @@ public class JellyfinProxyService // Log successful session-related responses if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint); + _logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint); } // Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress) @@ -421,13 +463,6 @@ public class JellyfinProxyService return (null, statusCode); } - // Log response content for session endpoints - if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent)) - { - var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent; - _logger.LogTrace("📥 SESSION: Jellyfin response body: {Body}", preview); - } - return (JsonDocument.Parse(responseContent), statusCode); } diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index 596919f..97e4882 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable /// /// 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. /// public async Task EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers) { if (string.IsNullOrEmpty(deviceId)) { - _logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID"); + _logger.LogWarning("Cannot create session - no device ID"); return false; } @@ -51,25 +52,37 @@ public class JellyfinSessionManager : IDisposable if (_sessions.TryGetValue(deviceId, out var existingSession)) { existingSession.LastActivity = DateTime.UtcNow; - _logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId); + _logger.LogTrace("Session already exists for device {DeviceId}", deviceId); // Refresh capabilities to keep session alive - await PostCapabilitiesAsync(headers); + // If this returns false (401), the token expired and client needs to re-auth + var success = await PostCapabilitiesAsync(headers); + if (!success) + { + // Token expired - remove the stale session + _logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId); + await RemoveSessionAsync(deviceId); + return false; + } + return true; } - _logger.LogDebug("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); - - // Log the headers we received for debugging - _logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}", - string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}..."))); + _logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); try { // Post session capabilities to Jellyfin - this creates the session - await PostCapabilitiesAsync(headers); + var success = await PostCapabilitiesAsync(headers); + + if (!success) + { + // Token expired or invalid - client needs to re-authenticate + _logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId); + return false; + } - _logger.LogDebug("✓ SESSION: Session created for {DeviceId}", deviceId); + _logger.LogDebug("Session created for {DeviceId}", deviceId); // Track this session _sessions[deviceId] = new SessionInfo @@ -89,15 +102,16 @@ public class JellyfinSessionManager : IDisposable } catch (Exception ex) { - _logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId); + _logger.LogError(ex, "Error creating session for {DeviceId}", deviceId); return false; } } /// /// Posts session capabilities to Jellyfin. + /// Returns true if successful, false if token expired (401). /// - private async Task PostCapabilitiesAsync(IHeaderDictionary headers) + private async Task PostCapabilitiesAsync(IHeaderDictionary headers) { var capabilities = new { @@ -118,16 +132,19 @@ public class JellyfinSessionManager : IDisposable if (statusCode == 204 || statusCode == 200) { - _logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode); + _logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode); + return true; } else if (statusCode == 401) { - // Token expired - this is expected, don't spam logs - _logger.LogDebug("SESSION: Capabilities returned 401 (token expired, will use fresh headers on next request)"); + // Token expired - this is expected, client needs to re-authenticate + _logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate"); + return false; } else { - _logger.LogDebug("SESSION: Capabilities post returned {StatusCode}", statusCode); + _logger.LogDebug("Capabilities post returned {StatusCode}", statusCode); + return false; } } @@ -473,6 +490,7 @@ public class JellyfinSessionManager : IDisposable /// /// 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). /// private async void KeepSessionsAlive(object? state) { @@ -484,29 +502,43 @@ public class JellyfinSessionManager : IDisposable return; } - _logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count); + _logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count); + + var expiredSessions = new List(); foreach (var session in activeSessions) { try { // Post capabilities again to keep session alive - // Note: This may fail with 401 if the client's token has expired - // That's okay - the WebSocket connection keeps the session alive anyway - await PostCapabilitiesAsync(session.Headers); + // If this returns false (401), the token has expired + var success = await PostCapabilitiesAsync(session.Headers); + + if (!success) + { + _logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId); + expiredSessions.Add(session.DeviceId); + } } catch (Exception ex) { - _logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId); + _logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId); } } + // Remove sessions with expired tokens + foreach (var deviceId in expiredSessions) + { + _logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId); + await RemoveSessionAsync(deviceId); + } + // Clean up stale sessions after 3 minutes of inactivity // This balances cleaning up finished sessions with allowing brief pauses/network issues var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList(); foreach (var stale in staleSessions) { - _logger.LogDebug("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)", + _logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)", stale.Key, (now - stale.Value.LastActivity).TotalMinutes); await RemoveSessionAsync(stale.Key); }