diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index c23cb2b..77776fe 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using allstarr.Models.Settings; +using allstarr.Models.Spotify; using allstarr.Services.Spotify; using allstarr.Services.Jellyfin; using allstarr.Services.Common; @@ -246,58 +247,96 @@ public class AdminController : ControllerBase if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) { - var localCount = 0; - var externalMatchedCount = 0; - - // Count tracks in Jellyfin playlist - var jellyfinTrackCount = items.GetArrayLength(); - - // Count local vs external tracks + // Build list of local tracks from Jellyfin (match by name only) + var localTracks = new List<(string Title, string Artist)>(); foreach (var item in items.EnumerateArray()) { - // Check if it's an external track by looking at the ID format - // External tracks have IDs like "deezer:123456" or "qobuz:123456" - var isExternal = false; - if (item.TryGetProperty("Id", out var idProp)) + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { - var id = idProp.GetString() ?? ""; - isExternal = id.Contains(":"); // External IDs contain provider prefix + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; } - if (isExternal) + if (!string.IsNullOrEmpty(title)) { - externalMatchedCount++; + localTracks.Add((title, artist)); } - else + } + + // Get Spotify tracks to match against + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); + + // Get matched external tracks cache once + var matchedTracksKey = $"spotify:matched:ordered:{config.Name}"; + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + var matchedSpotifyIds = new HashSet( + matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + + var localCount = 0; + var externalMatchedCount = 0; + var externalMissingCount = 0; + + // Match each Spotify track to determine if it's local, external, or missing + foreach (var track in spotifyTracks) + { + var isLocal = false; + + if (localTracks.Count > 0) + { + 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 (isLocal) { localCount++; } - } - - // Check matched tracks cache to get accurate external count - // External tracks are injected on-the-fly, not stored in Jellyfin - var matchedTracksKey = $"spotify:matched:ordered:{config.Name}"; - var matchedTracks = await _cache.GetAsync>(matchedTracksKey); - - if (matchedTracks != null && matchedTracks.Count > 0) - { - // Count how many matched tracks are external (not local) - // Assume tracks beyond local count are external - var totalMatched = matchedTracks.Count; - if (totalMatched > localCount) + else { - externalMatchedCount = totalMatched - localCount; + // Check if external track is matched + if (matchedSpotifyIds.Contains(track.SpotifyId)) + { + externalMatchedCount++; + } + else + { + externalMissingCount++; + } } } - var totalInJellyfin = localCount + externalMatchedCount; - var externalMissingCount = Math.Max(0, spotifyTrackCount - totalInJellyfin); - playlistInfo["localTracks"] = localCount; playlistInfo["externalMatched"] = externalMatchedCount; playlistInfo["externalMissing"] = externalMissingCount; playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount; - playlistInfo["totalInJellyfin"] = totalInJellyfin; + playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount; _logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing", config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount); diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 39d2bac..1e7bdf8 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -1209,10 +1209,11 @@ const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0; const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0; const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0; + const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0; const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)'; // Debug logging - console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, total=${completionPct}%`); + console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`); return ` @@ -1223,7 +1224,8 @@
-
+
+
${completionPct}%
@@ -1757,6 +1759,18 @@ data-artist="${escapeHtml(firstArtist)}" data-spotify-id="${escapeHtml(t.spotifyId || '')}" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local`; + } else { + // isLocal is null/undefined - track is missing (not found locally or externally) + statusBadge = 'Missing'; + // Add manual map button for missing tracks too + const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : ''; + mapButton = ``; } return `