Add Catppuccin Latte (the light version) theme based on the existing Catppuccin Macchiato theme.
The palette and player styling are adapted for light mode while staying as close as practical
to the existing Macchiato theme behavior. I've opted to use gray for the
color for controls.
The dark version appears to mix a few control/accent colors,
so for Latte I standardized those choices. This might be worth looking
into in a separate PR. It uses gray and blue.
Signed-off-by: Love Billenius <lovebillenius@disroot.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Signed-off-by: Deluan <deluan@navidrome.org>
* Add Moonbase theme
A warm dark theme with gold (#d4a039) accents on deep charcoal
backgrounds (#0a0a09/#141413). Features muted cream text (#e5ddd3),
copper error states (#c45c3c), and subtle earthy secondary tones.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix review comments on Moonbase theme
- Fix CSS selector: use :not(.player-delete) instead of :not([class=".player-delete"])
- Fix MuiFormHelperText override structure: target error key directly
- Remove empty icon: {} and avatar: {} from NDLogin overrides
- Use comma-separated rgba syntax and hex for linear-gradient
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add Moonbase Alpha (light) and rename dark to Moonbase Bravo
Split the Moonbase theme into a complementary pair:
- Moonbase Alpha: warm cream/stone light theme with deep gold accents
- Moonbase Bravo: the original deep charcoal dark theme
Both share the same gold (#d4a039) brand accent, copper error states,
and earthy neutral palette. Alpha uses darkened gold (#9a7420) for
better contrast on light backgrounds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* fix(ui): update Spanish translations and add missing gain keys
- Add missing 'albumGain' and 'trackGain' keys (matches recent additions in pt-br, ru)
- Translate 'Playlists' and 'Shared Playlists' to 'Listas de reproducción' / '...compartidas'
- Translate 'OFFLINE' (server down indicator) to 'DESCONECTADO'
Spanish translation now covers 553/553 keys (was 551/553).
Signed-off-by: Daniel Banariba <banaribad@gmail.com>
* fix(ui): address review feedback on Spanish translations
- Use 'Ganancia del álbum' (with article 'del') for consistency with the existing pattern in line 624 ('album': 'Ganancia del álbum') and 'Artista del álbum'. Thanks @gemini-code-assist for the catch.
- Revert 'playlists' and 'sharedPlaylists' to keep the loanword 'Playlist(s)' which is the form actually used by Spanish-speaking music app users (Spotify ES, etc.) and matches existing usage elsewhere in this same file (e.g. line 48 'Agregar a la playlist').
Signed-off-by: Daniel Banariba <banaribad@gmail.com>
---------
Signed-off-by: Daniel Banariba <banaribad@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* test: fix flaky tests in utils/cache
Two tests in the utils/cache suite were timing- and ordering-dependent
and failed intermittently on CI (notably on the Windows runner).
The FileHaunter tests raced the asynchronous cache-cleanup goroutine with
a fixed 400ms sleep, then asserted the directory state once. On slow
runners the haunter had not finished scrubbing, so the assertion saw the
original files and failed. Replace the fixed sleep with Eventually polling
so the assertions wait for the haunter to converge. While doing so, the
exact set and count of reaped files proved nondeterministic (the empty
file is double-counted in the size loop and LRU survivors depend on
OS access-time ordering), so the assertions now check the haunter's
actual guarantees: the empty file is always scrubbed and the cache stays
within the configured maxSize/maxItems bound. This also lets the
previously-disabled maxItems context and its commented-out assertions be
re-enabled.
The HTTPClient 'caches repeated requests' test relied on a shared
requestsReceived counter that was never reset in BeforeEach. Under
randomized spec order another spec could run first and leave the counter
non-zero, breaking the first assertion. Reset the counter and header in
BeforeEach to make the spec independent of execution order.
Verified with: ginkgo -race -repeat=80 --randomize-all ./utils/cache/
* test: surface errors in dirSize and align Eventually with house style
Address code review feedback on the cache flaky-test fix:
- dirSize now returns (uint64, error) and the maxSize spec asserts the
error is nil. Previously a ReadDir/Info failure silently returned 0,
which always satisfies '<= maxSize' and would mask a real filesystem
error as a passing test.
- dirSize skips non-regular entries (info.Mode().IsRegular()) to match
its doc comment and avoid counting directories or symlinks.
- The Eventually blocks now use .WithTimeout()/.WithPolling() with
time.Duration values instead of string-literal durations, matching the
prevailing pattern in the test suite.
Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count,
CountAll) did not apply an owner filter, so non-admin users saw shares
belonging to other users. The write paths already enforced per-user ownership;
this brings reads in line with them.
Add an addRestriction()/ownerFilter() based scope to share reads, keeping
admins and the headless public-share resolution path unrestricted. Route share
and player Delete through a new base-repo deleteOwned() primitive that applies
the ownership predicate in the DELETE's WHERE clause (atomic, no select-then-
delete window) and classifies a zero-row result as permission-denied vs
not-found, mirroring updateOwned. The addRestriction helper and the write-miss
classifier are hoisted onto the base repository so player and share share one
implementation.
Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error
handler so ownership/not-found failures from the rest-backed repositories
return the proper Subsonic codes (50 / 70) instead of a generic error.
Covered by unit tests (persistence, subsonic error mapping) and an end-to-end
cross-user sharing isolation test.
The native API exposes a `path` query param on /api/song, but it was not
registered in the media file filter map. Unmapped real columns fall through
to a default LIKE predicate that emits an unqualified `path LIKE ?`. Since
the song query joins the library table (which also has a `path` column),
SQLite returned "ambiguous column name: path" and the request failed with
HTTP 500.
Register a dedicated path filter qualified to media_file.path, resolving the
ambiguity. The value is matched with startsWith semantics (LIKE arg || '%')
against the library-relative path stored in media_file.path.
To register it inline (without a one-off wrapper), startsWithFilter now takes
a bound field and returns a filterFunc, mirroring containsFilter. The two
existing callers are updated accordingly, and the now-unused withTableName
helper is removed. The user 'name' filter, which previously relied on
withTableName, is now qualified directly as user.name; tests are added to
guard that filter against the same column-ambiguity class (the user query
also joins the library table, which has a name column).
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(security): restrict transcoding config reads to admins
Authenticated non-admin users could read transcoding configs through
the native API (GET /api/transcoding and /api/transcoding/{id}) when
EnableTranscodingConfig was enabled. The responses included the full
command templates, disclosing admin-configured ffmpeg invocations and
local command paths. Write operations were already admin-only.
The /transcoding route was registered in the general authenticated
group, and only the repository's write methods checked IsAdmin. This
applies the boundary at two layers:
- Move the route under adminOnlyMiddleware, alongside the other
admin-only resources (/library, /config, /inspect).
- Add an IsAdmin guard to the repository's rest.Repository read
methods (Read, ReadAll, Count) as defense-in-depth.
The guard is scoped to the REST methods only. The streaming pipeline
resolves profiles via Get/FindByFormat (model.TranscodingRepository),
which stay open so transcoding keeps working for non-admin users.
Adds regression tests covering non-admin read denial and confirming
non-admin streaming lookups (Get/FindByFormat) still succeed.
* fix(security): redact transcoding Command for non-admins instead of blocking reads
Reworks the previous approach after review (Codex P2): moving /transcoding
under adminOnlyMiddleware and denying non-admin reads broke legitimate
non-admin UI flows. The web UI reads the transcoding resource as a regular
user in several places that need only the profile name and target format:
the player edit dropdown (ReferenceInput), the player list (ReferenceField),
and the share/download format pickers (useGetList -> {targetFormat, name}).
The only sensitive field is Command (the admin-owned ffmpeg template). So:
- Revert the route move; /transcoding stays in the authenticated group.
- Read/ReadAll now return the profiles to any authenticated user but blank
the Command field for non-admins (mirrors user_repository's field-level
redaction). Count is no longer denied (the UI needs list pagination).
- Writes remain admin-only (Save/Update/Delete/Put).
- Streaming is unaffected: it resolves profiles via Get/FindByFormat, which
are not redacted, so on-the-fly transcoding keeps working for non-admins.
Tests updated: non-admin reads succeed with Command blank, admin reads keep
Command, non-admin Get/FindByFormat keep Command, writes still denied.
* fix(player): enforce ownership atomically on player update
The native API PUT /api/player/{id} authorized writes using the userId in
the request body via isPermitted, while the actual write targeted the row
by the URL id. A non-admin user could set userId to their own id in the
body to pass the check, then overwrite and reassign ownership of another
user's player row identified by the URL id (cross-tenant takeover).
Add updateOwned on the base repository: an atomic, ownership-restricted
UPDATE that folds the owner predicate (user_id = caller) into the WHERE
clause for non-admins, so a row owned by another user simply does not
match and no write happens. It also never writes user_id, so ownership is
immutable on update and no caller (admin included) can reassign a player
to a different owner. Unlike put, it never falls through to an INSERT, so
a non-matching id returns ErrNotFound instead of creating a row.
playerRepository.Update now uses updateOwned. Extract filterUpdateValues,
shared by put and updateOwned, so the update-column filtering lives in one
place. The create path (Save) keeps the body-based isPermitted check,
which is correct for new records.
Add regression tests covering the spoofed-userId hijack, regular-user and
admin ownership reassignment, legitimate owner updates, and the
nonexistent-player case.
* fix(share): enforce ownership atomically on share update
shareRepository.Update authorized writes with a separate checkOwnership
SELECT, then wrote the row via put(). The check and the write were two
statements (a TOCTOU window), put() could fall through to an INSERT on a
missing id, and put() would write user_id if present in the update
columns, so ownership was mutable on update.
Switch Update to updateOwned, which folds the owner predicate into the
UPDATE's WHERE clause, never writes user_id, and never inserts. This
makes the write atomic and ownership immutable, and drops the extra
ownership SELECT on the happy path.
To preserve the previous 403/404 distinction, updateOwned now classifies
a non-matching id: it runs a follow-up existence check only on the
failure path (count == 0, where no write happened, so no TOCTOU) and
returns ErrPermissionDenied when the row exists but is owned by another
user, ErrNotFound when the id is missing. The player path inherits this:
its tests now expect ErrPermissionDenied for a non-owner targeting an
existing row, and ErrNotFound only for a genuinely missing id.
Add share regression tests for the nonexistent-id and ownership-
reassignment cases. checkOwnership remains in use by Delete.
* refactor(persistence): extract canonical ownerFilter predicate
The non-admin owner-restriction predicate (user_id = me, exempting admins
and headless contexts) was spelled out independently in updateOwned and in
playerRepository.addRestriction. The two copies had drifted: addRestriction
did not exempt the headless/invalid user, so a headless context restricted
to user_id = "-1" (matching nothing) while updateOwned exempted it.
Extract sqlRepository.ownerFilter as the single definition and route both
call sites through it. addRestriction now exempts the headless user too;
that path is only reachable from the authenticated native API, so there is
no production behavior change, but the latent divergence is removed.
playlistRepository.userFilter is intentionally left alone: it encodes a
different policy (public OR owner_id = me, on the owner_id column).
* fix(share): preserve all-columns update path in Update
shareRepository.Update unconditionally appended "updated_at" to cols.
filterUpdateValues treats an empty cols as "update every column", so when
a caller passes no columns, appending "updated_at" turned an all-columns
update into an updated_at-only one, silently dropping every other field.
The REST controller always populates cols from the request-body field
names, so this path is not reachable through the native API and the
behavior was latent (and pre-existing). Guard the append so the
all-columns path is preserved, and add a regression test that updates
with no columns and asserts the other fields persist.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set
When a client reports playback with ignoreScrobble=true, the reportPlayback
handler suppressed both the scrobble submission and the NowPlaying update sent
to external agents (Last.fm, ListenBrainz, plugins). These are independent
concerns: ignoring the scrobble submission should not stop Navidrome from
telling external services what is currently playing.
The !params.IgnoreScrobble guard now applies only to the scrobble submission
and play-count path; the NowPlaying dispatch is gated solely by the player's
ScrobbleEnabled flag. This mirrors the legacy scrobble endpoint, where
submission=false has always still set NowPlaying.
* test(scrobbler): assert no scrobble dispatch when ignoreScrobble=true
Address PR review feedback: explicitly verify that ignoreScrobble=true
suppresses the scrobble submission (not just the play count) while NowPlaying
is still dispatched, so the flag cannot regress into ignoring nothing. Also
expand the NowPlaying gating comment to spell out the IgnoreScrobble vs
ScrobbleEnabled rules and identify the external agents involved.
* Add Gruvbox Dark theme
Add Gruvbox Dark color theme including:
- gruvboxDark.js with full palette and component overrides
- gruvboxDark.css.js with custom player styles
* Fix: move error state to MuiFormHelperText
The three It blocks that build a tight-cap streamer each spawned a fresh
transcoding cache without waiting for its background initialization. The
init goroutine reads conf.Server.CacheFolder, which races against
SnapshotConfig's pointer-swap restore (Server = &restored) fired by
DeferCleanup at the end of the spec. CI tripped the race under
-shuffle=on -race; locally it reproduced about 10% of the time.
Wait for tightCache.Available() before constructing the streamer, mirroring
the outer BeforeEach. For the slot-saturation spec, swap in a blocking
io.Pipe-backed mock ffmpeg so the cache's background copyAndClose can't
drain the source and release the slot — the previous behavior happened to
work only because the cache wasn't yet available and the no-cache path was
exercised.
* fix(playlists): preserve unchanged fields on partial REST updates (#5541)
The REST adapter for playlists was discarding the `cols` argument that
rest.Put provides (the list of fields actually present in the JSON
body). updatePlaylistEntity then compared the deserialized entity's
zero-valued Name/Comment against the DB row, decided "content changed",
and called updateMetadata with &entity.Name — overwriting the name with
the empty string.
This surfaced via the Playlists list view's bulk "Make Public" action,
which sends N parallel `PUT /api/playlist/{id}` requests with body
`{"public": true}`. Affected playlists ended up with their names wiped
(UI showed "Loading..." indefinitely). The per-row Public toggle was
unaffected because it spreads the full record into the payload.
Honor the cols list: gate every field-change check and every pointer
passed to updateMetadata by whether the field was actually in the
request body. Empty cols falls back to the existing "treat as a full
record" behavior so non-REST callers are unaffected.
* test(playlists): cover rules-only PUT + case-variant owner-change guard
Follow-ups from manual testing and code review of the prior commit:
- Manual testing confirmed Feishin-style rules-only PUT works correctly
on the fix; add ginkgo regression tests for rules-only update, name+
rules combined, idempotent rules PUT (no-op), and bulk Make-Public
preserving rules on smart playlists.
- Keep the non-admin owner-change permission check gated on the
deserialized entity content (not on `sent("ownerId")`) so a
case-variant JSON key like {"OwnerId":"x"} can't downgrade the 403
to a silent 200. Go's json decoder is case-insensitive on struct
field matching but rest.Put's field-name extraction is case-
sensitive; the entity-based guard catches both spellings. The
apply-side gating on ownerChanged still prevents the actual mutation,
so this was a behavioral (not security) regression, but worth fixing.
Adds a regression test asserting the case-variant key still returns
rest.ErrPermissionDenied.
- Correct misleading doc on applyContentUpdate: the path does not
rewrite the backing M3U file; it goes through updateMetadata which
bumps updatedAt and invalidates cached cover-art URLs.
* fix(playlists): match REST cols case-insensitively (PR #5542 review)
Go's encoding/json populates struct fields from case-variant keys like
{"Name":"x"} or {"OwnerId":"y"}, but rest.Put's getFieldNames extracts
raw JSON keys verbatim. With case-sensitive matching, sentFields would
ignore the field on the update side — a request with {"Name":"Renamed"}
would parse into entity.Name but then sent("name") returns false and
the rename silently no-ops.
Normalize both sides to lowercase. The entity-based owner-permission
guard added in the previous commit remains as belt-and-suspenders but
is now redundant with this change.
Also clarify the applyContentUpdate doc comment: namePtr/commentPtr
are nil when the field is absent OR present-but-unchanged, while
publicPtr only tracks presence (an idempotent public is still forwarded).
* refactor(playlists): drop redundant entity-based owner-permission guard
The case-insensitive sentFields predicate already prevents case-variant
JSON keys like {"OwnerId":"x"} from bypassing the ownerChanged check, so
the duplicated entity-content guard is no longer load-bearing.
Strengthen the regression test into a DescribeTable covering canonical,
PascalCase, all-upper, and all-lower spellings to lock in the
case-insensitive contract.
Dir embedded sync.Once directly and exposed a value-receiver GoString so
that pretty.Sprintf("%# v", Server) could render the path. That meant
every pretty-print copied the entire Dir along with its Once, and a
goroutine concurrently using the original (or any copy) for Path() could
hit a "sync: unlock of unlocked mutex" runtime fatal error. The failure
was reproduced deterministically on Windows CI when test-suite shuffle
ordering raced cache initialization (utils/cache/file_caches.go's
NewFileCache.func1 -> conf.CacheFolder.MustPath) against the
configuration-dump pretty.Sprintf in Load().
Drop the sync.Once entirely. Dir is now a plain {path, perm} value type,
and Path() calls os.MkdirAll on every invocation. MkdirAll is
idempotent, so repeated calls on an existing directory cost one stat
syscall — negligible for the few config paths read at startup and during
cache init.
This removes the entire class of bug:
- No Mutex, so copies (via reflection, pretty-print, etc.) are safe.
- No state pointer, so no nil-state defensive checks scattered across
methods, and no risk of two copies seeing different lifecycle state.
- go vet is happy with the value receivers — the //nolint:govet
suppression on GoString is gone.
Adds two regression tests in conf/dir_test.go:
- GoString renders Dir as a quoted path under pretty.Sprintf (and
does not leak the internal struct fields).
- Concurrent copy + Path() stress test, locking in the copy-safety
property in case the type ever grows non-trivial state again.
Move the option into the nested Transcoding config group alongside the
limit knobs it interacts with, so all transcoding-related settings live
together.
The old top-level name is still honored via the existing
mapDeprecatedOption / logDeprecatedOptions plumbing, which forwards the
value to the new key and logs a deprecation warning at startup. The old
struct field is removed (the new field is the single source of truth);
the deprecated default is removed so viper.IsSet correctly distinguishes
"user set the legacy option" from "no one set it."
* feat(transcoding): add MaxConcurrent and MaxConcurrentPerUser config
Introduce Transcoding.MaxConcurrent (default NumCPU()*2) and
Transcoding.MaxConcurrentPerUser (default 3) to support upcoming
concurrency limits on the streaming pipeline. No behavior change yet.
Refs #5246
* feat(transcoding): add TranscodeLimiter with global and per-user caps
Introduce a non-blocking limiter that gates concurrent transcodes. Returns
ErrTooManyTranscodes immediately when the cap is reached so callers can
translate it into a 429 response, rather than queuing requests.
The per-user reservation is taken first to avoid burning a global slot
that would only be rolled back when the per-user cap rejects the caller.
Release is idempotent so wrapping the transcoder reader's Close is safe.
Refs #5246
* feat(transcoding): cap concurrent transcodes in media streamer
Acquire a TranscodeLimiter slot before spawning ffmpeg in the transcoding
cache's read function, and release it when the resulting reader is closed.
Raw streams and cache hits bypass the limiter so a single saturating client
cannot block ordinary playback.
When the cap is reached, ErrTooManyTranscodes bubbles up through cache.Get,
ready for the HTTP layer to translate into a 429 response.
Refs #5246
* feat(transcoding): return HTTP 429 with Retry-After when transcode cap is hit
Map stream.ErrTooManyTranscodes to HTTP 429 in both the Subsonic API
(/stream, /download) and the public share endpoint, including a 5s
Retry-After hint. The Subsonic response still carries a failed-status
envelope so clients that ignore HTTP codes also see the failure.
Refs #5246
* feat(transcoding): default MaxConcurrent to 0 (disabled)
Ship the limiter opt-in so existing installations are not affected by a
behavior change on upgrade. Users hitting the DoS reported in #5246 can
enable it by setting Transcoding.MaxConcurrent to a positive value
(NumCPU()*2 is a reasonable starting point).
Refs #5246
* fix(transcoding): make global and per-user caps independent
Previously the limiter short-circuited to a no-op whenever MaxConcurrent
was zero, silently ignoring a configured MaxConcurrentPerUser. Treat each
cap independently so an operator can throttle per-user without enforcing
a global ceiling (or vice versa), and only fall back to the no-op limiter
when both caps are disabled.
* fix(archiver): abort archive download when the transcode limiter rejects
The album/artist/playlist zip writers were silently producing zip entries
with headers but no data when ms.NewStream returned ErrTooManyTranscodes,
because the per-file error was discarded by `_ = a.addFileToZip(...)`.
The client received HTTP 200 with a corrupt zip and no indication that
the server was rate-limited.
Now the zip loop bails out as soon as it sees ErrTooManyTranscodes, and
the Download handler swallows the error (the response status and
Content-Disposition are already flushed by the time the limit is hit, so
no 429 can be sent). The truncated zip surfaces the problem to the
client; operators see a clear "transcode cap reached" warning in the
server logs.
Refs #5246
* fix(transcoding): release limiter slot on client close, not ffmpeg EOF
Previously the slot was wrapped around the ffmpeg source reader, so it
was only released by the cache's background copyAndClose goroutine when
ffmpeg finished producing the file — meaning a client that disconnected
after a single byte still held the slot for the full transcode duration.
Under MaxConcurrent=N this serialized fresh requests behind abandoned
encodes for minutes.
Hand the release function back from the cache producer via the streamJob
struct and wire it into the consumer-side Stream.Close. The HTTP handler
already runs `defer stream.Close()`, so disconnect now frees the slot
immediately. Cache hits never enter the producer and still pay no slot,
and singleflight waiters on the same key correctly inherit no release
(only the original producer's job holds the slot).
Refs #5246
* fix(transcoding): skip per-user cap for anonymous requests
Public share viewers have no user in context, so userName(ctx) returned
the literal string "UNKNOWN" and the limiter mapped every anonymous
viewer to the same bucket. With MaxConcurrentPerUser=N, only N
unrelated anonymous clients could stream a viral share at any time —
the opposite of the fairness the per-user cap is meant to provide.
Introduce a limiterKey(ctx) helper that returns "" for anonymous
callers (userName(ctx) is unchanged for logs), and teach Acquire to
skip the per-user reservation when the key is empty. The global cap is
still enforced for anonymous traffic and remains the protection against
runaway anonymous load.
Refs #5246
* refactor(transcoding): tidy limiter struct and centralize Retry-After
Per review feedback:
- Drop the redundant maxConcurrent field on transcodeLimiter; the channel
capacity already enforces the global cap and the field was only used
inside the constructor.
- Only allocate the perUser map when MaxConcurrentPerUser > 0.
- Move the Retry-After value into core/stream as RetryAfterSeconds so the
Subsonic API and public-share handlers cannot drift if the window is
later tuned.
* fix(transcoding): do not log limiter rejections as cache failures
NewStream was emitting an error-level "Error accessing transcoding cache"
log whenever cache.Get returned anything non-nil, including the limiter's
ErrTooManyTranscodes — even though the producer had already logged the
rejection at warn level. The result was double logging and a misleading
"cache failure" classification that buries real cache problems.
Skip the error log when the cause is ErrTooManyTranscodes; the warn line
from the producer is the canonical signal.
* fix(archiver): open stream before writing zip entry header
Per review: addFileToZip previously called z.CreateHeader before
NewStream, so when the limiter rejected a transcode the zip already
contained a 0-byte entry for that track. Open the source first and only
write the header once the read side is ready; rejections now skip the
entry entirely.
The truncation comment in handleArchiveErr was also misleading — z.Close
finalises the central directory, so the client receives a well-formed
zip containing only the tracks written before the rejection, not a
"truncated" archive. Reword to match reality.
* fix(transcoding): hold slot for ffmpeg lifetime, force cancellable ctx
The previous release-on-consumer-close design let a client open many
unique transcodes, disconnect immediately, and still spawn the
configured cap's worth of ffmpeg processes — the cache writer goroutine
continued draining ffmpeg to disk after the client disappeared, defeating
the DoS protection the limiter is meant to provide.
Move the release back onto the source reader so the slot is freed only
when ffmpeg actually exits (either EOF or context cancellation). To keep
disconnects from leaking slots for the full transcode duration, force
the request context into ffmpeg whenever the limiter is enabled — so
client disconnect cancels the process and frees the slot promptly.
When the limiter is disabled, the legacy EnableTranscodingCancellation
behavior is preserved unchanged.
Reported by codex and Copilot reviewers on #5522.
* fix: split tag values from multiple sources individually
When a file has multiple tag frames mapping to the same logical tag
(e.g. both TXXX:MOOD and TMOO), TagLib merges them into one key with
multiple values. SplitTagValue had a len(values) != 1 guard that
skipped splitting entirely in this case, leaving comma-separated
values unsplit.
Change SplitTagValue to split each value individually regardless of
input count. Empty values are filtered during splitting.
Fixes#5065
* test: cover SplitTagValue with multi-frame regression cases
Add tests pinning the behavior fixed by SplitTagValue iterating over each
input value. The previous len(values) != 1 short-circuit silently skipped
splitting whenever TagLib merged multiple ID3v2 frames into the same
property (e.g. TMOO + TXXX:MOOD for mood, or duplicate TIPL entries for
composer), as reported in #5065.
Three layers of coverage:
- model/tag_mappings_test.go: direct unit tests on TagConf.SplitTagValue
covering single/multi-value input, case-insensitive separators, missing
SplitRx, empty input, and the empty-strings-passed-through contract that
the downstream metadata pipeline relies on.
- model/metadata/metadata_test.go: end-to-end check that a "mood" tag
surfaced as two raw values (the exact shape from the bug report) is
split, trimmed, and deduplicated to the expected three moods.
- model/metadata/map_participants_test.go: parallel multi-value case for
the COMPOSER tag, ensuring the same fix also corrects multi-frame role
parsing.
All three new specs fail on the pre-fix code and pass on the patched
SplitTagValue.
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* fix(lastfm): require signed state token on link callback
The Last.fm OAuth callback at /api/lastfm/link/callback trusted a raw
\`uid\` query parameter and wrote the resulting Last.fm session key under
that user with no ownership check. Any authenticated user who learned a
victim's internal user ID (e.g. from playlist ownerId) could redirect the
victim's scrobbles to an attacker-controlled Last.fm account by calling
the callback directly with the victim's uid and a Last.fm token obtained
for their own account.
The callback cannot use the regular auth middleware because it is reached
via a browser redirect from Last.fm, which cannot carry a JWT header.
Instead, GET /api/lastfm/link (authenticated) now also returns a short-
lived (5 min) HMAC-signed link token bound to the requesting user, with a
dedicated "lastfm-link" scope claim. The callback verifies the signature,
scope and expiry before deriving the user ID from the token; the \`uid\`
query value is no longer trusted as a user identifier. The UI fetches
this token at link-flow start and passes it in place of the raw user ID.
Reuses the existing HS256 secret via auth.EncodeToken/DecodeAndVerifyToken
so no new key management is introduced.
* fix(ui): keep Last.fm popup tied to user gesture for Safari
Opening the Last.fm OAuth tab after an awaited fetch causes the popup to
be blocked on Safari and on Firefox with strict popup blocking enabled,
because the browser's transient-activation window has already elapsed by
the time window.open is reached. Linking became impossible on those
browsers in the previous commit.
Move the click handler up to the parent component and open a placeholder
about:blank tab synchronously from the click; the linkToken fetch then
runs in parallel and we redirect the existing tab to Last.fm's auth URL
once it resolves. The user gesture stays attached to the window.open
call, so popup blockers no longer fire.
The polling/progress UI is unchanged; it now receives the openedTab ref
from the parent instead of owning it.
* fix(lastfm): require exp claim on link tokens
jwtauth.VerifyToken treats a JWT without an exp claim as non-expiring, so
verifyLinkToken used to delegate expiry handling entirely. A future
regression in createLinkToken that dropped the exp field would silently
turn link tokens into permanent bearer credentials.
Assert presence of an exp claim explicitly and add a regression test
covering the missing-exp case. Also tightens the wrong-scope test to use
a freshly-minted token with all claims present except the scope, instead
of relying on auth.CreatePublicToken which happens to also be missing
exp.
* style(lastfm): simplify comments in link token code
Trim doc comments on createLinkToken/verifyLinkToken/callback/startLink
to the load-bearing lines: keep the non-obvious 'jwtauth treats missing
exp as non-expiring' note and the popup-blocker hint, drop the rest
since the function names already describe behavior.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(lastfm): address review feedback on link token PR
- Wrap openInNewTab in a try/catch in startLink: openInNewTab calls
win.focus() unconditionally, so if the browser blocks the popup
(window.open returns null) it throws a TypeError synchronously,
before the catch() on the link-token fetch is attached. The throw
used to escape the click handler, leaving the UI without a
notification. Now the failure is surfaced as lastfmLinkFailure and
the toggle stays usable.
- Rename the link-token "subject" rejection message to "user ID" since
the claim is uid, not the JWT sub field.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
The navidrome-music-player library rewinds the current track by directly
mutating audio.currentTime when the Previous button is pressed with
restartCurrentOnPrev (and other programmatic seek paths like singleLoop
reset and mediaSession seek). It does not invoke its onAudioSeeked
callback for these, so the play tracker never learned about the new
position until the next ~30s heartbeat.
Replace the React onAudioSeeked prop with a native HTML5 'seeked' event
listener on the audio element, which fires for every seek (programmatic
or via slider release). The handler is debounced by 250ms so the burst
of seeks emitted while dragging the progress bar coalesces into a single
reportPlayback call at the final position.
The Subsonic API spec defines songCount and created as required attributes
on AlbumID3, but they were tagged with omitempty in our response struct,
allowing them to be silently dropped from responses (e.g. when songCount
was 0). Created was also a *time.Time, which compounded the omitempty
behavior.
Remove omitempty from both fields and change Created from *time.Time to
time.Time so they are always serialized, matching the spec contract that
clients rely on. The buildAlbumID3 helper and its tests are updated for
the non-pointer Created, and the AlbumWithSongsID3 snapshots are
regenerated to include the now-always-present fields.
* fix(server): optimize smart playlist role queries for large criteria (#5511)
Role-based smart playlist criteria (artist, composer, etc.) now query
the indexed media_file_artists join table instead of parsing JSON via
json_tree() on every row. Multiple conditions for the same role within
an OR group are merged into a single EXISTS subquery (batched at 200
to stay under SQLite's expression tree depth limit).
A composite index (media_file_id, role) replaces the now-redundant
single-column (media_file_id) index on media_file_artists.
Benchmark (40k tracks, 500 patterns, 3 artists/track):
- Merged join-table: 15ms (9.3x faster)
- Merged json_tree: 30ms (4.6x faster)
- Unmerged baseline: 137ms
* refactor: simplify role condition SQL generation and benchmark
Extract shared roleCondSQL/roleExistsSQL helpers to deduplicate the
EXISTS template between roleCond and roleCondGroup. Use slices.Chunk
for batching per project convention. Extract runBenchQuery helper to
eliminate triplicated benchmark execution loop.
* chore: raise roleCondBatchSize to 350
The empirical SQLite limit is 496 conditions per merged EXISTS
subquery. Raising from 200 to 350 reduces the number of batches
(e.g. 500 patterns now splits into 2 batches instead of 3).
* fix(server): apply OR-merge optimization to tag conditions too
Generalize mergeRoleConds into mergeJsonConds to also collapse multiple
tag conditions for the same tag (e.g. genre) within OR groups. This
gives the same ~5x speedup for tag-heavy smart playlists as the role
optimization gives for artist-heavy ones.
* refactor: benchmark uses real criteria pipeline instead of hand-built SQL
The "Current" sub-benchmark now builds criteria.Criteria expressions and
runs them through the actual newSmartPlaylistCriteria → Where() → ToSql()
pipeline, validating the real production code path. The baseline still
uses hand-built SQL representing the old json_tree approach.
* fix: stabilize merged group ordering and close rows before error check
Sort group keys in mergeJsonConds so the merged additions have
deterministic order across runs, improving SQLite statement cache reuse.
Move rows.Close() before rows.Err() in benchmark helper.
* fix(i18n): correct grammar errors in Serbian (sr) translation
Eight objective grammatical / lexical errors in `resources/i18n/sr.json`.
No stylistic or strategic re-wording — only corrections where the
current string is grammatically wrong, contains a non-word, or breaks
plural / case agreement.
| Key | Before | After | Why |
| --- | --- | --- | --- |
| `resources.song.fields.bitDepth` | `Битова` | `Битска дубина` | "Битова" is genitive plural of "bit" ("of bits") and drops the "depth" semantics. Adjective+noun shape matches `Битски проток` already used for the adjacent "Bit rate" field |
| `resources.song.fields.channels` | `Канала` | `Канали` | "Канала" is genitive plural ("of channels"); a column header needs nominative plural "Канали" |
| `resources.radio.name` plural | `Радији` | `Радио-станице` | "Радији" is not a valid plural of "Радио" in Serbian. The standard plural for radio stations is "Радио-станице" |
| `ra.input.file.upload_*` and `ra.input.image.upload_*` | `Упустите фајлове / слике …` | `Превуците фајлове / слике …` | "Упустити" means "to engage in / to indulge", not "to drop". For drag-and-drop UIs the standard Serbian verb is "Превуците" ("Drag") |
| `ra.navigation.prev` | `Претход` | `Претх.` | "Претход" is not a word — looks like a truncated "Претходна" missing the period. Restored as a proper abbreviation |
| `about.links.featureRequests` | `Захтеви за функцијама` | `Захтеви за функције` | Wrong case. Serbian "захтев за X" takes accusative ("захтев за помоћ"), not instrumental ("за функцијама") |
| `player.clickToPauseText` / `clickToPlayText` | `Кликни за паузирање / пуштање` | `Кликните за паузирање / пуштање` | The rest of the file uses formal plural imperative ("Кликните…"). Only these two used the singular informal "Кликни", which broke the consistent register |
JSON validated with `python3 -m json.tool`. No keys added, removed, or
re-ordered — diff is purely value substitution. Stylistic and lexical
modernization (e.g. "Уметник" → "Извођач" for music artist, filling
the missing `library` / `plugin` / `nowPlaying` blocks added in upstream
en.json) intentionally left for a follow-up PR after this baseline of
objective fixes lands.
* fix(i18n): fill missing keys in Serbian (sr) translation — 100% coverage
The Serbian translation was at 70% of upstream `en.json` (389 of 553 keys).
This commit fills all 164 missing keys, bringing coverage to 100%.
The file is also re-ordered to match `en.json`'s key sequence so that
future translation drift is easy to detect by diffing the two files
side-by-side. This is the same regeneration pattern used by the previous
maintainer's PR #3941.
## What was missing
| Block | Keys | Notes |
| --- | ---: | --- |
| `resources.plugin` | 58 | Whole Plugin system block — settings, config schema, permissions, notifications |
| `resources.library` | 43 | Whole Library management block — fields, scan actions, validation, notifications |
| `about.config` + `about.tabs` | 12 | Configuration export feature (TOML) |
| `message.*` | 11 | Cover-art upload/remove + Instant Mix + remove-all-missing |
| `resources.song` | 9 | composer, sample rate, gain fields, instant mix, show-in-playlist |
| `resources.playlist` | 6 | search-or-create UX, save-queue-to-playlist |
| `resources.artist` | 5 | top songs / shuffle / radio actions, missing field, maincredit role |
| `resources.user` | 5 | Per-user library access controls |
| `menu.librarySelector` | 4 | Multi-library selector |
| `activity` | 4 | selectiveScan, scanType, status, elapsedTime |
| `nowPlaying` | 3 | Now Playing widget (title / empty / minutesAgo plural) |
| `resources.album` | 2 | libraryName, missing |
| `resources.missing` | 2 | remove_all action, libraryName field |
## Translation conventions followed
- Stuck with the existing terminology already in `sr.json` for consistency
— e.g. `Уметник` for "Artist", `Плејлиста` for "Playlist", `Жетон`
for "Token". Whether `Уметник` → `Извођач` (music-context "performer")
is a worthwhile rename is a separate question that deserves its own PR
with a focused review surface; not in scope here.
- Cyrillic throughout (matches `languageName: "српски"`).
- Variable interpolation (`%{var}`) preserved exactly.
- Pluralisation separator (` |||| `) preserved on plural-aware keys
(`nowPlaying.minutesAgo`, `resources.library.name`, `resources.plugin.name`,
`resources.artist.roles.maincredit`).
## Validation
- JSON validated with `python3 -m json.tool`
- Key-count parity check: 553 keys in `en.json` → 553 keys in `sr.json`,
zero missing, zero extra.
- Diff is +408 / -200 (line moves due to canonical ordering plus the
net 164 new translations). All existing translations preserved verbatim.
## Builds on PR #5444
This branch sits on top of `i18n-sr-grammar-fixes` (PR #5444). If that
PR merges first, this one auto-rebases cleanly. If this one merges first,
PR #5444 has trivial conflicts in the same 7 strings (all already
resolved here as part of regeneration).
* fix: require admin for radio mutations
Subsonic internet radio station mutation endpoints are admin-only in the Subsonic and OpenSubsonic specs, but the router only required an authenticated player. Add a reusable Subsonic admin middleware and apply it to create, update, and delete radio routes while leaving the list endpoint available to authenticated users. Cover the middleware and router behavior with unit and e2e tests.
* fix: streamline admin-only routes for internet radio station management
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: use admin-only middleware for starting scans
Signed-off-by: Deluan <deluan@navidrome.org>
* test: align start scan authorization coverage
StartScan authorization now lives in the shared Subsonic admin middleware instead of the handler. Remove the obsolete direct handler unit assertion so the package tests reflect the route-level guard covered by middleware and e2e tests.
* fix: require admin for getUsers
The Subsonic getUsers endpoint exposes user-list semantics and should use the same shared admin middleware as other admin-only management endpoints. Apply the route-level guard while leaving getUser unchanged, and update the multi-user e2e coverage to expect regular users to receive an authorization failure.
* test: cover admin-only Subsonic access
Add e2e coverage that admins can still call getUsers after the route-level guard and that regular authenticated users can still list internet radio stations. These cases capture the access boundaries raised during PR review.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(conf): add Dir type with lazy directory creation
Introduces the Dir type that wraps a directory path string and defers
os.MkdirAll until the first call to Path() or MustPath(), using sync.Once
to ensure the creation happens exactly once. Implements fmt.Stringer,
encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration.
Includes Ginkgo/Gomega tests covering all methods and error paths.
* refactor(conf): replace eager dir creation with lazy Dir type
Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from
string to Dir. Remove all os.MkdirAll calls from Load() so directories
are created lazily on first Path()/MustPath() call. Artwork folder
creation was already handled at point-of-use in image_upload.go.
Add SnapshotConfig() to conf package for safe test config save/restore
that avoids copying sync.Once inside Dir fields. Fix copy-lock vet
warning in nativeapi/config.go by marshalling pointer instead of value.
* refactor(conf): migrate tests and db init to lazy Dir type
Update all test files to use conf.NewDir() for Dir field assignments.
Ensure DataFolder is created lazily when the database is first opened
in db.Db(). Remove eager directory creation from conf.Load() tests.
* fix(conf): address review findings for Dir type
- Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match
original behavior). Add NewDirWithPerm for PluginsFolder (0700).
- Use Path() instead of MustPath() in db.Prune() to avoid logFatal
from background cron job.
- Panic on marshal/unmarshal errors in SnapshotConfig (test helper).
- Clean up redundant String()/MustPath() calls in plugin manager.
- Remove dead code in dir_test.go.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(conf): add GoString to Dir for clean config dump output
Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path
string instead of internal struct fields (sync.Once, perm, err).
Also add TODO comment to configtest about removing the indirection.
* fix(dir): improve error logging in MustPath method
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(conf): address PR review feedback
- Ensure Plugins.Folder always uses 0700, even when user-configured
(previously only the derived default got restrictive permissions).
- Create LogFile parent directory before opening, so LogFile paths
inside a not-yet-created DataFolder work correctly.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Move the ffmpeg -ss (seek/offset) parameter before -i in all transcoding
commands so ffmpeg uses input seeking instead of output seeking. Per the
ffmpeg docs, placing -ss before -i seeks at the demuxer level by keyframe
(very fast), and since FFmpeg 2.1 it is also frame-accurate when
transcoding. The previous placement after -i caused ffmpeg to decode and
discard all audio up to the seek point, which was unnecessarily slow —
especially problematic for lengthy files (4+ hours).
Both code paths are updated: buildDynamicArgs (for default formats) and
createFFmpegCommand (for custom templates without %t). A database
migration updates existing default commands in the transcoding table.
The `held` channel in `runTwoRequests` was unbuffered, creating a race
condition with the `select/default` send in the handler. Under CI load
(slow runner, -race, -shuffle=on), the handler goroutine could reach
the select before the test goroutine blocked on `<-held`, causing the
send to silently fall through to `default` and deadlocking both
goroutines permanently.
Buffer the channel (capacity 1) so the send always succeeds regardless
of goroutine scheduling order.
* fix(transcoding): don't apply server-side transcoding override on getTranscodeDecision
The getTranscodeDecision endpoint was incorrectly applying server-side
player transcoding overrides (forced format and MaxBitRate cap), which
replaced the client's declared capabilities with synthetic profiles.
This caused the endpoint to ignore what the client can actually play and
return decisions for formats the client never requested (e.g. AAC when
the client only supports FLAC/opus/mp3). The override is now gated
behind an ApplyServerOverride flag in TranscodeOptions, which is only
set by the legacy stream endpoint where this behavior is expected.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: move server-side transcoding override to ResolveRequest
Moved the server-side player transcoding override logic (forced format
and MaxBitRate cap) from MakeDecision into ResolveRequest, where the
legacy stream context is handled. This makes MakeDecision a pure
function that only operates on the ClientInfo it receives, removing the
ApplyServerOverride flag and all context-sniffing from the decision
engine. Tests moved accordingly to legacy_client_test.go.
* test(e2e): update transcode decision tests for server override removal
Updated e2e tests to reflect that getTranscodeDecision no longer applies
server-side player overrides (MaxBitRate cap and forced transcoding
profile). The player MaxBitRate tests now verify the endpoint ignores
the player cap and relies solely on client-declared capabilities.
* test(e2e): assert opus default bitrate when player cap is ignored
Added bitrate assertion to verify the player MaxBitRate cap is truly
ignored: the target bitrate should be the opus format default (128kbps),
not the player cap (320kbps).
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(server): prevent artwork throttle token starvation on slow clients
Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).
The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.
* refactor(server): simplify throttle to middleware with same API as Chi
Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(server): add opt-out flag for buffered artwork throttle
Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(server): clean up throttle tests for clarity and speed
Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.
* fix: release artwork throttle tokens on panic
Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.
* fix: align buffered throttle response behavior
Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.
* refactor(server): remove setWriteTimeout from throttle middleware
SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Log skipped entries, total entries vs plugins found, and DB state
to help diagnose why sync may not find new plugins.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(ui): add Rescan button to plugin list empty state
When no plugins are installed and the folder watcher fails to detect new
plugins, users had no way to trigger a rescan. Extract RescanButton into
a shared component and render it in a custom empty state for the plugin
list.
* refactor(ui): address review feedback for plugin empty state
- Pass label translation key directly to RA Button (auto-translates)
- Use within() from Testing Library instead of querySelector for
scoped queries with better error messages
* fix(artwork): include top-level album folders in parent cover art lookup
The Path != "." guard added in #5451 was too aggressive — it excluded
any folder with Path=".", which includes top-level album folders (not
just the library root). Changed to ParentID != "" which correctly
excludes only the actual library root folder.
Fixes#5456
* fix: correct comment in test — album is under library root, not artist root
* test: add ascii tree diagram to top-level album e2e test
* test: replace internal bug references with issue link in e2e comments
Signed-off-by: Deluan <deluan@navidrome.org>
* test: add e2e test matching reporter's exact library layout (#5456)
Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs
using the reporter's actual folder names to verify artwork resolution
works for non-top-level album folders too.
* fix(scanner): use a syntectic admin user when no admin user is found
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache
When Phase 3 corrects an album's FolderIDs (or any other field), bump
UpdatedAt to the current time. This ensures the artwork cache key changes,
invalidating any stale artwork that was resolved and cached during Phase 1
when the album had incomplete folder data.
* fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache
Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt
semantics) and instead includes album.ImportedAt in the artwork cache key
computation. Since ImportedAt is bumped to time.Now() on every album Put,
any Phase 3 correction naturally invalidates cached artwork that was
resolved mid-scan with incomplete folder data.
* fix(artwork): simplify lastUpdate logic using TimeNewest utility
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* chore(ci): update GitHub Actions to latest major versions
Update actions/cache v4→v5, actions/github-script v3→v7,
actions/stale v9→v10, docker/login-action v3→v4,
docker/setup-buildx-action v3→v4, and docker/metadata-action v5→v6.
The github-script upgrade also migrates Octokit API calls from
github.* to github.rest.* namespace (required since v5).
* fix(ci): address review feedback on GitHub Actions update
Pass github_token to docker/metadata-action@v6 to avoid API rate
limiting. Fix github-script pagination to use the correct Octokit
paginate.iterator pattern (pass endpoint method, not awaited response).