diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs index 088581f..89179f1 100644 --- a/allstarr.Tests/JellyfinProxyServiceTests.cs +++ b/allstarr.Tests/JellyfinProxyServiceTests.cs @@ -63,11 +63,12 @@ public class JellyfinProxyServiceTests SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json"); // Act - var result = await _service.GetJsonAsync("Items"); + var (body, statusCode) = await _service.GetJsonAsync("Items"); // Assert - Assert.NotNull(result); - Assert.True(result.RootElement.TryGetProperty("Items", out var items)); + Assert.NotNull(body); + Assert.Equal(200, statusCode); + Assert.True(body.RootElement.TryGetProperty("Items", out var items)); Assert.Equal(1, items.GetArrayLength()); } @@ -78,10 +79,11 @@ public class JellyfinProxyServiceTests SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain"); // Act - var result = await _service.GetJsonAsync("Items"); + var (body, statusCode) = await _service.GetJsonAsync("Items"); // Assert - Assert.Null(result); + Assert.Null(body); + Assert.Equal(500, statusCode); } [Fact] @@ -207,12 +209,13 @@ public class JellyfinProxyServiceTests }); // Act - var result = await _service.GetItemAsync("abc-123"); + var (body, statusCode) = await _service.GetItemAsync("abc-123"); // Assert Assert.NotNull(captured); Assert.Contains("/Items/abc-123", captured!.RequestUri!.ToString()); - Assert.NotNull(result); + Assert.NotNull(body); + Assert.Equal(200, statusCode); } [Fact] diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 7bf4efc..cd161c3 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2448,7 +2448,10 @@ public class JellyfinController : ControllerBase } // Modify response if it contains Spotify playlists to update ChildCount - if (_spotifySettings.Enabled && result.RootElement.TryGetProperty("Items", out var items)) + // Only check for Items if the response is an object (not a string or array) + if (_spotifySettings.Enabled && + result.RootElement.ValueKind == JsonValueKind.Object && + result.RootElement.TryGetProperty("Items", out var items)) { _logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts"); result = await UpdateSpotifyPlaylistCounts(result); @@ -2782,11 +2785,11 @@ public class JellyfinController : ControllerBase { missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName); - // If we loaded from file, restore to Redis + // If we loaded from file, restore to Redis with no expiration if (missingTracks != null && missingTracks.Count > 0) { - await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromHours(24)); - _logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist}", + await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365)); + _logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)", missingTracks.Count, spotifyPlaylistName); } } @@ -2981,12 +2984,9 @@ public class JellyfinController : ControllerBase return null; } + // No expiration check - cache persists until next Jellyfin job generates new file var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath); - if (fileAge > TimeSpan.FromHours(24)) - { - _logger.LogDebug("File cache for {Playlist} is too old ({Age:F1}h)", playlistName, fileAge.TotalHours); - return null; - } + _logger.LogDebug("File cache for {Playlist} age: {Age:F1}h (no expiration)", playlistName, fileAge.TotalHours); var json = await System.IO.File.ReadAllTextAsync(filePath); var tracks = JsonSerializer.Deserialize>(json); diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs index 036dc31..85a8235 100644 --- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -169,28 +169,20 @@ public class SpotifyMissingTracksFetcher : BackgroundService { var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); _logger.LogInformation(" File exists! Age: {Age:F1}h", fileAge.TotalHours); + _logger.LogInformation(" ✓ Found file cache (age: {Age:F1}h, no expiration)", fileAge.TotalHours); - if (fileAge < TimeSpan.FromHours(24)) + // Load from file into Redis if not already there + var key = $"spotify:missing:{playlistName}"; + if (!await _cache.ExistsAsync(key)) { - _logger.LogInformation(" ✓ Found recent file cache (age: {Age:F1}h)", fileAge.TotalHours); - - // Load from file into Redis if not already there - var key = $"spotify:missing:{playlistName}"; - if (!await _cache.ExistsAsync(key)) - { - _logger.LogInformation(" Loading into Redis..."); - await LoadFromFileCache(playlistName); - } - else - { - _logger.LogInformation(" Already in Redis"); - } - return false; + _logger.LogInformation(" Loading into Redis..."); + await LoadFromFileCache(playlistName); } else { - _logger.LogInformation(" File too old ({Age:F1}h > 24h), will fetch new", fileAge.TotalHours); + _logger.LogInformation(" Already in Redis"); } + return false; } else { @@ -234,14 +226,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService { var cacheKey = $"spotify:missing:{playlistName}"; var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); - var ttl = TimeSpan.FromHours(24) - fileAge; - if (ttl > TimeSpan.Zero) - { - await _cache.SetAsync(cacheKey, tracks, ttl); - _logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist}", - tracks.Count, playlistName); - } + // No expiration - cache persists until next Jellyfin job generates new file + await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); + _logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)", + tracks.Count, playlistName, fileAge.TotalHours); } } catch (Exception ex) @@ -308,13 +297,26 @@ public class SpotifyMissingTracksFetcher : BackgroundService { var cacheKey = $"spotify:missing:{playlistName}"; - if (await _cache.ExistsAsync(cacheKey)) + // Check if we have existing cache and when it was last updated + var existingTracks = await _cache.GetAsync>(cacheKey); + var existingFileTime = DateTime.MinValue; + var filePath = GetCacheFilePath(playlistName); + + if (File.Exists(filePath)) { - _logger.LogInformation(" ✓ Cache already exists for {Playlist}, skipping fetch", playlistName); - return; + existingFileTime = File.GetLastWriteTimeUtc(filePath); + _logger.LogInformation(" Existing cache file from: {Time} ({Age:F1}h ago)", + existingFileTime, (DateTime.UtcNow - existingFileTime).TotalHours); + } + + if (existingTracks != null && existingTracks.Count > 0) + { + _logger.LogInformation(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count); + } + else + { + _logger.LogInformation(" No existing cache, will search for missing tracks file"); } - - _logger.LogInformation(" No cache found, will search for missing tracks file..."); var settings = _spotifySettings.Value; var jellyfinUrl = _jellyfinSettings.Value.Url; @@ -340,6 +342,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService _logger.LogInformation(" Searching +12h forward, -24h backward from {SyncTime}", syncTime); var found = false; + DateTime? foundFileTime = null; // Search forward 12 hours from sync time _logger.LogInformation(" Phase 1: Searching forward 12 hours from sync time..."); @@ -348,9 +351,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService if (cancellationToken.IsCancellationRequested) break; var time = syncTime.AddMinutes(minutesAhead); - if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken)) + var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime); + if (result.found) { found = true; + foundFileTime = result.fileTime; break; } @@ -370,9 +375,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService if (cancellationToken.IsCancellationRequested) break; var time = syncTime.AddMinutes(-minutesBehind); - if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken)) + var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime); + if (result.found) { found = true; + foundFileTime = result.fileTime; break; } @@ -386,17 +393,54 @@ public class SpotifyMissingTracksFetcher : BackgroundService if (!found) { - _logger.LogWarning(" ✗ Could not find missing tracks file (searched +12h/-24h window)"); + _logger.LogWarning(" ✗ Could not find new missing tracks file (searched +12h/-24h window)"); + + // Keep the existing cache - don't let it expire + if (existingTracks != null && existingTracks.Count > 0) + { + _logger.LogInformation(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count); + // Re-save with no expiration to ensure it persists + await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration + } + else if (File.Exists(filePath)) + { + // Load from file if Redis cache is empty + _logger.LogInformation(" 📦 Loading existing file cache to keep playlist populated"); + try + { + var json = await File.ReadAllTextAsync(filePath, cancellationToken); + var tracks = JsonSerializer.Deserialize>(json); + + if (tracks != null && tracks.Count > 0) + { + await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration + _logger.LogInformation(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, " Failed to reload cache from file for {Playlist}", playlistName); + } + } + else + { + _logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found"); + } + } + else if (foundFileTime.HasValue) + { + _logger.LogInformation(" ✓ Updated cache with newer file from {Time}", foundFileTime.Value); } } - private async Task TryFetchMissingTracksFile( + private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile( string playlistName, DateTime time, string jellyfinUrl, string apiKey, HttpClient httpClient, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + DateTime existingFileTime) { var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json"; var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" + @@ -413,16 +457,24 @@ public class SpotifyMissingTracksFetcher : BackgroundService if (tracks.Count > 0) { + // Check if this file is newer than what we already have + if (time <= existingFileTime) + { + _logger.LogDebug(" Skipping {Filename} - not newer than existing cache", filename); + return (false, null); + } + var cacheKey = $"spotify:missing:{playlistName}"; - // Save to both Redis and file - await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24)); + // Save to both Redis and file with extended TTL until next job runs + // Set to 365 days (effectively no expiration) - will be replaced when Jellyfin generates new file + await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); await SaveToFileCache(playlistName, tracks); _logger.LogInformation( - "✓ Cached {Count} missing tracks for {Playlist} from {Filename}", + "✓ Cached {Count} missing tracks for {Playlist} from {Filename} (no expiration until next Jellyfin job)", tracks.Count, playlistName, filename); - return true; + return (true, time); } } } @@ -431,7 +483,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService _logger.LogDebug(ex, "Failed to fetch {Filename}", filename); } - return false; + return (false, null); } private List ParseMissingTracks(string json)