* feat(ui): add perPageStore helper for items-per-page persistence
* feat(ui): persist items-per-page selection in localStorage
* feat(ui): enable per-page persistence on album grid, missing files and playlist tracks
* feat(ui): restore saved album grid page size on fresh load
* feat(ui): restore saved items-per-page on list load
* fix(ui): move defaultRowsPerPageOptions to perPageStore to satisfy react-refresh lint
* fix(ui): correct defaultRowsPerPageOptions import in List and add render test
* refactor(ui): default getStoredPerPage fallback to the first option
The fallback equalled options[0] at three of four call sites; default it so
those sites stop restating it. SongList and useAlbumsPerPage still pass an
explicit fallback where it legitimately differs from the first option.
* fix(ui): persist only user-selected page sizes, validate album size against width
Persisting on every pagination context value let a URL-injected perPage (e.g.
the perPage=15 album link in NowPlayingPanel) or a forced single-option mobile
grid overwrite the saved preference. Persist only a value that is an actual
option in a multi-option selector. Also validate the album grid's redux session
value against the current width so a size chosen at a wider breakpoint can't
leave an out-of-range selector.
* fix(ui): restore saved items-per-page on the radio list
RadioList passed a hard-coded perPage that overrode List's stored seed via the
props spread, so a radio-list page size was persisted but never restored. Seed
it from storage like the other list views.
* fix(ui): persist page size only on an actual selector change
Watching the pagination context value meant any valid value persisted itself:
loading a list at a breakpoint where the saved size is invalid stored the
responsive fallback, and opening a URL with a valid ?perPage= stored that too,
either way discarding the user's real preference. Inject a wrapped setPerPage
instead, so only the rows-per-page selector writes. This also drops the
option-validation heuristics, which the new trigger makes unnecessary.
* feat(playlists): register starred REST filter on playlist repository
* feat(ui): add persisted sidebarPlaylistsOnlyFavourites setting
* feat(ui): playlist favourites heart column and list filter
* feat(ui): favourite heart on playlist details header
* feat(ui): sidebar favourites-only playlist toggle with live refresh
* fix(playlists): qualify id in REST filter to avoid ambiguous column
The annotation join in selectPlaylist made a bare id filter ambiguous, so
GET /api/playlist?id=X (react-admin getMany, used by the sidebar refetch and
useResourceRefresh) failed with 'ambiguous column name: id'. Register
idFilter("playlist") like the album/artist/mediafile repos.
* fix(ui): refresh favourites sidebar on local star toggle
The SSE broker skips the client that originated a star, so the acting client
never got the refreshResource echo and its favourites-only sidebar went stale.
Key the sidebar query on a fingerprint of locally-known starred playlists so a
star/unstar on this client refetches; SSE still covers other clients.
* style(ui): prettier-format PlaylistsSubMenu test
* feat(ui): refine playlist favourites layout
- Move the favourite heart column to just before the edit button
- Space the Playlists sidebar header text from its action icons
- Use a list icon instead of a cog for the playlist-management action
* feat(ui): make the playlist Favourite column toggleable
Move the heart into the toggleable fields map so users can show/hide it
from the column selector like the other optional columns, keeping it last
so it stays just before the edit button.
* refactor(ui): memoize sidebar star fingerprint and gate it on favourites-only
- Derive starFingerprint via useMemo on the playlist data reference instead
of recomputing sort/join inside useSelector on every app-wide dispatch.
- Only include the fingerprint in the query payload when favourites-only is
on, so a star toggle no longer refetches the sidebar when it shows all
playlists.
* fix(ui): don't refetch favourites sidebar on SSE events when showing all
When favourites-only is off the sidebar shows every playlist, so a star
event from another client changes nothing visible. Gate the SSE-driven
refresh counter (and its payload key) on onlyFavourites so the sidebar no
longer redraws on unrelated playlist star events.
* fix(ui): address automated review feedback on playlist favourites
- Ignore a persisted favourites-only preference when EnableFavourites is off,
so disabling the feature later can't strand a filtered sidebar (Codex P2).
- Make PlaylistLove's datagrid header props explicit (source/sortable via
defaultProps, className forwarded) instead of relying on prop pass-through.
- Add aria-label to the SubMenu secondary action button.
- Cover the PlaylistLove list column with tests (Codex P1).
* Add resource lists to default view options
* refactor(ui): reuse getStoredDefaultView in AlbumList default-view redirect
Avoid duplicating the localStorage fallback logic and skip the unused
albumLists lookup in the resource-redirect branch, per PR review feedback.
* perf(db): add composite indexes for song list album/artist sorts
The media_file sort mappings for album, artist and albumArtist expand to
multi-column ORDER BY clauses that no existing index could satisfy, so SQLite
fell back to a full table scan plus a temp B-tree sort of every row (including
the large lyrics/tags/full_text columns) even for a single 15-item page. On a
96K-track library this made /api/song?_sort=album take 3.6s on a cold cache.
Add composite indexes matching the three sort mappings, allowing the query to
walk the index and stop at the page size, in both directions. Drop the now
redundant single-column order_album_name/order_artist_name indexes (strict
prefixes of the new composites) and three indexes with no query path:
birth_time is only read in Go code, and artist/album_artist text column
lookups go through the media_file_artists table instead.
* fix(ui): make composer and track number columns non-sortable in song list
Clicking the Composer header was a silent no-op: composer is not a media_file
column, so the native API's sanitizeSort drops the sort and returns rows in
table order. Track number sorting across the whole library is not meaningful
and cannot use an index (the existing index leads with disc_number). Mark both
columns sortable={false}, like quality and mood.
* test(persistence): add sort index coverage test for large tables
Guard against sort options silently losing index support: every sort mapping
on media_file, album and artist is now verified with EXPLAIN QUERY PLAN to be
satisfiable by an index (both directions), so adding a mapping or dropping an
index that reintroduces a full-table temp B-tree sort fails the test. Sorts
that genuinely cannot use an index (random, annotation-join columns, JSON
expressions) must be declared in an exceptions list with the reason, keeping
the trade-off visible in review.
To make the sort mappings the complete declared sort surface, add identity
mappings for the media_file columns the UI sorts by without a mapping (year,
genre, duration, channels, bpm, path, comment, play_count, play_date, rating).
These are behaviorally no-ops: the same ORDER BY was previously produced by
the field whitelist fallback.
* perf(db): drop PreferSortTags expression indexes from media_file
The media_file sort_title/sort_artist_name/sort_album_name expression indexes
are only usable when PreferSortTags is enabled - a config reported by ~0.1% of
installations (insights, week of 2026-06-22) - yet every install pays their
storage (~8.6MB on a 96K-track library) and scanner write overhead. Drop them:
PreferSortTags installs fall back to a full sort for title/artist/album orders,
everyone else gets smaller DBs and cheaper writes. The order_album_name and
order_artist_name collation checks remain valid, now satisfied by the composite
sort indexes.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): make self-service profile edits report their outcome
When a non-admin user saved their own profile (e.g. changing their
password via EnableUserEditing), the data provider followed the user
update with a call to the admin-only PUT /api/user/{id}/library
endpoint, which always failed with 403. The save error handler then
crashed reading error.body.errors on the plain-text response, so the
user got no notification at all - while the profile change had in fact
already been applied. This made password changes look like they were
silently ignored, and follow-up attempts failed with 'password does not
match' since the current password had already changed. Present since
the multi-library support introduced in v0.58.0 (#4181).
Only call the user-library association endpoint when the logged-in user
is an admin (the server manages assignments for self-edits), and make
the save error handler tolerate error bodies without field errors,
notifying a generic error instead of crashing.
* fix(ui): tolerate nullish rejection values in user save handler
Address review feedback: use optional chaining on the error itself in
the UserEdit save handler, so a nullish rejection value also results in
the generic error notification instead of a TypeError.
Pulls in the lyric timing fixes: lyrics no longer stack from the previous
song on rapid track changes (#5661), stay in sync after seeking/scrubbing
(including while paused), and show a music-note placeholder during intros
and gaps instead of the 'no lyrics' message.
Fixes a transient jump to the wrong song when switching the play queue.
When a new queue was loaded at a non-zero index (e.g. playing a different
album/playlist from a track other than the first, or playing a new album
after closing the player), the web player briefly loaded and played the
track that sat at the *previous* internal index in the new queue before
correcting to the chosen one — an audible "skip to a random song, then
back to the song I chose".
The root cause was in the player library: when loading a new audio list,
the initial track was picked using the stale internal play index instead
of the requested playIndex. Fixed in navidrome-music-player 4.25.3
(navidrome/react-music-player), which derives the initial track from the
requested playIndex.
Expand backend lyrics support with richer sidecar formats and upgrade the
OpenSubsonic songLyrics implementation to the version 2 structured karaoke
contract, while preserving version 1 behavior by default.
Sidecar formats and parsing:
- Add a TTML parser (core/lyrics/ttml.go): clock time, offset time, bare
decimal seconds, nested timing contexts, and token-level <span> timing for
word/syllable karaoke. Parses Apple Music-style metadata tracks (translation
and pronunciation/transliteration) and agent metadata into per-track agents[]
plus per-cue-line agentId. Hydrates missing line timing from cue timing.
- Add an SRT parser (core/lyrics/srt.go).
- Add a LRCLIB Lyricsfile (.yaml/.yml) parser (model/lyricsfile.go): maps
per-word lines[].words[] to cues with inclusive UTF-8 byte offsets and
attributes overlapping lines to synthetic voice agents so parallel vocals
split correctly in the enhanced response.
- Extend LRC parsing for Enhanced LRC inline <mm:ss.xx> word-timing markers.
- Add UTF-8 BOM and UTF-16 LE support for TTML/LRC sidecars.
- Parse the above formats from embedded tags as well as sidecar files.
Source resolution:
- Default lyricspriority is now
".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded" so the new formats are
discoverable without manual configuration.
- Preserve configured source priority across duplicate media-file candidates
instead of only checking the first DB match, so higher-priority sidecar
lyrics on older duplicates can still win.
- Raise the embedded-lyrics tag maxLength to 1 MB to fit word-timed
TTML/Enhanced-LRC karaoke for a full song.
OpenSubsonic songLyrics v2:
- Advertise songLyrics versions [1, 2].
- With enhanced=true, getLyricsBySongId may return structuredLyrics.kind
(main/translation/pronunciation), cueLine[] line-level karaoke groupings,
cueLine.cue[] timed words/syllables with required UTF-8 byteStart/byteEnd,
reusable structuredLyrics.agents[], and cueLine.agentId references.
- Without enhanced=true, the response stays v1-compatible: no kind, no cueLine,
no agents, no non-main tracks; the existing line[] payload is always
populated so legacy clients keep working.
Contract details:
- cueLine is emitted only for synced lyrics with cue data.
- Within a cueLine, cue.end is normalized all-or-none and overlaps are removed;
overlaps across separate cueLines remain valid for parallel vocal layers.
- Missing cue end-times are filled from the next cue or the parent line.
- When cueLines share an index, the one whose agent has role "main" is first.
- LyricCue.Value is serialized as XML chardata; cues with nil start are skipped
rather than serialized as 0.
Refactoring:
- Move pure format parsers into model/ (lyrics.go, lyrics_ttml.go,
lyrics_srt.go, lyrics_embedded.go, lyricsfile.go) and extract Subsonic
response building into server/subsonic/lyrics.go.
- Centralize lyric-kind constants and add Lyrics.EffectiveKind/IsMainKind.
- Add gg.Clone helper.
Spec references:
https://github.com/opensubsonic/open-subsonic-api/discussions/213https://github.com/opensubsonic/open-subsonic-api/pull/218 (songLyrics v2)
https://github.com/opensubsonic/open-subsonic-api/pull/228 (cue byte offsets)
* feat(stream): add ClientInfo.ForceFormat for browser-aware forced format
Restricts the client to a forced transcoding format and suppresses direct
play, but only when the client declares it supports that format. Part of #5583.
* fix(transcoding): honor player forced format on getTranscodeDecision
When the WebUI player has a forced transcoding format configured and the
browser declares it can play that format, transcode to it (suppressing
direct play). Fall back to normal negotiation with a warning when the
format is unsupported. The MaxBitRate cap still applies on top. Fixes#5583.
* test(e2e): cover player forced format on getTranscodeDecision
Forced format honored when the client supports it, falls back to negotiation
otherwise, and the MaxBitRate cap still applies on top. Part of #5583.
* feat(ui): remove obsolete 'format ignored' helper text on player form
The web player now honors the forced transcoding format, so the caveat added
in #5611 no longer applies. Reverts the Transcoding field to a plain selector.
Part of #5583.
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes#5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: load ND_DEFAULTLANGUAGE on app startup
Added in to apply on initial mount, ensuring the locale is set even when the login page is skipped by reverse-proxy authentication. Removed the redundant language-init effect from . Fixes#3605.
* style(ui): format App.jsx with Prettier
Ran Prettier on ui/src/App.jsx to satisfy code style checks after adding default-language useEffect.
* fix(ui): move default language initialization to Admin component
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): streamline locale setting in App component
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Add Catppuccin Latte (the light version) theme based on the existing Catppuccin Macchiato theme.
The palette and player styling are adapted for light mode while staying as close as practical
to the existing Macchiato theme behavior. I've opted to use gray for the
color for controls.
The dark version appears to mix a few control/accent colors,
so for Latte I standardized those choices. This might be worth looking
into in a separate PR. It uses gray and blue.
Signed-off-by: Love Billenius <lovebillenius@disroot.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Signed-off-by: Deluan <deluan@navidrome.org>
* Add Moonbase theme
A warm dark theme with gold (#d4a039) accents on deep charcoal
backgrounds (#0a0a09/#141413). Features muted cream text (#e5ddd3),
copper error states (#c45c3c), and subtle earthy secondary tones.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix review comments on Moonbase theme
- Fix CSS selector: use :not(.player-delete) instead of :not([class=".player-delete"])
- Fix MuiFormHelperText override structure: target error key directly
- Remove empty icon: {} and avatar: {} from NDLogin overrides
- Use comma-separated rgba syntax and hex for linear-gradient
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add Moonbase Alpha (light) and rename dark to Moonbase Bravo
Split the Moonbase theme into a complementary pair:
- Moonbase Alpha: warm cream/stone light theme with deep gold accents
- Moonbase Bravo: the original deep charcoal dark theme
Both share the same gold (#d4a039) brand accent, copper error states,
and earthy neutral palette. Alpha uses darkened gold (#9a7420) for
better contrast on light backgrounds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* Add Gruvbox Dark theme
Add Gruvbox Dark color theme including:
- gruvboxDark.js with full palette and component overrides
- gruvboxDark.css.js with custom player styles
* Fix: move error state to MuiFormHelperText
* fix(lastfm): require signed state token on link callback
The Last.fm OAuth callback at /api/lastfm/link/callback trusted a raw
\`uid\` query parameter and wrote the resulting Last.fm session key under
that user with no ownership check. Any authenticated user who learned a
victim's internal user ID (e.g. from playlist ownerId) could redirect the
victim's scrobbles to an attacker-controlled Last.fm account by calling
the callback directly with the victim's uid and a Last.fm token obtained
for their own account.
The callback cannot use the regular auth middleware because it is reached
via a browser redirect from Last.fm, which cannot carry a JWT header.
Instead, GET /api/lastfm/link (authenticated) now also returns a short-
lived (5 min) HMAC-signed link token bound to the requesting user, with a
dedicated "lastfm-link" scope claim. The callback verifies the signature,
scope and expiry before deriving the user ID from the token; the \`uid\`
query value is no longer trusted as a user identifier. The UI fetches
this token at link-flow start and passes it in place of the raw user ID.
Reuses the existing HS256 secret via auth.EncodeToken/DecodeAndVerifyToken
so no new key management is introduced.
* fix(ui): keep Last.fm popup tied to user gesture for Safari
Opening the Last.fm OAuth tab after an awaited fetch causes the popup to
be blocked on Safari and on Firefox with strict popup blocking enabled,
because the browser's transient-activation window has already elapsed by
the time window.open is reached. Linking became impossible on those
browsers in the previous commit.
Move the click handler up to the parent component and open a placeholder
about:blank tab synchronously from the click; the linkToken fetch then
runs in parallel and we redirect the existing tab to Last.fm's auth URL
once it resolves. The user gesture stays attached to the window.open
call, so popup blockers no longer fire.
The polling/progress UI is unchanged; it now receives the openedTab ref
from the parent instead of owning it.
* fix(lastfm): require exp claim on link tokens
jwtauth.VerifyToken treats a JWT without an exp claim as non-expiring, so
verifyLinkToken used to delegate expiry handling entirely. A future
regression in createLinkToken that dropped the exp field would silently
turn link tokens into permanent bearer credentials.
Assert presence of an exp claim explicitly and add a regression test
covering the missing-exp case. Also tightens the wrong-scope test to use
a freshly-minted token with all claims present except the scope, instead
of relying on auth.CreatePublicToken which happens to also be missing
exp.
* style(lastfm): simplify comments in link token code
Trim doc comments on createLinkToken/verifyLinkToken/callback/startLink
to the load-bearing lines: keep the non-obvious 'jwtauth treats missing
exp as non-expiring' note and the popup-blocker hint, drop the rest
since the function names already describe behavior.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(lastfm): address review feedback on link token PR
- Wrap openInNewTab in a try/catch in startLink: openInNewTab calls
win.focus() unconditionally, so if the browser blocks the popup
(window.open returns null) it throws a TypeError synchronously,
before the catch() on the link-token fetch is attached. The throw
used to escape the click handler, leaving the UI without a
notification. Now the failure is surfaced as lastfmLinkFailure and
the toggle stays usable.
- Rename the link-token "subject" rejection message to "user ID" since
the claim is uid, not the JWT sub field.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
The navidrome-music-player library rewinds the current track by directly
mutating audio.currentTime when the Previous button is pressed with
restartCurrentOnPrev (and other programmatic seek paths like singleLoop
reset and mediaSession seek). It does not invoke its onAudioSeeked
callback for these, so the play tracker never learned about the new
position until the next ~30s heartbeat.
Replace the React onAudioSeeked prop with a native HTML5 'seeked' event
listener on the audio element, which fires for every seek (programmatic
or via slider release). The handler is debounced by 250ms so the burst
of seeks emitted while dragging the progress bar coalesces into a single
reportPlayback call at the final position.
* 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
* 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>
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.
* 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>
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
* 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>
The Squiddies Glass theme applies a CSS color filter to all images inside
table cells (MuiTableCell '& img'), which was intended for small playback
indicator icons. This inadvertently also applied to disc cover art
thumbnails in multi-disc album views, turning them into solid color
blocks. Adding 'filter: none !important' to the discCoverArt style
ensures cover art images are always displayed correctly regardless of
the active theme.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): regenerate package-lock.json to have integrity fields
* chore(deps): update esbuild and related packages to version 0.27.7
Signed-off-by: Deluan <deluan@navidrome.org>
* chore(lint): exclude node_modules from golangci-lint
Prevents lint errors from Go files inside npm packages under
ui/node_modules from being picked up by golangci-lint.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* refactor(artwork): rename DevJpegCoverArt to EnableWebPEncoding
Replaced the internal DevJpegCoverArt flag with a user-facing
EnableWebPEncoding config option (defaults to true). When disabled, the
fallback encoding now preserves the original image format — PNG sources
stay PNG for non-square resizes, matching v0.60.3 behavior. The previous
implementation incorrectly re-encoded PNG sources as JPEG in non-square
mode. Also added EnableWebPEncoding to the insights data.
* feat: add configurable UICoverArtSize option
Converted the hardcoded UICoverArtSize constant (600px) into a
configurable option, allowing users to reduce the cover art size
requested by the UI to mitigate slow image encoding. The value is
served to the frontend via the app config and used by all components
that request cover art. Also simplified the cache warmer by removing
a single-iteration loop in favor of direct code.
* style: fix prettier formatting in subsonic test
* feat: log WebP encoder/decoder selection
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(artwork): address PR review feedback
- Add DevJpegCoverArt to logRemovedOptions so users with the old config
key get a clear warning instead of a silent ignore.
- Include EnableWebPEncoding in the resized artwork cache key to prevent
stale WebP responses after toggling the setting.
- Skip animated GIF to WebP conversion via ffmpeg when EnableWebPEncoding
is false, so the setting is consistent across all image types.
- Fix data race in cache warmer by reading UICoverArtSize at construction
time instead of per-image, avoiding concurrent access with config
cleanup in tests.
- Clarify cache warmer docstring to accurately describe caching behavior.
* Revert "fix(artwork): address PR review feedback"
This reverts commit 3a213ef03e.
* fix(artwork): avoid data race in cache warmer config access
Capture UICoverArtSize at construction time instead of reading from
conf.Server on each doCacheImage call. The background goroutine could
race with test config cleanup, causing intermittent race detector
failures in CI.
* fix(configuration): clamp UICoverArtSize to be within 200 and 1200
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(artwork): preserve album cache key compatibility with v0.60.3
Restored the v0.60.3 hash input order for album artwork cache keys
(Agents + CoverArtPriority) so that existing caches remain valid on
upgrade when EnableExternalServices is true. Also ensures
CoverArtPriority is always part of the hash even when external services
are disabled, fixing a v0.60.3 bug where changing CoverArtPriority had
no effect on cache invalidation.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: default EnableWebPEncoding to false and reduce artwork parallelism
Changed EnableWebPEncoding default to false so that upgrading users get
the same JPEG/PNG encoding behavior as v0.60.3 out of the box, avoiding
the WebP WASM overhead until native libwebp is available. Users can
opt in to WebP by setting EnableWebPEncoding=true. Also reduced the
default DevArtworkMaxRequests to half the CPU count (min 2) to lower
resource pressure during artwork processing.
* fix(configuration): update DefaultUICoverArtSize to 300
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(Makefile): append EXTRA_BUILD_TAGS to GO_BUILD_TAGS
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
The config flag gates all image uploads (artists, radios, playlists),
not just cover art. Rename it to accurately reflect its scope across
the backend config, native API permission check, Subsonic CoverArtRole,
serve_index JSON key, and frontend config.
* feat(ui): cancel in-flight image requests on pagination and cache across remounts
When paginating quickly through list/grid views, image requests for previous
pages were never canceled, queuing on the server and blocking new images.
This adds a useImageUrl hook that loads images via fetch() with AbortController,
so requests are canceled when components unmount. A module-level cache (URL →
blob URL) with reference counting ensures React Admin refreshes display images
instantly without re-fetching.
* feat(ui): update AlbumListPagination to conditionally render based on albumListType
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(ui): abort all in-flight image fetches on pagination change
Pagination component now watches page/perPage via useListContext and
calls abortAllInFlight() when either changes, freeing the browser
connection pool immediately for the next page's data request.
Also adds empty placeholder style to CoverArtAvatar so it renders as a
clean transparent area while loading instead of the default person icon.
* Revert "feat(ui): abort all in-flight image fetches on pagination change"
This reverts commit 3bc09f9d03.
* fix(ui): limit concurrent image fetches to prevent connection pool saturation
With <img src>, the browser prioritizes API requests over image loads.
With fetch(), all requests compete equally for the HTTP/1.1 connection
pool (6 per origin), causing API requests to queue behind images and
making pagination feel unresponsive. Caps concurrent image fetches at
4 with a pending queue, leaving connections free for API requests.
Queued fetches for unmounted components are removed without ever
hitting the network.
* fix(ui): fix queued fetch not aborted on unmount
Set queued=false when doFetch executes from the pending queue, so
cleanup correctly calls controller.abort() instead of searching an
already-drained queue.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Increased the UI cover art request size from 300px to 600px for sharper
images on high-DPI displays. Replaced BiLinear with CatmullRom (bicubic)
interpolation for higher quality image resizing. Extracted the hardcoded
size into a COVER_ART_SIZE constant in the frontend and consolidated
backend sizes into a CacheWarmerImageSizes slice. Removed the unused
UIThumbnailSize constant.
Signed-off-by: Deluan <deluan@navidrome.org>
Removed marginBottom: '3px' from tileBar and tileBarMobile styles that
was causing the hover overlay to not fully cover the album cover art.
The margin pushed the absolutely-positioned GridListTileBar up, leaving
a visible gap at the bottom. This became apparent after d2a54243a added
aspectRatio: 1 to the cover container.
* feat(artwork): add KindRadioArtwork and EntityRadio constant
* feat(model): add UploadedImage field and artwork methods to Radio
* feat(model): add Radio to GetEntityByID lookup chain
* feat(db): add uploaded_image column to radio table
* feat(artwork): add radio artwork reader with uploaded image fallback
* feat(api): add radio image upload/delete endpoints
* feat(ui): add radio artwork ID prefix to getCoverArtUrl
* feat(ui): add cover art display and upload to RadioEdit
* feat(ui): add cover art thumbnails to radio list
* feat(ui): prefer artwork URL in radio player helper
* refactor: remove redundant code in radio artwork
- Remove duplicate Avatar rendering in RadioList by reusing CoverArtField
- Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put)
* refactor(ui): extract shared useImageLoadingState hook
Move image loading/error/lightbox state management into a shared
useImageLoadingState hook in common/. Consolidates duplicated logic
from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views.
* feat(ui): use radio placeholder icon when no uploaded image
Remove album placeholder fallback from radio artwork reader so radios
without an uploaded image return ErrUnavailable. On the frontend, show
the internet-radio-icon.svg placeholder instead of requesting server
artwork when no image is uploaded, allowing favicon fallback in the
player.
* refactor(ui): update defaultOff fields in useSelectedFields for RadioList
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address code review feedback
- Add missing alt attribute to CardMedia in RadioEdit for accessibility
- Fix UpdateInternetRadio to preserve UploadedImage field by fetching
existing radio before updating (prevents Subsonic API from clearing
custom artwork)
- Add Reader() level tests to verify ErrUnavailable is returned when
radio has no uploaded image
* refactor: add colsToUpdate to RadioRepository.Put
Use the base sqlRepository.put with column filtering instead of
hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API
fields, preventing UploadedImage from being cleared. Image upload/delete
handlers specify only UploadedImage.
* fix: ensure UpdatedAt is included in colsToUpdate for radio Put
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Added a custom AlbumListPagination component that returns null while the
list is loading, preventing stale pagination controls from appearing
alongside the Loading spinner when navigating to the Random album view.
Added aspect-ratio: 1 to the cover container so it reserves the correct
square dimensions immediately on first render, before react-measure
reports the container width. Previously, contentRect.bounds.width started
as undefined/0, causing images to render with zero height and producing a
brief flash of compressed tiles before the measurement callback fired.
The lockfile still referenced the local file path from testing,
causing CI to fail resolving the navidrome-music-player import.
Regenerated to point to the npm registry.
* feat: add shared ImageUploadService for entity image management
* feat: add UploadedImage field and methods to Artist model
* feat: add uploaded_image column to artist table
* feat: add ArtistImageFolder config option
* refactor: wire ImageUploadService and delegate playlist file ops to it
Wire ImageUploadService into the DI container and refactor the playlist
service to delegate image file operations (SetImage/RemoveImage) to the
shared ImageUploadService, removing duplicated file I/O logic. A local
ImageUploadService interface is defined in core/playlists to avoid an
import cycle between core and core/playlists.
* feat: artist artwork reader checks uploaded image first
* feat: add image-folder priority source for artist artwork
* feat: cache key invalidation for image-folder and uploaded images
* refactor: extract shared image upload HTTP helpers
* feat: add artist image upload/delete API endpoints
* refactor: playlist handlers use shared image upload helpers
* feat: add shared ImageUploadOverlay component
* feat: add i18n keys for artist image upload
* feat: add image upload overlay to artist detail pages
* refactor: playlist details uses shared ImageUploadOverlay component
* fix: add gosec nolint directive for ParseMultipartForm
* refactor: deduplicate image upload code and optimize dir scanning
- Remove dead ImageFilename methods from Artist and Playlist models
(production code uses core.imageFilename exclusively)
- Extract shared uploadedImagePath helper in model/image.go
- Extract findImageInArtistFolder to deduplicate dir-scanning logic
between fromArtistImageFolder and getArtistImageFolderModTime
- Fix fileInputRef in useCallback dependency array
* fix: include artist UpdatedAt in artwork cache key
Without this, uploading or deleting an artist image would not
invalidate the cached artwork because the cache key was only based
on album folder timestamps, not the artist's own UpdatedAt field.
* feat: add Portuguese translations for artist image upload
* refactor: use shared i18n keys for cover art upload messages
Move cover art upload/remove translations from per-entity sections
(artist, playlist) to a shared top-level "message" section, avoiding
duplication across entity types and translation files.
* refactor: move cover art i18n keys to shared message section for all languages
* refactor: simplify image upload code and eliminate redundancies
Extracted duplicate image loading/lightbox state logic from
DesktopArtistDetails and MobileArtistDetails into a shared
useArtistImageState hook. Moved entity type constants to the consts
package and replaced raw string literals throughout model, core, and
nativeapi packages. Exported model.UploadedImagePath and reused it in
core/image_upload.go to consolidate path construction. Cached the
ArtistImageFolder lookup result in artistReader to eliminate a redundant
os.ReadDir call on every artwork request.
Signed-off-by: Deluan <deluan@navidrome.org>
* style: fix prettier formatting in ImageUploadOverlay
* fix: address code review feedback on image upload error handling
- RemoveImage now returns errors instead of swallowing them
- Artist handlers distinguish not-found from other DB errors
- Defer multipart temp file cleanup after parsing
* fix: enforce hard request size limit with MaxBytesReader for image uploads
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* style(ui): add tooltips for long playlist and album names - 5068
Signed-off-by: Thiago Sfreddo <sfredo@gmail.com>
* fix dnd and improve performance
Signed-off-by: Thiago Sfreddo <sfredo@gmail.com>
* lint fixes
Signed-off-by: Thiago Sfreddo <sfredo@gmail.com>
* fix(ui): update tooltip styles for improved visibility and consistency
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): add overflow tooltip to playlist name for better visibility
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(ui): simplify OverflowTooltip and improve render performance
- Inline styles from useMenuTooltipStyles into OverflowTooltip (single consumer)
- Use MUI named colors (grey[700]/grey[300] with alpha) instead of raw rgba
- Stabilize ref callback with useCallback to avoid unnecessary ref churn
- Memoize Tooltip classes and hoist TransitionProps to module level
- Fix useLayoutEffect dependency: observe DOM size, not title string
---------
Signed-off-by: Thiago Sfreddo <sfredo@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* fix(ui): allow DefaultTheme "Auto" from config
When DefaultTheme is set to "Auto" in the server config, the
defaultTheme() function in themeReducer now returns AUTO_THEME_ID
instead of falling through to the DarkTheme fallback.
This allows useCurrentTheme to correctly read prefers-color-scheme
and select Light or Dark theme automatically for new/incognito users.
Adds themeReducer unit tests covering Auto, named-theme, and
unrecognized-value fallback paths.
* chore: format
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>