* fix(plugins): align the Python HTTP example with the repo's host-call pattern
Bind http_send with raw memory offsets like nowplaying-py does, drop
guards for fields the host always sends, and document how plugins
without a PDK call host services and which built-in HTTP APIs are
disabled.
* docs(plugins): document the private-address rules for HTTP requiredHosts
Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can.
* docs(plugins): document the private-address rules for requiredHosts
Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can, for both
HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper.
* feat(plugins): derive Default for Rust host service structs
The ndpgen client.rs template now adds Default to the derive list of host
service structs, as the capability and shared types templates already do.
Plugin authors can now set only the fields they need, for example
HTTPRequest { method, url, ..Default::default() }. The webhook-rs and
discord-rich-presence-rs examples use this form now. The golden files and
the generated nd-pdk-host crate are updated to match.
* feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK
Navidrome no longer enables extism's http_request host function, so a
request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small
deprecation table and writes a Deprecated: paragraph for the listed extism
functions, in both the WASM wrapper and the native stub. Linters and IDEs
now point plugin authors to host.HTTPSend. The PDK example tests used to
teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock.
* docs(plugins): correct requiredHosts rules for websocket and private addresses
Two statements in the plugin docs did not match the code.
The WebSocket section claimed requiredHosts behaves like HTTP. It does not:
host_httpclient.go only consults the allowlist when the list is non-empty and
otherwise falls back to allowing public addresses, while host_websocket.go
always calls isHostInAllowlist, so an absent list blocks every connection.
The HTTP section claimed a named host can never reach a private address.
checkPrivateDial scans the whole requiredHosts list, so a named host does
reach a private address when the same list also holds a covering IP or CIDR.
Reworded both, plus the matching requiredHosts descriptions in
manifest-schema.json, and regenerated manifest_gen.go.
* fix(plugins): apply the private-address dial guard to WebSocket connections
The WebSocket host service only matched the host string against
requiredHosts, so an allowlisted name resolving (or rebinding) to a
private address was dialed. Share the HTTP client's resolved-IP check
and allowlist matching, so WebSocket follows the same rules: named hosts
can't reach private addresses, literal IP/CIDR entries and a bare "*"
can.
* refactor(plugins): drop redundant WebSocket dial timeout and tidy guard tests
* fix(share): always assign the authenticated user as share owner
A share's UserID was taken from the request body and only defaulted when
empty, so any authenticated user could create a share attributed to
another user. For playlist shares the contents are resolved in the
owner's library-access context, turning the spoofed owner into an
access-escalation vector in multi-library setups.
Force the owner from the request context at both the service boundary
and the persistence layer, ignoring any client-supplied UserID.
* fix(plugins): block SSRF to private IPs resolved from hostnames
The HTTP host client only checked the literal host string, so a symbolic
hostname (or a trailing-dot "localhost.") resolving to a private/loopback
address bypassed the SSRF guard when a plugin declared no requiredHosts.
Enforce the check at dial time via net.Dialer.Control on the resolved IP,
which also covers redirect hops and DNS rebinding. When an explicit
requiredHosts allowlist is set, defer to it as the operator's trust decision.
* fix(plugins): gate private IPs on explicit IP/CIDR allowlist entries
Following review feedback: an allowlisted hostname authorizes the external
service, not whatever private IP it may resolve or rebind to. Enforce the
resolved-IP guard even when requiredHosts is set, permitting a private
address only when a literal IP or CIDR entry explicitly covers it. This
keeps "reach this external API" and "reach my internal network" as two
separate, explicit operator decisions.
* fix(plugins): treat unspecified addresses as private in the SSRF guard
Dialing 0.0.0.0 or :: reaches the local host, so they bypassed the
private/loopback check.
* fix(plugins): let a bare "*" allowlist reach private addresses
Plugins such as AudioMuse-AI declare requiredHosts ["*"] to reach a
user-configured service on the LAN, whose address the manifest cannot
know. Requiring a literal IP/CIDR entry broke them. Named hosts and
subdomain wildcards still cannot resolve to private addresses.
* refactor(plugins): simplify the SSRF-guarded HTTP client and release its pool
Build the client directly around the guarded transport instead of
replacing a throwaway one, fail closed on an unparseable dial address,
and close the per-plugin transport's idle connections when the plugin
unloads. Trim stale comments.
* fix(plugins): stop enabling extism's unguarded http_request host function
Passing requiredHosts as the extism manifest's AllowedHosts enabled
extism's own http_request (pdk.NewHTTPRequest), which only glob-matches
the hostname and follows redirects without re-checking, bypassing the
resolved-IP SSRF guard. Plugins must use host.HTTPSend.
* fix(plugins): move bundled Rust examples to the host HTTP service
Extism's built-in http_request is now disabled, so the webhook and
Discord examples switch to nd_pdk::host::http::send. Update the README
to say host.HTTPSend is the only supported way to make HTTP requests.
* fix(plugins): move the Python example to the host HTTP service
coverartarchive-py used extism's built-in Http.request, which is now
disabled. Call Navidrome's http_send host function instead. The plugin
can no longer run under the standalone extism CLI, so drop the CLI test
targets and instructions.
* feat(cli): add missing file list and remap subcommands
Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>
* fix: prevent remapping from dropping participants on target track
* fix: after remapping, refresh stats synchronously
* fix: only move album annotations if moving a track would empty the old album
* fix(persistence): keep the new item's annotation when reassigning onto an item the user already annotated
ReassignAnnotation was a plain UPDATE; the annotation table is unique on
(user_id, item_id, item_type), so when a user had annotated both items the
statement aborted and none of the rows moved. In the scanner that surfaced as
a warning; in the missing-file remap it rolled back the whole operation.
UPDATE OR IGNORE moves what it can and leaves the conflicting rows for GC.
* fix(core): keep the target track's history when remapping a missing file onto it
The remap discards the target's row, and GC then dropped its play counts,
stars, ratings, bookmarks and every playlist entry pointing at it. That is
harmless in the scanner, whose target was imported seconds earlier, but the
CLI lets the user pick any existing track. Move those references onto the
surviving id first; where a user already has a row for both, theirs on the
missing file wins.
* fix(persistence): stop FindByPaths dropping plain paths that contain a colon
Any colon was taken as the libraryID separator, and a non-numeric prefix
made the whole path vanish from the lookup. 'missing fix' then rejected the
very paths 'missing list' printed, and M3U imports silently skipped such
tracks. Only a numeric prefix qualifies a path now.
* perf(cli): stream 'missing list' instead of loading every missing file into memory
GetAll materialised the whole result set before a single row was written;
on a library with 97k missing files that peaked at 1.28 GB of RSS. Iterate
the repository cursor and write rows as they arrive.
* refactor(core): tidy the missing-file remap
Drop the log lines copied from deleteMissing that still said 'after deleting
missing files', the debug-on-success branches, and the what-comments; build
the affected album list without slice helpers.
* fix(cli): move path to the last column of 'missing list'
Path is the only variable-width field, so leading with it misaligns every
row that follows. Applies to both csv and json.
* fix(persistence): also try a numeric colon prefix as a plain path
'1999: A Different Life/01.mp3' parsed as library 1999 plus a truncated path
and matched nothing. The prefix is ambiguous, so search both ways.
Also buffer the json branch of 'missing list', which wrote a syscall per row.
* fix(persistence): move scrobbles and buffered scrobbles off a discarded media file
Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix'
deleting the target erased its play history and dropped scrobbles still
waiting on an external service. scrobble_buffer needs OR IGNORE for its
unique (user_id, service, media_file_id, play_time).
* fix(persistence): recompute the cached average rating after merging annotations
Merging the discarded row's annotations grows the rating population of the
surviving track, so media_file.average_rating no longer matched what the
annotation rows say. Only reachable since the remap started merging those
rows instead of deleting them.
* fix(persistence): recompute the cached average rating inside ReassignAnnotation
Moving annotation rows always changes the new item's rating population, so
the recompute belongs with the move rather than at each call site. Covers
the album reassign in the remap and the two scanner sites, and replaces the
explicit call ReassignReferences was making.
Album was the worse case: rate an album, move its files, and 'missing fix'
handed the rating to an album still caching an average of 0.
* fix(cli): let libraryID:path win over a file literally named like one
FindByPaths searches a numeric-prefixed reference both ways, so a top-level
file named '1:foo.mp3' can tie with library 1's 'foo.mp3'. The CLI then
rejected the reference as ambiguous while advising the exact syntax the
caller had used. Also disambiguates the same path in two libraries, which
is what the qualified form is for.
---------
Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* Implementing the RememberProperty pattern for the settings set
through the UI on install. Previously, these got reset on upgrade.
This is as simple as squirrelling them away in the registry for
all settings except for the INSTALLDIR, as the INSTALLDIR depends
on the environment that the msi is being installed into (i.e. the
actual location of ProgramFiles can be anywhere technically), that
needs to be dynamically set after the CostFinalize phase and in the
version of WiX schema supported by wixl needs to be implemented
through a customAction.
This will not fix the upgrade issue for existing installs, as the
information entered doesn't exist in the registry or anything so
the best option imo is to backup the navidrome database and config
uninstall the old version and install the new version with the
desired paths. It should then upgrade from there on correctly.
* Make it possible to build 386 and amd64 on the same machine
* When upgrading from the pre-fix installer, it would dump everything
into the C:\ root as the UI never executes to set the value for
the MSI_INSTALLATIONDIRECTORY, and the custom action doesn't run
on upgrade as we should be reading from the registry in that situation
This will force the customaction to run when the path is the confusingly
named TARGETDIR (which is C:\ in 99% of cases).
All other properties when upgrading from the pre-fix to the fix
will be reset to the default values as well; which was the same
as the previous behaviour anyway.
* build(msi): drop local ffmpeg download cache
The cache key did not include the ffmpeg version, and a partial download
would stick forever. CI runners start clean, so the cache only helped
local builds.
* fix(msi): set install directory during silent installs and upgrades
SetInstallDirProperty only ran in InstallUISequence, which Windows
Installer skips for /passive and /qn (the modes winget uses). With
MSI_INSTALLATIONDIRECTORY unset, the files and the service went to the
root of the drive with the most free space. Upgrades from releases that
did not store MSI_INSTALLATIONDIRECTORY in the registry hit the same
path, even with the full UI.
The action now runs before CostFinalize in both sequences, whenever the
registry search did not find a saved directory, and defaults to
[ProgramFiles64Folder]Navidrome\ (ProgramFilesFolder on x86) so it does
not depend on INSTALLDIR being resolved. The unused INSTALLDIR directory
is removed.
Verified on a GitHub Actions Windows runner: fresh installs with /passive,
/qn and /qr, upgrades from 0.63.1 with /passive and /qr, and an upgrade
between two fixed builds that keeps a custom directory and port.
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* feat(db): add repair command to rebuild a corrupted FTS5 search index
A corrupted media_file_fts index made every scan fail with 'database disk
image is malformed', and sqlite3's built-in 'rebuild' command cannot repair
contentless FTS5 tables, leaving users to hand-drop tables and triggers.
Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the
reported corruption is confined to the FTS5 search tables, drops and
recreates the three tables and their nine triggers and repopulates them
from the base tables (which hold all the data, so nothing is lost). The
result is verified with the FTS5-native 'integrity-check' command, which
reads only the rebuilt indexes instead of re-scanning the whole database
(on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second
verify). A --rebuild flag forces the rebuild even when the check passes,
for silently desynced indexes. The rebuild refuses to run while migrations
are pending, and a schema-comparison test guards the duplicated DDL against
drifting from the migration.
The DbPath existence check and the YES confirmation prompt, previously
copy-pasted across the backup commands, are extracted into shared cmd
helpers used by both backup and repair.
Part of #6067
* fix(db): type the FTS migration version as int64 for 32-bit builds
The untyped constant defaults to int, which overflows on arm/v7 and 386.
* feat(db): split repair into 'db doctor' and 'search rebuild' commands
A single 'db repair' command promised more than it delivered: the only thing
it could actually repair was the search index, and its diagnosis and its fix
were welded together, so a forced rebuild paid the full integrity check twice.
Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA
integrity_check and PRAGMA foreign_key_check, and routes the user (to
'search rebuild' when corruption is FTS-only, to backup/.recover otherwise).
'navidrome search rebuild' just rebuilds and verifies the FTS index, which
takes ~2s on a prod-size library instead of ~19s.
* refactor(cmd): extract a testable doctor function and bound foreign key output
Extract the doctor routing (check, classify, advise) into a function that
takes an io.Writer, so the advice paths are unit-tested and the process exit
happens in the cobra wrapper after the DB is closed (os.Exit was skipping the
deferred close, leaving WAL/SHM files behind on the unhealthy paths).
Aggregate foreign_key_check by (table, parent): the raw pragma emits one row
per orphan, which is unbounded output on a large corrupted library. Also
make confirmYES take an io.Reader, drop the unused return from the renamed
requireExistingDB, share the FTS table list with the tests, and stop the
schema-guard specs from paying for a seeded database they never use.
* docs(cmd): promise 'never alters your data' instead of 'never modifies the database'
Closing the doctor's connection can checkpoint a stale WAL into the main
file (as any SQLite tool does), so the byte-level claim was too strong. The
checks themselves are read-only and no logical content ever changes.
* fix(cmd): make 'db doctor' advice honest when checks are inconclusive
PRAGMA integrity_check stops at 100 errors and emits no marker row, so a
saturated result was being read as the whole picture. IntegrityCheck now sets
the limit itself and reports saturation as a truncated list, and doctor no
longer claims corruption is limited to the search index in that case.
Foreign key violations now print a next step instead of only flipping the
exit code: migrations run with foreign_keys off, so orphan rows are a
realistic leftover on a database that is not corrupt.
Also corrects the 'search rebuild' help, which promised that 'db doctor'
detects when a rebuild is needed -- integrity_check cannot see an index that
is merely out of sync; gives the never-migrated case its intended message
instead of a raw 'no such table: goose_db_version'; and extracts
rebuildSearchIndex so the database is closed before log.Fatal exits.
* refactor(cmd): promote 'db doctor' to a top-level 'doctor' command
The 'db' group held a single subcommand, and the checks planned for it reach
past the database: config, music folder permissions, external tools. None of
those belong under 'db'.
Promoting it also evens out the shape of the pair. The command that finds the
problem is now top-level alongside 'search rebuild', the command that fixes
it, matching the 'brew doctor' convention users already expect.
'db doctor' has never been released, so no alias or deprecation is needed.
* refactor(db): tighten the doctor and search rebuild internals
Follow-up cleanup with no behaviour change except where noted.
integrity_check now asks the pragma for one row beyond the reported limit and
treats that extra row as the proof it truncated, instead of inferring truncation
from a saturated count. That distinguishes a list of exactly 100 issues from one
that was cut short -- the old test could not, and 100 was SQLite's own default,
so passing it was a no-op.
ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving
the prose to the layer that already owns the CLI vocabulary. The goose table
probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database
ever been migrated' has one spelling. Also folds ftsMigrationApplied into
requireFTSMigration, lifts printFindings out of a closure that captured nothing,
names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the
drift test compares against the full migration chain, not the single frozen
migration it claimed.
* fix(db): verify the rebuilt search index before committing it
RebuildFTS committed its transaction and only then ran the FTS5 integrity
check, from the caller. A rebuild that produced a bad index was therefore
already persisted by the time anyone noticed, leaving the user worse off than
before they ran the command.
The check now runs inside the transaction, so a rebuild that does not verify
rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB
signature for callers outside a transaction; the shared body takes the small
execer interface that both *sql.DB and *sql.Tx satisfy.
Adds a spec for the rollback: it removes a column the repopulating SELECT
reads, so the transaction fails after the drops, and asserts the old index
still answers queries.
* refactor(cmd): drop the unused io.Reader parameter from confirmYES
The reader was added as a test seam that no test ever used: all three callers
pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused
os import from backup.go and search.go.
* fix(cmd): stop promising a scan clears every foreign key violation
doctor told the user to run 'navidrome scan -f' for any foreign key
violation. SQLStore.GC only purges albums, artists, folders, annotations,
bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the
next doctor run still reports them. player.user_id references user(id) and no
scan phase touches that table at all.
The advice now says a scan clears some of them and the rest have to be removed
by hand, which keeps the next step the earlier round asked for without claiming
a cleanup that does not happen.
* docs(db): trim over-long comments on the doctor and rebuild paths
Six comments ran past two lines or repeated something already stated nearby.
The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which
the new 'verifies before committing' sentence already implies, and a spec
comment restated that same rationale a second time.
* docs: drop em dashes from the comments added in this branch
* fix(db): fail restore when the backup file does not exist instead of wiping the database
`navidrome backup restore -b <file>` passed the flag value straight to the
SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If
the file was not found (for example a file name relative to the working
directory instead of the backup directory), the driver silently created an
empty database and the backup API copied that emptiness over the live
database, reporting 'Restore complete' with an empty instance afterwards.
Two changes:
- db.Restore now opens the backup file read-only, so a missing file is an
error and nothing gets created or overwritten.
- A relative --backup-file is resolved against Backup.Path, the same folder
'backup create' writes to; absolute paths keep working as before.
Fixes#6083
* fix(db): stat the backup file instead of opening it read-only
The read-only DSN added in the previous commit works for the reported case but
breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the
path and a '%' sequence is percent-decoded, and a read-only open of a WAL
database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then
matched the unanchored prune regex, so 'backup prune -k 3' right after a restore
deleted real backups and kept one.
Stat the file before opening it and keep passing the plain path to the driver.
Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and
would otherwise open (and create) a different file. The prune regex is anchored
so sidecars are never counted as backups.
Also fixes the restore/backup/prune error logs, which printed BasePath (the web
URL prefix) instead of the backup location.
---------
Co-authored-by: Deluan <deluan@navidrome.org>
* fix(artwork): cap declared image dimensions before resizing
resizeStaticImage decoded the image with a raw image.Decode, so a small file declaring huge dimensions (e.g. a PNG header claiming 50k x 50k) forced a multi-gigabyte allocation on the serve-time resize path. The processor already guards its own decodes with decodeCapped; use it here too so the same 64M pixel cap applies to uploaded and sidecar images served through the cache.
* fix(share): validate every resource ID and reject mixed types when saving
Save only resolved the first ID in ResourceIDs to pick the resource type; the remaining IDs were never checked. A non-existent or hidden entity could ride along behind a valid first ID, and IDs of different kinds were accepted as one share. Resolve every ID as the current user and require all of them to be the same kind, returning ErrNotFound or ErrValidation otherwise.
* fix(share): scope album and media file shares to the owner's libraries
loadMedia already loaded artist and playlist shares as the share owner, but album and media_file shares used the repository context. Public share rendering carries no user, so the library filter was skipped and the share listed albums and tracks from libraries the owner cannot access. Streaming was already blocked, so only metadata leaked. Use ownerContext for all resource types.
* fix(server): limit login payload size and surface first-admin creation errors
The unauthenticated /login and /createAdmin handlers decoded the request body
with no size limit. Add a body-limit middleware to the /auth route group that
caps the payload at 8KiB, which is plenty for a username and password. Also
make createAdminUser return the datastore error instead of logging it and
returning nil, which previously let createAdmin proceed to a login attempt for
a user that was never saved.
* fix(conf): create the log file readable only by the owner
The log file was created with mode 0644, so other local users could read it. Logs can contain usernames, paths and, at trace level, request details, so create it with 0600 instead. Existing files keep their current mode.
* fix(lastfm): stop logging the auth token when fetching the session key fails
The Last.fm callback token was written to the log as a structured field on failure. The redaction hook only matches value patterns, so it was not masked. Drop the field; the request ID is enough to correlate the failure.
* fix(db): allow a music folder path containing a single quote on fresh databases
The library table migration interpolated conf.Server.MusicFolder into the SQL with fmt.Sprintf, so a path such as /music/Rock 'n' Roll produced invalid SQL and the migration failed on a brand new database. Bind the path as a parameter instead.
* fix(scanner): return an error when the folder watcher cannot start
When notify.Watch failed, the watcher goroutine logged the error and exited, but never signalled the started channel, so Start blocked until its context was cancelled and left the watching flag set. Call notify.Watch before spawning the event loop, so Start returns the error right away, the started/failed signalling goes away, and the storage can be watched again later.
* fix(jellyfin): limit the login request body size
The Jellyfin AuthenticateByName endpoint decoded its JSON body with no size limit, the same gap the native /auth routes had. Export the login body-limit middleware from the server package and apply it to the Jellyfin login route, before the optional per-IP rate limiter, so both unauthenticated login surfaces share the same 8KiB cap.
* fix(scanner): share one scanner instance across all injectors
Each wire injector built its own scanner controller, so the Subsonic and native API routers held a different instance from the ones used by the startup scan, the periodic scan, the folder watcher and the SIGUSR1 handler. Status reads the in-progress file and folder counters from its own instance, so getScanStatus reported scanning=true with count=0 for every scan not started through the API. Verified live with a startup scan: master reports count 0 while scanning, this branch reports the real counts. Expose the controller through a singleton, as the watcher, broker and play tracker already are, and wire everything to it. New stays available for tests that need isolated controllers.
* fix(share): do not panic when a media file share has no visible tracks
Share.CoverArtID picked a random track for media file shares without checking that any track was loaded. The tracks are empty when the files went missing, were deleted, or the owner lost access to their library, and the public share page then panicked inside the random pick and returned a 500. Return an empty artwork ID instead, so the page renders with the placeholder cover. The old guard on the split resource IDs was dead code, since SplitN always returns at least one element.
* fix(ui): prevent Safari album grid resize when top menus open
* fix(ui): disable scroll lock for all popovers
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Bumps all 13 direct dependencies that had newer releases, plus the
go-taglib fork pin. No source changes were needed.
The jwx bump to v3.3.0 carries a security fix (GHSA-4cf7-xm37-g63h):
custom claim, header and JWK names were written unescaped, so a name
containing a quote could inject extra members. Navidrome is not
affected - every claim name we emit is a hardcoded literal - but the
fix is worth taking. cascadia v1.3.5 similarly limits selector nesting
to avoid a stack overflow, and our only selector is a constant.
go-sqlite3 v1.14.52 is the only bump with real behavior change: it
flushes the statement cache on schema changes, steps cached statements
eagerly, and drops the per-row goroutine used for query cancellation.
goose v3.28.0 raises its minimum to Go 1.26 and otherwise only touches
MySQL, ClickHouse and Azure SQL, which we do not use. The golang.org/x
bumps are routine. govulncheck reports no reachable vulnerabilities.
The taglib fork pin picks up two fixes. Audio properties are now
clamped with std::max(0, ...) before the unsigned conversion, so a
malformed file no longer reports a duration of ~49 days; this ports
upstream sentriz/go-taglib 0524e91 and additionally covers
bitsPerSample, which is specific to this fork. Bit depth is also now
reported for DSDIFF, TrueAudio and Shorten, which previously returned
0. Both values reach media_file only on re-extraction, so existing
libraries need a full scan to pick them up.
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP
and X-Forwarded-For headers on every request, including when no trusted
reverse proxy was configured. The login rate limiters on /auth/login and the
Jellyfin /Users/AuthenticateByName derived their bucket from that value, so
an unauthenticated client could rotate a forwarding header and get a fresh
bucket for every password attempt, defeating the brute-force protection.
Resolve the client IP with chi's ClientIPFrom* middlewares instead. The
forwarding headers are only honoured when ExtAuth.TrustedSources is set and
the connecting peer is in that list, reusing the trust check that external
authentication already applies; otherwise the peer address is used. The
X-Forwarded-For chain is now walked against the trusted CIDRs rather than
taking its leftmost entry, so a spoofed value prepended by the client is
skipped.
Both limiters now key on the resolved address. The resolved address is still
mirrored into RemoteAddr, so request logging, player registration and the
Jellyfin local-network check keep reporting the client rather than the proxy.
Reported by gehan-psbc.
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes#6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
The Default Bit Rate dropdown on the Transcoding create/edit forms was fed
BITRATE_CHOICES, which starts at 32. There was no way to pick 0, and the
SelectInput was not resettable, so an admin could neither create nor restore a
transcoding with no default bit rate, such as the default FLAC one (seeded with
0 in consts.DefaultTranscodings). Editing that row also rendered a blank
dropdown, since its stored value matched no choice.
Adds TRANSCODING_BITRATE_CHOICES, which prepends a 0 entry labelled 'None' to
the shared list. The forms use it as SelectInput choices, and the list and
read-only show view render it through SelectField, so all four screens resolve
the label from the same array and cannot drift. The shared BITRATE_CHOICES is
left untouched, because 0 is not a meaningful option for the player Max. Bit
Rate or the share dialog.
Reported in discussion #6107, where a user had deleted the default
transcodings and could not recreate the FLAC one.
The theme rounded the cover image directly and set a border radius on
albumContainer, which has no background or clipping, so it rounded
nothing. The hover overlay is a sibling of the image inside the same
link, so it kept square corners that poked out over the rounded cover.
Move the radius to that link and clip it, so both the image and the
overlay follow the same rounded box. This also covers the mobile bar,
which is always visible.
Fixes#6110
The comment justified anonymous access with "item ids are unguessable".
That is not true: an artist id is a deterministic, unsalted hash of the
artist name, id.NewHash(id.NewHash(str.Clear(lower(name)))), so it is
computable offline by anyone who knows the name.
The real reason the route is public is that upstream Jellyfin's is too.
ImageController.GetItemImage carries no [Authorize] attribute (verified on
v12.0, master/13.0.0, v10.11.9 and v10.10.7), and an anonymous request
reaches LibraryManager.ItemIsVisible with a null user, which returns true
unconditionally. Clients build cover URLs with no credentials at all, so
requiring auth here would break them.
No behavior change.
The FLAC muxer writes STREAMINFO before it knows the stream length, then
rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout
(-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC
header" and the field stays 0. A decoder needs total_samples to turn a
timestamp into a byte offset, so it reports an unknown duration and refuses to
seek. Online playback hides this because the client re-requests with a new
offset each time, but an offline copy is permanently unseekable, the symptom
reported against Symfonium where seeking a downloaded track jumps back to the
start.
Transcode now wraps its own output and rewrites total_samples as the first
bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that
package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream
only learns a target format and hands back an io.ReadCloser, so compensating
there leaked a transcoder implementation detail one layer up. TranscodeOptions
grows a Duration field alongside the existing Offset, which also puts the
duration-minus-offset arithmetic in the same function that emits -ss.
The wrapper runs on every transcode rather than only FLAC targets: the format
on a transcoding row is a declared target that nothing validates against the
command's actual -f, so a custom command can emit FLAC under any target_format.
The magic-byte check inside the wrapper is the authoritative test and costs a
26-byte peek. The output sample rate is read back out of the header ffmpeg just
wrote rather than taken from the transcode options, so a resampled (-ar) output
still gets the right count. Anything that is not a FLAC stream with an unset
total_samples passes through byte for byte.
Measured on a 177s source: before, total_samples=0 and ffprobe reported
duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio
payload byte-identical. This affects every piped FLAC regardless of the source
format; only FLAC stores an authoritative "unknown", which is why mp3, opus
and aac survive the same pipe.
No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and
decoders binary-search using total_samples alone.
When a player has a forced transcoding format, ClientInfo.ForceFormat cleared
DirectPlayProfiles unconditionally. A FLAC source on a player configured to
transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth
for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the
resulting FLAC has total_samples=0 and no seek table -- an offline copy of it
can never be seeked. Reported against getTranscodeDecision by the Symfonium
author.
ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding
profiles instead of dropping them: a client declaring a transcoding profile for
a format is proof it can consume that format, so a source already in it is
served as-is. Container and codec come from resolveTargetFormat, so a legacy
"oga" target_format yields an ogg/opus profile, and the profile's
MaxAudioChannels is carried across.
DirectPlayProfile has no bitrate field, so restoring direct play needs a
ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now
seeds that ceiling from the transcoding row's DefaultBitRate when a format was
successfully forced, with the player's own MaxBitRate still taking precedence.
This also closes a gap where the new endpoint ignored DefaultBitRate entirely:
an mp3 320 source on a player forced to mp3@192 was served at 320, while the
legacy /rest/stream path correctly gave 192.
Applied via CapBitrate, which only ever lowers, so a client declaring a
stricter limit keeps it. The legacy path (applyServerOverride) is untouched --
ForceFormat has no other callers.
* added catppuccin mocha theme
* added catppuccin mocha theme to index.js
* fix syntax
* add catppuccin frappé theme
* made frappe and mocha themes more consistant with official color palette and added comments for easy verification
* same for macchiato, seperate commit in case original is preferred
* added comments to .js files
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
* docs: point jftui client link to canonical repository
* docs: fix relative path to webhook-rs example in nd-pdk-host README
* docs: fix capability schema paths in plugin examples README
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
The spec asserted on artistID("Kraftwerk") and intermittently found zero artists
on Windows. The scan did import the artist; it was then made invisible.
RefreshStats selects touched artists with a strict artist.updated_at >
library.last_scan_at (persistence/artist_repository.go:466). Windows' wall clock
has ~15ms granularity, so a new artist written by a quick scan can land in the
same tick as the previous scan's last_scan_at and be excluded. Its
library_artist.stats then stays at the '{}' default and the unscoped cleanup
DELETE removes the row, after which selectArtist's INNER JOIN on library_artist
hides the artist from GetAll.
Backdate last_scan_at before the scan so the comparison is unambiguous, matching
the fix already applied to the search_normalized spec below it.
* fix(i18n): Update Japanese translation
* fix(i18n): fix Japanese translation
* fix(i18n): fix Japanese translation
Update Japanese translations for `recentlyAdded`, `recentlyPlayed`, and `mostPlayed` in album lists
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Jellify's Discover tab calls GET /Items/Latest and got a 404: we only routed the
/Users/{userId}/Items/Latest form, which real Jellyfin marks [Obsolete] and hides
from its OpenAPI spec, so SDK-generated clients never see it. Add the current
route alongside the legacy one, both served by the same handler.
getLatest also ignored ParentId, so browsing a library or an artist returned the
newest albums across everything the user can see. It now scopes to the library
when ParentId names one, and filters to that artist's albums otherwise, which
also makes a stale id return nothing instead of silently widening back to the
full library set. A malformed ParentId 404s, matching /Items and the contract
decodeFilterParam documents.
PlaybackInfo returned a TranscodingUrl prefixed with the /jellyfin mount path.
Clients concatenate that value onto a server base URL that already carries the
prefix, producing /jellyfin/jellyfin/Audio/{id}/universal and a 404, so playback
never started. Jellify, jellyfin-web, jellyfin-vue and Streamyfin all consume the
field this way; real Jellyfin emits it server-relative (StreamInfo.ToUrl is called
with a nil baseUrl).
Emit the path server-relative to match. Finamp is unaffected: it builds its own
stream URLs and never reads the field.
`buildPlaylist` reported `evaluated_at` as `changed` for smart playlists, so a
rename or comment edit was invisible to clients until the next evaluation. A
never-evaluated smart playlist also reported the current time on every call,
which never settled.
Report `updated_at` for every playlist. `refreshCounters` now syncs the stamp it
writes back onto the model, and `refreshSmartPlaylist` reuses it for
`evaluated_at`. `changed` therefore still equals the evaluation time for a
just-evaluated playlist, and `validUntil` stays anchored to it.
Original Subsonic always bumps `changed` on any playlist update, so this also
aligns the behavior with upstream.
The Refresh Metadata action was only reachable from the Album and Artist context menus, so it could not be triggered from AlbumShow or ArtistShow. This adds an icon-only button, with a tooltip, to the action toolbar on both detail pages. Like the menu entry, it is only rendered for admins.
The button is built on react-admin's Button rather than a plain IconButton: the surrounding toolbars use the former, so the theme colour and the icon-only swap at the xs breakpoint are inherited instead of restated. The dataProvider call and its two notifications move into a new useRefreshMetadata hook, which ContextMenus now shares, keeping a single copy of that logic.
A playlistTrack id is a position in the playlist, not a stable key, so refetching
a row by id after the annotation is saved can return a different song: in a smart
playlist filtered on that annotation the track is gone and every later row has
shifted up. The stale-keyed record then renders as a duplicate of its neighbour.
Signed-off-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
The exclusion only worked on master. Coverage profiles name files by import
path; octocov shortens those to repo-relative paths using the checked-out
source, but coverage-on-pr.yml sparse-checks-out only .octocov.yml, so the
paths stay as github.com/navidrome/navidrome/tests/mock_*.go and 'tests/**'
never matched. '**/*_gen.go' matched either way, which is why only the 30
tests/ files leaked.
Every pull request since b77fb45 therefore reported ~-3.7% against master:
447 files on the base side, 477 on the pull request side (#6002, #6069).
* chore: ensure that all durations are nonnegative
* make sure you actually include the test file
* test(conf): use non-zero durations in the valid_duration fixture
Zero is the boundary between the accepted and rejected ranges, so it
passes even if the guard is off by one. 1s exercises an ordinary value.
---------
Co-authored-by: Deluan <deluan@navidrome.org>
An absent artwork state was revisited by an hourly job, by viewing the entity, and
by the startup backfill on any artwork config change. On a large library the last
one queued tens of thousands of external lookups at once and got the provider to
rate-limit us for hours.
Nothing revisits an absent state now. Retrying is explicit: `artwork reprocess` on
the CLI, or the refresh button in the UI. The config fingerprint survives only as an
advisory, warning at startup and naming the command that clears it.
Since absent is terminal, `artwork status` splits it into two disjoint columns, and
`--source failed` targets only the ones that gave up rather than being answered.
Both read through the filter CountBySource and EnqueueBySource already share, so the
reported number is the set the command acts on.
Also fixes the last_failure default left by 20260819204637, which marked every
pre-existing absent row as failed, and removes the code the deleted retry paths
orphaned.
The coverage profile counted the tests/ package and the *_gen.go files, none of
which are code under test: tests/ is the mock and helper package, and generated
code is never hand-tested. Together they added 2926 uncounted statements at 0%,
pulling the reported number down by almost 6 points (70.13% -> 75.98% on the
current master profile).
octocov's coverage.exclude takes doublestar globs matched against git-root-relative
paths. All 26 mock_*.go files live under tests/, so the single 'tests/**' pattern
covers them.
* fix(deezer): treat an exhausted quota as a throttle, not as a missing artist
Deezer reports quota exhaustion in the response body, with HTTP 200 and no
rate-limit headers. The client only looked for errors when the status was
not 200, so a throttled reply was decoded into an empty result type, and an
empty search became ErrNotFound. The agent then compounded it: it tested
`errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so
any failed search — which also returns no artists — reported not-found too.
The artwork worker settles an entity as "no image" on agents.ErrNotFound.
So being throttled did not make Navidrome back off; it made it record the
artist as having no artwork, and move on to do the same to the next one.
Errors are now parsed out of the body regardless of status, and the quota
code is joined with agents.RetryLaterError so the circuit breaker and the
artwork retry budget see a throttle for what it is. The agent checks err
before the empty-result case.
Last.fm already handles this exact shape (client.go errCodeRateLimit, with
a comment noting the 200-with-body-error pattern); this brings Deezer in
line with it, including the zero-delay RetryLaterError so both providers
share the default cooldown rather than a per-provider number.
Measured against the live API to pin the shape: a 120-request burst
returned 54 results and 66 quota replies, every one of them HTTP 200 with
{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}
and no Retry-After or rate-limit headers. A single request 5s later
succeeded, so the window is short and a cooldown fully clears it.
* refactor(deezer): fold the error envelope into one type
The envelope declared the code and message inline, parseBodyError copied
them field by field into a second struct with the same shape, and a zero
Code stood in for "no error reported". Making the envelope hold a pointer
to the error type removes all three: absent is nil, present is the error
itself, and the value returned needs no conversion.
searchArtist loses its empty-result branch. searchArtists converts an
empty result to errNotFound and returns early on any error, so it never
answers with no artists and no error, and the branch could not run. What
it left behind was a comment explaining an ordering that only mattered
while the branch existed.
ErrNotFound is unexported: nothing outside this package referenced it,
and it sat three lines from agents.ErrNotFound, which is a different
error with the opposite meaning for callers.
Throttling now joins agents.ErrRetryLater, the sentinel documented as the
zero-delay RetryLaterError, rather than allocating an equivalent value.
* refactor(deezer): return agents.ErrNotFound from the client
The client raised a package-local sentinel that the agent then translated
into agents.ErrNotFound, one call site each. Deezer was the only adapter
carrying its own: last.fm and listenbrainz have none.
The client already reports throttling with agents.ErrRetryLater, so it
already speaks the agent vocabulary; saying "not found" in the same words
costs nothing and lets searchArtist drop to plain error propagation.
* test(scrobbler): remove a race in the longest-server-delay test
newBufferedScrobbler starts its drain goroutine, and run() drains once
before it ever waits on the wake signal. The test enqueued user2, then
enqueued user1 via Scrobble, so that startup drain could land between
the two: it saw only user2, took its 45s delay, and set backingOff. The
wake from the second enqueue is then deliberately ignored — a wake
during a backoff window must not drain, which is the hammering the
window exists to prevent — so user1 was never attempted and the first
assertion read 1 instead of 2.
Buffering both users before the goroutine exists removes the window.
The test no longer goes through Scrobble, which the sibling tests
already cover; what this one is about is which delay wins.
Reproduced deterministically by forcing the interleaving with a
synctest.Wait between the two enqueues, which fails with the same
"expected both users drained, got 1 attempts" seen in CI. With both
enqueued first, that same forced drain passes.
An explicit RetryLaterError now opens the agent's breaker immediately for the
provider's own delay, instead of counting it as one generic failure that needs
five to open and then always probes after a fixed minute.
* ci: comment coverage on pull requests from forks
A pull_request run from a fork gets a read-only GITHUB_TOKEN, so octocov could not post its comment: it logged a 403 and exited 0, leaving the job green and the PR silent. The 'permissions:' block cannot grant what the token does not have.
The comment now comes from a workflow_run workflow, which runs on the base repository and does get a write token. The pipeline job keeps the job summary and the default-branch baseline, and hands the merged profile and the PR number to it as an artifact.
A workflow_run job otherwise looks like a push to the default branch, so octocov is pointed back at the pull request and at the run that produced the profile via its OCTOCOV_ environment overrides. Without the run id override the test execution time would be read from the wrong run; without the ref override a fork's coverage would be stored as the master baseline.
The job holds a write token, so it reads .octocov.yml from the base branch rather than from the fork.
* ci: stop checking out the fork in the coverage comment workflow
CodeQL flagged the pull request checkout as untrusted code in a privileged context (actions/untrusted-checkout/high): the job holds a write token. The checkout existed only so the code-to-test ratio would reflect the pull request, which does not justify the alert.
The workflow now checks out just .octocov.yml from the base branch, and the ratio is skipped when reporting from there. Coverage and its delta against master, the metrics that motivated the report, are unaffected: they come from the profile the pipeline uploads.
* ci: treat the coverage artifact as untrusted input
A pull_request run executes the fork's own copy of pipeline.yml, so every file in the octocov-pr artifact is attacker-controlled. The artifact was extracted into the workspace root, on top of the base-branch checkout, and download-artifact truncates existing files. A fork could therefore replace .octocov.yml before octocov loaded it.
That is not only a config swap. config.Load expands ${VAR} from the job environment and the action sets OCTOCOV_GITHUB_TOKEN, so a crafted comment.message posts the privileged job's token into a public comment; a body: section rewrites a pull request description, which pull-requests: write allows.
The artifact now lands in a subdirectory and only coverage.out is copied out, after pr_number is checked to be digits and the named pull request's head is confirmed to be the sha that triggered this run. Without that check the artifact could aim the comment at any open pull request, and unvalidated content reached GITHUB_OUTPUT.
* ci: report Go test coverage on pull requests
Adds octocov to the existing 'Test Go code' job. It reads the coverage
profile, posts a PR comment with the coverage percentage and the delta
against master, and writes the same report to the job summary.
The master-branch report is stored as a GitHub Actions artifact, so no
external service or secret is needed.
* ci: merge the plugins job coverage into the same report
The plugins suite runs in its own job, so its coverage was missing from
the report. Both jobs now upload their profile as an artifact and a new
'Report coverage' job merges them into a single PR comment.
* ci: update the coverage comment in place instead of reposting
octocov's default is to collapse the previous comment and create a new
one. updatePrevious edits the existing comment instead, so a PR keeps a
single coverage comment across pushes.
* ci: fix octocov timeout and step-time lookup
Storing the report hit the 30s default timeout: scanning this repo's
artifacts for the baseline consumed it first. Raise it to 5m.
The step-time lookup also matched the Windows job's 'Test' step and
waited for a job that was still running, so execution time was dropped
from the report. Rename the step to make it unique.
* ci: only store the coverage baseline from the default branch
* ci: report statement coverage instead of line coverage
octocov reports statement coverage for a single profile but switches to
line counting when it merges several itself, which made the number
disagree with 'go tool cover -func'. Merge the two job profiles into one
file first, so the reported number matches what developers see locally.
* ci: stop the download-link comment from clobbering the coverage report
Both comments are posted by github-actions[bot], and the download-link
job updated the first bot comment it found. On a new PR the coverage
comment is created first, so it would be overwritten. Match on the body
as well, and keep the coverage profiles out of the download list.
* test(plugins): build test plugins in Go instead of shelling out to make
The plugins suite built its .ndp test packages by running `make -C
plugins/testdata`, which needs make and zip on the PATH. That is the reason
the 26 WASM-dependent spec files are tagged //go:build !windows.
buildTestPlugins now does the same work in Go: the same mtime check make
performed, `GOOS=wasip1 GOARCH=wasm go build` per plugin, and archive/zip
for the package. TinyGo was already optional and unused in CI, so nothing is
lost there. The first plugin builds on its own so the shared wasip1 stdlib
and PDK objects land in the build cache before the rest fan out: on a cold
cache that is 2.2s against 3.4s for the sequential make and 7.3s for an
unrestrained fan-out.
Packaging moved into a writeNdp helper shared with createTestPackage, which
was already writing the same two-entry archive. Entries are written in a
fixed order, so the .ndp bytes are now reproducible; the loader hashes those
bytes, and `zip` also stored file mtimes, so the previous packages differed
on every rebuild.
The Makefile is unchanged and still works for building the plugins by hand.
Removing the !windows tags is a separate step, once CI is green here.
* test(plugins): run the WASM plugin specs on Windows
With the test plugins now built in Go, nothing in the suite needs a Unix
toolchain, so the //go:build !windows tags come off all 25 spec files. The
Windows CI job runs `go test ./...`, so it picks the suite up with no
workflow change.
plugins_suite_windows_test.go existed only to bootstrap the handful of specs
that compiled on Windows; plugins_suite_test.go now serves both.
* test(plugins): skip the planted-symlink spec where symlinks need privileges
os.Symlink needs an elevated token or Developer Mode on Windows, so the
unconditional Expect(...).To(Succeed()) would fail for contributors running
the suite on an ordinary Windows box. The elevated GitHub runner hides this.
The equivalent spec in sandbox_fs_internal_test.go already attempts the
symlink and skips on error; this does the same, keeping the pin live
everywhere it can run, including Windows CI.
* fix(ci): stop the Windows ndpgen test failing silently
The ndpgen suite builds its helper binary to %TEMP%\ndpgen-test, and Windows
will not exec a file without an executable extension, so the "supports
verbose mode" spec has been failing there. Nobody noticed because the
Test ndpgen step ran under pwsh, which carries on after a non-zero exit and
takes the step's status from the last command, so the job stayed green with
a FAIL line in its log.
Add the .exe suffix, and run the step under bash like the Linux job does, so
a failure in any of its three commands fails the job.