mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
entirely new session handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
201
allstarr/Services/Jellyfin/JellyfinSessionManager.cs
Normal file
201
allstarr/Services/Jellyfin/JellyfinSessionManager.cs
Normal 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
50
test-websocket.html
Normal 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>
|
||||
Reference in New Issue
Block a user