Use LRClib search API with fuzzy matching and prefer synced lyrics
Some checks failed
CI / build-and-test (push) Has been cancelled

- Search API is more forgiving than exact get endpoint
- Scores results by track/artist similarity and duration match
- +20 point bonus for results with synced lyrics
- Falls back to exact match if search fails
- Improves lyrics hit rate for metadata variations
This commit is contained in:
2026-02-01 12:30:24 -05:00
parent 5acdacf132
commit 0011538966

View File

@@ -42,25 +42,100 @@ public class LrclibService
try try
{ {
var url = $"{BaseUrl}/get?" + // First try search API for fuzzy matching (more forgiving)
$"track_name={Uri.EscapeDataString(trackName)}&" + var searchUrl = $"{BaseUrl}/search?" +
$"artist_name={Uri.EscapeDataString(artistName)}&" + $"track_name={Uri.EscapeDataString(trackName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" + $"artist_name={Uri.EscapeDataString(artistName)}";
$"duration={durationSeconds}";
_logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url); _logger.LogDebug("Searching lyrics from LRCLIB: {Url}", searchUrl);
var response = await _httpClient.GetAsync(url); var searchResponse = await _httpClient.GetAsync(searchUrl);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (searchResponse.IsSuccessStatusCode)
{
var searchJson = await searchResponse.Content.ReadAsStringAsync();
var searchResults = JsonSerializer.Deserialize<List<LrclibResponse>>(searchJson, JsonOptions);
if (searchResults != null && searchResults.Count > 0)
{
// Find best match by comparing track name, artist, and duration
LrclibResponse? bestMatch = null;
double bestScore = 0;
foreach (var result in searchResults)
{
// Calculate similarity scores
var trackScore = CalculateSimilarity(trackName, result.TrackName ?? "");
var artistScore = CalculateSimilarity(artistName, result.ArtistName ?? "");
// Duration match (within 5 seconds is good)
var durationDiff = Math.Abs(result.Duration - durationSeconds);
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
// Bonus for having synced lyrics (prefer synced over plain)
var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 20.0 : 0.0;
// Weighted score: track name most important, then artist, then duration, plus synced bonus
var totalScore = (trackScore * 0.5) + (artistScore * 0.3) + (durationScore * 0.2) + syncedBonus;
_logger.LogDebug("Candidate: {Track} by {Artist} - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, synced:{Synced})",
result.TrackName, result.ArtistName, totalScore, trackScore, artistScore, durationScore, !string.IsNullOrEmpty(result.SyncedLyrics));
if (totalScore > bestScore)
{
bestScore = totalScore;
bestMatch = result;
}
}
// Only use result if score is good enough (>60%)
if (bestMatch != null && bestScore >= 60)
{
_logger.LogInformation("Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1})",
artistName, trackName, bestMatch.Id, bestScore);
var result = new LyricsInfo
{
Id = bestMatch.Id,
TrackName = bestMatch.TrackName ?? trackName,
ArtistName = bestMatch.ArtistName ?? artistName,
AlbumName = bestMatch.AlbumName ?? albumName,
Duration = (int)Math.Round(bestMatch.Duration),
Instrumental = bestMatch.Instrumental,
PlainLyrics = bestMatch.PlainLyrics,
SyncedLyrics = bestMatch.SyncedLyrics
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
return result;
}
else
{
_logger.LogDebug("Best match score too low ({Score:F1}), trying exact match", bestScore);
}
}
}
// Fall back to exact match API if search didn't find good results
var exactUrl = $"{BaseUrl}/get?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}";
_logger.LogDebug("Trying exact match from LRCLIB: {Url}", exactUrl);
var exactResponse = await _httpClient.GetAsync(exactUrl);
if (exactResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
_logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName); _logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName);
return null; return null;
} }
response.EnsureSuccessStatusCode(); exactResponse.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(); var json = await exactResponse.Content.ReadAsStringAsync();
var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions); var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions);
if (lyrics == null) if (lyrics == null)
@@ -68,7 +143,7 @@ public class LrclibService
return null; return null;
} }
var result = new LyricsInfo var exactResult = new LyricsInfo
{ {
Id = lyrics.Id, Id = lyrics.Id,
TrackName = lyrics.TrackName ?? trackName, TrackName = lyrics.TrackName ?? trackName,
@@ -80,11 +155,11 @@ public class LrclibService
SyncedLyrics = lyrics.SyncedLyrics SyncedLyrics = lyrics.SyncedLyrics
}; };
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30)); await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
_logger.LogInformation("Retrieved lyrics for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id); _logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
return result; return exactResult;
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
@@ -98,6 +173,28 @@ public class LrclibService
} }
} }
private static double CalculateSimilarity(string str1, string str2)
{
if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2))
return 0;
str1 = str1.ToLowerInvariant();
str2 = str2.ToLowerInvariant();
if (str1 == str2)
return 100;
// Simple token-based matching
var tokens1 = str1.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
var tokens2 = str2.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens1.Length == 0 || tokens2.Length == 0)
return 0;
var matchedTokens = tokens1.Count(t1 => tokens2.Any(t2 => t2.Contains(t1) || t1.Contains(t2)));
return (matchedTokens * 100.0) / Math.Max(tokens1.Length, tokens2.Length);
}
public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds) public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds)
{ {
try try