fix: use playlist cache in view tracks endpoint

This commit is contained in:
2026-02-07 11:14:36 -05:00
parent 35c125d042
commit a75df9328a

View File

@@ -603,236 +603,198 @@ public class AdminController : ControllerBase
// Get Spotify tracks // Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); 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<object>(); var tracksWithStatus = new List<object>();
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<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
{ {
// Get existing tracks from Jellyfin to determine local/external status cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
var userId = _jellyfinSettings.UserId; }
if (!string.IsNullOrEmpty(userId)) 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<string, Dictionary<string, object?>>();
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}"; Dictionary<string, string>? providerIds = null;
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request); if (providerIdsObj is Dictionary<string, string> dict)
if (response.IsSuccessStatusCode)
{ {
var json = await response.Content.ReadAsStringAsync(); providerIds = dict;
using var doc = JsonDocument.Parse(json); }
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
// Build list of local tracks (match by name only - no Spotify IDs!) {
var localTracks = new List<(string Title, string Artist)>(); providerIds = new Dictionary<string, string>();
if (doc.RootElement.TryGetProperty("Items", out var items)) 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<string, string>? providerIds = null;
if (providerIdsObj is Dictionary<string, string> dict)
{
providerIds = dict;
}
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{ {
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; providerIds[prop.Name] = prop.Value.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));
}
} }
} }
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}", if (providerIds != null)
localTracks.Count, decodedName);
// Get matched external tracks cache
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
// Match Spotify tracks to local tracks by name (fuzzy matching)
foreach (var track in spotifyTracks)
{ {
bool? isLocal = null; // Check for external provider keys
string? externalProvider = null; if (providerIds.ContainsKey("SquidWTF"))
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<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{ {
// Manual Jellyfin mapping exists - this track is definitely local isLocal = false;
isLocal = true; externalProvider = "SquidWTF";
isManualMapping = true; }
manualMappingType = "jellyfin"; else if (providerIds.ContainsKey("Deezer"))
manualMappingId = manualJellyfinId; {
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}", isLocal = false;
track.Title, manualJellyfinId); externalProvider = "Deezer";
}
else if (providerIds.ContainsKey("Qobuz"))
{
isLocal = false;
externalProvider = "Qobuz";
}
else if (providerIds.ContainsKey("Tidal"))
{
isLocal = false;
externalProvider = "Tidal";
} }
else else
{ {
// Check for external manual mapping // No external provider - it's local
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; isLocal = true;
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;
}
}
} }
// 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<string>(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, try
trackCount = spotifyTracks.Count, {
tracks = tracksWithStatus 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 // Fallback: Cache not available, use matched tracks cache
// Just return tracks with basic external/missing status based on cache _logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}"; var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey); var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
var fallbackMatchedSpotifyIds = new HashSet<string>( var fallbackMatchedSpotifyIds = new HashSet<string>(
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>() fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
); );
// Clear and reuse tracksWithStatus for fallback
tracksWithStatus.Clear();
foreach (var track in spotifyTracks) foreach (var track in spotifyTracks)
{ {
bool? isLocal = null; bool? isLocal = null;
@@ -879,13 +841,11 @@ public class AdminController : ControllerBase
} }
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId)) else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
{ {
// Track is externally matched (search succeeded)
isLocal = false; isLocal = false;
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches externalProvider = "SquidWTF";
} }
else else
{ {
// Track is missing (search failed)
isLocal = null; isLocal = null;
externalProvider = null; externalProvider = null;
} }
@@ -903,7 +863,7 @@ public class AdminController : ControllerBase
albumArtUrl = track.AlbumArtUrl, albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal, isLocal = isLocal,
externalProvider = externalProvider, externalProvider = externalProvider,
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
}); });
} }