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,25 +2909,26 @@ 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<Song>();
var localUsedCount = 0;
var externalUsedCount = 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...",
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<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))
{
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 => !usedLocalTracks.Contains(song.Id)) // Don't reuse tracks
.Where(song => !usedJellyfinTracks.Contains(song.Id))
.Select(song => new
{
Song = song,
@@ -2939,17 +2940,17 @@ public class JellyfinController : ControllerBase
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) // Weight title more
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Use 70% threshold (same as Jellyfin Spotify Import plugin)
// Use 70% threshold for matching
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}%)",
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,
@@ -2959,27 +2960,33 @@ public class JellyfinController : ControllerBase
}
else if (bestMatch != null)
{
_logger.LogDebug(" ⚠️ #{Pos} '{Title}' - Best local match score too low: {Score:F1}% (need 70%)",
_logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)",
spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore);
}
}
// If we found a local track, USE IT! This is the priority!
if (localTrack != null)
_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))
{
finalTracks.Add(localTrack);
// 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);
}
}