From 4226ead53a38926e00c78ce7b2efe3aa97b6e4f3 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 6 Feb 2026 11:48:01 -0500 Subject: [PATCH] Add file-based caching for admin UI and fix Jellyfin API usage - Added 5-minute file cache for playlist summary to speed up admin UI loads - Added refresh parameter to force cache bypass - Invalidate cache when playlists are refreshed or tracks are matched - Fixed incorrect use of anyProviderIdEquals (Emby API) in Jellyfin - Now searches Jellyfin by artist and title instead of provider ID - Fixes 401 errors and 'no client headers' warnings in lyrics prefetch - All 225 tests passing --- allstarr/Controllers/AdminController.cs | 84 ++++++++++++++++++- .../Services/Lyrics/LyricsPrefetchService.cs | 57 +++++++++---- 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index eb74cab..df42657 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -211,8 +211,40 @@ public class AdminController : ControllerBase /// Get list of configured playlists with their current data /// [HttpGet("playlists")] - public async Task GetPlaylists() + public async Task GetPlaylists([FromQuery] bool refresh = false) { + var playlistCacheFile = "/app/cache/admin_playlists_summary.json"; + + // Check file cache first (5 minute TTL) unless refresh is requested + if (!refresh && System.IO.File.Exists(playlistCacheFile)) + { + try + { + var fileInfo = new FileInfo(playlistCacheFile); + var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc; + + if (age.TotalMinutes < 5) + { + var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile); + var cachedData = JsonSerializer.Deserialize>(cachedJson); + _logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes); + return Ok(cachedData); + } + else + { + _logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read cached playlist summary"); + } + } + else if (refresh) + { + _logger.LogInformation("🔄 Force refresh requested for playlist summary"); + } + var playlists = new List(); // Read playlists directly from .env file to get the latest configuration @@ -541,6 +573,24 @@ public class AdminController : ControllerBase playlists.Add(playlistInfo); } + // Save to file cache + try + { + var cacheDir = "/app/cache"; + Directory.CreateDirectory(cacheDir); + var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json"); + + var response = new { playlists }; + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false }); + await System.IO.File.WriteAllTextAsync(cacheFile, json); + + _logger.LogDebug("💾 Saved playlist summary to cache"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save playlist summary cache"); + } + return Ok(new { playlists }); } @@ -872,6 +922,10 @@ public class AdminController : ControllerBase { _logger.LogInformation("Manual playlist refresh triggered from admin UI"); await _playlistFetcher.TriggerFetchAsync(); + + // Invalidate playlist summary cache + InvalidatePlaylistSummaryCache(); + return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); } @@ -892,6 +946,10 @@ public class AdminController : ControllerBase try { await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); + + // Invalidate playlist summary cache + InvalidatePlaylistSummaryCache(); + return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow }); } catch (Exception ex) @@ -3126,6 +3184,30 @@ public class AdminController : ControllerBase } #endregion + + #region Helper Methods + + /// + /// Invalidates the cached playlist summary so it will be regenerated on next request + /// + private void InvalidatePlaylistSummaryCache() + { + try + { + var cacheFile = "/app/cache/admin_playlists_summary.json"; + if (System.IO.File.Exists(cacheFile)) + { + System.IO.File.Delete(cacheFile); + _logger.LogDebug("🗑️ Invalidated playlist summary cache"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to invalidate playlist summary cache"); + } + } + + #endregion } public class ManualMappingRequest diff --git a/allstarr/Services/Lyrics/LyricsPrefetchService.cs b/allstarr/Services/Lyrics/LyricsPrefetchService.cs index 6ea87e3..9442ac8 100644 --- a/allstarr/Services/Lyrics/LyricsPrefetchService.cs +++ b/allstarr/Services/Lyrics/LyricsPrefetchService.cs @@ -148,7 +148,7 @@ public class LyricsPrefetchService : BackgroundService } // Check if this track has local Jellyfin lyrics (embedded in file) - var hasLocalLyrics = await CheckForLocalJellyfinLyricsAsync(track.SpotifyId); + var hasLocalLyrics = await CheckForLocalJellyfinLyricsAsync(track.SpotifyId, track.PrimaryArtist, track.Title); if (hasLocalLyrics) { cached++; @@ -353,7 +353,7 @@ public class LyricsPrefetchService : BackgroundService /// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API. /// This prevents downloading lyrics from LRCLib when the local file already has them. /// - private async Task CheckForLocalJellyfinLyricsAsync(string spotifyTrackId) + private async Task CheckForLocalJellyfinLyricsAsync(string spotifyTrackId, string artistName, string trackTitle) { try { @@ -365,13 +365,15 @@ public class LyricsPrefetchService : BackgroundService return false; } - // Search for the track in Jellyfin by Spotify provider ID + // Search for the track in Jellyfin by artist and title + // Jellyfin doesn't support anyProviderIdEquals - that's an Emby API parameter + var searchTerm = $"{artistName} {trackTitle}"; var searchParams = new Dictionary { - ["anyProviderIdEquals"] = $"Spotify.{spotifyTrackId}", + ["searchTerm"] = searchTerm, ["includeItemTypes"] = "Audio", ["recursive"] = "true", - ["limit"] = "1" + ["limit"] = "5" // Get a few results to find best match }; var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null); @@ -389,30 +391,57 @@ public class LyricsPrefetchService : BackgroundService return false; } - // Get the first matching track's ID - var firstItem = items[0]; - if (!firstItem.TryGetProperty("Id", out var idElement)) + // Find the best matching track by comparing artist and title + string? bestMatchId = null; + foreach (var item in items.EnumerateArray()) { - return false; + if (!item.TryGetProperty("Name", out var nameEl) || + !item.TryGetProperty("Id", out var idEl)) + { + continue; + } + + var itemTitle = nameEl.GetString() ?? ""; + var itemId = idEl.GetString(); + + // Check if title matches (case-insensitive) + if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase)) + { + // Also check artist if available + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + var itemArtist = artistsEl[0].GetString() ?? ""; + if (itemArtist.Equals(artistName, StringComparison.OrdinalIgnoreCase)) + { + bestMatchId = itemId; + break; // Exact match found + } + } + + // If no exact artist match but title matches, use it as fallback + if (bestMatchId == null) + { + bestMatchId = itemId; + } + } } - var jellyfinTrackId = idElement.GetString(); - if (string.IsNullOrEmpty(jellyfinTrackId)) + if (string.IsNullOrEmpty(bestMatchId)) { return false; } // Check if this track has lyrics var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync( - $"Audio/{jellyfinTrackId}/Lyrics", + $"Audio/{bestMatchId}/Lyrics", null, null); if (lyricsResult != null && lyricsStatusCode == 200) { // Track has embedded lyrics in Jellyfin - _logger.LogDebug("Found embedded lyrics in Jellyfin for Spotify track {SpotifyId} (Jellyfin ID: {JellyfinId})", - spotifyTrackId, jellyfinTrackId); + _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})", + artistName, trackTitle, bestMatchId); return true; }