mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55: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}",
|
_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>
|
||||||
|
|||||||
Reference in New Issue
Block a user