Fix: Correct matching logic - Jellyfin tracks first, then fill gaps

CRITICAL FIX: Changed matching strategy completely
- Step 1: Match ALL Jellyfin tracks to Spotify positions (fuzzy 70%)
- Step 2: Build playlist in Spotify order using matched Jellyfin tracks
- Step 3: Fill remaining gaps with external tracks (cached or on-demand)
- Step 4: Add any unmatched Jellyfin tracks at the end

This ensures Jellyfin tracks are ALWAYS used when they match, preventing
external tracks from being used when local versions exist.
This commit is contained in:
2026-02-03 18:45:07 -05:00
parent dccdb7b744
commit d619881b8e

View File

@@ -2909,77 +2909,84 @@ public class JellyfinController : ControllerBase
} }
// Build the final track list in correct Spotify order // 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<Song>(); var finalTracks = new List<Song>();
var localUsedCount = 0; var localUsedCount = 0;
var externalUsedCount = 0; var externalUsedCount = 0;
var skippedCount = 0; var skippedCount = 0;
var usedLocalTracks = new HashSet<string>(); // Track which local tracks we've used (by Id)
_logger.LogInformation("🔍 Starting NAME-BASED matching for {Count} Spotify tracks against {Local} local tracks...", _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
spotifyTracks.Count, existingTracks.Count); existingTracks.Count, spotifyTracks.Count);
// Step 1: For each Spotify position, find the best matching Jellyfin track
var spotifyToJellyfinMap = new Dictionary<int, Song>(); // Spotify position -> Jellyfin track
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) 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!) // Find best matching Jellyfin track that hasn't been used yet
if (existingTracks.Count > 0) 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 spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song;
.Where(song => !usedLocalTracks.Contains(song.Id)) // Don't reuse tracks usedJellyfinTracks.Add(bestMatch.Song.Id);
.Select(song => new _logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)",
{ spotifyTrack.Position,
Song = song, spotifyTrack.Title,
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title), spotifyTrack.PrimaryArtist,
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist) bestMatch.Song.Title,
}) bestMatch.Song.Artist,
.Select(x => new bestMatch.TotalScore);
{
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);
}
} }
else if (bestMatch != null)
// If we found a local track, USE IT! This is the priority!
if (localTrack != 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++; 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 // First check pre-matched cache
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null) if (matched != null)
{ {
finalTracks.Add(matched.MatchedSong); finalTracks.Add(matched.MatchedSong);
externalUsedCount++; 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.Position,
spotifyTrack.Title, spotifyTrack.Title,
spotifyTrack.PrimaryArtist, spotifyTrack.PrimaryArtist,
@@ -3018,7 +3025,7 @@ public class JellyfinController : ControllerBase
{ {
finalTracks.Add(bestExternalMatch.Song); finalTracks.Add(bestExternalMatch.Song);
externalUsedCount++; 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.Position,
spotifyTrack.Title, spotifyTrack.Title,
spotifyTrack.PrimaryArtist, spotifyTrack.PrimaryArtist,
@@ -3029,7 +3036,7 @@ public class JellyfinController : ControllerBase
else else
{ {
skippedCount++; 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, spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
bestExternalMatch?.TotalScore ?? 0); bestExternalMatch?.TotalScore ?? 0);
} }
@@ -3037,35 +3044,34 @@ public class JellyfinController : ControllerBase
else else
{ {
skippedCount++; 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); spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
skippedCount++; 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); spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
} }
} }
} }
// CRITICAL: Add any remaining local tracks that didn't match any Spotify track // Step 3: Add any unmatched Jellyfin tracks at the end
// These tracks are in the Jellyfin playlist and MUST be included! var unmatchedJellyfinTracks = existingTracks
var unmatchedLocalTracks = existingTracks .Where(song => !usedJellyfinTracks.Contains(song.Id))
.Where(song => !usedLocalTracks.Contains(song.Id))
.ToList(); .ToList();
if (unmatchedLocalTracks.Count > 0) if (unmatchedJellyfinTracks.Count > 0)
{ {
_logger.LogInformation("📌 Adding {Count} unmatched LOCAL tracks from Jellyfin playlist (not in Spotify)", _logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)",
unmatchedLocalTracks.Count); unmatchedJellyfinTracks.Count);
foreach (var track in unmatchedLocalTracks) foreach (var track in unmatchedJellyfinTracks)
{ {
finalTracks.Add(track); finalTracks.Add(track);
localUsedCount++; localUsedCount++;
_logger.LogInformation(" + '{Title}' by {Artist} (local only)", track.Title, track.Artist); _logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist);
} }
} }