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);
|
_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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_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)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user