mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
fix: use playlist cache in view tracks endpoint
This commit is contained in:
@@ -603,66 +603,57 @@ 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
|
||||||
// Get existing tracks from Jellyfin to determine local/external status
|
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||||
var userId = _jellyfinSettings.UserId;
|
|
||||||
if (!string.IsNullOrEmpty(userId))
|
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
|
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
||||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
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))
|
|
||||||
{
|
|
||||||
foreach (var item in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
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))
|
catch (Exception cacheEx)
|
||||||
{
|
{
|
||||||
artist = albumArtistEl.GetString() ?? "";
|
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(title))
|
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||||
|
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||||
|
|
||||||
|
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||||
{
|
{
|
||||||
localTracks.Add((title, artist));
|
// Build a map of Spotify ID -> cached item for quick lookup
|
||||||
|
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||||
|
|
||||||
|
foreach (var item in cachedPlaylistItems)
|
||||||
|
{
|
||||||
|
if (item.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())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||||
|
{
|
||||||
|
spotifyIdToItem[spotifyId] = item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
// Match each Spotify track to its cached item
|
||||||
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)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
bool? isLocal = null;
|
bool? isLocal = null;
|
||||||
@@ -671,23 +662,69 @@ public class AdminController : ControllerBase
|
|||||||
string? manualMappingType = null;
|
string? manualMappingType = null;
|
||||||
string? manualMappingId = null;
|
string? manualMappingId = null;
|
||||||
|
|
||||||
// FIRST: Check for manual Jellyfin mapping
|
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
|
||||||
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
|
// Track is in the cache - determine if it's local or external
|
||||||
isLocal = true;
|
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
isManualMapping = true;
|
{
|
||||||
manualMappingType = "jellyfin";
|
Dictionary<string, string>? providerIds = null;
|
||||||
manualMappingId = manualJellyfinId;
|
|
||||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
if (providerIdsObj is Dictionary<string, string> dict)
|
||||||
track.Title, manualJellyfinId);
|
{
|
||||||
|
providerIds = dict;
|
||||||
|
}
|
||||||
|
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
providerIds = new Dictionary<string, string>();
|
||||||
|
foreach (var prop in jsonEl.EnumerateObject())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null)
|
||||||
|
{
|
||||||
|
// Check for external provider keys
|
||||||
|
if (providerIds.ContainsKey("SquidWTF"))
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// No external provider - it's local
|
||||||
|
isLocal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
else
|
||||||
{
|
{
|
||||||
// Check for external manual mapping
|
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
@@ -698,91 +735,25 @@ public class AdminController : ControllerBase
|
|||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
var extRoot = extDoc.RootElement;
|
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))
|
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;
|
isManualMapping = true;
|
||||||
manualMappingType = "external";
|
manualMappingType = "external";
|
||||||
manualMappingId = externalId;
|
manualMappingId = idEl.GetString();
|
||||||
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
|
||||||
track.Title, provider, externalId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch { }
|
||||||
{
|
|
||||||
_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
|
else
|
||||||
{
|
{
|
||||||
// Track is missing (search failed)
|
// Track not in cache - it's missing
|
||||||
isLocal = null;
|
isLocal = null;
|
||||||
externalProvider = null;
|
externalProvider = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check lyrics status (only from our cache - lrclib/Spotify)
|
// Check lyrics status
|
||||||
// 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 cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||||
@@ -799,7 +770,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,
|
||||||
isManualMapping = isManualMapping,
|
isManualMapping = isManualMapping,
|
||||||
manualMappingType = manualMappingType,
|
manualMappingType = manualMappingType,
|
||||||
manualMappingId = manualMappingId,
|
manualMappingId = manualMappingId,
|
||||||
@@ -814,25 +785,16 @@ public class AdminController : ControllerBase
|
|||||||
tracks = tracksWithStatus
|
tracks = tracksWithStatus
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user