diff --git a/go_backend/exports.go b/go_backend/exports.go index e589df80..192fed6e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -51,7 +51,7 @@ func releaseMemory(underPressure bool) { CloseIdleConnections() if underPressure { clearCoverMemoryCache() - globalLyricsCache.ClearAll() + globalLyricsCache.DropMemory() clearPrivateIPCache() clearExtensionHealthCache() } diff --git a/go_backend/exports_extensions.go b/go_backend/exports_extensions.go index 30ca077f..29a5c8cd 100644 --- a/go_backend/exports_extensions.go +++ b/go_backend/exports_extensions.go @@ -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 } diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go index 9f96b4be..488d1649 100644 --- a/go_backend/extension_provider_wrapper.go +++ b/go_backend/extension_provider_wrapper.go @@ -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") diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index d669ca59..7ac29929 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -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, diff --git a/go_backend/lyrics_config.go b/go_backend/lyrics_config.go index 88696089..42b83ae8 100644 --- a/go_backend/lyrics_config.go +++ b/go_backend/lyrics_config.go @@ -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) + } +} diff --git a/go_backend/lyrics_supplement_test.go b/go_backend/lyrics_supplement_test.go index 5139751c..221edbb4 100644 --- a/go_backend/lyrics_supplement_test.go +++ b/go_backend/lyrics_supplement_test.go @@ -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") {