fix: prioritize local tracks in search and make genre enrichment non-blocking

- Local tracks get +10 score boost in search results (exact matches now appear first)
- Genre enrichment is now fire-and-forget (doesn't block cover art or playback)
- External playlist tracks are enriched with MusicBrainz genres during pre-building
- Genres are cached for 30 days to minimize API calls
This commit is contained in:
2026-02-10 12:50:24 -05:00
parent ae418bef94
commit b4fbf7431d
5 changed files with 95 additions and 6 deletions

View File

@@ -283,22 +283,23 @@ public class JellyfinController : ControllerBase
// Just interleave local and external results based on which source has better overall match // 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 // Calculate average match score for each source to determine which should come first
// Give local tracks a +10 boost to prioritize them
var localSongsAvgScore = localSongs.Any() var localSongsAvgScore = localSongs.Any()
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title)) ? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + 10.0)
: 0.0; : 0.0;
var externalSongsAvgScore = externalResult.Songs.Any() var externalSongsAvgScore = externalResult.Songs.Any()
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title)) ? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0; : 0.0;
var localAlbumsAvgScore = localAlbums.Any() var localAlbumsAvgScore = localAlbums.Any()
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title)) ? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + 10.0)
: 0.0; : 0.0;
var externalAlbumsAvgScore = externalResult.Albums.Any() var externalAlbumsAvgScore = externalResult.Albums.Any()
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title)) ? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0; : 0.0;
var localArtistsAvgScore = localArtists.Any() var localArtistsAvgScore = localArtists.Any()
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name)) ? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + 10.0)
: 0.0; : 0.0;
var externalArtistsAvgScore = externalResult.Artists.Any() var externalArtistsAvgScore = externalResult.Artists.Any()
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name)) ? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))

View File

@@ -211,9 +211,20 @@ public class DeezerMetadataService : IMusicMetadataService
// Enrich with MusicBrainz genres if missing // Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
// Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{ {
await _genreEnrichment.EnrichSongGenreAsync(song); await _genreEnrichment.EnrichSongGenreAsync(song);
} }
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
return song; return song;
} }

View File

@@ -185,9 +185,20 @@ public class QobuzMetadataService : IMusicMetadataService
// Enrich with MusicBrainz genres if missing // Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre)) if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
{
// Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{ {
await _genreEnrichment.EnrichSongGenreAsync(song); await _genreEnrichment.EnrichSongGenreAsync(song);
} }
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
return song; return song;
} }

View File

@@ -1302,6 +1302,61 @@ public class SpotifyTrackMatchingService : BackgroundService
if (finalItems.Count > 0) if (finalItems.Count > 0)
{ {
// Enrich external tracks with genres from MusicBrainz
if (externalUsedCount > 0)
{
try
{
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
if (genreEnrichment != null)
{
_logger.LogInformation("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
// Extract external songs from matched tracks
var externalSongs = matchedTracks
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
.Select(t => t.MatchedSong!)
.ToList();
// Enrich genres in parallel
await genreEnrichment.EnrichSongsGenresAsync(externalSongs);
// Update the genres in finalItems
foreach (var item in finalItems)
{
if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-"))
{
// Find the corresponding song
var song = externalSongs.FirstOrDefault(s => s.Id == id);
if (song != null && !string.IsNullOrEmpty(song.Genre))
{
// Update Genres array
item["Genres"] = new[] { song.Genre };
// Update GenreItems array
item["GenreItems"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = song.Genre,
["Id"] = $"genre-{song.Genre.ToLowerInvariant()}"
}
};
_logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre);
}
}
}
_logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
}
}
// Save to Redis cache with same expiration as matched tracks (until next cron run) // Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = $"spotify:playlist:items:{playlistName}"; var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration); await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);

View File

@@ -291,9 +291,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres) // Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
// Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{ {
await _genreEnrichment.EnrichSongGenreAsync(song); await _genreEnrichment.EnrichSongGenreAsync(song);
} }
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService) // NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
// This avoids redundant conversions and ensures it's done in parallel with the download // This avoids redundant conversions and ensures it's done in parallel with the download