4846 Commits

Author SHA1 Message Date
David Vedvick
4ce5522a98
Merge b5c149285bde744c02a26f7d3836dfb7b27565aa into 2a43c4683ea41492a6ad8c6b22af9a1f7eacb1e9 2026-05-29 06:32:45 -05:00
David Vedvick
b5c149285b
Merge branch 'master' into feat/support-playlist-paths 2026-05-29 06:32:43 -05:00
David
4bac694669 refactor(smartplaylists): make NormalizeChildPaths non-mutating
Signed-off-by: David <dvedvick@gmail.com>
2026-05-29 06:16:07 -05:00
Deluan
2a43c4683e chore: go fix
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-28 22:13:05 -03:00
Deluan
59b6755014 chore(deps): update dependencies to latest versions in go.mod and go.sum
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-28 20:15:40 -03:00
David
8877d06b5c fix(smartplaylists): handle empty playlist paths
Signed-off-by: David <dvedvick@gmail.com>
2026-05-27 22:14:20 -05:00
Deluan
833c50adc7 test(stream): fix data race in MediaStreamer transcoding cap tests
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.
2026-05-28 00:07:49 -03:00
Deluan Quintão
74a5c0c6d1
fix(playlists): preserve unchanged fields on partial REST updates (#5542)
* 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.
2026-05-27 23:29:17 -03:00
Deluan Quintão
fc9cdf39c8
fix(conf): make Dir a plain value type to prevent sync.Once corruption (#5543)
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.
2026-05-27 23:18:35 -03:00
David
7328bbbba0 chore(smartplaylists): log field parsing error
Signed-off-by: David <dvedvick@gmail.com>
2026-05-27 19:38:04 -05:00
David
62d9bc6c5a fix(smartplaylists): refreshing child playlists
Signed-off-by: David <dvedvick@gmail.com>
2026-05-26 23:00:07 -05:00
David
0b4397bc2c fix(smartplaylists): protect against nil panic
Signed-off-by: David <dvedvick@gmail.com>
2026-05-26 20:43:36 -05:00
Deluan Quintão
823d851b75
refactor(transcoding): rename EnableTranscodingCancellation to Transcoding.EnableCancellation (#5523)
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."
2026-05-24 00:51:58 -03:00
Deluan Quintão
945d0ba1e2
fix(transcoding): cap concurrent transcodes to prevent ffmpeg DoS (#5522)
* 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.
2026-05-24 00:24:30 -03:00
Tom Boucher
55a31f30b3
fix(scanner): respect tag split config when multiple frames map to the same tag (#5193)
* 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>
2026-05-23 19:20:18 -03:00
Deluan Quintão
edffca24b1
fix(lastfm): require signed state token on link callback (#5521)
* 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>
2026-05-23 12:16:15 -03:00
Deluan
0265ff3ad1 fix(ui): report playback when restarting current track via prev
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.
2026-05-22 22:23:36 -03:00
Deluan Quintão
4a6038cfaf
Merge branch 'master' into feat/support-playlist-paths 2026-05-22 20:19:47 -03:00
Deluan
8897ec918e fix(subsonic): mark AlbumID3 songCount and created as required
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.
2026-05-22 18:42:17 -03:00
Deluan Quintão
74185dc6d1
fix(smartplaylists): optimize smart playlist performance for role and tag criteria (#5515)
* 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.
2026-05-22 18:00:13 -03:00
Deluan
e75ab3b037 fix(cli): restore int cast for syscall.Stdin on Windows
On Windows, syscall.Stdin is syscall.Handle (uintptr), not int,
so term.ReadPassword requires an explicit int() cast to compile.
2026-05-20 19:33:42 -03:00
Deluan
03ac02d964 refactor: more warnings clean up
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-20 17:43:12 -03:00
Deluan
efe9291db0 refactor: multiple syntax updates for Go 1.26
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-19 18:02:36 -03:00
nenadjokic
545a9ecc3c
fix(ui): update Serbian translation (#5444)
* 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).
2026-05-19 14:50:34 -03:00
Deluan Quintão
a84f092d00
fix(subsonic): require admin access for Subsonic management endpoints (#5510)
* 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>
2026-05-19 14:23:38 -03:00
VirtualWolf
23252e0638
fix(ui): updated the AMusic theme to use the correct text colour for primary confirmation buttons (#5509) 2026-05-19 11:01:41 -03:00
Kendall Garner
339a6271f1
chore(subsonic): only log response body on send error when at trace level or higher (#5501) 2026-05-18 09:50:30 -03:00
Deluan
725f6ab34b feat(i18n): add Estonian translation file
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-18 09:31:57 -03:00
David
622b1d02b9 feat: Support relative playlist paths in smartlists
Signed-off-by: David <dvedvick@gmail.com>
2026-05-14 22:58:17 -05:00
David
a5f72f2678 feat: Add support for referencing playlists using paths
Signed-off-by: David <dvedvick@gmail.com>
2026-05-14 22:52:32 -05:00
Deluan Quintão
8f0b4930ff
refactor(conf): replace eager dir creation with lazy Dir type (#5495)
* 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>
2026-05-13 17:44:22 -03:00
Deluan Quintão
24e526e09a
fix(transcoding): place -ss before -i for fast input seeking (#5492)
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.
2026-05-13 17:17:20 -03:00
Deluan
2b3b879c57 chore(deps): update dependencies to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-11 20:34:04 -03:00
Deluan
e55a35544b chore(deps): update TagLib to 2.3
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-11 20:33:14 -03:00
Deluan
f12e75aa11 feat(subsonic): add groupings field to OpenSubsonic Child response
Include the ID3 grouping tag in OpenSubsonic responses as an array of
strings, per opensubsonic/open-subsonic-api#232. The grouping tag was
already being extracted and stored in MediaFile.Tags via mappings.yaml
aliases (GRP1, GROUPING, ©grp, wm/contentgroupdescription), so this
change only adds the field to the response struct and populates it in
both song and album child builders.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-10 18:35:13 -03:00
Deluan Quintão
569de4cd23
fix(test): prevent flaky deadlock in throttle backlog test (#5474)
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.
2026-05-06 11:03:11 -04:00
Deluan Quintão
b18dfb474a
fix(transcoding): don't apply server-side override on getTranscodeDecision (#5473)
* 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>
2026-05-06 10:03:24 -04:00
IEEE-754
59c1454ffb
fix(ui): update zh-Hant.json (#5470) 2026-05-06 08:49:13 -04:00
Deluan
82f101a7b2 ci: fix input parameter name for GitHub token
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-06 00:16:09 -04:00
Deluan Quintão
5f0245ea84
fix(server): prevent artwork throttle token starvation on slow clients (#5472)
* fix(server): prevent artwork throttle token starvation on slow clients

Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).

The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.

* refactor(server): simplify throttle to middleware with same API as Chi

Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(server): add opt-out flag for buffered artwork throttle

Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.

Signed-off-by: Deluan <deluan@navidrome.org>

* test(server): clean up throttle tests for clarity and speed

Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.

* fix: release artwork throttle tokens on panic

Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.

* fix: align buffered throttle response behavior

Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.

* refactor(server): remove setWriteTimeout from throttle middleware

SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-06 00:12:50 -04:00
Deluan
97c01c0614 fix(plugins): add debug logs to syncPlugins for troubleshooting
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>
2026-05-05 18:58:50 -04:00
Deluan Quintão
39f8eec8d2
fix(ui): add Rescan button to plugin list empty state (#5471)
* 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
2026-05-05 18:49:24 -04:00
Deluan
5b85b2839a fix(scanner): fix error when importing playlists without an admin user
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-04 18:04:11 -04:00
Deluan Quintão
f48416685f
fix(artwork): fix stale cache and top-level album artwork for multi-disc albums (#5457)
* 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>
2026-05-04 17:26:39 -04:00
Deluan Quintão
a34a4abbc1
chore(ci): update GitHub Actions to latest major versions (#5462)
* 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).
2026-05-04 13:59:05 -04:00
Deluan
dd2b6865b0 chore(deps): update Go dependencies in go.mod and go.sum
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-03 12:34:00 -04:00
Deluan
52099ce91f test: fix flaky watcher and scheduler tests on Windows CI
Replace timing-sensitive time.Sleep synchronization with proper
Eventually/Consistently assertions in watcher tests, and increase
Eventually timeouts from 200ms to 500ms. Add FlakeAttempts(3) to the
inherently timing-dependent tests. For the scheduler test, increase
the Eventually timeout from 1s to 5s for the cron job execution check.
2026-05-03 11:26:07 -04:00
Deluan Quintão
a00152397e
fix(artwork): prefer album-root images over disc-subfolder images for multi-disc albums (#5451)
Fixed two bugs in album cover art resolution for multi-disc layouts:

1. compareImageFiles now sorts by path depth (shallower first) when basenames
   tie, so album-root images like Artist/Album/cover.jpg are preferred over
   disc-subfolder images like Artist/Album/CD1/cover.jpg.

2. commonParentFolder now includes the parent folder for single-disc-subfolder
   albums, with a Path != "." guard to avoid pulling artist-folder images.

Closes #5376
2026-05-02 19:48:44 -04:00
Deluan Quintão
ae0e0c89d9
feat(plugins): add PlaybackReport to scrobbler capability (#5452)
* feat(plugins): add PlaybackReport to Scrobbler interface and all implementations

* feat(plugins): add PlaybackReport worker and dispatch in PlayTracker

* feat(plugins): add PlaybackReportRequest to plugin scrobbler capability

* chore(plugins): regenerate PDK files with PlaybackReport

* feat(plugins): add PlaybackReport to test scrobbler plugin

* feat(plugins): add PlaybackReport to plugin scrobbler adapter

* refactor(plugins): fix double DB fetch in StateStopped and batch getActiveScrobblers

- Hoist mf from scrobble branch so PlaybackReport reuses it instead of
  fetching again from DB
- Call getActiveScrobblers once per drain batch instead of per-entry

* chore(plugins): include generated scrobbler schema with PlaybackReport

* fix(plugins): skip PlaybackReport for plugins that don't export it

Plugins detected as scrobblers only need to export one scrobbler
function. Older plugins that don't export nd_scrobbler_playback_report
would cause noisy error logs on every reportPlayback call. Now
errFunctionNotFound and errNotImplemented are treated as no-ops.

* refactor: rename NowPlayingInfo to PlaybackReport

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename stopNowPlayingWorker to stopBackgroundWorkers

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move NowPlaying and PlaybackReport logic to separate worker files

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scrobbler): rename NowPlayingInfo to PlaybackSession and add expired state

Rename NowPlayingInfo struct to PlaybackSession to better reflect its role
as a complete playback session representation. Add UserId field to make
sessions self-contained, removing redundant userId parameters from
PlaybackReport interface method and internal dispatch functions. Introduce
StateExpired internal state that fires when a session cache entry expires
without an explicit stop, ensuring plugins always receive a terminal event
regardless of client behavior.

* fix(scrobbler): update playback state description to include 'expired'

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scrobbler): resolve data race in OnExpiration callback

Capture conf.Server.EnableNowPlaying at construction time instead of
reading it from the background ttlcache eviction goroutine. The previous
code raced with test config cleanup that writes to the same field
concurrently.

* fix(scrobbler): return error when media file lookup fails in StateStopped

Simplify the MediaFile population logic in the stopped case to return an
error if the track cannot be found. A stop report with an empty MediaFile
is useless to plugins, and returning the error allows clients to retry
or alert the user when auto-scrobble is enabled.

* refactor(scrobbler): use session data directly in PlaybackReport adapter

Use info.Username from PlaybackSession instead of extracting it from
context in the plugin adapter, since the session is now self-contained.
Add debug/trace logging for session expiration and enqueue the expired
report with a user-enriched context so downstream handlers can identify
the user.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-02 16:14:53 -04:00
Deluan Quintão
13c48b38a0
fix(smartplaylists): coerce string booleans in smart playlist rules (#5450)
* fix(criteria): coerce string booleans in smart playlist rules - #4826

When clients (e.g. Feishin) send boolean values as strings ("true"/"false")
in smart playlist JSON rules, the SQL comparison fails because SQLite stores
booleans as 0/1 integers. For example, `COALESCE(annotation.starred, false) = 'true'`
never matches.

This adds a `boolean` flag to mapped fields and coerces string values to
native Go bools in `mapFields`, so squirrel generates correct SQL parameters.

Signed-off-by: mango766 <mango766@users.noreply.github.com>
Signed-off-by: easonysliu <easonysliu@tencent.com>

* fix(criteria): implement boolean string coercion for smart playlist rules

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: mango766 <mango766@users.noreply.github.com>
Signed-off-by: easonysliu <easonysliu@tencent.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: easonysliu <easonysliu@tencent.com>
2026-05-01 19:21:48 -04:00