mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(go): split lyrics provider config, LRC codec, and search matching out of lyrics.go
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user