From 74ae85338ced11e9df192c3338d622192d0c6254 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 31 Jan 2026 20:56:03 -0500 Subject: [PATCH] merge local Jellyfin tracks with matched external tracks in Spotify playlists --- allstarr/Controllers/JellyfinController.cs | 177 ++++++++++++++------- 1 file changed, 116 insertions(+), 61 deletions(-) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 8e13e3d..faae1c4 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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 /// - /// 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. /// - private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName) + private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId) { try { @@ -1931,81 +1932,135 @@ 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(); + var existingSpotifyIds = new HashSet(); + + 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>(missingTracksKey); if (missingTracks == null || missingTracks.Count == 0) { - _logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName); - return _responseBuilder.CreateItemsResponse(new List()); + _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 => - { - try + // Match missing tracks (excluding ones we already have locally) + var matchTasks = missingTracks + .Where(track => !existingSpotifyIds.Contains(track.SpotifyId)) + .Select(async track => { - // Search with just title and artist for better matching - var query = $"{track.Title} {track.PrimaryArtist}"; - var results = await _metadataService.SearchSongsAsync(query, limit: 5); - - if (results.Count == 0) - return null; - - // Fuzzy match to find best result - var bestMatch = results - .Select(song => new - { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist), - TotalScore = 0 - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more - }) - .OrderByDescending(x => x.TotalScore) - .FirstOrDefault(); - - // Only return if match is good enough (>60% combined score) - if (bestMatch != null && bestMatch.TotalScore >= 60) + try { - _logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})", - track.Title, track.PrimaryArtist, - bestMatch.Song.Title, bestMatch.Song.Artist, - bestMatch.TotalScore); - return bestMatch.Song; + // Search with just title and artist for better matching + var query = $"{track.Title} {track.PrimaryArtist}"; + var results = await _metadataService.SearchSongsAsync(query, limit: 5); + + if (results.Count == 0) + return (track.SpotifyId, (Song?)null); + + // Fuzzy match to find best result + var bestMatch = results + .Select(song => new + { + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), + ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist), + TotalScore = 0.0 + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + // Only return if match is good enough (>60% combined score) + if (bestMatch != null && bestMatch.TotalScore >= 60) + { + _logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})", + track.Title, track.PrimaryArtist, + bestMatch.Song.Title, bestMatch.Song.Artist, + bestMatch.TotalScore); + 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 (track.SpotifyId, (Song?)null); } - - _logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})", - track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0); - return null; - } - catch (Exception ex) + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", + track.Title, track.PrimaryArtist); + return (track.SpotifyId, (Song?)null); + } + }); + + var matchResults = await Task.WhenAll(matchTasks); + var matchedBySpotifyId = matchResults + .Where(x => x.Item2 != null) + .ToDictionary(x => x.SpotifyId, x => x.Item2!); + + // Build final track list in Spotify playlist order + var finalTracks = new List(); + 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)); + + if (existingTrack != null) { - _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", - track.Title, track.PrimaryArtist); - return null; + finalTracks.Add(existingTrack); } - }); + else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack)) + { + finalTracks.Add(matchedTrack); + } + // Skip tracks we couldn't match + } - var matchedTracks = (await Task.WhenAll(matchTasks)) - .Where(t => t != null) - .Cast() - .ToList(); + await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); - await _cache.SetAsync(cacheKey, matchedTracks, 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); - _logger.LogInformation("Matched {Matched}/{Total} tracks for {Playlist}", - matchedTracks.Count, missingTracks.Count, spotifyPlaylistName); - - return _responseBuilder.CreateItemsResponse(matchedTracks); + return _responseBuilder.CreateItemsResponse(finalTracks); } catch (Exception ex) {