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
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Score and filter Jellyfin results by relevance
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
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);
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
// Just interleave local and external results based on which source has better overall match
// 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 scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
var localAlbumsAvgScore = localAlbums.Any()
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
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 allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var localArtistsAvgScore = localArtists.Any()
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var externalArtistsAvgScore = externalResult.Artists.Any()
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
// Interleave results: put better-matching source first, preserve original ordering within each source
var allSongs = localSongsAvgScore >= externalSongsAvgScore
? localSongs.Concat(externalResult.Songs).ToList()
: externalResult.Songs.Concat(localSongs).ToList();
// NO deduplication - just merge and sort by relevance score
// Show ALL matches (local + external) sorted by best match first
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
? localAlbums.Concat(externalResult.Albums).ToList()
: externalResult.Albums.Concat(localAlbums).ToList();
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
? localArtists.Concat(externalResult.Artists).ToList()
: externalResult.Artists.Concat(localArtists).ToList();
// Log results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
var localArtistNames = scoredLocalArtists.Select(x => $"{x.Item.Name} (local, score: {x.Score:F2})").ToList();
var externalArtistNames = scoredExternalArtists.Select(x => $"{x.Item.Name} ({x.Item.ExternalProvider}, score: {x.Score:F2})").ToList();
_logger.LogDebug("🎤 Artist results: Local={LocalArtists}, External={ExternalArtists}, Total={TotalCount}",
string.Join(", ", localArtistNames),
string.Join(", ", externalArtistNames),
artistScores.Count);
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
}
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).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)
{
var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
var playlistItems = playlistResult
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
.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);
// Pre-fetch lyrics for top 3 songs in background (don't await)