mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
fix(lyrics): reject mismatched search results
This commit is contained in:
+118
-22
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user