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
This commit is contained in:
2026-02-03 18:36:33 -05:00
parent 1492778b14
commit f240423822
2 changed files with 123 additions and 67 deletions

View File

@@ -312,26 +312,65 @@ public class AdminController : ControllerBase
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
var localSpotifyIds = new HashSet<string>(); // 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)) if (doc.RootElement.TryGetProperty("Items", out var items))
{ {
foreach (var item in items.EnumerateArray()) foreach (var item in items.EnumerateArray())
{ {
if (item.TryGetProperty("ProviderIds", out var providerIds) && var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
providerIds.TryGetProperty("Spotify", out var spotifyId)) var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{ {
var id = spotifyId.GetString(); artist = artistsEl[0].GetString() ?? "";
if (!string.IsNullOrEmpty(id)) }
{ else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
localSpotifyIds.Add(id); {
} 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) 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 tracksWithStatus.Add(new
{ {
position = track.Position, position = track.Position,
@@ -342,7 +381,7 @@ public class AdminController : ControllerBase
spotifyId = track.SpotifyId, spotifyId = track.SpotifyId,
durationMs = track.DurationMs, durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl, albumArtUrl = track.AlbumArtUrl,
isLocal = localSpotifyIds.Contains(track.SpotifyId) isLocal = isLocal
}); });
} }

View File

@@ -2881,8 +2881,6 @@ public class JellyfinController : ControllerBase
} }
var existingTracks = new List<Song>(); var existingTracks = new List<Song>();
var existingBySpotifyId = new Dictionary<string, Song>(); // SpotifyId -> Song
var existingByIsrc = new Dictionary<string, Song>(); // ISRC -> Song
if (existingTracksResponse != null && if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
@@ -2891,33 +2889,15 @@ public class JellyfinController : ControllerBase
{ {
var song = _modelMapper.ParseSong(item); var song = _modelMapper.ParseSong(item);
existingTracks.Add(song); existingTracks.Add(song);
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist);
// 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.LogInformation("✅ Found {Count} existing tracks in Jellyfin playlist ({SpotifyIds} with Spotify IDs, {Isrcs} with ISRCs)", _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
existingTracks.Count, existingBySpotifyId.Count, existingByIsrc.Count); existingTracks.Count);
} }
else else
{ {
_logger.LogError(" No existing tracks found in Jellyfin playlist {PlaylistId} - Jellyfin Spotify Import plugin may not have run yet", playlistId); _logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
return null; // Don't return null - continue with external tracks only
} }
// Get the full playlist from Spotify to know the correct order // 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 // Build the final track list in correct Spotify order
// CRITICAL: LOCAL TRACKS FIRST! Match by name only (title + artist)
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 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);
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{ {
Song? localTrack = null; Song? localTrack = null;
// Try to find local track by Spotify ID first (fastest and most reliable) // Match by title + artist name (ONLY method available - no Spotify IDs on local tracks!)
if (existingBySpotifyId.TryGetValue(spotifyTrack.SpotifyId, out var trackBySpotifyId)) if (existingTracks.Count > 0)
{
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
{ {
var bestMatch = existingTracks var bestMatch = existingTracks
.Where(song => !usedLocalTracks.Contains(song.Id)) // Don't reuse tracks
.Select(song => new .Select(song => new
{ {
Song = song, Song = song,
@@ -2972,39 +2944,72 @@ public class JellyfinController : ControllerBase
.OrderByDescending(x => x.TotalScore) .OrderByDescending(x => x.TotalScore)
.FirstOrDefault(); .FirstOrDefault();
// Only use if match is good enough (>75% combined score) // Use 70% threshold (same as Jellyfin Spotify Import plugin)
if (bestMatch != null && bestMatch.TotalScore >= 75) if (bestMatch != null && bestMatch.TotalScore >= 70)
{ {
localTrack = bestMatch.Song; localTrack = bestMatch.Song;
_logger.LogDebug("#{Pos} {Title} - Found LOCAL by fuzzy match: {MatchTitle} (score: {Score:F1})", usedLocalTracks.Add(localTrack.Id);
spotifyTrack.Position, spotifyTrack.Title, bestMatch.Song.Title, bestMatch.TotalScore); _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) if (localTrack != null)
{ {
finalTracks.Add(localTrack); finalTracks.Add(localTrack);
localUsedCount++; 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); 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.LogDebug("#{Pos} {Title} - Using EXTERNAL match: {Provider}/{Id}", _logger.LogInformation("📥 #{Pos} '{Title}' by {Artist} → EXTERNAL: {Provider}/{Id}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.Position,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId); spotifyTrack.Title,
spotifyTrack.PrimaryArtist,
matched.MatchedSong.ExternalProvider,
matched.MatchedSong.ExternalId);
} }
else else
{ {
_logger.LogDebug("#{Pos} {Title} - NO MATCH (skipping)", skippedCount++;
spotifyTrack.Position, spotifyTrack.Title); _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 // Cache the result
@@ -3013,11 +3018,23 @@ public class JellyfinController : ControllerBase
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks); await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
_logger.LogInformation( _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, finalTracks.Count,
localUsedCount, localUsedCount,
externalUsedCount, 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); return _responseBuilder.CreateItemsResponse(finalTracks);
} }