mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 15:45:10 -05:00
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:
@@ -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<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 &&
|
||||
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<Song>();
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
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<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
|
||||
_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<Dictionary<string, object?>>(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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user