From 358154c39ecdb0d8514139677f16f0a1b705381a Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 27 Jul 2026 00:16:18 +0700 Subject: [PATCH] refactor(go): split lyrics provider config, LRC codec, and search matching out of lyrics.go --- go_backend/lyrics.go | 784 ---------------------------------- go_backend/lyrics_config.go | 406 ++++++++++++++++++ go_backend/lyrics_lrc.go | 267 ++++++++++++ go_backend/lyrics_matching.go | 135 ++++++ 4 files changed, 808 insertions(+), 784 deletions(-) create mode 100644 go_backend/lyrics_config.go create mode 100644 go_backend/lyrics_lrc.go create mode 100644 go_backend/lyrics_matching.go diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 128ec04f..5241f756 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -2,15 +2,10 @@ package gobackend import ( "encoding/json" - "errors" "fmt" "math" "net/http" "net/url" - "os" - "path/filepath" - "regexp" - "strconv" "strings" "sync" "time" @@ -21,401 +16,6 @@ const ( durationToleranceSec = 10.0 ) -const ( - lyricsProviderUnavailableCooldown = 10 * time.Minute - lyricsProviderParallelism = 3 - lyricsProviderPriorityGrace = 5000 * time.Millisecond -) - -const ( - LyricsProviderLRCLIB = "lrclib" - LyricsProviderNetease = "netease" - LyricsProviderMusixmatch = "musixmatch" - LyricsProviderAppleMusic = "apple_music" - LyricsProviderQQMusic = "qqmusic" - LyricsProviderSpotify = "spotify" - LyricsProviderDeezer = "deezer" - LyricsProviderYouTube = "youtube" - LyricsProviderKugou = "kugou" - LyricsProviderGenius = "genius" - LyricsProviderLyricsPlus = "lyricsplus" -) - -var DefaultLyricsProviders = []string{ - LyricsProviderLRCLIB, - LyricsProviderAppleMusic, -} - -var ( - lyricsProvidersMu sync.RWMutex - lyricsProviders []string // ordered list of enabled providers - appVersionMu sync.RWMutex - appVersion string -) - -type lyricsProviderHealthEntry struct { - unavailableUntil time.Time - reason string -} - -type lyricsProviderSearchRequest struct { - spotifyID string - trackName string - artistName string - primaryArtist string - simplifiedTrack string - durationSec float64 - fetchOptions LyricsFetchOptions -} - -type lyricsProviderSearchResult struct { - index int - providerName string - lyrics *LyricsResponse - err error -} - -var ( - lyricsProviderHealthMu sync.RWMutex - lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry) -) - -func SetAppVersion(version string) { - normalized := strings.TrimSpace(version) - - appVersionMu.Lock() - defer appVersionMu.Unlock() - appVersion = normalized -} - -func GetAppVersion() string { - appVersionMu.RLock() - defer appVersionMu.RUnlock() - return appVersion -} - -func appUserAgent() string { - version := GetAppVersion() - - if version == "" { - return "SpotiFLAC-Mobile" - } - - return "SpotiFLAC-Mobile/" + version -} - -type LyricsFetchOptions struct { - IncludeTranslationNetease bool `json:"include_translation_netease"` - IncludeRomanizationNetease bool `json:"include_romanization_netease"` - MultiPersonWordByWord bool `json:"multi_person_word_by_word"` - AppleElrcWordSync bool `json:"apple_elrc_word_sync"` - MusixmatchLanguage string `json:"musixmatch_language,omitempty"` -} - -var defaultLyricsFetchOptions = LyricsFetchOptions{ - IncludeTranslationNetease: false, - IncludeRomanizationNetease: false, - MultiPersonWordByWord: true, - AppleElrcWordSync: false, - MusixmatchLanguage: "", -} - -var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`) - -var ( - lyricsFetchOptionsMu sync.RWMutex - lyricsFetchOptions = defaultLyricsFetchOptions -) - -func SetLyricsProviderOrder(providers []string) { - lyricsProvidersMu.Lock() - defer lyricsProvidersMu.Unlock() - - if len(providers) == 0 { - lyricsProviders = nil - clearLyricsProviderHealth() - return - } - - validNames := map[string]bool{ - LyricsProviderLRCLIB: true, - LyricsProviderNetease: true, - LyricsProviderMusixmatch: true, - LyricsProviderAppleMusic: true, - LyricsProviderQQMusic: true, - LyricsProviderSpotify: true, - LyricsProviderDeezer: true, - LyricsProviderYouTube: true, - LyricsProviderKugou: true, - LyricsProviderGenius: true, - LyricsProviderLyricsPlus: true, - } - - var valid []string - for _, p := range providers { - normalized := strings.ToLower(strings.TrimSpace(p)) - if validNames[normalized] { - valid = append(valid, normalized) - } - } - - lyricsProviders = valid - clearLyricsProviderHealth() - GoLog("[Lyrics] Provider order set to: %v\n", valid) -} - -func clearLyricsProviderHealth() { - lyricsProviderHealthMu.Lock() - defer lyricsProviderHealthMu.Unlock() - lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry) -} - -func lyricsProviderHealthKey(providerName string) string { - return strings.ToLower(strings.TrimSpace(providerName)) -} - -func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) { - key := lyricsProviderHealthKey(providerName) - if key == "" { - return false, 0, "" - } - - now := time.Now() - lyricsProviderHealthMu.RLock() - entry, ok := lyricsProviderHealth[key] - lyricsProviderHealthMu.RUnlock() - if !ok { - return false, 0, "" - } - if !now.Before(entry.unavailableUntil) { - lyricsProviderHealthMu.Lock() - if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) { - delete(lyricsProviderHealth, key) - } - lyricsProviderHealthMu.Unlock() - return false, 0, "" - } - return true, time.Until(entry.unavailableUntil), entry.reason -} - -func markLyricsProviderAvailable(providerName string) { - key := lyricsProviderHealthKey(providerName) - if key == "" { - return - } - lyricsProviderHealthMu.Lock() - delete(lyricsProviderHealth, key) - lyricsProviderHealthMu.Unlock() -} - -func markLyricsProviderUnavailable(providerName string, err error) { - if err == nil || !isLyricsProviderUnavailableError(err) { - return - } - key := lyricsProviderHealthKey(providerName) - if key == "" { - return - } - reason := strings.TrimSpace(err.Error()) - if len(reason) > 160 { - reason = reason[:160] - } - unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown) - - lyricsProviderHealthMu.Lock() - lyricsProviderHealth[key] = lyricsProviderHealthEntry{ - unavailableUntil: unavailableUntil, - reason: reason, - } - lyricsProviderHealthMu.Unlock() - GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason) -} - -// isLyricsProviderUnavailableError reports whether err is a provider/API-level -// failure that should temporarily disable a lyrics source. Providers classify -// their failures with the typed errors in lyrics_errors.go at the point of -// origin; transport failures are handled by isConnectivityFailure. -func isLyricsProviderUnavailableError(err error) bool { - if err == nil { - return false - } - if errors.Is(err, errLyricsNotFound) { - return false - } - if errors.Is(err, errLyricsServiceUnavailable) { - return true - } - return isConnectivityFailure(err) -} - -func GetLyricsProviderOrder() []string { - lyricsProvidersMu.RLock() - defer lyricsProvidersMu.RUnlock() - - if len(lyricsProviders) == 0 { - return DefaultLyricsProviders - } - - result := make([]string, len(lyricsProviders)) - copy(result, lyricsProviders) - return result -} - -func GetAvailableLyricsProviders() []map[string]any { - return []map[string]any{ - {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, - {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"}, - {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"}, - {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"}, - {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"}, - {"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"}, - {"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"}, - {"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"}, - {"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"}, - {"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"}, - {"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"}, - } -} - -func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions { - opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage)) - opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "") - if len(opts.MusixmatchLanguage) > 16 { - opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16] - } - return opts -} - -func SetLyricsFetchOptions(opts LyricsFetchOptions) { - normalized := normalizeLyricsFetchOptions(opts) - - lyricsFetchOptionsMu.Lock() - defer lyricsFetchOptionsMu.Unlock() - changed := lyricsFetchOptions != normalized - lyricsFetchOptions = normalized - - if changed { - globalLyricsCache.ClearAll() - } - - GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n", - normalized.IncludeTranslationNetease, - normalized.IncludeRomanizationNetease, - normalized.MultiPersonWordByWord, - normalized.AppleElrcWordSync, - normalized.MusixmatchLanguage, - ) -} - -func GetLyricsFetchOptions() LyricsFetchOptions { - lyricsFetchOptionsMu.RLock() - defer lyricsFetchOptionsMu.RUnlock() - return lyricsFetchOptions -} - -type lyricsCacheEntry struct { - response *LyricsResponse - expiresAt time.Time -} - -type lyricsCache struct { - mu sync.RWMutex - cache map[string]*lyricsCacheEntry -} - -var globalLyricsCache = &lyricsCache{ - cache: make(map[string]*lyricsCacheEntry), -} - -func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string { - normalizedArtist := strings.ToLower(strings.TrimSpace(artist)) - normalizedTrack := strings.ToLower(strings.TrimSpace(track)) - roundedDuration := math.Round(durationSec/10) * 10 - return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration) -} - -func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - key := c.generateKey(artist, track, durationSec) - entry, exists := c.cache[key] - if !exists { - return nil, false - } - - if time.Now().After(entry.expiresAt) { - return nil, false - } - - return entry.response, true -} - -const lyricsCacheMaxEntries = 500 - -func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) { - c.mu.Lock() - defer c.mu.Unlock() - - // Bound the cache: without eviction a long session accumulates every - // looked-up track's full lyrics forever. - if len(c.cache) >= lyricsCacheMaxEntries { - now := time.Now() - for key, entry := range c.cache { - if now.After(entry.expiresAt) { - delete(c.cache, key) - } - } - for len(c.cache) >= lyricsCacheMaxEntries { - var oldestKey string - var oldestAt time.Time - for key, entry := range c.cache { - if oldestKey == "" || entry.expiresAt.Before(oldestAt) { - oldestKey = key - oldestAt = entry.expiresAt - } - } - delete(c.cache, oldestKey) - } - } - - key := c.generateKey(artist, track, durationSec) - c.cache[key] = &lyricsCacheEntry{ - response: response, - expiresAt: time.Now().Add(lyricsCacheTTL), - } -} - -func (c *lyricsCache) CleanExpired() int { - c.mu.Lock() - defer c.mu.Unlock() - - now := time.Now() - cleaned := 0 - for key, entry := range c.cache { - if now.After(entry.expiresAt) { - delete(c.cache, key) - cleaned++ - } - } - return cleaned -} - -func (c *lyricsCache) Size() int { - c.mu.RLock() - defer c.mu.RUnlock() - return len(c.cache) -} - -func (c *lyricsCache) ClearAll() int { - c.mu.Lock() - defer c.mu.Unlock() - - cleared := len(c.cache) - c.cache = make(map[string]*lyricsCacheEntry) - return cleared -} - type LRCLibResponse struct { ID int `json:"id"` Name string `json:"name"` @@ -1078,387 +678,3 @@ func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse return result } - -var lrcLinePattern = regexp.MustCompile(`\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)`) - -func parseSyncedLyrics(syncedLyrics string) []LyricsLine { - var lines []LyricsLine - - for _, line := range strings.Split(syncedLyrics, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // Preserve Apple/QQ background vocal tags by attaching them to - // the previous timed line. This keeps [bg:...] in final exported LRC. - if strings.HasPrefix(line, "[bg:") && len(lines) > 0 { - lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line) - continue - } - - matches := lrcLinePattern.FindStringSubmatch(line) - if len(matches) == 5 { - startMs := lrcTimestampToMs(matches[1], matches[2], matches[3]) - words := strings.TrimSpace(matches[4]) - if words == "" { - continue - } - - lines = append(lines, LyricsLine{ - StartTimeMs: startMs, - Words: words, - EndTimeMs: 0, - }) - } - } - - for i := 0; i < len(lines)-1; i++ { - lines[i].EndTimeMs = lines[i+1].StartTimeMs - } - - if len(lines) > 0 { - lines[len(lines)-1].EndTimeMs = lines[len(lines)-1].StartTimeMs + 5000 - } - - return lines -} - -func plainTextLyricsLines(rawLyrics string) []LyricsLine { - var lines []LyricsLine - for _, line := range strings.Split(rawLyrics, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - lines = append(lines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - return lines -} - -func lyricsHasUsableText(lyrics *LyricsResponse) bool { - if lyrics == nil { - return false - } - if lyrics.Instrumental { - return true - } - if strings.TrimSpace(lyrics.PlainLyrics) != "" { - return true - } - for _, line := range lyrics.Lines { - if strings.TrimSpace(line.Words) != "" { - return true - } - } - return false -} - -func detectLyricsErrorPayload(raw string) (string, bool) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" || !strings.HasPrefix(trimmed, "{") { - return "", false - } - - var payload map[string]any - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return "", false - } - - lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"} - hasLyricsKey := false - for _, key := range lyricsKeys { - if _, ok := payload[key]; ok { - hasLyricsKey = true - break - } - } - - errorKeys := []string{"message", "error", "detail", "reason"} - for _, key := range errorKeys { - if msg, ok := payload[key].(string); ok { - msg = strings.TrimSpace(msg) - if msg != "" && !hasLyricsKey { - return msg, true - } - } - } - - if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey { - return "request unsuccessful", true - } - if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey { - return "request unsuccessful", true - } - if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey { - if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { - return strings.TrimSpace(msg), true - } - if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" { - return strings.TrimSpace(msg), true - } - return fmt.Sprintf("unexpected response code %.0f", code), true - } - - return "", false -} - -func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 { - min, _ := strconv.ParseInt(minutes, 10, 64) - sec, _ := strconv.ParseInt(seconds, 10, 64) - cs, _ := strconv.ParseInt(centiseconds, 10, 64) - - if len(centiseconds) == 2 { - cs *= 10 - } - - return min*60*1000 + sec*1000 + cs -} - -func msToLRCTimestamp(ms int64) string { - return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms)) -} - -func msToLRCTimestampInline(ms int64) string { - totalSeconds := ms / 1000 - minutes := totalSeconds / 60 - seconds := totalSeconds % 60 - centiseconds := (ms % 1000) / 10 - - return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds) -} - -// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag, -// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent. -const lrcSourceMarker = "(source: " - -func lyricsSourceUsesPaxsenix(source string) bool { - s := strings.ToLower(strings.TrimSpace(source)) - if s == "" { - return false - } - if strings.HasPrefix(s, "lrclib") || - strings.HasPrefix(s, "extension:") || - strings.HasPrefix(s, "heuristic") { - return false - } - return true -} - -func extractLyricsSourceFromLRC(lrc string) string { - for _, line := range strings.Split(lrc, "\n") { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") { - continue - } - idx := strings.Index(trimmed, lrcSourceMarker) - if idx < 0 { - return "" - } - rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):]) - rest = strings.TrimSuffix(rest, "]") - rest = strings.TrimSuffix(rest, ")") - return strings.TrimSpace(rest) - } - return "" -} - -func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { - if lyrics == nil || len(lyrics.Lines) == 0 { - return "" - } - - var builder strings.Builder - - builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) - builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) - source := strings.TrimSpace(lyrics.Source) - if source == "" { - source = strings.TrimSpace(lyrics.Provider) - } - credit := "SpotiFLAC-Mobile" - if lyricsSourceUsesPaxsenix(source) { - credit = "SpotiFLAC-Mobile via Paxsenix API" - } - if source == "" { - builder.WriteString(fmt.Sprintf("[by:%s]\n", credit)) - } else { - builder.WriteString( - fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source), - ) - } - builder.WriteString("\n") - - if lyrics.SyncType == "LINE_SYNCED" { - for _, line := range lyrics.Lines { - if line.Words == "" { - continue - } - timestamp := msToLRCTimestamp(line.StartTimeMs) - builder.WriteString(timestamp) - builder.WriteString(line.Words) - builder.WriteString("\n") - } - } else { - for _, line := range lyrics.Lines { - if line.Words == "" { - continue - } - builder.WriteString(line.Words) - builder.WriteString("\n") - } - } - - return builder.String() -} - -var simplifyTrackNamePatterns = func() []*regexp.Regexp { - patterns := []string{ - `\s*\(feat\.?.*?\)`, - `\s*\(ft\.?.*?\)`, - `\s*\(featuring.*?\)`, - `\s*\(with.*?\)`, - `\s*-\s*Remaster(ed)?.*$`, - `\s*-\s*\d{4}\s*Remaster.*$`, - `\s*\(Remaster(ed)?.*?\)`, - `\s*\(Deluxe.*?\)`, - `\s*\(Bonus.*?\)`, - `\s*\(Live.*?\)`, - `\s*\(Acoustic.*?\)`, - `\s*\(Radio Edit\)`, - `\s*\(Single Version\)`, - } - compiled := make([]*regexp.Regexp, len(patterns)) - for i, pattern := range patterns { - compiled[i] = regexp.MustCompile("(?i)" + pattern) - } - return compiled -}() - -func simplifyTrackName(name string) string { - result := name - for _, re := range simplifyTrackNamePatterns { - result = re.ReplaceAllString(result, "") - } - result = strings.TrimSpace(result) - if result == "" { - return result - } - - if loose := normalizeLooseTitle(result); loose != "" { - return loose - } - - 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 "} - - result := name - for _, sep := range separators { - if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 { - result = result[:idx] - break - } - } - - return strings.TrimSpace(result) -} - -func isLikelyInstrumentalTrack(name string) bool { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return false - } - - return instrumentalTrackPattern.MatchString(trimmed) -} - -func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { - if lrcContent == "" { - return "", fmt.Errorf("empty LRC content") - } - - dir := filepath.Dir(audioFilePath) - ext := filepath.Ext(audioFilePath) - baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) - - lrcFilePath := filepath.Join(dir, baseName+".lrc") - - if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil { - return "", fmt.Errorf("failed to write LRC file: %w", err) - } - - GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath) - return lrcFilePath, nil -} diff --git a/go_backend/lyrics_config.go b/go_backend/lyrics_config.go new file mode 100644 index 00000000..ab39714f --- /dev/null +++ b/go_backend/lyrics_config.go @@ -0,0 +1,406 @@ +package gobackend + +import ( + "errors" + "fmt" + "math" + "regexp" + "strings" + "sync" + "time" +) + +const ( + lyricsProviderUnavailableCooldown = 10 * time.Minute + lyricsProviderParallelism = 3 + lyricsProviderPriorityGrace = 5000 * time.Millisecond +) + +const ( + LyricsProviderLRCLIB = "lrclib" + LyricsProviderNetease = "netease" + LyricsProviderMusixmatch = "musixmatch" + LyricsProviderAppleMusic = "apple_music" + LyricsProviderQQMusic = "qqmusic" + LyricsProviderSpotify = "spotify" + LyricsProviderDeezer = "deezer" + LyricsProviderYouTube = "youtube" + LyricsProviderKugou = "kugou" + LyricsProviderGenius = "genius" + LyricsProviderLyricsPlus = "lyricsplus" +) + +var DefaultLyricsProviders = []string{ + LyricsProviderLRCLIB, + LyricsProviderAppleMusic, +} + +var ( + lyricsProvidersMu sync.RWMutex + lyricsProviders []string // ordered list of enabled providers + appVersionMu sync.RWMutex + appVersion string +) + +type lyricsProviderHealthEntry struct { + unavailableUntil time.Time + reason string +} + +type lyricsProviderSearchRequest struct { + spotifyID string + trackName string + artistName string + primaryArtist string + simplifiedTrack string + durationSec float64 + fetchOptions LyricsFetchOptions +} + +type lyricsProviderSearchResult struct { + index int + providerName string + lyrics *LyricsResponse + err error +} + +var ( + lyricsProviderHealthMu sync.RWMutex + lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry) +) + +func SetAppVersion(version string) { + normalized := strings.TrimSpace(version) + + appVersionMu.Lock() + defer appVersionMu.Unlock() + appVersion = normalized +} + +func GetAppVersion() string { + appVersionMu.RLock() + defer appVersionMu.RUnlock() + return appVersion +} + +func appUserAgent() string { + version := GetAppVersion() + + if version == "" { + return "SpotiFLAC-Mobile" + } + + return "SpotiFLAC-Mobile/" + version +} + +type LyricsFetchOptions struct { + IncludeTranslationNetease bool `json:"include_translation_netease"` + IncludeRomanizationNetease bool `json:"include_romanization_netease"` + MultiPersonWordByWord bool `json:"multi_person_word_by_word"` + AppleElrcWordSync bool `json:"apple_elrc_word_sync"` + MusixmatchLanguage string `json:"musixmatch_language,omitempty"` +} + +var defaultLyricsFetchOptions = LyricsFetchOptions{ + IncludeTranslationNetease: false, + IncludeRomanizationNetease: false, + MultiPersonWordByWord: true, + AppleElrcWordSync: false, + MusixmatchLanguage: "", +} + +var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`) + +var ( + lyricsFetchOptionsMu sync.RWMutex + lyricsFetchOptions = defaultLyricsFetchOptions +) + +func SetLyricsProviderOrder(providers []string) { + lyricsProvidersMu.Lock() + defer lyricsProvidersMu.Unlock() + + if len(providers) == 0 { + lyricsProviders = nil + clearLyricsProviderHealth() + return + } + + validNames := map[string]bool{ + LyricsProviderLRCLIB: true, + LyricsProviderNetease: true, + LyricsProviderMusixmatch: true, + LyricsProviderAppleMusic: true, + LyricsProviderQQMusic: true, + LyricsProviderSpotify: true, + LyricsProviderDeezer: true, + LyricsProviderYouTube: true, + LyricsProviderKugou: true, + LyricsProviderGenius: true, + LyricsProviderLyricsPlus: true, + } + + var valid []string + for _, p := range providers { + normalized := strings.ToLower(strings.TrimSpace(p)) + if validNames[normalized] { + valid = append(valid, normalized) + } + } + + lyricsProviders = valid + clearLyricsProviderHealth() + GoLog("[Lyrics] Provider order set to: %v\n", valid) +} + +func clearLyricsProviderHealth() { + lyricsProviderHealthMu.Lock() + defer lyricsProviderHealthMu.Unlock() + lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry) +} + +func lyricsProviderHealthKey(providerName string) string { + return strings.ToLower(strings.TrimSpace(providerName)) +} + +func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) { + key := lyricsProviderHealthKey(providerName) + if key == "" { + return false, 0, "" + } + + now := time.Now() + lyricsProviderHealthMu.RLock() + entry, ok := lyricsProviderHealth[key] + lyricsProviderHealthMu.RUnlock() + if !ok { + return false, 0, "" + } + if !now.Before(entry.unavailableUntil) { + lyricsProviderHealthMu.Lock() + if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) { + delete(lyricsProviderHealth, key) + } + lyricsProviderHealthMu.Unlock() + return false, 0, "" + } + return true, time.Until(entry.unavailableUntil), entry.reason +} + +func markLyricsProviderAvailable(providerName string) { + key := lyricsProviderHealthKey(providerName) + if key == "" { + return + } + lyricsProviderHealthMu.Lock() + delete(lyricsProviderHealth, key) + lyricsProviderHealthMu.Unlock() +} + +func markLyricsProviderUnavailable(providerName string, err error) { + if err == nil || !isLyricsProviderUnavailableError(err) { + return + } + key := lyricsProviderHealthKey(providerName) + if key == "" { + return + } + reason := strings.TrimSpace(err.Error()) + if len(reason) > 160 { + reason = reason[:160] + } + unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown) + + lyricsProviderHealthMu.Lock() + lyricsProviderHealth[key] = lyricsProviderHealthEntry{ + unavailableUntil: unavailableUntil, + reason: reason, + } + lyricsProviderHealthMu.Unlock() + GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason) +} + +// isLyricsProviderUnavailableError reports whether err is a provider/API-level +// failure that should temporarily disable a lyrics source. Providers classify +// their failures with the typed errors in lyrics_errors.go at the point of +// origin; transport failures are handled by isConnectivityFailure. +func isLyricsProviderUnavailableError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, errLyricsNotFound) { + return false + } + if errors.Is(err, errLyricsServiceUnavailable) { + return true + } + return isConnectivityFailure(err) +} + +func GetLyricsProviderOrder() []string { + lyricsProvidersMu.RLock() + defer lyricsProvidersMu.RUnlock() + + if len(lyricsProviders) == 0 { + return DefaultLyricsProviders + } + + result := make([]string, len(lyricsProviders)) + copy(result, lyricsProviders) + return result +} + +func GetAvailableLyricsProviders() []map[string]any { + return []map[string]any{ + {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, + {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"}, + {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"}, + {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"}, + {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"}, + {"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"}, + {"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"}, + {"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"}, + {"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"}, + {"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"}, + {"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"}, + } +} + +func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions { + opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage)) + opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "") + if len(opts.MusixmatchLanguage) > 16 { + opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16] + } + return opts +} + +func SetLyricsFetchOptions(opts LyricsFetchOptions) { + normalized := normalizeLyricsFetchOptions(opts) + + lyricsFetchOptionsMu.Lock() + defer lyricsFetchOptionsMu.Unlock() + changed := lyricsFetchOptions != normalized + lyricsFetchOptions = normalized + + if changed { + globalLyricsCache.ClearAll() + } + + GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n", + normalized.IncludeTranslationNetease, + normalized.IncludeRomanizationNetease, + normalized.MultiPersonWordByWord, + normalized.AppleElrcWordSync, + normalized.MusixmatchLanguage, + ) +} + +func GetLyricsFetchOptions() LyricsFetchOptions { + lyricsFetchOptionsMu.RLock() + defer lyricsFetchOptionsMu.RUnlock() + return lyricsFetchOptions +} + +type lyricsCacheEntry struct { + response *LyricsResponse + expiresAt time.Time +} + +type lyricsCache struct { + mu sync.RWMutex + cache map[string]*lyricsCacheEntry +} + +var globalLyricsCache = &lyricsCache{ + cache: make(map[string]*lyricsCacheEntry), +} + +func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string { + normalizedArtist := strings.ToLower(strings.TrimSpace(artist)) + normalizedTrack := strings.ToLower(strings.TrimSpace(track)) + roundedDuration := math.Round(durationSec/10) * 10 + return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration) +} + +func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + key := c.generateKey(artist, track, durationSec) + entry, exists := c.cache[key] + if !exists { + return nil, false + } + + if time.Now().After(entry.expiresAt) { + return nil, false + } + + return entry.response, true +} + +const lyricsCacheMaxEntries = 500 + +func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) { + c.mu.Lock() + defer c.mu.Unlock() + + // Bound the cache: without eviction a long session accumulates every + // looked-up track's full lyrics forever. + if len(c.cache) >= lyricsCacheMaxEntries { + now := time.Now() + for key, entry := range c.cache { + if now.After(entry.expiresAt) { + delete(c.cache, key) + } + } + for len(c.cache) >= lyricsCacheMaxEntries { + var oldestKey string + var oldestAt time.Time + for key, entry := range c.cache { + if oldestKey == "" || entry.expiresAt.Before(oldestAt) { + oldestKey = key + oldestAt = entry.expiresAt + } + } + delete(c.cache, oldestKey) + } + } + + key := c.generateKey(artist, track, durationSec) + c.cache[key] = &lyricsCacheEntry{ + response: response, + expiresAt: time.Now().Add(lyricsCacheTTL), + } +} + +func (c *lyricsCache) CleanExpired() int { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + cleaned := 0 + for key, entry := range c.cache { + if now.After(entry.expiresAt) { + delete(c.cache, key) + cleaned++ + } + } + return cleaned +} + +func (c *lyricsCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.cache) +} + +func (c *lyricsCache) ClearAll() int { + c.mu.Lock() + defer c.mu.Unlock() + + cleared := len(c.cache) + c.cache = make(map[string]*lyricsCacheEntry) + return cleared +} diff --git a/go_backend/lyrics_lrc.go b/go_backend/lyrics_lrc.go new file mode 100644 index 00000000..149d2de1 --- /dev/null +++ b/go_backend/lyrics_lrc.go @@ -0,0 +1,267 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var lrcLinePattern = regexp.MustCompile(`\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)`) + +func parseSyncedLyrics(syncedLyrics string) []LyricsLine { + var lines []LyricsLine + + for _, line := range strings.Split(syncedLyrics, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Preserve Apple/QQ background vocal tags by attaching them to + // the previous timed line. This keeps [bg:...] in final exported LRC. + if strings.HasPrefix(line, "[bg:") && len(lines) > 0 { + lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line) + continue + } + + matches := lrcLinePattern.FindStringSubmatch(line) + if len(matches) == 5 { + startMs := lrcTimestampToMs(matches[1], matches[2], matches[3]) + words := strings.TrimSpace(matches[4]) + if words == "" { + continue + } + + lines = append(lines, LyricsLine{ + StartTimeMs: startMs, + Words: words, + EndTimeMs: 0, + }) + } + } + + for i := 0; i < len(lines)-1; i++ { + lines[i].EndTimeMs = lines[i+1].StartTimeMs + } + + if len(lines) > 0 { + lines[len(lines)-1].EndTimeMs = lines[len(lines)-1].StartTimeMs + 5000 + } + + return lines +} + +func plainTextLyricsLines(rawLyrics string) []LyricsLine { + var lines []LyricsLine + for _, line := range strings.Split(rawLyrics, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + lines = append(lines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + return lines +} + +func lyricsHasUsableText(lyrics *LyricsResponse) bool { + if lyrics == nil { + return false + } + if lyrics.Instrumental { + return true + } + if strings.TrimSpace(lyrics.PlainLyrics) != "" { + return true + } + for _, line := range lyrics.Lines { + if strings.TrimSpace(line.Words) != "" { + return true + } + } + return false +} + +func detectLyricsErrorPayload(raw string) (string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return "", false + } + + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return "", false + } + + lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"} + hasLyricsKey := false + for _, key := range lyricsKeys { + if _, ok := payload[key]; ok { + hasLyricsKey = true + break + } + } + + errorKeys := []string{"message", "error", "detail", "reason"} + for _, key := range errorKeys { + if msg, ok := payload[key].(string); ok { + msg = strings.TrimSpace(msg) + if msg != "" && !hasLyricsKey { + return msg, true + } + } + } + + if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey { + return "request unsuccessful", true + } + if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey { + return "request unsuccessful", true + } + if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey { + if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg), true + } + if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg), true + } + return fmt.Sprintf("unexpected response code %.0f", code), true + } + + return "", false +} + +func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 { + min, _ := strconv.ParseInt(minutes, 10, 64) + sec, _ := strconv.ParseInt(seconds, 10, 64) + cs, _ := strconv.ParseInt(centiseconds, 10, 64) + + if len(centiseconds) == 2 { + cs *= 10 + } + + return min*60*1000 + sec*1000 + cs +} + +func msToLRCTimestamp(ms int64) string { + return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms)) +} + +func msToLRCTimestampInline(ms int64) string { + totalSeconds := ms / 1000 + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + centiseconds := (ms % 1000) / 10 + + return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds) +} + +// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag, +// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent. +const lrcSourceMarker = "(source: " + +func lyricsSourceUsesPaxsenix(source string) bool { + s := strings.ToLower(strings.TrimSpace(source)) + if s == "" { + return false + } + if strings.HasPrefix(s, "lrclib") || + strings.HasPrefix(s, "extension:") || + strings.HasPrefix(s, "heuristic") { + return false + } + return true +} + +func extractLyricsSourceFromLRC(lrc string) string { + for _, line := range strings.Split(lrc, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") { + continue + } + idx := strings.Index(trimmed, lrcSourceMarker) + if idx < 0 { + return "" + } + rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):]) + rest = strings.TrimSuffix(rest, "]") + rest = strings.TrimSuffix(rest, ")") + return strings.TrimSpace(rest) + } + return "" +} + +func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { + if lyrics == nil || len(lyrics.Lines) == 0 { + return "" + } + + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) + builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) + source := strings.TrimSpace(lyrics.Source) + if source == "" { + source = strings.TrimSpace(lyrics.Provider) + } + credit := "SpotiFLAC-Mobile" + if lyricsSourceUsesPaxsenix(source) { + credit = "SpotiFLAC-Mobile via Paxsenix API" + } + if source == "" { + builder.WriteString(fmt.Sprintf("[by:%s]\n", credit)) + } else { + builder.WriteString( + fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source), + ) + } + builder.WriteString("\n") + + if lyrics.SyncType == "LINE_SYNCED" { + for _, line := range lyrics.Lines { + if line.Words == "" { + continue + } + timestamp := msToLRCTimestamp(line.StartTimeMs) + builder.WriteString(timestamp) + builder.WriteString(line.Words) + builder.WriteString("\n") + } + } else { + for _, line := range lyrics.Lines { + if line.Words == "" { + continue + } + builder.WriteString(line.Words) + builder.WriteString("\n") + } + } + + return builder.String() +} + +func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { + if lrcContent == "" { + return "", fmt.Errorf("empty LRC content") + } + + dir := filepath.Dir(audioFilePath) + ext := filepath.Ext(audioFilePath) + baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) + + lrcFilePath := filepath.Join(dir, baseName+".lrc") + + if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil { + return "", fmt.Errorf("failed to write LRC file: %w", err) + } + + GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath) + return lrcFilePath, nil +} diff --git a/go_backend/lyrics_matching.go b/go_backend/lyrics_matching.go new file mode 100644 index 00000000..0002330b --- /dev/null +++ b/go_backend/lyrics_matching.go @@ -0,0 +1,135 @@ +package gobackend + +import ( + "math" + "regexp" + "strings" +) + +var simplifyTrackNamePatterns = func() []*regexp.Regexp { + patterns := []string{ + `\s*\(feat\.?.*?\)`, + `\s*\(ft\.?.*?\)`, + `\s*\(featuring.*?\)`, + `\s*\(with.*?\)`, + `\s*-\s*Remaster(ed)?.*$`, + `\s*-\s*\d{4}\s*Remaster.*$`, + `\s*\(Remaster(ed)?.*?\)`, + `\s*\(Deluxe.*?\)`, + `\s*\(Bonus.*?\)`, + `\s*\(Live.*?\)`, + `\s*\(Acoustic.*?\)`, + `\s*\(Radio Edit\)`, + `\s*\(Single Version\)`, + } + compiled := make([]*regexp.Regexp, len(patterns)) + for i, pattern := range patterns { + compiled[i] = regexp.MustCompile("(?i)" + pattern) + } + return compiled +}() + +func simplifyTrackName(name string) string { + result := name + for _, re := range simplifyTrackNamePatterns { + result = re.ReplaceAllString(result, "") + } + result = strings.TrimSpace(result) + if result == "" { + return result + } + + if loose := normalizeLooseTitle(result); loose != "" { + return loose + } + + 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 "} + + result := name + for _, sep := range separators { + if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 { + result = result[:idx] + break + } + } + + return strings.TrimSpace(result) +} + +func isLikelyInstrumentalTrack(name string) bool { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return false + } + + return instrumentalTrackPattern.MatchString(trimmed) +}