Fix RuntimeBinderException, add session cleanup, memory stats endpoint, and fix all warnings

- Fixed RuntimeBinderException when comparing JsonElement with null
- Added HasValue() helper method for safe dynamic type checking
- Implemented intelligent session cleanup:
  * 50 seconds after playback stops (allows song changes)
  * 3 minutes of total inactivity (catches crashed clients)
- Added memory stats endpoint: GET /api/admin/memory-stats
- Added sessions monitoring endpoint: GET /api/admin/sessions
- Added GetSessionsInfo() to JellyfinSessionManager for debugging
- Fixed all nullable reference warnings
- Reduced warnings from 10 to 0
This commit is contained in:
2026-02-05 09:17:40 -05:00
parent d9c0b8bb54
commit 3fd13b855d
5 changed files with 136 additions and 16 deletions

View File

@@ -82,6 +82,17 @@ public class AdminController : ControllerBase
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath); _logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
} }
/// <summary>
/// Helper method to safely check if a dynamic cache result has a value
/// Handles the case where JsonElement cannot be compared to null directly
/// </summary>
private static bool HasValue(dynamic? obj)
{
if (obj == null) return false;
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null;
return true;
}
/// <summary> /// <summary>
/// Get current system status and configuration /// Get current system status and configuration
/// </summary> /// </summary>
@@ -522,12 +533,12 @@ public class AdminController : ControllerBase
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey); var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null) if (HasValue(externalMapping))
{ {
try try
{ {
var provider = externalMapping.provider?.ToString(); var provider = externalMapping?.provider?.ToString();
var externalId = externalMapping.id?.ToString(); var externalId = externalMapping?.id?.ToString();
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)) if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{ {
@@ -535,7 +546,7 @@ public class AdminController : ControllerBase
isLocal = false; isLocal = false;
externalProvider = provider; externalProvider = provider;
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}", _logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
track.Title, (object)provider, (object)externalId); track.Title, (object?)provider, (object?)externalId);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -646,11 +657,11 @@ public class AdminController : ControllerBase
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey); var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null) if (HasValue(externalMapping))
{ {
try try
{ {
var provider = externalMapping.provider?.ToString(); var provider = externalMapping?.provider?.ToString();
if (!string.IsNullOrEmpty(provider)) if (!string.IsNullOrEmpty(provider))
{ {
isLocal = false; isLocal = false;
@@ -2137,6 +2148,29 @@ public class AdminController : ControllerBase
} }
} }
/// <summary>
/// Gets current active sessions for debugging.
/// </summary>
[HttpGet("sessions")]
public IActionResult GetActiveSessions()
{
try
{
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
if (sessionManager == null)
{
return BadRequest(new { error = "Session manager not available" });
}
var sessionInfo = sessionManager.GetSessionsInfo();
return Ok(sessionInfo);
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary> /// <summary>
/// Helper method to trigger GC after large file operations to prevent memory leaks. /// Helper method to trigger GC after large file operations to prevent memory leaks.
/// </summary> /// </summary>

View File

@@ -2217,6 +2217,7 @@ public class JellyfinController : ControllerBase
string? itemId = null; string? itemId = null;
string? itemName = null; string? itemName = null;
long? positionTicks = null; long? positionTicks = null;
string? deviceId = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{ {
@@ -2233,6 +2234,12 @@ public class JellyfinController : ControllerBase
positionTicks = posProp.GetInt64(); positionTicks = posProp.GetInt64();
} }
// Try to get device ID from headers for session management
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
{
deviceId = deviceIdHeader.FirstOrDefault();
}
if (!string.IsNullOrEmpty(itemId)) if (!string.IsNullOrEmpty(itemId))
{ {
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
@@ -2244,6 +2251,14 @@ public class JellyfinController : ControllerBase
: "unknown"; : "unknown";
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})", _logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId); itemName ?? "Unknown", position, provider, externalId);
// Mark session as potentially ended after playback stops
// Wait 50 seconds for next song to start before cleaning up
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(50));
}
return NoContent(); return NoContent();
} }

View File

@@ -143,6 +143,64 @@ public class JellyfinSessionManager : IDisposable
} }
} }
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// The session will be cleaned up if no new activity occurs within the timeout.
/// </summary>
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
deviceId, timeout.TotalSeconds);
_ = Task.Run(async () =>
{
var markedTime = DateTime.UtcNow;
await Task.Delay(timeout);
// Check if there's been activity since we marked it
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime)
{
_logger.LogInformation("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId);
}
else
{
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
}
});
}
}
/// <summary>
/// Gets information about current active sessions for debugging.
/// </summary>
public object GetSessionsInfo()
{
var now = DateTime.UtcNow;
var sessions = _sessions.Values.Select(s => new
{
DeviceId = s.DeviceId,
Client = s.Client,
Device = s.Device,
Version = s.Version,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
{
TotalSessions = sessions.Count,
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
};
}
/// <summary> /// <summary>
/// Removes a session when the client disconnects. /// Removes a session when the client disconnects.
/// </summary> /// </summary>
@@ -408,12 +466,14 @@ public class JellyfinSessionManager : IDisposable
} }
} }
// Clean up stale sessions (inactive for > 10 minutes) // Clean up stale sessions after 3 minutes of inactivity
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList(); // 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) foreach (var stale in staleSessions)
{ {
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key); _logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
_sessions.TryRemove(stale.Key, out _); stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key);
} }
} }

View File

@@ -77,7 +77,7 @@ public class MusicBrainzService
// Return the first recording (ISRCs should be unique) // Return the first recording (ISRCs should be unique)
var recording = result.Recordings[0]; var recording = result.Recordings[0];
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string>(); var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})", _logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres)); isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));

View File

@@ -41,6 +41,17 @@ public class SpotifyTrackMatchingService : BackgroundService
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
} }
/// <summary>
/// Helper method to safely check if a dynamic cache result has a value
/// Handles the case where JsonElement cannot be compared to null directly
/// </summary>
private static bool HasValue(dynamic? obj)
{
if (obj == null) return false;
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null;
return true;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
@@ -318,7 +329,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Also check for external manual mappings // Also check for external manual mappings
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey); var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null) if (HasValue(externalMapping))
{ {
hasManualMappings = true; hasManualMappings = true;
break; break;
@@ -822,12 +833,12 @@ public class SpotifyTrackMatchingService : BackgroundService
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey); var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null) if (HasValue(externalMapping))
{ {
try try
{ {
var provider = externalMapping.provider?.ToString(); var provider = externalMapping?.provider?.ToString();
var externalId = externalMapping.id?.ToString(); var externalId = externalMapping?.id?.ToString();
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)) if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{ {
@@ -852,7 +863,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}); });
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}", _logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, (object)provider, (object)externalId); spotifyTrack.Title, (object?)provider, (object?)externalId);
continue; // Skip to next track continue; // Skip to next track
} }
} }