diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index d46e972..ff64aee 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2912,7 +2912,7 @@ public class JellyfinController : ControllerBase _logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}", orderedTracks.Count, spotifyPlaylistName); - // Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found) + // Get existing Jellyfin playlist items (RAW - don't convert!) // CRITICAL: Must include UserId parameter or Jellyfin returns empty results var userId = _settings.UserId; if (string.IsNullOrEmpty(userId)) @@ -2921,7 +2921,8 @@ public class JellyfinController : ControllerBase return null; // Fall back to legacy mode } - var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}"; + // Request MediaSources field to get bitrate info + var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources"; _logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}", playlistId, userId); @@ -2937,24 +2938,41 @@ public class JellyfinController : ControllerBase return null; } - var existingTracks = new List(); + // Keep raw Jellyfin items - don't convert to Song objects! + var jellyfinItems = new List(); + var jellyfinItemsByName = new Dictionary(); if (existingTracksResponse != null && existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) { foreach (var item in items.EnumerateArray()) { - var song = _modelMapper.ParseSong(item); - existingTracks.Add(song); - _logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist); + jellyfinItems.Add(item); + + // Index by title+artist for matching + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + var key = $"{title}|{artist}".ToLowerInvariant(); + if (!jellyfinItemsByName.ContainsKey(key)) + { + jellyfinItemsByName[key] = item; + } } - _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only", - existingTracks.Count); + + _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count); } else { _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 @@ -2966,225 +2984,90 @@ public class JellyfinController : ControllerBase } // Build the final track list in correct Spotify order - // STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external - var finalTracks = new List(); + var finalItems = new List>(); + var usedJellyfinItems = new HashSet(); var localUsedCount = 0; var externalUsedCount = 0; - var skippedCount = 0; - _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...", - existingTracks.Count, spotifyTracks.Count); - - // Step 1: Check for manual mappings first - var manualMappings = new Dictionary(); // Spotify ID -> Jellyfin ID - foreach (var spotifyTrack in spotifyTracks) - { - var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}"; - var jellyfinId = await _cache.GetAsync(mappingKey); - if (!string.IsNullOrEmpty(jellyfinId)) - { - manualMappings[spotifyTrack.SpotifyId] = jellyfinId; - _logger.LogInformation("📌 Manual mapping found: Spotify {SpotifyId} → Jellyfin {JellyfinId}", - spotifyTrack.SpotifyId, jellyfinId); - } - } - - // Step 2: For each Spotify position, find the best matching Jellyfin track - var spotifyToJellyfinMap = new Dictionary(); // Spotify position -> Jellyfin track - var usedJellyfinTracks = new HashSet(); // Track which Jellyfin tracks we've used + _logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count); foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) { - if (existingTracks.Count == 0) break; + // Try to find matching Jellyfin item by fuzzy matching + JsonElement? matchedJellyfinItem = null; + string? matchedKey = null; + double bestScore = 0; - // Check for manual mapping first - if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId)) + foreach (var kvp in jellyfinItemsByName) { - var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId); - if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id)) + if (usedJellyfinItems.Contains(kvp.Key)) continue; + + var item = kvp.Value; + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { - spotifyToJellyfinMap[spotifyTrack.Position] = mappedTrack; - usedJellyfinTracks.Add(mappedTrack.Id); - _logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'", - spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title); - continue; + artist = artistsEl[0].GetString() ?? ""; + } + + var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title); + var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); + var totalScore = (titleScore * 0.7) + (artistScore * 0.3); + + if (totalScore > bestScore && totalScore >= 70) + { + bestScore = totalScore; + matchedJellyfinItem = item; + matchedKey = kvp.Key; } } - // Find best matching Jellyfin track that hasn't been used yet - var bestMatch = existingTracks - .Where(song => !usedJellyfinTracks.Contains(song.Id)) - .Select(song => new + if (matchedJellyfinItem.HasValue && matchedKey != null) + { + // Use the raw Jellyfin item (preserves ALL metadata including MediaSources!) + var itemDict = JsonSerializer.Deserialize>(matchedJellyfinItem.Value.GetRawText()); + if (itemDict != null) { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist) - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) - }) - .OrderByDescending(x => x.TotalScore) - .FirstOrDefault(); - - // Use 70% threshold for matching - if (bestMatch != null && bestMatch.TotalScore >= 70) - { - spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song; - usedJellyfinTracks.Add(bestMatch.Song.Id); - _logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)", - spotifyTrack.Position, - spotifyTrack.Title, - spotifyTrack.PrimaryArtist, - bestMatch.Song.Title, - bestMatch.Song.Artist, - bestMatch.TotalScore); - } - else if (bestMatch != null) - { - _logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)", - spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore); - } - } - - _logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)", - spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count); - - // Step 3: Build final playlist in Spotify order - foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) - { - // Check if we have a Jellyfin track for this position - if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack)) - { - finalTracks.Add(jellyfinTrack); - localUsedCount++; - continue; // Use local track, skip external search - } - - // No local match - try to find external track - // First check pre-matched cache - var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); - if (matched != null) - { - finalTracks.Add(matched.MatchedSong); - externalUsedCount++; - _logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}", - spotifyTrack.Position, - spotifyTrack.Title, - spotifyTrack.PrimaryArtist, - matched.MatchedSong.ExternalProvider, - matched.MatchedSong.ExternalId); + finalItems.Add(itemDict); + usedJellyfinItems.Add(matchedKey); + localUsedCount++; + _logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)", + spotifyTrack.Position, spotifyTrack.Title, bestScore); + } } else { - // No cached match - search external providers on-demand - try + // No local match - try to find external track + var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); + if (matched != null && matched.MatchedSong != null) { - var query = $"{spotifyTrack.Title} {spotifyTrack.PrimaryArtist}"; - var searchResults = await _metadataService.SearchSongsAsync(query, limit: 5); - - if (searchResults.Count > 0) - { - // Fuzzy match to find best result - var bestExternalMatch = searchResults - .Select(song => new - { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist) - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) - }) - .OrderByDescending(x => x.TotalScore) - .FirstOrDefault(); - - if (bestExternalMatch != null && bestExternalMatch.TotalScore >= 60) - { - finalTracks.Add(bestExternalMatch.Song); - externalUsedCount++; - _logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)", - spotifyTrack.Position, - spotifyTrack.Title, - spotifyTrack.PrimaryArtist, - bestExternalMatch.Song.ExternalProvider, - bestExternalMatch.Song.ExternalId, - bestExternalMatch.TotalScore); - } - else - { - skippedCount++; - _logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)", - spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist, - bestExternalMatch?.TotalScore ?? 0); - } - } - else - { - skippedCount++; - _logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (no external results)", - spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist); - } + // Convert external song to Jellyfin item format + var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); + finalItems.Add(externalItem); + externalUsedCount++; + _logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}", + spotifyTrack.Position, spotifyTrack.Title, + matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId); } - catch (Exception ex) + else { - skippedCount++; - _logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers", - spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist); + _logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH", + spotifyTrack.Position, spotifyTrack.Title); } } } - // Step 3: Add any unmatched Jellyfin tracks at the end - var unmatchedJellyfinTracks = existingTracks - .Where(song => !usedJellyfinTracks.Contains(song.Id)) - .ToList(); - - if (unmatchedJellyfinTracks.Count > 0) - { - _logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)", - unmatchedJellyfinTracks.Count); - - foreach (var track in unmatchedJellyfinTracks) - { - finalTracks.Add(track); - localUsedCount++; - _logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist); - } - } - - // Cache the result - var cacheKey = $"spotify:matched:{spotifyPlaylistName}"; - await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); - await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks); - _logger.LogInformation( - "🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)", - spotifyPlaylistName, - finalTracks.Count, - localUsedCount, - externalUsedCount, - skippedCount); + "🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)", + spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount); - if (localUsedCount == 0 && existingTracks.Count > 0) + // Return raw Jellyfin response format + return new JsonResult(new { - _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); + Items = finalItems, + TotalRecordCount = finalItems.Count, + StartIndex = 0 + }); } ///