perf(lyrics): coalesce and persist provider results

This commit is contained in:
zarzet
2026-08-30 23:18:51 +07:00
parent bd07d5c233
commit b18def64d3
6 changed files with 397 additions and 22 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ func releaseMemory(underPressure bool) {
CloseIdleConnections()
if underPressure {
clearCoverMemoryCache()
globalLyricsCache.ClearAll()
globalLyricsCache.DropMemory()
clearPrivateIPCache()
clearExtensionHealthCache()
}
+2
View File
@@ -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
}
+12 -3
View File
@@ -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
View File
@@ -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 &copy
}
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 &copy, 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
View File
@@ -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 &copy
}
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)
}
}
+62
View File
@@ -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") {