From f2404238229beb5d42c1d6b41e5e3e37fd4c42c4 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 3 Feb 2026 18:36:33 -0500 Subject: [PATCH] Fix: Prioritize LOCAL tracks in Spotify playlist injection - match by name only - Remove Spotify ID/ISRC matching (Jellyfin plugin doesn't add these) - Use ONLY fuzzy name matching (title + artist, 70% threshold) - LOCAL tracks ALWAYS used first before external providers - Include ALL tracks from Jellyfin playlist (even if not in Spotify) - Prevent duplicate track usage with HashSet tracking - AdminController also updated to match by name for Local/External badges - Better logging with emojis for debugging --- allstarr/Controllers/AdminController.cs | 59 ++++++++-- allstarr/Controllers/JellyfinController.cs | 131 ++++++++++++--------- 2 files changed, 123 insertions(+), 67 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 81cdeff..a582804 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -312,26 +312,65 @@ public class AdminController : ControllerBase var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - var localSpotifyIds = new HashSet(); + // 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()) { - if (item.TryGetProperty("ProviderIds", out var providerIds) && - providerIds.TryGetProperty("Spotify", out var spotifyId)) + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { - var id = spotifyId.GetString(); - if (!string.IsNullOrEmpty(id)) - { - localSpotifyIds.Add(id); - } + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + if (!string.IsNullOrEmpty(title)) + { + localTracks.Add((title, artist)); } } } - // Mark tracks as local or external + _logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}", + localTracks.Count, decodedName); + + // Match Spotify tracks to local tracks by name (fuzzy matching) 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; + } + } + tracksWithStatus.Add(new { position = track.Position, @@ -342,7 +381,7 @@ public class AdminController : ControllerBase spotifyId = track.SpotifyId, durationMs = track.DurationMs, albumArtUrl = track.AlbumArtUrl, - isLocal = localSpotifyIds.Contains(track.SpotifyId) + isLocal = isLocal }); } diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index b64655b..2e43adc 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2881,8 +2881,6 @@ public class JellyfinController : ControllerBase } var existingTracks = new List(); - var existingBySpotifyId = new Dictionary(); // SpotifyId -> Song - var existingByIsrc = new Dictionary(); // ISRC -> Song if (existingTracksResponse != null && existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) @@ -2891,33 +2889,15 @@ public class JellyfinController : ControllerBase { var song = _modelMapper.ParseSong(item); existingTracks.Add(song); - - // Index by Spotify ID if available (from Jellyfin Spotify Import plugin) - if (item.TryGetProperty("ProviderIds", out var providerIds) && - providerIds.TryGetProperty("Spotify", out var spotifyIdElement)) - { - var spotifyId = spotifyIdElement.GetString(); - if (!string.IsNullOrEmpty(spotifyId)) - { - existingBySpotifyId[spotifyId] = song; - _logger.LogDebug(" 📌 Indexed local track by Spotify ID: {SpotifyId} -> {Title}", spotifyId, song.Title); - } - } - - // Index by ISRC for matching (most reliable) - if (!string.IsNullOrEmpty(song.Isrc)) - { - existingByIsrc[song.Isrc] = song; - _logger.LogDebug(" 📌 Indexed local track by ISRC: {Isrc} -> {Title}", song.Isrc, song.Title); - } + _logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist); } - _logger.LogInformation("✅ Found {Count} existing tracks in Jellyfin playlist ({SpotifyIds} with Spotify IDs, {Isrcs} with ISRCs)", - existingTracks.Count, existingBySpotifyId.Count, existingByIsrc.Count); + _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only", + existingTracks.Count); } else { - _logger.LogError("❌ No existing tracks found in Jellyfin playlist {PlaylistId} - Jellyfin Spotify Import plugin may not have run yet", playlistId); - return null; + _logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId); + // Don't return null - continue with external tracks only } // Get the full playlist from Spotify to know the correct order @@ -2929,33 +2909,25 @@ public class JellyfinController : ControllerBase } // Build the final track list in correct Spotify order + // CRITICAL: LOCAL TRACKS FIRST! Match by name only (title + artist) var finalTracks = new List(); var localUsedCount = 0; var externalUsedCount = 0; + var skippedCount = 0; + var usedLocalTracks = new HashSet(); // Track which local tracks we've used (by Id) + + _logger.LogInformation("🔍 Starting NAME-BASED matching for {Count} Spotify tracks against {Local} local tracks...", + spotifyTracks.Count, existingTracks.Count); foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) { Song? localTrack = null; - // Try to find local track by Spotify ID first (fastest and most reliable) - if (existingBySpotifyId.TryGetValue(spotifyTrack.SpotifyId, out var trackBySpotifyId)) - { - localTrack = trackBySpotifyId; - _logger.LogDebug("#{Pos} {Title} - Found LOCAL by Spotify ID: {SpotifyId}", - spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.SpotifyId); - } - // Try to find by ISRC (most reliable for matching) - else if (!string.IsNullOrEmpty(spotifyTrack.Isrc) && - existingByIsrc.TryGetValue(spotifyTrack.Isrc, out var trackByIsrc)) - { - localTrack = trackByIsrc; - _logger.LogDebug("#{Pos} {Title} - Found LOCAL by ISRC: {Isrc}", - spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.Isrc); - } - // Fallback: Match by title + artist name (like Jellyfin Spotify Import plugin does) - else + // Match by title + artist name (ONLY method available - no Spotify IDs on local tracks!) + if (existingTracks.Count > 0) { var bestMatch = existingTracks + .Where(song => !usedLocalTracks.Contains(song.Id)) // Don't reuse tracks .Select(song => new { Song = song, @@ -2972,39 +2944,72 @@ public class JellyfinController : ControllerBase .OrderByDescending(x => x.TotalScore) .FirstOrDefault(); - // Only use if match is good enough (>75% combined score) - if (bestMatch != null && bestMatch.TotalScore >= 75) + // Use 70% threshold (same as Jellyfin Spotify Import plugin) + if (bestMatch != null && bestMatch.TotalScore >= 70) { localTrack = bestMatch.Song; - _logger.LogDebug("#{Pos} {Title} - Found LOCAL by fuzzy match: {MatchTitle} (score: {Score:F1})", - spotifyTrack.Position, spotifyTrack.Title, bestMatch.Song.Title, bestMatch.TotalScore); + usedLocalTracks.Add(localTrack.Id); + _logger.LogInformation("✅ #{Pos} '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{LocalTitle}' by {LocalArtist} (score: {Score:F1}%)", + spotifyTrack.Position, + spotifyTrack.Title, + spotifyTrack.PrimaryArtist, + bestMatch.Song.Title, + bestMatch.Song.Artist, + bestMatch.TotalScore); + } + else if (bestMatch != null) + { + _logger.LogDebug(" ⚠️ #{Pos} '{Title}' - Best local match score too low: {Score:F1}% (need 70%)", + spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore); } } - // If we found a local track, use it + // If we found a local track, USE IT! This is the priority! if (localTrack != null) { finalTracks.Add(localTrack); localUsedCount++; - continue; + continue; // SKIP external matching entirely! } - // No local track - check if we have a matched external track + // ONLY if no local track exists, check for external match var matched = orderedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); if (matched != null) { finalTracks.Add(matched.MatchedSong); externalUsedCount++; - _logger.LogDebug("#{Pos} {Title} - Using EXTERNAL match: {Provider}/{Id}", - spotifyTrack.Position, spotifyTrack.Title, - matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId); + _logger.LogInformation("📥 #{Pos} '{Title}' by {Artist} → EXTERNAL: {Provider}/{Id}", + spotifyTrack.Position, + spotifyTrack.Title, + spotifyTrack.PrimaryArtist, + matched.MatchedSong.ExternalProvider, + matched.MatchedSong.ExternalId); } else { - _logger.LogDebug("#{Pos} {Title} - NO MATCH (skipping)", - spotifyTrack.Position, spotifyTrack.Title); + skippedCount++; + _logger.LogWarning("❌ #{Pos} '{Title}' by {Artist} → NO MATCH (not in Jellyfin, not in external cache)", + spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist); + } + } + + // CRITICAL: Add any remaining local tracks that didn't match any Spotify track + // These tracks are in the Jellyfin playlist and MUST be included! + var unmatchedLocalTracks = existingTracks + .Where(song => !usedLocalTracks.Contains(song.Id)) + .ToList(); + + if (unmatchedLocalTracks.Count > 0) + { + _logger.LogInformation("📌 Adding {Count} unmatched LOCAL tracks from Jellyfin playlist (not in Spotify)", + unmatchedLocalTracks.Count); + + foreach (var track in unmatchedLocalTracks) + { + finalTracks.Add(track); + localUsedCount++; + _logger.LogInformation(" + '{Title}' by {Artist} (local only)", track.Title, track.Artist); } - // If no match, the track is simply omitted (not available from any source) } // Cache the result @@ -3013,11 +3018,23 @@ public class JellyfinController : ControllerBase await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks); _logger.LogInformation( - "Final ordered playlist: {Total} tracks ({Local} local + {External} external) for {Playlist}", + "🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)", + spotifyPlaylistName, finalTracks.Count, localUsedCount, externalUsedCount, - spotifyPlaylistName); + skippedCount); + + if (localUsedCount == 0 && existingTracks.Count > 0) + { + _logger.LogWarning("⚠️ WARNING: Found {Count} tracks in Jellyfin playlist but NONE matched by name!", existingTracks.Count); + _logger.LogWarning(" → Track names may be too different between Spotify and Jellyfin"); + _logger.LogWarning(" → Check that the Jellyfin playlist has the correct tracks"); + } + else if (localUsedCount > 0) + { + _logger.LogInformation("✅ Successfully used {Local} LOCAL tracks from Jellyfin playlist", localUsedCount); + } return _responseBuilder.CreateItemsResponse(finalTracks); }