diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 790b2c7..4128c17 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2909,77 +2909,84 @@ public class JellyfinController : ControllerBase } // Build the final track list in correct Spotify order - // CRITICAL: LOCAL TRACKS FIRST! Match by name only (title + artist) + // STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external 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); + _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...", + existingTracks.Count, spotifyTracks.Count); + + // Step 1: For each Spotify position, find the best matching Jellyfin track + var spotifyToJellyfinMap = new Dictionary(); // Spotify position -> Jellyfin track + var usedJellyfinTracks = new HashSet(); // Track which Jellyfin tracks we've used foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) { - Song? localTrack = null; + if (existingTracks.Count == 0) break; - // Match by title + artist name (ONLY method available - no Spotify IDs on local tracks!) - if (existingTracks.Count > 0) + // Find best matching Jellyfin track that hasn't been used yet + var bestMatch = existingTracks + .Where(song => !usedJellyfinTracks.Contains(song.Id)) + .Select(song => new + { + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title), + ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist) + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + // Use 70% threshold for matching + if (bestMatch != null && bestMatch.TotalScore >= 70) { - var bestMatch = existingTracks - .Where(song => !usedLocalTracks.Contains(song.Id)) // Don't reuse tracks - .Select(song => new - { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist) - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) // Weight title more - }) - .OrderByDescending(x => x.TotalScore) - .FirstOrDefault(); - - // Use 70% threshold (same as Jellyfin Spotify Import plugin) - if (bestMatch != null && bestMatch.TotalScore >= 70) - { - localTrack = bestMatch.Song; - 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); - } + spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song; + usedJellyfinTracks.Add(bestMatch.Song.Id); + _logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)", + spotifyTrack.Position, + spotifyTrack.Title, + spotifyTrack.PrimaryArtist, + bestMatch.Song.Title, + bestMatch.Song.Artist, + bestMatch.TotalScore); } - - // If we found a local track, USE IT! This is the priority! - if (localTrack != null) + else if (bestMatch != null) { - finalTracks.Add(localTrack); + _logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)", + spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore); + } + } + + _logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks", + spotifyToJellyfinMap.Count, spotifyTracks.Count); + + // Step 2: Build final playlist in Spotify order + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) + { + // Check if we have a Jellyfin track for this position + if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack)) + { + finalTracks.Add(jellyfinTrack); localUsedCount++; - continue; // SKIP external matching entirely! + continue; // Use local track, skip external search } - // ONLY if no local track exists, check for external match + // No local match - try to find external track // First check pre-matched cache var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); if (matched != null) { finalTracks.Add(matched.MatchedSong); externalUsedCount++; - _logger.LogInformation("📥 #{Pos} '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}", + _logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}", spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist, @@ -3018,7 +3025,7 @@ public class JellyfinController : ControllerBase { finalTracks.Add(bestExternalMatch.Song); externalUsedCount++; - _logger.LogInformation("📥 #{Pos} '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)", + _logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)", spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist, @@ -3029,7 +3036,7 @@ public class JellyfinController : ControllerBase else { skippedCount++; - _logger.LogWarning("❌ #{Pos} '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)", + _logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)", spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist, bestExternalMatch?.TotalScore ?? 0); } @@ -3037,35 +3044,34 @@ public class JellyfinController : ControllerBase else { skippedCount++; - _logger.LogWarning("❌ #{Pos} '{Title}' by {Artist} → NO MATCH (no external results)", + _logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (no external results)", spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist); } } catch (Exception ex) { skippedCount++; - _logger.LogError(ex, "❌ #{Pos} '{Title}' by {Artist} → ERROR searching external providers", + _logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers", 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)) + // Step 3: Add any unmatched Jellyfin tracks at the end + var unmatchedJellyfinTracks = existingTracks + .Where(song => !usedJellyfinTracks.Contains(song.Id)) .ToList(); - if (unmatchedLocalTracks.Count > 0) + if (unmatchedJellyfinTracks.Count > 0) { - _logger.LogInformation("📌 Adding {Count} unmatched LOCAL tracks from Jellyfin playlist (not in Spotify)", - unmatchedLocalTracks.Count); + _logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)", + unmatchedJellyfinTracks.Count); - foreach (var track in unmatchedLocalTracks) + foreach (var track in unmatchedJellyfinTracks) { finalTracks.Add(track); localUsedCount++; - _logger.LogInformation(" + '{Title}' by {Artist} (local only)", track.Title, track.Artist); + _logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist); } }