merge local Jellyfin tracks with matched external tracks in Spotify playlists

This commit is contained in:
2026-01-31 20:56:03 -05:00
parent 72b7198f1d
commit 74ae85338c

View File

@@ -1284,7 +1284,7 @@ public class JellyfinController : ControllerBase
var playlistName = nameElement.GetString() ?? "";
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
playlistName, playlistId);
return await GetSpotifyPlaylistTracksAsync(playlistName);
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
}
else
{
@@ -1915,9 +1915,10 @@ public class JellyfinController : ControllerBase
#region Spotify Playlist Injection
/// <summary>
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
/// and merging with existing local tracks from Jellyfin.
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName)
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
{
try
{
@@ -1931,19 +1932,50 @@ public class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
// Get existing Jellyfin playlist items (tracks the plugin already found)
var existingTracksResponse = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
var existingTracks = new List<Song>();
var existingSpotifyIds = new HashSet<string>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var song = _modelMapper.MapToSong(item);
existingTracks.Add(song);
// Track Spotify IDs to avoid duplicates
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
providerIds.TryGetProperty("Spotify", out var spotifyId))
{
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
}
}
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
}
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(new List<Song>());
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
spotifyPlaylistName, existingTracks.Count);
return _responseBuilder.CreateItemsResponse(existingTracks);
}
_logger.LogInformation("Matching {Count} tracks for {Playlist}",
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
var matchTasks = missingTracks.Select(async track =>
// Match missing tracks (excluding ones we already have locally)
var matchTasks = missingTracks
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
.Select(async track =>
{
try
{
@@ -1952,7 +1984,7 @@ public class JellyfinController : ControllerBase
var results = await _metadataService.SearchSongsAsync(query, limit: 5);
if (results.Count == 0)
return null;
return (track.SpotifyId, (Song?)null);
// Fuzzy match to find best result
var bestMatch = results
@@ -1961,7 +1993,7 @@ public class JellyfinController : ControllerBase
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist),
TotalScore = 0
TotalScore = 0.0
})
.Select(x => new
{
@@ -1980,32 +2012,55 @@ public class JellyfinController : ControllerBase
track.Title, track.PrimaryArtist,
bestMatch.Song.Title, bestMatch.Song.Artist,
bestMatch.TotalScore);
return bestMatch.Song;
return (track.SpotifyId, (Song?)bestMatch.Song);
}
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
return null;
return (track.SpotifyId, (Song?)null);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
return null;
return (track.SpotifyId, (Song?)null);
}
});
var matchedTracks = (await Task.WhenAll(matchTasks))
.Where(t => t != null)
.Cast<Song>()
.ToList();
var matchResults = await Task.WhenAll(matchTasks);
var matchedBySpotifyId = matchResults
.Where(x => x.Item2 != null)
.ToDictionary(x => x.SpotifyId, x => x.Item2!);
await _cache.SetAsync(cacheKey, matchedTracks, TimeSpan.FromHours(1));
// Build final track list in Spotify playlist order
var finalTracks = new List<Song>();
foreach (var missingTrack in missingTracks)
{
// Check if we have it locally first
var existingTrack = existingTracks.FirstOrDefault(t =>
t.Title.Equals(missingTrack.Title, StringComparison.OrdinalIgnoreCase) &&
t.Artist.Equals(missingTrack.PrimaryArtist, StringComparison.OrdinalIgnoreCase));
_logger.LogInformation("Matched {Matched}/{Total} tracks for {Playlist}",
matchedTracks.Count, missingTracks.Count, spotifyPlaylistName);
if (existingTrack != null)
{
finalTracks.Add(existingTrack);
}
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
{
finalTracks.Add(matchedTrack);
}
// Skip tracks we couldn't match
}
return _responseBuilder.CreateItemsResponse(matchedTracks);
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local, {Matched} matched, {Missing} missing)",
finalTracks.Count,
existingTracks.Count,
matchedBySpotifyId.Count,
missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count);
return _responseBuilder.CreateItemsResponse(finalTracks);
}
catch (Exception ex)
{