From 4b60b21316f9e8467479c1f389e2a12a112bcbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 29 Aug 2026 16:36:08 -0400 Subject: [PATCH 01/27] fix(scanner): read file birth time via statx on Linux (#6046) * fix(scanner): read file birth time via statx on Linux On Linux the file birth time is only reachable through statx(2). We were reading it with times.Get(), which looks only at the plain stat() result, where the field does not exist: djherbis/times declares HasBirthTime=false for Linux, so the check was always false and every file fell back to time.Now(). This has been the case since #2553 introduced the feature, which means that PR was a no-op on Linux from day one. macOS and Windows were never affected, as there the birth time does come back from plain stat. BirthTime() now tries times.Get() first, which costs no syscall and is already correct on macOS, Windows and BSD, and only falls back to times.Stat() on the path when that comes back empty. Ordering matters: on Windows times.Stat() opens the file asking for FILE_WRITE_ATTRIBUTES, which fails on a read-only share before falling back. Not every filesystem stores a birth time. Measured with a probe over real mounts: ext4, SMB/CIFS and mergerfs report one, while NFS and rclone/FUSE never do. Asking those on every file is pure overhead, so a miss is remembered per device on the localFS and skipped from then on. The memo is keyed by device rather than by library, so a library spanning two mounts does not lose birth times on the mount that does support them. Cost of the extra call is ~2us per file against ~52us just to open a file for tag reading, so 0.23s across a 97k-file library, and only for files whose tags are actually read. Existing rows keep their current birth_time: the repository drops that column on update, so only newly added files get the real value. * fix(scanner): return the device id opaquely to satisfy unconvert st.Dev is uint64 on Linux and int32 on darwin, so a uint64() cast is redundant on one and required on the other. Returning it as an opaque value drops the cast entirely, which also removes the gosec suppression that came with it. The value is only ever used as a sync.Map key. --- core/storage/local/deviceid_unix.go | 18 ++++++++++++ core/storage/local/deviceid_windows.go | 8 ++++++ core/storage/local/local.go | 40 +++++++++++++++++++++++++- core/storage/local/local_test.go | 33 +++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 core/storage/local/deviceid_unix.go create mode 100644 core/storage/local/deviceid_windows.go diff --git a/core/storage/local/deviceid_unix.go b/core/storage/local/deviceid_unix.go new file mode 100644 index 000000000..42e7dac70 --- /dev/null +++ b/core/storage/local/deviceid_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package local + +import ( + "io/fs" + "syscall" +) + +// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. +// It is returned opaquely because its width varies by platform, and it is only used as a map key. +func deviceID(fi fs.FileInfo) (any, bool) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, false + } + return st.Dev, true +} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go new file mode 100644 index 000000000..f1b44be4d --- /dev/null +++ b/core/storage/local/deviceid_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package local + +import "io/fs" + +// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. +func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 32aff0955..686838565 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "sync" "sync/atomic" "time" @@ -61,6 +62,8 @@ type localFS struct { fs.FS extractor Extractor root string + // devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once + noBirthTime sync.Map } // ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the @@ -84,7 +87,11 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { if err != nil { return nil, err } - v.FileInfo = localFileInfo{info} + v.FileInfo = localFileInfo{ + FileInfo: info, + path: filepath.Join(lfs.root, filepath.FromSlash(path)), + noBirthTime: &lfs.noBirthTime, + } res[path] = v } } @@ -95,15 +102,46 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { // with metadata.FileInfo type localFileInfo struct { fs.FileInfo + path string + noBirthTime *sync.Map } func (lfi localFileInfo) BirthTime() time.Time { if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { return ts.BirthTime() } + if bt, ok := lfi.statxBirthTime(); ok { + return bt + } return time.Now() } +// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it. +// Filesystems that never report one are remembered per device, so a scan asks each only once. +func (lfi localFileInfo) statxBirthTime() (time.Time, bool) { + if lfi.path == "" { + return time.Time{}, false + } + dev, hasDev := deviceID(lfi.FileInfo) + memo := lfi.noBirthTime + if hasDev && memo != nil { + if _, skip := memo.Load(dev); skip { + return time.Time{}, false + } + } + ts, err := times.Stat(lfi.path) + if err != nil { + return time.Time{}, false + } + if ts.HasBirthTime() { + return ts.BirthTime(), true + } + if hasDev && memo != nil { + memo.Store(dev, struct{}{}) + } + return time.Time{}, false +} + func init() { storage.Register(storage.LocalSchemaID, newLocalStorage) } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 90bdd4b5b..3ed6d6fd9 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" + "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -440,6 +442,37 @@ var _ = Describe("LocalStorage", func() { // Should be around the current time (within last few minutes) Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) }) + + It("reads the birth time from the path, not the time of the call", func() { + // On Linux, birth time is only available via statx(2) on the path. + lfi := localFileInfo{FileInfo: fileInfo, path: testFile} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) + }) + + It("does not remember filesystems that do report a birth time", func() { + memo := &sync.Map{} + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + lfi.BirthTime() + + count := 0 + memo.Range(func(_, _ any) bool { count++; return true }) + Expect(count).To(BeZero()) + }) + + It("skips statx on filesystems already known to have none", func() { + if times.Get(fileInfo).HasBirthTime() { + Skip("this platform reports birth time from FileInfo, so statx is never called") + } + dev, ok := deviceID(fileInfo) + Expect(ok).To(BeTrue()) + + memo := &sync.Map{} + memo.Store(dev, struct{}{}) + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + }) }) It("should delegate all other FileInfo methods", func() { From d7ca00d0189210f5f00c110d7a35b74a98b5a71a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 29 Aug 2026 17:07:17 -0400 Subject: [PATCH 02/27] chore(deps): update fscache fork to the CancelWithErr simplification stream v1.5.0 added CancelWithErr, which delivers a cancellation cause to blocked reads, future reads, and NextReader. The fscache fork now delegates CloseWithError to it, dropping its own cause recording and reader wrappers. Behavior is unchanged on the Navidrome side. --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 624cf9a01..93e84b052 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ go 1.26 replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 // Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 -replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 +replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 require ( github.com/Masterminds/squirrel v1.5.4 @@ -17,7 +17,7 @@ require ( github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.4.0 + github.com/djherbis/stream v1.5.0 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 diff --git a/go.sum b/go.sum index 0fcd0a4c7..c75b72dd0 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 h1:s254V2hsrrCJXYtAn9WPG/5p4QHenfL9E+j6Tiq5MW4= -github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2/go.mod h1:eNFa48vJrse+8ysT4IJnnUeXwLZNcR0JQumU/W/QoUI= +github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 h1:u3oDvM7pOIouwDGeIoMu9SYgSzVMtiSkOc+xywtKiJk= +github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -41,8 +41,8 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= -github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/stream v1.5.0 h1:+ewqpS/ndTmEiJRH142JyOpmmjnkTxdbJtqoAG5sg0Y= +github.com/djherbis/stream v1.5.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= From 59448e928351f5afd08ee3ce86fa46b081128c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 29 Aug 2026 17:28:29 -0400 Subject: [PATCH 03/27] fix(scrobbler): back off when a provider asks us to, instead of retrying per play (#6028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): retry-later error type with optional server delay Add agents.ErrRetryLater and agents.RetryLaterError, which carries the delay requested by an external service (e.g. ListenBrainz's X-RateLimit-Reset-In). scrobbler.ErrRetryLater becomes an alias of the new sentinel, so existing errors.Is checks and the plugin error-string protocol keep working unchanged. Groundwork for honoring server-requested retry delays across scrobbling, metadata agents and artwork. Song.Equals tests moved to song_test.go to enable external test package. * fix(scrobbler): honor backoff window and server-requested retry delay ListenBrainz 429s were decoded into a typed error that classified as unrecoverable, silently discarding the scrobble (a JSON-bodied 429 was measured live). The client now maps any 429 to agents.RetryLaterError, carrying X-RateLimit-Reset-In when present (capped at 1h). Last.fm error 29 (rate limit) is now retryable like 11/16. The buffer's drain loop no longer lets wake signals bypass an active backoff window - new plays enqueue but drain only when the window closes - and the wait honors the server delay via max(backoff, retryIn). * feat(agents): skip cooling-down agents in aggregate calls When an agent reports retry-later, remember a per-agent cooldown deadline (the server-requested delay, or 1 minute when unspecified) and skip that agent in all aggregate metadata calls until it passes. A round that found no data but skipped or saw a throttled agent returns ErrRetryLater instead of ErrNotFound, so callers cannot mistake rate limiting for a definitive 'no data' answer. * feat(artwork): honor server-requested retry delay when rescheduling When an external image lookup fails with a retry-later error carrying a delay (e.g. a 429 with X-RateLimit-Reset-In), the chain trace carries the largest such hint back to the worker, which reschedules the item at max(exponential backoff, server delay) instead of backoff alone. * feat(plugins): retry-later with optional delay for scrobbler and agent plugins Scrobbler plugins can now return scrobbler(retry_later:N) to request a retry in N seconds (capped at 1h); the bare token keeps its old meaning. Metadata-agent plugins, which had no error vocabulary at all, gain the parallel agent(retry_later[:N]) token, mapped to agents.RetryLaterError so the aggregate's cooldown and the artwork worker honor plugin throttling the same way as built-in agents. * fix: address whole-branch review findings for retry-later handling Narrow the aggregate's throttled rule to the spec sentence: core.Agents returns ErrRetryLater only when no agent answered at all (all skipped-cooling or retry-later). An agent that does not implement the called method now returns an internal errUnsupported instead of ErrNotFound, so it counts as "did not run" — without that, the always-appended local agent would answer for biography, URL and images and make ErrRetryLater unreachable. Wire the consequence in core/external: a throttled round no longer stamps ExternalInfoUpdatedAt (artist and album), so the empty result is not cached for the TTL, and TopSongs maps ErrRetryLater to the same empty-200 the not-found path already produced instead of a new client-facing error. Move the Last.fm code-29 mapping into the client's central error construction so every metadata path produces RetryLaterError, and map ListenBrainz's body-level code 429 (sent with a non-429 HTTP status) the same way. Clamp server- and plugin-requested delays in seconds before scaling to a Duration, in all three parse sites: a header of 18446744074 wrapped past 2^64 and came out as a 0.29s delay. Also: extract the artwork worker's reschedule computation into retryDelay() and cover both it and the trace RetryIn wiring with tests; collapse the double regex call in mapScrobblerError; drop capabilities.ScrobblerErrorRetryLaterIn (ndpgen never emits funcs, so plugin authors could not reach it); regenerate the PDKs so MetadataAgentError reaches the Go and Rust SDKs; de-flake the cooldown tests (long RetryIn for the skip case, separate expiry spec); and cover the max() retry-delay aggregation across users in the scrobble buffer. * refactor: dedupe retry-later parsing and simplify error collection - Add agents.NewRetryLater and agents.RetryLaterFromSeconds, with a single 1h cap, replacing the parse+clamp+multiply logic and the maxRetryInSeconds constant duplicated across listenbrainz, plugins and the agent adapter. - Move HTTP header parsing to httpclient.RetryAfter, so the transport layer owns it and stays domain-agnostic; drop retryInFromHeaders from the ListenBrainz client. Covered by a new Ginkgo table in that package. - Collapse the two near-identical plugin retry_later regexes into one parseRetryLater(prefix, msg) shared by the agent and scrobbler adapters. - Fold the duplicated noteRetryIn snippet from fetchArtistImage and fetchAlbumImage into recordAgent, which already branched on the same isTransientExternal condition. - Replace the atomic.Bool + note() closure in populateArtistInfo with errgroup's own error collection; the group carries no context, so a returned error does not cancel its siblings. - Reuse recoveringScrobbler for the per-user delay test instead of a third double, and switch fakeScrobbler's mutex-guarded error to the atomic.Pointer idiom already used in the same package. * refactor(listenbrainz): keep rate-limit header parsing in the adapter The X-RateLimit-Reset-In header is ListenBrainz's own convention, not a shared one: Last.fm sends no rate-limit headers at all and reports its limit as a body code, and no other integration in tree sends Retry-After. A parser in utils/httpclient implied a uniformity across services that does not exist, so it moves back next to the only client that can know which header its service sends. * refactor(agents): collapse the retry-later sentinel and error into one type ErrRetryLater is now the zero-delay RetryLaterError rather than a separate errors.New value, so errors.Is and errors.AsType both match the sentinel and every delay-carrying variant. That removes the trap where a bare sentinel silently skipped the AsType path, and lets every consumer read the delay off the error directly: the RetryIn accessor and the two constructors are gone, with the policy cap applied where untrusted input is parsed. * refactor(agents): split the cooldown store from the per-dispatch tally The cooldown map and mutex become a cooldowns value with active/park, holding no knowledge of errors; agentAttempts records one dispatch's outcomes and owns the classification that noteAgentError used to hide behind a bool. The three dispatch loops now touch a single object: skip folds the cooldown check and the throttled flag into one call, so the store never appears in the loops. * refactor(agents): share one dispatch loop between the agent call helpers callAgentMethod and callAgentSliceMethod ran identical loops, differing only in how they test a result for emptiness: a slice cannot be compared against its zero value, so the two could not share a constraint. Both now delegate to callAgent, which takes that test as a parameter. Keeping the loop in one place matters more than the lines saved: it holds the cooldown skip, the attempt recording and the empty-dispatch verdict, and a fix applied to one copy but not the other would be silent. * test: cover the two retry-later paths a mutation could break silently Both gaps were proven, not guessed: making the artwork worker pass 0 instead of the collected hint left all 386 specs green, and replacing the default agent cooldown with 0 left the agents suite green. The worker test drives a throttled image agent through drain and asserts the persisted retry_at, and the cooldown test parks an agent that asked to be retried without naming a delay, which is what Last.fm does on every rate limit. * refactor(artwork): carry the external failure as an error, not a flag plus a trace field The retry delay was riding on ChainTrace, a diagnostic that gets persisted, while the very same signal — an external source faulted — already travelled by value as resolution.extError. That was two mechanisms for one idea, and it put control-flow state inside a serializable trace. resolution.extError and chainState.extErr become the error itself, so a caller checks err != nil for the fault and errors.AsType for the delay the provider asked for. The agent loops return that error last, per convention, and longerRetry keeps whichever failure wants the longer wait. ChainTrace goes back to holding only steps and no longer imports core/agents. * fix(artwork): check the resolve error before reading its resolution Reading res.extError before the err check was safe only because every error path in resolve returns a bare resolution{}; a future path returning a partly-filled one would have been read silently. The failure path now returns no delay explicitly. * test(artwork): assert the delay acquire reports, not just its downstream effect acquire's retry delay was only covered through the worker's persisted retry_at, one layer away from where the value is computed. Both outcomes are now pinned at the processor: a plain failure asks for nothing, a throttled provider's delay is passed through. * refactor: share the retry-seconds parse and drop the backoff deadline arithmetic The clamp-before-scaling invariant lived in two parsers and was independently re-tested in three files with the same magic number; a fix applied to one copy would have left the others wrapping a huge value down to a fraction of a second. It moves to agents.ParseRetryIn. The buffer tracked an absolute retryDeadline only to re-arm a timer that was already armed for the same instant; a backingOff flag says the same thing without the arithmetic. The plugin token regex now carries its capability in the pattern instead of capturing and comparing, so another capability's token in the same message cannot mask it. resolution.extError becomes extErr, matching its chainState counterpart. * fix(agents): keep the longer cooldown when parks overlap Calls to one agent overlap, so a short cooldown could land after a long one started and cut it short. park now keeps whichever deadline is later, matching the rule longerRetry already applies on the artwork side. No in-tree provider can currently produce two different delays for the same agent, so this is hardening rather than a fix for observed behaviour. * fix(agents): parse the retry delay at a fixed width strconv.Atoi parses into the native int, so on the 32-bit targets we ship (linux/386, windows/386, three ARM variants) a delay above MaxInt32 seconds overflowed and became unspecified instead of being capped. No provider sends a 68-year delay, so this is not user-visible, but the overflow tests asserted the cap and would have failed on those architectures, where tests never run. * fix(plugins): anchor the retry_later regex to a word boundary Prevents a superstring like useragent(retry_later) from matching the agent capability token. --- adapters/lastfm/agent.go | 3 +- adapters/lastfm/agent_test.go | 19 +++ adapters/lastfm/client.go | 11 +- adapters/listenbrainz/agent_test.go | 13 ++ adapters/listenbrainz/client.go | 17 +++ adapters/listenbrainz/client_test.go | 73 +++++++++ core/agents/agents.go | 144 +++++++++++++----- core/agents/agents_test.go | 126 ++++++++++++++- core/agents/interfaces.go | 51 ++++++- core/agents/interfaces_test.go | 47 ++++-- core/agents/song_test.go | 27 ++++ core/artwork/agent_images.go | 36 +++-- core/artwork/agent_images_test.go | 65 +++++--- core/artwork/processor.go | 32 ++-- core/artwork/processor_test.go | 57 ++++--- core/artwork/resolve.go | 49 +++--- core/artwork/resolve_test.go | 46 +++--- core/artwork/worker.go | 9 +- core/artwork/worker_soak_test.go | 2 +- core/artwork/worker_test.go | 33 ++++ core/external/provider.go | 63 +++++--- core/external/provider_similarsongs.go | 2 +- core/external/provider_topsongs_test.go | 15 ++ .../external/provider_updatealbuminfo_test.go | 22 +++ .../provider_updateartistinfo_test.go | 19 +++ core/scrobbler/buffered_scrobbler.go | 47 +++--- core/scrobbler/buffered_scrobbler_test.go | 122 ++++++++++++++- core/scrobbler/interfaces.go | 4 +- core/scrobbler/play_tracker_test.go | 37 +++-- plugins/capabilities.go | 29 +++- plugins/capabilities/metadata_agent.go | 12 ++ plugins/metadata_agent.go | 3 + plugins/metadata_agent_test.go | 26 ++++ plugins/pdk/go/metadata/metadata.go | 12 ++ plugins/pdk/go/metadata/metadata_stub.go | 12 ++ .../rust/nd-pdk-capabilities/src/metadata.rs | 5 + plugins/scrobbler_adapter.go | 5 +- plugins/scrobbler_adapter_test.go | 22 +++ 38 files changed, 1085 insertions(+), 232 deletions(-) create mode 100644 core/agents/song_test.go diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 863868b5a..7f005db1a 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -405,7 +405,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) return errors.Join(err, scrobbler.ErrRetryLater) } - if lfErr.Code == 11 || lfErr.Code == 16 { + // 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client. + if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) { return errors.Join(err, scrobbler.ErrRetryLater) } return errors.Join(err, scrobbler.ErrUnrecoverable) diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94024b9ab..ce81e0916 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -100,6 +100,15 @@ var _ = Describe("lastfmAgent", func() { Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) + + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + _, err := agent.GetArtistBiography(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) }) Describe("Language Fallback", func() { @@ -497,6 +506,16 @@ var _ = Describe("lastfmAgent", func() { Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrRetryLater on http errors", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 726df1360..e468aa638 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -14,11 +15,15 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" + // errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200 + // and no rate-limit headers, so the body code is the only signal. + errCodeRateLimit = 29 ) type lastFMError struct { @@ -225,7 +230,11 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return nil, jsonErr } if response.Error != 0 { - return &response, &lastFMError{Code: response.Error, Message: response.Message} + var err error = &lastFMError{Code: response.Error, Message: response.Message} + if response.Error == errCodeRateLimit { + err = errors.Join(err, &agents.RetryLaterError{}) + } + return &response, err } return &response, nil diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index 2c4668296..a201b7c3a 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -164,6 +164,19 @@ var _ = Describe("listenBrainzAgent", func() { err := agent.Scrobble(ctx, "user-1", sc) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + It("keeps a 429 scrobble for retry and carries the delay", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)), + } + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) }) Describe("GetArtistUrl", func() { diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 708f02f28..aae4fb51d 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -13,6 +13,7 @@ import ( "slices" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) @@ -21,6 +22,12 @@ const ( labsBase = "https://labs.api.listenbrainz.org/" ) +// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In +// (delta-seconds) on every response, including the 429, and never Retry-After. +func retryLaterErr(h http.Header) *agents.RetryLaterError { + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))} +} + var ( ErrorNotFound = errors.New("listenbrainz: not found") ) @@ -174,6 +181,9 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en } defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var response listenBrainzResponse @@ -185,6 +195,10 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en return nil, jsonErr } if response.Code != 0 && response.Code != 200 { + // LB also reports rate limiting as a body code, not only as an HTTP status. + if response.Code == http.StatusTooManyRequests { + return &response, retryLaterErr(resp.Header) + } return &response, &listenBrainzError{Code: response.Code, Message: response.Error} } @@ -211,6 +225,9 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint // On a 200 code, there is no code. Decode using using error message if it exists if resp.StatusCode != 200 { defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var lbzError lbzHttpError diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 319cf01ab..ec0b0ac11 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" + "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -461,4 +465,73 @@ var _ = Describe("client", func() { })) }) }) + + Describe("rate limiting", func() { + It("returns RetryLaterError with the header delay on 429", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(3 * time.Second)) + }) + + It("returns RetryLaterError with zero delay when no header is present", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(BeZero()) + }) + + DescribeTable("caps absurd header values at one hour", + func(header string) { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{header}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(Equal(time.Hour)) + }, + Entry("a large value", "999999"), + Entry("a huge value", "99999999999"), + // Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("a value that overflows int64 nanoseconds", "18446744074"), + ) + + It("maps a body-level 429 sent with a non-429 status", func() { + httpClient.Res = http.Response{ + StatusCode: 200, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) + + It("returns RetryLaterError on a 429 from makeGenericRequest", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/core/agents/agents.go b/core/agents/agents.go index 348f7d4e7..ac623951c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -1,9 +1,12 @@ package agents import ( + "cmp" "context" + "errors" "slices" "strings" + "sync" "time" "github.com/navidrome/navidrome/conf" @@ -22,11 +25,43 @@ type PluginLoader interface { LoadMediaAgent(name string) (Interface, bool) } +// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific +// RetryIn duration. +const agentCooldown = time.Minute + +// errUnsupported marks an agent that does not implement the requested method: it never ran, +// so it neither answered nor throttled. +var errUnsupported = errors.New("agent does not support this method") + // Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order // until one returns valid data. type Agents struct { ds model.DataStore pluginLoader PluginLoader + cooldowns cooldowns +} + +// cooldowns remembers, across dispatches, which agents asked to be left alone and until when. +type cooldowns struct { + mu sync.RWMutex + until map[string]time.Time +} + +func (c *cooldowns) active(name string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return time.Now().Before(c.until[name]) +} + +// park keeps whichever deadline is later, so a call still in flight when a longer cooldown +// starts cannot cut it short when it finally answers. +func (c *cooldowns) park(name string, d time.Duration) { + until := time.Now().Add(d) + c.mu.Lock() + defer c.mu.Unlock() + if until.After(c.until[name]) { + c.until[name] = until + } } // GetAgents returns the singleton instance of Agents @@ -41,6 +76,7 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { return &Agents{ ds: ds, pluginLoader: pluginLoader, + cooldowns: cooldowns{until: map[string]time.Time{}}, } } @@ -171,7 +207,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistMBIDRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistMBID(ctx, id, name) }) @@ -188,7 +224,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistURLRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistURL(ctx, id, name, mbid) }) @@ -205,7 +241,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistBiographyRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistBiography(ctx, id, name, mbid) }) @@ -224,7 +260,11 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) start := time.Now() + attempts := newAttempts(&a.cooldowns) for _, enabledAgent := range a.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := a.getAgent(enabledAgent) if ag == nil { continue @@ -237,6 +277,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l continue } similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) + attempts.record(enabledAgent.name, err) if len(similar) > 0 && err == nil { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) @@ -246,7 +287,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l return similar, err } } - return nil, ErrNotFound + return nil, attempts.noResultErr() } func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { @@ -260,7 +301,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(ArtistImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistImages(ctx, id, name, mbid) }) @@ -281,7 +322,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) }) @@ -295,7 +336,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { retriever, ok := ag.(AlbumInfoRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumInfo(ctx, name, artist, mbid) }) @@ -309,7 +350,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(AlbumImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumImages(ctx, name, artist, mbid) }) @@ -320,7 +361,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByTrackRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) }) @@ -331,7 +372,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByAlbumRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) }) @@ -349,16 +390,61 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByArtistRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count) }) } -func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { +// agentAttempts tallies what the enabled agents did in one dispatch. +type agentAttempts struct { + cooldowns *cooldowns + throttled bool + answered bool +} + +func newAttempts(c *cooldowns) agentAttempts { + return agentAttempts{cooldowns: c} +} + +// skip reports whether name is still cooling down, counting it as throttled for this dispatch. +func (t *agentAttempts) skip(name string) bool { + if !t.cooldowns.active(name) { + return false + } + t.throttled = true + return true +} + +// record files one agent's outcome, parking it when it asked to be retried later. +func (t *agentAttempts) record(name string, err error) { + switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); { + case errors.Is(err, errUnsupported): + case isRetryLater: + t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown)) + t.throttled = true + default: + t.answered = true + } +} + +// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss. +func (t *agentAttempts) noResultErr() error { + if t.throttled && !t.answered { + return ErrRetryLater + } + return ErrNotFound +} + +// callAgent tries each enabled agent in order until found reports a usable result. +func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) { var zero T start := time.Now() + attempts := newAttempts(&agents.cooldowns) for _, enabledAgent := range agents.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := agents.getAgent(enabledAgent) if ag == nil { continue @@ -367,41 +453,29 @@ func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodNa break } result, err := fn(ag) + attempts.record(enabledAgent.name, err) if err != nil { log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) continue } - if result != zero { + if found(result) { log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) return result, nil } } - return zero, ErrNotFound + return zero, attempts.noResultErr() +} + +func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { + return callAgent(ctx, agents, methodName, fn, func(result T) bool { + var zero T + return result != zero + }) } func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) { - start := time.Now() - for _, enabledAgent := range agents.getEnabledAgentNames() { - ag := agents.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } - results, err := fn(ag) - if err != nil { - log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) - continue - } - - if len(results) > 0 { - log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start)) - return results, nil - } - } - return nil, ErrNotFound + return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 }) } var _ Interface = (*Agents)(nil) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e79b2b3c8..35ebf18d8 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "context" "errors" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -14,6 +15,29 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("cooldowns", func() { + // Calls to one agent overlap, so a short cooldown can land after a long one started. + It("keeps the longer deadline when a shorter park lands after it", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Hour) + c.park("fake", time.Millisecond) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) + + It("extends the deadline when the later park is longer", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Millisecond) + c.park("fake", time.Hour) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) +}) + var _ = Describe("Agents", func() { var ctx context.Context var cancel context.CancelFunc @@ -160,6 +184,102 @@ var _ = Describe("Agents", func() { }) }) + Describe("cooldown", func() { + It("skips an agent that returned RetryLaterError until the deadline", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + // Immediately after: agent is skipped, not called + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + // Providers that throttle without saying for how long (Last.fm sends no delay at all) + // must still be parked, or the aggregate keeps calling them on every request. + It("parks an agent that asked to be retried without a delay", func() { + mock.Err = ErrRetryLater + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("calls the agent again once the cooldown expires", func() { + mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + Eventually(func() (string, error) { + return ag.GetArtistBiography(ctx, "id", "name", "mbid") + }, 5*time.Second, 10*time.Millisecond).Should(Equal("bio")) + }) + + It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() { + mock.Err = errors.New("boom") + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + // ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive + // answer from any other agent is an answer, throttled peer or not. + It("returns ErrNotFound when another agent answered with a definitive miss", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + + // The cooldown was still recorded for the throttled agent + calls := mock.Calls + _, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + }) + + It("returns ErrNotFound when another agent answered with an empty slice", func() { + empty := &testImageAgent{Name: "emptyImages"} + Register("emptyImages", func(model.DataStore) Interface { return empty }) + conf.Server.Agents = "fake,emptyImages" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + }) + Describe("GetArtistImages", func() { It("returns on first match", func() { Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{ @@ -423,8 +543,9 @@ var _ = Describe("Agents", func() { }) type mockAgent struct { - Args []any - Err error + Args []any + Err error + Calls int } func (a *mockAgent) AgentName() string { @@ -449,6 +570,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { a.Args = []any{id, name, mbid} + a.Calls++ if a.Err != nil { return "", a.Err } diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 7fc5de361..9225a0442 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -3,6 +3,9 @@ package agents import ( "context" "errors" + "fmt" + "strconv" + "time" "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" @@ -52,11 +55,49 @@ func (s Song) Equals(other Song) bool { return h1 == h2 } -var ( - // ErrNotFound means the provider answered and had nothing. Return the underlying error - // for a fault instead, or callers that back off on faults will treat it as definitive. - ErrNotFound = errors.New("not found") -) +// ErrNotFound means the provider answered and had nothing. Return the underlying error +// for a fault instead, or callers that back off on faults will treat it as definitive. +var ErrNotFound = errors.New("not found") + +// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable +// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and +// errors.AsType[*RetryLaterError] match it and every delay-carrying variant. +// Treat it as immutable; build a new RetryLaterError to name a delay. +var ErrRetryLater = &RetryLaterError{} + +// RetryLaterError asks callers to back off, optionally for the delay the provider requested. +type RetryLaterError struct { + RetryIn time.Duration +} + +func (e *RetryLaterError) Error() string { + if e.RetryIn > 0 { + return fmt.Sprintf("retry later (in %s)", e.RetryIn) + } + return "retry later" +} + +func (e *RetryLaterError) Is(target error) bool { + _, ok := target.(*RetryLaterError) + return ok +} + +// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely. +const MaxRetryIn = time.Hour +const maxRetryInSeconds = int(MaxRetryIn / time.Second) + +// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token. +// Anything unparseable or non-positive means unspecified. +func ParseRetryIn(seconds string) time.Duration { + // Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds, + // turning "wait an age" into a fraction of a second. Parse at a fixed width so the + // cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first. + secs, err := strconv.ParseInt(seconds, 10, 64) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second +} // AlbumInfoRetriever provides album info (no images) type AlbumInfoRetriever interface { diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go index c13710a38..6acbc545d 100644 --- a/core/agents/interfaces_test.go +++ b/core/agents/interfaces_test.go @@ -1,27 +1,42 @@ -package agents +package agents_test import ( + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} - It("true for identical songs incl Artists", func() { - Expect(base.Equals(base)).To(BeTrue()) +var _ = Describe("RetryLaterError", func() { + It("matches the ErrRetryLater sentinel via errors.Is", func() { + err := &agents.RetryLaterError{RetryIn: 30 * time.Second} + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when Artists differ", func() { - other := base - other.Artists = []Artist{{ID: "y", Name: "B"}} - Expect(base.Equals(other)).To(BeFalse()) + + It("matches through errors.Join and wrapping", func() { + err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{})) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when a scalar differs", func() { - other := base - other.Name = "T" - Expect(base.Equals(other)).To(BeFalse()) + + It("exposes the delay through the wrapped error", func() { + err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second}) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(42 * time.Second)) }) - It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S"} - Expect(a.Equals(a)).To(BeTrue()) + + It("matches the sentinel too, reporting no delay", func() { + retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(BeZero()) + }) + + It("is the same sentinel as scrobbler.ErrRetryLater", func() { + Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue()) + Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue()) }) }) diff --git a/core/agents/song_test.go b/core/agents/song_test.go new file mode 100644 index 000000000..c13710a38 --- /dev/null +++ b/core/agents/song_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 95596dabc..985abacd7 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "io" "net/url" @@ -41,22 +42,36 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL { return best } -// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no +// longerRetry keeps whichever external failure asks for the longer wait, so one provider's +// short delay cannot shorten another's. +func longerRetry(a, b error) error { + if a == nil { + return b + } + var ra, rb *agents.RetryLaterError + if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) { + return b + } + return a +} + +// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no // agent succeeded and at least one failed transiently. -func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) { // Synthetic artists would otherwise get an unrelated agent result assigned to them. switch ar.ID { case consts.UnknownArtistID, consts.VariousArtistsID: traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) - return nil, "", false + return nil, "", nil } name := externalName(ar.Name) imageAgents := ag.ArtistImageAgents() if len(imageAgents) == 0 { traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "no enabled agent provides artist images"}) - return nil, "", false + return nil, "", nil } + var extErr error for _, a := range imageAgents { reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) @@ -71,10 +86,10 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar }) recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) } } @@ -82,14 +97,15 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } // fetchAlbumImage is the album counterpart of fetchArtistImage. -func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) { name, artist := externalName(al.Name), externalName(al.AlbumArtist) imageAgents := ag.AlbumImageAgents() if len(imageAgents) == 0 { traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "no enabled agent provides album images"}) - return nil, "", false + return nil, "", nil } + var extErr error for _, a := range imageAgents { reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) @@ -104,10 +120,10 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m }) recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) } } diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 60a34352d..d0c2429b0 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -2,11 +2,13 @@ package artwork import ( "context" + "errors" "io" "net/http" "net/http/httptest" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -153,11 +155,11 @@ var _ = Describe("agent images", func() { a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} ag := imageAgents(a) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentA")) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) }) It("skips the external lookup for synthetic artists", func() { @@ -165,10 +167,10 @@ var _ = Describe("agent images", func() { ag := imageAgents(a) for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) } Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") }) @@ -177,9 +179,9 @@ var _ = Describe("agent images", func() { ag := imageAgents() t := &ChainTrace{} - r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) + r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, Detail: "no enabled agent provides artist images"}}), "a configured external token must never be silently absent from the chain") @@ -211,11 +213,11 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentB")) - Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error") + Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error") Expect(a.artistCalls).To(Equal(1)) Expect(b.artistCalls).To(Equal(1)) }) @@ -225,20 +227,43 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure") + Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure") }) - It("reports extErr when one agent fails transiently and the rest find nothing", func() { + It("reports an error when one agent fails transiently and the rest find nothing", func() { a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} ag := imageAgents(a, b) - r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) + }) + + // The worker reschedules on this delay, so it is only honored if the agent loop + // returns it. Two throttled agents: the longest wait is the one that must survive. + It("returns the longest retry delay the providers asked for", func() { + a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}} + b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}} + ag := imageAgents(a, b) + + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(10 * time.Second)) + }) + + It("returns no delay when the provider did not ask for one", func() { + ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")}) + + _, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(err).To(HaveOccurred()) + _, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle") }) }) @@ -247,11 +272,11 @@ var _ = Describe("agent images", func() { a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} ag := imageAgents(a) - r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) + r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentA")) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(a.albumCalls).To(Equal(1)) }) @@ -259,21 +284,21 @@ var _ = Describe("agent images", func() { ag := imageAgents() t := &ChainTrace{} - r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) + r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, Detail: "no enabled agent provides album images"}}), "a configured external token must never be silently absent from the chain") }) - It("reports extErr when the only agent fails transiently", func() { + It("reports an error when the only agent fails transiently", func() { a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} ag := imageAgents(a) - r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) + r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) }) }) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index fdb28189a..cf2176775 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/core/artwork/dominant" "github.com/navidrome/navidrome/core/artwork/thumbhash" @@ -80,7 +81,7 @@ type processor struct { // acquire resolves one queue item end to end: find an image, hash/decode/ // blurhash it, place its bytes, and persist the resulting state. -func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) { +func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { repo := p.ds.Artwork(ctx) start := time.Now() defer func() { @@ -92,10 +93,13 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "resolve", err) log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, 0 + } + if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok { + retryIn = retry.RetryIn } if res.reader == nil { - if res.extError || res.localError { + if res.extErr != nil || res.localError { // A fault is not a definitive "no image": never settle absent, keep serving old state. // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. if t := traceFrom(ctx); len(t.Steps()) == 0 { @@ -106,10 +110,10 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) } log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", - "kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError) - return outcomeFailed, nil + "kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError) + return outcomeFailed, nil, retryIn } - return writeAbsent(ctx, repo, item), nil + return writeAbsent(ctx, repo, item), nil, 0 } defer res.reader.Close() @@ -118,7 +122,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "read", err) log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) @@ -128,7 +132,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "hash", err) log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) @@ -152,14 +156,14 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "decode", err) log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) default: traceStage(ctx, "lookup", err) log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } art.SizeBytes = int64(len(data)) @@ -167,15 +171,15 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "store", err) log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } got = &acquired{ia: ia, mime: art.Mime, data: data} - if res.extError { + if res.extErr != nil { log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", "kind", item.ItemKind, "id", item.ItemID, "source", res.source) - return outcomeFoundStale, got + return outcomeFoundStale, got, retryIn } - return outcomeFound, got + return outcomeFound, got, retryIn } // persist places the bytes and commits the rows referencing them, excluding Prune for that diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 0ca5a308e..554ca08dc 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -90,7 +90,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) @@ -127,7 +127,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) Expect(out).To(Equal(outcomeFound)) Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") @@ -141,7 +141,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) Expect(out).To(Equal(outcomeAbsent)) Expect(lock.locks).To(BeZero()) }) @@ -153,7 +153,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = nil - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) @@ -176,7 +176,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al3", Name: "Album"}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(out).To(Equal(outcomeAbsent)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) @@ -197,7 +197,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) @@ -222,7 +222,7 @@ var _ = Describe("processor.acquire", func() { DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) @@ -249,7 +249,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} trace := &ChainTrace{} - out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) + out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) Expect(out).To(Equal(outcomeFailed)) steps := trace.Steps() @@ -265,13 +265,26 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("failed-on-extError: reports the delay a throttled provider asked for", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4r", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}}) + + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"}) + Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(Equal(42 * time.Second)) + }) + It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -283,7 +296,7 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) @@ -300,7 +313,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary) @@ -320,7 +333,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary) @@ -338,7 +351,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary) @@ -357,7 +370,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) @@ -382,7 +395,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, }) - out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(out1).To(Equal(outcomeFound)) ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -392,7 +405,7 @@ var _ = Describe("processor.acquire", func() { poisoned.BlurHash = "SENTINEL" artRepo.Data[ia1.Hash] = poisoned - out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) Expect(out2).To(Equal(outcomeFound)) ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -422,7 +435,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} - outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) + outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) Expect(outN).To(Equal(outcomeFound)) iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -436,7 +449,7 @@ var _ = Describe("processor.acquire", func() { artRepo.Data[iaA.Hash] = poisoned folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} - outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) + outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) Expect(outN).To(Equal(outcomeFound)) iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -467,7 +480,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} ds.MockedRadio = radioRepo - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) @@ -488,7 +501,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} ds.MockedRadio = radioRepo - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) Expect(out).To(Equal(outcomeFailed)) _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) @@ -554,7 +567,7 @@ var _ = Describe("processor.acquire", func() { Expect(err).ToNot(HaveOccurred()) Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) Expect(out).To(Equal(outcomeFound)) upgraded, err := artRepo.GetImage(hash) @@ -574,7 +587,7 @@ var _ = Describe("processor.acquire", func() { Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) proc.store = NewImageStore(blockedRoot) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 6663679fa..e7a2d3765 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -25,9 +25,9 @@ type resolution struct { source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath - // external source errored/timed out. With no reader it forces failed (never absent); - // on a hit a higher-priority external step failed—serve this, but retry later. - extError bool + // a faulted external source, carrying the provider's requested delay when it named one. + // With no reader it forces failed (never absent); on a hit, serve this but retry later. + extErr error // a local source that should have been readable wasn't. With no reader it forces failed, // so a transient I/O fault never records absent. localError bool @@ -36,14 +36,15 @@ type resolution struct { // chainState carries what a priority walk has seen so far. A hit takes extErr with it so a // transient external failure still retries; localErr is dropped, as the scanner re-lists changes. type chainState struct { - extErr, localErr bool - trace *ChainTrace // nil only where no caller attached one + extErr error + localErr bool + trace *ChainTrace // nil only where no caller attached one } // try stamps the accumulated external failure onto a hit, and records the miss otherwise. func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { if ok { - res.extError = c.extErr + res.extErr = c.extErr c.record(candidate, OutcomeHit, res.sourcePath) return res, true } @@ -62,7 +63,7 @@ func (c *chainState) record(candidate string, out Outcome, detail string) { // exhausted is the outcome when no source in the chain yielded an image. func (c *chainState) exhausted() resolution { - return resolution{extError: c.extErr, localError: c.localErr} + return resolution{extErr: c.extErr, localError: c.localErr} } // externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. @@ -181,16 +182,16 @@ func chainFetchesExternal(priority string) bool { // Album and artist fetches stop here when the resolver is local-only, rather than at each point in // the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. -func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) } -func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) } @@ -223,10 +224,10 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution return res, nil } case pattern == externalCandidate: - if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { + if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil { return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if isErr { - chain.extErr = true + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case len(imgFiles) > 0: res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) @@ -285,10 +286,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } switch { case pattern == externalCandidate: - if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { + if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil { return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if isErr { - chain.extErr = true + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case pattern == "image-folder": res, ok := resolveArtistImageFolder(ar) @@ -332,7 +333,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso return resolution{}, err } - var extErr bool + var extErr error for _, src := range []struct{ path, source string }{ {pl.UploadedImagePath(), "upload"}, {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, @@ -366,7 +367,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { return res, nil } else if err != nil { - extErr = true + extErr = longerRetry(extErr, err) // Record it here with its detail: once album sampling adds its own steps, the processor's // empty-trace fallback no longer fires, and the error that forced the retry would be lost. traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) @@ -389,8 +390,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } continue } - if res.extError { - extErr = true + if res.extErr != nil { + extErr = longerRetry(extErr, res.extErr) } if res.reader == nil { continue @@ -409,7 +410,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if tileErr != nil { return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) } - return resolution{extError: extErr}, nil + return resolution{extErr: extErr}, nil } // Grow to 4 tiles by repeating what we have. switch len(tiles) { @@ -420,9 +421,9 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } grid, err := assembleTiles(tiles) if err != nil { - return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error + return resolution{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error } - return resolution{reader: grid, source: "generated", extError: extErr}, nil + return resolution{reader: grid, source: "generated", extErr: extErr}, nil } // resolveRadio serves only an uploaded image; there is no fallback. diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 236e76b9b..402a11363 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -100,7 +100,7 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("embedded")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when the track has no cover art", func() { @@ -111,7 +111,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when media file cover art is disabled", func() { @@ -154,7 +154,7 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("folder")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("falls back to embedded art when no folder image matches", func() { @@ -172,7 +172,7 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr when the external source errors without being not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al3", Name: "Album"}, @@ -182,10 +182,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, @@ -195,10 +195,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) - It("carries extError onto a fallback folder hit after a transient external failure", func() { + It("carries extErr onto a fallback folder hit after a transient external failure", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", @@ -214,10 +214,10 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", @@ -233,7 +233,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -250,7 +250,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -298,7 +298,7 @@ var _ = Describe("resolveItem", func() { Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr when the external source errors without being not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) @@ -308,10 +308,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) @@ -321,7 +321,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -338,7 +338,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -516,7 +516,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) @@ -537,7 +537,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) steps := trace.Steps() var m3u *TraceStep @@ -562,7 +562,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { @@ -582,7 +582,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("generated")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) // A local resolver holds no agents: reaching the external branch would panic, not degrade. @@ -594,7 +594,7 @@ var _ = Describe("resolveItem", func() { res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one") + Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one") }) // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. @@ -642,7 +642,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) It("yields an empty resolution when no album has art", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 0358708c0..bea478aa5 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -241,7 +241,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) trace := &ChainTrace{} ctx = withTrace(ctx, trace) - out, got := w.proc.acquire(ctx, item) + out, got, retryIn := w.proc.acquire(ctx, item) queue := w.proc.ds.ArtworkQueue(ctx) switch out { @@ -252,7 +252,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } case outcomeFoundStale, outcomeFailed: - retryAt := time.Now().Add(backoff(item.Attempts)) + retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn)) encoded := trace.encode("") if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its @@ -341,3 +341,8 @@ func backoffFor(attempts int, jitter float64) time.Duration { func backoff(attempts int) time.Duration { return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive } + +// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer. +func retryDelay(attempts int, hint time.Duration) time.Duration { + return max(backoff(attempts), hint) +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index eb7346102..803cc2dfe 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -95,7 +95,7 @@ var _ = Describe("Worker soak", func() { start := time.Now() for i := range soakCycles { it := items[i%len(items)] - out, _ := proc.acquire(context.Background(), it) + out, _, _ := proc.acquire(context.Background(), it) // Read-back exercises the surfaces a caller would use after acquisition. if out == outcomeFound { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 248e400e1..b0ef665fc 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -245,6 +245,23 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) + // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. + const askedFor = 90 * time.Minute + imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al9") + Expect(it).ToNot(BeNil()) + Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute)) + }) + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -911,3 +928,19 @@ var _ = Describe("backoff", func() { } }) }) + +var _ = Describe("retryDelay", func() { + It("uses the backoff schedule when the provider asked for nothing", func() { + d := retryDelay(0, 0) + Expect(d).To(BeNumerically(">=", 3*time.Second)) + Expect(d).To(BeNumerically("<=", 7*time.Second)) + }) + + It("waits the provider's delay when it is longer than the backoff", func() { + Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour)) + }) + + It("keeps the backoff when it is longer than the provider's delay", func() { + Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second)) + }) +}) diff --git a/core/external/provider.go b/core/external/provider.go index 5c46dc644..3a3f4bd46 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -148,7 +148,8 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl start := time.Now() albumName := album.Name() info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if errors.Is(err, agents.ErrNotFound) { + // Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call. + if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) { return album, nil } if err != nil { @@ -253,28 +254,37 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au start := time.Now() // Get MBID first, if it is not yet available artistName := artist.Name() + var mbidErr error if artist.MbzArtistID == "" { mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) + mbidErr = err if mbid != "" && err == nil { artist.MbzArtistID = mbid } } - // Call all registered agents and collect information + // Call all registered agents and collect information. The group carries no context, so a + // returned error does not cancel the siblings; only throttling is reported back. g := errgroup.Group{} g.SetLimit(2) - g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) - _ = g.Wait() + g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) }) + g.Go(func() error { + return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true)) + }) + throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater) if utils.IsCtxDone(ctx) { log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = new(time.Now()) + // A throttled round keeps the previous timestamp, so the next call retries instead of + // serving an empty cache entry for the whole TTL. + if !throttled { + artist.ExternalInfoUpdatedAt = new(time.Now()) + } err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, @@ -334,8 +344,9 @@ func (e *provider) TopSongs(ctx context.Context, artistName, id string, count in songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) if err != nil { switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "TopSongs not found", "name", artistName) + // Throttled is not an answer, but the caller keeps the empty 200 it got before. + case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater): + log.Trace(ctx, "TopSongs not found", "name", artistName, err) return nil, model.ErrNotFound case errors.Is(err, context.Canceled): log.Debug(ctx, "TopSongs call canceled", err) @@ -385,22 +396,33 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { - artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) - if err != nil { - return +// retryLaterOnly discards every failure the caller does not act on, so errgroup's +// first-error slot is reserved for the throttling signal. +func retryLaterOnly(err error) error { + if errors.Is(err, agents.ErrRetryLater) { + return err } - artist.ExternalUrl = artisURL + return nil } -func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error { + artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) + if err != nil { + return err + } + artist.ExternalUrl = artisURL + return nil +} + +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error { bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return + return err } bio = str.SanitizeText(bio) bio = strings.ReplaceAll(bio, "\n", " ") artist.Biography = strings.ReplaceAll(bio, "(retry_later[:seconds])` token, which is +// all a plugin fault carries back across the WASM boundary. The capability is part of the +// pattern, so another capability's token in the same message cannot mask this one. The leading +// \b keeps a superstring like `useragent(retry_later)` from matching `agent`. +var ( + agentRetryLaterRe = retryLaterRe("agent") + scrobblerRetryLaterRe = retryLaterRe("scrobbler") +) + +func retryLaterRe(capability string) *regexp.Regexp { + return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`) +} + +// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay. +func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) { + m := re.FindStringSubmatch(msg) + if m == nil { + return nil, false + } + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true +} diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 72cb1622f..8fec7f5a8 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -224,3 +224,15 @@ type SimilarSongsResponse struct { // Songs is the list of similar songs. Songs []types.SongRef `json:"songs"` } + +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 607926438..17062ba6b 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -56,6 +56,9 @@ func agentErr(err error) error { if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { return errors.Join(agents.ErrNotFound, err) } + if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok { + return errors.Join(retryLater, err) + } return err } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 2dc67d41c..a7a0aa8b8 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -5,6 +5,7 @@ package plugins import ( "errors" "fmt" + "time" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" @@ -31,6 +32,31 @@ var _ = Describe("agentErr", func() { Entry("a non-zero exit is a fault", errors.New("plugin call exited with code 1"), false), ) + + DescribeTable("agentErr retry-later", + func(msg string, wantDelay time.Duration) { + err := agentErr(errors.New(msg)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "agent(retry_later)", time.Duration(0)), + Entry("with seconds", "agent(retry_later:120)", 120*time.Second), + Entry("capped at 1h", "agent(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour), + ) + + It("leaves other plugin errors untouched", func() { + orig := errors.New("some plugin failure") + Expect(agentErr(orig)).To(Equal(orig)) + }) + + It("does not treat a superstring token as a throttle", func() { + orig := errors.New("useragent(retry_later)") + Expect(agentErr(orig)).To(Equal(orig)) + }) }) var _ = Describe("MetadataAgent", Ordered, func() { diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index bb0ae9620..57546352e 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -18,6 +18,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index 572eba4da..f979419a9 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -16,6 +16,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 38fcae9da..890e16954 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -24,6 +24,11 @@ pub type ArtistRef = nd_pdk_types::ArtistRef; #[deprecated(note = "use nd_pdk::types::SongRef")] pub type SongRef = nd_pdk_types::SongRef; +/// MetadataAgentError represents an error type for metadata agent operations. +pub type MetadataAgentError = &'static str; +/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. +/// Append ":" inside the parentheses to request a specific delay. +pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)"; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index b3203a352..721f0d3fa 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -174,11 +174,12 @@ func mapScrobblerError(err error) error { return nil } errMsg := err.Error() + retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg) switch { case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): return scrobbler.ErrNotAuthorized - case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()): - return scrobbler.ErrRetryLater + case isRetryLater: + return retryLater case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): return scrobbler.ErrUnrecoverable default: diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 56a452742..3efd5d1b1 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -7,6 +7,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -362,4 +363,25 @@ var _ = Describe("mapScrobblerError", func() { err := mapScrobblerError(errors.New("some unknown error")) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + DescribeTable("mapScrobblerError retry-later", + func(msg string, wantDelay time.Duration) { + err := mapScrobblerError(errors.New(msg)) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "scrobbler(retry_later)", time.Duration(0)), + Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second), + Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour), + Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second), + ) + + It("still maps unknown errors to unrecoverable", func() { + err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage")) + Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue()) + }) }) From b134f16fd5f95308700b1e66940fc4f495f80ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 11:11:35 -0400 Subject: [PATCH 04/27] feat(plugins): surface the valid agent names in logs and the Plugins UI (#5910) The agent name used in the `Agents` config option comes from the .ndp file name, not from the manifest. The Plugins UI showed the ID but never said what it was for, so renaming a plugin file silently breaks the config with only a Debug-level "Unknown agent ignored" line to go on. Add a caption under the ID in the Plugins UI, and list the accepted names alongside the rejected one in that log line. Related to navidrome/apple-music-plugin#14 --- core/agents/agents.go | 10 +++++++- core/agents/agents_test.go | 17 ++++++++++++++ resources/i18n/pt-br.json | 1 + ui/src/i18n/en.json | 1 + ui/src/plugin/InfoCard.jsx | 9 +++++++- ui/src/plugin/InfoCard.test.jsx | 41 +++++++++++++++++++++++++++++++++ ui/src/plugin/styles.js | 4 ++++ 7 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 ui/src/plugin/InfoCard.test.jsx diff --git a/core/agents/agents.go b/core/agents/agents.go index ac623951c..8ae3124ef 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "errors" + "maps" "slices" "strings" "sync" @@ -126,12 +127,19 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Debug("Unknown agent ignored", "name", name) + log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins)) } } return validAgents } +// availableAgentNames returns every name accepted by the Agents config option. +func availableAgentNames(plugins []string) []string { + names := append(slices.Collect(maps.Keys(Map)), plugins...) + slices.Sort(names) + return names +} + func (a *Agents) getAgent(ea enabledAgent) Interface { if ea.isPlugin { // Try to load WASM plugin agent (if plugin loader is available) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 35ebf18d8..6163c7f3c 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "context" "errors" + "slices" "time" "github.com/navidrome/navidrome/conf/configtest" @@ -91,6 +92,22 @@ var _ = Describe("Agents", func() { Expect(ags).ToNot(ContainElement("disabled")) }) + Describe("availableAgentNames", func() { + It("combines built-in agents with the given plugins", func() { + names := availableAgentNames([]string{"apple-music"}) + Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty")) + }) + + It("returns the names sorted", func() { + names := availableAgentNames([]string{"zz-plugin", "aa-plugin"}) + Expect(slices.IsSorted(names)).To(BeTrue()) + }) + + It("works when there are no plugins", func() { + Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName)) + }) + }) + Describe("GetArtistMBID", func() { It("returns on first match", func() { Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index a4ad6bc8c..ccc5f872b 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -394,6 +394,7 @@ "invalidJson": "A configuração deve ser um JSON válido" }, "messages": { + "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.", "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.", "clickPermissions": "Clique em uma permissão para ver detalhes", "noConfig": "Nenhuma configuração definida", diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 8823a6749..de96d47c0 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -397,6 +397,7 @@ "invalidJson": "Configuration must be valid JSON" }, "messages": { + "idHelp": "The plugin ID, derived from its file name. Use it when referencing this plugin in configuration options, such as Agents.", "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", "configValidationError": "Configuration validation failed:", "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", diff --git a/ui/src/plugin/InfoCard.jsx b/ui/src/plugin/InfoCard.jsx index 8fb6853fe..3a7bb24c7 100644 --- a/ui/src/plugin/InfoCard.jsx +++ b/ui/src/plugin/InfoCard.jsx @@ -123,6 +123,13 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( isSmall={isSmall} > {record.id} + + {translate('resources.plugin.messages.idHelp')} + {manifest?.name && ( @@ -201,7 +208,7 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( {translate('resources.plugin.messages.clickPermissions')} diff --git a/ui/src/plugin/InfoCard.test.jsx b/ui/src/plugin/InfoCard.test.jsx new file mode 100644 index 000000000..24fa48caa --- /dev/null +++ b/ui/src/plugin/InfoCard.test.jsx @@ -0,0 +1,41 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../common', () => ({ + DateField: ({ source }) => , +})) + +const { InfoCard } = await import('./InfoCard') + +const record = { + id: 'apple-music', + path: '/data/plugins/apple-music.ndp', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', +} + +const renderCard = () => + render( + key} + isSmall={false} + />, + ) + +describe('InfoCard', () => { + it('shows the plugin ID', () => { + renderCard() + expect(screen.getByText('apple-music')).toBeInTheDocument() + }) + + it('explains that the ID is the name used in config options', () => { + renderCard() + expect( + screen.getByText('resources.plugin.messages.idHelp'), + ).toBeInTheDocument() + }) +}) diff --git a/ui/src/plugin/styles.js b/ui/src/plugin/styles.js index 104d8bc0f..68c0be929 100644 --- a/ui/src/plugin/styles.js +++ b/ui/src/plugin/styles.js @@ -45,6 +45,10 @@ export const usePluginShowStyles = makeStyles( fontSize: '0.85rem', wordBreak: 'break-all', }, + fieldHelp: { + marginTop: theme.spacing(0.5), + display: 'block', + }, permissionsContainer: { display: 'flex', flexWrap: 'wrap', From 3867fab4da6ea7142bfc6374c94b34a708e03b4e Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Sun, 30 Aug 2026 20:51:14 +0530 Subject: [PATCH 05/27] fix(transcoding): report AAC streams as audio/aac instead of audio/mp4 (#5998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default AAC transcode emits raw ADTS (`ffmpeg ... -f adts -`), but the MIME table mapped `.aac` to `audio/mp4`. Clients that dispatch strictly on Content-Type could reject the stream because the declared container did not match the payload. `.m4a` and `.alac` stay on `audio/mp4`, since those really are MP4. Fixes #5958 Signed-off-by: Aditya Raj Singh Co-authored-by: Deluan Quintão --- resources/mime_types.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index 83abf2e5c..18a2c22b5 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -10,7 +10,7 @@ types: .ogg: audio/ogg .oga: audio/ogg .opus: audio/ogg - .aac: audio/mp4 + .aac: audio/aac .alac: audio/mp4 .m4a: audio/mp4 .m4b: audio/mp4 From ff033d8db62c9b045ae3dea54fc794a8114dd5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 12:43:15 -0400 Subject: [PATCH 06/27] chore(deps): upgrade to Go 1.27 (#5990) * build: upgrade to Go 1.27 Bumps the toolchain in go.mod, both golang base images in the Dockerfile, and the devcontainer VARIANT. CI needs no change, as the workflows resolve the version through go-version-file: go.mod. Tests, race tests, build and vet all pass on go1.27.0. * build: upgrade golangci-lint to v2.13.0 v2.13.0 is the first release built with Go 1.27, so it can lint a module whose go directive is 1.27. It also enables gosec's G404 on math/rand/v2, which flags the three rand.Shuffle call sites. Shuffle order is not a security decision, and the crypto-backed alternative in utils/random costs 25x and allocates per swap, so the call sites are annotated rather than the rule excluded, keeping G404 active for the cases where it would matter. * chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan * build: bump golangci-lint to v2.13.2 --------- Signed-off-by: Deluan --- .devcontainer/devcontainer.json | 2 +- Dockerfile | 4 ++-- Makefile | 2 +- core/external/provider_similarsongs.go | 1 + core/playback/queue.go | 1 + db/backup_test.go | 1 + go.mod | 2 +- 7 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c9e4ba2bf..efe965428 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.26", + "VARIANT": "1.27", // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24" diff --git a/Dockerfile b/Dockerfile index 847c19bf7..3d7a03f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -85,7 +85,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base +FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace diff --git a/Makefile b/Makefile index fa0d10475..81a609422 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.12.0 +GOLANGCI_LINT_VERSION ?= v2.13.2 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go index 7720c9349..4ab465b03 100644 --- a/core/external/provider_similarsongs.go +++ b/core/external/provider_similarsongs.go @@ -167,6 +167,7 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model. if len(matched) == 0 { matched = seeds } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) if len(matched) > count { matched = matched[:count] diff --git a/core/playback/queue.go b/core/playback/queue.go index d15eaad96..116db5fe6 100644 --- a/core/playback/queue.go +++ b/core/playback/queue.go @@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() { backupID = current.ID } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] }) var err error diff --git a/db/backup_test.go b/db/backup_test.go index 5e8f877e6..5d1bfc6e3 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -68,6 +68,7 @@ var _ = Describe("database backups", func() { timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) copy(timesShuffled, timesDecreasingChronologically) + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(timesShuffled), func(i, j int) { timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] }) diff --git a/go.mod b/go.mod index 93e84b052..a7b005c25 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.26 +go 1.27 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 From b3ecaddd9c49534f6612a12715a83bd457fe0a67 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 30 Aug 2026 11:28:32 -0400 Subject: [PATCH 07/27] chore(deps): update fscache and stream dependencies to latest versions Signed-off-by: Deluan --- go.mod | 7 ++----- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a7b005c25..cdf8fc699 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,6 @@ go 1.27 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 -// Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 -replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 - require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.4 @@ -16,8 +13,8 @@ require ( github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 - github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.5.0 + github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 + github.com/djherbis/stream v1.5.1 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 diff --git a/go.sum b/go.sum index c75b72dd0..d852285f1 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 h1:u3oDvM7pOIouwDGeIoMu9SYgSzVMtiSkOc+xywtKiJk= -github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -41,8 +39,10 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/stream v1.5.0 h1:+ewqpS/ndTmEiJRH142JyOpmmjnkTxdbJtqoAG5sg0Y= -github.com/djherbis/stream v1.5.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 h1:GNKxzBirvK9arfVRGVebhFYBp3tnOZG3nlIog6N5/6I= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= +github.com/djherbis/stream v1.5.1 h1:9AoCl0bnm7imWT2uUORqT8aLuTh+YllyynYpolpjIqY= +github.com/djherbis/stream v1.5.1/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= From b7ea48057682f564e6e8955de95a5115e99f959b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 30 Aug 2026 12:44:04 -0400 Subject: [PATCH 08/27] refactor: simplify return statements Signed-off-by: Deluan --- core/share.go | 3 +-- tests/mock_artist_repo.go | 3 +-- tests/mock_mediafile_repo.go | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/core/share.go b/core/share.go index 5a611c7f0..b2f32ba39 100644 --- a/core/share.go +++ b/core/share.go @@ -123,8 +123,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.Contents = str.TruncateRunes(s.Contents, 30, "...") - id, err = r.Persistable.Save(s) - return id, err + return r.Persistable.Save(s) } func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b71942208..af393129e 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -205,8 +205,7 @@ func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model. return nil, errors.New("unexpected error") } // Simple mock implementation - just return all artists for testing - allArtists, err := m.GetAll() - return allArtists, err + return m.GetAll() } var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 58e84785b..f18280fd5 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -319,8 +319,7 @@ func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (mod return nil, errors.New("unexpected error") } // Simple mock implementation - just return all media files for testing - allFiles, err := m.GetAll() - return allFiles, err + return m.GetAll() } // Cross-library move detection mock methods From aee8a705b1a579e328b88672826ee0d68da80a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 14:25:36 -0400 Subject: [PATCH 09/27] build(docker): upgrade Alpine base image to 3.22 (#6048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves both the xx-build toolchain stage and the final runtime image from Alpine 3.20 (past end of active support) to 3.22. 3.22 is the last release where ffmpeg is still 6.1.x — it jumps to 8.0 in 3.23 — so transcoding behavior is unchanged by this bump. Alpine 3.21 repackaged mesa, and from that release on `mpv` requires so:libEGL.so.1 and so:libgbm.so.1. Those pull mesa -> llvm20-libs (156MB) plus the gallium drivers (62MB), which took the image from 231MB/62MB compressed to 578MB/147MB. mesa-egl is the only provider of libEGL.so.1, and newer Alpine releases do not improve on this. Navidrome runs mpv headless for jukebox audio and never enters a video path, so this replaces libEGL/libgbm with generated no-op stubs and drops the mesa/LLVM stack. The stub symbol list is read from real mesa at build time and cross-compiled with the existing xx toolchain, so it adapts per architecture rather than being hardcoded. Verified with logging stubs across mp3/flac/ogg/opus/m4a/wav driving the default MPVCmdTemplate (pause, volume, time-pos seek, quit): zero calls into the stubbed libraries. The final stage now also runs mpv once at build time, so a broken stub fails the build instead of shipping. Image size: 323MB -> 325MB (84MB -> 86MB compressed). --- Dockerfile | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d7a03f5d..3427d6806 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build +FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -152,19 +152,52 @@ RUN xx-verify --static /out/navidrome* FROM scratch AS binary COPY --from=build /out / +######################################################################################################################## +### Build no-op stubs for mpv's video-output libraries +# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio. +# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls. +FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs +COPY --from=xx / / +RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm +ARG TARGETPLATFORM +RUN xx-apk add --no-cache musl-dev +RUN < /tmp/stub.c + test -s /tmp/stub.c + xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c + xx-verify /out/$so + done +EOT + ######################################################################################################################## ### Build Final Image -FROM alpine:3.20 AS final +FROM alpine:3.22 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" # Install runtime dependencies # - libwebp + symlinks: enables native WebP encoding via purego/dlopen +# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, +# otherwise the deleted bytes still ship in the image. RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ for lib in libwebp libwebpdemux libwebpmux; do \ target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ - done + done && \ + rm -rf /usr/lib/gallium-pipe /usr/lib/dri \ + /usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \ + /usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so* + +COPY --from=mpv-stubs /out/ /usr/lib/ +RUN mpv --no-video --ao=null --version > /dev/null # Copy navidrome binary (musl build for Docker, enables native libwebp) COPY --from=build-alpine /out/navidrome /app/ From 46041bb90884b818c8dbcaf851855cb1ee1aeccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 16:06:08 -0400 Subject: [PATCH 10/27] ci: cache the plugins test suite WASM compilation across runs (#6049) * ci: cache the plugins test suite WASM compilation across runs The 'Test Go code' job was dominated by a single package: 'plugins' took 541s of the 699s test step. The suite builds 25 test plugins as full-Go wasip1 modules of ~4.5MB each, and wazero must compile every one to machine code. Under -race that compiler work is instrumented, so each module costs around 11 seconds. The suite already shared a wazero compilation cache, but three things kept it from paying off. It lived in a fresh temp dir, so nothing survived the run. The default plugins.cachesize of 200MB was smaller than the 334MB the cache actually needs, so the purge evicted entries mid-run. And the wasm binaries embedded VCS stamps, so every commit produced different bytes and missed the content-addressed cache anyway. Point CacheFolder at plugins/testdata/.wazero-cache, raise the test cache limit past what the suite needs, build the test plugins with -buildvcs=false, and restore the directory in CI. Locally the package goes from 256s to 74s with the cache warm and the wasm rebuilt from scratch. * ci: key the WASM cache on what actually changes the modules The test plugins are separate Go modules with their own go.mod and go.sum; they reach the PDK through a replace directive and never read the root module. So the root go.sum has no bearing on the wasm bytes, and the wazero version it pins is already namespaced by wazero itself, which stores entries under wazero---. Keying on it only rotated the cache on every unrelated dependency bump. Drop it, and add the go.mod files that were missing: the test plugins' own and the PDK's. The root go.mod stays, since it selects the toolchain that builds the modules. * ci: key the WASM cache on the toolchain version, not go.mod Only the Go toolchain in the root go.mod affects the built wasm, but the file also changes on every direct dependency bump, which would rotate the cache for no reason. Take setup-go's go-version output instead: it is the version that actually built the modules. --- .github/workflows/pipeline.yml | 10 ++++++++++ .gitignore | 5 ++++- plugins/plugins_suite_test.go | 21 ++++++++------------- plugins/testdata/Makefile | 6 ++++-- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index b91c19505..012e3a9c3 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -131,12 +131,22 @@ jobs: uses: actions/checkout@v7 - uses: actions/setup-go@v6 + id: setup-go with: go-version-file: go.mod - name: Download dependencies run: go mod download + # Without this, the plugins suite recompiles every test plugin WASM module, + # which dominates the job runtime under -race. + - name: Cache the plugins test suite WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + - name: Test run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v diff --git a/.gitignore b/.gitignore index 6459ded9b..810074e72 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ go.work* .playwright-mcp/ # Temp benchmark files -zz_*_test.go \ No newline at end of file +zz_*_test.go + +# wazero compilation cache for the plugins test suite +/plugins/testdata/.wazero-cache/ diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 77956e2f9..c8585f2c1 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -25,7 +25,10 @@ import ( . "github.com/onsi/gomega" ) -const testDataDir = "plugins/testdata" +const ( + testDataDir = "plugins/testdata" + wazeroCacheDir = ".wazero-cache" +) // Shared test state initialized in BeforeSuite var ( @@ -38,18 +41,10 @@ func TestPlugins(t *testing.T) { tests.Init(t, false) buildTestPlugins(t, testDataDir) - // Create a shared wazero compilation cache directory. - // All test managers will point CacheFolder here so that WASM compilation - // is done once per binary and then reused from disk cache. - sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*") - if err != nil { - t.Fatalf("Failed to create shared cache dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(sharedCacheDir) }) - - // Set CacheFolder globally so all tests (including those using - // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) + // Set globally so tests using configtest.SetupConfig inherit it. The cache + // persists between runs; entries are content-addressed, so a stale one only misses. + conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir)) + conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index d53f2aaee..758497095 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,6 +10,7 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) + rm -rf .wazero-cache # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) @@ -22,10 +23,11 @@ PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) @rm -f plugin.wasm @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp -# Build the wasm binary +# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so +# the test suite's wazero compilation cache still hits after a rebuild. %.wasm: %/*.go %/go.mod $(PDK_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ . + cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ . endif \ No newline at end of file From a2de8e61efe82ec1d81d67d985026c083ad541fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 16:57:40 -0400 Subject: [PATCH 11/27] ci: run the plugins test suite in parallel processes (#6051) * test(plugins): make the suite safe to run in parallel processes Two things broke when the suite ran across several Ginkgo processes. buildTestPlugins ran in every process, so N copies of make raced in the same directory. The packaging rule made that worse by staging every plugin through one shared plugin.wasm, so concurrent targets clobbered each other and left orphaned temp files behind. That also ruled out make -j. Stage each package under its own per-target directory, and move the build into SynchronizedBeforeSuite so process 1 does it once while the others wait. * ci: run the plugins suite in parallel processes With the compilation cache warm the suite is bound by spec execution, which splits cleanly across processes. Run it as its own step with the ginkgo CLI, already declared as a tool in go.mod, and drop the package from the main go test invocation so it is not run twice. Locally, with -race: 69s to 19s warm, and 256s to 96s cold. * ci: give the plugins suite its own job so it runs concurrently Running it as a second step in the go job serialised it against the other 90 packages, which cancelled out the parallel win: the job went from 5m41s to only 5m29s even though the suite itself dropped from ~175s to 82s. Move it to its own job so the two run at the same time. The WASM compilation cache moves with it, since the go job no longer runs the suite. --- .github/workflows/pipeline.yml | 38 +++++++++++++++++++++++----------- .gitignore | 1 + plugins/plugins_suite_test.go | 20 ++++++++---------- plugins/testdata/Makefile | 10 +++++---- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 012e3a9c3..9da99e864 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -131,24 +131,14 @@ jobs: uses: actions/checkout@v7 - uses: actions/setup-go@v6 - id: setup-go with: go-version-file: go.mod - name: Download dependencies run: go mod download - # Without this, the plugins suite recompiles every test plugin WASM module, - # which dominates the job runtime under -race. - - name: Cache the plugins test suite WASM compilation cache - uses: actions/cache@v6 - with: - path: plugins/testdata/.wazero-cache - key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} - restore-keys: wazero-${{ runner.os }}- - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -157,6 +147,30 @@ jobs: go build -o ndpgen . ./ndpgen --help + go-plugins: + name: Test Go plugins + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + id: setup-go + with: + go-version-file: go.mod + + # Without this, the suite recompiles every test plugin WASM module, + # which dominates its runtime under -race. + - name: Cache the WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + + - name: Test plugins + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/ + go-windows: name: Test Go code (Windows) runs-on: windows-2022 @@ -294,7 +308,7 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] diff --git a/.gitignore b/.gitignore index 810074e72..4ed971491 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ zz_*_test.go # wazero compilation cache for the plugins test suite /plugins/testdata/.wazero-cache/ +/plugins/testdata/*.stage/ diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index c8585f2c1..2dae65d00 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "net/http" "os" "os/exec" @@ -39,7 +40,6 @@ var ( func TestPlugins(t *testing.T) { tests.Init(t, false) - buildTestPlugins(t, testDataDir) // Set globally so tests using configtest.SetupConfig inherit it. The cache // persists between runs; entries are content-addressed, so a stale one only misses. @@ -51,16 +51,11 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(t *testing.T, path string) { - t.Helper() +func buildTestPlugins(path string) { start := time.Now() - t.Logf("[BeforeSuite] Current working directory: %s", path) - cmd := exec.Command("make", "-C", path) - out, err := cmd.CombinedOutput() - t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start)) - if err != nil { - t.Fatalf("Failed to build test plugins: %v", err) - } + out, err := exec.Command("make", "-C", path).CombinedOutput() + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s:\n%s", time.Since(start), out) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") } // createTestManager creates a new plugin Manager with the given plugin config. @@ -146,7 +141,10 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s return manager, tmpDir } -var _ = BeforeSuite(func() { +var _ = SynchronizedBeforeSuite(func() { + // Build once: the testdata Makefile is not safe to run concurrently. + buildTestPlugins(testDataDir) +}, func() { // Get testdata directory (where test plugin .ndp packages live) _, currentFile, _, ok := runtime.Caller(0) Expect(ok).To(BeTrue()) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index 758497095..de948a2bb 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,17 +10,19 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) - rm -rf .wazero-cache + rm -rf .wazero-cache $(PLUGINS:%=%.stage) # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) # Build the .ndp package (zip containing manifest.json + plugin.wasm) +# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds. %.ndp: %.wasm %/manifest.json @rm -f $@ - @cp $< plugin.wasm - zip -j $@ $*/manifest.json plugin.wasm - @rm -f plugin.wasm + @rm -rf $*.stage && mkdir -p $*.stage + @cp $< $*.stage/plugin.wasm + zip -j $@ $*/manifest.json $*.stage/plugin.wasm + @rm -rf $*.stage @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp # Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so From 9ff005862071ee16f60f1ca103397b993821201d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 21:24:50 -0400 Subject: [PATCH 12/27] fix: assorted scanner, plugin, and server fixes from the Go 1.27 work (#6050) * fix(plugins): stop the cache janitor when a plugin cache is dropped newCacheService started a ttlcache janitor goroutine that only stopped via the explicit Close() path, so a cache service that was discarded without being closed leaked its janitor for the process lifetime. It now registers the same runtime.AddCleanup safety net that utils/cache.simpleCache already uses. * fix(scanner): stop splitting multi-byte characters when truncating tags sanitize() capped tag values with a byte slice, so a value whose limit falls in the middle of a multi-byte character was stored as invalid UTF-8. defaultMaxTagLength is 1024, which is not a multiple of 3, so any sufficiently long CJK title hit this. Only trailing invalid bytes are trimmed, leaving bad bytes elsewhere in the value untouched. * fix(scanner): store MusicBrainz ids in their canonical form uuid.Parse accepts a UUID wrapped in any two bytes, as well as braced and urn: forms, but sanitize() returned the raw string. A tag like {} or a quoted value was therefore persisted with its wrapper into the mbz_* columns, where the exact-match MBID search can never find it. The parsed value is now stored, which also lowercases uppercase ids and adds the dashes to unhyphenated ones. * fix(plugins): parse IPv6 hosts correctly in the websocket allowlist isHostAllowed cut the host at the last colon, which mangles an IPv6 literal: "[::1]:8080" became "[::1]" and "[::1]" became "[:". A plugin manifest could therefore never allow an IPv6 host. It now uses net.SplitHostPort, falling back to unwrapping the brackets when there is no port. * fix(server): serve pprof profiles when a BaseURL is configured net/http/pprof's Index resolves the profile name by trimming "/debug/pprof/" from the raw request path, which never matches once MountRouter prepends the BasePath. Requests for any profile without an explicit chi route fell through to the index page, returning HTML with a 200 instead of the profile. The handler now strips the BasePath first. * test(scanner): run the goroutine leak check unconditionally The scanner suite's goleak check only ran when the GOLEAK env var was set, so it never ran in CI and could not catch a regression. It passes with the existing ignore list, verified over repeated runs, so the gate is removed. * fix(server): close the background image body on a non-200 response serveImage returned early on an unexpected status code without closing the response body, pinning the connection until the 5s client timeout. The nolint:bodyclose above the request suppressed the linter that would have caught it, and its justification only holds on the success path, where the body is handed to the CachedStream wrapper. * test(scanner): repair BenchmarkScan so it can actually run The benchmark failed three ways before reaching its first iteration: it reused a shared temp DB and tried to repoint the default library, it never loaded the config defaults so the scanner got a concurrency of 0, and it lacked the notify ignore that the suite already carries. tests.Init now takes a testing.TB so a benchmark can load the test config the same way the suites do. * refactor(artwork): drop the unused sourceFunc Stringer sourceFunc.String derived a label from the closure's symbol name via reflection, but nothing called it: the trace output builds its candidate labels from explicit strings. Whole-program analysis confirms it is unreachable, and dropping it removes a reflection-based dependency on compiler closure-naming details. * refactor(plugins): reuse extractHostname in the websocket allowlist The IPv6 host parsing added for isHostAllowed duplicated extractHostname, which already lives in the same package and backs the HTTP client's identical allowlist check. Two copies of a security-relevant parser can drift, so the websocket service now calls the existing helper. The port-stripping specs move into the URL Validation block that already covered them. * perf(scanner): bound the tag truncation trim to a partial rune The trim loop dropped every trailing byte that failed to decode, so a value ending in a long run of invalid bytes was walked one byte at a time: a 1 MiB lyrics tag measured 2.58ms against 45ns for a normal cut. A partial rune is at most 3 trailing bytes, so the loop is capped there, which also stops it consuming a pre-existing invalid run. * test: tighten the tests added with the Go 1.27 bugfixes Drop the testItem stub in favour of the package's own cacheKey, register the pprof test profile once at package scope, and replace the hand-rolled goroutine settle loop with Eventually. Also corrects a comment that credited a TestMain the scanner suite does not have. * test(scanner): ignore notify's nonrecursive-tree goroutines on Linux The goroutine leak check only ignored the recursive tree (macOS/FSEvents). Linux CI uses inotify, whose nonrecursive tree leaks dispatch and internal goroutines after Stop(), failing the check. * fix(scanner): avoid a truncation panic when MaxLength is 1 or 2 A value of only UTF-8 continuation bytes drained the partial-rune loop to empty, then sliced value[:-1] and panicked. Break when DecodeLastRune returns size 0 (empty string) by testing size != 1 instead of size > 1. * fix: address Codex review on the pprof base path and scan benchmark - profilerHandler: treat a root BasePath ("/") as no prefix, so http.StripPrefix keeps the leading slash chi needs; without this the profiler 404s when BaseURL is "/". Cover the root case in the test. - BenchmarkScan: make it run regardless of test/benchmark ordering. Add singleton.DeleteInstance so a fresh DB is opened after TestScanner closes the shared one, guard driver registration with sync.Once so the rebuild does not re-Register, and ignore the Ginkgo interrupt-handler and Linux notify goroutines the preceding suite leaves behind. * fix: address Codex round 2 on BasePath trailing slash and benchmark DB cleanup - profilerHandler: trim all trailing slashes (TrimRight), not just a bare "/", so a BaseURL like "/music/" strips correctly instead of 404ing. Cover it in the test. - BenchmarkScan: keep and defer db.Init's closer so the DB is closed before b.TempDir cleanup, which otherwise cannot delete the open SQLite/WAL files on Windows. --- cmd/root.go | 11 +++- cmd/root_test.go | 46 +++++++++++++ core/artwork/sources.go | 12 ---- db/db.go | 21 ++++-- model/metadata/metadata.go | 14 +++- model/metadata/metadata_internal_test.go | 20 ++++++ model/metadata/metadata_test.go | 38 +++++++++++ plugins/host_cache.go | 10 ++- plugins/host_cache_test.go | 34 ++++++++++ plugins/host_websocket.go | 6 +- plugins/host_websocket_test.go | 26 ++++---- scanner/scanner_benchmark_test.go | 22 ++++++- scanner/scanner_suite_test.go | 20 +++--- server/backgrounds/backgrounds_suite_test.go | 17 +++++ server/backgrounds/handler.go | 3 +- server/backgrounds/handler_test.go | 68 ++++++++++++++++++++ tests/init_tests.go | 2 +- utils/singleton/singleton.go | 10 +++ 18 files changed, 323 insertions(+), 57 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 model/metadata/metadata_internal_test.go create mode 100644 server/backgrounds/backgrounds_suite_test.go create mode 100644 server/backgrounds/handler_test.go diff --git a/cmd/root.go b/cmd/root.go index ff1641bd3..94f861f40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "net/http" "os" "os/signal" "strings" @@ -138,7 +139,7 @@ func startServer(ctx context.Context) func() error { a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler()) } if conf.Server.DevEnableProfiler { - a.MountRouter("Profiling", "/debug", middleware.Profiler()) + a.MountRouter("Profiling", "/debug", profilerHandler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) @@ -147,6 +148,14 @@ func startServer(ctx context.Context) func() error { } } +// profilerHandler returns the pprof handler. net/http/pprof resolves the profile +// name from the raw request path, so the BasePath has to come off first. +func profilerHandler() http.Handler { + // A trailing or root slash would make StripPrefix drop the leading slash chi needs. + basePath := strings.TrimRight(conf.Server.BasePath, "/") + return http.StripPrefix(basePath, middleware.Profiler()) +} + // schedulePeriodicScan schedules a periodic scan of the music library, if configured. func schedulePeriodicScan(ctx context.Context) func() error { return func() error { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 000000000..af8d44e7e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "path" + "runtime/pprof" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = pprof.NewProfile("nd-profiler-test") + +var _ = Describe("profilerHandler", func() { + // Mirrors how server.MountRouter mounts the handler. + mount := func() http.Handler { + router := chi.NewRouter() + router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler()) + return router + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("serves a named profile", + func(basePath string) { + conf.Server.BasePath = basePath + + w := httptest.NewRecorder() + target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1" + mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0")) + }, + Entry("without a BasePath", ""), + Entry("with a BasePath", "/music"), + Entry("with a root BasePath", "/"), + Entry("with a trailing-slash BasePath", "/music/"), + ) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 069b7bf5a..f2abf9da5 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -10,9 +10,7 @@ import ( "net/http" "net/url" "path/filepath" - "reflect" "regexp" - "runtime" "strings" "time" @@ -29,16 +27,6 @@ var errSourceUnreadable = errors.New("artwork source unreadable") type sourceFunc func() (r io.ReadCloser, path string, err error) -func (f sourceFunc) String() string { - name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() - name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") - if _, after, found := strings.Cut(name, ")."); found { - name = after - } - name = strings.TrimSuffix(name, ".func1") - return name -} - func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { var openErr error diff --git a/db/db.go b/db/db.go index a325dd3f5..c53aa364a 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "errors" "fmt" + "sync" "time" "github.com/mattn/go-sqlite3" @@ -33,15 +34,21 @@ var embedMigrations embed.FS const migrationsFolder = "migrations" +// sql.Register panics if called twice, so guard it: the singleton instance can be reset +// (tests/benchmarks) and rebuilt, but the driver is process-global and registers only once. +var registerDriverOnce sync.Once + func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { - sql.Register(Driver, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { - return err - } - return conn.RegisterCollation(NaturalCollation, natural.CompareFold) - }, + registerDriverOnce.Do(func() { + sql.Register(Driver, &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { + return err + } + return conn.RegisterCollation(NaturalCollation, natural.CompareFold) + }, + }) }) Path = conf.Server.DbPath if Path == ":memory:" { diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 729e83564..0efbe94ec 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/navidrome/navidrome/consts" @@ -366,6 +367,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s if len(value) > maxLength { log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength) value = value[:maxLength] + // Drop the partial rune the cut may have left: at most 3 trailing bytes, + // so a pre-existing invalid run elsewhere is never consumed. + for range 3 { + if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 { + break + } + value = value[:len(value)-1] + } } switch tag.Type { @@ -387,11 +396,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s return "" } case model.TagTypeUUID: - _, err := uuid.Parse(value) + u, err := uuid.Parse(value) if err != nil { log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) return "" } + // Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any + // two-byte wrapper, and a wrapped value would never match an exact-match query + value = u.String() } return value } diff --git a/model/metadata/metadata_internal_test.go b/model/metadata/metadata_internal_test.go new file mode 100644 index 000000000..99fbba328 --- /dev/null +++ b/model/metadata/metadata_internal_test.go @@ -0,0 +1,20 @@ +package metadata + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("sanitize truncation with a tiny MaxLength", + // A value of only continuation bytes drains the partial-rune loop to empty; the loop + // must stop there instead of slicing value[:-1] and panicking. + func(maxLength int, value string) { + Expect(func() { + Expect(sanitize("file.mp3", "title", model.TagConf{MaxLength: maxLength}, value)).To(Equal("")) + }).NotTo(Panic()) + }, + Entry("maxLength 1", 1, "\x80\x80"), + Entry("maxLength 2", 2, "\x80\x80\x80"), + Entry("maxLength 3", 3, "\x80\x80\x80\x80"), +) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 7ebe9fa4a..09a2dfde0 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" @@ -122,6 +123,43 @@ var _ = Describe("Metadata", func() { Expect(pair[0].Value()).To(HaveLen(1048570)) }) + It("should not split a multi-byte character when truncating", func() { + // 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune. + props.Tags = model.RawTags{ + "Title": {strings.Repeat("日", 2048)}, + } + md = metadata.New(filePath, props) + + title := md.String(model.TagTitle) + Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8") + Expect(len(title)).To(BeNumerically("<=", 1024)) + }) + + It("should keep invalid bytes that are not at the truncation point", func() { + props.Tags = model.RawTags{ + "Title": {"a\xffb" + strings.Repeat("c", 2048)}, + } + md = metadata.New(filePath, props) + + Expect(md.String(model.TagTitle)).To(HaveLen(1024)) + }) + + DescribeTable("should normalize UUID tags to their canonical form", + func(raw, expected string) { + props.Tags = model.RawTags{"musicbrainz_artistid": {raw}} + md = metadata.New(filePath, props) + + Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected)) + }, + Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("not a uuid", "the beatles", ""), + ) + It("should split multiple values", func() { props.Tags = model.RawTags{ "Genre": {"Rock/Pop;;Punk"}, diff --git a/plugins/host_cache.go b/plugins/host_cache.go index b90d790cf..f410dfd3d 100644 --- a/plugins/host_cache.go +++ b/plugins/host_cache.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "runtime" "time" "github.com/jellydator/ttlcache/v3" @@ -29,11 +30,18 @@ func newCacheService(pluginName string) *cacheServiceImpl { // Start the janitor goroutine to clean up expired entries go cache.Start() - return &cacheServiceImpl{ + svc := &cacheServiceImpl{ pluginName: pluginName, cache: cache, defaultTTL: defaultCacheTTL, } + + // Automatic cleanup to prevent goroutine leak when the service is garbage collected + runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) { + ttlCache.Stop() + }, cache) + + return svc } // getTTL converts seconds to a duration, using default if 0 or negative diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index cf3973fc4..5e91b04ec 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -8,9 +8,11 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "os" "path/filepath" + "runtime" "time" "github.com/navidrome/navidrome/conf" @@ -599,3 +601,35 @@ var _ = Describe("CacheService Integration", Ordered, func() { }) }) }) + +var _ = Describe("newCacheService", func() { + // The suite above leaves goroutines winding down, so settle before sampling. + settledBaseline := func() int { + var n int + Eventually(func() int { + runtime.GC() + prev := n + n = runtime.NumGoroutine() + return n - prev + }).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero()) + return n + } + + It("stops the janitor goroutine once the service is unreachable", func() { + const numServices = 5 + baseline := settledBaseline() + + func() { + services := make([]*cacheServiceImpl, 0, numServices) + for i := range numServices { + services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i))) + } + Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices), + "expected one janitor goroutine per cache service") + }() + + Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }). + WithTimeout(10*time.Second).WithPolling(20*time.Millisecond). + Should(BeNumerically("<=", baseline), "janitor goroutines leaked") + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 90403f4c0..82aded0cb 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -243,11 +243,7 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection } func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - // Strip port from host if present - hostWithoutPort := host - if idx := strings.LastIndex(host, ":"); idx != -1 { - hostWithoutPort = host[:idx] - } + hostWithoutPort := extractHostname(host) for _, pattern := range s.requiredHosts { if matchHostPattern(pattern, hostWithoutPort) { diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index e41cfbb82..2aa85cd21 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -144,20 +144,18 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(allowed).To(BeFalse()) }) - It("should strip port before checking host", func() { - // Implementation strips port before matching against patterns - // test-websocket manifest has "localhost:*" which matches "localhost" - // after port stripping - // Note: The port wildcard pattern isn't actually implemented, but - // since port is stripped, "localhost:*" is compared against "localhost" - // which won't match. To make localhost work, we'd need exact "localhost" - // in the allowed hosts list. - - // Testing that port is properly stripped - // The pattern "localhost:*" won't match "localhost" due to exact match - allowed := testService.isHostAllowed("localhost:8080") - Expect(allowed).To(BeFalse()) - }) + DescribeTable("should match against the host with its port stripped", + func(allowed []string, host string, expected bool) { + svc := &webSocketServiceImpl{requiredHosts: allowed} + Expect(svc.isHostAllowed(host)).To(Equal(expected)) + }, + Entry("hostname with port", []string{"example.com"}, "example.com:8080", true), + Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true), + Entry("IPv6 without port", []string{"::1"}, "[::1]", true), + Entry("host not in the list", []string{"::2"}, "[::1]:8080", false), + // "localhost:*" is matched against the stripped "localhost", so it never hits + Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false), + ) }) Describe("Connection Management", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index ca1064631..65410d500 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -2,8 +2,8 @@ package scanner_test import ( "context" + "database/sql" "fmt" - "os" "path/filepath" "runtime" "testing" @@ -21,6 +21,8 @@ import ( "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/singleton" "go.uber.org/goleak" ) @@ -31,11 +33,25 @@ func BenchmarkScan(b *testing.B) { goleak.IgnoreAnyFunction("testing.(*B).doBench"), // Ignore database/sql.(*DB).connectionOpener, as we are not closing the database connection goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"), + // A preceding TestScanner leaves Ginkgo's interrupt handler running. + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps watcher goroutines alive after Stop(); recursive on macOS, nonrecursive on Linux. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), ) - tmpDir := os.TempDir() + tests.Init(b, false) + + tmpDir := b.TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - db.Init(context.Background()) + // The default library is seeded from MusicFolder, and its path cannot be changed afterwards + conf.Server.MusicFolder = "fake:///music" + // TestScanner may run first and close the shared DB singleton; drop it so db.Init + // opens a fresh one whether or not the test suite ran before this benchmark. + singleton.DeleteInstance[*sql.DB]() + // Close before b.TempDir cleanup runs, or Windows cannot delete the open DB/WAL files. + defer db.Init(context.Background())() ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 10be0401f..07ffd6e3a 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -3,7 +3,6 @@ package scanner_test import ( "context" "io/fs" - "os" "testing" "github.com/navidrome/navidrome/consts" @@ -31,16 +30,15 @@ func init() { } func TestScanner(t *testing.T) { - // Only run goleak checks when the GOLEAK env var is set - if os.Getenv("GOLEAK") != "" { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library creates internal goroutines for file watching that persist after Stop() is called. - // These are created by the plugins package tests and are expected behavior. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - ) - } + // Detect any goroutine leaks in the scanner code under test + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps internal goroutines alive after Stop(). The backend picks the tree per + // platform: recursive on macOS (FSEvents), nonrecursive on Linux (inotify), so ignore both. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), + ) tests.Init(t, true) defer db.Close(context.Background()) diff --git a/server/backgrounds/backgrounds_suite_test.go b/server/backgrounds/backgrounds_suite_test.go new file mode 100644 index 000000000..5ad81907b --- /dev/null +++ b/server/backgrounds/backgrounds_suite_test.go @@ -0,0 +1,17 @@ +package backgrounds + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBackgrounds(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Backgrounds Suite") +} diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index f6e159b4b..dcaaa9c66 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -81,7 +81,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e } c := httpclient.New(imageRequestTimeout) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) - resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper + resp, err := c.Do(req) //nolint:bodyclose,gosec // On success the body is closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) return strings.NewReader(string(defaultImage)), nil @@ -90,6 +90,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e return nil, fmt.Errorf("could not get background image from hosting service: %w", err) } if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode) } log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start)) diff --git a/server/backgrounds/handler_test.go b/server/backgrounds/handler_test.go new file mode 100644 index 000000000..e8c77380f --- /dev/null +++ b/server/backgrounds/handler_test.go @@ -0,0 +1,68 @@ +package backgrounds + +import ( + "context" + "io" + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type recordingBody struct { + io.Reader + closed *bool +} + +func (b recordingBody) Close() error { + *b.closed = true + return nil +} + +type stubTransport struct { + statusCode int + closed *bool +} + +func (t stubTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: t.statusCode, + Header: make(http.Header), + Body: recordingBody{Reader: strings.NewReader("image-bytes"), closed: t.closed}, + }, nil +} + +var _ = Describe("serveImage", func() { + var closed bool + + BeforeEach(func() { + closed = false + }) + + stubStatus := func(statusCode int) { + original := http.DefaultTransport + http.DefaultTransport = stubTransport{statusCode: statusCode, closed: &closed} + DeferCleanup(func() { http.DefaultTransport = original }) + } + + It("closes the response body when the hosting service returns an error", func() { + stubStatus(http.StatusNotFound) + + _, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).To(MatchError(ContainSubstring("unexpected status code"))) + Expect(closed).To(BeTrue(), "response body was left open") + }) + + It("hands the still-open body to the caller on success", func() { + stubStatus(http.StatusOK) + + reader, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).ToNot(HaveOccurred()) + Expect(closed).To(BeFalse(), "response body must stay open for the CachedStream wrapper") + body, _ := io.ReadAll(reader) + Expect(string(body)).To(Equal("image-bytes")) + }) +}) diff --git a/tests/init_tests.go b/tests/init_tests.go index 582ad95fc..902cf196d 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -13,7 +13,7 @@ import ( var once sync.Once -func Init(t *testing.T, skipOnShort bool) { +func Init(t testing.TB, skipOnShort bool) { if skipOnShort && testing.Short() { t.Skip("skipping test in short mode.") } diff --git a/utils/singleton/singleton.go b/utils/singleton/singleton.go index 83f8c53ab..8271034ad 100644 --- a/utils/singleton/singleton.go +++ b/utils/singleton/singleton.go @@ -67,3 +67,13 @@ func GetInstance[T any](constructor func() T) T { return newInstance } + +// DeleteInstance drops the cached instance of type T so the next GetInstance rebuilds it. +// Intended for tests and benchmarks that need a fresh instance regardless of run order. +func DeleteInstance[T any]() { + var v T + name := reflect.TypeOf(v).String() + lock.Lock() + delete(instances, name) + lock.Unlock() +} From dbd26ba2e71d0a5b79dba873a2beeff59f1cd8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 22:17:25 -0400 Subject: [PATCH 13/27] perf(scanner): improve playlist importing on large libraries (#6055) * perf(persistence): avoid a full media_file scan when resolving playlist paths FindByPaths built one OR-ed equality term per path. On the real media_file schema SQLite abandons the path index at just two OR-ed terms and falls back to SCAN media_file, re-testing every term against every row, so the cost grows with (rows x terms). Group the candidates by library and emit one IN list per library instead, which plans as SEARCH media_file USING INDEX media_file_path_nocase. The NOCASE collation is kept so ASCII case-insensitive matching still works. This is the dominant cost of M3U playlist import, which resolves every track on every scan. Measured with a 1000-track playlist against a migrated DB: 100k media_file rows: 397 -> 51,414 tracks/sec 500k media_file rows: 78.5 -> 47,174 tracks/sec The rate no longer degrades as the table grows, which is the expected shape for an index lookup. Reported in #6043, where an 8 hour scan of a 2M-song library spent 7h52m in the playlist phase. * docs(playlists): correct the stale reason for the M3U lookup chunk size The expression-tree depth ceiling applied to the old OR-per-path query, which capped a batch at roughly 500 terms. The IN form is bound by SQLite's 32766 variable limit instead, which the 400 candidates per chunk sit far below. --- core/playlists/parse_m3u.go | 4 ++-- persistence/mediafile_repository.go | 25 +++++++++++++++++------- persistence/mediafile_repository_test.go | 22 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index a64c337c9..286f2e420 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -25,8 +25,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m return err } var mfs model.MediaFiles - // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates - // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000. + // Chunked so a huge playlist is not held in memory at once. Each line yields up to + // 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables. for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { filteredLines := make([]string, 0, len(lines)) for _, line := range lines { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 320b95ef2..ed18333de 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "iter" + "maps" "slices" "strconv" "strings" @@ -355,7 +356,10 @@ func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions // Library-qualified paths search within the specified library, while unqualified paths // search across all libraries for backward compatibility. func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { - query := Or{} + // One IN list per library instead of one OR term per path: SQLite abandons the + // path index at just two OR-ed equality terms and scans the whole table. + byLibrary := map[int][]string{} + var unqualified []string for _, path := range paths { parts := strings.SplitN(path, ":", 2) @@ -366,17 +370,24 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err // Invalid format, skip continue } - relativePath := parts[1] - query = append(query, And{ - Eq{"path collate nocase": relativePath}, - Eq{"library_id": libraryID}, - }) + byLibrary[libraryID] = append(byLibrary[libraryID], parts[1]) } else { // Unqualified path: search across all libraries - query = append(query, Eq{"path collate nocase": path}) + unqualified = append(unqualified, path) } } + query := Or{} + for _, libraryID := range slices.Sorted(maps.Keys(byLibrary)) { + query = append(query, And{ + Eq{"path collate nocase": byLibrary[libraryID]}, + Eq{"library_id": libraryID}, + }) + } + if len(unqualified) > 0 { + query = append(query, Eq{"path collate nocase": unqualified}) + } + if len(query) == 0 { return model.MediaFiles{}, nil } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index c1a91c5a5..8a492a813 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1055,6 +1055,28 @@ var _ = Describe("MediaRepository", func() { Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("otherlib-track")) }) + + It("resolves paths from multiple libraries in a single call", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + results, err := adminMr.FindByPaths([]string{ + "1:artist/Album/track.mp3", + fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) + }) + + It("keeps each path scoped to its own library when several are queried", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + // Each path exists, but under the other library's ID, so neither must match. + results, err := adminMr.FindByPaths([]string{ + fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), + "1:hidden/test.mp3", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) }) }) From 96b051ffa774eab2e0d877f5fbabe3215bc4d4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 11:21:32 -0400 Subject: [PATCH 14/27] fix(nativeapi): stop partial PUTs from clearing untouched columns (#6058) * fix(nativeapi): stop partial PUTs from clearing untouched columns The REST layer parses the request body's top-level JSON keys and passes them to Repository.Update as colsToUpdate. The radio and library repositories discarded that list and issued a full-row UPDATE, so any field absent from the body was written as its zero value. For radio this wiped uploaded_image, deleting the station's cover on every partial update (the Web UI is unaffected because its form submits the whole record). For library it silently cleared remote_path and default_new_users. Thread the column list through to Put in both repositories, and extract the column-selection half of filterUpdateValues into selectUpdateColumns so library, which hand-builds its update map, shares the same rule instead of copying it. Fixes #6057 * refactor(persistence): drop pluginRepository's dead rest.Persistable methods Save and Update had no callers: PUT /api/plugin/{id} is served by the hand-written updatePlugin handler over a typed request struct, and the route only wires rest.GetAll and rest.Get. Both methods delegated to Put, which upserts all twelve columns, so wiring rest.Put to this repository would have reintroduced the partial-update clobbering fixed in the previous commit. Removing them, along with the rest.Persistable assertion, makes that a compile error instead of a silent data loss. Put itself is unchanged and still backs plugin discovery. --- core/library.go | 4 ++-- core/library_test.go | 9 +++++++++ model/library.go | 2 +- persistence/library_repository.go | 10 +++++----- persistence/library_repository_test.go | 20 ++++++++++++++++++++ persistence/plugin_repository.go | 26 -------------------------- persistence/radio_repository.go | 2 +- persistence/radio_repository_test.go | 18 ++++++++++++++++++ persistence/sql_base_repository.go | 15 +++++++++------ tests/mock_library_repo.go | 10 ++++++---- 10 files changed, 71 insertions(+), 45 deletions(-) diff --git a/core/library.go b/core/library.go index 365dcbd4c..d905e00cb 100644 --- a/core/library.go +++ b/core/library.go @@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error { +func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error { lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { @@ -211,7 +211,7 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) er pathChanged := originalLib.Path != lib.Path - err = r.LibraryRepository.Put(lib) + err = r.LibraryRepository.Put(lib, cols...) if err != nil { return r.mapError(err) } diff --git a/core/library_test.go b/core/library_test.go index 175d9c37d..43097414d 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -188,6 +188,15 @@ var _ = Describe("Library Service", func() { Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) }) + It("forwards the columns sent by the client to the repository", func() { + library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} + + err := repo.Update("1", library, "name", "path") + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) + }) + It("fails when library doesn't exist", func() { // Create a unique temporary directory to avoid path conflicts uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") diff --git a/model/library.go b/model/library.go index bcb2864c8..aceab533a 100644 --- a/model/library.go +++ b/model/library.go @@ -45,7 +45,7 @@ type LibraryRepository interface { GetPath(id int) (string, error) GetAll(...QueryOptions) (Libraries, error) CountAll(...QueryOptions) (int64, error) - Put(*Library) error + Put(l *Library, colsToUpdate ...string) error Delete(id int) error StoreMusicFolder() error AddArtist(id int, artistID string) error diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 5a0142423..df5c9a066 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -70,7 +70,7 @@ func (r *libraryRepository) GetPath(id int) (string, error) { } } -func (r *libraryRepository) Put(l *model.Library) error { +func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error { if l.ID == model.DefaultLibraryID { currentLib, err := r.Get(1) // if we are creating it, it's ok. @@ -89,13 +89,13 @@ func (r *libraryRepository) Put(l *model.Library) error { err = r.db.Model(l).Insert() } else { // Try to update first - cols := map[string]any{ + cols := selectUpdateColumns(map[string]any{ "name": l.Name, "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, - "updated_at": l.UpdatedAt, - } + }, colsToUpdate...) + cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) rowsAffected, updateErr := r.executeSQL(sq) if updateErr != nil { @@ -340,7 +340,7 @@ func (r *libraryRepository) Update(id string, entity any, cols ...string) error } lib.ID = idInt - return r.Put(lib) + return r.Put(lib, cols...) } var _ model.LibraryRepository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 1743df209..949dd93c5 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -52,6 +52,26 @@ var _ = Describe("LibraryRepository", func() { }) }) + Context("when colsToUpdate is specified", func() { + It("only writes the requested columns", func() { + lib := &model.Library{ + Name: "Original Library", + Path: "/music/original", + RemotePath: "/remote/original", + DefaultNewUsers: true, + } + Expect(repo.Put(lib)).To(Succeed()) + + Expect(repo.Put(&model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) + + saved, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.RemotePath).To(Equal("/remote/original")) + Expect(saved.DefaultNewUsers).To(BeTrue()) + }) + }) + Context("when ID is non-zero and record exists", func() { It("updates the existing record", func() { // First create a library diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 35c32de91..c1e36f0b1 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -141,31 +141,5 @@ func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *pluginRepository) Save(entity any) (string, error) { - p := entity.(*model.Plugin) - if !r.isPermitted() { - return "", rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return p.ID, err -} - -func (r *pluginRepository) Update(id string, entity any, cols ...string) error { - p := entity.(*model.Plugin) - p.ID = id - if !r.isPermitted() { - return rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err -} - var _ model.PluginRepository = (*pluginRepository)(nil) var _ rest.Repository = (*pluginRepository)(nil) -var _ rest.Persistable = (*pluginRepository)(nil) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index b73487e40..915859559 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -152,7 +152,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - err := r.Put(t) + err := r.Put(t, cols...) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound } diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index e2564455d..c35c85ad7 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -141,6 +141,24 @@ var _ = Describe("RadioRepository", func() { ))) }) }) + + Describe("Update", func() { + It("only writes the columns sent by the client", func() { + radio := radioWithHomePage + radio.UploadedImage = "cover.png" + Expect(repo.Put(&radio)).To(Succeed()) + + persistable := repo.(rest.Persistable) + Expect(persistable.Update(radio.ID, &model.Radio{Name: "Renamed"}, "name")).To(Succeed()) + + item, err := repo.Get(radio.ID) + Expect(err).To(BeNil()) + Expect(item.Name).To(Equal("Renamed")) + Expect(item.UploadedImage).To(Equal("cover.png")) + Expect(item.StreamUrl).To(Equal(radio.StreamUrl)) + Expect(item.HomePageUrl).To(Equal(radio.HomePageUrl)) + }) + }) }) Describe("Regular User", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index f49e1bc4f..5530d2568 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -560,13 +560,11 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } -// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the -// row identified by id: only the requested colsToUpdate (or all columns when none are specified), -// dropping columns that must never be overwritten on update (created_at, birth_time). -func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { +// selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are +// specified), dropping columns that must never be overwritten on update (created_at, birth_time). +func selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any { updateValues := map[string]any{} - // This is a map of the columns that need to be updated, if specified c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { return toSnakeCase(s), struct{}{} }) @@ -576,7 +574,6 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string } } - updateValues["id"] = id delete(updateValues, "created_at") // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now // TODO move to mediafile_repository when each repo has its own upsert method @@ -584,6 +581,12 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string return updateValues } +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := selectUpdateColumns(values, colsToUpdate...) + updateValues["id"] = id + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 3f0e576e9..1a16a7e0b 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -14,9 +14,10 @@ import ( type MockLibraryRepo struct { model.LibraryRepository - Data map[int]model.Library - Err error - PutFn func(*model.Library) error // Allow custom Put behavior for testing + Data map[int]model.Library + Err error + PutFn func(*model.Library) error // Allow custom Put behavior for testing + PutCols []string } func (m *MockLibraryRepo) SetData(data model.Libraries) { @@ -90,7 +91,8 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } -func (m *MockLibraryRepo) Put(library *model.Library) error { +func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) error { + m.PutCols = colsToUpdate if m.PutFn != nil { return m.PutFn(library) } From 1f861d27efd97fa7df2b7a6c3e83e665e43270a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 21:27:43 -0400 Subject: [PATCH 15/27] fix(plugins): build public URLs on the caller's address instead of localhost (#6059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugins): build public URLs on the caller's address instead of localhost The artwork host service had no `*http.Request`, so it passed `nil` to `publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that produced `http://localhost/share/img/...`, which is useless to anything outside the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so it fell back to the Navidrome logo instead of the real cover art. `serverAddressMiddleware` already works out the client-facing scheme and host from the `X-Forwarded-*` headers. It now also records them in the request context, and `publicurl` takes a `context.Context` instead of an `*http.Request` so any caller can reach them. Extism passes the caller's context through to host functions, so plugins invoked during a request now get a reachable URL with no configuration. Switching the parameter also removes the need for a second, parallel entry point: the package previously wanted only a scheme, a host, and a context, and took a whole request to get them. `AbsoluteURL` no longer dereferences a possibly-nil request on its parse-error path. Plugin calls that start from `context.Background()` (scheduler and websocket callbacks, the buffered scrobble drain) still fall back to localhost, since they have no request to learn from. A debug log now points at `ShareURL` when that happens. * fix(publicurl): include the configured port in the localhost fallback The last-resort fallback built `http://localhost/...`, which points at port 80 and so is unreachable for a server listening anywhere else — the default 4533 included. Use `conf.Server.Port` so a consumer on the same machine can actually fetch the URL. * fix(publicurl): use https in the localhost fallback when TLS is configured The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL advertised a URL it does not answer on. Mirror the server's own switch, which requires both a certificate and a key. * refactor(publicurl): tidy the localhost fallback and its tests Use gg.If for the fallback scheme so it reads as an expression, like the BaseScheme branch above it, instead of assigning http and overwriting it. Drop two tests the ctx refactor left redundant: one asserted PublicURL "works without a request" but became a byte-identical copy of the ShareURL spec once the *http.Request parameter went away, and the two port specs differed only in the integer, where the non-default port is the stronger assertion. * refactor(conf): add TLSEnabled and use it instead of repeating the predicate Whether the server speaks HTTPS was decided inline in three unconnected places. This PR added the third, in a URL-building package that has no business inferring the transport config. Move the rule to conf, next to the fields it derives from, and call it from publicurl and the insights collector. server.Run keeps its own expression: it takes the certificate and key as parameters, and its test passes values that do not come from the config. --- conf/configuration.go | 6 ++ conf/configuration_test.go | 27 ++++++++ core/metrics/insights.go | 2 +- core/publicurl/publicurl.go | 41 +++++------ core/publicurl/publicurl_test.go | 113 ++++++++++++++++++++----------- model/request/request.go | 21 ++++++ model/request/request_test.go | 31 +++++++++ plugins/host_artwork.go | 16 ++--- plugins/host_artwork_test.go | 11 ++- server/middlewares.go | 2 + server/middlewares_test.go | 29 ++++++++ server/public/handle_shares.go | 6 +- server/public/public.go | 5 +- server/subsonic/browsing.go | 12 ++-- server/subsonic/helpers.go | 4 +- server/subsonic/searching.go | 2 +- server/subsonic/sharing.go | 2 +- 17 files changed, 245 insertions(+), 85 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index df22e4ae2..b9c9f027e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -315,6 +315,12 @@ var currentGOOS = func() string { return runtime.GOOS } +// TLSEnabled reports whether the server serves HTTPS. Both halves are required, +// so callers cannot infer it from the certificate alone. +func (c *configOptions) TLSEnabled() bool { + return c.TLSCert != "" && c.TLSKey != "" +} + var ( Server = &configOptions{} hooks []func() diff --git a/conf/configuration_test.go b/conf/configuration_test.go index ac6477572..344ab3595 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -453,3 +454,29 @@ var _ = Describe("Configuration", func() { Entry("JSON format", "json"), ) }) + +var _ = Describe("TLSEnabled", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("is false when neither the certificate nor the key is set", func() { + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is true when both the certificate and the key are set", func() { + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeTrue()) + }) + + It("is false when only the certificate is set", func() { + conf.Server.TLSCert = "cert.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is false when only the key is set", func() { + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) +}) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 706df6559..d952f517a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -198,7 +198,7 @@ var staticData = sync.OnceValue(func() insights.Data { // Config info data.Config.LogLevel = conf.Server.LogLevel data.Config.LogFileConfigured = conf.Server.LogFile != "" - data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" + data.Config.TLSConfigured = conf.Server.TLSEnabled() data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index b0865e78b..345606324 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -2,7 +2,7 @@ package publicurl import ( "cmp" - "net/http" + "context" "net/url" "path" "strconv" @@ -13,35 +13,36 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" ) // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. -func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { +func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string { token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { params.Add("size", strconv.Itoa(size)) } - return PublicURL(req, uri, params) + return PublicURL(ctx, uri, params) } // PublicURL builds a full URL for public-facing resources. -// It uses ShareURL from config if available, otherwise falls back to extracting -// the scheme and host from the provided http.Request. -// If req is nil and ShareURL is not set, it defaults to http://localhost. -func PublicURL(req *http.Request, u string, params url.Values) string { +// It uses ShareURL from config if available, otherwise falls back to the address the +// client used to reach the server, recorded in the context. +func PublicURL(ctx context.Context, u string, params url.Values) string { if conf.Server.ShareURL == "" { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } shareUrl, err := url.Parse(conf.Server.ShareURL) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl, err := url.Parse(u) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host @@ -55,13 +56,12 @@ func PublicURL(req *http.Request, u string, params url.Values) string { } // AbsoluteURL builds an absolute URL from a relative path. -// It uses BaseHost/BaseScheme from config if available, otherwise extracts -// the scheme and host from the http.Request. -// If req is nil and BaseHost is not set, it defaults to http://localhost. -func AbsoluteURL(req *http.Request, u string, params url.Values) string { +// It uses BaseHost/BaseScheme from config if available, otherwise the address the client +// used to reach the server, recorded in the context by the server's address middleware. +func AbsoluteURL(ctx context.Context, u string, params url.Values) string { buildUrl, err := url.Parse(u) if err != nil { - log.Error(req.Context(), "Failed to parse URL path", "url", u, err) + log.Error(ctx, "Failed to parse URL path", "url", u, err) return "" } if strings.HasPrefix(u, "/") { @@ -69,12 +69,13 @@ func AbsoluteURL(req *http.Request, u string, params url.Values) string { if conf.Server.BaseHost != "" { buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") buildUrl.Host = conf.Server.BaseHost - } else if req != nil { - buildUrl.Scheme = req.URL.Scheme - buildUrl.Host = req.Host + } else if scheme, host, ok := request.ServerAddressFrom(ctx); ok { + buildUrl.Scheme = scheme + buildUrl.Host = host } else { - buildUrl.Scheme = "http" - buildUrl.Host = "localhost" + log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u) + buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http") + buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port) } } if len(params) > 0 { diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 7e9ee8b8e..0f8ea806c 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -1,7 +1,7 @@ package publicurl_test import ( - "net/http" + "context" "net/url" "testing" @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,24 +37,17 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses ShareURL as the base", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/path/to/resource", nil) + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://share.example.com/path/to/resource")) }) It("includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"300"}, "format": []string{"png"}} - result := publicurl.PublicURL(r, "/image/123", params) + result := publicurl.PublicURL(context.Background(), "/image/123", params) Expect(result).To(ContainSubstring("https://share.example.com/image/123")) Expect(result).To(ContainSubstring("size=300")) Expect(result).To(ContainSubstring("format=png")) }) - - It("works without a request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("https://share.example.com/path/to/resource")) - }) }) When("ShareURL includes a path", func() { @@ -62,21 +56,19 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends the ShareURL path to the resource", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) It("prepends the ShareURL path and includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"600"}} - result := publicurl.PublicURL(r, "/share/img/hash", params) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", params) Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) }) It("handles trailing slash in ShareURL path", func() { conf.Server.ShareURL = "https://example.com/navi/" - result := publicurl.PublicURL(nil, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) }) @@ -87,15 +79,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("falls back to AbsoluteURL with request", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/test", nil) - r.Host = "myserver.com" - result := publicurl.PublicURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "myserver.com") + result := publicurl.PublicURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://myserver.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 4533 + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) }) @@ -109,15 +101,13 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses BaseHost and BaseScheme", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://configured.example.com/path/to/resource")) }) It("defaults to http scheme if BaseScheme is empty", func() { conf.Server.BaseScheme = "" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("http://configured.example.com/path/to/resource")) }) }) @@ -129,15 +119,30 @@ var _ = Describe("Public URL Utilities", func() { }) It("extracts host from request", func() { - r, _ := http.NewRequest("GET", "https://request.example.com/test", nil) - r.Host = "request.example.com" - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "request.example.com") + result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://request.example.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 8080 + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:8080/path/to/resource")) + }) + + It("uses https in the fallback when TLS is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("https://localhost:4533/path/to/resource")) + }) + + It("stays on http when only the certificate is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) @@ -149,24 +154,21 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends BasePath to the URL", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) }) }) It("passes through absolute URLs unchanged", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil) + result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil) Expect(result).To(Equal("https://other.example.com/path")) }) It("includes query parameters", func() { conf.Server.BaseHost = "example.com" conf.Server.BaseScheme = "https" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"key": []string{"value"}} - result := publicurl.AbsoluteURL(r, "/path", params) + result := publicurl.AbsoluteURL(context.Background(), "/path", params) Expect(result).To(Equal("https://example.com/path?key=value")) }) }) @@ -180,20 +182,51 @@ var _ = Describe("Public URL Utilities", func() { It("generates a URL with the artwork token", func() { artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).To(HavePrefix("https://share.example.com/share/img/")) }) It("includes size parameter when provided", func() { artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) - result := publicurl.ImageURL(nil, artID, 300) + result := publicurl.ImageURL(context.Background(), artID, 300) Expect(result).To(ContainSubstring("size=300")) }) It("omits size parameter when zero", func() { artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).ToNot(ContainSubstring("size=")) }) }) + + Describe("ImageURL address precedence", func() { + var artID model.ArtworkID + + BeforeEach(func() { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) + }) + + It("uses the address of the request that triggered the call", func() { + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 300) + Expect(result).To(HavePrefix("https://music.example.com/share/img/")) + Expect(result).To(ContainSubstring("size=300")) + }) + + It("prefers ShareURL over the address in the context", func() { + conf.Server.ShareURL = "https://share.example.com" + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 0) + Expect(result).To(HavePrefix("https://share.example.com/share/img/")) + }) + + It("falls back to localhost on the configured port when no address is available", func() { + conf.Server.Port = 4533 + result := publicurl.ImageURL(context.Background(), artID, 0) + Expect(result).To(HavePrefix("http://localhost:4533/share/img/")) + }) + }) }) diff --git a/model/request/request.go b/model/request/request.go index 2b1cfb9ef..33c49f797 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -20,6 +20,7 @@ const ( ReverseProxyIp = contextKey("reverseProxyIp") InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins TokenEpochHolder = contextKey("tokenEpochHolder") + ServerAddress = contextKey("serverAddress") ) var allKeys = []contextKey{ @@ -32,6 +33,7 @@ var allKeys = []contextKey{ ClientUniqueId, ReverseProxyIp, InternalAuth, + ServerAddress, } func WithUser(ctx context.Context, u model.User) context.Context { @@ -70,6 +72,25 @@ func WithInternalAuth(ctx context.Context, username string) context.Context { return context.WithValue(ctx, InternalAuth, username) } +// serverAddress is the public scheme and host the client used to reach this server, +// so code running without an http.Request can still build absolute URLs. +type serverAddress struct { + scheme string + host string +} + +func WithServerAddress(ctx context.Context, scheme, host string) context.Context { + return context.WithValue(ctx, ServerAddress, serverAddress{scheme: scheme, host: host}) +} + +func ServerAddressFrom(ctx context.Context) (scheme, host string, ok bool) { + a, ok := ctx.Value(ServerAddress).(serverAddress) + if !ok || a.host == "" { + return "", "", false + } + return a.scheme, a.host, true +} + func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok diff --git a/model/request/request_test.go b/model/request/request_test.go index ef9af8231..da819f9c0 100644 --- a/model/request/request_test.go +++ b/model/request/request_test.go @@ -38,3 +38,34 @@ var _ = Describe("Token epoch holder", func() { Expect(ok).To(BeFalse()) }) }) + +var _ = Describe("Server address", func() { + It("reports nothing when unset", func() { + _, _, ok := ServerAddressFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) + + It("round-trips the scheme and host", func() { + ctx := WithServerAddress(context.TODO(), "https", "music.example.com") + + scheme, host, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(scheme).To(Equal("https")) + Expect(host).To(Equal("music.example.com")) + }) + + It("reports nothing when the host is empty", func() { + ctx := WithServerAddress(context.TODO(), "https", "") + + _, _, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("is carried over to a background context by AddValues", func() { + reqCtx := WithServerAddress(context.TODO(), "https", "music.example.com") + + _, host, ok := ServerAddressFrom(AddValues(context.Background(), reqCtx)) + Expect(ok).To(BeTrue()) + Expect(host).To(Equal("music.example.com")) + }) +}) diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go index 49b9a285d..7a19eddb9 100644 --- a/plugins/host_artwork.go +++ b/plugins/host_artwork.go @@ -14,24 +14,24 @@ func newArtworkService() host.ArtworkService { return &artworkServiceImpl{} } -func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 218d3d892..50a1a0335 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -122,7 +123,7 @@ var _ = Describe("ArtworkService", Ordered, func() { Size: size, } inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_artwork", inputBytes) + _, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes) if err != nil { return "", err } @@ -189,6 +190,14 @@ var _ = Describe("ArtworkService", Ordered, func() { Expect(artID.ID).To(Equal("al-456")) }) + It("uses the address of the request that triggered the plugin", func() { + ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com") + + url, err := callTestArtwork(ctx, "track", "mf-789", 300) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(HavePrefix("https://music.example.com/share/img/")) + }) + It("should handle unknown artwork type", func() { _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) Expect(err).To(HaveOccurred()) diff --git a/server/middlewares.go b/server/middlewares.go index 23e11eaa6..b710b4068 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -208,6 +208,8 @@ func serverAddressMiddleware(h http.Handler) http.Handler { if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme + // Recorded so code running without the request (e.g. plugins) can build public URLs. + r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost)) } // Call the next handler in the chain with the modified request and response. diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 5cecba7d5..4ba9a853b 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -69,10 +70,15 @@ var _ = Describe("middlewares", func() { middleware http.Handler recorder *httptest.ResponseRecorder req *http.Request + gotScheme string + gotHost string + gotOK bool ) BeforeEach(func() { + gotScheme, gotHost, gotOK = "", "", false nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context()) w.WriteHeader(http.StatusOK) }) middleware = serverAddressMiddleware(nextHandler) @@ -89,6 +95,13 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("example.com")) Expect(req.URL.Scheme).To(Equal("http")) }) + + It("should record the address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("http")) + Expect(gotHost).To(Equal("example.com")) + }) }) Context("with X-Forwarded-Host header", func() { @@ -143,6 +156,22 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("forwarded.example.com")) Expect(req.URL.Scheme).To(Equal("https")) }) + + It("should record the forwarded address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("https")) + Expect(gotHost).To(Equal("forwarded.example.com")) + }) + + It("lets a handler build a public URL on the forwarded address", func() { + var got string + serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil) + })).ServeHTTP(recorder, req) + + Expect(got).To(Equal("https://forwarded.example.com/share/img/token")) + }) }) Context("with multiple values in X-Forwarded-Host header", func() { diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 13a7e4c32..d67cfe456 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -81,8 +81,8 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s } func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { - s.URL = ShareURL(r, s.ID) - s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) + s.URL = ShareURL(r.Context(), s.ID) + s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -92,7 +92,7 @@ func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { for i := range s.Tracks { id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) + s.Tracks[i].Path = publicurl.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil) } return &s } diff --git a/server/public/public.go b/server/public/public.go index 18867e1c4..142c474bd 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -1,6 +1,7 @@ package public import ( + "context" "net/http" "path" @@ -59,7 +60,7 @@ func (pub *Router) routes() http.Handler { return r } -func ShareURL(r *http.Request, id string) string { +func ShareURL(ctx context.Context, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicurl.PublicURL(r, uri, nil) + return publicurl.PublicURL(ctx, uri, nil) } diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d32a5c857..ac0edb69f 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -231,9 +231,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description if !album.ImageAbsent { - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 1200) } response.AlbumInfo.LastFmUrl = album.ExternalUrl @@ -298,9 +298,9 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography if !artist.ImageAbsent { - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + base.SmallImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 1200) } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e367cdee5..2d9d53b18 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -112,7 +112,7 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -132,7 +132,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { UserRating: int32(a.Rating), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index cc787f041..35233a98f 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -116,7 +116,7 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), } if !artist.ImageAbsent { - a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + a.ArtistImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 540ae79d7..36124c40b 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -31,7 +31,7 @@ func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { resp := responses.Share{ ID: share.ID, - Url: public.ShareURL(r, share.ID), + Url: public.ShareURL(r.Context(), share.ID), Description: share.Description, Username: share.Username, Created: share.CreatedAt, From c9385fbb6b2e985d6c03034cf44eb05237c7e417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 21:42:10 -0400 Subject: [PATCH 16/27] test(plugins): build test plugins in Go instead of shelling out to make (#6060) * test(plugins): build test plugins in Go instead of shelling out to make The plugins suite built its .ndp test packages by running `make -C plugins/testdata`, which needs make and zip on the PATH. That is the reason the 26 WASM-dependent spec files are tagged //go:build !windows. buildTestPlugins now does the same work in Go: the same mtime check make performed, `GOOS=wasip1 GOARCH=wasm go build` per plugin, and archive/zip for the package. TinyGo was already optional and unused in CI, so nothing is lost there. The first plugin builds on its own so the shared wasip1 stdlib and PDK objects land in the build cache before the rest fan out: on a cold cache that is 2.2s against 3.4s for the sequential make and 7.3s for an unrestrained fan-out. Packaging moved into a writeNdp helper shared with createTestPackage, which was already writing the same two-entry archive. Entries are written in a fixed order, so the .ndp bytes are now reproducible; the loader hashes those bytes, and `zip` also stored file mtimes, so the previous packages differed on every rebuild. The Makefile is unchanged and still works for building the plugins by hand. Removing the !windows tags is a separate step, once CI is green here. * test(plugins): run the WASM plugin specs on Windows With the test plugins now built in Go, nothing in the suite needs a Unix toolchain, so the //go:build !windows tags come off all 25 spec files. The Windows CI job runs `go test ./...`, so it picks the suite up with no workflow change. plugins_suite_windows_test.go existed only to bootstrap the handful of specs that compiled on Windows; plugins_suite_test.go now serves both. * test(plugins): skip the planted-symlink spec where symlinks need privileges os.Symlink needs an elevated token or Developer Mode on Windows, so the unconditional Expect(...).To(Succeed()) would fail for contributors running the suite on an ordinary Windows box. The elevated GitHub runner hides this. The equivalent spec in sandbox_fs_internal_test.go already attempts the symlink and skips on error; this does the same, keeping the pin live everywhere it can run, including Windows CI. * fix(ci): stop the Windows ndpgen test failing silently The ndpgen suite builds its helper binary to %TEMP%\ndpgen-test, and Windows will not exec a file without an executable extension, so the "supports verbose mode" spec has been failing there. Nobody noticed because the Test ndpgen step ran under pwsh, which carries on after a non-zero exit and takes the step's status from the last command, so the job stayed green with a FAIL line in its log. Add the .exe suffix, and run the step under bash like the Linux job does, so a failure in any of its three commands fails the job. --- .github/workflows/pipeline.yml | 6 +- plugins/cmd/ndpgen/integration_test.go | 4 + plugins/host_artwork_test.go | 2 - plugins/host_cache_test.go | 2 - plugins/host_config_test.go | 2 - plugins/host_httpclient_test.go | 2 - plugins/host_kvstore_test.go | 2 - plugins/host_library_test.go | 2 - plugins/host_matcher_test.go | 2 - plugins/host_scheduler_test.go | 2 - plugins/host_scrobbleretriever_test.go | 2 - plugins/host_storage_test.go | 2 - plugins/host_subsonicapi_test.go | 2 - plugins/host_taskqueue_test.go | 2 - plugins/host_users_test.go | 2 - plugins/host_websocket_test.go | 2 - plugins/lyrics_adapter_test.go | 2 - plugins/manager_call_test.go | 2 - plugins/manager_loader_load_test.go | 2 - plugins/manager_readonly_test.go | 2 - plugins/manager_test.go | 2 - plugins/manager_watcher_test.go | 2 - plugins/metadata_agent_test.go | 2 - plugins/package_test.go | 50 +++++----- plugins/plugins_suite_test.go | 12 --- plugins/plugins_suite_windows_test.go | 23 ----- plugins/sandbox_fs_test.go | 6 +- plugins/scrobbler_adapter_test.go | 2 - plugins/sonic_similarity_adapter_test.go | 2 - plugins/testdata_builder_test.go | 119 +++++++++++++++++++++++ 30 files changed, 156 insertions(+), 110 deletions(-) delete mode 100644 plugins/plugins_suite_windows_test.go create mode 100644 plugins/testdata_builder_test.go diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 9da99e864..37b532eb0 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -237,12 +237,12 @@ jobs: run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v - name: Test ndpgen - shell: pwsh + shell: bash run: | - cd plugins\cmd\ndpgen + cd plugins/cmd/ndpgen go test -shuffle=on -v go build -o ndpgen.exe . - .\ndpgen.exe --help + ./ndpgen.exe --help js: name: Test JS code diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index d8bc5859d..bbe886f8d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" . "github.com/onsi/ginkgo/v2" @@ -32,6 +33,9 @@ var _ = Describe("ndpgen CLI", Ordered, func() { // Build the ndpgen binary ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") + if runtime.GOOS == "windows" { + ndpgenBin += ".exe" // Windows refuses to exec a file without it + } cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") cmd.Dir = mustGetWd(GinkgoT()) output, err := cmd.CombinedOutput() diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 50a1a0335..311bf21d5 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 5e91b04ec..925856701 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index b296d29fb..80b06efe1 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 27e92d59d..4eb34a247 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 997409146..9af3fcbcd 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index eb5b17a02..00a953b24 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go index 9d44ee581..f0967628c 100644 --- a/plugins/host_matcher_test.go +++ b/plugins/host_matcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index ca53aed56..6940d8820 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index ab817c282..aa92c9eb5 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go index d6f895bff..9fc58c396 100644 --- a/plugins/host_storage_test.go +++ b/plugins/host_storage_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index c3d9ffe8a..4b941bc43 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index d459fd69b..72fb4cccd 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 42f6a3032..1721d3ee2 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 2aa85cd21..9f2d20bef 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..fe15e40b2 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 3e64f1cee..5e2c6385e 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go index 8f35548af..eb004276e 100644 --- a/plugins/manager_loader_load_test.go +++ b/plugins/manager_loader_load_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go index 9d16b76e9..019b14fbf 100644 --- a/plugins/manager_readonly_test.go +++ b/plugins/manager_readonly_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 9b6f7ea39..6cf90994a 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 5b5ffca02..99326bde1 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index a7a0aa8b8..0a7b6c1e2 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/package_test.go b/plugins/package_test.go index 953797750..1539d8df7 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -2,8 +2,11 @@ package plugins import ( "archive/zip" + "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" @@ -266,6 +269,14 @@ func (h *testZipHelper) close() error { // createTestPackage creates an .ndp package file from a manifest and wasm bytes. // This is primarily used for testing. func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error { + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes)) +} + +func writeNdp(ndpPath string, manifest, wasm io.Reader) error { f, err := os.Create(ndpPath) if err != nil { return fmt.Errorf("creating package file: %w", err) @@ -273,30 +284,23 @@ func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) err defer f.Close() zw := zip.NewWriter(f) - defer zw.Close() - - // Write manifest.json - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("marshaling manifest: %w", err) + add := func(name string, r io.Reader) error { + w, err := zw.Create(name) + if err != nil { + return fmt.Errorf("creating %s in package: %w", name, err) + } + if _, err := io.Copy(w, r); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + return nil } - - mw, err := zw.Create(manifestFileName) - if err != nil { - return fmt.Errorf("creating manifest in zip: %w", err) + // Entry order is fixed: the loader hashes the package bytes, so they must + // be reproducible across rebuilds. + if err := add(manifestFileName, manifest); err != nil { + return err } - if _, err := mw.Write(manifestBytes); err != nil { - return fmt.Errorf("writing manifest: %w", err) + if err := add(wasmFileName, wasm); err != nil { + return err } - - // Write plugin.wasm - ww, err := zw.Create(wasmFileName) - if err != nil { - return fmt.Errorf("creating wasm in zip: %w", err) - } - if _, err := ww.Write(wasmBytes); err != nil { - return fmt.Errorf("writing wasm: %w", err) - } - - return nil + return errors.Join(zw.Close(), f.Close()) } diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 2dae65d00..cc1b45f97 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -7,15 +5,12 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strings" "testing" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -51,13 +46,6 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(path string) { - start := time.Now() - out, err := exec.Command("make", "-C", path).CombinedOutput() - fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s:\n%s", time.Since(start), out) - Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") -} - // createTestManager creates a new plugin Manager with the given plugin config. // It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager. // Returns the manager, temp directory path, and a cleanup function. diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go deleted file mode 100644 index ed43bdcc3..000000000 --- a/plugins/plugins_suite_windows_test.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows - -package plugins - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Runs the subset of plugin specs compiled on Windows (files without the -// //go:build !windows tag): capabilities, manager_cache, manager_plugin, -// manifest, package. WASM-runtime-dependent specs live in !windows-tagged -// files and aren't reached here. -func TestPlugins(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Plugins Suite") -} diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go index ea033283f..0dd9d15ab 100644 --- a/plugins/sandbox_fs_test.go +++ b/plugins/sandbox_fs_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -134,7 +132,9 @@ var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() // Accepted residual, pinned so a future tightening can't happen silently It("still follows a symlink planted in the mount by something else", func() { - Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed()) + if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil { + Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges + } out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 3efd5d1b1..00c9045a4 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index cb08b9acd..e08fc124f 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/testdata_builder_test.go b/plugins/testdata_builder_test.go new file mode 100644 index 000000000..746a8af22 --- /dev/null +++ b/plugins/testdata_builder_test.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildTestPlugins packages every test plugin under dir, replicating +// `make -C plugins/testdata` without needing make or zip on the PATH. +func buildTestPlugins(dir string) { + start := time.Now() + built, err := buildPackages(dir) + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") +} + +func buildPackages(dir string) ([]string, error) { + mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod")) + if err != nil || len(mods) == 0 { + return nil, err + } + pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go")) + if err != nil { + return nil, err + } + + built := make([]string, len(mods)) + errs := make([]error, len(mods)) + build := func(i int) { + pluginDir := filepath.Dir(mods[i]) + var rebuilt bool + if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt { + built[i] = filepath.Base(pluginDir) + } + } + + // The first build populates the wasip1 stdlib and PDK objects every plugin + // shares; fanning out before it lands makes each one compile them again. + build(0) + var wg sync.WaitGroup + for i := range mods[1:] { + wg.Go(func() { build(i + 1) }) + } + wg.Wait() + return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...) +} + +func buildPackage(dir string, pdkTime time.Time) (bool, error) { + sourceTime, err := newestModTime(dir) + if err != nil { + return false, err + } + pkg := dir + PackageExtension + if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) { + return false, nil + } + + wasm, err := filepath.Abs(pkg + ".build.wasm") + if err != nil { + return false, err + } + defer os.Remove(wasm) + // -buildvcs=false keeps the bytes stable across commits, so the suite's + // wazero compilation cache still hits after a rebuild. + cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + if out, err := cmd.CombinedOutput(); err != nil { + return false, fmt.Errorf("building %s: %w\n%s", dir, err, out) + } + + tmp := pkg + ".build.ndp" + defer os.Remove(tmp) + if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil { + return false, fmt.Errorf("packaging %s: %w", dir, err) + } + return true, os.Rename(tmp, pkg) +} + +func packageFiles(pkg, manifest, wasm string) error { + m, err := os.Open(manifest) + if err != nil { + return err + } + defer m.Close() + w, err := os.Open(wasm) + if err != nil { + return err + } + defer w.Close() + return writeNdp(pkg, m, w) +} + +func newestModTime(root string) (time.Time, error) { + var newest time.Time + err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + newest = utils.TimeNewest(newest, info.ModTime()) + return nil + }) + return newest, err +} From 4ed7494a3293a9e9e647897ebfb9be327efd981b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 23:03:09 -0400 Subject: [PATCH 17/27] ci: report Go test coverage on pull requests (#6061) * ci: report Go test coverage on pull requests Adds octocov to the existing 'Test Go code' job. It reads the coverage profile, posts a PR comment with the coverage percentage and the delta against master, and writes the same report to the job summary. The master-branch report is stored as a GitHub Actions artifact, so no external service or secret is needed. * ci: merge the plugins job coverage into the same report The plugins suite runs in its own job, so its coverage was missing from the report. Both jobs now upload their profile as an artifact and a new 'Report coverage' job merges them into a single PR comment. * ci: update the coverage comment in place instead of reposting octocov's default is to collapse the previous comment and create a new one. updatePrevious edits the existing comment instead, so a PR keeps a single coverage comment across pushes. * ci: fix octocov timeout and step-time lookup Storing the report hit the 30s default timeout: scanning this repo's artifacts for the baseline consumed it first. Raise it to 5m. The step-time lookup also matched the Windows job's 'Test' step and waited for a job that was still running, so execution time was dropped from the report. Rename the step to make it unique. * ci: only store the coverage baseline from the default branch * ci: report statement coverage instead of line coverage octocov reports statement coverage for a single profile but switches to line counting when it merges several itself, which made the number disagree with 'go tool cover -func'. Merge the two job profiles into one file first, so the reported number matches what developers see locally. * ci: stop the download-link comment from clobbering the coverage report Both comments are posted by github-actions[bot], and the download-link job updated the first bot comment it found. On a new PR the coverage comment is created first, so it would be overwritten. Match on the body as well, and keep the coverage profiles out of the download list. --- .github/workflows/download-link-on-pr.yml | 11 ++++-- .github/workflows/pipeline.yml | 47 +++++++++++++++++++++-- .octocov.yml | 33 ++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 .octocov.yml diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 5b421331b..80ec18e90 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -34,16 +34,19 @@ jobs: } const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); - if (!artifacts.length) { + const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); + if (!downloadable.length) { return core.error(`No artifacts found`); } - let body = `Download the artifacts for this pull request:\n`; - for (const art of artifacts) { + const header = `Download the artifacts for this pull request:`; + let body = `${header}\n`; + for (const art of downloadable) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); + // Match on the body too: octocov also comments as github-actions[bot]. + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 37b532eb0..7625cf410 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -137,8 +137,10 @@ jobs: - name: Download dependencies run: go mod download - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$') + # Name must stay unique across the workflow: octocov matches step names + # by name across every job, and waits for each match to finish. + - name: Test with coverage + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -147,6 +149,13 @@ jobs: go build -o ndpgen . ./ndpgen --help + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-go + path: coverage.out + if-no-files-found: error + go-plugins: name: Test Go plugins runs-on: ubuntu-latest @@ -169,7 +178,39 @@ jobs: restore-keys: wazero-${{ runner.os }}- - name: Test plugins - run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/ + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ + + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-plugins + path: coverage.out + if-no-files-found: error + + coverage: + name: Report coverage + runs-on: ubuntu-latest + needs: [go, go-plugins] + permissions: + contents: read + pull-requests: write + actions: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + pattern: octocov-* + + # Merge here rather than letting octocov do it: octocov reports statement + # coverage for a single profile, but switches to line counting for several. + - name: Merge coverage profiles + run: | + echo "mode: atomic" > coverage.out + awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ + octocov-*/coverage.out | sort >> coverage.out + + - uses: k1LoW/octocov-action@v1 go-windows: name: Test Go code (Windows) diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 000000000..397b5b364 --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,33 @@ +# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov +# The 30s default is not enough: scanning this repo's artifacts for the baseline +# eats most of it, leaving none for the report upload. +timeout: 5m +coverage: + # A single pre-merged profile: octocov reports statements for one path, but + # switches to line counting when it merges several itself. + paths: + - coverage.out +codeToTestRatio: + code: + - '**/*.go' + - '!**/*_test.go' + - '!**/*_gen.go' + test: + - '**/*_test.go' +testExecutionTime: + if: true + steps: + - Test with coverage + - Test plugins +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} +comment: + if: is_pull_request + updatePrevious: true +summary: + if: true +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} From 09867e5cc18678cb9ae9579c912dc54a597bf651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 07:32:28 -0400 Subject: [PATCH 18/27] ci: comment coverage on pull requests from forks (#6065) * ci: comment coverage on pull requests from forks A pull_request run from a fork gets a read-only GITHUB_TOKEN, so octocov could not post its comment: it logged a 403 and exited 0, leaving the job green and the PR silent. The 'permissions:' block cannot grant what the token does not have. The comment now comes from a workflow_run workflow, which runs on the base repository and does get a write token. The pipeline job keeps the job summary and the default-branch baseline, and hands the merged profile and the PR number to it as an artifact. A workflow_run job otherwise looks like a push to the default branch, so octocov is pointed back at the pull request and at the run that produced the profile via its OCTOCOV_ environment overrides. Without the run id override the test execution time would be read from the wrong run; without the ref override a fork's coverage would be stored as the master baseline. The job holds a write token, so it reads .octocov.yml from the base branch rather than from the fork. * ci: stop checking out the fork in the coverage comment workflow CodeQL flagged the pull request checkout as untrusted code in a privileged context (actions/untrusted-checkout/high): the job holds a write token. The checkout existed only so the code-to-test ratio would reflect the pull request, which does not justify the alert. The workflow now checks out just .octocov.yml from the base branch, and the ratio is skipped when reporting from there. Coverage and its delta against master, the metrics that motivated the report, are unaffected: they come from the profile the pipeline uploads. * ci: treat the coverage artifact as untrusted input A pull_request run executes the fork's own copy of pipeline.yml, so every file in the octocov-pr artifact is attacker-controlled. The artifact was extracted into the workspace root, on top of the base-branch checkout, and download-artifact truncates existing files. A fork could therefore replace .octocov.yml before octocov loaded it. That is not only a config swap. config.Load expands ${VAR} from the job environment and the action sets OCTOCOV_GITHUB_TOKEN, so a crafted comment.message posts the privileged job's token into a public comment; a body: section rewrites a pull request description, which pull-requests: write allows. The artifact now lands in a subdirectory and only coverage.out is copied out, after pr_number is checked to be digits and the named pull request's head is confirmed to be the sha that triggered this run. Without that check the artifact could aim the comment at any open pull request, and unvalidated content reached GITHUB_OUTPUT. --- .github/workflows/coverage-on-pr.yml | 60 ++++++++++++++++++++++++++++ .github/workflows/pipeline.yml | 17 +++++++- .octocov.yml | 7 +++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/coverage-on-pr.yml diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml new file mode 100644 index 000000000..03260cacc --- /dev/null +++ b/.github/workflows/coverage-on-pr.yml @@ -0,0 +1,60 @@ +name: Report coverage on PR +on: + workflow_run: + workflows: ['Pipeline: Test, Lint, Build'] + types: [completed] +jobs: + comment: + name: Comment coverage report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + env: + COVERAGE_COMMENT: 'true' + steps: + # Only the config, from the base branch: this job holds a write token, so + # it must never check out the fork. + - name: Check out the octocov config + uses: actions/checkout@v7 + with: + sparse-checkout: .octocov.yml + sparse-checkout-cone-mode: false + persist-credentials: false + + # Into a subdirectory. A pull_request run executes the fork's own copy of + # pipeline.yml, so every file in here is attacker-controlled. + - uses: actions/download-artifact@v8 + with: + name: octocov-pr + path: untrusted + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify the artifact and take the coverage profile + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') + case "$number" in ''|*[!0-9]*) + echo "::error::artifact pr_number is not a number"; exit 1;; + esac + sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) + if [ "$sha" != "$HEAD_SHA" ]; then + echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 + fi + cp untrusted/coverage.out coverage.out + echo "number=$number" >> "$GITHUB_OUTPUT" + + - uses: k1LoW/octocov-action@v1 + env: + # A workflow_run job looks like a push to the default branch. Point + # octocov back at the pull request and at the run that produced it. + GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} + OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge + OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 7625cf410..aa8e29e49 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -193,8 +193,9 @@ jobs: needs: [go, go-plugins] permissions: contents: read - pull-requests: write actions: write + env: + COVERAGE_COMMENT: 'false' steps: - uses: actions/checkout@v7 @@ -212,6 +213,20 @@ jobs: - uses: k1LoW/octocov-action@v1 + - name: Save the PR number for the comment workflow + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > pr_number + + - name: Upload the merged profile for the comment workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: octocov-pr + path: | + coverage.out + pr_number + if-no-files-found: error + go-windows: name: Test Go code (Windows) runs-on: windows-2022 diff --git a/.octocov.yml b/.octocov.yml index 397b5b364..52d4ff9bf 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,6 +8,9 @@ coverage: paths: - coverage.out codeToTestRatio: + # Needs the pull request's own source, which the comment workflow must not + # check out: it holds a write token. + if: env.COVERAGE_COMMENT != 'true' code: - '**/*.go' - '!**/*_test.go' @@ -23,7 +26,9 @@ diff: datastores: - artifact://${GITHUB_REPOSITORY} comment: - if: is_pull_request + # Only the 'Report coverage on PR' workflow sets this: a pull_request run from + # a fork gets a read-only token, so commenting from here 403s. + if: env.COVERAGE_COMMENT == 'true' updatePrevious: true summary: if: true From 3784fd0ea7c0d902a513777bef9f2c8f015ad42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 11:07:58 -0400 Subject: [PATCH 19/27] fix(artwork): honor a provider's explicit retry-later delay in the circuit breaker (#6056) An explicit RetryLaterError now opens the agent's breaker immediately for the provider's own delay, instead of counting it as one generic failure that needs five to open and then always probes after a fixed minute. --- core/artwork/gate.go | 19 ++++++++++++++++++- core/artwork/gate_test.go | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index ebb65bcbe..aa2ad8f2f 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -1,6 +1,7 @@ package artwork import ( + "cmp" "context" "errors" "io" @@ -95,6 +96,9 @@ type breaker struct { // generation identifies the current open episode, so an answer from a call admitted before // the breaker opened cannot be mistaken for evidence that it has recovered. generation int + // probeAfter overrides the probe delay for the current episode when a provider named its own + // back-off; zero falls back to breakerProbeAfter. + probeAfter time.Duration } func newBreaker() *breaker { return &breaker{} } @@ -107,7 +111,7 @@ func (b *breaker) allow() (bool, int) { if b.failures < breakerThreshold { return true, 0 } - if time.Since(b.openedAt) >= breakerProbeAfter { + if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) { b.openedAt = time.Now() // start a fresh probe window so only one caller passes return true, b.generation } @@ -121,11 +125,24 @@ func (b *breaker) record(name string, gen int, err error) { } b.mu.Lock() defer b.mu.Unlock() + // An explicit back-off is a definitive "stop for this long", so it opens the breaker at once + // with the provider's own delay instead of waiting for the failure threshold. + if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 { + b.recoveries = 0 + b.failures = breakerThreshold + b.openedAt = time.Now() + b.probeAfter = retry.RetryIn + b.generation++ + log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name, + "probeAfter", retry.RetryIn) + return + } if isTransientExternal(err) { b.recoveries = 0 b.failures++ if b.failures == breakerThreshold { b.openedAt = time.Now() + b.probeAfter = 0 b.generation++ log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go index abe723508..a954d4cac 100644 --- a/core/artwork/gate_test.go +++ b/core/artwork/gate_test.go @@ -2,7 +2,9 @@ package artwork import ( "errors" + "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -40,4 +42,15 @@ var _ = Describe("breaker", func() { Expect(allowed(b)).To(BeFalse(), "answers from calls admitted before the breaker opened must not close it") }) + + It("opens at once when a provider asks to retry later, honoring its delay", func() { + b := newBreaker() + Expect(allowed(b)).To(BeTrue(), "starts closed") + + // A single explicit back-off opens the breaker without reaching the failure threshold. + b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second}) + + Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately") + Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval") + }) }) From 88cd1c39374783e30ac7b5bcd9401789243a6cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 17:17:52 -0400 Subject: [PATCH 20/27] fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes. --- adapters/deezer/client.go | 44 +++++++++++++++++------ adapters/deezer/client_test.go | 34 +++++++++++++++++- adapters/deezer/deezer.go | 3 -- adapters/deezer/deezer_test.go | 17 +++++++++ adapters/deezer/responses.go | 8 ++--- adapters/deezer/responses_test.go | 2 +- core/scrobbler/buffered_scrobbler_test.go | 7 ++-- 7 files changed, 90 insertions(+), 25 deletions(-) diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index d51f65dd9..03f37af19 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -13,15 +13,26 @@ import ( "strings" "github.com/microcosm-cc/bluemonday" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" const authBaseURL = "https://auth.deezer.com" -var ( - ErrNotFound = errors.New("deezer: not found") -) +// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200 +// and no rate-limit headers, so the body code is the only signal. +const errCodeQuota = 4 + +type deezerError struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` +} + +func (e *deezerError) Error() string { + return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message) +} type httpDoer interface { Do(req *http.Request) (*http.Response, error) @@ -56,7 +67,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A } if len(results.Data) == 0 { - return nil, ErrNotFound + return nil, agents.ErrNotFound } return results.Data, nil } @@ -74,20 +85,31 @@ func (c *client) makeRequest(req *http.Request, response any) error { return err } + // Checked before the status: a throttled request still answers 200, and decoding its body + // into a result type yields an empty one, which reads as "nothing found". + if err := parseBodyError(data); err != nil { + return err + } if resp.StatusCode != 200 { - return c.parseError(data) + return fmt.Errorf("deezer http status: (%d)", resp.StatusCode) } return json.Unmarshal(data, response) } -func (c *client) parseError(data []byte) error { - var deezerError Error - err := json.Unmarshal(data, &deezerError) - if err != nil { - return err +// parseBodyError returns the error Deezer reported in the body, or nil when it reported none. +func parseBodyError(data []byte) error { + var body errorResponse + // Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer. + _ = json.Unmarshal(data, &body) + switch { + case body.Error == nil: + return nil + case body.Error.Code == errCodeQuota: + return errors.Join(body.Error, agents.ErrRetryLater) + default: + return body.Error } - return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go index 9fa7afdd9..84d981a76 100644 --- a/adapters/deezer/client_test.go +++ b/adapters/deezer/client_test.go @@ -2,12 +2,14 @@ package deezer import ( "bytes" + "errors" "fmt" "io" "net/http" "os" "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -41,7 +43,37 @@ var _ = Describe("client", func() { }) _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(MatchError(ErrNotFound)) + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + + // Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal. + It("reports an exhausted quota as a retryable error, not as a missing artist", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound), + "a throttled lookup would otherwise settle the artist as having no image") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("Quota limit exceeded")) + }) + + It("reports a non-quota body error as a plain error", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Invalid query","code":100}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(), + "only a throttle asks the caller to come back later") }) }) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 1fa10e25c..d3570a29f 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -91,9 +91,6 @@ func isPlaceholderPicture(url string) bool { func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) - if errors.Is(err, ErrNotFound) || len(artists) == 0 { - return nil, agents.ErrNotFound - } if err != nil { return nil, err } diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 360db1f13..82d02c244 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -3,6 +3,7 @@ package deezer import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -80,6 +81,22 @@ var _ = Describe("deezerAgent", func() { Expect(artist.ID).To(Equal(2)) }) + // The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled + // lookup reaching that here would record a permanent absence. + It("surfaces an exhausted quota instead of reporting the artist as not found", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrNotFound when no result matches the name exactly", func() { httpClient.mock("https://api.deezer.com/search/artist", http.Response{ StatusCode: 200, diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go index 266c44c62..6cc95dd4b 100644 --- a/adapters/deezer/responses.go +++ b/adapters/deezer/responses.go @@ -22,12 +22,8 @@ type Artist struct { Type string `json:"type"` } -type Error struct { - Error struct { - Type string `json:"type"` - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` +type errorResponse struct { + Error *deezerError `json:"error"` } type RelatedArtists struct { diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go index a9de5c5fb..5a3fc7798 100644 --- a/adapters/deezer/responses_test.go +++ b/adapters/deezer/responses_test.go @@ -26,7 +26,7 @@ var _ = Describe("Responses", func() { Describe("Error", func() { It("parses the error response correctly", func() { - var errorResp Error + var errorResp errorResponse body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) err := json.Unmarshal(body, &errorResp) Expect(err).To(BeNil()) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index fd972e87d..16172194b 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -251,12 +251,13 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { "user1": 10 * time.Second, "user2": 45 * time.Second, }} + // Both are buffered before the drain goroutine exists: it drains once on startup, and + // seeing only one user there would park it on that user's delay, ignoring the other. + _ = buffer.Enqueue("test", "user1", "1", time.Now()) + _ = buffer.Enqueue("test", "user2", "2", time.Now()) bs := newBufferedScrobbler(ds, scr, "test") defer bs.Stop() - // user2 is enqueued directly so both are buffered before the first drain wakes. - _ = buffer.Enqueue("test", "user2", "2", time.Now()) - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) synctest.Wait() if got := scr.count.Load(); got != 2 { t.Fatalf("expected both users drained, got %d attempts", got) From b77fb45088e90c9530a17acfc34d30f1670c0314 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 1 Sep 2026 20:32:25 -0400 Subject: [PATCH 21/27] ci: exclude test helpers and generated code from the coverage report The coverage profile counted the tests/ package and the *_gen.go files, none of which are code under test: tests/ is the mock and helper package, and generated code is never hand-tested. Together they added 2926 uncounted statements at 0%, pulling the reported number down by almost 6 points (70.13% -> 75.98% on the current master profile). octocov's coverage.exclude takes doublestar globs matched against git-root-relative paths. All 26 mock_*.go files live under tests/, so the single 'tests/**' pattern covers them. --- .octocov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.octocov.yml b/.octocov.yml index 52d4ff9bf..467998b51 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -7,6 +7,10 @@ coverage: # switches to line counting when it merges several itself. paths: - coverage.out + # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + exclude: + - 'tests/**' + - '**/*_gen.go' codeToTestRatio: # Needs the pull request's own source, which the comment workflow must not # check out: it holds a write token. From 47bc3c00f3a526ba4879307a6026004c241c83d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 20:48:41 -0400 Subject: [PATCH 22/27] fix(artwork): never retry absent artwork on its own (#6054) An absent artwork state was revisited by an hourly job, by viewing the entity, and by the startup backfill on any artwork config change. On a large library the last one queued tens of thousands of external lookups at once and got the provider to rate-limit us for hours. Nothing revisits an absent state now. Retrying is explicit: `artwork reprocess` on the CLI, or the refresh button in the UI. The config fingerprint survives only as an advisory, warning at startup and naming the command that clears it. Since absent is terminal, `artwork status` splits it into two disjoint columns, and `--source failed` targets only the ones that gave up rather than being answered. Both read through the filter CountBySource and EnqueueBySource already share, so the reported number is the set the command acts on. Also fixes the last_failure default left by 20260819204637, which marked every pre-existing absent row as failed, and removes the code the deleted retry paths orphaned. --- cmd/artwork.go | 123 +++++---- cmd/artwork_test.go | 102 ++++--- cmd/root.go | 30 +-- consts/consts.go | 9 +- core/artwork/artwork.go | 9 +- core/artwork/artwork_test.go | 17 +- core/artwork/housekeeping.go | 125 +++------ core/artwork/housekeeping_test.go | 248 ++---------------- core/artwork/worker.go | 25 +- core/artwork/worker_test.go | 30 ++- ...1225726_normalize_artwork_last_failure.sql | 9 + model/album.go | 1 - model/artist.go | 1 - model/artwork.go | 33 ++- model/mediafile.go | 2 - model/playlist.go | 1 - model/radio.go | 1 - persistence/album_repository.go | 6 +- persistence/album_repository_test.go | 4 +- persistence/artist_repository.go | 6 +- persistence/artist_repository_test.go | 4 +- persistence/artwork_hydration_test.go | 2 +- persistence/artwork_queue_repository.go | 35 +-- persistence/artwork_queue_repository_test.go | 83 +++--- persistence/mediafile_repository.go | 6 +- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 4 +- persistence/radio_repository.go | 8 - persistence/radio_repository_test.go | 12 - tests/mock_album_repo.go | 9 - tests/mock_artist_repo.go | 9 - tests/mock_artwork_queue_repo.go | 73 +----- tests/mock_mediafile_repo.go | 8 - tests/mock_playlist_repo.go | 9 - tests/mock_radio_repository.go | 9 - 35 files changed, 341 insertions(+), 718 deletions(-) create mode 100644 db/migrations/20260901225726_normalize_artwork_last_failure.sql diff --git a/cmd/artwork.go b/cmd/artwork.go index 5cd4fc146..9a64cd8c3 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -42,9 +42,10 @@ func init() { "stored trace of the last resolution; also initializes plugin agents, which may open "+ "external connections") artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") + "kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable") artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, - "only items currently resolved from these sources (e.g. folder, external:deezer, absent)") + "only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+ + "or failed for the absent ones that gave up)") artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, "report what would be queued and exit without queueing") @@ -113,7 +114,7 @@ var artworkCancelCmd = &cobra.Command{ "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + - "backfill, not to stop the worker.", + "reprocess, not to stop the worker.", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { runCancel(cmd.Context()) @@ -122,7 +123,7 @@ var artworkCancelCmd = &cobra.Command{ var artworkStatusCmd = &cobra.Command{ Use: "status", - Short: "Report the artwork queue, where artwork resolves from, and the backfill state", + Short: "Report the artwork queue, where artwork resolves from, and the config state", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { runStatus(cmd.Context()) @@ -146,9 +147,11 @@ type sourceCount struct { count int64 } +// absentCount partitions a kind's absent states: noImage was answered, failed gave up. type absentCount struct { - kind model.Kind - model.ArtworkAbsentStat + kind model.Kind + noImage int64 + failed int64 } type statusReport struct { @@ -170,16 +173,6 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 { return n } -func (r statusReport) backfillQueued() int64 { - var n int64 - for _, s := range r.queue { - if s.Priority == model.ArtworkPriorityBackfill { - n += s.Count - } - } - return n -} - func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { q := ds.ArtworkQueue(ctx) var rep statusReport @@ -188,8 +181,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) } - cutoff := time.Now().Add(-artwork.StaleAbsentAge) - for _, k := range artwork.RecheckKinds { + for _, k := range artwork.ReprocessKinds { sources, err := q.SourcesInUse(k) if err != nil { return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) @@ -201,12 +193,15 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) } rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) + // An absent state is exactly a row with no source, so it needs no second query. + if s == "" { + failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed}) + if err != nil { + return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) + } + rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed}) + } } - stat, err := q.CountAbsent(k, cutoff) - if err != nil { - return rep, fmt.Errorf("counting absent %s artwork: %w", k, err) - } - rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat}) } rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() @@ -234,19 +229,20 @@ func formatStatus(rep statusReport) string { } fmt.Fprintln(w, "\nAbsent (resolved, no image found)") - fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK") + fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED") for _, a := range rep.absent { - fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale) + fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed) } - fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n", - artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch) + fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)") + fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+ + " 'artwork reprocess --source failed' retries just those)") - fmt.Fprintln(w, "\nBackfill") - fmt.Fprintf(w, " State:\t%s\n", backfillState(rep)) + fmt.Fprintln(w, "\nConfig") + fmt.Fprintf(w, " State:\t%s\n", configState(rep)) fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) if len(rep.inputs) > 0 { - fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):") + fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):") for _, in := range rep.inputs { fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) } @@ -256,18 +252,10 @@ func formatStatus(rep statusReport) string { return sb.String() } -// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually -// already stored the new fingerprint, and "up to date" would bury the flood it is still working through. -func backfillState(rep statusReport) string { - pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup" - if n := rep.backfillQueued(); n > 0 { - if rep.stored != rep.current { - return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending) - } - return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n) - } +func configState(rep statusReport) string { if rep.stored != rep.current { - return pending + return "fingerprint changed — stored artwork keeps the old resolution; " + + "run 'artwork reprocess --all' to apply it" } return "up to date" } @@ -297,8 +285,8 @@ type artworkPriority struct { var knownPriorities = []artworkPriority{ {"bump", model.ArtworkPriorityBump}, {"scan", model.ArtworkPriorityScan}, - {"backfill", model.ArtworkPriorityBackfill}, {"recheck", model.ArtworkPriorityRecheck}, + {"backfill", model.ArtworkPriorityBackfill}, } // priorityName falls back to the number: a row written by a newer version still has to print. @@ -351,29 +339,41 @@ func runReprocess(ctx context.Context) { func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { // A source filter on its own is already a complete selection, so it does not also need a kind. if all || (len(kinds) == 0 && len(sources) > 0) { - return artwork.RecheckKinds, nil + return artwork.ReprocessKinds, nil } if len(kinds) == 0 { return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") } return parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.RecheckKinds) + return parseArtworkKind(s, artwork.ReprocessKinds) }) } -// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI. -const absentSource = "absent" +// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and +// failedSource the subset of it that gave up rather than being answered. +const ( + absentSource = "absent" + failedSource = "failed" +) func repositorySources(sources []string) []string { return slice.Map(sources, func(s string) string { - if s == absentSource { + switch s { + case absentSource: return "" + case failedSource: + return model.ArtworkSourceFailed } return s }) } -func displaySource(s string) string { return cmp.Or(s, absentSource) } +func displaySource(s string) string { + if s == model.ArtworkSourceFailed { + return failedSource + } + return cmp.Or(s, absentSource) +} type confirmFunc func(out io.Writer, total, external int64) bool @@ -447,7 +447,7 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { return nil } var inUse []string - for _, k := range artwork.RecheckKinds { + for _, k := range artwork.ReprocessKinds { found, err := q.SourcesInUse(k) if err != nil { return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) @@ -456,14 +456,16 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { } var unknown []string for _, s := range sources { - if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent + // The reserved absent and failed sources are valid even when nothing currently matches them. + if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) { unknown = append(unknown, displaySource(s)) } } if len(unknown) == 0 { return nil } - valid := slice.Map(inUse, displaySource) + // failed is accepted but never stored, so listing only what is in use would hide it. + valid := append(slice.Map(inUse, displaySource), failedSource) slices.Sort(valid) return fmt.Errorf("no artwork resolves from %s; sources in use: %s", strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) @@ -478,6 +480,18 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin return err } + // Derived from what actually drives the queries, so a filter added to this signature cannot + // silently keep stamping the fingerprint for a partial run. + markApplied := func() error { + if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) { + return nil + } + if err := artwork.MarkConfigApplied(ctx, ds); err != nil { + return fmt.Errorf("recording the applied artwork config: %w", err) + } + return nil + } + matched := make([]int64, len(kinds)) var total, external int64 for i, k := range kinds { @@ -496,8 +510,9 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin fmt.Fprintln(out, "\nDry run: nothing was queued.") return nil case total == 0: + // An empty match set still leaves nothing resolved under the old config. fmt.Fprintln(out, "Nothing was queued.") - return nil + return markApplied() case !confirm(out, total, external): fmt.Fprintln(out, "Aborted: nothing was queued.") return nil @@ -519,7 +534,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin if skipped := total - queued; skipped > 0 { fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) } - return nil + return markApplied() } func runCancel(ctx context.Context) { @@ -546,7 +561,7 @@ func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, if len(kinds) == 0 && len(priorities) == 0 { return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") } - // RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them. + // RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them. outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { return parseArtworkKind(s, artwork.RefreshableKinds) }) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index a7220d2d5..71b450914 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -3,7 +3,6 @@ package cmd import ( "context" "errors" - "fmt" "io" "strings" "time" @@ -20,20 +19,20 @@ import ( var _ = Describe("parseArtworkKind", func() { It("accepts a supported kind", func() { - k, err := parseArtworkKind("ar", artwork.RecheckKinds) + k, err := parseArtworkKind("ar", artwork.ReprocessKinds) Expect(err).ToNot(HaveOccurred()) Expect(k).To(Equal(model.KindArtistArtwork)) }) It("rejects an unknown kind and lists the valid ones", func() { - _, err := parseArtworkKind("zz", artwork.RecheckKinds) + _, err := parseArtworkKind("zz", artwork.ReprocessKinds) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ar")) Expect(err.Error()).To(ContainSubstring("al")) }) It("rejects a known kind the command does not accept", func() { - _, err := parseArtworkKind("mf", artwork.RecheckKinds) + _, err := parseArtworkKind("mf", artwork.ReprocessKinds) Expect(err).To(HaveOccurred()) }) @@ -442,13 +441,13 @@ var _ = Describe("artwork reprocess selection", func() { It("returns every kind for --all", func() { ks, err := selectedKinds(nil, nil, true) Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.RecheckKinds)) + Expect(ks).To(ConsistOf(artwork.ReprocessKinds)) }) It("returns every kind for a source filter given without a kind", func() { ks, err := selectedKinds(nil, []string{"folder"}, false) Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection") + Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection") }) It("returns only the named kinds", func() { @@ -512,6 +511,11 @@ var _ = Describe("repositorySources", func() { Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) }) + It("maps the failed name onto the pseudo-source, and back for display", func() { + Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed})) + Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource)) + }) + It("keeps an empty selection empty, meaning every source", func() { Expect(repositorySources(nil)).To(BeEmpty()) }) @@ -607,6 +611,25 @@ var _ = Describe("reprocessArtwork", func() { Expect(queue.Count()).To(BeZero()) }) + DescribeTable("records the applied config only for a run that leaves nothing on the old one", + func(selected []model.Kind, sources []string, dryRun, applied bool) { + Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) + + want := "stale-fingerprint" + if applied { + want = artwork.ConfigFingerprint() + } + Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) + }, + Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), + Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), + Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false), + Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false), + Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false), + ) + It("queues the matching items at recheck priority, leaving their artwork state alone", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) @@ -760,6 +783,14 @@ var _ = Describe("reprocessArtwork", func() { imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") }) + It("names failed among the valid sources when rejecting a typo", func() { + err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed"), + "failed is accepted but never stored, so it has to be named explicitly") + }) + It("accepts a source another kind uses, letting the empty selection report itself", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, imageAgents, false, decline, &out)).To(Succeed()) @@ -792,14 +823,16 @@ var _ = Describe("collectStatus", func() { ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour)) - put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) + // ar-3 is absent because it gave up, so the two absent artists split across the columns. + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", + ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) }) - It("reports the queue, the source distribution and the absent ages", func() { + It("reports the queue, the source distribution and the absent totals", func() { rep, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -810,8 +843,8 @@ var _ = Describe("collectStatus", func() { sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, )) - Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, - ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}})) + Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}), + "two absent artists, one answered and one that gave up") }) It("compares the stored fingerprint against the current one", func() { @@ -845,7 +878,7 @@ var _ = Describe("formatStatus", func() { {kind: model.KindArtistArtwork, source: "", count: 2}, }, absent: []absentCount{ - {kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}, + {kind: model.KindArtistArtwork, noImage: 1, failed: 1}, }, inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, stored: "abc123", @@ -878,50 +911,37 @@ var _ = Describe("formatStatus", func() { Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) }) - It("prints the absent total and how many are due for recheck", func() { - absent := block(formatStatus(rep), "Absent (resolved, no image found)") - Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) + It("partitions the absent states into answered and gave up", func() { + out := block(formatStatus(rep), "Absent (resolved, no image found)") + Expect(out).To(ContainSubstring("NO IMAGE")) + Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent") + Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed")) }) - It("states the recheck window and the drip rate the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours()))) - Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) + It("says absent states are never retried on their own, and names both commands that do", func() { + out := formatStatus(rep) + Expect(out).To(ContainSubstring("nothing retries these")) + Expect(out).To(ContainSubstring("artwork reprocess --source absent")) + Expect(out).To(ContainSubstring("artwork reprocess --source failed")) }) - It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { - out := block(formatStatus(rep), "Backfill") - Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`), - "an operator scanning for trouble must not read 'up to date' while 2 items churn") - Expect(out).To(ContainSubstring("fingerprint up to date")) - }) - - It("keeps the re-enqueue warning while a backfill is already running", func() { - rep.stored = "older" - - out := block(formatStatus(rep), "Backfill") - Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`)) - Expect(out).To(ContainSubstring("re-enqueued"), - "the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one") - }) - - It("reports up to date only once the backfill has drained", func() { - rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}} - - Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`)) + It("reports a matching fingerprint as up to date, whatever else is queued", func() { + Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`)) }) It("echoes the config inputs a fingerprint change would have come from", func() { - out := block(formatStatus(rep), "Backfill") + out := block(formatStatus(rep), "Config") Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") }) - It("reports a changed fingerprint as a pending re-resolve of everything", func() { + It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() { rep.stored = "older" rep.queue = nil out := formatStatus(rep) Expect(out).To(ContainSubstring("fingerprint changed")) + Expect(out).To(ContainSubstring("artwork reprocess --all")) Expect(out).ToNot(ContainSubstring("up to date")) }) @@ -1015,7 +1035,7 @@ var _ = Describe("needsImageAgents", func() { It("is false once the chains no longer reach an agent", func() { conf.Server.CoverArtPriority = "cover.*" conf.Server.ArtistArtPriority = "artist.*" - Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse()) + Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse()) }) }) diff --git a/cmd/root.go b/cmd/root.go index 94f861f40..c4e360010 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -366,21 +366,18 @@ func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() erro } } -// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the -// recurring stale-absent recheck and prune jobs. +// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and +// reports an artwork config change without acting on it. func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { return func() error { schedulerInstance := scheduler.GetInstance() - if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { - if err := worker.EnqueueStaleAbsentAll(ctx); err != nil { - log.Error(ctx, "Error enqueueing stale artwork rechecks", err) - } + if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { if err := worker.EnqueueMissingAll(ctx); err != nil { log.Error(ctx, "Error enqueueing missing artwork rechecks", err) } }); err != nil { - log.Error(ctx, "Error scheduling artwork stale-absent recheck", err) + log.Error(ctx, "Error scheduling artwork missing-state recheck", err) } if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { @@ -397,23 +394,8 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu log.Error(ctx, "Error enqueueing missing artwork rechecks", err) } - backfilled, err := worker.Backfill(ctx) - if err != nil { - log.Error(ctx, "Error running artwork backfill", err) - return nil - } - if !backfilled { - return nil - } - log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune") - timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay) - defer timer.Stop() - select { - case <-timer.C: - if err := worker.RunPrune(ctx); err != nil { - log.Error(ctx, "Error running post-backfill artwork prune", err) - } - case <-ctx.Done(): + if err := worker.ReconcileConfig(ctx); err != nil { + log.Error(ctx, "Error checking the artwork config fingerprint", err) } return nil } diff --git a/consts/consts.go b/consts/consts.go index 2934cd968..0f950ef10 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -24,8 +24,8 @@ const ( LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" DBAnalyzePendingKey = "DBAnalyzePending" DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" - // ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against - // to detect artwork-affecting config changes across restarts. + // ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check + // compares against to detect artwork-affecting config changes across restarts. ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" @@ -39,9 +39,8 @@ const ( DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour - ArtworkStaleAbsentRecheckSchedule = "@every 1h" - ArtworkPruneSchedule = "@daily" - ArtworkPostBackfillPruneDelay = 10 * time.Minute + ArtworkEnqueueMissingSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index e8458a0f9..663d06d25 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -118,10 +118,6 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa } } -// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't -// hammer external services; below StaleAbsentAge to catch younger absences. -const requestRecheckAge = time.Hour - func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary) switch { @@ -130,10 +126,7 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i case err != nil: return nil, err case ia.Hash == "": - // Inserts an immediately-eligible recheck for a settled absent row. - if time.Since(ia.AttemptedAt) > requestRecheckAge { - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - } + // Settled absent: only an explicit reprocess or refresh retries it. return nil, ErrUnavailable default: return s.serveHash(ctx, artID, ia, size, square) diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 7de5475d6..907b300de 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -204,28 +204,15 @@ var _ = Describe("Artwork", func() { Expect(err).To(MatchError(ErrUnavailable)) }) - It("does not re-enqueue a recently-attempted absent state", func() { + It("never re-enqueues an absent state on view, however old", func() { Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(), + ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), })).To(Succeed()) _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) Expect(err).To(MatchError(ErrUnavailable)) Expect(queueRepo.Data).To(BeEmpty()) }) - - It("promotes a stale absent state at Bump priority on view", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) }) Describe("provisional read-through", func() { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index a3330d7cf..3ca452bdc 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -6,26 +6,18 @@ import ( "slices" "strconv" "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" "github.com/zeebo/xxh3" ) -// StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 30 * 24 * time.Hour - -// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, -// oldest first, so external agents see a flat drip instead of a daily burst. -const StaleAbsentRecheckBatch = 100 - -// RecheckKinds omits media files: they resolve embedded-only, at scan or on view. -var RecheckKinds = []model.Kind{ +// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead +// so bulk enqueues give the most external-dependent kind a queue headstart. +var ReprocessKinds = []model.Kind{ model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, } @@ -34,14 +26,16 @@ var RecheckKinds = []model.Kind{ func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } // RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds -// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker -// resolves them, it just never revisits them on its own. -var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork) +// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker +// resolves them, it just never enumerates them in bulk. +var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork) -// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. -func hasRecheckPath(prefix string) bool { +// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media +// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local +// read, and only a view ever enqueues them. +func settlesAbsentOnGiveUp(prefix string) bool { kind, ok := model.ParseKind(prefix) - return ok && slices.Contains(RecheckKinds, kind) + return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork } // artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. @@ -72,93 +66,36 @@ func ConfigFingerprint() string { return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } -// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one -// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again. -type backfillSummary struct { - Ran bool - PerKind map[string]int64 - Items int64 - MaxExternalLookups int64 -} - -// backfill enqueues artwork resolution for every entity when the config fingerprint changed. -func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) { - start := time.Now() - ctx = auth.WithAdminUser(ctx, ds) +// ReconcileConfigFingerprint warns when the artwork config changed since the library was last +// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. +func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { current := ConfigFingerprint() - props := ds.Property(ctx) - stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") + stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { - return backfillSummary{}, err + return err } - if stored == current { - return backfillSummary{}, nil - } - - // Artists first: few entities, most external-dependent, so they get a queue headstart. - kinds := []struct { - kind model.Kind - fetch func() ([]string, error) - }{ - {model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, - {model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, - {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, - {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, - } - // Counted here, not by the caller: building the agent list constructs every enabled agent, and - // an unchanged fingerprint returns above without ever needing the number. - agents := agentCount() - summary := backfillSummary{Ran: true, PerKind: map[string]int64{}} - for _, k := range kinds { - ids, err := k.fetch() - if err != nil { - return backfillSummary{}, err - } - if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { - return backfillSummary{}, err - } - n := int64(len(ids)) - summary.PerKind[k.kind.Prefix()] = n - summary.Items += n - summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents) - } - - if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { - return backfillSummary{}, err - } - log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items, - "byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups, - "elapsed", time.Since(start)) - return summary, nil -} - -func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { - if len(ids) == 0 { - return nil - } - items := slice.Map(ids, func(id string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, - } - }) - return ds.ArtworkQueue(ctx).Enqueue(items...) -} - -func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { - cutoff := time.Now().Add(-StaleAbsentAge) - queue := ds.ArtworkQueue(ctx) - for _, kind := range RecheckKinds { - if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil { - return err - } + switch stored { + case current: + case "": + // An unset fingerprint counts as current; the alternative warns every upgrading install once. + return MarkConfigApplied(ctx, ds) + default: + log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+ + "the old resolution; run 'navidrome artwork reprocess --all' to apply the change", + "stored", stored, "current", current, "inputs", FingerprintInputs()) } return nil } +// MarkConfigApplied records the current fingerprint as the one the library is resolved under. +func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { + return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) +} + // enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { queue := ds.ArtworkQueue(ctx) - for _, kind := range RecheckKinds { + for _, kind := range ReprocessKinds { if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { return err } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index c9809203a..2027cba9a 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -2,59 +2,17 @@ package artwork import ( "context" - "fmt" "slices" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only -// visible when the ctx carries an admin, so headless work must wrap ctx with one first. -type visibilityPlaylistDS struct { - *tests.MockDataStore - private model.Playlist - tracks model.PlaylistTrackRepository -} - -func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { - repo := tests.CreateMockPlaylistRepo() - repo.TracksRepo = v.tracks - if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { - repo.SetData(model.Playlists{v.private}) - } - return repo -} - -func adminUserRepo() *tests.MockedUserRepo { - repo := tests.CreateMockUserRepo() - Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) - return repo -} - -func noAgents() ImageAgentCount { return ImageAgentCount{} } - -// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can -// assert phase ordering (artists-first) that same-priority timestamps can't guarantee. -type orderTrackingQueueRepo struct { - *tests.MockArtworkQueueRepo - callKinds []string -} - -func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { - if len(items) > 0 { - o.callKinds = append(o.callKinds, items[0].ItemKind) - } - return o.MockArtworkQueueRepo.Enqueue(items...) -} - var _ = Describe("RefreshableKinds", func() { // The two are meant to describe the same fact. Nothing but this test stops them from drifting, // and a drift would have `artwork explain` report state for a kind that keeps none. @@ -72,7 +30,7 @@ var _ = Describe("Housekeeping", func() { var ( ctx context.Context ds *tests.MockDataStore - queueRepo *orderTrackingQueueRepo + queueRepo *tests.MockArtworkQueueRepo propRepo *tests.MockedPropertyRepo ) @@ -84,52 +42,24 @@ var _ = Describe("Housekeeping", func() { conf.Server.Agents = "spotify" conf.Server.EnableExternalServices = true - queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + queueRepo = tests.CreateMockArtworkQueueRepo() propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} }) - seedEntities := func() { - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}}) - ds.MockedArtist = artistRepo - - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1"}}) - ds.MockedAlbum = albumRepo - - playlistRepo := tests.CreateMockPlaylistRepo() - playlistRepo.SetData(model.Playlists{{ID: "pl1"}}) - ds.MockedPlaylist = playlistRepo - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.All = model.Radios{{ID: "ra1"}} - ds.MockedRadio = radioRepo - } - Describe("Fingerprint", func() { - It("changes when a fingerprint-affecting config value changes", func() { - f1 := ConfigFingerprint() - conf.Server.CoverArtPriority = "folder, embedded" - f2 := ConfigFingerprint() - Expect(f1).NotTo(Equal(f2)) - }) + DescribeTable("changes when a fingerprint-affecting config value changes", + func(change func()) { + before := ConfigFingerprint() + change() + Expect(ConfigFingerprint()).NotTo(Equal(before)) + }, + Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }), + Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }), + Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }), + ) - It("changes when ArtistImageFolder changes", func() { - conf.Server.ArtistImageFolder = "/before" - f1 := ConfigFingerprint() - conf.Server.ArtistImageFolder = "/after" - Expect(ConfigFingerprint()).NotTo(Equal(f1)) - }) - - It("changes when EnableM3UExternalAlbumArt is toggled", func() { - conf.Server.EnableM3UExternalAlbumArt = false - f1 := ConfigFingerprint() - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ConfigFingerprint()).NotTo(Equal(f1)) - }) - - // Pinned: a changed formula re-resolves every library on upgrade, flooding external providers. + // Pinned: a changed formula tells every existing install its artwork config went stale. It("hashes a given config to a stable value", func() { conf.Server.CoverArtPriority = "cover.*, embedded" conf.Server.ArtistArtPriority = "artist.*, external" @@ -157,145 +87,23 @@ var _ = Describe("Housekeeping", func() { f1 := ConfigFingerprint() consts.Version = original + "-next" Expect(ConfigFingerprint()).To(Equal(f1), - "the version must not invalidate artwork state: it would re-resolve every entity on every build") + "the version must not invalidate artwork state: every build would report a stale config") }) }) - Describe("Backfill", func() { - It("enqueues nothing and returns false when the stored fingerprint matches", func() { - seedEntities() - Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed()) + Describe("ReconcileConfigFingerprint", func() { + It("records the current fingerprint when none was ever stored", func() { + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - counted := false - s, err := backfill(ctx, ds, func() ImageAgentCount { - counted = true - return ImageAgentCount{Artist: 3, Album: 2} - }) - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(Equal(backfillSummary{})) - Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it") - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) }) - It("runs the backfill when no fingerprint was ever stored", func() { - seedEntities() - - s, err := backfill(ctx, ds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio - - stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey) - Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal(ConfigFingerprint())) - }) - - It("enqueues a private playlist by resolving it under an admin context", func() { - ds.MockedUser = adminUserRepo() - vds := &visibilityPlaylistDS{ - MockDataStore: ds, - private: model.Playlist{ID: "plPrivate", OwnerID: "admin"}, - tracks: &tests.MockPlaylistTrackRepo{}, - } - - s, err := backfill(ctx, vds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) - }) - - It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { - seedEntities() + It("leaves a stale fingerprint stored, so the warning survives a restart", func() { Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - s, err := backfill(ctx, ds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - Expect(queueRepo.callKinds).ToNot(BeEmpty()) - firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) - Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call") - if firstOther >= 0 { - Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"), - "no artist Enqueue may follow another kind") - } - - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill)) - Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) - } - }) - - It("reports what it enqueued, per kind and as an external-lookup ceiling", func() { - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - seedEntities() - - s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} }) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - - Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1})) - Expect(s.Items).To(Equal(int64(5))) - // 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch. - Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2))) - }) - }) - - Describe("EnqueueStaleAbsentAll", func() { - var artRepo *tests.MockArtworkRepo - - BeforeEach(func() { - artRepo = tests.CreateMockArtworkRepo() - ds.MockedArtwork = artRepo - queueRepo.ItemArtworkSource = artRepo - }) - - It("enqueues only absent entries older than the recheck window, across all kinds", func() { - old := time.Now().Add(-StaleAbsentAge - time.Hour) - recent := time.Now().Add(-StaleAbsentAge + time.Hour) - - artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old} - - err := enqueueStaleAbsentAll(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - Expect(queueRepo.Data).To(HaveLen(4)) - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - } - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) - }) - - It("caps each tick at the recheck batch, oldest attempts first", func() { - for i := range StaleAbsentRecheckBatch + 1 { - id := fmt.Sprintf("ar%d", i) - artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary, - Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)} - } - - Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed()) - - Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch)) - // ar0 has the newest attempted_at of the cohort, so it is the one left out. - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) }) }) @@ -315,8 +123,8 @@ var _ = Describe("Housekeeping", func() { }) It("enqueues only entities that have no item_artwork row, across all kinds", func() { - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()} - artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()} + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"} + artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""} err := enqueueMissingAll(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -324,11 +132,11 @@ var _ = Describe("Housekeeping", func() { for _, it := range queueRepo.Data { Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) } - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil()) + Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "al", "al1")).To(BeNil()) + Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil()) }) }) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index bea478aa5..bb09be55e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -23,8 +23,8 @@ import ( const ( workerPollInterval = 5 * time.Second backoffBase = 5 * time.Second - // giveUpAfter bounds the retry budget from enqueue; past it the item falls to the - // periodic stale-absent recheck. + // giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an + // explicit reprocess retries it. giveUpAfter = 12 * time.Hour ) @@ -40,7 +40,6 @@ type drainPool struct { // independently, and pruneMu serializes prune against the store-write window. type Worker struct { proc *processor - agents *agents.Agents cache cache.FileCache ffmpeg ffmpeg.FFmpeg broker events.Broker @@ -55,7 +54,6 @@ type Worker struct { func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { w := &Worker{ proc: &processor{ds: ds, store: store}, - agents: ag, cache: imgCache, ffmpeg: ffmpeg, broker: broker, @@ -133,17 +131,9 @@ func (w *Worker) RunPrune(ctx context.Context) error { return prune(ctx, w.proc.ds, w.proc.store) } -// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, -// artists first. It reports whether the backfill ran. -func (w *Worker) Backfill(ctx context.Context) (bool, error) { - s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) }) - return s.Ran, err -} - -// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most -// StaleAbsentRecheckBatch per kind, oldest first. -func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { - return enqueueStaleAbsentAll(ctx, w.proc.ds) +// ReconcileConfig records the artwork config fingerprint, or warns when it changed. +func (w *Worker) ReconcileConfig(ctx context.Context) error { + return ReconcileConfigFingerprint(ctx, w.proc.ds) } // EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything @@ -265,10 +255,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) break } - // Absent is only recoverable where a periodic recheck revisits it, so other kinds keep - // no row; art already being served is kept, as exhaustion means unreachable, not removed. + // Art already being served is kept: exhaustion means unreachable, not removed. settled := "kept previous state" - if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { + if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) settled = "recorded absent" } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index b0ef665fc..ebb8de251 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/cache" @@ -115,6 +116,29 @@ func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQu return nil } +// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only +// visible when the ctx carries an admin, so headless work must wrap ctx with one first. +type visibilityPlaylistDS struct { + *tests.MockDataStore + private model.Playlist + tracks model.PlaylistTrackRepository +} + +func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { + repo := tests.CreateMockPlaylistRepo() + repo.TracksRepo = v.tracks + if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { + repo.SetData(model.Playlists{v.private}) + } + return repo +} + +func adminUserRepo() *tests.MockedUserRepo { + repo := tests.CreateMockUserRepo() + Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + return repo +} + var _ = Describe("Worker", func() { var ( ctx context.Context @@ -441,9 +465,9 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art") }) - // Media files are excluded from RecheckKinds, so an absent row here would never be - // revisited: a transient read error would look permanent. - It("does not settle absent on exhaustion for a kind with no recheck path", func() { + // Only a view enqueues a media file, and an absent row is exactly what stops a view from + // doing so: a transient read error would look permanent. + It("does not settle absent on exhaustion for a media file", func() { conf.Server.EnableMediaFileCoverArt = true ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ diff --git a/db/migrations/20260901225726_normalize_artwork_last_failure.sql b/db/migrations/20260901225726_normalize_artwork_last_failure.sql new file mode 100644 index 000000000..6de12e757 --- /dev/null +++ b/db/migrations/20260901225726_normalize_artwork_last_failure.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- 20260819204637 added last_failure with DEFAULT '[]', so every row already in the table got a +-- non-empty value. That is how a give-up is now told apart from a definitive "no image", which +-- would report every pre-existing absent row as failed. +UPDATE item_artwork SET last_failure = '' WHERE last_failure = '[]'; + +-- +goose Down +-- Irreversible: a genuine give-up and a backfilled default are indistinguishable once normalized. +SELECT 1; diff --git a/model/album.go b/model/album.go index 5a436fec0..ee24bfa96 100644 --- a/model/album.go +++ b/model/album.go @@ -143,7 +143,6 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) - GetAllIDs(...QueryOptions) ([]string, error) // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in // any of the given library-relative subtrees. GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error) diff --git a/model/artist.go b/model/artist.go index f3704b669..f88b3a974 100644 --- a/model/artist.go +++ b/model/artist.go @@ -90,7 +90,6 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) - GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/artwork.go b/model/artwork.go index 3c0df209b..8856a35fa 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -18,6 +18,10 @@ type Artwork struct { const ImageTypePrimary = "primary" +// ArtworkSourceFailed is a pseudo-source selecting absent states that exhausted the retry budget +// rather than being answered. The "!" keeps it from colliding with a stored source value. +const ArtworkSourceFailed = "!failed" + // ItemImage is per-entity artwork state hydrated at query time; never persisted. type ItemImage struct { ImageHash string `structs:"-" json:"imageHash,omitempty"` @@ -88,11 +92,12 @@ func (i ItemArtworkInfo) Image() ItemImage { } type ArtworkQueueItem struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Priority int `structs:"priority"` - Attempts int `structs:"attempts"` + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + // RetryAt is the earliest time the drain may take this row, not when it will run. RetryAt time.Time `structs:"retry_at"` EnqueuedAt time.Time `structs:"enqueued_at"` // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. @@ -101,7 +106,9 @@ type ArtworkQueueItem struct { // Queue priorities: higher drains first. const ( - ArtworkPriorityRecheck = 0 + ArtworkPriorityRecheck = 0 + // ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues + // it today; it stays named so a row still carrying it can be reported and cancelled. ArtworkPriorityBackfill = 10 ArtworkPriorityScan = 50 ArtworkPriorityBump = 100 @@ -134,15 +141,13 @@ type ArtworkQueueRepository interface { // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a // request-triggered read-through never resets a failed resolution's backoff. EnqueuePreservingBackoff(items ...ArtworkQueueItem) error - // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest - // first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls). - EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (int64, error) // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. EnqueueAllMissing(kind Kind, priority int) (int64, error) // EnqueueIfMissing inserts only for items with no item_artwork row yet. EnqueueIfMissing(items ...ArtworkQueueItem) error // CountBySource reports how many items of a kind currently resolve from the given sources. - // An empty sources slice means every source; "" matches absent state. + // An empty sources slice means every source; "" matches absent state, and the pseudo-source + // ArtworkSourceFailed matches the absent states that gave up. CountBySource(kind Kind, sources []string) (int64, error) // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. SourcesInUse(kind Kind) ([]string, error) @@ -161,9 +166,6 @@ type ArtworkQueueRepository interface { // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; // an empty filter means every one. CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // CountAbsent reports the absent states of a kind, and how many are past the given cutoff, - // eligible for EnqueueStaleAbsent (which drains them limit rows per call). - CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. @@ -175,8 +177,3 @@ type ArtworkQueueStat struct { Priority int Count int64 } - -type ArtworkAbsentStat struct { - Total int64 - Stale int64 -} diff --git a/model/mediafile.go b/model/mediafile.go index 99aee591e..2669018f3 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -553,8 +553,6 @@ type MediaFileRepository interface { // expression, using the logged user's annotations. Limit and offset are ignored. MatchesCriteria(id string, c criteria.Criteria) (bool, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) - // GetAllIDs returns just the media_file IDs for the same row set as GetAll. - GetAllIDs(options ...QueryOptions) ([]string, error) // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given // folders or their direct children. GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error) diff --git a/model/playlist.go b/model/playlist.go index 306401271..abd11b8c4 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -150,7 +150,6 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) - GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error diff --git a/model/radio.go b/model/radio.go index 466ff48b0..013a24beb 100644 --- a/model/radio.go +++ b/model/radio.go @@ -35,6 +35,5 @@ type RadioRepository interface { Exists(id string) (bool, error) Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) - GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 7ac875a51..808c880fb 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -259,8 +259,8 @@ func (r *albumRepository) hydrateArtwork(albums model.Albums) { func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) } -// GetAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. -func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. +func (r *albumRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "album.id") @@ -304,7 +304,7 @@ func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, pat } func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 0fb680cff..c3d7f018f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -152,12 +152,12 @@ var _ = Describe("AlbumRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := albumRepo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := albumRepo.GetAllIDs() + ids, err := albumRepo.getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 67d0df448..1ff291a0f 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -265,9 +265,9 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } -// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the // heavy stats columns and JSON post-processing. -func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +func (r *artistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "artist.id") @@ -284,7 +284,7 @@ func (r *artistRepository) hydrateArtwork(artists model.Artists) { } func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 25472ffe1..d337b4c22 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -285,12 +285,12 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() + ids, err := repo.(*artistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) }) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 2b52d7d82..bb9cda04d 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -578,7 +578,7 @@ var _ = Describe("Artwork hydration", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} // Both phases must filter on their own: the id pre-pass and the chunk fetch. - Expect(repo.GetAllIDs(opts)).To(ConsistOf(plsBest.ID)) + Expect(repo.(*playlistRepository).getAllIDs(opts)).To(ConsistOf(plsBest.ID)) all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists}) Expect(err).ToNot(HaveOccurred()) Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 1c0077fc7..88b6f6f80 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -56,14 +56,6 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { - now := time.Now() - return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ? - FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ? - ORDER BY attempted_at LIMIT ?`, - model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit) -} - func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { entityTable, ok := artworkOwnerTables[kind] if !ok { @@ -110,13 +102,23 @@ func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) } -// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state. +// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the +// absent state, and ArtworkSourceFailed the absent states that gave up. Several are a union, so +// asking for both absent and failed is just absent. func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { f := And{Eq{"item_kind": kind.Prefix()}} - if len(sources) > 0 { - f = append(f, Eq{"source": sources}) + if len(sources) == 0 { + return f } - return f + stored := slices.DeleteFunc(slices.Clone(sources), func(s string) bool { return s == model.ArtworkSourceFailed }) + var match Or + if len(stored) > 0 { + match = append(match, Eq{"source": stored}) + } + if len(stored) != len(sources) { + match = append(match, And{Eq{"hash": ""}, NotEq{"last_failure": ""}}) + } + return append(f, match) } func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) { @@ -231,13 +233,4 @@ func (r *artworkQueueRepository) Count() (int64, error) { return res.Count, err } -// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from. -func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { - var res model.ArtworkAbsentStat - err := r.queryOne(Select("count(*) as total"). - Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)). - From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res) - return res, err -} - var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 1673a5b0e..0481d4193 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -237,41 +237,6 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) }) - It("enqueues stale absent states for recheck", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - old := time.Now().Add(-48 * time.Hour) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) - - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - items, err := repo.DequeueBatch(10) - Expect(err).ToNot(HaveOccurred()) - Expect(items).To(HaveLen(1)) - Expect(items[0].ItemID).To(Equal("stale1")) - Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) - }) - - It("enqueues only the oldest stale absent states up to the limit", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - now := time.Now() - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed()) - - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(2))) - - items, err := repo.DequeueBatch(10) - Expect(err).ToNot(HaveOccurred()) - ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf("oldest", "older")) - }) - It("enqueues entities that have no item_artwork row at all", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) @@ -435,29 +400,49 @@ var _ = Describe("ArtworkQueueRepository", func() { )) }) - It("reports an empty queue as no rows", func() { - Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) - }) - - It("counts absent states and how many are due for recheck", func() { + It("selects only the absent states that gave up, not those a source answered", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - old := time.Now().Add(-48 * time.Hour) for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, - {ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}, - {ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old}, - {ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, } { Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) } - Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))). - To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1})) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), + "an item still serving art is not absent, however its last attempt went") + + // A later success rewrites the row, clearing the record. + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", + ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) }) - It("reports a kind with no absent state as zero, not as an error", func() { - Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{})) + It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, + } { + Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + } + failedAndAbsent := []string{model.ArtworkSourceFailed, ""} + Expect(repo.CountBySource(model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), + "failed is a subset of absent, so asking for both is asking for absent") + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). + To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") }) + + It("reports a kind with nothing failed as zero", func() { + Expect(repo.CountBySource(model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + }) + + It("reports an empty queue as no rows", func() { + Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) + }) + }) Describe("PurgeQueued", func() { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ed18333de..da167b1a8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -308,8 +308,8 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me return wrapMediaFileCursor(cursor), nil } -// GetAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. -func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. +func (r *mediaFileRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "media_file.id") @@ -341,7 +341,7 @@ func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs . // GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 505f23440..bf8d6d5a8 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -208,8 +208,8 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } -// GetAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. -func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. +func (r *playlistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY // on the playlist's own ambiguous. sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name"). @@ -224,7 +224,7 @@ func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index f60b4e7ca..60263807a 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -74,12 +74,12 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() + ids, err := repo.(*playlistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) }) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 915859559..e042ee6bf 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -79,14 +79,6 @@ func (r *radioRepository) hydrateArtwork(radios model.Radios) { func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) } -// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). -func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - sel := r.newSelect(options...).Columns("id") - ids := []string{} - err := r.queryAllSlice(sel, &ids) - return ids, err -} - func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index c35c85ad7..a958f715d 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -79,17 +78,6 @@ var _ = Describe("RadioRepository", func() { }) }) - Describe("GetAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll() - Expect(err).To(BeNil()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() - Expect(err).To(BeNil()) - Expect(ids).To(ConsistOf(slice.Map(want, func(r model.Radio) string { return r.ID }))) - }) - }) - Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index c63f7c425..1b14f225b 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockAlbumRepo() *MockAlbumRepo { @@ -85,14 +84,6 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } -func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(a model.Album) string { return a.ID }), nil -} - func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index af393129e..db7d54d5d 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockArtistRepo() *MockArtistRepo { @@ -117,14 +116,6 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } -func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(options...) - if err != nil { - return nil, err - } - return slice.Map(all, func(a model.Artist) string { return a.ID }), nil -} - func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 51ddf4b61..c482e2150 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -16,7 +16,7 @@ type MockArtworkQueueRepo struct { mu sync.Mutex Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) Err error - // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + // ItemArtworkSource, when set, backs the set-difference insert with real item_artwork state. ItemArtworkSource *MockArtworkRepo // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. ExistingIDs map[string]map[string]bool @@ -226,26 +226,6 @@ func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) return res, nil } -// CountAbsent mirrors the SQL predicate: an absent state is one with no hash. -func (m *MockArtworkQueueRepo) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { - m.mu.Lock() - defer m.mu.Unlock() - var res model.ArtworkAbsentStat - if m.Err != nil || m.ItemArtworkSource == nil { - return res, m.Err - } - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind != kind.Prefix() || ia.Hash != "" { - continue - } - res.Total++ - if ia.AttemptedAt.Before(attemptedBefore) { - res.Stale++ - } - } - return res, nil -} - func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -272,49 +252,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu return nil } -func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil || m.ItemArtworkSource == nil { - return 0, m.Err - } - var stale []model.ItemArtwork - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) { - stale = append(stale, ia) - } - } - slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) }) - // The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget. - stale = stale[:min(limit, len(stale))] - now := time.Now() - var inserted int64 - for _, ia := range stale { - k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) - if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows - continue - } - m.Data[k] = model.ArtworkQueueItem{ - ItemKind: ia.ItemKind, - ItemID: ia.ItemID, - ImageType: ia.ImageType, - Priority: model.ArtworkPriorityRecheck, - RetryAt: now, - EnqueuedAt: now, - } - inserted++ - } - return inserted, nil -} - -// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state. +// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and +// ArtworkSourceFailed the absent states that gave up. func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { if m.ItemArtworkSource == nil { return nil } + matches := func(ia model.ItemArtwork) bool { + if len(sources) == 0 { + return true + } + if slices.Contains(sources, ia.Source) { + return true + } + return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != "" + } var res []model.ItemArtwork for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && (len(sources) == 0 || slices.Contains(sources, ia.Source)) { + if ia.ItemKind == kind.Prefix() && matches(ia) { res = append(res, ia) } } @@ -366,7 +321,7 @@ func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string return inserted, nil } -// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. +// EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f18280fd5..7a1a8f926 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -130,14 +130,6 @@ func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (mode return m.GetCursor(qo...) } -func (m *MockMediaFileRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(mf model.MediaFile) string { return mf.ID }), nil -} - func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index f04ed98c6..0fa9618ae 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -7,7 +7,6 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockPlaylistRepo() *MockPlaylistRepo { @@ -54,14 +53,6 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } -func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(options...) - if err != nil { - return nil, err - } - return slice.Map(all, func(p model.Playlist) string { return p.ID }), nil -} - func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 2baeadc5c..20f81ec45 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -5,7 +5,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) type MockedRadioRepo struct { @@ -74,14 +73,6 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(r model.Radio) string { return r.ID }), nil -} - func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") From bd46284087ad0ceed420e1aaf7f54e380637648b Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:16:14 -0700 Subject: [PATCH 23/27] feat: validate all configuration durations (#6002) * chore: ensure that all durations are nonnegative * make sure you actually include the test file * test(conf): use non-zero durations in the valid_duration fixture Zero is the boundary between the accepted and rejected ranges, so it passes even if the guard is off by one. 1s exercises an ordinary value. --------- Co-authored-by: Deluan --- conf/configuration.go | 27 +++++++++++++++-- conf/configuration_test.go | 45 +++++++++++++++++++++++++++++ conf/testdata/invalid_duration.toml | 12 ++++++++ conf/testdata/valid_duration.toml | 12 ++++++++ consts/consts.go | 6 ++++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 conf/testdata/invalid_duration.toml create mode 100644 conf/testdata/valid_duration.toml diff --git a/conf/configuration.go b/conf/configuration.go index b9c9f027e..ff119417a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -351,6 +351,13 @@ func LoadFromFile(confFile string) { Load(true) } +func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) { + if val.Nanoseconds() < 0 { + log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original) + *val = original + } +} + func Load(noConfigDump bool) { parseIniFileConfiguration() remapEnvVarKeysFromConfig() @@ -418,6 +425,20 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) + durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout) + durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh) + durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration) + durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval) + durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength) + durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait) + + durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate) + durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout) + durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay) + durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout) + // Log deprecated, removed and unknown options for _, o := range deprecatedOptions { logDeprecatedOptions(o.name, o.replacement) @@ -967,7 +988,7 @@ func setViperDefaults() { viper.SetDefault("autoimportplaylists", true) viper.SetDefault("defaultplaylistpublicvisibility", false) viper.SetDefault("playlistspath", "") - viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second) + viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) viper.SetDefault("enablem3uexternalalbumart", false) @@ -1011,14 +1032,14 @@ func setViperDefaults() { viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") - viper.SetDefault("defaultshareexpiration", 8760*time.Hour) + viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration) viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) - viper.SetDefault("authwindowlength", 20*time.Second) + viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength) viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 344ab3595..2c7f8edaa 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -6,9 +6,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -453,6 +455,49 @@ var _ = Describe("Configuration", func() { Entry("INI format", "ini"), Entry("JSON format", "json"), ) + + It("should use default values for negative duration fields", func() { + filename := filepath.Join("testdata", "invalid_duration.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh)) + Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration)) + Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval)) + Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength)) + Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive)) + Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay)) + Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout)) + }) + + It("should use parsed values for duration fields", func() { + conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false) + conf.Load(true) + + configured := 1 * time.Second + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(configured)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured)) + Expect(server.DefaultShareExpiration).To(Equal(configured)) + Expect(server.UIPlaybackReportInterval).To(Equal(configured)) + Expect(server.AuthWindowLength).To(Equal(configured)) + Expect(server.Scanner.WatcherWait).To(Equal(configured)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(configured)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(configured)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured)) + Expect(server.DevInsightsInitialDelay).To(Equal(configured)) + Expect(server.DevPluginCompilationTimeout).To(Equal(configured)) + }) }) var _ = Describe("TLSEnabled", func() { diff --git a/conf/testdata/invalid_duration.toml b/conf/testdata/invalid_duration.toml new file mode 100644 index 000000000..6540fef60 --- /dev/null +++ b/conf/testdata/invalid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "-10s" +SmartPlaylistRefreshDelay = "-10s" +UIPlaybackReportInterval = "-10s" +AuthWindowLength = "-10s" +DefaultShareExpiration = "-10s" +Scanner.WatcherWait = "-10s" +DevActivityPanelUpdateRate = "-10s" +DevArtworkThrottleBacklogTimeout = "-10s" +DevArtistInfoTimeToLive = "-10s" +DevAlbumInfoTimeToLive = "-10s" +DevInsightsInitialDelay = "-10s" +DevPluginCompilationTimeout = "-10s" diff --git a/conf/testdata/valid_duration.toml b/conf/testdata/valid_duration.toml new file mode 100644 index 000000000..6ec4af480 --- /dev/null +++ b/conf/testdata/valid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "1s" +SmartPlaylistRefreshDelay = "1s" +UIPlaybackReportInterval = "1s" +AuthWindowLength = "1s" +DefaultShareExpiration = "1s" +Scanner.WatcherWait = "1s" +DevActivityPanelUpdateRate = "1s" +DevArtworkThrottleBacklogTimeout = "1s" +DevArtistInfoTimeToLive = "1s" +DevAlbumInfoTimeToLive = "1s" +DevInsightsInitialDelay = "1s" +DevPluginCompilationTimeout = "1s" diff --git a/consts/consts.go b/consts/consts.go index 0f950ef10..486ea66bc 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -34,6 +34,8 @@ const ( JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour + DefaultSmartRefresh = 5 * time.Second + DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year DBAnalyzeCheckSchedule = "@every 30m" @@ -71,6 +73,7 @@ const ( DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline DefaultMaxSidebarPlaylists = 100 + DefaultAuthWindowLength = 20 * time.Second RequestThrottleBacklogLimit = 100 RequestThrottleBacklogTimeout = time.Minute @@ -106,6 +109,9 @@ const ( DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') + + DefaultActivityPanelUpdateRate = 300 * time.Millisecond + DefaultPluginCompilationTimeout = time.Minute ) const ( From cb045b8ef3959f0ee225d65857b665dfc1508df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 23:02:15 -0400 Subject: [PATCH 24/27] ci: exclude tests/ from the coverage report on pull requests too (#6070) The exclusion only worked on master. Coverage profiles name files by import path; octocov shortens those to repo-relative paths using the checked-out source, but coverage-on-pr.yml sparse-checks-out only .octocov.yml, so the paths stay as github.com/navidrome/navidrome/tests/mock_*.go and 'tests/**' never matched. '**/*_gen.go' matched either way, which is why only the 30 tests/ files leaked. Every pull request since b77fb45 therefore reported ~-3.7% against master: 447 files on the base side, 477 on the pull request side (#6002, #6069). --- .octocov.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.octocov.yml b/.octocov.yml index 467998b51..c4ad10712 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,8 +8,10 @@ coverage: paths: - coverage.out # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + # Both patterns need the '**/' prefix: the comment workflow has no source tree, + # so octocov cannot shorten the profile's import paths to repo-relative ones. exclude: - - 'tests/**' + - '**/tests/**' - '**/*_gen.go' codeToTestRatio: # Needs the pull request's own source, which the comment workflow must not From 8407fe6dda7e2c24a618127fd5cade2608e9f1c0 Mon Sep 17 00:00:00 2001 From: polybjorn Date: Thu, 3 Sep 2026 00:49:50 +0000 Subject: [PATCH 25/27] fix(ui): reload the playlist after rating or loving a track (#6009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playlistTrack id is a position in the playlist, not a stable key, so refetching a row by id after the annotation is saved can return a different song: in a smart playlist filtered on that annotation the track is gone and every later row has shifted up. The stale-keyed record then renders as a duplicate of its neighbour. Signed-off-by: Bjørn A. Andersen Co-authored-by: Bjørn A. Andersen Co-authored-by: Deluan Quintão --- ui/src/common/useRating.jsx | 22 ++++++--------- ui/src/common/useRating.test.js | 43 +++++++++++++++-------------- ui/src/common/useToggleLove.jsx | 31 ++++++++++----------- ui/src/common/useToggleLove.test.js | 39 +++++++++++++------------- 4 files changed, 65 insertions(+), 70 deletions(-) diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index 2eb5d9eca..82e9bfc06 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -1,11 +1,12 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useRating = (resource, record) => { const [loading, setLoading] = useState(false) const notify = useNotify() const dataProvider = useDataProvider() + const refresh = useRefresh() const mountedRef = useRef(false) const rating = record.rating @@ -17,23 +18,18 @@ export const useRating = (resource, record) => { }, []) const refreshRating = useCallback(() => { - // For playlist tracks, refresh both resources to keep data in sync if (record.mediaFileId) { - // This is a playlist track - refresh both the playlist track and the song - const promises = [ - dataProvider.getOne('song', { id: record.mediaFileId }), - dataProvider.getOne('playlistTrack', { - id: record.id, - filter: { playlist_id: record.playlistId }, - }), - ] - - Promise.all(promises) + // A playlistTrack id is a position, not a stable key: rating a song can drop it out + // of a smart playlist, and that position then holds a different track. Refetching + // the row by id would write the neighbour's data under this row, so reload the list. + dataProvider + .getOne('song', { id: record.mediaFileId }) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + refresh() if (mountedRef.current) { setLoading(false) } @@ -52,7 +48,7 @@ export const useRating = (resource, record) => { } }) } - }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource]) + }, [dataProvider, record.id, record.mediaFileId, refresh, resource]) const rate = (val, id) => { setLoading(true) diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js index b1353512e..ffe9abffd 100644 --- a/ui/src/common/useRating.test.js +++ b/ui/src/common/useRating.test.js @@ -4,6 +4,8 @@ import { useRating } from './useRating' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { setRating: vi.fn(() => Promise.resolve()), @@ -16,13 +18,16 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) describe('useRating', () => { let getOne beforeEach(() => { - getOne = vi.fn(() => Promise.resolve()) + getOne = vi.fn((resource, params) => + Promise.resolve({ data: { id: params.id } }), + ) useDataProvider.mockReturnValue({ getOne }) vi.clearAllMocks() }) @@ -56,9 +61,9 @@ describe('useRating', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', rating: 2, @@ -71,18 +76,21 @@ describe('useRating', () => { // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // rating can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', rating: 1, @@ -92,16 +100,8 @@ describe('useRating', () => { await result.current[0](3, 'sg-10') }) - // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -111,9 +111,10 @@ describe('useRating', () => { await result.current[0](2, 'sg-1') }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx index 3f98a2e21..22468cbe1 100644 --- a/ui/src/common/useToggleLove.jsx +++ b/ui/src/common/useToggleLove.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useToggleLove = (resource, record = {}) => { @@ -15,33 +15,32 @@ export const useToggleLove = (resource, record = {}) => { }, []) const dataProvider = useDataProvider() + const refresh = useRefresh() const refreshRecord = useCallback(() => { - const promises = [] + // A playlistTrack id is a position, not a stable key: loving a song can drop it out of + // a smart playlist, and that position then holds a different track. Refetching the row + // by id would write the neighbour's data under this row, so reload the list instead. + const isPlaylistTrack = !!record.mediaFileId + const target = isPlaylistTrack + ? { resource: 'song', params: { id: record.mediaFileId } } + : { resource, params: { id: record.id } } - // Always refresh the original resource - const params = { id: record.id } - if (record.playlistId) { - params.filter = { playlist_id: record.playlistId } - } - promises.push(dataProvider.getOne(resource, params)) - - // If we have a mediaFileId, also refresh the song - if (record.mediaFileId) { - promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) - } - - Promise.all(promises) + dataProvider + .getOne(target.resource, target.params) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + if (isPlaylistTrack) { + refresh() + } if (mountedRef.current) { setLoading(false) } }) - }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource]) + }, [dataProvider, record.mediaFileId, record.id, refresh, resource]) const toggleLove = () => { const toggle = record.starred ? subsonic.unstar : subsonic.star diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js index 640e9ff89..10db1b3c3 100644 --- a/ui/src/common/useToggleLove.test.js +++ b/ui/src/common/useToggleLove.test.js @@ -4,6 +4,8 @@ import { useToggleLove } from './useToggleLove' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { star: vi.fn(() => Promise.resolve()), @@ -17,6 +19,7 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) @@ -58,9 +61,9 @@ describe('useToggleLove', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', starred: false, @@ -75,18 +78,21 @@ describe('useToggleLove', () => { // Should star using the media file ID expect(subsonic.star).toHaveBeenCalledWith('sg-1') - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // loving can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', starred: true, @@ -98,16 +104,8 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should unstar using the media file ID expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -117,9 +115,10 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { From afb3a2f881a245c28cc7b65888f51ea1a2cbbb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 2 Sep 2026 23:29:31 -0400 Subject: [PATCH 26/27] feat(ui): add Refresh Metadata action to the album and artist pages (#6078) The Refresh Metadata action was only reachable from the Album and Artist context menus, so it could not be triggered from AlbumShow or ArtistShow. This adds an icon-only button, with a tooltip, to the action toolbar on both detail pages. Like the menu entry, it is only rendered for admins. The button is built on react-admin's Button rather than a plain IconButton: the surrounding toolbars use the former, so the theme colour and the icon-only swap at the xs breakpoint are inherited instead of restated. The dataProvider call and its two notifications move into a new useRefreshMetadata hook, which ContextMenus now shares, keeping a single copy of that logic. --- ui/src/album/AlbumActions.jsx | 7 +- ui/src/album/AlbumActions.test.jsx | 68 ++++++++++++ ui/src/artist/ArtistActions.jsx | 7 ++ ui/src/artist/ArtistActions.test.jsx | 32 +++++- ui/src/common/ContextMenus.jsx | 8 +- ui/src/common/RefreshMetadataButton.jsx | 62 +++++++++++ ui/src/common/RefreshMetadataButton.test.jsx | 104 +++++++++++++++++++ ui/src/common/index.js | 2 + ui/src/common/useRefreshMetadata.js | 16 +++ 9 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 ui/src/album/AlbumActions.test.jsx create mode 100644 ui/src/common/RefreshMetadataButton.jsx create mode 100644 ui/src/common/RefreshMetadataButton.test.jsx create mode 100644 ui/src/common/useRefreshMetadata.js diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx index 96cfab09a..c3b3a4bc2 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.jsx @@ -27,7 +27,7 @@ import { } from '../actions' import { formatBytes } from '../utils' import config from '../config' -import { ToggleFieldsMenu } from '../common' +import { RefreshMetadataButton, ToggleFieldsMenu } from '../common' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -138,6 +138,11 @@ const AlbumActions = ({ )} +
{isNotSmall && }
diff --git a/ui/src/album/AlbumActions.test.jsx b/ui/src/album/AlbumActions.test.jsx new file mode 100644 index 000000000..246a15558 --- /dev/null +++ b/ui/src/album/AlbumActions.test.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import AlbumActions from './AlbumActions' + +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: false, + enableDownloads: false, + losslessFormats: 'FLAC,WAV', + }, + mockPermissions: { value: 'admin' }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-redux', () => ({ + useDispatch: () => vi.fn(), + useSelector: () => ({}), +})) + +const mockRefreshMetadata = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('AlbumActions', () => { + const record = { id: 'al1', name: 'Album', size: 1024 } + const refreshLabel = 'resources.album.actions.refresh' + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } }) + }) + + const renderAlbumActions = () => + render( + + + , + ) + + it('refreshes the album metadata for admins', async () => { + renderAlbumActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderAlbumActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) +}) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 2f329a034..c2614c05a 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -26,6 +26,7 @@ import { import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from '../common/artist' +import { RefreshMetadataButton } from '../common/RefreshMetadataButton' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -33,6 +34,7 @@ const useStyles = makeStyles((theme) => ({ padding: '0 !important', background: 'transparent', boxShadow: 'none', + alignItems: 'center', '& .MuiToolbar-root': { minHeight: 'auto', padding: '0 !important', @@ -174,6 +176,11 @@ const ArtistActions = ({ className, record, ...rest }) => { icon={} /> )} + ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index ad25177b3..05f53f835 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -18,20 +18,26 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) -const { mockConfig } = vi.hoisted(() => ({ +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ mockConfig: { enableSharing: true, enableDownloads: true }, + mockPermissions: { value: 'admin' }, })) vi.mock('../config', () => ({ default: mockConfig })) const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) +const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } }) vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => mockNotify, - useDataProvider: () => ({ getList: mockGetList }), + useDataProvider: () => ({ + getList: mockGetList, + refreshMetadata: mockRefreshMetadata, + }), + usePermissions: () => ({ permissions: mockPermissions.value }), useTranslate: () => (x) => x, } }) @@ -64,6 +70,7 @@ describe('ArtistActions', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' const songWithReplayGain = { id: 'rec1', @@ -290,4 +297,25 @@ describe('ArtistActions', () => { expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() }) }) + + describe('Refresh metadata action', () => { + const refreshLabel = 'resources.album.actions.refresh' + + it('refreshes the artist metadata for admins', async () => { + renderArtistActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderArtistActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 606506459..4407392c1 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -30,6 +30,7 @@ import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from './artist' +import { useRefreshMetadata } from './useRefreshMetadata' const useStyles = makeStyles({ noWrap: { @@ -75,6 +76,7 @@ const ContextMenu = ({ const translate = useTranslate() const notify = useNotify() const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() const [anchorEl, setAnchorEl] = useState(null) const isArtist = resource === 'artist' @@ -139,11 +141,7 @@ const ContextMenu = ({ enabled: permissions === 'admin', needData: false, label: translate('resources.album.actions.refresh'), - action: (record) => - dataProvider - .refreshMetadata(resource, record.id) - .then(() => notify('message.metadataRefreshStarted')) - .catch(() => notify('ra.page.error', 'warning')), + action: (record) => refreshMetadata(resource, record.id), }, ...(!hideInfo && { info: { diff --git a/ui/src/common/RefreshMetadataButton.jsx b/ui/src/common/RefreshMetadataButton.jsx new file mode 100644 index 000000000..aa0533ade --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.jsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import Tooltip from '@material-ui/core/Tooltip' +import { makeStyles } from '@material-ui/core/styles' +import { Button, usePermissions, useTranslate } from 'react-admin' +import { MdRefresh } from 'react-icons/md' +import { useRefreshMetadata } from './useRefreshMetadata' + +const useStyles = makeStyles({ + // Tooltip needs a ref-holding child, and react-admin's Button does not forward one. + wrapper: { display: 'inline-flex', verticalAlign: 'middle' }, + button: { minWidth: 'auto' }, +}) + +// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap +// at xs match without restating either rule. +export const RefreshMetadataButton = ({ + resource, + record, + className, + size, +}) => { + const translate = useTranslate() + const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() + const classes = useStyles() + + const handleClick = useCallback( + () => refreshMetadata(resource, record?.id), + [refreshMetadata, resource, record], + ) + + if (permissions !== 'admin' || !record?.id) return null + + const label = translate('resources.album.actions.refresh') + return ( + + + + + + ) +} + +RefreshMetadataButton.propTypes = { + resource: PropTypes.oneOf(['album', 'artist']).isRequired, + record: PropTypes.object, + className: PropTypes.string, + size: PropTypes.oneOf(['small', 'medium']), +} + +RefreshMetadataButton.defaultProps = { + size: 'small', +} diff --git a/ui/src/common/RefreshMetadataButton.test.jsx b/ui/src/common/RefreshMetadataButton.test.jsx new file mode 100644 index 000000000..7bda0497c --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.test.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { RefreshMetadataButton } from './RefreshMetadataButton' + +const mockNotify = vi.fn() +const mockRefreshMetadata = vi.fn() +const { mockPermissions } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('RefreshMetadataButton', () => { + const record = { id: 'al-1', name: 'Album' } + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } }) + }) + + const renderButton = (props = {}) => + render( + + + , + ) + + it('renders an icon-only button labelled by the refresh action', () => { + renderButton() + const button = screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }) + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent('') + }) + + it('shows the label as a tooltip on hover', async () => { + renderButton() + fireEvent.mouseOver( + screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }), + ) + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'resources.album.actions.refresh', + ), + ) + }) + + it('renders nothing for non-admin users', () => { + mockPermissions.value = 'regular' + const { container } = renderButton() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing without a record', () => { + const { container } = renderButton({ record: undefined }) + expect(container).toBeEmptyDOMElement() + }) + + it('requests a refresh for the record and notifies success', async () => { + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'), + ) + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'), + ) + }) + + it('passes the artist resource through', async () => { + renderButton({ resource: 'artist', record: { id: 'ar-1' } }) + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'), + ) + }) + + it('notifies a warning when the request fails', async () => { + mockRefreshMetadata.mockRejectedValue(new Error('boom')) + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'), + ) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 047f5b3cf..0177df326 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -36,6 +36,8 @@ export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' export * from './RatingField' +export * from './RefreshMetadataButton' +export * from './useRefreshMetadata' export * from './useRating' export * from './useSelectedFields' export * from './ToggleFieldsMenu' diff --git a/ui/src/common/useRefreshMetadata.js b/ui/src/common/useRefreshMetadata.js new file mode 100644 index 000000000..d34042919 --- /dev/null +++ b/ui/src/common/useRefreshMetadata.js @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import { useDataProvider, useNotify } from 'react-admin' + +export const useRefreshMetadata = () => { + const dataProvider = useDataProvider() + const notify = useNotify() + + return useCallback( + (resource, id) => + dataProvider + .refreshMetadata(resource, id) + .then(() => notify('message.metadataRefreshStarted')) + .catch(() => notify('ra.page.error', 'warning')), + [dataProvider, notify], + ) +} From a7365e119b4b2fdc3debbff7d8d801c3417f2824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 3 Sep 2026 16:07:53 -0400 Subject: [PATCH 27/27] fix(subsonic): update the playlist `changed` timestamp when renaming a smart playlist (#6082) `buildPlaylist` reported `evaluated_at` as `changed` for smart playlists, so a rename or comment edit was invisible to clients until the next evaluation. A never-evaluated smart playlist also reported the current time on every call, which never settled. Report `updated_at` for every playlist. `refreshCounters` now syncs the stamp it writes back onto the model, and `refreshSmartPlaylist` reuses it for `evaluated_at`. `changed` therefore still equals the evaluation time for a just-evaluated playlist, and `validUntil` stays anchored to it. Original Subsonic always bumps `changed` on any playlist update, so this also aligns the behavior with upstream. --- persistence/playlist_repository.go | 4 +++- persistence/smart_playlist_repository.go | 3 ++- persistence/smart_playlist_repository_test.go | 17 +++++++++++++++++ server/subsonic/playlists.go | 11 +---------- server/subsonic/playlists_test.go | 19 +++++++++++++++++-- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index bf8d6d5a8..bc6bffd25 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -316,11 +316,12 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { } // Update playlist's total duration, size and count + now := time.Now() upd := Update("playlist"). Set("duration", res.Duration). Set("size", res.Size). Set("song_count", res.Count). - Set("updated_at", time.Now()). + Set("updated_at", now). Where(Eq{"id": pls.ID}) _, err = r.executeSQL(upd) if err != nil { @@ -329,6 +330,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) + pls.UpdatedAt = now return nil } diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 65ae4656b..9d2ac9590 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -58,7 +58,8 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - now := time.Now() + // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree + now := pls.UpdatedAt updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) if _, err = r.executeSQL(updSql); err != nil { log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index ddc155fab..6f8684d5c 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -45,6 +45,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Context("after an evaluation", func() { + It("stamps updated_at and evaluated_at with the same instant", func() { + newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + refreshed, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + stored, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.EvaluatedAt).ToNot(BeNil()) + Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) + Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt)) + }) + }) + Context("invalid rules", func() { It("fails to Put it in the DB", func() { rules = &criteria.Criteria{ diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 774a9c430..e64fc9e82 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -133,15 +132,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.SongCount = int32(p.SongCount) pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt - if p.IsSmartPlaylist() { - if p.EvaluatedAt != nil { - pls.Changed = *p.EvaluatedAt - } else { - pls.Changed = time.Now() - } - } else { - pls.Changed = p.UpdatedAt - } + pls.Changed = p.UpdatedAt player, ok := request.PlayerFrom(ctx) if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f18f33b47..c7775c0fa 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -220,7 +220,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(evaluatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) // These should not be set Expect(result.Comment).To(BeEmpty()) @@ -245,7 +245,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(*playlist.EvaluatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) @@ -271,6 +271,21 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("when it was never evaluated", func() { + BeforeEach(func() { + playlist.EvaluatedAt = nil + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("omits validUntil but still reports changed", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.ValidUntil).To(BeNil()) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + }) + }) + Context("with a per-playlist refreshDelay", func() { BeforeEach(func() { playlist.Rules.RefreshDelay = 24 * time.Hour