mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -82,6 +82,17 @@ public class AdminController : ControllerBase
|
||||
_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>
|
||||
/// Get current system status and configuration
|
||||
/// </summary>
|
||||
@@ -522,12 +533,12 @@ public class AdminController : ControllerBase
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
|
||||
if (externalMapping != null)
|
||||
if (HasValue(externalMapping))
|
||||
{
|
||||
try
|
||||
{
|
||||
var provider = externalMapping.provider?.ToString();
|
||||
var externalId = externalMapping.id?.ToString();
|
||||
var provider = externalMapping?.provider?.ToString();
|
||||
var externalId = externalMapping?.id?.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
@@ -535,7 +546,7 @@ public class AdminController : ControllerBase
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
_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)
|
||||
@@ -646,11 +657,11 @@ public class AdminController : ControllerBase
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
|
||||
if (externalMapping != null)
|
||||
if (HasValue(externalMapping))
|
||||
{
|
||||
try
|
||||
{
|
||||
var provider = externalMapping.provider?.ToString();
|
||||
var provider = externalMapping?.provider?.ToString();
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
{
|
||||
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>
|
||||
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||
/// </summary>
|
||||
|
||||
@@ -2217,6 +2217,7 @@ public class JellyfinController : ControllerBase
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
string? deviceId = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
@@ -2233,6 +2234,12 @@ public class JellyfinController : ControllerBase
|
||||
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))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
@@ -2244,6 +2251,14 @@ public class JellyfinController : ControllerBase
|
||||
: "unknown";
|
||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
/// Removes a session when the client disconnects.
|
||||
/// </summary>
|
||||
@@ -408,12 +466,14 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale sessions (inactive for > 10 minutes)
|
||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
|
||||
// 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.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
|
||||
_sessions.TryRemove(stale.Key, out _);
|
||||
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
|
||||
await RemoveSessionAsync(stale.Key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ public class MusicBrainzService
|
||||
|
||||
// Return the first recording (ISRCs should be unique)
|
||||
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})",
|
||||
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||
|
||||
|
||||
@@ -41,6 +41,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_serviceProvider = serviceProvider;
|
||||
_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)
|
||||
{
|
||||
@@ -318,7 +329,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Also check for external manual mappings
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
if (externalMapping != null)
|
||||
if (HasValue(externalMapping))
|
||||
{
|
||||
hasManualMappings = true;
|
||||
break;
|
||||
@@ -822,12 +833,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
|
||||
if (externalMapping != null)
|
||||
if (HasValue(externalMapping))
|
||||
{
|
||||
try
|
||||
{
|
||||
var provider = externalMapping.provider?.ToString();
|
||||
var externalId = externalMapping.id?.ToString();
|
||||
var provider = externalMapping?.provider?.ToString();
|
||||
var externalId = externalMapping?.id?.ToString();
|
||||
|
||||
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}",
|
||||
spotifyTrack.Title, (object)provider, (object)externalId);
|
||||
spotifyTrack.Title, (object?)provider, (object?)externalId);
|
||||
continue; // Skip to next track
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user