From a75df9328ac2686fb8ee6536109bb594e49f2a83 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 7 Feb 2026 11:14:36 -0500 Subject: [PATCH] fix: use playlist cache in view tracks endpoint --- allstarr/Controllers/AdminController.cs | 360 +++++++++++------------- 1 file changed, 160 insertions(+), 200 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 05c3ead..fd44443 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -603,236 +603,198 @@ public class AdminController : ControllerBase // Get Spotify tracks var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); - // Get the playlist config to find Jellyfin ID - var playlistConfig = _spotifyImportSettings.Playlists - .FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase)); - var tracksWithStatus = new List(); - if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) + // Use the pre-built playlist cache (same as GetPlaylists endpoint) + // This cache includes all matched tracks with proper provider IDs + var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}"; + + List>? cachedPlaylistItems = null; + try { - // Get existing tracks from Jellyfin to determine local/external status - var userId = _jellyfinSettings.UserId; - if (!string.IsNullOrEmpty(userId)) + cachedPlaylistItems = await _cache.GetAsync>>(playlistItemsCacheKey); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName); + } + + _logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}", + decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0); + + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) + { + // Build a map of Spotify ID -> cached item for quick lookup + var spotifyIdToItem = new Dictionary>(); + + foreach (var item in cachedPlaylistItems) { - try + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) { - var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}"; - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + Dictionary? providerIds = null; - var response = await _jellyfinHttpClient.SendAsync(request); - if (response.IsSuccessStatusCode) + if (providerIdsObj is Dictionary dict) { - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - - // Build list of local tracks (match by name only - no Spotify IDs!) - var localTracks = new List<(string Title, string Artist)>(); - if (doc.RootElement.TryGetProperty("Items", out var items)) + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + foreach (var prop in jsonEl.EnumerateObject()) { - foreach (var item in items.EnumerateArray()) + providerIds[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId)) + { + spotifyIdToItem[spotifyId] = item; + } + } + } + + // Match each Spotify track to its cached item + foreach (var track in spotifyTracks) + { + bool? isLocal = null; + string? externalProvider = null; + bool isManualMapping = false; + string? manualMappingType = null; + string? manualMappingId = null; + + if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem)) + { + // Track is in the cache - determine if it's local or external + if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + foreach (var prop in jsonEl.EnumerateObject()) { - var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; - var artist = ""; - - if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) - { - artist = artistsEl[0].GetString() ?? ""; - } - else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) - { - artist = albumArtistEl.GetString() ?? ""; - } - - if (!string.IsNullOrEmpty(title)) - { - localTracks.Add((title, artist)); - } + providerIds[prop.Name] = prop.Value.GetString() ?? ""; } } - _logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}", - localTracks.Count, decodedName); - - // Get matched external tracks cache - var matchedTracksKey = $"spotify:matched:ordered:{decodedName}"; - var matchedTracks = await _cache.GetAsync>(matchedTracksKey); - var matchedSpotifyIds = new HashSet( - matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() - ); - - // Match Spotify tracks to local tracks by name (fuzzy matching) - foreach (var track in spotifyTracks) + if (providerIds != null) { - bool? isLocal = null; - string? externalProvider = null; - bool isManualMapping = false; - string? manualMappingType = null; - string? manualMappingId = null; - - // FIRST: Check for manual Jellyfin mapping - var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; - var manualJellyfinId = await _cache.GetAsync(manualMappingKey); - - if (!string.IsNullOrEmpty(manualJellyfinId)) + // Check for external provider keys + if (providerIds.ContainsKey("SquidWTF")) { - // Manual Jellyfin mapping exists - this track is definitely local - isLocal = true; - isManualMapping = true; - manualMappingType = "jellyfin"; - manualMappingId = manualJellyfinId; - _logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}", - track.Title, manualJellyfinId); + isLocal = false; + externalProvider = "SquidWTF"; + } + else if (providerIds.ContainsKey("Deezer")) + { + isLocal = false; + externalProvider = "Deezer"; + } + else if (providerIds.ContainsKey("Qobuz")) + { + isLocal = false; + externalProvider = "Qobuz"; + } + else if (providerIds.ContainsKey("Tidal")) + { + isLocal = false; + externalProvider = "Tidal"; } else { - // Check for external manual mapping - var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; - var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - - if (!string.IsNullOrEmpty(externalMappingJson)) - { - try - { - using var extDoc = JsonDocument.Parse(externalMappingJson); - var extRoot = extDoc.RootElement; - - string? provider = null; - string? externalId = null; - - if (extRoot.TryGetProperty("provider", out var providerEl)) - { - provider = providerEl.GetString(); - } - - if (extRoot.TryGetProperty("id", out var idEl)) - { - externalId = idEl.GetString(); - } - - if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)) - { - // External manual mapping exists - isLocal = false; - externalProvider = provider; - isManualMapping = true; - manualMappingType = "external"; - manualMappingId = externalId; - _logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}", - track.Title, provider, externalId); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title); - } - } - else if (localTracks.Count > 0) - { - // SECOND: No manual mapping, try fuzzy matching with local tracks - var bestMatch = localTracks - .Select(local => new - { - Local = local, - TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist) - }) - .Select(x => new - { - x.Local, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) - }) - .OrderByDescending(x => x.TotalScore) - .FirstOrDefault(); - - // Use 70% threshold (same as playback matching) - if (bestMatch != null && bestMatch.TotalScore >= 70) - { - isLocal = true; - } - } + // No external provider - it's local + isLocal = true; } - - // If not local, check if it's externally matched or missing - if (isLocal != true) - { - // Check if there's a manual external mapping - if (isManualMapping && manualMappingType == "external") - { - // Track has manual external mapping - it's available externally - isLocal = false; - // externalProvider already set above - } - else if (matchedSpotifyIds.Contains(track.SpotifyId)) - { - // Track is externally matched (search succeeded) - isLocal = false; - externalProvider = "SquidWTF"; // Default to SquidWTF for external matches - } - else - { - // Track is missing (search failed) - isLocal = null; - externalProvider = null; - } - } - - // Check lyrics status (only from our cache - lrclib/Spotify) - // Note: For local tracks, Jellyfin may have embedded lyrics that we don't check here - // Those will be served directly by Jellyfin when requested - var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}"; - var existingLyrics = await _cache.GetStringAsync(cacheKey); - var hasLyrics = !string.IsNullOrEmpty(existingLyrics); - - tracksWithStatus.Add(new - { - position = track.Position, - title = track.Title, - artists = track.Artists, - album = track.Album, - isrc = track.Isrc, - spotifyId = track.SpotifyId, - durationMs = track.DurationMs, - albumArtUrl = track.AlbumArtUrl, - isLocal = isLocal, - externalProvider = externalProvider, - searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing - isManualMapping = isManualMapping, - manualMappingType = manualMappingType, - manualMappingId = manualMappingId, - hasLyrics = hasLyrics - }); } + } + + // Check if this is a manual mapping + var manualJellyfinKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualJellyfinKey); + + if (!string.IsNullOrEmpty(manualJellyfinId)) + { + isManualMapping = true; + manualMappingType = "jellyfin"; + manualMappingId = manualJellyfinId; + } + else + { + var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; + var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - return Ok(new + if (!string.IsNullOrEmpty(externalMappingJson)) { - name = decodedName, - trackCount = spotifyTracks.Count, - tracks = tracksWithStatus - }); + try + { + using var extDoc = JsonDocument.Parse(externalMappingJson); + var extRoot = extDoc.RootElement; + + if (extRoot.TryGetProperty("id", out var idEl)) + { + isManualMapping = true; + manualMappingType = "external"; + manualMappingId = idEl.GetString(); + } + } + catch { } + } } } - catch (Exception ex) + else { - _logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName); + // Track not in cache - it's missing + isLocal = null; + externalProvider = null; } + + // Check lyrics status + var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}"; + var existingLyrics = await _cache.GetStringAsync(cacheKey); + var hasLyrics = !string.IsNullOrEmpty(existingLyrics); + + tracksWithStatus.Add(new + { + position = track.Position, + title = track.Title, + artists = track.Artists, + album = track.Album, + isrc = track.Isrc, + spotifyId = track.SpotifyId, + durationMs = track.DurationMs, + albumArtUrl = track.AlbumArtUrl, + isLocal = isLocal, + externalProvider = externalProvider, + searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, + isManualMapping = isManualMapping, + manualMappingType = manualMappingType, + manualMappingId = manualMappingId, + hasLyrics = hasLyrics + }); } + + return Ok(new + { + name = decodedName, + trackCount = spotifyTracks.Count, + tracks = tracksWithStatus + }); } - // If we get here, we couldn't get local tracks from Jellyfin - // Just return tracks with basic external/missing status based on cache + // Fallback: Cache not available, use matched tracks cache + _logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName); + var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}"; var fallbackMatchedTracks = await _cache.GetAsync>(fallbackMatchedTracksKey); var fallbackMatchedSpotifyIds = new HashSet( fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() ); - // Clear and reuse tracksWithStatus for fallback - tracksWithStatus.Clear(); - foreach (var track in spotifyTracks) { bool? isLocal = null; @@ -879,13 +841,11 @@ public class AdminController : ControllerBase } else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId)) { - // Track is externally matched (search succeeded) isLocal = false; - externalProvider = "SquidWTF"; // Default to SquidWTF for external matches + externalProvider = "SquidWTF"; } else { - // Track is missing (search failed) isLocal = null; externalProvider = null; } @@ -903,7 +863,7 @@ public class AdminController : ControllerBase albumArtUrl = track.AlbumArtUrl, isLocal = isLocal, externalProvider = externalProvider, - searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing + searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null }); }