mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
407 lines
11 KiB
Go
407 lines
11 KiB
Go
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
|
|
}
|