fix(lyrics): reject mismatched search results

This commit is contained in:
zarzet
2026-07-24 18:48:05 +07:00
parent 4713be02de
commit 452a15b652
7 changed files with 491 additions and 75 deletions
+118 -22
View File
@@ -428,6 +428,16 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
}
func lrclibTrackName(response *LRCLibResponse) string {
if response == nil {
return ""
}
if trackName := strings.TrimSpace(response.TrackName); trackName != "" {
return trackName
}
return strings.TrimSpace(response.Name)
}
type LyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"`
@@ -490,11 +500,19 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
if err := c.lrclibGet("/api/get", params, &lrcResp); err != nil {
return nil, err
}
if !lyricsSearchTitlesMatch(lrclibTrackName(&lrcResp), track, false) ||
!lyricsSearchArtistsMatch(lrcResp.ArtistName, artist) {
return nil, lyricsNotFoundErrorf("LRCLIB returned mismatched track metadata")
}
return c.parseLRCLibResponse(&lrcResp), nil
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
return c.fetchLyricsFromLRCLibSearch(query, "", "", durationSec)
}
func (c *LyricsClient) fetchLyricsFromLRCLibSearch(query, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
params := url.Values{}
params.Set("q", query)
@@ -507,35 +525,48 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return nil, lyricsNotFoundErrorf("no lyrics found")
}
bestMatch := c.findBestMatch(results, durationSec)
bestMatch := c.findBestLRCLibSearchMatch(results, query, trackName, artistName, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
}
}
return c.parseLRCLibResponse(&results[0]), nil
return nil, lyricsNotFoundErrorf("no matching lyrics found")
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
func lrclibSearchResultMatches(result *LRCLibResponse, query, trackName, artistName string, durationSec float64) bool {
if result == nil || !lyricsSearchDurationMatches(result.Duration, durationSec) {
return false
}
candidateTrack := lrclibTrackName(result)
if strings.TrimSpace(trackName) != "" || strings.TrimSpace(artistName) != "" {
return lyricsSearchTitlesMatch(candidateTrack, trackName, false) &&
lyricsSearchArtistsMatch(result.ArtistName, artistName)
}
normalizedQuery := normalizeLooseArtistName(query)
normalizedTrack := normalizeLooseArtistName(simplifyTrackName(candidateTrack))
normalizedArtist := normalizeLooseArtistName(normalizeArtistName(result.ArtistName))
return normalizedQuery != "" &&
normalizedTrack != "" &&
normalizedArtist != "" &&
containsWordSequence(normalizedQuery, normalizedTrack) &&
containsWordSequence(normalizedQuery, normalizedArtist)
}
func (c *LyricsClient) findBestLRCLibSearchMatch(results []LRCLibResponse, query, trackName, artistName string, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
result := &results[i]
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
if !lrclibSearchResultMatches(result, query, trackName, artistName, targetDurationSec) {
continue
}
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
@@ -1001,7 +1032,7 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
}
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
lyrics, err = c.fetchLyricsFromLRCLibSearch(query, trackName, primaryArtist, durationSec)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB Search"
return lyrics, nil
@@ -1012,7 +1043,7 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
lyrics, err = c.fetchLyricsFromLRCLibSearch(query, simplifiedTrack, primaryArtist, durationSec)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB Search (simplified)"
return lyrics, nil
@@ -1291,8 +1322,8 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
var simplifyTrackNamePatterns = func() []*regexp.Regexp {
patterns := []string{
`\s*\(feat\..*?\)`,
`\s*\(ft\..*?\)`,
`\s*\(feat\.?.*?\)`,
`\s*\(ft\.?.*?\)`,
`\s*\(featuring.*?\)`,
`\s*\(with.*?\)`,
`\s*-\s*Remaster(ed)?.*$`,
@@ -1329,6 +1360,71 @@ func simplifyTrackName(name string) string {
return result
}
func normalizedLyricsSearchTitle(name string) string {
return strings.ToLower(strings.TrimSpace(simplifyTrackName(name)))
}
func containsWordSequence(value, sequence string) bool {
valueWords := strings.Fields(value)
sequenceWords := strings.Fields(sequence)
if len(valueWords) == 0 || len(sequenceWords) == 0 || len(sequenceWords) > len(valueWords) {
return false
}
for start := 0; start <= len(valueWords)-len(sequenceWords); start++ {
matches := true
for offset := range sequenceWords {
if valueWords[start+offset] != sequenceWords[offset] {
matches = false
break
}
}
if matches {
return true
}
}
return false
}
func lyricsSearchTitlesMatch(candidateTrack, trackName string, allowDecoratedCandidate bool) bool {
expected := normalizedLyricsSearchTitle(trackName)
candidate := normalizedLyricsSearchTitle(candidateTrack)
if expected == "" || candidate == "" {
return false
}
if candidate == expected {
return true
}
return allowDecoratedCandidate && containsWordSequence(candidate, expected)
}
func lyricsSearchArtistsMatch(candidateArtist, artistName string) bool {
expected := normalizeLooseArtistName(normalizeArtistName(artistName))
if expected == "" {
return true
}
candidate := normalizeLooseArtistName(normalizeArtistName(candidateArtist))
if candidate == "" {
return false
}
return candidate == expected || sameWordsUnordered(candidate, expected)
}
func lyricsSearchDurationMatches(candidateDuration, durationSec float64) bool {
if candidateDuration <= 0 || durationSec <= 0 {
return true
}
return math.Abs(candidateDuration-durationSec) <= durationToleranceSec
}
func lyricsSearchArtistAppearsInTitle(candidateTrack, artistName string) bool {
expectedArtist := normalizeLooseArtistName(normalizeArtistName(artistName))
candidateTitle := normalizeLooseArtistName(candidateTrack)
return expectedArtist != "" &&
candidateTitle != "" &&
containsWordSequence(candidateTitle, expectedArtist)
}
func normalizeArtistName(name string) string {
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
+27 -32
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
@@ -86,53 +85,49 @@ func NewAppleMusicClient() *AppleMusicClient {
}
}
func appleMusicSearchResultMatches(result appleMusicSearchResult, trackName, artistName string, durationSec float64) bool {
if !lyricsSearchTitlesMatch(result.SongName, trackName, false) {
return false
}
if !lyricsSearchArtistsMatch(result.ArtistName, artistName) {
return false
}
if !lyricsSearchDurationMatches(float64(result.Duration)/1000.0, durationSec) {
return false
}
return true
}
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
if len(results) == 0 {
return nil
}
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
if normalizedArtist == "" {
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
}
bestIndex := 0
bestIndex := -1
bestScore := -1
for i := range results {
result := &results[i]
score := 0
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && result.Duration > 0 {
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
if diff <= durationToleranceSec {
score += 20
}
if !appleMusicSearchResultMatches(*result, trackName, artistName, durationSec) {
continue
}
score := scoreLyricsSearchCandidate(
result.SongName,
result.ArtistName,
float64(result.Duration)/1000.0,
trackName,
artistName,
durationSec,
)
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 {
return nil
}
return &results[bestIndex]
}
+91
View File
@@ -0,0 +1,91 @@
package gobackend
import "testing"
func TestSelectBestAppleMusicSearchResultRejectsWrongSongWithMatchingArtistAndDuration(t *testing.T) {
results := []appleMusicSearchResult{
{
ID: "azul",
SongName: "Azul",
ArtistName: "Guru Randhawa",
Duration: 186000,
},
}
best := selectBestAppleMusicSearchResult(
results,
"SIX",
"Guru Randhawa, Kiran Bajwa, Gurjit Gill & Lavish Dhiman",
186,
)
if best != nil {
t.Fatalf("expected the unrelated Azul result to be rejected, got %#v", best)
}
}
func TestSelectBestAppleMusicSearchResultRequiresAvailableIdentitySignals(t *testing.T) {
tests := []struct {
name string
result appleMusicSearchResult
}{
{
name: "wrong artist",
result: appleMusicSearchResult{
ID: "wrong-artist",
SongName: "SIX",
ArtistName: "Different Artist",
Duration: 186000,
},
},
{
name: "wrong duration",
result: appleMusicSearchResult{
ID: "wrong-duration",
SongName: "SIX",
ArtistName: "Guru Randhawa",
Duration: 240000,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
best := selectBestAppleMusicSearchResult(
[]appleMusicSearchResult{test.result},
"SIX",
"Guru Randhawa",
186,
)
if best != nil {
t.Fatalf("expected mismatched result to be rejected, got %#v", best)
}
})
}
}
func TestSelectBestAppleMusicSearchResultAcceptsCompatibleMetadata(t *testing.T) {
results := []appleMusicSearchResult{
{
ID: "azul",
SongName: "Azul",
ArtistName: "Guru Randhawa",
Duration: 186000,
},
{
ID: "six",
SongName: "SIX (feat. Kiran Bajwa)",
ArtistName: "Guru Randhawa & Kiran Bajwa",
Duration: 187000,
},
}
best := selectBestAppleMusicSearchResult(
results,
"SIX",
"Guru Randhawa, Kiran Bajwa, Gurjit Gill & Lavish Dhiman",
186,
)
if best == nil || best.ID != "six" {
t.Fatalf("expected the compatible SIX result, got %#v", best)
}
}
+43 -9
View File
@@ -13,16 +13,18 @@ type NeteaseClient struct {
httpClient *http.Client
}
type neteaseSearchSong struct {
Name string `json:"name"`
ID int64 `json:"id"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
}
type neteaseSearchResponse struct {
Result struct {
Songs []struct {
Name string `json:"name"`
ID int64 `json:"id"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
} `json:"songs"`
SongCount int `json:"songCount"`
Songs []neteaseSearchSong `json:"songs"`
SongCount int `json:"songCount"`
} `json:"result"`
Code int `json:"code"`
Message string `json:"message"`
@@ -104,7 +106,39 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, lyricsNotFoundErrorf("no songs found on netease")
}
return searchResp.Result.Songs[0].ID, nil
best := selectBestNeteaseSearchResult(searchResp.Result.Songs, trackName, artistName)
if best == nil || best.ID == 0 {
return 0, lyricsNotFoundErrorf("no matching songs found on netease")
}
return best.ID, nil
}
func selectBestNeteaseSearchResult(results []neteaseSearchSong, trackName, artistName string) *neteaseSearchSong {
bestIndex := -1
bestScore := -1
for i := range results {
result := &results[i]
artists := make([]string, 0, len(result.Artists))
for _, artist := range result.Artists {
if name := strings.TrimSpace(artist.Name); name != "" {
artists = append(artists, name)
}
}
candidateArtist := strings.Join(artists, ", ")
if !lyricsSearchTitlesMatch(result.Name, trackName, false) ||
!lyricsSearchArtistsMatch(candidateArtist, artistName) {
continue
}
score := scoreLyricsSearchCandidate(result.Name, candidateArtist, 0, trackName, artistName, 0)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
if bestIndex < 0 {
return nil
}
return &results[bestIndex]
}
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
+46 -10
View File
@@ -270,16 +270,25 @@ func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, tr
return nil
}
bestIndex := 0
bestIndex := -1
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
candidateDuration := parseClockDuration(result.Duration)
if !lyricsSearchTitlesMatch(result.Name, trackName, false) ||
!lyricsSearchArtistsMatch(result.ArtistName, artistName) ||
!lyricsSearchDurationMatches(candidateDuration, durationSec) {
continue
}
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, candidateDuration, trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
if bestIndex < 0 {
return nil
}
return &results[bestIndex]
}
@@ -378,16 +387,27 @@ func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, tr
return nil
}
bestIndex := 0
bestIndex := -1
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
candidateDuration := parseClockDuration(result.Duration)
artistMatches := lyricsSearchArtistsMatch(result.Author, artistName) ||
lyricsSearchArtistAppearsInTitle(result.Title, artistName)
if !lyricsSearchTitlesMatch(result.Title, trackName, true) ||
!artistMatches ||
!lyricsSearchDurationMatches(candidateDuration, durationSec) {
continue
}
score := scoreLyricsSearchCandidate(result.Title, result.Author, candidateDuration, trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
if bestIndex < 0 {
return nil
}
return &results[bestIndex]
}
@@ -435,16 +455,24 @@ func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackN
return nil
}
bestIndex := 0
bestIndex := -1
bestScore := -1
for i := range results {
result := &results[i]
if !lyricsSearchTitlesMatch(result.Title, trackName, false) ||
!lyricsSearchArtistsMatch(result.Artist, artistName) ||
!lyricsSearchDurationMatches(result.Duration, durationSec) {
continue
}
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
if bestIndex < 0 {
return nil
}
return &results[bestIndex]
}
@@ -482,6 +510,14 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
return "", fmt.Errorf("failed to decode genius search: %w", err)
}
bestURL := selectBestGeniusLyricsSearchResult(results, trackName, artistName, durationSec)
if bestURL == "" {
return "", lyricsNotFoundErrorf("no songs found on genius")
}
return bestURL, nil
}
func selectBestGeniusLyricsSearchResult(results geniusSearchResponse, trackName, artistName string, durationSec float64) string {
bestURL := ""
bestScore := -1
for _, section := range results.Response.Sections {
@@ -494,6 +530,10 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
if strings.TrimSpace(artist) == "" {
artist = hit.Result.ArtistNames
}
if !lyricsSearchTitlesMatch(hit.Result.Title, trackName, false) ||
!lyricsSearchArtistsMatch(artist, artistName) {
continue
}
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
if score > bestScore {
bestScore = score
@@ -501,11 +541,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
}
}
}
if bestURL == "" {
return "", lyricsNotFoundErrorf("no songs found on genius")
}
return bestURL, nil
return bestURL
}
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
+155
View File
@@ -0,0 +1,155 @@
package gobackend
import (
"encoding/json"
"testing"
)
func TestLyricsSearchSelectorsRejectUnrelatedSongWithMatchingArtistAndDuration(t *testing.T) {
const (
trackName = "SIX"
artistName = "Guru Randhawa"
durationSec = 186
)
if best := selectBestSpotifyLyricsSearchResult(
[]spotifyLyricsSearchResult{{
TrackID: "azul",
Name: "Azul",
ArtistName: artistName,
Duration: "3:06",
}},
trackName,
artistName,
durationSec,
); best != nil {
t.Fatalf("Spotify accepted unrelated result: %#v", best)
}
if best := selectBestYouTubeLyricsSearchResult(
[]youtubeLyricsSearchResult{{
VideoID: "azul",
Title: "Azul",
Author: artistName,
Duration: "3:06",
}},
trackName,
artistName,
durationSec,
); best != nil {
t.Fatalf("YouTube accepted unrelated result: %#v", best)
}
if best := selectBestKugouLyricsSearchResult(
[]kugouLyricsSearchResult{{
Hash: "azul",
Title: "Azul",
Artist: artistName,
Duration: durationSec,
}},
trackName,
artistName,
durationSec,
); best != nil {
t.Fatalf("Kugou accepted unrelated result: %#v", best)
}
var geniusResults geniusSearchResponse
if err := json.Unmarshal([]byte(`{
"response": {
"sections": [{
"hits": [{
"type": "song",
"result": {
"title": "Azul",
"primary_artist_names": "Guru Randhawa",
"url": "https://genius.com/guru-randhawa-azul-lyrics"
}
}]
}]
}
}`), &geniusResults); err != nil {
t.Fatalf("decode Genius fixture: %v", err)
}
if bestURL := selectBestGeniusLyricsSearchResult(
geniusResults,
trackName,
artistName,
durationSec,
); bestURL != "" {
t.Fatalf("Genius accepted unrelated result: %q", bestURL)
}
var neteaseResults neteaseSearchResponse
if err := json.Unmarshal([]byte(`{
"result": {
"songCount": 1,
"songs": [{
"name": "Azul",
"id": 123,
"artists": [{"name": "Guru Randhawa"}]
}]
},
"code": 200
}`), &neteaseResults); err != nil {
t.Fatalf("decode Netease fixture: %v", err)
}
if best := selectBestNeteaseSearchResult(
neteaseResults.Result.Songs,
trackName,
artistName,
); best != nil {
t.Fatalf("Netease accepted unrelated result: %#v", best)
}
lrclibResult := &LRCLibResponse{
TrackName: "Azul",
ArtistName: artistName,
Duration: durationSec,
SyncedLyrics: "[00:01.00]Wrong",
}
if lrclibSearchResultMatches(
lrclibResult,
"Guru Randhawa SIX",
trackName,
artistName,
durationSec,
) {
t.Fatalf("LRCLIB accepted unrelated result: %#v", lrclibResult)
}
}
func TestYouTubeLyricsSearchAllowsDecoratedTitleWithArtistSignal(t *testing.T) {
results := []youtubeLyricsSearchResult{{
VideoID: "six",
Title: "Guru Randhawa - SIX (Official Music Video)",
Author: "T-Series",
Duration: "3:06",
}}
best := selectBestYouTubeLyricsSearchResult(
results,
"SIX",
"Guru Randhawa",
186,
)
if best == nil || best.VideoID != "six" {
t.Fatalf("expected decorated YouTube result to match, got %#v", best)
}
}
func TestDecoratedLyricsTitleMatchingUsesWholeWords(t *testing.T) {
if !lyricsSearchTitlesMatch(
"Guru Randhawa - SIX (Official Music Video)",
"SIX",
true,
) {
t.Fatal("expected decorated SIX title to match")
}
if lyricsSearchTitlesMatch("SIXTEEN", "SIX", true) {
t.Fatal("SIX must not match SIXTEEN")
}
if lyricsSearchArtistsMatch("Guru Randhawa Tribute", "Guru Randhawa") {
t.Fatal("artist matching must not accept a longer unrelated name")
}
}
+11 -2
View File
@@ -114,7 +114,7 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
case "/api/get":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":1,"trackName":"Song","artistName":"Artist","duration":180,"syncedLyrics":"[00:01.00]Hello"}`)), Request: req}, nil
case "/api/search":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":2,"duration":180,"plainLyrics":"Plain\nLyric"},{"id":3,"duration":180,"syncedLyrics":"[00:02.00]Synced"}]`)), Request: req}, nil
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":2,"trackName":"Song","artistName":"Artist","duration":180,"plainLyrics":"Plain\nLyric"},{"id":3,"trackName":"Song","artistName":"Artist","duration":180,"syncedLyrics":"[00:02.00]Synced"}]`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
@@ -127,7 +127,16 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
if err != nil || len(search.Lines) == 0 {
t.Fatalf("FetchLyricsFromLRCLibSearch = %#v/%v", search, err)
}
if best := client.findBestMatch([]LRCLibResponse{{Duration: 100, PlainLyrics: "A"}, {Duration: 180, SyncedLyrics: "[00:01.00]B"}}, 180); best == nil || best.SyncedLyrics == "" {
if best := client.findBestLRCLibSearchMatch(
[]LRCLibResponse{
{TrackName: "Other", ArtistName: "Artist", Duration: 180, PlainLyrics: "A"},
{TrackName: "Song", ArtistName: "Artist", Duration: 180, SyncedLyrics: "[00:01.00]B"},
},
"Artist Song",
"Song",
"Artist",
180,
); best == nil || best.SyncedLyrics == "" {
t.Fatalf("best = %#v", best)
}
if !client.durationMatches(181, 180) || client.durationMatches(300, 180) {