2020-01-19 14:21:44 -09:00
|
|
|
package subsonic
|
2020-01-08 16:45:07 -09:00
|
|
|
|
2020-01-09 17:58:03 -09:00
|
|
|
import (
|
2020-11-17 09:30:37 -09:00
|
|
|
"bytes"
|
2025-06-16 08:04:41 -08:00
|
|
|
"cmp"
|
2020-01-22 04:32:31 -09:00
|
|
|
"context"
|
2023-12-27 16:20:29 -09:00
|
|
|
"encoding/json"
|
2020-01-09 17:58:03 -09:00
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http/httptest"
|
2025-06-16 08:04:41 -08:00
|
|
|
"slices"
|
2022-12-27 10:30:00 -09:00
|
|
|
"time"
|
2020-01-09 17:58:03 -09:00
|
|
|
|
2025-04-30 04:10:19 -08:00
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
|
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
2023-01-31 14:22:49 -09:00
|
|
|
"github.com/navidrome/navidrome/core/artwork"
|
2026-03-03 11:48:39 -09:00
|
|
|
"github.com/navidrome/navidrome/core/lyrics"
|
2020-01-23 15:44:08 -09:00
|
|
|
"github.com/navidrome/navidrome/model"
|
2023-12-27 16:20:29 -09:00
|
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
2020-11-13 10:57:49 -09:00
|
|
|
"github.com/navidrome/navidrome/tests"
|
2022-07-26 12:47:16 -08:00
|
|
|
. "github.com/onsi/ginkgo/v2"
|
2020-01-09 17:58:03 -09:00
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("MediaRetrievalController", func() {
|
2022-11-21 08:57:56 -09:00
|
|
|
var router *Router
|
2021-10-19 12:33:06 -08:00
|
|
|
var ds model.DataStore
|
2025-06-16 08:58:20 -08:00
|
|
|
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
|
2020-07-31 05:31:19 -08:00
|
|
|
var artwork *fakeArtwork
|
2020-01-09 17:58:03 -09:00
|
|
|
var w *httptest.ResponseRecorder
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
2021-10-19 12:33:06 -08:00
|
|
|
ds = &tests.MockDataStore{
|
|
|
|
|
MockedMediaFile: mockRepo,
|
|
|
|
|
}
|
2025-06-16 08:58:20 -08:00
|
|
|
artwork = &fakeArtwork{data: "image data"}
|
feat(subsonic): add sonicSimilarity extension as plugin capability (#5419)
* feat(plugins): add sonicSimilarity capability types
Defines the SonicSimilarity plugin capability interface with
GetSonicSimilarTracks and FindSonicPath methods, along with
their request/response types.
* feat(sonic): add core sonic similarity service
Implements the Sonic service with HasProvider, GetSonicSimilarTracks,
and FindSonicPath, delegating to the PluginLoader and using the
Matcher for index-preserving library resolution.
* test(sonic): add sonic service unit tests
Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with
mock plugin loader and provider, verifying error propagation and
successful match resolution via the library matcher.
* feat(matcher): add MatchSongsToLibraryMap for index-preserving matching
Adds a new method alongside MatchSongsToLibrary that returns a
map[int]MediaFile keyed by input song index rather than a flat slice,
enabling callers to correlate similarity scores back to the original
position in the results.
* fix(sonic): check provider availability before MediaFile lookup
Avoids unnecessary DB call when no plugin is available, and ensures
the correct error path is tested.
* feat(plugins): add sonic similarity adapter
Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the
plugin system to the core sonic service via Extism plugin functions.
Reuses existing songRefsToAgentSongs helper for SongRef conversion.
* feat(plugins): add LoadSonicSimilarity to plugin manager
Adds Manager.LoadSonicSimilarity method following the pattern of
LoadLyricsProvider, enabling the core sonic service to load a
SonicSimilarityAdapter from a named plugin.
* feat(subsonic): add sonicMatch response type
Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results.
* feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers
Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types.
* feat(subsonic): advertise sonicSimilarity extension when plugin available
Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter.
* feat(subsonic): wire sonic similarity service into router
Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go.
* fix(e2e): add sonic parameter to subsonic.New call in e2e tests
* test(subsonic): add sonicSimilarity extension advertisement tests
Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin.
* feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints
Handlers return ErrorDataNotFound when no sonic service is configured,
preventing nil panics. E2e tests verify both endpoints return proper
error responses when no plugin is available.
* fix(subsonic): return HTTP 404 when no sonic similarity plugin available
Endpoints are always registered but return 404 when no provider is
available, rather than a subsonic error code 70.
* refactor: clean up sonic similarity code after review
Extract shared helpers to reduce duplication across the sonic similarity
implementation: loadAllMatches in matcher consolidates the 4-phase
matching pipeline, songRefToAgentSong eliminates per-iteration slice
allocation in the adapter, sonicMatchResponse deduplicates response
building in handlers, and a package-level constant replaces raw
capability name strings in core/sonic.
* fix empty response shapes
Signed-off-by: Deluan <deluan@navidrome.org>
* test(plugins): add testdata plugin and e2e tests for sonic similarity
Add a test-sonic-similarity WASM plugin that implements both
GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity
PDK. The plugin returns deterministic test data with decreasing similarity
scores and supports error injection via config. Adapter tests verify the
full round-trip through the WASM plugin including error handling. Also
includes regenerated PDK code from make gen.
* docs: update README to include new capabilities and usage examples for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): enhance sonic similarity tests with additional scenarios and mock provider
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address PR review feedback for sonic similarity
Fix incorrect field names in README documentation ({from, to} →
{startSong, endSong}) and remove unnecessary XML serialization test
from e2e suite since OpenSubsonic endpoints only use JSON.
* refactor: rename Matcher methods for conciseness
Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to
MatchSongsIndexed. The Matcher receiver already establishes the "to
library" context, making that suffix redundant, and "Indexed" better
describes the intent (preserving input ordering) than "Map" which
describes the data structure.
* refactor: standardize variable naming for media files in sonic path methods
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: simplify plugin loading by introducing adapter constructors
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-27 13:50:09 -08:00
|
|
|
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil)
|
2020-01-09 17:58:03 -09:00
|
|
|
w = httptest.NewRecorder()
|
2025-04-30 04:10:19 -08:00
|
|
|
DeferCleanup(configtest.SetupConfig())
|
|
|
|
|
conf.Server.LyricsPriority = "embedded,.lrc"
|
2020-01-09 17:58:03 -09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("GetCoverArt", func() {
|
|
|
|
|
It("should return data for that id", func() {
|
2025-06-16 08:58:20 -08:00
|
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
2022-11-21 08:57:56 -09:00
|
|
|
_, err := router.GetCoverArt(w, r)
|
2020-01-09 17:58:03 -09:00
|
|
|
|
2025-06-16 08:58:20 -08:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
2020-07-31 05:31:19 -08:00
|
|
|
Expect(artwork.recvSize).To(Equal(128))
|
2025-06-16 08:58:20 -08:00
|
|
|
Expect(artwork.recvSquare).To(BeTrue())
|
2021-05-29 07:37:00 -08:00
|
|
|
Expect(w.Body.String()).To(Equal(artwork.data))
|
2020-01-09 17:58:03 -09:00
|
|
|
})
|
|
|
|
|
|
2025-06-16 09:01:38 -08:00
|
|
|
It("should return placeholder if id parameter is missing (mimicking Subsonic)", func() {
|
|
|
|
|
r := newGetRequest() // No id parameter
|
|
|
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
|
|
|
|
|
|
Expect(err).To(BeNil())
|
|
|
|
|
Expect(artwork.recvId).To(BeEmpty())
|
|
|
|
|
Expect(w.Body.String()).To(Equal(artwork.data))
|
|
|
|
|
})
|
|
|
|
|
|
2020-01-09 17:58:03 -09:00
|
|
|
It("should fail when the file is not found", func() {
|
2020-07-31 05:31:19 -08:00
|
|
|
artwork.err = model.ErrNotFound
|
2025-06-16 08:58:20 -08:00
|
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
2022-11-21 08:57:56 -09:00
|
|
|
_, err := router.GetCoverArt(w, r)
|
2020-01-09 17:58:03 -09:00
|
|
|
|
2020-07-31 05:31:19 -08:00
|
|
|
Expect(err).To(MatchError("Artwork not found"))
|
2020-01-09 17:58:03 -09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should fail when there is an unknown error", func() {
|
2020-07-31 05:31:19 -08:00
|
|
|
artwork.err = errors.New("weird error")
|
2020-01-27 11:10:46 -09:00
|
|
|
r := newGetRequest("id=34", "size=128")
|
2022-11-21 08:57:56 -09:00
|
|
|
_, err := router.GetCoverArt(w, r)
|
2020-01-09 17:58:03 -09:00
|
|
|
|
2020-10-27 11:23:29 -08:00
|
|
|
Expect(err).To(MatchError("weird error"))
|
2020-01-09 17:58:03 -09:00
|
|
|
})
|
2025-06-16 08:58:20 -08:00
|
|
|
|
|
|
|
|
When("client disconnects (context is cancelled)", func() {
|
|
|
|
|
It("should not call the service if cancelled before the call", func() {
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
|
|
|
|
r = r.WithContext(ctx)
|
fix(server): prevent artwork throttle token starvation on slow clients (#5472)
* fix(server): prevent artwork throttle token starvation on slow clients
Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).
The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.
* refactor(server): simplify throttle to middleware with same API as Chi
Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(server): add opt-out flag for buffered artwork throttle
Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(server): clean up throttle tests for clarity and speed
Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.
* fix: release artwork throttle tokens on panic
Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.
* fix: align buffered throttle response behavior
Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.
* refactor(server): remove setWriteTimeout from throttle middleware
SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-05 20:12:50 -08:00
|
|
|
cancel()
|
2025-06-16 08:58:20 -08:00
|
|
|
|
|
|
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
|
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(artwork.recvId).To(Equal(""))
|
|
|
|
|
Expect(artwork.recvSize).To(Equal(0))
|
|
|
|
|
Expect(artwork.recvSquare).To(BeFalse())
|
|
|
|
|
Expect(w.Body.String()).To(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should not return data if cancelled during the call", func() {
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
fix(server): prevent artwork throttle token starvation on slow clients (#5472)
* fix(server): prevent artwork throttle token starvation on slow clients
Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).
The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.
* refactor(server): simplify throttle to middleware with same API as Chi
Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(server): add opt-out flag for buffered artwork throttle
Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(server): clean up throttle tests for clarity and speed
Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.
* fix: release artwork throttle tokens on panic
Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.
* fix: align buffered throttle response behavior
Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.
* refactor(server): remove setWriteTimeout from throttle middleware
SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-05 20:12:50 -08:00
|
|
|
defer cancel()
|
2025-06-16 08:58:20 -08:00
|
|
|
r := newGetRequest("id=34", "size=128", "square=true")
|
|
|
|
|
r = r.WithContext(ctx)
|
fix(server): prevent artwork throttle token starvation on slow clients (#5472)
* fix(server): prevent artwork throttle token starvation on slow clients
Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).
The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.
* refactor(server): simplify throttle to middleware with same API as Chi
Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(server): add opt-out flag for buffered artwork throttle
Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(server): clean up throttle tests for clarity and speed
Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.
* fix: release artwork throttle tokens on panic
Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.
* fix: align buffered throttle response behavior
Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.
* refactor(server): remove setWriteTimeout from throttle middleware
SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-05 20:12:50 -08:00
|
|
|
artwork.ctxCancelFunc = cancel
|
2025-06-16 08:58:20 -08:00
|
|
|
|
|
|
|
|
_, err := router.GetCoverArt(w, r)
|
|
|
|
|
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(artwork.recvId).To(Equal("34"))
|
|
|
|
|
Expect(artwork.recvSize).To(Equal(128))
|
|
|
|
|
Expect(artwork.recvSquare).To(BeTrue())
|
|
|
|
|
Expect(w.Body.String()).To(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
})
|
2020-01-09 17:58:03 -09:00
|
|
|
})
|
2021-10-19 12:33:06 -08:00
|
|
|
|
|
|
|
|
Describe("GetLyrics", func() {
|
|
|
|
|
It("should return data for given artist & title", func() {
|
|
|
|
|
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
2023-12-27 16:20:29 -09:00
|
|
|
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
|
|
|
|
|
lyricsJson, err := json.Marshal(model.LyricList{
|
|
|
|
|
*lyrics,
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
2025-06-16 08:04:41 -08:00
|
|
|
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
2021-10-19 12:33:06 -08:00
|
|
|
mockRepo.SetData(model.MediaFiles{
|
|
|
|
|
{
|
2025-06-16 08:04:41 -08:00
|
|
|
ID: "2",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
Lyrics: "[]",
|
|
|
|
|
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
ID: "1",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
Lyrics: string(lyricsJson),
|
|
|
|
|
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
ID: "3",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
Lyrics: "[]",
|
|
|
|
|
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
|
2021-10-19 12:33:06 -08:00
|
|
|
},
|
|
|
|
|
})
|
2022-11-21 08:57:56 -09:00
|
|
|
response, err := router.GetLyrics(r)
|
2025-06-16 08:58:20 -08:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
2021-10-19 12:33:06 -08:00
|
|
|
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
|
|
|
|
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
2023-12-27 16:20:29 -09:00
|
|
|
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
2021-10-19 12:33:06 -08:00
|
|
|
})
|
|
|
|
|
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
|
|
|
|
|
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
|
|
|
|
|
mockRepo.SetData(model.MediaFiles{})
|
2022-11-21 08:57:56 -09:00
|
|
|
response, err := router.GetLyrics(r)
|
2025-06-16 08:58:20 -08:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
2021-10-19 12:33:06 -08:00
|
|
|
Expect(response.Lyrics.Artist).To(Equal(""))
|
|
|
|
|
Expect(response.Lyrics.Title).To(Equal(""))
|
|
|
|
|
Expect(response.Lyrics.Value).To(Equal(""))
|
2023-12-27 16:20:29 -09:00
|
|
|
})
|
2025-04-30 04:10:19 -08:00
|
|
|
It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() {
|
|
|
|
|
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
|
|
|
|
mockRepo.SetData(model.MediaFiles{
|
|
|
|
|
{
|
|
|
|
|
Path: "tests/fixtures/test.mp3",
|
|
|
|
|
ID: "1",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
},
|
2025-06-16 08:04:41 -08:00
|
|
|
{
|
|
|
|
|
Path: "tests/fixtures/test.mp3",
|
|
|
|
|
ID: "2",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
},
|
2025-04-30 04:10:19 -08:00
|
|
|
})
|
|
|
|
|
response, err := router.GetLyrics(r)
|
2025-06-16 08:58:20 -08:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
2025-04-30 04:10:19 -08:00
|
|
|
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
|
|
|
|
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
|
|
|
|
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
|
|
|
|
})
|
2023-12-27 16:20:29 -09:00
|
|
|
})
|
|
|
|
|
|
2025-06-16 08:58:20 -08:00
|
|
|
Describe("GetLyricsBySongId", func() {
|
2023-12-27 16:20:29 -09:00
|
|
|
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
|
|
|
|
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
|
|
|
|
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
|
|
|
|
|
var times = []int64{18800, 22801}
|
|
|
|
|
|
|
|
|
|
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
|
|
|
|
|
Expect(actual).ToNot(BeNil())
|
|
|
|
|
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
|
|
|
|
|
for i, realLyric := range actual.StructuredLyrics {
|
|
|
|
|
expectedLyric := expected.StructuredLyrics[i]
|
|
|
|
|
|
|
|
|
|
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
|
|
|
|
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
|
|
|
|
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
|
|
|
|
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
|
|
|
|
|
|
|
|
|
if expectedLyric.Offset == nil {
|
|
|
|
|
Expect(realLyric.Offset).To(BeNil())
|
|
|
|
|
} else {
|
|
|
|
|
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
|
|
|
|
|
for j, realLine := range realLyric.Line {
|
|
|
|
|
expectedLine := expectedLyric.Line[j]
|
|
|
|
|
Expect(realLine.Value).To(Equal(expectedLine.Value))
|
|
|
|
|
|
|
|
|
|
if expectedLine.Start == nil {
|
|
|
|
|
Expect(realLine.Start).To(BeNil())
|
|
|
|
|
} else {
|
|
|
|
|
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
It("should return mixed lyrics", func() {
|
|
|
|
|
r := newGetRequest("id=1")
|
|
|
|
|
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
|
|
|
|
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
|
|
|
|
lyricsJson, err := json.Marshal(model.LyricList{
|
|
|
|
|
*synced, *unsynced,
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
mockRepo.SetData(model.MediaFiles{
|
|
|
|
|
{
|
|
|
|
|
ID: "1",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
Lyrics: string(lyricsJson),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
response, err := router.GetLyricsBySongId(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
compareResponses(response.LyricsList, responses.LyricsList{
|
|
|
|
|
StructuredLyrics: responses.StructuredLyrics{
|
|
|
|
|
{
|
|
|
|
|
Lang: "eng",
|
|
|
|
|
DisplayArtist: "Rick Astley",
|
|
|
|
|
DisplayTitle: "Never Gonna Give You Up",
|
|
|
|
|
Synced: true,
|
|
|
|
|
Line: []responses.Line{
|
|
|
|
|
{
|
|
|
|
|
Start: ×[0],
|
|
|
|
|
Value: "We're no strangers to love",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Start: ×[1],
|
|
|
|
|
Value: "You know the rules and so do I",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Lang: "xxx",
|
|
|
|
|
DisplayArtist: "Rick Astley",
|
|
|
|
|
DisplayTitle: "Never Gonna Give You Up",
|
|
|
|
|
Synced: false,
|
|
|
|
|
Line: []responses.Line{
|
|
|
|
|
{
|
|
|
|
|
Value: "We're no strangers to love",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Value: "You know the rules and so do I",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should parse lrc metadata", func() {
|
|
|
|
|
r := newGetRequest("id=1")
|
|
|
|
|
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
|
|
|
|
lyricsJson, err := json.Marshal(model.LyricList{
|
|
|
|
|
*synced,
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
mockRepo.SetData(model.MediaFiles{
|
|
|
|
|
{
|
|
|
|
|
ID: "1",
|
|
|
|
|
Artist: "Rick Astley",
|
|
|
|
|
Title: "Never Gonna Give You Up",
|
|
|
|
|
Lyrics: string(lyricsJson),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
response, err := router.GetLyricsBySongId(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
2021-10-19 12:33:06 -08:00
|
|
|
|
2023-12-27 16:20:29 -09:00
|
|
|
compareResponses(response.LyricsList, responses.LyricsList{
|
|
|
|
|
StructuredLyrics: responses.StructuredLyrics{
|
|
|
|
|
{
|
|
|
|
|
DisplayArtist: "Rick Astley",
|
|
|
|
|
DisplayTitle: "That one song",
|
|
|
|
|
Lang: "eng",
|
|
|
|
|
Synced: true,
|
|
|
|
|
Line: []responses.Line{
|
|
|
|
|
{
|
|
|
|
|
Start: ×[0],
|
|
|
|
|
Value: "We're no strangers to love",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Start: ×[1],
|
|
|
|
|
Value: "You know the rules and so do I",
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-19 13:02:29 -08:00
|
|
|
Offset: new(int64(-100)),
|
2023-12-27 16:20:29 -09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
2021-10-19 12:33:06 -08:00
|
|
|
})
|
|
|
|
|
})
|
2020-01-09 17:58:03 -09:00
|
|
|
})
|
2020-07-31 05:31:19 -08:00
|
|
|
|
|
|
|
|
type fakeArtwork struct {
|
2023-01-31 14:22:49 -09:00
|
|
|
artwork.Artwork
|
2025-06-16 08:58:20 -08:00
|
|
|
data string
|
|
|
|
|
err error
|
|
|
|
|
ctxCancelFunc func()
|
|
|
|
|
recvId string
|
|
|
|
|
recvSize int
|
|
|
|
|
recvSquare bool
|
2020-07-31 05:31:19 -08:00
|
|
|
}
|
|
|
|
|
|
2024-05-24 16:19:26 -08:00
|
|
|
func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
2020-07-31 05:31:19 -08:00
|
|
|
if c.err != nil {
|
2022-12-27 10:30:00 -09:00
|
|
|
return nil, time.Time{}, c.err
|
2020-07-31 05:31:19 -08:00
|
|
|
}
|
|
|
|
|
c.recvId = id
|
|
|
|
|
c.recvSize = size
|
2025-06-16 08:58:20 -08:00
|
|
|
c.recvSquare = square
|
|
|
|
|
if c.ctxCancelFunc != nil {
|
fix(server): prevent artwork throttle token starvation on slow clients (#5472)
* fix(server): prevent artwork throttle token starvation on slow clients
Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).
The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.
* refactor(server): simplify throttle to middleware with same API as Chi
Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(server): add opt-out flag for buffered artwork throttle
Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(server): clean up throttle tests for clarity and speed
Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.
* fix: release artwork throttle tokens on panic
Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.
* fix: align buffered throttle response behavior
Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.
* refactor(server): remove setWriteTimeout from throttle middleware
SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-05 20:12:50 -08:00
|
|
|
c.ctxCancelFunc()
|
2025-06-16 08:58:20 -08:00
|
|
|
return nil, time.Time{}, context.Canceled
|
|
|
|
|
}
|
2022-12-27 10:30:00 -09:00
|
|
|
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
|
2020-07-31 05:31:19 -08:00
|
|
|
}
|
2021-10-19 12:33:06 -08:00
|
|
|
|
|
|
|
|
type mockedMediaFile struct {
|
2025-06-16 08:58:20 -08:00
|
|
|
tests.MockMediaFileRepo
|
2021-10-19 12:33:06 -08:00
|
|
|
}
|
|
|
|
|
|
2025-06-16 08:04:41 -08:00
|
|
|
func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) {
|
2025-06-16 08:58:20 -08:00
|
|
|
data, err := m.MockMediaFileRepo.GetAll(opts...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2025-06-16 08:04:41 -08:00
|
|
|
if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" {
|
2025-06-16 08:58:20 -08:00
|
|
|
return data, nil
|
2025-06-16 08:04:41 -08:00
|
|
|
}
|
|
|
|
|
|
2025-06-16 08:58:20 -08:00
|
|
|
result := slices.Clone(data)
|
2025-06-16 08:04:41 -08:00
|
|
|
slices.SortFunc(result, func(a, b model.MediaFile) int {
|
|
|
|
|
diff := cmp.Or(
|
|
|
|
|
cmp.Compare(a.Lyrics, b.Lyrics),
|
|
|
|
|
cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()),
|
|
|
|
|
)
|
|
|
|
|
if opts[0].Order == "desc" {
|
|
|
|
|
return -diff
|
|
|
|
|
}
|
|
|
|
|
return diff
|
|
|
|
|
})
|
|
|
|
|
return result, nil
|
2021-10-19 12:33:06 -08:00
|
|
|
}
|