entirely new session handling

This commit is contained in:
2026-02-02 18:37:38 -05:00
parent 04079223c2
commit 4c55520ce0
4 changed files with 299 additions and 86 deletions

View File

@@ -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<JellyfinController> _logger;
@@ -43,6 +44,7 @@ public class JellyfinController : ControllerBase
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
RedisCacheService cache,
ILogger<JellyfinController> 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<string>(),
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;
}
/// <summary>
/// Extracts device information from Authorization header.
/// </summary>
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

View File

@@ -177,6 +177,7 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
}

View File

@@ -0,0 +1,201 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Manages Jellyfin sessions for connected clients.
/// Creates sessions on first playback and keeps them alive with periodic pings.
/// </summary>
public class JellyfinSessionManager
{
private readonly JellyfinProxyService _proxyService;
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
JellyfinProxyService proxyService,
ILogger<JellyfinSessionManager> logger)
{
_proxyService = proxyService;
_logger = logger;
// Keep sessions alive every 30 seconds
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}
/// <summary>
/// Ensures a session exists for the given device. Creates one if needed.
/// </summary>
public async Task<bool> 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;
}
}
/// <summary>
/// Updates session activity timestamp.
/// </summary>
public void UpdateActivity(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastActivity = DateTime.UtcNow;
}
}
/// <summary>
/// Removes a session when the client disconnects.
/// </summary>
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);
}
}
}
/// <summary>
/// Periodically pings Jellyfin to keep sessions alive.
/// </summary>
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; }
}
}

50
test-websocket.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
</head>
<body>
<h1>Jellyfin WebSocket Test</h1>
<div id="status">Connecting...</div>
<div id="messages"></div>
<script>
// Replace with your actual token and device ID
const token = "4d19e81402394d40a7e787222606b3c2";
const deviceId = "test-device-123";
// Connect to your proxy
const wsUrl = `ws://jfm.joshpatra.me/socket?api_key=${token}&deviceId=${deviceId}`;
console.log("Connecting to:", wsUrl);
document.getElementById('status').textContent = `Connecting to: ${wsUrl}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("✓ WebSocket connected!");
document.getElementById('status').textContent = "✓ Connected!";
document.getElementById('status').style.color = "green";
};
ws.onmessage = (event) => {
console.log("Message received:", event.data);
const msgDiv = document.createElement('div');
msgDiv.textContent = `[${new Date().toLocaleTimeString()}] ${event.data}`;
document.getElementById('messages').appendChild(msgDiv);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
document.getElementById('status').textContent = "✗ Error!";
document.getElementById('status').style.color = "red";
};
ws.onclose = (event) => {
console.log("WebSocket closed:", event.code, event.reason);
document.getElementById('status').textContent = `✗ Closed (${event.code})`;
document.getElementById('status').style.color = "orange";
};
</script>
</body>
</html>