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();
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user