fix: dedupe and cap concurrent lyrics plugin fetches (#5792)

* fix: dedupe and cap concurrent lyrics plugin fetches

Clients like Finamp prefetch lyrics for several queue tracks at once. The
resulting burst of concurrent plugin calls can rate-limit the primary
lyrics provider into a timeout, making the plugin fall back to a lower
quality source and cache the bad result.

SimpleCache.GetWithLoader now deduplicates concurrent loads of the same
key via singleflight, with every waiter receiving the winner's result or
error. The Jellyfin lyrics loader is detached from the request context so
one cancelled request cannot fail the load for all waiters, and the
lyrics adapter caps in-flight plugin calls at 2 per plugin, queueing the
rest. As a side effect, the cached HTTP client used by the Last.fm,
Deezer and ListenBrainz agents also collapses identical concurrent
requests into a single upstream call.

* fix: harden lyrics concurrency fixes per review

Replace the stringified singleflight keys with a per-cache flight map
keyed by the cache key type itself, eliminating potential key collisions
for non-string keys, the nil-interface assertion panic, and the
stringification overhead. Release the lyrics semaphore slot via defer so
a panicking plugin call cannot leak it, and bound the detached lyrics
load with a one-minute timeout so a hung plugin cannot pin its
singleflight and semaphore slot indefinitely.
This commit is contained in:
Deluan Quintão 2026-07-16 20:10:35 -04:00 • committed by GitHub
commit 756df9decf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 256 additions and 26 deletions

View file

@ -14,6 +14,10 @@ const (
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
)
// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch
// lyrics for whole queues, and the resulting burst can rate-limit upstream providers.
const maxConcurrentLyricsCalls = 2
func init() {
registerCapability(
CapabilityLyrics,
@ -34,6 +38,12 @@ type LyricsPlugin struct {
// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response
// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain).
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
select {
case l.plugin.lyricsSem <- struct{}{}:
defer func() { <-l.plugin.lyricsSem }()
case <-ctx.Done():
return nil, ctx.Err()
}
req := capabilities.GetLyricsRequest{
Track: mediaFileToTrackInfo(l.plugin, mf),
}

View file

@ -3,6 +3,8 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -71,6 +73,45 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
Expect(result[0].Lang).To(Equal("xxx"))
})
It("blocks new calls while the per-plugin concurrency cap is saturated", func() {
sem := provider.plugin.lyricsSem
for range cap(sem) {
sem <- struct{}{}
}
ctx := GinkgoT().Context()
track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"}
done := make(chan error, 1)
go func() {
_, err := provider.GetLyrics(ctx, track)
done <- err
}()
Consistently(done, "500ms").ShouldNot(Receive())
<-sem // free one slot; the pending call should now proceed
Eventually(done).Should(Receive(BeNil()))
for range cap(sem) - 1 {
<-sem
}
})
It("gives up waiting for a slot when the context is cancelled", func() {
sem := provider.plugin.lyricsSem
for range cap(sem) {
sem <- struct{}{}
}
defer func() {
for range cap(sem) {
<-sem
}
}()
ctx, cancel := context.WithCancel(GinkgoT().Context())
cancel()
_, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"})
Expect(err).To(MatchError(context.Canceled))
})
It("returns error when plugin returns error", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-lyrics": {"error": "service unavailable"},

View file

@ -421,6 +421,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
}
m.mu.Unlock()
loaded = true

View file

@ -24,6 +24,7 @@ type plugin struct {
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
libraries libraryAccess
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
}
// instance creates a new plugin instance for the given context.