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