Commit graph

5,155 commits

Author SHA1 Message Date
Deluan Quintão
a8af29ff26
Merge branch 'master' into t3code/enable-get-now-playing 2026-09-24 19:46:23 -04:00
Deluan Quintão
101145742f
test(plugins): stub DNS in the host SSRF guard tests (#6208) v0.64.2
The dial-time SSRF guard runs on the resolved IP, so the tests that prove a
symbolic hostname cannot reach loopback used "localhost." — a trailing dot never
matches /etc/hosts, so Go queries real DNS. Machines whose resolver does not
answer "localhost." (a VPN DNS, for example) got "no such host" before the dial
guard ever ran, failing three specs.

Add tests.StubResolver, a net.Resolver backed by an in-memory DNS responder over
net.Pipe, and let the plugin dialers take a resolver so tests can inject it.
Name resolution in those specs no longer depends on the machine's DNS.
2026-09-23 18:42:11 -04:00
Deluan Quintão
ee6dd1bc03
fix(scanner): stop DB lock starvation during scans on slow storage (#6201)
* fix(artwork): pause the artwork worker while a scan is running

The artwork worker added in 0.64 writes to the database continuously, including while a scan runs. On slow storage the scanner holds the write lock for many seconds per folder, so the two writers keep timing each other out: artwork writes fail with "database is locked", and a single busy timeout on the scanner side aborts the whole scan. The worker now stops dispatching queue items while scanner.IsScanning reports true, including mid-batch, and resumes on the next poll after the scan ends. Artwork requests are unaffected, since they serve local art without the worker.

* fix(db): run ANALYZE one index at a time so writers are not starved

A full ANALYZE is a single write transaction, so every other write waits for it to finish and fails after the 15s busy timeout. On slow NAS storage it was measured taking over 26 minutes. The analysis now runs ANALYZE per index (per table for unindexed and WITHOUT ROWID tables), which produces the same sqlite_stat1 rows as a full ANALYZE, and pauses briefly between steps (up to 150ms, just above SQLite's longest busy-handler sleep) so waiting writers get the lock.

* fix(scanner): ignore Synology @eaDir metadata folders

Synology creates an @eaDir folder next to media files, holding one subfolder per file with generated thumbnails. The scanner and watcher treated them as regular folders, which on one reported library added tens of thousands of extra folders to every scan.

* fix(db): analyze tables with only partial indexes as a whole

A partial index does not record the table's row count, so a table whose only indexes are partial needs a table-level ANALYZE to get the sqlite_stat1 row a full ANALYZE would write. Navidrome's schema has no such table today, but the stepped analysis should match a full ANALYZE for any schema a future migration creates.

* fix(scanner): retry busy folder saves and stop phase 1 on a fatal error

On slow storage, a single SQLITE_BUSY while saving a folder aborted the whole scan, even when another writer held the lock only briefly. The folder save now runs as a retryable unit: on a busy error it waits (5s, 10s, 15s) and reruns the transaction, up to three times, before failing. Side effects that do not survive a rollback (the album ID map consumed by persistAlbum, the artwork queue items, the image-change record) are rebuilt per attempt or recorded only after a successful commit.

When a folder save does fail, phase 1 used to keep walking the library and reading tags for every remaining folder, discarding the results, before reporting the error; a reporter saw 40 silent minutes. The walk now stops as soon as the save fails, and the walker honors cancellation instead of blocking on its channel. Because an early stop leaves folders unvisited, phase 1 no longer marks unvisited folders missing when the phase failed; the resumed scan handles them.

* refactor(persistence): move busy retry into DataStore.WithTxRetry

The scanner retried its folder save itself, which meant it had to know SQLite error codes. WithTxRetry now owns that policy: it reruns the block in a fresh transaction on SQLITE_BUSY, up to three times with growing delays, and runs it only once when already inside a transaction, since the outer transaction would still hold the lock. The block receives the context to use, and attempts that will be retried carry a marker so a busy statement in them is logged as a warning; only the final attempt logs errors. The scanner's inner error logs are folded into wrapped errors, so a recovered retry no longer prints error-level lines, and the folder path travels in the log context.

* fix(persistence): join the enclosing transaction in a nested WithTxRetry

Called on a store that is already inside a transaction, WithTxRetry went through WithTx, which opens a second, independent transaction on another connection. That transaction waits on the lock the outer one holds and fails with SQLITE_BUSY, and if it does succeed the outer transaction cannot roll it back. It now runs the block on the enclosing transaction, which owns the lock, the commit and the rollback. Found by a Codex (gpt-6-sol) review.

* fix(scanner): retry the remaining scan writes on a busy database

Every write step after phase 1 still aborted the whole scan on a single SQLITE_BUSY: phase 1 finalize, phase 2 moves and purge, phase 3 album saves and play count refreshes, the deferred playlist import flag, library ScanBegin, GC, the missing-artwork enqueue, tag counts, and the final library update. They now go through WithTxRetry. The phase 2 move had to be made rerun-safe first: it changed the target track's ID inside the transaction, so a rerun would have deleted the moved track itself, and it marked album annotations as handled even when the transaction rolled back. It now works on a copy per attempt and records the annotation reassignment only after a commit.

Artist.RefreshStats is left alone: it updates artists in batches outside a transaction, and one transaction around all of them would hold the write lock for the whole refresh on slow storage. Phase 4 playlist imports go through the playlist service and are left for a follow-up.

* fix(scanner): claim the album before moving its annotations

The rerun-safe moveMatched checked processedAlbumAnnotations before its transaction and marked the album only after the commit. Phase 2 runs same-library and cross-library moves in separate pipeline stages, so two moves into one album could both pass the check; the second would reassign annotations again and overwrite the album's created_at. The album is now claimed under the lock before the transaction, as the old code effectively did, and the claim is released if the move fails so a later move can still reassign. Found by a Codex (gpt-6-sol) review.

* fix(artwork): keep artwork housekeeping from writing during scans

The artwork worker already pauses while a scan runs, but its housekeeping jobs did not: the hourly missing-artwork recheck (a bulk INSERT ... SELECT over albums and artists), the startup run of the same recheck, and the daily prune all kept competing with the scanner for the write lock. They now run through LockForMaintenance, like the scheduled DB analysis: they skip while a scan is running and keep a scan from starting until they finish. Skipping the recheck loses nothing, since each scan with changes queues missing artwork at its end.

* refactor(scanner): log retried step errors once, from the caller

Blocks passed to WithTxRetry still logged their own errors at error level on every attempt, so a busy error that a retry absorbed printed several error lines (GC printed three). They now return wrapped errors and the callers, which already log them, report the final outcome once. Also: drop a leftover variable in phase 1 finalize, check the walk context once, stop repeating the folder field that is already in the log context, stop shadowing finalize's err in phase 3, and format the WithTxRetry scope the same way as WithTx.

* test(scanner): make the scanner suite's temp DB cleanup best effort

Which DB file the process-wide DB handle opens depends on which spec touches it first. When the Scanner container wins the random order, its temp DB stays open until db.Close after RunSpecs, and on Windows removing the temp dir fails with 'being used by another process'. Ginkgo pins that on the container's last spec, which is now one of the busy-database specs. The sibling suites skip Windows for the same reason; this one now removes its temp dir on a best-effort basis instead, so it keeps running there.
2026-09-23 17:04:50 -04:00
Adrián Sánchez Zapico
27483a46dc
fix(server): fail startup on initial setup errors and fix JSON/M3U response headers (#5897)
* fix(server): stop swallowing errors and correct two response bugs

Four independent bugs found while reviewing the HTTP layer:

initial_setup.go: createInitialAdminUser assigned the users.Put error to a
shadowed err, so the outer err (always nil by then, since a CountAll failure
panics) was returned instead. A failure to create the admin user was reported
as success, and initialSetup went on to commit the "setup complete" property
in the same transaction — so no admin user existed and initial setup was
skipped on every later boot.

auth.go: createAdminUser logged the Put error but returned nil, so createAdmin
fell through to doLogin and answered 401 "Invalid username or password"
instead of surfacing the real failure. It also logged the whole model.User,
which puts the new admin's password in the log in clear text; every other call
site logs user.UserName.

native_api.go: writeDeleteManyResponse did not return after http.Error when
marshaling failed, then wrote a nil body over the 500. It also built the
single-id body by hand with html.EscapeString, which does not escape
backslashes, so an id ending in one produced `{"id":"a\"}` — invalid JSON.
Both shapes now go through json.Marshal. A failed Write is now logged rather
than answered with http.Error, which could not work once the body had started.

handle_shares.go: handleM3U set Content-Type after WriteHeader, so it was
never sent and shared playlists were served with a sniffed type.

Signed-off-by: zapisanchez <zapisanchez@gmail.com>

* fix(server): address review feedback

- writeDeleteManyResponse uses rest.RespondWithJSON, so the response now
  has Content-Type: application/json. This also removes a marshal error
  branch that could never run.
- createInitialAdminUser returns the CountAll error instead of panicking,
  and wraps its errors. initialSetup now stops the server with log.Fatal
  when setup fails. Before, the error was dropped and the server started
  with a half-done setup.
- Trim comments that described PR history.

---------

Signed-off-by: zapisanchez <zapisanchez@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-23 12:10:25 -04:00
Deluan Quintão
39028f65c8
fix(jellyfin): honor IsPublic when creating a playlist (#6204)
POST /Playlists dropped the client's IsPublic flag, so every playlist was
created private. JellyBox Player's create-playlist form defaults its "public"
checkbox to true, so JellyBox users could never create a public playlist.

Upstream's PlaylistsController passes IsPublic into PlaylistCreationRequest.
core/playlists.Create has no visibility parameter and widening it would ripple
into the Subsonic and native APIs, so createPlaylist follows the same pattern
updatePlaylist already uses: after Create succeeds, a non-nil IsPublic is
applied with a follow-up Update. The field is a pointer so an absent one keeps
today's default instead of forcing private.

If that second write fails the handler surfaces the error through playlistError
rather than returning the id: answering 200 for a playlist that is not as
visible as the client asked is the same silent drop this fixes.
2026-09-23 09:54:35 -04:00
Adrián Sánchez Zapico
a3f41fb422
sec(server): sanitize user-controlled filenames in Content-Disposition (#5895)
* sec(server): sanitize user-controlled filenames in Content-Disposition

Playlist export, Subsonic download and public share download built the
Content-Disposition header by interpolating a user-controlled name into a
quoted-string with fmt.Sprintf. A name containing a double quote closes the
string early and the rest is parsed as additional parameters, so a playlist
named `party"; filename="evil.html` yielded

    attachment; filename="party"; filename="evil.html.m3u"

letting whoever chose the name decide what the browser saves the download as.
The names come from playlists, album/artist names and media file tags.

Go's net/http already rewrites CR and LF in header values to spaces, so
response splitting was not reachable; parameter injection was.

Add str.ContentDispositionAttachment, which emits a sanitized ASCII-only
quoted `filename` plus an RFC 5987 `filename*` carrying the original UTF-8
name, and use it at all four call sites. The `filename*` parameter also fixes
non-ASCII names, which previously went out raw or were mangled by sanitizing.

Signed-off-by: zapisanchez <zapisanchez@gmail.com>

* fix(server): keep download names intact and sanitize filename*

Rework ContentDispositionAttachment after review. Names with no ASCII
letters now fall back to download.<ext> instead of a bare extension
(東京.mp3 gave filename="mp3"). filename* is built from the same
sanitized name as the ASCII fallback, so path separators, reserved
characters, control and bidi characters, and invalid UTF-8 no longer
reach it. The ASCII fallback transliterates accents and typographic
punctuation (Legião -> Legiao, She’s -> She's) through the existing
sanitize.Accents and str.Clear helpers, keeps leading dots, and only
trims trailing ones. Names are capped at 255 bytes, keeping the
extension.

Pure ASCII names now get only the quoted filename parameter, so the
header for them matches the previous output byte for byte. filename*
is encoded with mime.FormatMediaType instead of a hand-written RFC 5987
encoder. Adds tests for the M3U export and Subsonic download headers.

* fix(server): handle dot-only names and long fake extensions

A name made only of dots trimmed down to an empty filename. It now
falls back to download, like an empty stem does.

path.Ext treats anything after the last dot as the extension, so a long
suffix with no real extension was kept whole and replaced the stem with
download, going past the 255-byte cap. Suffixes longer than 16 bytes
are now treated as part of the stem and truncated with it.

Neither case is reachable from the current call sites, which always
append a short extension.

---------

Signed-off-by: zapisanchez <zapisanchez@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-23 09:47:16 -04:00
Deluan Quintão
961ee8c413
fix(scanner): keep tag numbers within the int32 range (#6202)
* fix(scanner): keep tag numbers within the int32 range

A track number of 4294967295 (-1 stored as an unsigned 32-bit tag) was saved
as-is by 64-bit builds. 32-bit builds (armv5/6/7, 386) cannot read that value
back into an int, so every scan failed with "converting driver.Value type
int64 to a int: value out of range" when loading the folder's media files.

Track and disc numbers (and their totals) are now parsed as int32 and fall
back to 0 when out of range, matching how unparseable values are handled.
BPM values outside the int32 range are dropped. A migration resets existing
out-of-range track_number, disc_number and bpm values, and removes
out-of-range keys from album.discs, so databases written by 64-bit builds are
readable again by 32-bit ones. Persistent IDs are unaffected because they use
the raw tag text.

Fixes #6200

* fix(scanner): accept the int32 minimum as a BPM value

The BPM range check compared the absolute value against MaxInt32, which
rejected -2147483648 even though it fits in an int32. Compare against
MinInt32 and MaxInt32 separately, matching atoi32 and the migration.

* fix(scanner): treat negative track, disc and BPM values as missing

Track numbers, disc numbers and BPM can never be negative, so negative tag
values now map to 0 (track/disc, including totals) or nil (BPM), the same as
unparseable ones. The migration resets existing negative values as well as the
ones above the int32 range, and keeps only album disc keys from 0 to MaxInt32.
2026-09-22 19:51:32 -04:00
Deluan
9e3deb4330 ci: scope digest artifact cleanup to the current run
The cleanup job listed artifacts repo-wide, so it deleted digest files
uploaded by any concurrent pipeline run. When the v0.64.1 tag run
overlapped with a master run, the master run's cleanup removed three of
the tag run's digests before the manifest job downloaded them, and
0.64.1/latest shipped with only linux/arm64, arm/v7 and riscv64.

List artifacts for the current run instead, so a run can only delete its
own digests.
2026-09-21 19:27:48 -04:00
Deluan Quintão
285dc4f391
fix(lastfm): report a failure when the artist page has no image (#6198) v0.64.1
GetArtistImages scrapes the og:image tag off the Last.fm artist page, because
the API only ever returns the placeholder image. Last.fm now answers non-browser
clients with a Fastly bot challenge, served as a 200 with valid HTML, so the
query found no og:image and the agent returned an empty list with no error. The
artwork worker read that as a definitive "this artist has no image" and settled
the state as absent, silently and with nothing in the log.

A real artist page always carries an og:image, so its absence now returns an
error instead. The worker keeps the previous state, other agents still get their
turn, and its per-agent circuit breaker bounds the retries. The error is
deliberately not a RetryLaterError: that would park the whole Last.fm agent,
including the API-backed biography, similar-artists and top-songs calls, which
the page block does not affect.

The new fixture is the real 3038-byte challenge page.

Fixes #6192
2026-09-21 17:34:32 -04:00
Karl Ostendorf
cb7b042e36
fix(artwork): make stored images group-readable (#6189)
Signed-off-by: Karl Ostendorf <karl@ostendorf.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-21 17:25:46 -04:00
Deluan Quintão
07c756db3c
fix(ui): update Portuguese (BR) translations from POEditor (#6197)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-09-21 16:18:38 -04:00
Antonio Russo
00bb120288
fix(contrib): support libblas in systemd sandbox (#6190)
Navidrome calls out to ffprobe, which in turn may use libblas on some
setups (e.g., Debian 13).  The previous syscall filter excluded the
"mbind" syscall (via @resources).

Through direct experimentation, mbind is required by libblas, and so it
is added to the allowed syscall list.

Signed-off-by: Antonio Enrico Russo <aerusso@aerusso.net>
2026-09-21 09:06:12 -04:00
Deluan
6b3938b5b6 fix(subsonic): warn when a nowPlaying scrobble sends multiple ids
The scrobble endpoint accepts multiple ids, but a nowPlaying notification
(submission=false) describes a single track, so only the first id is used.
The extra ids were dropped silently, which made client bugs invisible. Log a
warning instead, keeping the existing behavior for clients that rely on it.
2026-09-20 22:25:55 -04:00
Deluan Quintão
c3b9b4ecb3
fix(subsonic): rate limit failed authentication attempts (#6185)
* fix(subsonic): limit failed authentication attempts per client IP and username

The Subsonic API checked credentials on every request with no limit on failures, so any
account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped
per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same
settings that guard the UI login. Every request carries credentials, so only failures count:
a slot is taken before the check and given back on success or on a server error, which also
stops concurrent guesses from overshooting the limit.

Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no
Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and
internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is
now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket.

* refactor(subsonic): simplify failed authentication limiter

Release the limiter slot from a single place in authenticate(), after the user lookup and
credential check, instead of separately in the canceled branch. Store attempt counters by
value instead of by pointer, and drop limiter unit tests that only repeated the middleware
specs.

* fix(subsonic): wait for an in-flight auth check instead of rejecting

Slots were reserved before the credential check and only released afterwards, so once
AuthRequestLimit checks for the same client IP and username overlapped, the next request was
answered with error code 40 even when its credentials were valid. Clients that fan out parallel
requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty
five, and the web UI authenticates its own /rest calls the same way.

A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather
than failing when other checks for that key are in flight. Failures are recorded after the check,
and a request is only rejected when the key already reached the limit within the window. A waiting
request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most
AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land.

* docs(subsonic): state the real guess ceiling of the auth limiter

The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
2026-09-20 22:02:23 -04:00
Deluan Quintão
cabfd16f9f
fix(ui): update Finnish, Dutch translations from POEditor (#6148)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-09-20 21:36:35 -04:00
Deluan Quintão
9e8811e4c5
fix(server): enforce player ownership on create and registration (#6184)
POST /api/player passed the ownership check using the userId from the request body, then
saved with the body id. When that id belonged to another user's player, the save became an
update with no owner restriction, overwriting the row and moving it to the caller. Save now
always creates a new player and ignores any id in the body; edits keep going through the
owner-scoped Update.

Player registration also reused a player by the id sent in the Subsonic player cookie or the
Jellyfin DeviceId without checking its owner, letting a user attach to another user's player
and overwrite its name, user agent and IP. Register now only reuses a player owned by the
requesting user, falling back to the user's own players otherwise.
2026-09-20 21:10:41 -04:00
Deluan Quintão
237276efcd
fix(artwork): block private and loopback addresses in remote image fetches (#6181)
* fix(artwork): block private and loopback addresses in remote image fetches

fromURL fetched any URL with a plain HTTP client, and two untrusted inputs reach it. A playlist
can set #EXTALBUMARTURL to an http(s) URL, which the artwork worker later fetches when
EnableM3UExternalAlbumArt is on, so any user who can import a playlist controls the target.
Metadata agents, including WASM plugins without the http permission, return image URLs that the
core fetches too. Either path could make the server request loopback, LAN or link-local
addresses and store the response as artwork that is served back.

Add httpclient.NewExternal, which dials through a net.Dialer Control hook that rejects private,
loopback, link-local and unspecified addresses. The check runs at dial time on the resolved IP,
so DNS names, redirects and DNS rebinding are covered. fromURL now uses one shared client built
with it and treats a refused address as a definitive miss, so the item settles absent instead of
retrying and tripping the agent's circuit breaker. httpclient.New is unchanged for the other callers.

The IP classification moves from plugins to the new utils/netguard package, shared by the plugin
host client and the new constructor. The artwork test suite swaps in a client that allows
loopback so existing specs can keep using httptest servers; the fromURL specs use the production
client to assert the refusal.

* fix(httpclient): keep dialing a configured proxy in the guarded client

The guard runs on the resolved address, and with HTTP_PROXY set that address is the proxy, not
the image host. A proxy on a private address would have had every remote artwork fetch refused,
and a refusal settles the item as absent, so covers would silently disappear for those setups.

Dial the configured proxy endpoint directly and keep the guard for every other dial. A proxy
relays the request itself, so it is the operator's egress policy, the same one every other
httpclient.New caller already goes through.

* fix(httpclient): exempt only the hop that actually goes through the proxy

The exemption matched any dial to a configured proxy's address, but net/http never proxies
loopback targets, so a URL aimed at a loopback proxy was dialed directly and skipped the guard.
That let an image URL reach that one address.

Tag each request with the proxy it resolves to and exempt a dial only when it is that hop.
Redirects re-enter the RoundTripper, so every hop is tagged on its own.
2026-09-20 20:46:46 -04:00
Deluan Quintão
0429fb3d40
fix(test): stop the Windows test job from failing at random (#6182)
* fix(persistence): don't format a nil-model row in wrapCursor

* test(plugins): assert task queue delay against the first dispatch, not consecutive gaps

* test(artwork): let the e2e worker wait outlast one retry

* chore: trim comments

* fix(persistence): guard dbFolder and dbMediaFile String() against a nil model
2026-09-20 15:27:20 -04:00
Deluan Quintão
8b4125267e
fix(artwork): only use image files as local artwork sources (#6180)
* fix(playlists): limit local cover paths to images in owner's libraries

A local #EXTALBUMARTURL path (absolute or file://) was only checked against the union of all
libraries. The artwork resolver then opened it with no further check and served the bytes as the
playlist cover, undecoded. Any user who can upload an M3U could read any file under any library
root, including libraries they were not granted, through getCoverArt (GHSA-vwq6-xrw5-phpg).

resolveImageURL now requires an image extension, and for uploaded playlists (no folder) the
library holding the cover must pass the owner's HasLibraryAccess. Scanner and CLI imports keep
the all-libraries check, since those files are admin-controlled.

resolveLocalFile, used by every file-backed artwork source, now ignores paths without an image
extension, which covers playlists stored before this fix that were not resolved yet. openOriginal
refuses a stored file-backed row whose path is not an image, so the existing dangling path
re-resolves it and the playlist falls back to the generated grid. No migration is needed.

* fix(artwork): skip non-image files matched by folder cover patterns

Album and disc folder sources opened any file in the folder's image list that matched a
cover pattern, without checking its extension. openOriginal now refuses to serve file-backed
rows whose path is not an image, so a stored row like that would be refused, re-resolved to
the same file, and refused again on every view. The list comes from the scanner, which only
records image files, but a database scanned where the OS mime table knows more image types
than the serving process could still reach this.

Both fromExternalFile variants now skip matches that are not image files, so the album falls
back to its next source instead. Also correct the parser comment: a playlist without a folder
can come from an API upload or from a CLI import of a file outside all libraries.

* fix(artwork): check stored source type before using the resize cache

The image-extension check for file-backed rows ran inside openOriginal, which the resize
cache skips on a hit. Before the fix, a resized request for a playlist pointing at a non-image
file cached the raw bytes, because a failed resize falls back to the original data. After the
upgrade the same request still hit that entry and returned the file.

serveHash now refuses a file-backed row whose path is not an image before calling serveSource,
so both full-size and resized requests go through dangling and re-resolve the item. The stale
cache entry is keyed by the old hash and is no longer reachable once the row changes.

* test: register mime_types.yaml in test binaries

Artwork resolution now skips candidates that are not image files, and model.IsImageFile answers
from the process mime table. The server registers the extra image types from
resources/mime_types.yaml through a conf hook, but a test binary only does that if it links
conf/mime, so the artwork e2e suite fell back to the host table: .jxl resolves on macOS and
Linux and does not on Windows, where the #5950 cover spec then found no source.

tests.Init now imports conf/mime for its side effect, so every suite that loads the test config
sees the same image types as the server.

* fix(artwork): drop the image-file guard from the disc art reader

The guard was added to both fromExternalFile variants, but disc artwork keeps no state row and
is never queued, so it cannot hit the refuse-and-re-resolve loop the guard exists to prevent.
The only case where it can fire is a real image whose extension this process's mime table does
not know, and there it drops a disc cover that used to work. The album variant keeps the guard,
since those resolutions are stored and re-served.
2026-09-20 13:40:14 -04:00
Deluan Quintão
8e784b6af7
fix: apply the per-user library filter to bookmarks, playlists and now-playing (#6179)
On a multi-library instance, a few reads and writes built their own queries
without the per-user library filter that every other media read applies. A
user granted only some libraries could see, and store, tracks from libraries
they had no access to.

- getBookmarks now filters the query. It has to be the query and not the
  result: the loop below it pre-sizes the response from the bookmark count,
  so a row dropped afterwards would emit an empty bookmark entry.
- createBookmark rejects an id the caller cannot read, returning error 70 to
  match getSong. Stored rows are left alone rather than purged, so a
  temporary revoke does not lose saved playback positions.
- playlistTrackRepository Read, Count and GetAlbumIDs get the filter their
  siblings CountAll and GetMediaFileIDs already had. Read is the one that
  mattered most: its id is the integer playlist position, so it needed no
  track id at all.
- Playlist track writes are filtered in playlistRepository.addTracks, the
  only writer of playlist_tracks rows apart from smart playlists, so Add,
  Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all
  go through it. Insert reserves a slot per requested id, so when the filter
  drops one it renumbers to close the hole.
- playTracker.GetNowPlaying honours its context instead of discarding it.
  The cache is process-global, so the filter belongs in the tracker rather
  than in the Subsonic handler, and any future caller inherits it.

Admins and single-library installs are unaffected: applyLibraryFilter and
HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as
admin, and M3U and CLI imports already resolve tracks through FindByPaths as
the same user, so neither changes.
2026-09-20 12:37:18 -04:00
strecke
a5334ee46b
fix(ui): update missing German translations (#6146)
* Update missing German translations

* Fix typo in idHelp message in German translation

* Update German translations to remove formal "Sie" forms

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-20 12:17:57 -04:00
Jonatan Nyberg
2913c13cdf
fix(i18n): improve Swedish translation (#6177)
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 12:10:47 -04:00
David Vedvick
49f626c00a
feat(smartplaylists): add support for referencing playlists using paths (#5187)
* feat: Add support for referencing playlists using paths

Signed-off-by: David <dvedvick@gmail.com>

* feat: Support relative playlist paths in smartlists

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): protect against nil panic

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): refreshing child playlists

Signed-off-by: David <dvedvick@gmail.com>

* chore(smartplaylists): log field parsing error

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): handle empty playlist paths

Signed-off-by: David <dvedvick@gmail.com>

* refactor(smartplaylists): make NormalizeChildPaths non-mutating

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field

Rules that reference a playlist by id have no path field, and the reverse, so
the warning fired on every refresh. The log call also had a bad argument count.

* fix(smartplaylists): ignore empty inPlaylist id and path references

An empty path matched every playlist without a file path, including the
referencing playlist itself, so the refresh recursed until the stack overflowed.
An empty id also shadowed a valid path in the same rule.

* fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms

A playlist path is stored in the Unicode form the filesystem reports, which can
differ from the form typed in the .nsp file. The exact comparison then found no
playlist for names with accents.

* fix(smartplaylists): keep all criteria fields when normalizing child paths

The field-by-field copy dropped RefreshDelay.

* fix(smartplaylists): clean absolute inPlaylist path references

Only relative references were cleaned, so an absolute reference such as
/music/./child.nsp never matched the stored /music/child.nsp.

* fix(smartplaylists): stop infinite recursion on playlists that reference each other

Two smart playlists referencing each other, by id or by path, recursed until the
stack overflowed and the server died. The refresh now tracks visited playlists.

* fix(smartplaylists): resolve inPlaylist path references with OS-native separators

Playlist.Path is OS-native, but references in a .nsp file use forward slashes.
On Windows they never matched, and a leading slash was not seen as absolute.
The specs now build OS-native paths, so they also run on Windows.

* fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved

A playlist created in the UI has no file path, so a relative reference silently
matched nothing.

* refactor(smartplaylists): simplify child playlist reference handling

Share one extractor for child ids and paths, return only the normalized rules
instead of a playlist copy, and resolve each path reference in a single switch.

* test(smartplaylists): store the Unicode child path in OS-native form

Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched
the normalized reference.

---------

Signed-off-by: David <dvedvick@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-19 21:05:58 -04:00
Deluan
672c0af580 docs(README): update Docker networking instructions for UDP discovery 2026-09-19 17:10:49 -04:00
Deluan Quintão
fb45ad7b9c
fix(auth): ExtAuth logout redirect on unauthenticated loads, and warning spam from untrusted sources (#6176)
* fix(ui): only redirect to ExtAuth logout URL for proxy-authenticated sessions

react-admin calls authProvider.logout() when the boot-time checkAuth fails
and after a 401, not only when the user clicks Logout. With
ExtAuth.LogoutURL set, every unauthenticated page load (e.g. direct LAN
access that bypasses the auth proxy) was sent to the IdP sign-out page and
the login form was never shown.

Redirect only when the page was authenticated by the reverse proxy
(config.auth is present). Other sessions fall back to the login form.

Fixes #6175

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

* fix(server): only warn about untrusted ExtAuth sources when the header is sent

UsernameFromExtAuthHeader checked the source IP before looking for the user
header, so every request from an IP outside ExtAuth.TrustedSources logged a
warning, even when it carried no header at all. With direct LAN access
alongside a forward-auth proxy, a single polling client produced a constant
stream of warnings (twice per Subsonic request, since the middleware chain
resolves the username in both checkRequiredParameters and authenticate).

Look for the header first and warn only when an untrusted source actually
sends it, which is the case worth seeing: a misconfigured proxy or a spoof
attempt.

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-19 17:08:49 -04:00
Deluan Quintão
b76ae14286
feat(jellyfin): add Quick Connect sign-in (#6174)
* feat(jellyfin): add Quick Connect sign-in

Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.

- core/quickconnect: in-memory store shared by both routers through wire.
  Codes expire after 10 minutes; a secret redeems only once (Jellyfin
  allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
  Admins may approve for another user via UserId, like Swiftfin's admin page.
  Initiate and redeem share the login rate limiter; Connect does not, since
  Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
  the app and device before approving, so a user can't be tricked into
  approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
  when the Jellyfin API is enabled.

* refactor(jellyfin): tidy Quick Connect naming and route guards

Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.

* refactor(jellyfin): remove duplicated Jellyfin date formatting function

* test(jellyfin): set play count and starred in the song fixture literal

* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper

* fix(jellyfin): bound the client fields Quick Connect keeps in memory

Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.

Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.

* fix(jellyfin): rate-limit Quick Connect code approval

Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
2026-09-19 14:57:01 -04:00
Deluan Quintão
be8ec15168
feat(ui): format dates using the selected language (#6160)
Dates were rendered with the browser locale, ignoring the language chosen in
Personal settings, so a user browsing in German still saw US-style dates. All
date rendering now resolves its locale through a useDateLocale hook that returns
the selected language, augmented with the region from navigator.languages when
the language carries none (Intl reads a bare "en" as en-US). Applied to
DateField, the rated/loved tooltips, the mobile user list and album release
dates; the three inline timestamp tooltips now share a formatDateTime helper.

Closes #229
2026-09-19 14:07:42 -04:00
Deluan Quintão
fed35e08de
feat(jellyfin): add opt-in LAN auto-discovery (#6169)
* feat(jellyfin): compute the address advertised by auto-discovery

* fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address

* feat(jellyfin): answer LAN auto-discovery broadcasts

* test(jellyfin): e2e check that auto-discovery matches the public server identity

* feat(jellyfin): add opt-in AutoDiscovery option and start the listener

* fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures

* refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place

discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored.

* refactor(jellyfin): run auto-discovery as its own service in the main errgroup

Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot.

* refactor(jellyfin): build Discovery through wire and drop the serverName forwarder

startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly.

* fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host

With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is.

* docs(jellyfin): note which address auto-discovery advertises on restricted binds

Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is.
2026-09-19 13:33:19 -04:00
Deluan Quintão
549dfa7f30
feat(jellyfin): advertise Jellyfin 12.1.0 and add the missing 12.x quick wins (#6163)
* feat(jellyfin): advertise Jellyfin server version 12.1.0

Streamyfin, jellyfin-android and jellyfin-androidtv refuse servers older than
10.10, Swiftfin warns below 12.0, and @jellyfin/sdk flags anything below its
minimum as unsupported, so 10.9.11 locked those clients out. 12.1.0 is the
current Jellyfin release (after 10.11 Jellyfin renumbered to 12.0). No client
checked has an upper bound or assumes the major is 10, and the value keeps
three parts because the Kotlin SDK and Swiftfin reject two-part versions.

* feat(jellyfin): acknowledge POST /Sessions/Playing/Ping

Jellyfin clients ping this endpoint to keep a transcode job alive while
paused. Navidrome ties transcodes to the stream request, so there is nothing
to keep alive; answer 204 like Jellyfin instead of a 404. It shares one no-op
handler with Sessions/Capabilities, renamed to acknowledge.

* feat(jellyfin): reorder playlist entries via Items/{entryId}/Move

Adds POST /Playlists/{id}/Items/{entryId}/Move/{newIndex}, which clients use
to reorder playlists. It maps the entry's PlaylistItemId (its position) and
Jellyfin's zero-based newIndex onto the existing core ReorderTrack, which
enforces ownership. As in Jellyfin, an index past the end appends and an
unknown entry is a no-op; out-of-range positions never reach Reorder, which
would otherwise shift unrelated rows.

* feat(jellyfin): honor position when adding items to a playlist

POST /Playlists/{id}/Items takes an optional zero-based position (added to
the Jellyfin spec in 12.0). Match Jellyfin: zero or negative prepends, past
the end appends, otherwise the new items are inserted at that index in the
order they were added.

Adds PlaylistTrackRepository.Insert and core playlists.InsertTracks, which
shift the following entries and insert in one transaction, instead of
appending and moving each new track with its own ReorderTrack call.

* feat(jellyfin): add type-specific InstantMix routes

Jellyfin exposes InstantMix under Songs/, Albums/, Artists/ and Playlists/
as well as Items/, plus the legacy Artists/InstantMix and
MusicGenres/InstantMix forms that take the seed as ?id=. Clients generated
from the Jellyfin SDKs call the type-specific routes, which 404ed. All of them
now share the existing Items/{id}/InstantMix handler; the external provider
already builds mixes from song, album, artist, playlist and genre seeds.

* feat(jellyfin): honor Width, Height and Fill* image size params

The image endpoint only read MaxWidth/MaxHeight, so clients that size covers
with fillWidth/fillHeight (Manet, Finamp) or width/height got the full-size
original on a cold artwork cache: a 578 KB PNG instead of a 13 KB resize.
Jellyfin applies Width/Height, caps them with MaxWidth/MaxHeight, then
shrinks to the smallest size that still covers the Fill box. Navidrome
resizes on one dimension, so the tightest bound wins and a fill box counts
as its larger side.

* feat(jellyfin): answer HEAD on audio, file and image routes

Fintunes sends HEAD to /Audio/{id}/universal to read the content type and
detect direct play (a Content-Length means direct play), and to the audio and
image URLs before a download, aborting the download when it fails. Those routes
were GET-only, so HEAD got a 404. HEAD now reuses the GET handlers. Direct
play goes through Stream.Serve, which already answers HEAD; a transcode
answers with the target content type and no length without starting ffmpeg,
so a probe never costs a transcode.

* fix(jellyfin): clamp playlist move and insert positions before adding one

movePlaylistItem computed min(newIndex+1, SongCount): newIndex=MaxInt wrapped
to a negative position, and Reorder then left the playlist with a gap
(positions 2, 3, 999998), making the moved entry unmovable. Clamp against the
playlist length first. addToPlaylist's min(position, MaxInt32)+1 wrapped on
32-bit builds and req.Int truncated large values there, so a far-past-the-end
position prepended; parse as int64 and clamp before converting.

* fix(playlists): validate reorder positions inside the write transaction

Reorder never checked its positions, so a source outside the playlist or a
destination past its end shifted rows around a missing entry and left a gap
(e.g. ids 1 and 3), which made the moved entry unmovable afterwards. The
Jellyfin Move handler guarded this with a SongCount read before ReorderTrack,
but a concurrent removal between the two reopened it, and the native API
reorder endpoint passed client positions through unchecked.

Reorder now reads the last position in the same transaction, returns
ErrNotFound for a source outside the playlist and clamps the destination.
ReorderTrack uses an immediate transaction so that read and the updates are
atomic against other writers. The Jellyfin handler drops its pre-check and
maps ErrNotFound to Jellyfin's no-op 204; the native API now answers 404 for
an unknown track instead of corrupting the order.
2026-09-19 13:19:48 -04:00
Deluan Quintão
16567f147b
fix(jellyfin): match Jellyfin on login SessionInfo, item types and universal streams (#6161)
* fix(jellyfin): send SessionInfo on login so JellyBox gets past sign-in

JellyBox parses AuthenticateByName's SessionInfo as a required object and
fails silently when it is missing, leaving the user on the login screen.
Real Jellyfin always sends it (SessionManager.AuthenticateNewSessionInternal,
10.10.7 and master), so the login response now carries a full SessionInfo
built from the user and the MediaBrowser auth header. It includes every field
JellyBox (Id, PlayState) and Finamp (UserId, LastActivityDate, the activity
and control bools, PlayState's CanSeek/IsPaused/IsMuted) require once the
object is present. The session Id is derived from client and device id, so
repeated logins from one install share it.

* fix(jellyfin): ignore IncludeItemTypes names that aren't Jellyfin kinds

JellyBox opens an album with ParentId=<album>&IncludeItemTypes=music. Music
is not a BaseItemKind, and Jellyfin's comma-delimited binder drops values it
cannot parse, so real Jellyfin treats the request as having no type filter and
lists the album's tracks. Navidrome returned an empty list, so every album
opened empty. Entries that aren't BaseItemKind names are now dropped before
type resolution, so an all-unknown list behaves like an absent one. Real kinds
Navidrome doesn't serve, such as Boxset, still return nothing.

* fix(jellyfin): treat universal Container as the direct-play list

On /Audio/{id}/universal, Container lists the "container|codec" entries the
client can direct play, and TranscodingContainer/AudioCodec name the target
when it can't (UniversalAudioController builds DirectPlayProfiles from it).
Navidrome passed the whole list to the decider as one target format, which
matched nothing and fell back to DefaultDownsamplingFormat, so JellyBox got
every MP3 transcoded to Opus. /universal now has its own handler: a source
matching an entry keeps its format (still downsampled under a bitrate cap),
anything else is transcoded to TranscodingContainer, then AudioCodec. The
/stream routes keep treating Container as the target format.

* refactor(jellyfin): let the stream decider resolve universal requests

streamUniversal matched the Container list itself with plain string equality
and then asked the legacy resolver for the source format. That skipped the
decider's container and codec aliases (mp4 vs m4a, ogg vs opus), and a
direct-playable source over the bitrate cap was transcoded to its own format
instead of the client's TranscodingContainer.

The shared part of ResolveRequest (server-side player override, player
MaxBitRate cap, decision to Request mapping) moves to a resolve helper, and a
new ResolveClientRequest exposes it for callers that build their own
ClientInfo. streamUniversal now turns Container into DirectPlayProfiles and
TranscodingContainer/AudioCodec into a transcoding profile, so the decision
uses the same rules as the Subsonic getTranscodeDecision path. streamFile and
the /stream routes share a serveStream helper, NewSessionInfo reads the clock
itself, and duplicate comments and tests are trimmed.
2026-09-17 23:48:39 -04:00
Deluan Quintão
ded4f47d93
fix(release): repair root-owned artwork and plugins folders on upgrade (#6143)
* fix(release): repair root-owned artwork and plugins folders on upgrade

Navidrome 0.57.0, 0.60.x and 0.61.x created the plugins and artwork folders as soon as the configuration loaded. The deb/rpm postinstall script runs navidrome as root, so fresh installs and upgrades on those versions left these folders owned by root. The service runs as the navidrome user and cannot write to them. Since 0.64.0 new artwork is stored under artwork/hashed, so affected installs fail to persist artwork and cannot read the plugins folder.

The postinstall script now changes the owner of these two folders to navidrome, only when they exist and are owned by root. The change is not recursive: root created the folders empty and the service could never write inside them, so fixing the folder itself is enough and stays instant regardless of how much artwork exists. Folders an admin assigned to another user are left untouched.

Fixes #6140

* fix(release): handle root-owned cache folder without install noise

The postinstall script ran an unconditional chown on /var/lib/navidrome/cache during fresh installs. Since folders are created lazily, the cache folder does not exist at that point, so every fresh deb/rpm install printed "chown: cannot access '/var/lib/navidrome/cache': No such file or directory".

The cache folder is now part of the same root-owned folder check used for artwork and plugins: it is fixed when it exists and is owned by root, and skipped silently otherwise. This also covers installs from 0.54.1 and 0.54.2, which created the cache folder as root before the chown was added.

* fix(release): never follow symlinks when repairing folder ownership

The ownership check used find's default -P mode, so -user root tested a symlink itself, while chown dereferenced it. A root-owned symlink pointing to a folder owned by another account made the postinstall script reassign that folder to navidrome, bypassing the root-owner guard.

The check now only matches real directories (-type d without following links) and uses chown -h. Following the link with find -H was rejected: /var/lib/navidrome is owned by navidrome, so the service account could plant a symlink to any root-owned directory and have the next upgrade hand it over. As a trade-off, a symlink to a root-owned folder is no longer repaired; the folders affected by the original bug were always real directories.
2026-09-17 17:17:56 -04:00
Deluan
5bd14da65c test(artwork): cover artist folder lookup for a single album without images
Add an e2e spec for an artist whose only album folder has no images of its
own, while the artist folder holds folder.jpg (plus unrelated images) and
ArtistArtPriority starts with folder.*. Before #5856, the album's parent was
promoted into the album paths, so the artist folder resolved to the library
root and the artist got no image. The spec fails if that promotion comes
back, and passes on current code.

Refs #5823
2026-09-17 10:59:28 -04:00
Deluan Quintão
decac50f60
fix(lastfm): double-encode plus signs in artist and track names (#6158)
Last.fm decodes the artist and track params of artist.getInfo, artist.getSimilar, artist.getTopTracks and track.getSimilar twice, so a "+" in a name becomes a space. Names like "Florence + The Machine" resolved to a misspelled duplicate page whose bio is Last.fm's "incorrect tag" notice, and names like "+44" were not found at all. Encode "+" as %2B before the normal query encoding for those calls. album.getInfo decodes only once, so it keeps the plain encoding.
2026-09-17 07:29:56 -04:00
Deluan Quintão
18205366c8
fix(jellyfin): match Jellyfin's item payloads so strict clients can sync (#6151)
* fix(jellyfin): match Jellyfin's item payloads so strict clients can sync

Manet (iOS/macOS) aborted its whole library sync on the first item that was
missing a key its decoder requires, leaving the library empty (#6147). Every
gap was a field real Jellyfin always sends:

- dates now use .NET's round-trip layout with 7 fractional digits, which Manet
  requires and plain RFC3339 failed
- playlists carry SortName/DateCreated, and every item carries MediaType,
  ImageTags, ChannelId and, when Fields asks, Genres/GenreItems/Tags
- albums carry Artists and LocationType; songs always carry HasLyrics
- the library view is a full CollectionFolder (ChildCount, DateCreated,
  SortName, Path, LocationType, UserData), read from the library rows rather
  than the user projection, which has no counts
- IncludeItemTypes matches case-insensitively and returns nothing for Jellyfin
  kinds Navidrome has none of, instead of falling back to every album

Verified against a real Jellyfin 10.10.7 server and a live Manet client.

* refactor(jellyfin): fold the repeated empty-list defaults into one helper

The three mappers each initialised Genres/GenreItems/Tags the same way, and the
e2e suite grew three near-identical specs walking every item type. Both now go
through a single helper and one table.

* refactor(jellyfin): fill the always-present item fields at the serialization edge

The defaults real Jellyfin puts on every item were spread across three mappers,
so item types nobody had tested yet (playlists, genres, the library view) still
shipped payloads a strict client rejects. stampItem now takes the request's
Fields and fills them for every item, which is provably the only path to JSON.

Also drops the hand-copied BaseItemKind list: only an absent IncludeItemTypes
defaults to albums now, so any type Navidrome does not serve returns nothing,
as it would from Jellyfin. The synthetic playlists folder matches
case-insensitively like the rest, /Items/{libraryId} reads the full library row
instead of the count-less user projection, and PremiereDate and LastPlayedDate
go through jellyfinDate rather than spelling the layout out again.
2026-09-16 07:28:32 -04:00
fxj368
97270d44e1
fix(ui): update Chinese Simplified translations (#6152) 2026-09-16 07:24:50 -04:00
Deluan Quintão
3f4b6a642c
fix(server): return 404 instead of 500 for missing native API resources (#6131)
* fix: return 404 instead of 500 for missing native API resources

The deluan/rest controller only maps rest.ErrNotFound to 404, comparing with ==.
Most repositories return model.ErrNotFound, which had the same message but was a
different value, so requesting a missing playlist, album, artist, song, radio,
player, transcoding or library returned 500. This also applied to other users'
private playlists.

Make model.ErrNotFound the same value as rest.ErrNotFound. This fixes every REST
route at once, with no per-route wrapping. errors.Is checks against either
error keep working, and nothing wraps model.ErrNotFound before it reaches the
controller.

Fixes #6130

* fix(radio): return not found when deleting a missing radio station

radioRepository.Delete used the shared delete helper, which never reports a
missing row because SQL DELETE on zero rows is not an error. Deleting an unknown
id silently succeeded: DELETE /api/radio/{id} returned 200, and the Subsonic
deleteInternetRadioStation endpoint returned ok.

Delete now checks the affected row count and returns model.ErrNotFound when
nothing was deleted. The native API returns 404, and deleteInternetRadioStation
returns error 70 (data not found). This matches Subsonic 6.1.6, gonic (both
verified live) and Ampache (verified in source). Airsonic-Advanced does not
implement this endpoint.

The shared delete helper is unchanged, as several callers rely on deletes of
absent rows succeeding.

* fix(ui): return 404 for missing files that are not missing or do not exist

missingRepository.Read filtered media files by bare "id" and "missing" columns.
The media file query joins the library table, so SQLite rejected the query as
ambiguous and GET /api/missing/{id} returned 500. Read now loads the file with
MediaFileRepository.Get, which qualifies the column, and returns not found
when the file does not exist or is not marked missing.

* fix: report missing rows on single-item deletes and adopt deluan/rest errors.Is

Bump github.com/deluan/rest to the version whose controller matches errors
with errors.Is and errors.As. model.ErrNotFound stays the same value as
rest.ErrNotFound, so the many hand-written conversions from model.ErrNotFound
to rest.ErrNotFound in repositories, core services and test mocks did nothing.
Remove them, along with the duplicate rest.ErrNotFound check in the Subsonic
error mapper. Mappings from model.ErrNotAuthorized stay, as those are
different errors.

User and transcoding deletes had the same silent success as radio: the shared
delete helper never reports a missing row, so their not-found checks never
fired and DELETE /api/user/{id} and /api/transcoding/{id} returned 200 for
unknown ids. Add deleteByID, which returns model.ErrNotFound when no row
matched, and use it for radio, user and transcoding. Also drop the dead
sql.ErrNoRows branch from delete, since a DELETE never returns it.

The Subsonic deleteUser endpoint is not implemented (501), so this does not
change the Subsonic API. Plugin deletes keep the silent helper: there is no
REST route for them, and the plugin manager only deletes rows it just read.

* chore: drop ErrNotFound comment and its identity test

The alias to rest.ErrNotFound is self-explanatory, and the identity test only
restated the declaration.

* refactor: alias model.ErrNotAuthorized to rest.ErrPermissionDenied

Like ErrNotFound, make model.ErrNotAuthorized the same value as the rest
library's error, so REST endpoints map it to 403 directly. This removes the
ErrNotAuthorized to rest.ErrPermissionDenied mappings in the library and
playlist REST adapters and the duplicate check in the Subsonic error mapper.

Handlers that check model.ErrNotAuthorized now also recognize
rest.ErrPermissionDenied returned by repositories, so writePlaylistError, the
image upload handlers and the public share handler return 403 for it instead
of their fallback status. The error message changes from "not authorized" to
"permission denied".
2026-09-14 22:46:21 -04:00
Deluan
847570966b feat(subsonic): add option to disable getNowPlaying
- Add Subsonic.EnableGetNowPlaying configuration, enabled by default
- Return an empty response when the endpoint is disabled
2026-09-14 17:33:56 -04:00
Deluan
dd71f1c57f chore(release): fix PikaPods link and replace Danian with Zenith
The release notes footer pointed to an outdated PikaPods URL and still
listed Danian as a hosting option. Use the PikaPods run URL and Zenith,
matching the links already used in the published v0.64.0 release notes.
2026-09-13 20:52:39 -04:00
Deluan
d00c84716b fix(scanner): update go-taglib to fix permission denied on shared hosts
The go-taglib WASM compilation cache lived in a shared $TMPDIR/go-taglib-wasm
directory, created with 0700 by whichever user ran first. A second Navidrome
instance running as another user on the same machine failed to read every
file with "permission denied", so its scan found no files.

The updated fork uses a per-user cache directory (go-taglib-wasm-<uid>) and
falls back to running without the cache when it cannot be created or used.
2026-09-13 11:03:56 -04:00
Deluan Quintão
1072e9f7eb
chore(plugins): document requiredHosts rules and deprecate pdk.NewHTTPRequest (#6129) v0.64.0
* 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.
2026-09-12 13:59:46 -04:00
Deluan Quintão
276d767ce5
Merge commit from fork
* 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
2026-09-12 13:41:00 -04:00
Deluan Quintão
1a8463f7de
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
Sudo-Ivan
4168377b65
Merge commit from fork 2026-09-12 13:37:33 -04:00
Deluan Quintão
2d09ebc676
fix(ui): update Chinese (traditional) translations from POEditor (#6128)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-09-12 12:58:00 -04:00
ts
c6732e1fdf
feat(cli): add missing file list and remap subcommands (#5928)
* 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>
2026-09-12 12:08:25 -04:00
Rob Emery
2802c05a66
feat(msi): keep install folder and settings across upgrades (#5333)
* 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>
2026-09-12 09:40:59 -04:00
Deluan Quintão
d9d38f842a
fix(ui): update German, Greek, Finnish, Galician, Polish, Portuguese (BR), Thai, Ukrainian, Chinese (traditional) translations from POEditor (#5833)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-09-12 00:52:54 -04:00
Deluan Quintão
d0d5403708
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069)
* 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
2026-09-11 22:26:28 -04:00
Huang-404-Q
9e950cb63d
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085)
* 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>
2026-09-11 20:50:45 -04:00
Deluan Quintão
2dc0983629
fix: miscellaneous fixes for shares, artwork resize, auth limits, and watcher start (#6098)
* 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.
2026-09-11 15:03:54 -04:00