mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -312,26 +312,65 @@ public class AdminController : ControllerBase
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
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))
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2881,8 +2881,6 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingBySpotifyId = new Dictionary<string, Song>(); // SpotifyId -> Song
|
||||
var existingByIsrc = new Dictionary<string, Song>(); // 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<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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user