diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs index cc5e758..755ab4f 100644 --- a/allstarr/Services/Lyrics/LrclibService.cs +++ b/allstarr/Services/Lyrics/LrclibService.cs @@ -42,25 +42,100 @@ public class LrclibService try { - var url = $"{BaseUrl}/get?" + - $"track_name={Uri.EscapeDataString(trackName)}&" + - $"artist_name={Uri.EscapeDataString(artistName)}&" + - $"album_name={Uri.EscapeDataString(albumName)}&" + - $"duration={durationSeconds}"; + // First try search API for fuzzy matching (more forgiving) + var searchUrl = $"{BaseUrl}/search?" + + $"track_name={Uri.EscapeDataString(trackName)}&" + + $"artist_name={Uri.EscapeDataString(artistName)}"; - _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>(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); return null; } - response.EnsureSuccessStatusCode(); + exactResponse.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await exactResponse.Content.ReadAsStringAsync(); var lyrics = JsonSerializer.Deserialize(json, JsonOptions); if (lyrics == null) @@ -68,7 +143,7 @@ public class LrclibService return null; } - var result = new LyricsInfo + var exactResult = new LyricsInfo { Id = lyrics.Id, TrackName = lyrics.TrackName ?? trackName, @@ -80,11 +155,11 @@ public class LrclibService 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) { @@ -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 GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds) { try