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
This commit is contained in:
2026-02-07 23:25:14 -05:00
parent f1dd01f6d5
commit 56bc9d4ea9
4 changed files with 163 additions and 287 deletions

View File

@@ -18,13 +18,17 @@ SUBSONIC_URL=http://localhost:4533
# Server URL (required if using Jellyfin backend) # Server URL (required if using Jellyfin backend)
JELLYFIN_URL=http://localhost:8096 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= 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= JELLYFIN_USER_ID=
# Music library ID (optional, auto-detected if not set) # Music library ID (optional, auto-detected if not set)
# If you have multiple libraries, set this to filter to music only
JELLYFIN_LIBRARY_ID= JELLYFIN_LIBRARY_ID=
# ===== MUSIC SOURCE SELECTION ===== # ===== MUSIC SOURCE SELECTION =====

View File

@@ -2,239 +2,44 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace allstarr.Filters; namespace allstarr.Filters;
/// <summary> /// <summary>
/// Authentication filter for Jellyfin API endpoints. /// REMOVED: Authentication filter for Jellyfin API endpoints.
/// Validates client credentials against configured username and API key. ///
/// Clients can authenticate via: /// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
/// - Authorization header: MediaBrowser Token="apikey" /// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
/// - X-Emby-Token header ///
/// - Query parameter: api_key /// Authentication flow:
/// - JSON body (for login endpoints): Username/Pw fields /// 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.
/// </summary> /// </summary>
public partial class JellyfinAuthFilter : IAsyncActionFilter public class JellyfinAuthFilter : IAsyncActionFilter
{ {
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinAuthFilter> _logger; private readonly ILogger<JellyfinAuthFilter> _logger;
public JellyfinAuthFilter( public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger)
IOptions<JellyfinSettings> settings,
ILogger<JellyfinAuthFilter> logger)
{ {
_settings = settings.Value;
_logger = logger; _logger = logger;
} }
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{ {
// Skip auth if no credentials configured (open mode) // This filter is now a no-op - all authentication is handled by Jellyfin
if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey)) // Keeping the class for backwards compatibility but it does nothing
{
_logger.LogDebug("Auth skipped - no client credentials configured"); _logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
await next();
return;
}
var request = context.HttpContext.Request;
// 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(); 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();
} }

View File

@@ -168,6 +168,11 @@ public class JellyfinProxyService
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) || (h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true); 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 // Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0) if (clientHeaders != null && clientHeaders.Count > 0)
{ {
@@ -179,11 +184,27 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString(); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization: {Value}", headerValue); _logger.LogTrace("Forwarded X-Emby-Authorization header");
break; 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 // If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
// Some clients send it as "Authorization" instead of "X-Emby-Authorization" // Some clients send it as "Authorization" instead of "X-Emby-Authorization"
if (!authHeaderAdded) if (!authHeaderAdded)
@@ -201,37 +222,32 @@ public class JellyfinProxyService
// Forward as X-Emby-Authorization (Jellyfin's expected header) // Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Converted Authorization to X-Emby-Authorization: {Value}", headerValue); _logger.LogTrace("Converted Authorization to X-Emby-Authorization");
} }
else else
{ {
// Standard Bearer token - forward as-is // Standard Bearer token - forward as-is
request.Headers.TryAddWithoutValidation("Authorization", headerValue); request.Headers.TryAddWithoutValidation("Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Forwarded Authorization (Bearer): {Value}", headerValue); _logger.LogTrace("Forwarded Authorization header");
} }
break; break;
} }
} }
} }
// Only log warnings for non-browser static requests // Check for api_key query parameter (some clients use this)
if (!authHeaderAdded && !isBrowserStaticRequest) if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}", authHeaderAdded = true; // It's in the URL, no need to add header
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}"))); _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 // Only log warnings for non-public, non-browser requests without auth
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin) if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
_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")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -248,14 +264,28 @@ public class JellyfinProxyService
{ {
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) 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); _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); return (null, statusCode);
} }
@@ -297,8 +327,10 @@ public class JellyfinProxyService
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
bool authHeaderAdded = false; bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client (case-insensitive) // Forward authentication headers from client (case-insensitive)
// Try X-Emby-Authorization first
foreach (var header in clientHeaders) foreach (var header in clientHeaders)
{ {
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
@@ -306,11 +338,28 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString(); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client"); _logger.LogTrace("Forwarded X-Emby-Authorization header");
break; 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) if (!authHeaderAdded)
{ {
foreach (var header in clientHeaders) foreach (var header in clientHeaders)
@@ -325,13 +374,13 @@ public class JellyfinProxyService
{ {
// Forward as X-Emby-Authorization // Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization"); _logger.LogTrace("Converted Authorization to X-Emby-Authorization");
} }
else else
{ {
// Standard Bearer token // Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue); request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header"); _logger.LogTrace("Forwarded Authorization header");
} }
authHeaderAdded = true; authHeaderAdded = true;
break; break;
@@ -339,30 +388,23 @@ public class JellyfinProxyService
} }
} }
// DO NOT use server credentials as fallback // For authentication endpoints, credentials are in the body, not headers
// Exception: For auth endpoints, client provides their own credentials in the body // For other endpoints without auth, let Jellyfin reject the request
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it if (!authHeaderAdded && !isAuthEndpoint)
if (!authHeaderAdded)
{ {
_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")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords! // 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); _logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
} }
else else
{ {
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); _logger.LogTrace("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);
}
} }
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
@@ -376,12 +418,12 @@ public class JellyfinProxyService
// 401 is expected when tokens expire - don't spam logs // 401 is expected when tokens expire - don't spam logs
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) 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 else
{ {
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", _logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent); response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
} }
// Try to parse error response as JSON to pass through to client // Try to parse error response as JSON to pass through to client
@@ -404,7 +446,7 @@ public class JellyfinProxyService
// Log successful session-related responses // Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase)) 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) // Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
@@ -421,13 +463,6 @@ public class JellyfinProxyService
return (null, statusCode); 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); return (JsonDocument.Parse(responseContent), statusCode);
} }

View File

@@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable
/// <summary> /// <summary>
/// Ensures a session exists for the given device. Creates one if needed. /// 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.
/// </summary> /// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers) public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{ {
if (string.IsNullOrEmpty(deviceId)) if (string.IsNullOrEmpty(deviceId))
{ {
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID"); _logger.LogWarning("Cannot create session - no device ID");
return false; return false;
} }
@@ -51,25 +52,37 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var existingSession)) if (_sessions.TryGetValue(deviceId, out var existingSession))
{ {
existingSession.LastActivity = DateTime.UtcNow; 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 // 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; return true;
} }
_logger.LogDebug("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); _logger.LogDebug("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))}...")));
try try
{ {
// Post session capabilities to Jellyfin - this creates the session // 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 // Track this session
_sessions[deviceId] = new SessionInfo _sessions[deviceId] = new SessionInfo
@@ -89,15 +102,16 @@ public class JellyfinSessionManager : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId); _logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
return false; return false;
} }
} }
/// <summary> /// <summary>
/// Posts session capabilities to Jellyfin. /// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// </summary> /// </summary>
private async Task PostCapabilitiesAsync(IHeaderDictionary headers) private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{ {
var capabilities = new var capabilities = new
{ {
@@ -118,16 +132,19 @@ public class JellyfinSessionManager : IDisposable
if (statusCode == 204 || statusCode == 200) 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) else if (statusCode == 401)
{ {
// Token expired - this is expected, don't spam logs // Token expired - this is expected, client needs to re-authenticate
_logger.LogDebug("SESSION: Capabilities returned 401 (token expired, will use fresh headers on next request)"); _logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
} }
else 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
/// <summary> /// <summary>
/// Periodically pings Jellyfin to keep sessions alive. /// Periodically pings Jellyfin to keep sessions alive.
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive. /// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary> /// </summary>
private async void KeepSessionsAlive(object? state) private async void KeepSessionsAlive(object? state)
{ {
@@ -484,29 +502,43 @@ public class JellyfinSessionManager : IDisposable
return; return;
} }
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count); _logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions) foreach (var session in activeSessions)
{ {
try try
{ {
// Post capabilities again to keep session alive // Post capabilities again to keep session alive
// Note: This may fail with 401 if the client's token has expired // If this returns false (401), the token has expired
// That's okay - the WebSocket connection keeps the session alive anyway var success = await PostCapabilitiesAsync(session.Headers);
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) 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 // Clean up stale sessions after 3 minutes of inactivity
// This balances cleaning up finished sessions with allowing brief pauses/network issues // 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(); var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions) 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); stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key); await RemoveSessionAsync(stale.Key);
} }