After moving parsing into model and resolution into the core service, the test
suite had parser tests duplicated across layers and builder tests in the wrong
file. Realign them to match the production structure:
- Move the GetLyricsBySongId response-building tests into a new
server/subsonic/lyrics_test.go, matching the extracted server/subsonic/lyrics.go.
- Replace the per-format parse-output assertions in core/lyrics/sources_test.go
with a suffix-dispatch table; keep the core-only encoding (BOM/UTF-16) and
not-exist cases. Parser correctness is covered in the model tests.
- Drop the duplicate embedded TTML/SRT parse assertions in the metadata mapping
tests; keep the tag-cap (>32KB) guard as a mapping-level smoke check.
- Trim the subsonic GetLyrics handler tests to handler wiring, dropping the
source-priority resolution and Options.Max assertions now owned by the core
service tests.
- Rename the core lyrics Describe to "Lyrics" and nest GetLyricsByArtistTitle.
No production code changed; coverage is preserved at the correct layer.
Move the legacy getLyrics artist/title resolution out of the subsonic handler
and into the core lyrics service as GetLyricsByArtistTitle. The service now
holds a DataStore and owns the candidate query, the bounded duplicate window
(maxLegacyLyricsCandidates), and the source-priority resolution, mirroring the
by-song-id path.
The subsonic handler collapses to a single service call plus response mapping,
no longer reaching into the datastore or knowing the candidate cap. NewLyrics
gains a DataStore parameter (wired via Wire). The now-unused
SongsByArtistTitleWithLyricsFirst filter is removed. Behavior is unchanged.
Adds core tests covering the window bound, empty result, and resolution.
Signed-off-by: Deluan <deluan@navidrome.org>
Turn the lyric-kind helpers into methods on the model types so the domain
logic lives next to the data instead of in the subsonic layer:
- Lyrics.EffectiveKind() replaces the free LyricKindOrMain(string) function
- Lyrics.IsMainKind() centralizes the main-kind predicate
- LyricList.Main() replaces the subsonic-local mainKindLyric helper, with
the main-kind-then-first fallback for the legacy plain-text getLyrics
The subsonic build helpers now call these methods, removing the duplicated
inline kind check. Behavior is unchanged. Adds model tests for all three.
Add a nil-safe generic gg.Clone[T](*T) *T to utils/gg alongside the existing
V and If helpers, and route the lyrics package's bespoke pointer-copy helpers
through it: ptrOrNil (model/lyrics.go) and cloneTTMLCue (model/lyrics_ttml.go)
are removed in favor of gg.Clone. Also replaces the local positiveOrDefault
helper with gg.If at its three TTML timing-param call sites.
This removes duplicated single-purpose plumbing in favor of shared, generic
utilities. Behavior is unchanged. Adds tests for gg.Clone covering copy,
non-aliasing, and nil input.
buildStructuredLyric had grown a ~70-line inline block doing agent indexing,
a bespoke main-first agent sort, and per-agent cue-line assembly, pushing
helpers.go past 700 lines. Move all lyric response builders into a dedicated
lyrics.go and split the inline logic into focused units: a lyricAgents index
with a named less() comparator, and buildCueLines for per-agent assembly.
helpers.go drops from 705 to 533 lines; no behavior change (existing v2
cue/agent tests pass).
getLyrics emitted structuredLyrics[0] verbatim, trusting parser ordering to
put the main track first. With translation/pronunciation tracks now possible,
select the main-kind entry explicitly (falling back to the first for untyped
lyrics), matching the kind-filtering the v2 getLyricsBySongId endpoint already
does. Adds a test with a translation track ahead of the main track.
The "main"/"translation"/"pronunciation" kind values were redefined in
three places (ttmlLyricKind* in the TTML parser, lyricsfileKindMain in the
Lyricsfile parser, and inline literals in the subsonic response builder).
Define them once as model.LyricKind* (kept as wire-compatible string values)
plus a LyricKindOrMain helper, and use them everywhere a kind is produced,
defaulted, or filtered. No wire-facing types change.
buildLyricCues (subsonic response) reimplemented the same fill-from-next /
fallback / all-or-none end-time logic that normalizeCueLine (model) already
ran at parse time. They operate on different groupings -- the response
builder normalizes per-agent cue subsets, the model the whole line -- so they
cannot be collapsed, but the algorithm itself was duplicated.
Extract model.NormalizeCueEnds([]Cue, fallbackEnd) as the single
implementation (now also clamping ends within the model line normalization),
and have buildLyricCues delegate to it and only map to the response type.
mapLyrics() used a dedicated rawPairs()/rawTags path solely to bypass the
32KB lyrics truncation cap (the comment claiming sanitization "strips XML"
was inaccurate -- pair-type tags are only truncated, never stripped). That
duplicated every file s tags into a second lowercased map on the scan hot
path.
Instead, raise the lyrics maxLength so the normal Pairs() path keeps large
word-timed TTML/Enhanced-LRC intact, and remove rawTags/rawPairs. The
existing ">32KB embedded TTML" test continues to pass through Pairs().
The legacy getLyrics handler type-asserted api.lyrics to a separate
BatchLyrics interface with a fallback loop for the non-batch case. The
assertion always succeeded (the only implementation is *lyricsService),
so the fallback was dead code -- and had it run, its file-outer /
priority-inner order would have violated the configured source priority.
Add GetLyricsForMediaFiles to the Lyrics interface and introduce a
narrow Provider interface (just GetLyrics) for the single-file plugin
contract. The handler now calls GetLyricsForMediaFiles directly, and
LoadLyricsProvider returns the narrower Provider.
Relocates the TTML, SRT, and embedded-dispatch parsers (pure text ->
model.LyricList transforms) into package model, alongside the existing
ToLyrics (LRC/ELRC) and ParseLyricsfile parsers.
This removes the model/metadata -> core/lyrics import edge (a lower layer
depending on business logic), which was the only reason model imported
core. core/lyrics now contains only the source-priority + plugin service,
which is consumed solely by server/subsonic.
parseTTML/parseSRT are exported as ParseTTML/ParseSRT so the remaining
core/lyrics service can still call them across the package boundary.
The scanner classified a file by the name of the directory entry, so a
symlink was treated as audio/image/playlist based on the link name rather
than what it actually points to. Now symlinks are fully resolved (following
the whole chain) and classified by the resolved target's extension, so a
symlink to a non-audio file is no longer imported as a track.
This also makes Scanner.FollowSymlinks apply to file symlinks, not just
directory symlinks as before. The default stays true, so following symlinks
to real audio files (second drives, shared folders, etc.) keeps working.
Adds trace logging for symlink resolution decisions and real-fs regression
tests covering multi-level symlink chains.
Thread the context.Context that goose.UpContext already passes into every
migration through to all DB calls: tx.Exec/Query/QueryRow become
tx.ExecContext/QueryContext/QueryRowContext with ctx. The shared helpers in
migration.go (notice, forceFullRescan, isDBInitialized) gain a ctx parameter
and all call sites are updated. No-op migration functions use blank params
(_ context.Context, _ *sql.Tx).
This is a behavior-preserving change: the SQL, arguments, and ordering of every
migration are unchanged; only cancellation/deadline propagation is added.
Add a forbidigo lint rule scoped to db/migrations/ that forbids the
non-context tx.Exec/Query/QueryRow forms, preventing regression.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): preserve source metadata when transcoding downloads
Default transcoding commands used `-map 0🅰️0` with no metadata mapping, so
transcoded files lost all source tags (title, artist, album, etc.). Downloads
in the original format were unaffected because the file is copied byte-for-byte.
Add `-map_metadata 0 -map_metadata 0:s:0` to the default commands. Both flags
are required: `-map_metadata 0` copies format-level tags (MP3/FLAC sources)
and `-map_metadata 0:s:0` copies stream-level tags (OPUS/OGG sources), which
store tags at different levels.
The flags are added in three coordinated places, since for users on the default
command the args are built programmatically (buildDynamicArgs) rather than from
the stored command string:
- consts.go default commands, for new installations
- buildDynamicArgs, the active path for default-command users
- a migration updating only rows that still hold the exact old default, so
customized commands are left untouched
AAC is included for consistency but remains a no-op: its `-f adts` container
cannot hold metadata, and the MP4 alternative breaks pipe streaming.
Fixes#5623
* fix(transcoding): target audio stream for metadata and propagate ctx
Address review feedback on the metadata-preservation change:
- Use `-map_metadata 0:s:a:0` instead of `0:s:0` to copy tags from the first
audio stream specifically. When a source has embedded cover art exposed as a
video stream at index 0 (common in music files), `0:s:0` pulls the image
stream's metadata and the audio tags are lost. Verified empirically with
ffmpeg 7.1.3: a source with video at stream 0 and a tagged audio stream loses
its title under `0:s:0` but keeps it under `0:s:a:0`; audio-only OPUS/MP3/FLAC
sources are unaffected by the change.
- Propagate the migration context via `tx.ExecContext(ctx, ...)` instead of
discarding it, so the migration honors cancellation/timeouts.
Claude-Session: https://claude.ai/code/session_015iFHDzX53wCKt11qFHMeZk
* perf(subsonic): speed up artist search3 deep-offset pagination
Empty-query and FTS artist search (search3/search2) paginated via a
CROSS JOIN library_artist + DISTINCT in Phase 1 purely for library access
control. The DISTINCT forced a temp b-tree over the whole junction table on
every page, making deep offsets O(offset): ~200ms at offset 299k on 300k
artists.
Replace it with a join-free EXISTS predicate keyed on artist.id, backed by a
new covering index on library_artist(artist_id, library_id). EXISTS keeps
artist as the ordered driver and never fans out rowids, so Phase 1 stays a
plain ordered scan that LIMIT/OFFSET can short-circuit. Admin, headless, and
all-libraries users skip the filter entirely (the dominant case) for a flat
ordered walk over the primary key.
Measured on a 300k-artist / 1M-song library: admin/all-libs pagination is
~4.5-5.4x faster at depth (~180ms to ~33ms at offset 400k); restricted
subset users keep correct, gap-free pages while also getting faster.
The narrowing artist filter is applied at the subsonic layer only when the
request targets a strict subset of the user's libraries, so the common case
(and the admin fast-path) is never burdened with a redundant predicate.
* fix(subsonic): narrow artist search by library set, not count
narrowsArtistLibraries decided whether to add the subsonic-layer artist
narrowing filter by comparing len(requested) < len(accessible). musicFolderId
is not deduplicated, so duplicate IDs inflated the requested count: a user
requesting ?musicFolderId=1&musicFolderId=1&musicFolderId=2 against three
accessible libraries produced len([1,1,2])==3, which is not < 3, so the filter
was skipped and the user saw artists from the third library too.
Compare as set membership instead: the request narrows iff some accessible
library is absent from it (requested is always a subset of accessible, validated
upstream by selectedMusicFolderIds). This is immune to duplicate IDs. Add a
regression test that fails against the old length-based check.
Also consolidate the repeated EXISTS/no-DISTINCT/O(page) rationale that the
prior commit spread across five sites down to a single authoritative comment on
ArtistLibraryFilter, with the call sites referencing it.
* perf(subsonic): drop redundant library_artist covering index
The migration added an index on library_artist(artist_id, library_id) on the
theory that the restricted-subset artist-search EXISTS needed it to seek by
artist_id. Benchmarking on a 405k-artist / 5-library dataset showed no benefit:
the EXISTS subquery constrains both columns (artist_id = and library_id IN), so
SQLite already resolves it as a covering-index seek on the existing
(library_id, artist_id) UNIQUE autoindex. With the new index present the planner
still picks the autoindex and ignores it.
Drop the migration and correct the comment. Removing ~11MB of dead index plus
its write-amplification on every library_artist insert/delete, for zero query
gain.
* fix(scanner): mark artists missing when they lose their last library
Artist search Phase 1 filters on artist.missing and Phase 2 inner-joins
library_artist, so a non-missing artist with no library_artist row (an orphan)
takes a pagination slot in Phase 1 and then vanishes in Phase 2, shortening the
page and shifting deep offsets. The admin/headless search fast-path walks artist
unfiltered, so it is fully exposed to this.
Two paths created such orphans without updating artist.missing:
- RefreshStats deletes library_artist rows whose stats are '{}' (artist lost all
content in a library) after every scan. This is the common source.
- Library deletion cascades away the library's library_artist rows.
Mark newly-orphaned artists missing at both sources, so the shared
'missing = false' search filter excludes them immediately instead of waiting for
a later scan. In RefreshStats the update only runs when the cleanup actually
removed rows (the only way a new orphan can appear), so steady-state scans pay
nothing; measured ~160ms on 300k artists only when orphans can exist.
* refactor(subsonic): address review feedback on artist search filter
Code-review follow-ups to the artist search pagination change:
- ArtistLibraryFilter: short-circuit to a constant-false predicate when no
library IDs are given, avoiding a degenerate empty IN () subquery.
- ArtistLibraryFilter: add an inner LIMIT 1 to the correlated EXISTS so SQLite
cannot flatten it into a fan-out join (an artist in multiple of the user's
libraries would otherwise yield duplicate rowids and corrupt pagination).
- narrowsArtistLibraries: compare accessible-vs-requested as a set lookup
instead of slices.Contains in a loop.
- searchConfig.LibraryFilter: document that a join-free filter is now a
correctness requirement (DISTINCT was removed), not just a performance one.
* docs: trim verbose comments in artist search/orphan code
Condense the over-explained comments added in this PR to the essential 'why',
removing repeated cross-references and restatements of the adjacent code.
* fix(scanner): heal pre-existing orphan artists on full refresh
The orphan-marking added to RefreshStats only ran when its empty-stats cleanup
deleted rows, so it reconciled newly-created orphans but not ones already left
in the database by older versions (whose library_artist row was deleted before
this fix existed). Such legacy orphans would surface in the admin/headless search
fast-path as short/gappy pages.
Also run the orphan-marking on a full refresh (allArtists), so a full scan — which
upgrades commonly trigger and users can run manually — reconciles the backlog. No
migration needed; the runtime fixes prevent recurrence.
* perf(subsonic): extend artist search fast-path to all-library users
applyLibraryFilterToSearchQuery only skipped the library filter for admin and
headless processes. A regular (non-admin) user who can access every library has
the same result set as an admin, but was still given the EXISTS filter — an
O(offset) cost for a predicate that matches every non-missing artist anyway.
Skip the filter for them too, using a cheap library CountAll() (a count over the
tiny library table) compared against the user's library count. On any error it
falls back to the filtered path, which is correct, just slower.
* fix(scanner): log error as trailing arg, not explicit error key
Signed-off-by: Deluan <deluan@navidrome.org>
* test(scanner): e2e guard for orphan artists under PurgeMissing
Adds an end-to-end scanner test for the orphan-artist invariant fixed in
RefreshStats: with Scanner.PurgeMissing enabled, removing all of an artist's
files hard-deletes them, cascades away their media_file_artists rows, and
RefreshStats then drops the artist's emptied library_artist row. The test
asserts no non-missing artist is left without a library_artist row. Verified it
fails without the RefreshStats orphan-marking and passes with it.
* test(scanner): assert the orphaned artist is marked missing
The orphan e2e test only checked the aggregate no-orphan invariant
(orphanCount == 0), which a fully-deleted artist or an un-cleaned row would also
satisfy — so it could pass without exercising the fix. Assert Pink Floyd's row
specifically: missing=false before, missing=true after, and absent from the
non-missing results. Verified it fails without the RefreshStats orphan-marking.
* test(scanner): drop misleading non-missing-list assertion for orphan
GetAll has no default missing filter, but selectArtist inner-joins library_artist,
so an orphaned artist (no junction row) is excluded from the results whether or
not it is marked missing. The Not(ContainElement) check therefore passed for the
wrong reason. The direct floydMissing() == 1 query is the assertion that actually
validates the missing flag; keep that plus the orphan-count invariant and an
over-marking guard on The Beatles.
* test(scanner): document why orphan check reads the artist row directly
Clarify that GetAll cannot observe the orphan: selectArtist inner-joins
library_artist, so an artist with no junction row is excluded from results
whether or not it is marked missing. Asserting on GetAll would pass even without
the fix, so the test reads the artist row directly to check the missing flag.
* test(scanner): return descriptive artist state for clearer failures
floydState returns PRESENT/MISSING/NOT_FOUND instead of 0/1/-1, so a failure
reads '<string>: PRESENT to equal MISSING' rather than '0 to equal 1'.
* refactor(subsonic): keep artist library scoping in the repository
The search endpoint built a persistence-layer EXISTS predicate
(persistence.ArtistLibraryFilter) and injected it into artistOpts.Filters — the
only place the subsonic package reached into persistence, leaking a storage
detail up two layers.
Pass the same Eq{"library_id": ids} filter used for albums and songs, and let
the artist repository translate it to the join-free library_artist predicate
(scopeSearchToLibraries), where the junction knowledge belongs. The subset-vs-
fast-path decision moves there too, so narrowsArtistLibraries and the persistence
import are gone from the subsonic layer. Behavior is unchanged; coverage for the
translation moves to artist_repository_test.
* refactor(persistence): extract canonical markOrphansMissing helper
The 'mark non-missing artists with no library_artist row as missing' invariant
was hand-written as SQL in two places (RefreshStats and libraryRepository.Delete),
in two slightly different dialects (not exists vs id not in). Extract a single
artistRepository.markOrphansMissing method next to markMissing and call it from
both sites, so the invariant has one definition.
* fix(persistence): apply scoped library filter in both search phases
Two bugs from moving the artist library-scoping into the repository:
- Search() scoped opts.Filters for Phase 1 but still passed the original
(unscoped) options to selectArtist, so Phase 2 re-applied the raw
Eq{library_id} against the wrong columns and a restricted user's search
returned nothing. Pass the scoped opts to both phases.
- scopeSearchToLibraries dropped the filter unconditionally for admins, so an
admin explicitly narrowing via musicFolderId (e.g. search3?musicFolderId=2)
leaked content from other libraries. Compare the request against the user's
visible library set (all libraries for admin/headless), narrowing whenever it
is a strict subset.
Both regressions were caught by the server/e2e multi-library suite.
* fix(core): delete library and reconcile orphans in one transaction
libraryRepository.Delete runs the FK-cascade delete and the orphaned-artist
reconciliation (markOrphansMissing) as two writes on r.db. Called directly they
autocommit separately, so an interruption between them could leave non-missing
artists with no library_artist row — the orphan state the artist search
fast-path forbids. Wrap the deletion in ds.WithTx at the core wrapper so both
writes commit atomically; the watcher/scanner/broker side-effects stay
post-commit.
* refactor(persistence): unify artist search library scoping into one filter
Phase 1 previously applied two overlapping library predicates: cfg.LibraryFilter
(scoped to the user's libraries) AND options.Filters (the requested subset),
producing two correlated EXISTS subqueries per rowid even though the request is
always a subset of the user's libraries. And the 'does this user see everything'
decision was implemented twice (userHasAllLibraries via CountAll vs
scopeSearchToLibraries via set-membership), with applyLibraryFilterToSearchQuery
as a third scoping path.
Resolve the effective library scope once in Search() via searchScope (intersect
the requested set with the user's visible libraries; nil = fast-path), clear
opts.Filters, and realize that single scope as the only Phase-1 LibraryFilter.
The visibility logic is now one pipeline: requestedLibraryIDs + visibleLibraryIDs
+ userSeesAllLibraries. Behavior unchanged; one EXISTS instead of two on the hot
path, one source of truth for library visibility.
* fix(persistence): harden artist search against malformed library_id filter
Search consumed only an Eq{"library_id": []int} filter; an Eq whose library_id
value wasn't []int slipped through unconsumed and would reach Phase 1's bare
artist table (no library_id column) → SQL error. Recognize any Eq carrying a
library_id key (isLibraryIDFilter) and always consume it, falling back to the
user's visible scope for a malformed value. Non-library filters are still left
in place for doSearch.
* refactor(persistence): trim redundant comments and unexport artist library filter
The artist-search-pagination work left dense explanatory comments, with the
join-free / LIMIT-1 anti-flatten rationale and the orphan-artist mechanics each
restated in several places. Consolidate each rationale into one canonical home
(artistLibraryFilter for the EXISTS/LIMIT-1 trick, markOrphansMissing for the
orphan lifecycle) and have the other sites reference it instead of repeating it.
Also unexport ArtistLibraryFilter to artistLibraryFilter: its only caller is
searchCfg in the same package and no test references it, so it never needed to
be part of the package's exported surface.
Comments only plus the rename; no behavior change.
* refactor: add slice.ToSet and use it for the artist search subset check
searchScope's subset test compared the requested libraries against the visible
set with a nested slices.Contains, which is O(visible * requested). On an instance
with many libraries (e.g. 100 libraries, a user granted 99) and an explicit
musicFolderId request, that is ~9.8k comparisons; with a set it is ~200.
Add a small reusable slice.ToSet helper (a slice -> map[T]struct{} set, collapsing
duplicates) and use it to make the membership lookups O(1), restoring O(n+m) without
the throwaway struct{}{} literal that an inline ToMap would need. No behavior change.
* refactor(artist): move artistLibraryFilter to artist_repository
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
getRandomSongs used a single ORDER BY random() over the media_file table. At large
library sizes that forces SQLite to scan the wide table and sort every matching row
before applying the limit — roughly 4 seconds for a 1M-track library, regardless of how
many songs are requested.
Add MediaFileRepository.GetRandom, which does this in two passes: first select N random
rowids over a narrow index (filters + library scope only, no wide columns or joins), so
the random sort runs over compact, index-friendly data; then hydrate just those rows with
the full select. The wide media_file row is never part of the sort. End-to-end this drops
getRandomSongs from ~4s to ~0.3s on a 1M-track library, and the cost no longer grows with
the requested size.
The handler now calls GetRandom directly. The filter helper that builds the genre/year
filters is renamed SongsByRandom -> SongsByGenreAndYearRange to reflect what it does, since
the random ordering is now owned by GetRandom rather than a Sort option. Filters (genre,
year, library) compose into the first pass unchanged. The album random list path is left
as-is (far fewer rows, already fast).
* 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(scanner): import playlists skipped when no admin existed yet (#5499)
On a fresh install the first scan runs before any admin user exists, so the
scanner's playlist phase skips all playlists (playlists are owned by the
first admin). Nothing re-imported them afterwards because folder selection
is gated on updated_at > last_scan_at, which nothing bumps.
The playlist phase now:
- resolves the admin at phase time (FindFirstAdmin) instead of trusting the
context snapshot taken at scan start, so a long admin-less scan still
imports playlists in its own phase if an admin was created meanwhile;
- records a persisted PlaylistsImportPending flag when no admin exists yet;
- when that flag is set, imports ALL playlist folders via a new
GetAllWithPlaylists (bypassing the timestamp gate) and clears the flag.
Playlists are recovered by the next scan that runs with an admin, with no
dependency on scan duration and no changes to the auth/server layers.
* fix(scanner): surface datastore errors in playlist import deferral (#5499)
Address review feedback:
- distinguish model.ErrNotFound (no admin yet -> defer) from real datastore
errors when resolving the admin, so DB failures are propagated, not swallowed;
- propagate the error if the pending-import flag can't be persisted, so a scan
doesn't complete as successful without recording the recovery;
- surface read errors when checking the pending flag.
Also name the no-admin condition for readability.
* fix(scanner): simplify admin existence check in playlist import
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(scanner): streamline folder access in playlist import logic
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(smartplaylists): merge negated artist/tag rules in AND groups
Smart playlists with many negated role/tag conditions ANDed together (e.g.
100+ "isNot artist" rules, issue #5511) generated one correlated NOT EXISTS
subquery per rule, scanning media_file_artists for every candidate row. On
large libraries this took minutes and triggered API timeouts and SQLite lock
contention.
By De Morgan, "NOT EXISTS(role=X) AND NOT EXISTS(role=Y)" is equivalent to
"NOT EXISTS(role=X OR role=Y)", so multiple negated conditions for the same
field can be collapsed into a single batched NOT EXISTS. This mirrors the
existing OR-group merge that #5515 added for positive conditions.
The shared grouping/batching logic is extracted into mergeSameFieldConds,
parameterized by polarity, so the OR/positive and AND/negated paths reuse one
algorithm instead of duplicating it. roleCondGroup/tagCondGroup gain a 'not'
flag to emit the negated subquery.
Benchmark (323k tracks, 120 isNot artist rules, reporter's exact shape):
merged ~54ms vs unmerged ~8.7s steady-state (~160x faster).
* docs: trim redundant comments on merge helpers
The De Morgan explanation was repeated across three doc comments. Keep it in
one place (mergeNegatedJsonConds, where negation is introduced) and reduce the
shared core and group-type comments to concise one-liners.
* fix(artwork): avoid WebP segfault on 32-bit ARM
On 32-bit ARM, the gen2brain/webp native libwebp path uses ebitengine/purego
reverse callbacks, which purego does not support on that architecture. Selecting
it crashes the process with a SIGSEGV when encoding or decoding WebP cover art,
taking down the whole server on the first web UI artwork request (issue #5597).
Force the safe WASM path on armv7/v6 in two layers: build the Docker arm binary
with the gen2brain/webp "nodynamic" tag so purego is never linked, and add a
runtime GOARCH guard in the init hook so source builds on 32-bit ARM are also
protected. arm64 keeps the native libwebp path.
* fix(artwork): also disable native WebP on 32-bit x86
purego's callback implementation is built with the constraint !386 && !arm,
so 32-bit x86 (386) crashes with the same SIGSEGV as 32-bit ARM when the native
libwebp path is used. Navidrome ships linux/386 and windows/386 builds, so guard
386 alongside arm: extend the runtime GOARCH check and the Docker nodynamic build
tag to cover both. 64-bit arches keep the native libwebp path.
* fix(artwork): rely on nodynamic build tag, drop ineffective runtime guard
The previous runtime GOARCH guard did not actually prevent the crash:
gen2brain/webp selects the native (purego) vs WASM backend in its own package
init() and registers the purego write callback at import time, before any
Navidrome hook runs. webp.Dynamic() is only a status getter, and Decode/Encode
branch on the library's unexported flag, so the guard merely skipped a log line
while the native path stayed active.
The effective fix is the nodynamic build tag (applied for 32-bit ARM and x86 in
the Dockerfile), which compiles gen2brain/webp WASM-only so purego is never
linked. Drop the misleading guard and document that source builds on 32-bit
architectures must be built with -tags nodynamic.
* fix(artwork): don't enable WebP encoding by default in Docker
The Docker image set ND_ENABLEWEBPENCODING=true, which (a) forced cover-art
thumbnails through WebP for every install and (b) overrode any
EnableWebPEncoding=false set in the user's navidrome.toml, since env vars take
precedence over the config file in Viper.
On 32-bit platforms the only available WebP backend is the WASM encoder, which
is slow on the underpowered hardware those builds typically run on, so enabling
it by default is the wrong tradeoff there. Remove the env default and leave
EnableWebPEncoding off unless the user opts in. Combined with the nodynamic
build tag, 32-bit images neither crash nor pay the WASM cost out of the box.
A smarter automatic policy (use WebP only when native libwebp is available) can
be revisited separately.
* test(artwork): add failing e2e tests for artist image leaking as album art
Reproduces a v0.62.0 regression (#5451/#5457): the album cover-art
parent-folder fallback can include the artist folder, serving the artist
thumbnail (e.g. Artist/folder.jpg) as album art for any album without
image files in its own folder(s). Covers three scenarios: a plain
Artist/Album layout with no album images, a single-disc album spread
across sibling folders under the artist folder, and a spread album whose
own front.jpg is shadowed by the artist's cover.jpg via CoverArtPriority
order. Also adds an albumByName test helper for multi-album layouts.
The tests are expected to fail until the parent-folder inclusion is
gated by a structural check (skip the common parent when audio from
other albums lives under it).
* fix(artwork): never serve artist folder images as album art
The album cover-art parent-folder fallback (introduced in #5451/#5457)
could include the artist folder as a source of album images, serving the
artist thumbnail (e.g. Artist/folder.jpg) as cover art for any album
without image files in its own folder(s). This affected both plain
Artist/Album layouts and single-disc albums spread across sibling
folders under the artist folder.
Gate the common-parent inclusion with a structural check: the parent
only qualifies as an album root when no audio belonging to other albums
lives in it or anywhere beneath it. An artist folder contains other
albums' tracks, while an album root above disc subfolders contains only
this album's, so the check works for any disc folder naming scheme and
never affects the multi-disc fixes from #5376/#5456. A single-album
artist with no images anywhere remains structurally indistinguishable
from an album root and is a known residual case.
* refactor(artwork): move album-root audio check into folder repository
Replace the raw subtree SQL (LIKE/ESCAPE expression and wildcard
escaping) that lived in core/artwork with an explicit
FolderRepository.HasAudioOutsideFolders method, implemented in the
persistence layer next to the existing folder-subtree query pattern.
This also removes the test mock's brittle dispatch that sniffed the
generated SQL to recognize the query; the fake now overrides the new
method directly.
Extract the whole parent-folder resolution from loadAlbumFoldersPaths
into an albumRootParent helper, flattening four levels of nesting back
into a linear flow. Behavior is unchanged; the unit test for a parent
containing audio moved to the persistence suite, with added coverage
for subtree boundaries, missing folders, and LIKE-wildcard escaping in
folder paths.
* refactor(persistence): use exists helper in HasAudioOutsideFolders
Replace the hand-rolled count(*) query with the repository's canonical
exists helper, as suggested in PR review.
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields
Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable
in the criteria field map, then extend missingExpr to handle string columns
where absence is encoded as NULL or empty string (plus '[]' for lyrics). The
Numeric/Boolean path (ReplayGain) is preserved via an explicit type check.
* refactor(model): make MediaFile BPM and BitDepth nullable pointers
Convert BPM and BitDepth fields in model.MediaFile from int to *int so that
'tag absent' is distinguishable from zero. The metadata mapper now uses
NullableFloat for BPM (nil when absent or zero/unparseable) and only sets
BitDepth when the audio property is non-zero (lossy codecs report 0).
All read sites use gg.V() for zero-fallback deref so Subsonic API output and
transcoding behaviour are byte-identical to before. The persistence layer
bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0
back to nil on read in PostMapArgs/PostScan; a later migration task will drop
those constraints.
Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil
*int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue,
so no files will be spuriously re-imported after this change.
Extra files touched beyond the plan's list: core/stream/legacy_client_test.go
(BitDepth in model.MediaFile literals), persistence/mediafile_repository.go
(NOT NULL bridge).
* test(model): pin pre-conversion golden hashes for BPM/BitDepth
* feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth
* feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL
Drop the NOT NULL constraint on media_file.bpm and bit_depth via a
lossless migration that converts legacy 0-means-absent values to real
NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging
the old NOT NULL columns to the *int model fields. Add round-trip
persistence tests asserting NULL storage for nil pointers and correct
value round-trip for non-nil pointers.
* test(e2e): verify isMissing/isPresent partition for nullable fields
Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id:
for each field, isMissing + isPresent song counts must equal the total
library count, proving the nullable-column SQL is exhaustive and correct.
* test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial
* fix(model): omit bitDepth from JSON when absent instead of emitting null
* feat(smartplaylist): support isMissing/isPresent on more string fields
Enable isMissing/isPresent operators for album, comment, catalognumber,
discsubtitle, albumcomment, sorttitle, sortalbum, sortartist,
sortalbumartist, and explicitstatus by marking them Nullable in fieldMap.
* refactor(smartplaylist): unify missingExpr column logic into one flow
Collapse the numeric/string fork in missingExpr into a single
empties-driven loop (numeric/boolean fields simply have no empties),
and replace the duplicated IsTag/IsRole guard with a three-way switch
that expresses the dispatch model once. No SQL semantics change for
string fields; numeric/boolean fields now emit a single-element Or/And
which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare
`col IS NULL`) — update the affected test expectations accordingly.
* fix(subsonic): make search3 empty-query pagination fast at large offsets
Empty-query search3 (used by clients like Symfonium to sync the whole
library) degraded linearly with songOffset: the offset optimization in
optimizePagination keeps the original query's LEFT JOINs (annotation,
bookmark, library) inside its rowid NOT IN subquery, making it as slow as
plain OFFSET (~5s per page at offset 900K on a 920K-track library).
Rewrite the empty-query branch of doSearch to use the same two-phase
approach as the FTS search: Phase 1 paginates rowids on the bare main
table, which SQLite satisfies with a covering index at any offset; Phase 2
hydrates only the page's rows with all JOINs. The Phase 2 hydration logic
is extracted into hydrateRowidPage, now shared with ftsSearch.execute.
Also replace the media_file_missing index with a composite covering index
on (missing, library_id), so Phase 1 stays covering for non-admin users,
whose queries include a library_id filter. The composite serves all
missing-only lookups via its prefix.
With a 920K-track / 85K-album test library, search3 empty-query responses
are now flat (~0.1s) at every offset, for both admin and non-admin users
(previously 3-5s at offsets above 600K).
* refactor(persistence): share search Phase 1 contract and dedup junction fan-out
Extract the Phase 1 query assembly that was duplicated between the FTS
search and the empty-query search into executeTwoPhase: both paths now
supply only their strategy-specific FROM/JOINs and ORDER BY, while the
shared contract (missing filter, library access, options.Filters, and
Max/Offset semantics) lives in one place.
Also fix a pagination integrity bug: the artist library filter joins the
library_artist junction table, so an artist present in multiple libraries
produced duplicate rowids in Phase 1, corrupting offset-based pagination
(short pages and repeated artists during full-library syncs). Phase 1 now
applies DISTINCT whenever a junction-based LibraryFilter is set. DISTINCT
is used instead of GROUP BY because bm25() cannot be evaluated in a
grouped query; plain-filter tables (media_file, album) skip the dedup so
their Phase 1 keeps the streaming covering-index plan. This also fixes the
same duplication in the pre-existing FTS search path.
* fix(persistence): pin artist search Phase 1 join order with CROSS JOIN
search3 always filters artists by library (library_artist.library_id IN
...), and with the junction JOIN in the search Phase 1 rowid query SQLite
chose to drive from library_artist, sorting every junction row with a temp
b-tree on each page — a flat ~200ms penalty per request at 405K artists,
even at offset 0 (the previous code avoided this by accident: its GROUP BY
artist.id pinned an artist-driven plan).
Use CROSS JOIN (SQLite's explicit join-order override) in a search-only
variant of the artist library filter, keeping artist as the outer table so
Phase 1 streams rowids in artist.id order from the primary key index and
LIMIT/OFFSET short-circuits. The DISTINCT dedup stays and costs nothing
under the streaming plan. Other artist queries keep the planner's freedom.
With 405K artists, empty-query artist search is now 0.07s at offset 0 and
0.25s at offset 399K end-to-end (was 0.31s/0.34s before this fix, and up
to 1.2s on master at deep offsets). Artist FTS text search is unaffected.
The jwx library always deserializes numeric claims from a parsed token as
float64, so the int and int64 branches in getIntClaim could never succeed
and were dead code. Keep only the float64 path, which is the one actually
exercised by the token round-trip, and update the comment to document the
library behavior.
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields
ReplayGain values are stored in dedicated nullable columns (rg_album_gain,
rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags
JSON blob. The isMissing/isPresent operators previously only supported tag and
role fields, causing two failure modes:
1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256:
these got registered as JSON tags from mappings.yaml, so isMissing queried
json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty
-> the playlist matched ALL songs.
2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL
generation returned an error. Because refreshSmartPlaylist deletes old tracks
before regenerating, the abort left the playlist empty.
Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add
static alias entries (replaygain_album_gain -> rgalbumgain etc.) with
Numeric+Nullable set; because AddTagNames skips names already in the field map,
these static entries take precedence over the mappings.yaml tag registration.
missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead
of the json_tree lookup.
Fixes#5584
* chore(smartplaylist): address code review feedback
- Simplify the isMissing/isPresent unsupported-field error message,
removing the internal "nullable fields" jargon
- Standardize comments on mappings.yaml (the actual filename)
- Clarify the alias precedence comment in the LookupField test
* 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>
* fix(ui): update Spanish translations and add missing gain keys
- Add missing 'albumGain' and 'trackGain' keys (matches recent additions in pt-br, ru)
- Translate 'Playlists' and 'Shared Playlists' to 'Listas de reproducción' / '...compartidas'
- Translate 'OFFLINE' (server down indicator) to 'DESCONECTADO'
Spanish translation now covers 553/553 keys (was 551/553).
Signed-off-by: Daniel Banariba <banaribad@gmail.com>
* fix(ui): address review feedback on Spanish translations
- Use 'Ganancia del álbum' (with article 'del') for consistency with the existing pattern in line 624 ('album': 'Ganancia del álbum') and 'Artista del álbum'. Thanks @gemini-code-assist for the catch.
- Revert 'playlists' and 'sharedPlaylists' to keep the loanword 'Playlist(s)' which is the form actually used by Spanish-speaking music app users (Spotify ES, etc.) and matches existing usage elsewhere in this same file (e.g. line 48 'Agregar a la playlist').
Signed-off-by: Daniel Banariba <banaribad@gmail.com>
---------
Signed-off-by: Daniel Banariba <banaribad@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>