mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
perf(lyrics): coalesce and persist provider results
This commit is contained in:
@@ -51,7 +51,7 @@ func releaseMemory(underPressure bool) {
|
||||
CloseIdleConnections()
|
||||
if underPressure {
|
||||
clearCoverMemoryCache()
|
||||
globalLyricsCache.ClearAll()
|
||||
globalLyricsCache.DropMemory()
|
||||
clearPrivateIPCache()
|
||||
clearExtensionHealthCache()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -294,6 +295,7 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||
if err := settingsStore.SetDataDir(dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
globalLyricsCache.SetPersistencePath(filepath.Join(dataDir, ".lyrics_cache.json"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,8 +39,9 @@ type extCallOpts struct {
|
||||
perfName string
|
||||
invoke func(vm *goja.Runtime) (goja.Value, error)
|
||||
timeout time.Duration
|
||||
itemID string // optional: binds download-cancel + active-item tracking
|
||||
requestID string // optional: binds request-cancel via context (customSearch only)
|
||||
itemID string // optional: binds download-cancel + active-item tracking
|
||||
requestID string // optional: binds request-cancel via context (customSearch only)
|
||||
context context.Context // optional: caller lifecycle for non-download work
|
||||
// beforeRun runs after lock+cancel setup, right before the invocation. Its
|
||||
// returned cleanup, if any, runs after the call.
|
||||
beforeRun func() func()
|
||||
@@ -58,7 +59,10 @@ type extCallOpts struct {
|
||||
// any ProviderID stamping.
|
||||
func callExtension[T any](p *extensionProviderWrapper, opts extCallOpts, parse func(perf *extensionCallPerf, result goja.Value) (T, error)) (T, error) {
|
||||
var zero T
|
||||
ctx := context.Background()
|
||||
ctx := opts.context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
perf := newExtensionCallPerf(p.extension.ID, opts.perfName)
|
||||
defer perf.finish()
|
||||
@@ -959,6 +963,10 @@ type ExtLyricsLine struct {
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
return p.FetchLyricsContext(context.Background(), trackName, artistName, albumName, durationSec)
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) FetchLyricsContext(ctx context.Context, trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
if !p.extension.Manifest.IsLyricsProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||
}
|
||||
@@ -970,6 +978,7 @@ func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
perfName: "fetchLyrics",
|
||||
invoke: extensionMethodInvocation("fetchLyrics", trackName, artistName, albumName, durationSec),
|
||||
timeout: DefaultJSTimeout,
|
||||
context: ctx,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*LyricsResponse, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("fetchLyrics returned null")
|
||||
|
||||
+182
-5
@@ -1,19 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
lyricsCacheTTL = 24 * time.Hour
|
||||
durationToleranceSec = 10.0
|
||||
lyricsNegativeTTL = 5 * time.Minute
|
||||
lyricsNegativeMax = 500
|
||||
)
|
||||
|
||||
var (
|
||||
lyricsFetchFlight singleflight.Group
|
||||
lyricsNegativeMu sync.Mutex
|
||||
lyricsNegative = map[string]time.Time{}
|
||||
)
|
||||
|
||||
type LRCLibResponse struct {
|
||||
@@ -57,6 +69,31 @@ type LyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type lyricsContextTransport struct {
|
||||
ctx context.Context
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t lyricsContextTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return t.base.RoundTrip(request.Clone(t.ctx))
|
||||
}
|
||||
|
||||
func bindLyricsHTTPClientContext(
|
||||
client *http.Client,
|
||||
ctx context.Context,
|
||||
) *http.Client {
|
||||
if client == nil || ctx == nil {
|
||||
return client
|
||||
}
|
||||
copy := *client
|
||||
transport := client.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
copy.Transport = lyricsContextTransport{ctx: ctx, base: transport}
|
||||
return ©
|
||||
}
|
||||
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
@@ -189,7 +226,105 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
func lyricsFetchCacheKey(spotifyID, trackName, artistName string, durationSec float64) string {
|
||||
providers := GetLyricsProviderOrder()
|
||||
extensions := make([]string, 0)
|
||||
if manager := getExtensionManager(); manager != nil {
|
||||
for _, provider := range manager.GetLyricsProviders() {
|
||||
extensions = append(extensions, strings.ToLower(strings.TrimSpace(provider.extension.ID)))
|
||||
}
|
||||
}
|
||||
sort.Strings(extensions)
|
||||
opts := GetLyricsFetchOptions()
|
||||
return fmt.Sprintf(
|
||||
"%s|%s|%s|%.0f|%s|%s|%t|%t|%t|%t|%s",
|
||||
strings.TrimSpace(spotifyID),
|
||||
strings.ToLower(strings.TrimSpace(artistName)),
|
||||
strings.ToLower(strings.TrimSpace(trackName)),
|
||||
math.Round(durationSec/10)*10,
|
||||
strings.Join(providers, ","),
|
||||
strings.Join(extensions, ","),
|
||||
opts.IncludeTranslationNetease,
|
||||
opts.IncludeRomanizationNetease,
|
||||
opts.MultiPersonWordByWord,
|
||||
opts.AppleElrcWordSync,
|
||||
opts.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
func isNegativeLyricsCached(key string) bool {
|
||||
now := time.Now()
|
||||
lyricsNegativeMu.Lock()
|
||||
defer lyricsNegativeMu.Unlock()
|
||||
expiresAt, ok := lyricsNegative[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !now.Before(expiresAt) {
|
||||
delete(lyricsNegative, key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cacheNegativeLyrics(key string) {
|
||||
now := time.Now()
|
||||
lyricsNegativeMu.Lock()
|
||||
defer lyricsNegativeMu.Unlock()
|
||||
if len(lyricsNegative) >= lyricsNegativeMax {
|
||||
for existingKey, expiresAt := range lyricsNegative {
|
||||
if !now.Before(expiresAt) {
|
||||
delete(lyricsNegative, existingKey)
|
||||
}
|
||||
}
|
||||
for len(lyricsNegative) >= lyricsNegativeMax {
|
||||
for existingKey := range lyricsNegative {
|
||||
delete(lyricsNegative, existingKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
lyricsNegative[key] = now.Add(lyricsNegativeTTL)
|
||||
}
|
||||
|
||||
func clearNegativeLyrics(key string) {
|
||||
lyricsNegativeMu.Lock()
|
||||
delete(lyricsNegative, key)
|
||||
lyricsNegativeMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
key := lyricsFetchCacheKey(spotifyID, trackName, artistName, durationSec)
|
||||
if isNegativeLyricsCached(key) {
|
||||
return nil, lyricsNotFoundErrorf("lyrics not found (cached)")
|
||||
}
|
||||
|
||||
value, err, _ := lyricsFetchFlight.Do(key, func() (any, error) {
|
||||
lyrics, fetchErr := c.fetchLyricsAllSourcesUncoalesced(
|
||||
spotifyID,
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
)
|
||||
if fetchErr != nil {
|
||||
cacheNegativeLyrics(key)
|
||||
return nil, fetchErr
|
||||
}
|
||||
clearNegativeLyrics(key)
|
||||
return lyrics, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lyrics, _ := value.(*LyricsResponse)
|
||||
if lyrics == nil {
|
||||
return nil, lyricsNotFoundErrorf("lyrics not found from any source")
|
||||
}
|
||||
copy := *lyrics
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) fetchLyricsAllSourcesUncoalesced(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
configuredProviderOrder := GetLyricsProviderOrder()
|
||||
@@ -268,15 +403,17 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
fetchProvider := func(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||
if provider, ok := extensionProviders[providerName]; ok {
|
||||
lyrics, err := provider.FetchLyrics(request.trackName, request.artistName, "", request.durationSec)
|
||||
lyrics, err := provider.FetchLyricsContext(ctx, request.trackName, request.artistName, "", request.durationSec)
|
||||
return lyrics, err, true
|
||||
}
|
||||
return c.fetchBuiltInLyricsProvider(providerName, request)
|
||||
return c.fetchBuiltInLyricsProviderContext(ctx, providerName, request)
|
||||
}
|
||||
|
||||
lyrics, err := fetchLyricsProviders(providerOrder, request, fetchProvider)
|
||||
lyrics, err := fetchLyricsProvidersContext(ctx, providerOrder, request, fetchProvider)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
@@ -314,6 +451,17 @@ func fetchLyricsProviders(
|
||||
request lyricsProviderSearchRequest,
|
||||
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
|
||||
) (*LyricsResponse, error) {
|
||||
return fetchLyricsProvidersContext(context.Background(), providerOrder, request, fetchProvider)
|
||||
}
|
||||
|
||||
func fetchLyricsProvidersContext(
|
||||
ctx context.Context,
|
||||
providerOrder []string,
|
||||
request lyricsProviderSearchRequest,
|
||||
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
|
||||
) (*LyricsResponse, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
type providerCandidate struct {
|
||||
index int
|
||||
name string
|
||||
@@ -342,8 +490,15 @@ func fetchLyricsProviders(
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Trying provider: %s\n", candidate.name)
|
||||
lyrics, err, ok := fetchProvider(candidate.name, request)
|
||||
@@ -358,7 +513,10 @@ func fetchLyricsProviders(
|
||||
GoLog("[Lyrics] Provider %s failed: %v\n", candidate.name, err)
|
||||
markLyricsProviderUnavailable(candidate.name, err)
|
||||
}
|
||||
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err}
|
||||
select {
|
||||
case results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -413,6 +571,8 @@ func fetchLyricsProviders(
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case result, ok := <-results:
|
||||
if !ok {
|
||||
remaining = 0
|
||||
@@ -465,6 +625,13 @@ func isKnownBuiltInLyricsProvider(providerName string) bool {
|
||||
}
|
||||
|
||||
func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||
return c.fetchBuiltInLyricsProviderContext(context.Background(), providerName, request)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) fetchBuiltInLyricsProviderContext(ctx context.Context, providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||
clientCopy := *c
|
||||
clientCopy.httpClient = bindLyricsHTTPClientContext(c.httpClient, ctx)
|
||||
c = &clientCopy
|
||||
switch providerName {
|
||||
case LyricsProviderLRCLIB:
|
||||
lyrics, err := c.tryLRCLIB(request.primaryArtist, request.artistName, request.trackName, request.simplifiedTrack, request.durationSec)
|
||||
@@ -472,6 +639,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderNetease:
|
||||
neteaseClient := NewNeteaseClient()
|
||||
neteaseClient.httpClient = bindLyricsHTTPClientContext(neteaseClient.httpClient, ctx)
|
||||
lyrics, err := neteaseClient.FetchLyrics(
|
||||
request.trackName,
|
||||
request.primaryArtist,
|
||||
@@ -501,6 +669,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderMusixmatch:
|
||||
musixmatchClient := NewMusixmatchClient()
|
||||
musixmatchClient.httpClient = bindLyricsHTTPClientContext(musixmatchClient.httpClient, ctx)
|
||||
lyrics, err := musixmatchClient.FetchLyrics(
|
||||
request.trackName,
|
||||
request.primaryArtist,
|
||||
@@ -519,6 +688,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
appleClient.httpClient = bindLyricsHTTPClientContext(appleClient.httpClient, ctx)
|
||||
lyrics, err := appleClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
|
||||
@@ -527,6 +697,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
qqClient := NewQQMusicClient()
|
||||
qqClient.httpClient = bindLyricsHTTPClientContext(qqClient.httpClient, ctx)
|
||||
lyrics, err := qqClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = qqClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
|
||||
@@ -535,6 +706,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderSpotify:
|
||||
spotifyClient := NewSpotifyLyricsClient()
|
||||
spotifyClient.httpClient = bindLyricsHTTPClientContext(spotifyClient.httpClient, ctx)
|
||||
lyrics, err := spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
|
||||
@@ -546,6 +718,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderDeezer:
|
||||
deezerClient := NewDeezerLyricsClient()
|
||||
deezerClient.httpClient = bindLyricsHTTPClientContext(deezerClient.httpClient, ctx)
|
||||
lyrics, err := deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
|
||||
@@ -554,6 +727,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderYouTube:
|
||||
youtubeClient := NewYouTubeLyricsClient()
|
||||
youtubeClient.httpClient = bindLyricsHTTPClientContext(youtubeClient.httpClient, ctx)
|
||||
lyrics, err := youtubeClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||
@@ -565,6 +739,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderKugou:
|
||||
kugouClient := NewKugouLyricsClient()
|
||||
kugouClient.httpClient = bindLyricsHTTPClientContext(kugouClient.httpClient, ctx)
|
||||
lyrics, err := kugouClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = kugouClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||
@@ -576,6 +751,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderGenius:
|
||||
geniusClient := NewGeniusLyricsClient()
|
||||
geniusClient.httpClient = bindLyricsHTTPClientContext(geniusClient.httpClient, ctx)
|
||||
lyrics, err := geniusClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||
lyrics, err = geniusClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||
@@ -587,6 +763,7 @@ func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request l
|
||||
|
||||
case LyricsProviderLyricsPlus:
|
||||
lyricsPlusClient := NewLyricsPlusClient()
|
||||
lyricsPlusClient.httpClient = bindLyricsHTTPClientContext(lyricsPlusClient.httpClient, ctx)
|
||||
lyrics, err := lyricsPlusClient.FetchLyrics(
|
||||
request.trackName,
|
||||
request.primaryArtist,
|
||||
|
||||
+138
-13
@@ -1,9 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -310,9 +311,9 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
normalized := normalizeLyricsFetchOptions(opts)
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
changed := lyricsFetchOptions != normalized
|
||||
lyricsFetchOptions = normalized
|
||||
lyricsFetchOptionsMu.Unlock()
|
||||
|
||||
if changed {
|
||||
globalLyricsCache.ClearAll()
|
||||
@@ -339,8 +340,11 @@ type lyricsCacheEntry struct {
|
||||
}
|
||||
|
||||
type lyricsCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*lyricsCacheEntry
|
||||
mu sync.RWMutex
|
||||
cache map[string]*lyricsCacheEntry
|
||||
persistencePath string
|
||||
persistGeneration uint64
|
||||
persistencePending bool
|
||||
}
|
||||
|
||||
var globalLyricsCache = &lyricsCache{
|
||||
@@ -348,17 +352,14 @@ var globalLyricsCache = &lyricsCache{
|
||||
}
|
||||
|
||||
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)
|
||||
return lyricsFetchCacheKey("", track, artist, durationSec)
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
entry, exists := c.cache[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
@@ -368,12 +369,15 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.response, true
|
||||
responseCopy := *entry.response
|
||||
responseCopy.Lines = append([]LyricsLine(nil), entry.response.Lines...)
|
||||
return &responseCopy, true
|
||||
}
|
||||
|
||||
const lyricsCacheMaxEntries = 500
|
||||
|
||||
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -399,11 +403,11 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
|
||||
}
|
||||
}
|
||||
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
c.cache[key] = &lyricsCacheEntry{
|
||||
response: response,
|
||||
response: cloneLyricsResponse(response),
|
||||
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||
}
|
||||
c.schedulePersistenceLocked()
|
||||
}
|
||||
|
||||
func (c *lyricsCache) CleanExpired() int {
|
||||
@@ -433,5 +437,126 @@ func (c *lyricsCache) ClearAll() int {
|
||||
|
||||
cleared := len(c.cache)
|
||||
c.cache = make(map[string]*lyricsCacheEntry)
|
||||
c.schedulePersistenceLocked()
|
||||
return cleared
|
||||
}
|
||||
|
||||
// DropMemory releases the in-memory snapshot without deleting the persistent
|
||||
// cache. It is used for OS memory-pressure handling; a later app start can
|
||||
// still restore successful lyrics lookups from disk.
|
||||
func (c *lyricsCache) DropMemory() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cleared := len(c.cache)
|
||||
c.cache = make(map[string]*lyricsCacheEntry)
|
||||
return cleared
|
||||
}
|
||||
|
||||
type persistedLyricsCacheEntry struct {
|
||||
Response *LyricsResponse `json:"response"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
type persistedLyricsCache struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]persistedLyricsCacheEntry `json:"entries"`
|
||||
}
|
||||
|
||||
func cloneLyricsResponse(response *LyricsResponse) *LyricsResponse {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *response
|
||||
copy.Lines = append([]LyricsLine(nil), response.Lines...)
|
||||
return ©
|
||||
}
|
||||
|
||||
func (c *lyricsCache) SetPersistencePath(path string) {
|
||||
path = filepath.Clean(strings.TrimSpace(path))
|
||||
if path == "." || path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
loaded := make(map[string]*lyricsCacheEntry)
|
||||
if err == nil {
|
||||
var persisted persistedLyricsCache
|
||||
if json.Unmarshal(data, &persisted) == nil && persisted.Version == 1 {
|
||||
now := time.Now()
|
||||
for key, entry := range persisted.Entries {
|
||||
expiresAt := time.Unix(entry.ExpiresAt, 0)
|
||||
if entry.Response == nil || !now.Before(expiresAt) {
|
||||
continue
|
||||
}
|
||||
loaded[key] = &lyricsCacheEntry{
|
||||
response: cloneLyricsResponse(entry.Response),
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
if len(loaded) >= lyricsCacheMaxEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.persistencePath = path
|
||||
for key, entry := range loaded {
|
||||
if _, exists := c.cache[key]; !exists {
|
||||
c.cache[key] = entry
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *lyricsCache) schedulePersistenceLocked() {
|
||||
if c.persistencePath == "" {
|
||||
return
|
||||
}
|
||||
c.persistGeneration++
|
||||
if c.persistencePending {
|
||||
return
|
||||
}
|
||||
c.persistencePending = true
|
||||
go c.persistAfterDebounce()
|
||||
}
|
||||
|
||||
func (c *lyricsCache) persistAfterDebounce() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
for {
|
||||
c.mu.RLock()
|
||||
path := c.persistencePath
|
||||
generation := c.persistGeneration
|
||||
snapshot := persistedLyricsCache{
|
||||
Version: 1,
|
||||
Entries: make(map[string]persistedLyricsCacheEntry, len(c.cache)),
|
||||
}
|
||||
for key, entry := range c.cache {
|
||||
snapshot.Entries[key] = persistedLyricsCacheEntry{
|
||||
Response: cloneLyricsResponse(entry.response),
|
||||
ExpiresAt: entry.expiresAt.Unix(),
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if data, err := json.Marshal(snapshot); err == nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err == nil {
|
||||
tempPath := path + ".tmp"
|
||||
if os.WriteFile(tempPath, data, 0600) == nil {
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if generation == c.persistGeneration {
|
||||
c.persistencePending = false
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.mu.Unlock()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,74 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLyricsLookupSingleflightAndPersistentCache(t *testing.T) {
|
||||
SetLyricsProviderOrder([]string{LyricsProviderLRCLIB})
|
||||
defer SetLyricsProviderOrder(nil)
|
||||
clearLyricsProviderHealth()
|
||||
globalLyricsCache.ClearAll()
|
||||
|
||||
var calls atomic.Int32
|
||||
client := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":1,"trackName":"Singleflight Song","artistName":"Cache Artist","duration":180,"plainLyrics":"Cached lyric"}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}}
|
||||
|
||||
var wait sync.WaitGroup
|
||||
for range 8 {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
lyrics, err := client.FetchLyricsAllSources("", "Singleflight Song", "Cache Artist", 180)
|
||||
if err != nil || lyrics == nil {
|
||||
t.Errorf("FetchLyricsAllSources = %#v/%v", lyrics, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("identical lyrics lookups made %d HTTP calls, want 1", got)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "lyrics-cache.json")
|
||||
cache := &lyricsCache{cache: make(map[string]*lyricsCacheEntry)}
|
||||
cache.SetPersistencePath(path)
|
||||
cache.Set("Cache Artist", "Persistent Song", 180, &LyricsResponse{PlainLyrics: "Persisted", Source: "test"})
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("persistent lyrics cache was not written")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
reloaded := &lyricsCache{cache: make(map[string]*lyricsCacheEntry)}
|
||||
reloaded.SetPersistencePath(path)
|
||||
lyrics, ok := reloaded.Get("Cache Artist", "Persistent Song", 180)
|
||||
if !ok || lyrics.PlainLyrics != "Persisted" {
|
||||
t.Fatalf("reloaded persistent lyrics = %#v/%v", lyrics, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
||||
SetAppVersion("4.5.0")
|
||||
if ua := appUserAgent(); !strings.Contains(ua, "4.5.0") {
|
||||
|
||||
Reference in New Issue
Block a user