feat: preserve source search ordering instead of re-scoring

- Respect SquidWTF/Tidal's native search ranking (better than fuzzy matching)
- Interleave local and external results based on average match quality
- Put better-matching source first, preserve original order within each source
- Remove unnecessary re-scoring that was disrupting optimal search results
- Simplifies search logic and improves result relevance
This commit is contained in:
2026-02-07 12:43:15 -05:00
parent c0444becad
commit 2254616d32

View File

@@ -279,63 +279,71 @@ public class JellyfinController : ControllerBase
// Parse Jellyfin results into domain models // Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Score and filter Jellyfin results by relevance // Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false); // Just interleave local and external results based on which source has better overall match
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false); // Calculate average match score for each source to determine which should come first
var localSongsAvgScore = localSongs.Any()
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
var externalSongsAvgScore = externalResult.Songs.Any()
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
// Score external results with a small boost var localAlbumsAvgScore = localAlbums.Any()
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true); ? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true); : 0.0;
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true); var externalAlbumsAvgScore = externalResult.Albums.Any()
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
// Merge and sort by score (no filtering - just reorder by relevance) var localArtistsAvgScore = localArtists.Any()
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs) ? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
.OrderByDescending(x => x.Score) : 0.0;
.Select(x => x.Item) var externalArtistsAvgScore = externalResult.Artists.Any()
.ToList(); ? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums) // Interleave results: put better-matching source first, preserve original ordering within each source
.OrderByDescending(x => x.Score) var allSongs = localSongsAvgScore >= externalSongsAvgScore
.Select(x => x.Item) ? localSongs.Concat(externalResult.Songs).ToList()
.ToList(); : externalResult.Songs.Concat(localSongs).ToList();
// NO deduplication - just merge and sort by relevance score var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
// Show ALL matches (local + external) sorted by best match first ? localAlbums.Concat(externalResult.Albums).ToList()
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists) : externalResult.Albums.Concat(localAlbums).ToList();
.OrderByDescending(x => x.Score)
.Select(x => x.Item) var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
.ToList(); ? localArtists.Concat(externalResult.Artists).ToList()
: externalResult.Artists.Concat(localArtists).ToList();
// Log results for debugging // Log results for debugging
if (_logger.IsEnabled(LogLevel.Debug)) if (_logger.IsEnabled(LogLevel.Debug))
{ {
var localArtistNames = scoredLocalArtists.Select(x => $"{x.Item.Name} (local, score: {x.Score:F2})").ToList(); _logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
var externalArtistNames = scoredExternalArtists.Select(x => $"{x.Item.Name} ({x.Item.ExternalProvider}, score: {x.Score:F2})").ToList(); localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
_logger.LogDebug("🎤 Artist results: Local={LocalArtists}, External={ExternalArtists}, Total={TotalCount}", _logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
string.Join(", ", localArtistNames), localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
string.Join(", ", externalArtistNames), _logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
artistScores.Count); localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
} }
// Convert to Jellyfin format // Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList(); var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList(); var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var mergedArtists = artistScores.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList(); var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Add playlists (score them too) // Add playlists (preserve their order too)
if (playlistResult.Count > 0) if (playlistResult.Count > 0)
{ {
var scoredPlaylists = playlistResult var playlistItems = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) }) .Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
.ToList(); .ToList();
mergedAlbums.AddRange(scoredPlaylists); mergedAlbums.AddRange(playlistItems);
} }
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}", _logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count); mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await) // Pre-fetch lyrics for top 3 songs in background (don't await)