4 Commits
v1.2.3 ... beta

Author SHA1 Message Date
713ecd4ec8 v1.2.6: fix search result ordering to prioritize local tracks
Some checks are pending
Docker Build & Push / build-and-test (push) Waiting to run
Docker Build & Push / docker (push) Blocked by required conditions
2026-02-10 13:36:06 -05:00
0ff1e3a428 v1.2.5: fix genre enrichment blocking cover art loading 2026-02-10 12:56:43 -05:00
cef18b9482 v1.2.5: prioritize local tracks and optimize genre enrichment
Local tracks now appear first in search results with +10 score boost. Genre enrichment is non-blocking for faster cover art and playback.
2026-02-10 12:50:52 -05:00
1bfe30b216 v1.2.4: stop racing SquidWTF endpoints for better throughput
Use round-robin instead of racing to enable parallel processing of 12 tracks simultaneously (one per endpoint) instead of racing all endpoints for each track.
2026-02-10 12:14:38 -05:00
5 changed files with 145 additions and 59 deletions

View File

@@ -279,53 +279,50 @@ 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);
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching) // Sort all results by match score (local tracks get +10 boost)
// Just interleave local and external results based on which source has better overall match // This ensures best matches appear first regardless of source
var allSongs = localSongs.Concat(externalResult.Songs)
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Song)
.ToList();
// Calculate average match score for each source to determine which should come first var allAlbums = localAlbums.Concat(externalResult.Albums)
var localSongsAvgScore = localSongs.Any() .Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title)) .OrderByDescending(x => x.Score)
: 0.0; .Select(x => x.Album)
var externalSongsAvgScore = externalResult.Songs.Any() .ToList();
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
var localAlbumsAvgScore = localAlbums.Any() var allArtists = localArtists.Concat(externalResult.Artists)
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title)) .Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
: 0.0; .OrderByDescending(x => x.Score)
var externalAlbumsAvgScore = externalResult.Albums.Any() .Select(x => x.Artist)
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title)) .ToList();
: 0.0;
var localArtistsAvgScore = localArtists.Any() // Log top results for debugging
? 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;
// 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();
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)) if (_logger.IsEnabled(LogLevel.Debug))
{ {
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}", if (allSongs.Any())
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore); {
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}", var topSong = allSongs.First();
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore); var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}", _logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore); topSong.Title, topSong.IsLocal, topScore);
}
if (allAlbums.Any())
{
var topAlbum = allAlbums.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
topAlbum.Title, topAlbum.IsLocal, topScore);
}
if (allArtists.Any())
{
var topArtist = allArtists.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
topArtist.Name, topArtist.IsLocal, topScore);
}
} }
// Convert to Jellyfin format // Convert to Jellyfin format
@@ -343,7 +340,7 @@ public class JellyfinController : ControllerBase
mergedAlbums.AddRange(playlistItems); mergedAlbums.AddRange(playlistItems);
} }
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}", _logger.LogInformation("Merged and sorted results by score: 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)

View File

@@ -212,7 +212,18 @@ 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))
{ {
await _genreEnrichment.EnrichSongGenreAsync(song); // Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
catch
{
// Silently ignore genre enrichment failures
}
});
} }
return song; return song;

View File

@@ -186,7 +186,18 @@ 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))
{ {
await _genreEnrichment.EnrichSongGenreAsync(song); // Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
} }
return song; return song;

View File

@@ -992,7 +992,8 @@ public class SpotifyTrackMatchingService : BackgroundService
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\""; headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
} }
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources"; // Request all fields that clients typically need (not just MediaSources)
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers); var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
if (statusCode != 200 || existingTracksResponse == null) if (statusCode != 200 || existingTracksResponse == null)
@@ -1301,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

@@ -86,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20) public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{ {
// Race all endpoints for fastest search results // Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Use 's' parameter for track search as per hifi-api spec // Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
throw new HttpRequestException($"HTTP {response.StatusCode}"); throw new HttpRequestException($"HTTP {response.StatusCode}");
} }
var json = await response.Content.ReadAsStringAsync(ct); var json = await response.Content.ReadAsStringAsync();
// Check for error in response body // Check for error in response body
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
@@ -132,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20) public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{ {
// Race all endpoints for fastest search results // Use round-robin to distribute load across endpoints (allows parallel processing)
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used // Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
throw new HttpRequestException($"HTTP {response.StatusCode}"); throw new HttpRequestException($"HTTP {response.StatusCode}");
} }
var json = await response.Content.ReadAsStringAsync(ct); var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
var albums = new List<Album>(); var albums = new List<Album>();
@@ -169,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20) public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{ {
// Race all endpoints for fastest search results // Use round-robin to distribute load across endpoints (allows parallel processing)
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Per hifi-api spec: use 'a' parameter for artist search // Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); _logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, ct); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -184,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
throw new HttpRequestException($"HTTP {response.StatusCode}"); throw new HttpRequestException($"HTTP {response.StatusCode}");
} }
var json = await response.Content.ReadAsStringAsync(ct); var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
var artists = new List<Artist>(); var artists = new List<Artist>();
@@ -292,7 +292,18 @@ 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))
{ {
await _genreEnrichment.EnrichSongGenreAsync(song); // Fire-and-forget: don't block the response waiting for genre enrichment
_ = Task.Run(async () =>
{
try
{
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)