diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index daa935d..86e2dff 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -82,6 +82,17 @@ public class AdminController : ControllerBase _logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath); } + /// + /// Helper method to safely check if a dynamic cache result has a value + /// Handles the case where JsonElement cannot be compared to null directly + /// + private static bool HasValue(dynamic? obj) + { + if (obj == null) return false; + if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null; + return true; + } + /// /// Get current system status and configuration /// @@ -522,12 +533,12 @@ public class AdminController : ControllerBase var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMapping = await _cache.GetAsync(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(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 } } + /// + /// Gets current active sessions for debugging. + /// + [HttpGet("sessions")] + public IActionResult GetActiveSessions() + { + try + { + var sessionManager = HttpContext.RequestServices.GetService(); + 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 }); + } + } + /// /// Helper method to trigger GC after large file operations to prevent memory leaks. /// diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index b9668f8..dc91dd9 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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(); } diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index 74b66d6..859cde3 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -143,6 +143,64 @@ public class JellyfinSessionManager : IDisposable } } + /// + /// 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. + /// + 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); + } + }); + } + } + + /// + /// Gets information about current active sessions for debugging. + /// + 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) + }; + } + /// /// Removes a session when the client disconnects. /// @@ -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); } } diff --git a/allstarr/Services/MusicBrainz/MusicBrainzService.cs b/allstarr/Services/MusicBrainz/MusicBrainzService.cs index 0904970..025a2ee 100644 --- a/allstarr/Services/MusicBrainz/MusicBrainzService.cs +++ b/allstarr/Services/MusicBrainz/MusicBrainzService.cs @@ -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(); + var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List(); _logger.LogInformation("โœ“ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})", isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres)); diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index b8e30d0..5f7daa2 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -41,6 +41,17 @@ public class SpotifyTrackMatchingService : BackgroundService _serviceProvider = serviceProvider; _logger = logger; } + + /// + /// Helper method to safely check if a dynamic cache result has a value + /// Handles the case where JsonElement cannot be compared to null directly + /// + 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(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(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 } }