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
}
}