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
This commit is contained in:
2026-02-06 11:48:01 -05:00
parent 2155c4a9d5
commit 4226ead53a
2 changed files with 126 additions and 15 deletions

View File

@@ -211,8 +211,40 @@ public class AdminController : ControllerBase
/// Get list of configured playlists with their current data /// Get list of configured playlists with their current data
/// </summary> /// </summary>
[HttpGet("playlists")] [HttpGet("playlists")]
public async Task<IActionResult> GetPlaylists() public async Task<IActionResult> 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<Dictionary<string, object>>(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<object>(); var playlists = new List<object>();
// Read playlists directly from .env file to get the latest configuration // Read playlists directly from .env file to get the latest configuration
@@ -541,6 +573,24 @@ public class AdminController : ControllerBase
playlists.Add(playlistInfo); 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 }); return Ok(new { playlists });
} }
@@ -872,6 +922,10 @@ public class AdminController : ControllerBase
{ {
_logger.LogInformation("Manual playlist refresh triggered from admin UI"); _logger.LogInformation("Manual playlist refresh triggered from admin UI");
await _playlistFetcher.TriggerFetchAsync(); await _playlistFetcher.TriggerFetchAsync();
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
} }
@@ -892,6 +946,10 @@ public class AdminController : ControllerBase
try try
{ {
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow }); return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
} }
catch (Exception ex) catch (Exception ex)
@@ -3126,6 +3184,30 @@ public class AdminController : ControllerBase
} }
#endregion #endregion
#region Helper Methods
/// <summary>
/// Invalidates the cached playlist summary so it will be regenerated on next request
/// </summary>
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 public class ManualMappingRequest

View File

@@ -148,7 +148,7 @@ public class LyricsPrefetchService : BackgroundService
} }
// Check if this track has local Jellyfin lyrics (embedded in file) // 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) if (hasLocalLyrics)
{ {
cached++; cached++;
@@ -353,7 +353,7 @@ public class LyricsPrefetchService : BackgroundService
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API. /// 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. /// This prevents downloading lyrics from LRCLib when the local file already has them.
/// </summary> /// </summary>
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId) private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId, string artistName, string trackTitle)
{ {
try try
{ {
@@ -365,13 +365,15 @@ public class LyricsPrefetchService : BackgroundService
return false; 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<string, string> var searchParams = new Dictionary<string, string>
{ {
["anyProviderIdEquals"] = $"Spotify.{spotifyTrackId}", ["searchTerm"] = searchTerm,
["includeItemTypes"] = "Audio", ["includeItemTypes"] = "Audio",
["recursive"] = "true", ["recursive"] = "true",
["limit"] = "1" ["limit"] = "5" // Get a few results to find best match
}; };
var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null); var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null);
@@ -389,30 +391,57 @@ public class LyricsPrefetchService : BackgroundService
return false; return false;
} }
// Get the first matching track's ID // Find the best matching track by comparing artist and title
var firstItem = items[0]; string? bestMatchId = null;
if (!firstItem.TryGetProperty("Id", out var idElement)) foreach (var item in items.EnumerateArray())
{ {
return false; if (!item.TryGetProperty("Name", out var nameEl) ||
!item.TryGetProperty("Id", out var idEl))
{
continue;
} }
var jellyfinTrackId = idElement.GetString(); var itemTitle = nameEl.GetString() ?? "";
if (string.IsNullOrEmpty(jellyfinTrackId)) 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;
}
}
}
if (string.IsNullOrEmpty(bestMatchId))
{ {
return false; return false;
} }
// Check if this track has lyrics // Check if this track has lyrics
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync( var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
$"Audio/{jellyfinTrackId}/Lyrics", $"Audio/{bestMatchId}/Lyrics",
null, null,
null); null);
if (lyricsResult != null && lyricsStatusCode == 200) if (lyricsResult != null && lyricsStatusCode == 200)
{ {
// Track has embedded lyrics in Jellyfin // Track has embedded lyrics in Jellyfin
_logger.LogDebug("Found embedded lyrics in Jellyfin for Spotify track {SpotifyId} (Jellyfin ID: {JellyfinId})", _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})",
spotifyTrackId, jellyfinTrackId); artistName, trackTitle, bestMatchId);
return true; return true;
} }