mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* 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.
167 lines
5.1 KiB
Go
167 lines
5.1 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("LyricsPlugin", Ordered, func() {
|
|
var (
|
|
lyricsManager *Manager
|
|
provider *LyricsPlugin
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
lyricsManager, _ = createTestManagerWithPlugins(nil,
|
|
"test-lyrics"+PackageExtension,
|
|
"test-metadata-agent"+PackageExtension,
|
|
)
|
|
|
|
p, ok := lyricsManager.LoadLyricsProvider("test-lyrics")
|
|
Expect(ok).To(BeTrue())
|
|
provider = p.(*LyricsPlugin)
|
|
})
|
|
|
|
Describe("LoadLyricsProvider", func() {
|
|
It("returns a lyrics provider for a plugin with Lyrics capability", func() {
|
|
Expect(provider).ToNot(BeNil())
|
|
})
|
|
|
|
It("returns false for a plugin without Lyrics capability", func() {
|
|
_, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent")
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
|
|
It("returns false for non-existent plugin", func() {
|
|
_, ok := lyricsManager.LoadLyricsProvider("non-existent")
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("GetLyrics", func() {
|
|
It("successfully returns lyrics from the plugin", func() {
|
|
track := &model.MediaFile{
|
|
ID: "track-1",
|
|
Title: "Test Song",
|
|
Artist: "Test Artist",
|
|
}
|
|
|
|
result, err := provider.GetLyrics(GinkgoT().Context(), track)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(1))
|
|
Expect(result[0].Line).ToNot(BeEmpty())
|
|
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
|
|
})
|
|
|
|
It("defaults language to 'xxx' when plugin does not provide one", func() {
|
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
|
"test-lyrics": {"no_lang": "true"},
|
|
}, "test-lyrics"+PackageExtension)
|
|
|
|
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
|
Expect(ok).To(BeTrue())
|
|
|
|
track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"}
|
|
result, err := p.GetLyrics(GinkgoT().Context(), track)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(1))
|
|
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"},
|
|
}, "test-lyrics"+PackageExtension)
|
|
|
|
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
|
Expect(ok).To(BeTrue())
|
|
|
|
track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
|
|
_, err := p.GetLyrics(GinkgoT().Context(), track)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
// Each DescribeTable entry proves that the adapter's content-sniffing routes
|
|
// the plugin's rich payload to the right parser rather than mangling it as plain text.
|
|
DescribeTable("content-sniffs plugin responses across all supported formats",
|
|
func(format string, wantSynced bool, wantLine string) {
|
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
|
"test-lyrics": {"format": format},
|
|
}, "test-lyrics"+PackageExtension)
|
|
|
|
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
|
Expect(ok).To(BeTrue())
|
|
|
|
track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"}
|
|
result, err := p.GetLyrics(GinkgoT().Context(), track)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(1))
|
|
Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format)
|
|
Expect(result[0].Line).To(HaveLen(1))
|
|
Expect(result[0].Line[0].Value).To(Equal(wantLine))
|
|
},
|
|
Entry("ttml", "ttml", true, "plugin ttml line"),
|
|
Entry("srt", "srt", true, "plugin srt line"),
|
|
Entry("yaml", "yaml", true, "plugin yaml line"),
|
|
Entry("lrc", "lrc", true, "plugin lrc line"),
|
|
Entry("plain", "plain", false, "plugin plain line"),
|
|
)
|
|
})
|
|
|
|
Describe("PluginNames", func() {
|
|
It("returns plugin names with Lyrics capability", func() {
|
|
names := lyricsManager.PluginNames("Lyrics")
|
|
Expect(names).To(ContainElement("test-lyrics"))
|
|
})
|
|
|
|
It("does not return metadata agent plugins for Lyrics capability", func() {
|
|
names := lyricsManager.PluginNames("Lyrics")
|
|
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
|
})
|
|
})
|
|
})
|