Fix bitrate for injected playlists by preserving raw Jellyfin items

CRITICAL FIX: Don't convert Jellyfin items to Song objects and back!

- Get raw Jellyfin playlist items with MediaSources field
- Reorder them according to Spotify positions
- Inject external tracks where needed
- Return raw items preserving ALL Jellyfin metadata (bitrate, etc)

This ensures local tracks in Spotify playlists show correct bitrate
just like regular Jellyfin playlists.
This commit is contained in:
2026-02-03 23:41:29 -05:00
parent e17eee9bf3
commit e3a118e578

View File

@@ -2912,7 +2912,7 @@ public class JellyfinController : ControllerBase
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}", _logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
orderedTracks.Count, spotifyPlaylistName); 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 // CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId; var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
@@ -2921,7 +2921,8 @@ public class JellyfinController : ControllerBase
return null; // Fall back to legacy mode 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}", _logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
playlistId, userId); playlistId, userId);
@@ -2937,24 +2938,41 @@ public class JellyfinController : ControllerBase
return null; return null;
} }
var existingTracks = new List<Song>(); // Keep raw Jellyfin items - don't convert to Song objects!
var jellyfinItems = new List<JsonElement>();
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse != null && if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{ {
foreach (var item in items.EnumerateArray()) foreach (var item in items.EnumerateArray())
{ {
var song = _modelMapper.ParseSong(item); jellyfinItems.Add(item);
existingTracks.Add(song);
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist); // 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() ?? "";
} }
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only", else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
existingTracks.Count); {
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", jellyfinItems.Count);
} }
else else
{ {
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId); _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 // 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 // Build the final track list in correct Spotify order
// STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external var finalItems = new List<Dictionary<string, object?>>();
var finalTracks = new List<Song>(); var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0; var localUsedCount = 0;
var externalUsedCount = 0; var externalUsedCount = 0;
var skippedCount = 0;
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...", _logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
existingTracks.Count, spotifyTracks.Count);
// Step 1: Check for manual mappings first
var manualMappings = new Dictionary<string, string>(); // Spotify ID -> Jellyfin ID
foreach (var spotifyTrack in spotifyTracks)
{
var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}";
var jellyfinId = await _cache.GetAsync<string>(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<int, Song>(); // Spotify position -> Jellyfin track
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) 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 foreach (var kvp in jellyfinItemsByName)
if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId))
{ {
var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId); if (usedJellyfinItems.Contains(kvp.Key)) continue;
if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id))
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; artist = artistsEl[0].GetString() ?? "";
usedJellyfinTracks.Add(mappedTrack.Id); }
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'",
spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title); var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
continue; 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 if (matchedJellyfinItem.HasValue && matchedKey != null)
var bestMatch = existingTracks
.Where(song => !usedJellyfinTracks.Contains(song.Id))
.Select(song => new
{ {
Song = song, // Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title), var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist) if (itemDict != null)
})
.Select(x => new
{ {
x.Song, finalItems.Add(itemDict);
x.TitleScore, usedJellyfinItems.Add(matchedKey);
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++; localUsedCount++;
continue; // Use local track, skip external search _logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
spotifyTrack.Position, spotifyTrack.Title, bestScore);
} }
}
else
{
// No local match - try to find external track // No local match - try to find external track
// First check pre-matched cache
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null) if (matched != null && matched.MatchedSong != null)
{ {
finalTracks.Add(matched.MatchedSong); // Convert external song to Jellyfin item format
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
finalItems.Add(externalItem);
externalUsedCount++; externalUsedCount++;
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}", _logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}",
spotifyTrack.Position, spotifyTrack.Position, spotifyTrack.Title,
spotifyTrack.Title, matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
spotifyTrack.PrimaryArtist,
matched.MatchedSong.ExternalProvider,
matched.MatchedSong.ExternalId);
} }
else else
{ {
// No cached match - search external providers on-demand _logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
try spotifyTrack.Position, spotifyTrack.Title);
{
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);
}
}
catch (Exception ex)
{
skippedCount++;
_logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
} }
} }
} }
// 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( _logger.LogInformation(
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)", "🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
spotifyPlaylistName, spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
finalTracks.Count,
localUsedCount,
externalUsedCount,
skippedCount);
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); Items = finalItems,
_logger.LogWarning(" → Track names may be too different between Spotify and Jellyfin"); TotalRecordCount = finalItems.Count,
_logger.LogWarning(" → Check that the Jellyfin playlist has the correct tracks"); StartIndex = 0
} });
else if (localUsedCount > 0)
{
_logger.LogInformation("✅ Successfully used {Local} LOCAL tracks from Jellyfin playlist", localUsedCount);
}
return _responseBuilder.CreateItemsResponse(finalTracks);
} }
/// <summary> /// <summary>