4830 Commits

Author SHA1 Message Date
dependabot[bot]
4622d25745
Merge 5cc4ce1aceba792ab1793053488dfa7471b47df3 into edffca24b11b5378876cbcfed62acbbed8d95d10 2026-05-24 00:30:34 +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
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
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
Deluan Quintão
7e16b6acb5
feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448)
* feat(config): add UIPlaybackReportInterval setting

* feat(server): expose playbackReportIntervalMs to UI config

* feat(ui): add playbackReportIntervalMs config default

* feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer

* feat(ui): replace scrobble logic with reportPlayback state machine in Player

* refactor(ui): simplify Player heartbeat using useInterval hook

- Replace manual setInterval/clearInterval with existing useInterval hook
- Extract shared reportPlaybackUrl helper to deduplicate URL construction
- Use ref for currentTrackId in beforeunload to stabilize effect deps
- Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime

* feat(ui): redesign NowPlaying panel with Discord-style layout

Show album art with play/pause overlay icon, track title, artist,
album name, progress bar with position/duration, and username.

* fix(ui): adjust NowPlaying panel height to fit 3 entries

* fix(ui): send stopped on player close and tab close while paused

- onBeforeDestroy now sends reportPlayback stopped before clearing queue
- beforeunload sends stopped beacon regardless of pause state

* feat(ui): animate NowPlaying progress bar with 1s client-side tick

* fix(ui): account for playbackRate in NowPlaying progress interpolation

* fix(ui): use timestamp-based interpolation for smooth NowPlaying progress

Replace tick counter with fetchedAt timestamp so the progress bar
advances smoothly without resetting on each server poll.

* fix(ui): fix NowPlaying progress bar not animating

Pass `now` (Date.now()) as a prop that changes every tick, so
React.memo'd components actually re-render each second.

* fix(ui): prevent progress bar reset on NowPlaying poll

Set fetchedAt and now atomically on fetch so the elapsed offset
starts at zero and the server's already-estimated positionMs
is used as the base without a visible jump.

* fix(ui): stamp entries with fetch time to prevent progress bar reset

Embed _fetchedAt timestamp directly into each entry object so the
position and its reference timestamp are always in the same state
update, eliminating the React 17 multi-setState batching race.

* fix(server): estimate position for starting state in GetNowPlaying

GetNowPlaying was only estimating elapsed position for the "playing"
state, returning raw positionMs=0 for "starting". Since the UI
player sends "starting" once and then doesn't update until the
60s heartbeat, NowPlaying polls returned 0 for up to a minute,
causing the progress bar to reset on every poll.

* fix(ui): send playing immediately after starting to enable position estimation

The server only estimates elapsed position for "playing" state in
GetNowPlaying. The Player was sending "starting" once and then not
updating until the 60s heartbeat, leaving the server state as
"starting" with positionMs=0 for up to a minute.

Now the Player follows up "starting" with an immediate "playing"
call, transitioning the server state so position estimation works
from the first poll.

* fix(subsonic): fix getNowPlaying returning same playerId for all entries

PlayerId was never incremented in the map callback, so every entry
got playerId=1. This caused the UI to use duplicate React keys,
mixing up rendered entries between players. Also use a stable
composite key in the UI instead of the sequential playerId.

* fix(ui): only send stopped beacon when tab is actually closing

Move the reportPlaybackBeacon call from beforeunload to pagehide.
beforeunload fires before the confirmation dialog, so cancelling
the close would still send stopped. pagehide only fires when the
page is actually being unloaded.

* fix(ui): revert to beforeunload for stopped beacon

pagehide does not fire reliably in Chrome when closing tabs.
Use beforeunload instead — if the user cancels the close dialog,
the heartbeat will re-register the NowPlaying entry on its next tick.

* fix(ui): use synchronous XHR for stopped report on tab close

Replace sendBeacon with synchronous XMLHttpRequest in beforeunload.
This blocks the page from closing until the server acknowledges
the stopped state, ensuring the NowPlaying entry is always removed.

* fix(ui): fix confirmation dialog and use fetch keepalive for tab close

- Move e.preventDefault() before the stopped report so the dialog
  always shows regardless of XHR errors
- Use fetch with keepalive:true instead of sync XHR (more reliable,
  non-blocking, survives page teardown)
- Fall back to sendBeacon if fetch throws

* fix(ui): prevent heartbeat from re-adding entry after stopped on tab close

Set a stoppedRef flag in beforeunload so the heartbeat interval
skips sending playing reports after stopped has been sent.
Without this, the heartbeat could re-register the NowPlaying
entry after the stopped event removed it.

* fix(ui): include client unique ID header in stopped report on tab close

Root cause: reportPlaybackSync (fetch keepalive) did not include the
X-ND-Client-Unique-Id header. Regular reportPlayback calls via
httpClient include this header, and the server uses it as the playMap
key. Without the header, the stopped call fell back to player.ID
as the key, which didn't match the entry added with the UUID key.
The playMap.Remove targeted the wrong key, so the entry persisted.

Fix: export clientUniqueId from httpClient and include it as a header
in the fetch keepalive request.

* fix(ui): use pagehide for stopped report to avoid premature send

beforeunload fires before the confirmation dialog, so the stopped
event was sent even when the user cancelled closing. Move the
stopped report to pagehide, which only fires when the page is
actually being unloaded (after confirmation).

* feat(server): broadcast NowPlaying SSE on every state change

Previously, the SSE broadcast only fired when the NowPlaying count
changed. Now it fires on every reportPlayback call (starting,
playing, paused, stopped), so the NowPlaying panel gets instant
updates for state transitions and position changes.

The UI reducer stores a nowPlayingLastUpdate timestamp alongside
the count, ensuring every SSE event triggers a re-fetch even when
the count is unchanged (e.g., pause/resume).

* fix(ui): clamp NowPlaying position to prevent negative time display

* fix(ui): debounce NowPlaying fetches to prevent progress bar trembling

During track changes, rapid SSE events (stopped, starting, playing)
triggered multiple refetches within milliseconds, each resetting the
interpolation base and causing the progress bar to oscillate. Skip
fetches within 1 second of the previous fetch.

* feat(ui): report playback position on seek

Send a reportPlayback(playing) call when the user seeks/scrubs in
the player, so the NowPlaying panel and server position stay in
sync immediately instead of waiting for the next 60s heartbeat.

* refactor: code review cleanup

- Export clientUniqueIdHeader from httpClient, use in subsonic layer
- Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList
- Fix onBeforeDestroy nested dep (read isRadio from ref instead)
- Only broadcast SSE on state transitions, not heartbeat position updates
- Only enqueue NowPlaying to external scrobblers on state transitions

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

* fix(ui): use trailing-edge debounce for NowPlaying fetch

Replace the leading-edge throttle (which fetched on the first event
and blocked subsequent ones) with a trailing-edge debounce (300ms).
During track transitions, the burst of events (stopped → starting →
playing) now collapses into a single fetch after the burst settles,
showing the new track immediately instead of briefly showing empty.

* fix(ui): only show overlay on NowPlaying artwork when paused

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

* refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync

* refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive

The function was never synchronous — it uses fetch with keepalive:true,
which is fire-and-forget. The name now reflects the actual behavior.

* style: format code with prettier

* test: add tests for reportPlayback SSE broadcast and UI changes

- play_tracker: verify SSE broadcast on every state transition and
  that broadcasts are skipped when EnableNowPlaying is false
- activityReducer: verify nowPlayingLastUpdate timestamp is set
- subsonic/index: verify reportPlayback URL construction

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

* fix(ui): prevent NowPlaying from fetching every second when panel is open

fetchList had unstable identity because it depended on doFetch
(which depended on notify/dispatch). Each 1s setNow re-render
recreated the callback chain, re-triggering the useEffect that
calls fetchList. Use a ref for the fetch logic so fetchList has
a stable identity with empty deps.

* fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel

The fetch dispatched nowPlayingCountUpdate on every result, which
updated nowPlayingLastUpdate in Redux, which triggered the SSE
effect to call fetchList again — creating a fetch loop.

Fix: remove dispatch from fetch results. The badge count uses
entries.length (from local state) when entries are loaded, falling
back to Redux count (from SSE) when they aren't. SSE events remain
the only trigger for nowPlayingLastUpdate, breaking the loop.

* fix(ui): clear NowPlaying entries on panel close so badge uses SSE count

* style: format code with prettier

* fix: address code review feedback

- Fix currentTime truthiness check to handle position 0 correctly
- Report actual player state (playing/paused) on seek instead of
  always sending 'playing'
- Remove idx from React key to avoid reorder issues
- Add debounce timer cleanup on unmount
- Keep entries on panel close so badge stays accurate from polling
- Fix test description to match actual assertion

* fix(ui): keep NowPlaying badge count accurate from polling

Add a separate nowPlayingCountSync action that updates the Redux
count without setting nowPlayingLastUpdate (which would trigger
the SSE effect and cause a fetch loop). Polling results now sync
the badge count via this action, so the badge stays accurate even
when SSE is unavailable.

* style: format code with prettier

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-01 15:27:32 -04:00
Deluan
556f345a10 chore(lint): upgrade golangci-lint, fix/ignore new errors
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-01 14:22:51 -04:00
Deluan Quintão
94eb6c522b
feat(subsonic): implement playbackReport OpenSubsonic extension (#5442)
* feat(req): add Float64Or helper for parsing float query params

* feat(scrobbler): extend NowPlayingInfo with state/position/rate fields

* feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble

* feat(responses): add state/positionMs/playbackRate to NowPlayingEntry

* feat(subsonic): add reportPlayback endpoint handler

* feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response

* feat(subsonic): register playbackReport OpenSubsonic extension

* test(e2e): add reportPlayback endpoint e2e tests

* refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication

- Add state constants and exported ValidStates map
- Extract remainingTTL() helper (was duplicated 3x)
- Merge playing/paused switch cases into single branch
- Use Get instead of GetWithParticipants for non-stopped states
- Guard NowPlayingCount broadcast with count-change detection
- Use cache entry for NowPlaying dispatch instead of extra DB query
- Remove redundant Position field from NowPlayingInfo

* refactor(scrobbler): skip DB query in playing/paused when playMap has entry

* fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache

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

* refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo

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

* fix(play_tracker): check all ReportPlayback errors in tests

Replace _ = with explicit error assertions to avoid masking
failures in intermediate calls.

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

* test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback

Replace noopPlayTracker with a real PlayTracker backed by the E2E
database. E2E tests now verify the full round-trip: reportPlayback
creates/updates/removes entries visible via getNowPlaying, including
state, positionMs, and playbackRate fields.

Export NewPlayTracker constructor for use outside the scrobbler package.

* fix(play_tracker): account for playback rate in TTL and detect track switches

The remainingTTL function now divides remaining time by the playback rate,
so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves
the TTL). Zero/negative rates default to 1.0. The playing/paused case now
checks if the cached MediaFile ID matches the reported mediaId, falling back
to a DB fetch when the client switches tracks without sending stopped/starting.
Adds parameterized tests for remainingTTL covering rate variations and edge cases.

* fix(subsonic): validate positionMs and playbackRate in reportPlayback

Reject negative positionMs values and invalid playbackRate values (NaN,
Inf, zero, negative) at the API boundary before they reach TTL and
position estimation math. Returns clear error messages for each case.

* feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters

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

* refactor(play_tracker): replace NowPlaying method with ReportPlayback calls

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

* refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-30 23:04:05 -04:00
Deluan
2b9f326993 chore(docker): add app directory to PATH in Dockerfile 2026-04-30 16:55:10 -04:00
Deluan Quintão
2307a64da7
fix(ui): start new album from track 1 after closing player (#5441)
When a user played an album, advanced a few tracks, closed the player,
then played a different album, playback started mid-album at the
previous track index instead of track 1.

The root cause was in reduceSyncQueue: the hasPendingSwitch check
compared playIndex against savedPlayIndex, but after clearQueue both
reset to 0, making the check falsely conclude no switch was pending.
This caused PLAYER_SYNC_QUEUE to prematurely clear the playIndex and
clear flags before the music player library could act on them.

Fix: also treat clear=true as a signal that a track switch is pending,
since it means a new queue was just loaded.
2026-04-29 15:36:39 -04:00
Daniel Barrientos Anariba
bdea9ed6a1
fix(ui): show album tile actions on keyboard focus (#5434)
* fix(ui): show album tile actions on keyboard focus - #4836

The album grid tile bar (containing play/heart/context-menu buttons) had
opacity:0 by default and only became visible on mouse :hover. Keyboard
users tabbing through the album grid never saw which tile was focused
and could not discover the available actions.

Mirrors the existing :hover rule with :focus-within, which matches when
the link itself or any descendant (e.g. the play button) has focus.

Signed-off-by: Daniel Banariba <banaribad@gmail.com>

* fix(ui): also disable pointer events on hidden album tile bar - #4836

Per review feedback (@gemini-code-assist): the tileBar's buttons remained
clickable even at opacity:0, causing accidental Play/Menu triggers when a
user clicked what looked like the album cover.

Set 'pointerEvents: none' on the base tileBar and restore 'auto' on the
same selector that turns it visible.

Signed-off-by: Daniel Banariba <banaribad@gmail.com>

---------

Signed-off-by: Daniel Banariba <banaribad@gmail.com>
2026-04-28 22:48:59 -04:00
Deluan
57fc85f434 refactor(smartplaylist): remove unused 'value' field and clarify 'random' usage
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-28 20:44:54 -04:00
Deluan
0fd9c6df2e refactor(smartplaylist): clarify FieldInfo naming in criteria package
Rename FieldInfo.Name to Alias (only meaningful for backward-compat
entries like albumtype→releasetype) and FieldInfo.alias to tagAlias
(tag names from mappings.yml that resolve to existing fields). Add a
Name() method that derives the canonical name from the map key,
eliminating 60+ redundant Name declarations where the value matched
the key. LookupField now populates the private name field automatically.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-28 20:24:04 -04:00
Deluan
d9dac44456 feat(smartplaylist): add ReplayGain fields to criteria system
Expose the four ReplayGain database columns (rg_album_gain, rg_album_peak,
rg_track_gain, rg_track_peak) as first-class numeric criteria fields for
smart playlists. This allows users to filter tracks by ReplayGain values,
e.g. finding tracks with album gain below a threshold.
2026-04-28 19:46:12 -04:00
Deluan Quintão
46b4dcd5f6
feat(smartplaylist): add isMissing and isPresent operators (#5436)
* feat(smartplaylist): add IsMissing and IsPresent operator types

Add two new Expression types for detecting absent/present tags and
roles in smart playlist criteria. Includes JSON marshal/unmarshal
support and Walk visitor registration.

* test(smartplaylist): add JSON marshal/unmarshal tests for isMissing/isPresent

* feat(smartplaylist): add SQL generation for isMissing/isPresent operators

Tags check json_tree(media_file.tags) for key existence.
Roles check json_tree(media_file.participants) for key existence.
Regular DB column fields are rejected with an error.

* test(smartplaylist): add e2e tests for isMissing/isPresent operators

Tests cover tag presence/absence with selective matching (grouping),
universal absence (lyricist role), universal presence (composer role),
and combined operator usage.

* refactor(smartplaylist): use strconv.ParseBool in IsTruthy

Replace hand-rolled string truthiness check with strconv.ParseBool,
which correctly handles standard boolean strings and rejects
unrecognized values as false.

* refactor(smartplaylist): clarify missingExpr parameter naming

Rename defaultNegate to checkAbsence and extract truthy local for
readability. The XNOR logic (checkAbsence == truthy) is now easier
to follow: isMissing passes true, isPresent passes false.

* refactor(smartplaylist): reuse jsonExpr in missingExpr, improve errors

- tagCond/roleCond now handle nil cond (existence-only check)
- missingExpr delegates to jsonExpr(info, nil, negate) instead of
  building SQL manually
- Better error messages: unknown fields now report the field name
2026-04-28 19:40:08 -04:00
Deluan Quintão
d5ba61adf8
fix(ui): prevent autoplay when clearing the play queue (#5430)
When clearing the queue, the reducer resets to initialState which lacks
an autoPlay field (undefined). The autoPlay option was computed as true
because undefined !== false, causing the music player library to attempt
playback of the first song before internal state was fully cleared.

Added a queue.length > 0 guard to the autoPlay calculation so it is
never true when the queue is empty, regardless of other state flags.

Fixes #5331
2026-04-27 21:45:13 -04:00
Deluan
a4c1fa6378 chore(deps): update dependencies to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-27 20:24:01 -04:00
dependabot[bot]
5cc4ce1ace
chore(deps): bump uuid, @jsonforms/core, @jsonforms/material-renderers and @jsonforms/react
Bumps [uuid](https://github.com/uuidjs/uuid), [@jsonforms/core](https://github.com/eclipsesource/jsonforms/tree/HEAD/packages/core), [@jsonforms/material-renderers](https://github.com/eclipsesource/jsonforms/tree/HEAD/packages/material-renderers) and [@jsonforms/react](https://github.com/eclipsesource/jsonforms/tree/HEAD/packages/react). These dependencies needed to be updated together.

Updates `uuid` from 13.0.0 to 14.0.0
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0)

Updates `@jsonforms/core` from 2.5.2 to 3.7.0
- [Release notes](https://github.com/eclipsesource/jsonforms/releases)
- [Commits](https://github.com/eclipsesource/jsonforms/commits/v3.7.0/packages/core)

Updates `@jsonforms/material-renderers` from 2.5.2 to 3.7.0
- [Release notes](https://github.com/eclipsesource/jsonforms/releases)
- [Commits](https://github.com/eclipsesource/jsonforms/commits/v3.7.0/packages/material-renderers)

Updates `@jsonforms/react` from 2.5.2 to 3.7.0
- [Release notes](https://github.com/eclipsesource/jsonforms/releases)
- [Commits](https://github.com/eclipsesource/jsonforms/commits/v3.7.0/packages/react)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.0
  dependency-type: direct:production
- dependency-name: "@jsonforms/core"
  dependency-version: 3.7.0
  dependency-type: direct:production
- dependency-name: "@jsonforms/material-renderers"
  dependency-version: 3.7.0
  dependency-type: direct:production
- dependency-name: "@jsonforms/react"
  dependency-version: 3.7.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 00:23:12 +00:00
Deluan Quintão
3e25ca3868
test: enable Subsonic response snapshot tests on Windows (#5427)
* fix(test): enable Subsonic response snapshot tests on Windows

Replaced cupaloy with a simple custom snapshot matcher that normalizes
CRLF line endings before comparison. The tests were skipped on Windows
via a //go:build unix tag because Git for Windows checks out snapshot
files with CRLF, while Go's xml/json.MarshalIndent always produces LF,
causing direct string comparison to fail. The new matcher reads snapshot
files with os.ReadFile and normalizes \r\n to \n before comparing.
Also added a .gitattributes in the .snapshots directory to enforce LF
checkout, and removed the now-unused cupaloy dependency.

* fix(test): add UPDATE_SNAPSHOTS support to custom snapshot matcher

Restore the ability to update snapshots via `make snapshots`
(UPDATE_SNAPSHOTS=true), which was lost when replacing cupaloy
with the custom matcher.
2026-04-27 20:19:28 -04:00
Deluan Quintão
5c4f0298a6
fix(sharing): validate JWT expiration and share existence on stream endpoint (#5426)
* fix(sharing): validate JWT expiration and share existence on stream endpoint

The public stream endpoint (/public/s/{token}) was using
TokenAuth.Decode() which only verifies the JWT signature but skips
exp claim validation. This allowed expired share stream URLs to remain
functional indefinitely. Additionally, deleting a share did not revoke
previously issued stream tokens since the handler never performed a
server-side share lookup.

Fixed by switching decodeStreamInfo() to use auth.Validate() which
properly checks the exp claim, and by embedding the share ID ("sid")
in stream tokens so the handler can verify the share still exists.
Old tokens without the sid claim remain backward compatible but still
benefit from expiration validation.

* fix(sharing): check share expiration on stream requests

Replace the lightweight Exists() check with Get() + expiration
validation, so that shares whose ExpiresAt was updated to an earlier
time after token issuance are also rejected (410 Gone). Reuses the
existing checkShareError handler for consistent error responses.
2026-04-27 19:36:57 -04:00
Daniel Barrientos Anariba
0fe08bfa74
feat(ui): add Not Starred filter option (#5362)
* feat(ui): add Not Starred filter option - #5108

Signed-off-by: Daniel Banariba <banaribad@gmail.com>

* fix(ui): apply notStarred translation to playlistTrack resource - #5108

Signed-off-by: Daniel Banariba <banaribad@gmail.com>

* refactor(ui): use NullableBooleanInput for starred filter - #5108

Replace QuickFilter approach with NullableBooleanInput per maintainer
review feedback. Single tri-state filter (Yes/No/Any) instead of two
separate buttons + dataProvider translation. Matches the existing pattern
used by the 'missing' filter.

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>
2026-04-27 18:21:32 -04:00
Deluan Quintão
259c1a9484
feat(subsonic): add sonicSimilarity extension as plugin capability (#5419)
* feat(plugins): add sonicSimilarity capability types

Defines the SonicSimilarity plugin capability interface with
GetSonicSimilarTracks and FindSonicPath methods, along with
their request/response types.

* feat(sonic): add core sonic similarity service

Implements the Sonic service with HasProvider, GetSonicSimilarTracks,
and FindSonicPath, delegating to the PluginLoader and using the
Matcher for index-preserving library resolution.

* test(sonic): add sonic service unit tests

Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with
mock plugin loader and provider, verifying error propagation and
successful match resolution via the library matcher.

* feat(matcher): add MatchSongsToLibraryMap for index-preserving matching

Adds a new method alongside MatchSongsToLibrary that returns a
map[int]MediaFile keyed by input song index rather than a flat slice,
enabling callers to correlate similarity scores back to the original
position in the results.

* fix(sonic): check provider availability before MediaFile lookup

Avoids unnecessary DB call when no plugin is available, and ensures
the correct error path is tested.

* feat(plugins): add sonic similarity adapter

Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the
plugin system to the core sonic service via Extism plugin functions.
Reuses existing songRefsToAgentSongs helper for SongRef conversion.

* feat(plugins): add LoadSonicSimilarity to plugin manager

Adds Manager.LoadSonicSimilarity method following the pattern of
LoadLyricsProvider, enabling the core sonic service to load a
SonicSimilarityAdapter from a named plugin.

* feat(subsonic): add sonicMatch response type

Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results.

* feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers

Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types.

* feat(subsonic): advertise sonicSimilarity extension when plugin available

Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter.

* feat(subsonic): wire sonic similarity service into router

Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go.

* fix(e2e): add sonic parameter to subsonic.New call in e2e tests

* test(subsonic): add sonicSimilarity extension advertisement tests

Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin.

* feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints

Handlers return ErrorDataNotFound when no sonic service is configured,
preventing nil panics. E2e tests verify both endpoints return proper
error responses when no plugin is available.

* fix(subsonic): return HTTP 404 when no sonic similarity plugin available

Endpoints are always registered but return 404 when no provider is
available, rather than a subsonic error code 70.

* refactor: clean up sonic similarity code after review

Extract shared helpers to reduce duplication across the sonic similarity
implementation: loadAllMatches in matcher consolidates the 4-phase
matching pipeline, songRefToAgentSong eliminates per-iteration slice
allocation in the adapter, sonicMatchResponse deduplicates response
building in handlers, and a package-level constant replaces raw
capability name strings in core/sonic.

* fix empty response shapes

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

* test(plugins): add testdata plugin and e2e tests for sonic similarity

Add a test-sonic-similarity WASM plugin that implements both
GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity
PDK. The plugin returns deterministic test data with decreasing similarity
scores and supports error injection via config. Adapter tests verify the
full round-trip through the WASM plugin including error handling. Also
includes regenerated PDK code from make gen.

* docs: update README to include new capabilities and usage examples for plugins

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

* test(e2e): enhance sonic similarity tests with additional scenarios and mock provider

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

* fix: address PR review feedback for sonic similarity

Fix incorrect field names in README documentation ({from, to} →
{startSong, endSong}) and remove unnecessary XML serialization test
from e2e suite since OpenSubsonic endpoints only use JSON.

* refactor: rename Matcher methods for conciseness

Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to
MatchSongsIndexed. The Matcher receiver already establishes the "to
library" context, making that suffix redundant, and "Indexed" better
describes the intent (preserving input ordering) than "Map" which
describes the data structure.

* refactor: standardize variable naming for media files in sonic path methods

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

* refactor: simplify plugin loading by introducing adapter constructors

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-27 17:50:09 -04:00