From aa9b5c874d6e700f4c9915335d0b37c6a93922be Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 3 Feb 2026 17:29:28 -0500 Subject: [PATCH] Skip matching tracks already in Jellyfin playlist - Fetch existing tracks from Jellyfin playlist before matching - Extract Spotify IDs from ProviderIds to identify already-matched tracks - Only search for tracks not already in Jellyfin - Logs: 'Matching X/Y tracks (skipping Z already in Jellyfin)' - Significantly reduces matching time for playlists with local content - Example: If 20/50 tracks exist locally, only searches for 30 tracks --- .../Spotify/SpotifyTrackMatchingService.cs | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 399c0f4..c130734 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -2,6 +2,7 @@ using allstarr.Models.Domain; using allstarr.Models.Settings; using allstarr.Models.Spotify; using allstarr.Services.Common; +using allstarr.Services.Jellyfin; using Microsoft.Extensions.Options; using System.Text.Json; @@ -146,6 +147,7 @@ public class SpotifyTrackMatchingService : BackgroundService /// /// New matching mode that uses ISRC when available for exact matches. /// Preserves track position for correct playlist ordering. + /// Only matches tracks that aren't already in the Jellyfin playlist. /// private async Task MatchPlaylistTracksWithIsrcAsync( string playlistName, @@ -163,25 +165,84 @@ public class SpotifyTrackMatchingService : BackgroundService return; } + // Get the Jellyfin playlist ID to check which tracks already exist + var playlistConfig = _spotifySettings.Playlists + .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + + HashSet existingSpotifyIds = new(); + + if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) + { + // Get existing tracks from Jellyfin playlist to avoid re-matching + using var scope = _serviceProvider.CreateScope(); + var proxyService = scope.ServiceProvider.GetService(); + + if (proxyService != null) + { + try + { + var (existingTracksResponse, _) = await proxyService.GetJsonAsync( + $"Playlists/{playlistConfig.JellyfinId}/Items", + null, + null); + + if (existingTracksResponse != null && + existingTracksResponse.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 id = spotifyId.GetString(); + if (!string.IsNullOrEmpty(id)) + { + existingSpotifyIds.Add(id); + } + } + } + _logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these", + existingSpotifyIds.Count, playlistName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName); + } + } + } + + // Filter to only tracks not already in Jellyfin + var tracksToMatch = spotifyTracks + .Where(t => !existingSpotifyIds.Contains(t.SpotifyId)) + .ToList(); + + if (tracksToMatch.Count == 0) + { + _logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching", + spotifyTracks.Count, playlistName); + return; + } + + _logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})", + tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching); + // Check cache - use snapshot/timestamp to detect changes var existingMatched = await _cache.GetAsync>(matchedTracksKey); - if (existingMatched != null && existingMatched.Count == spotifyTracks.Count) + if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count) { _logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping", playlistName, existingMatched.Count); return; } - _logger.LogInformation("Matching {Count} tracks for {Playlist} (ISRC: {IsrcEnabled})", - spotifyTracks.Count, playlistName, _spotifyApiSettings.PreferIsrcMatching); - var matchedTracks = new List(); var isrcMatches = 0; var fuzzyMatches = 0; var noMatch = 0; // Process tracks in batches for parallel searching - var orderedTracks = spotifyTracks.OrderBy(t => t.Position).ToList(); + var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList(); for (int i = 0; i < orderedTracks.Count; i += BatchSize) { if (cancellationToken.IsCancellationRequested) break; @@ -293,7 +354,7 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation( "✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})", - matchedTracks.Count, spotifyTracks.Count, playlistName, isrcMatches, fuzzyMatches, noMatch); + matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch); } else {