5164 Commits

Author SHA1 Message Date
Jiho Andrew Lee
6a1b201542 Merge branch 'master' into feat-podcast-ux-improvements/5420 2026-09-22 16:20:45 +09: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)
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
v0.64.1
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
Jiho Andrew Lee
23f28aac66 fix(podcast): implement missing SSRF validation and fix migration boot failure
Two blocking bugs found while reviewing this branch:

1. Compile error: validateURL() was called in fetchAndParse/doDownload (added
   while applying Strix's SSRF suggestions) but the function itself was never
   committed - only "Add validateURL and isReservedIP helper functions" was
   left as a plain-text suggestion with no one-click apply, and it got missed.
   Implemented validateURL/isReservedIP plus a safeHTTPTransport whose
   DialContext re-resolves and re-checks the target IP at actual connection
   time (not just once via a URL pre-check), so a DNS answer that changes
   between the check and the request (DNS rebinding) can't reach a reserved
   address - this also covers HTTP redirect targets for free, since redirects
   reuse the same Transport. Added AllowLoopbackHTTPForTests() so the
   existing httptest-based suite (which binds to 127.0.0.1) still passes
   without weakening the guard for any other address.

2. Migration boot failure: the podcast migrations were dated 2026-04-27/28
   (when the feature was actually developed), but goose.UpContext (as this
   project calls it, no WithAllowMissing) hard-errors on any pending
   migration older than the DB's already-applied max version. Any install
   already past April on current master would fail to start entirely on
   upgrade. Renumbered all 5 podcast migrations to 2026-09-02 (after
   everything currently on master). This also meant the podcast_* columns
   added to the already-shipped uniform_canonical_ids migration's idColumns
   were dead code for any install that had already run that migration -
   editing an applied migration's Go source doesn't make it re-run. Reverted
   that edit and split the podcast id canonicalization into its own,
   later migration (20260902000005) that reuses the same buildIDMap/
   applyIDMap machinery. Verified both fresh-install and existing-install
   upgrade paths end-to-end against real sqlite DBs: no boot error, and
   legacy-shaped podcast ids (plus their FK references) get correctly
   rewritten to canonical form.

Verified: full build clean, core/podcasts + server/nativeapi + db/migrations
test suites all pass, gofmt clean.
2026-09-19 21:07:31 +09:00
Jiho Andrew Lee
7338461efe Update core/podcasts/podcasts.go
Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com>
2026-09-19 21:07:31 +09:00
Jiho Andrew Lee
8d3facf0dd Update server/nativeapi/podcasts.go
Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com>
2026-09-19 21:07:31 +09:00
Jiho Andrew Lee
e66e53c5dd Update core/podcasts/podcasts.go
Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com>
2026-09-19 21:07:31 +09:00
Jiho Andrew Lee
d5cd6993d2 Update core/podcasts/podcasts.go
Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com>
2026-09-19 21:07:31 +09:00
Jiho Andrew Lee
0e1dde287d fix(podcast): resolve post-rebase breakage after rebasing onto latest master
Rebasing onto master's Podcasting-branch-unrelated changes surfaced several
integration gaps the merge conflicts didn't catch:

- conf.Server.DataFolder is now a Dir type, not a string; podcasts.go and
  its tests needed .String()/conf.NewDir() at each call site.
- subsonic.New() gained the podcasts.Podcasts parameter; several e2e/unit
  test call sites elsewhere in the tree were still passing the old arg count.
- The uniform-canonical-ids migration (dated after our podcast migrations,
  so it runs against a schema that already has the podcast tables) didn't
  know about the new podcast_* id columns, leaving them unrewritten while
  everything else (including podcast_episode.stream_id's matching
  media_file.id) got canonicalized.
- opensubsonic_test.go's expected extension count was miscounted during
  conflict resolution (9 Podcasting 2.0 extensions, not 7).
- .gitignore's unanchored `podcasts/` entry from an earlier commit
  accidentally matched core/podcasts/ (source) in addition to the
  downloaded-episode directory; anchored both to their actual paths.
2026-09-19 21:07:31 +09:00
Jiho Andrew Lee
aa2d89611e fix(podcast): fix build failure and missing episode play/status UX
server/e2e was failing to build because subsonic.New() gained a
podcasts.Podcasts parameter that the sonic-similarity e2e test wasn't
passing. Also fixes real bugs surfaced while getting the podcast tests
green: completed episodes had no play button, the downloading status
badge dropped its label text, and PodcastCreate's test suite assumed a
single-step form submit instead of the actual preview-then-add flow.
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
b6b91709f0 feat(i18n): complete Korean translation
Add missing 105 keys across all resource categories:
- Song: composer, albumGain, trackGain, disc, showInPlaylist, instantMix
- Artist: podcast action
- Podcast: full resource (name, fields, actions, notifications, status)
- Library: quickScan/fullScan actions and notifications, fix scanStarted typo
- Plugin: full resource (fields, sections, status, actions, notifications,
  validation, messages, placeholders)
- Message: cover art upload/remove, startingInstantMix
- About.config: downloadToml
- Activity: selectiveScan
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
3ce6141887 feat(ui): improve podcast display in playlist and podcast views
- Show podcast-aware album link in playlist (navigates to podcast channel page)
- Show '-' instead of '[Unknown Artist]' for podcast episodes with no artist
- Use best-resolution image URL in podcast list view
- Add srcSet/sizes for responsive cover images in podcast grid and show views
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
6166396152 feat(podcast): add Podcasting 2.0 metadata support with persistence layer
- Parse podcast:images, podcast:funding, podcast:transcript, podcast:chapters,
  podcast:soundbite, and podcast:person tags from RSS feeds
- Add DB migration and repositories for podcast images and funding sources
- Expose Podcasting 2.0 fields in Subsonic API responses
- Tag downloaded episodes with genre=Podcast for playlist filtering
- Update mock data store and fix subsonic test compatibility
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
152a032ae1 feat(podcast): implement Podcasting 2.0 namespace support (Tier 1–3)
Adds full support for the Podcasting 2.0 namespace
(https://podcastindex.org/namespace/1.0) across RSS parsing, persistence,
Subsonic API responses, and OpenSubsonic extensions.

- podcast:guid: channel-level UUIDv5 for stable identity across feed URL changes
- podcast:chapters: per-episode chapters URL + MIME type (chaptersUrl in API)
- podcast:transcript: multiple transcripts per episode stored in a dedicated
  podcast_transcript table; attributes: url, type, language, rel
- podcast:season: season number + optional name per episode
- podcast:episode: episode number (decimal string) + optional display label

- podcast:person: host/guest entries at both channel and episode level stored in
  a dedicated podcast_person table; attributes: name, role, group, img, href;
  role defaults to "host" and group defaults to "cast" per spec
- podcast:locked: feed lock flag + optional owner email on channel
- podcast:funding: first funding entry URL + display text on channel
- podcast:medium: content type classification on channel
- podcast:soundbite: startTime (float), duration (float), title per episode
- podcast:updateFrequency: display text + rrule + complete flag on channel

- podcast:podroll: creator-recommended feed list stored in podcast_podroll table
  (feedGuid, feedUrl, title, sort_order); returned as podroll[] in GetPodcasts
- podcast:liveItem: live stream detection stored in podcast_live_item table
  (one row per channel, unique index); stores status, start/end times,
  enclosure URL/type, and contentLink for fallback playback; returned as
  liveItem object in GetPodcasts; Upsert preserves created_at on updates
- podcast:podping: usesPodping boolean on channel; RefreshChannels skips
  channels with usesPodping=true (they receive updates via Podping WebSocket)

- 20260428000000_add_podcast20.go: ALTER TABLE adds 9 columns to
  podcast_channel, 9 columns to podcast_episode; CREATE TABLE
  podcast_transcript (episode_id FK, url, mime_type, language, rel) and
  podcast_person (no FK constraints — put() serialises "" not NULL)
- 20260428120000_add_podcast_tier3.go: ALTER TABLE adds uses_podping to
  podcast_channel; CREATE TABLE podcast_podroll and podcast_live_item
  (UNIQUE INDEX on channel_id)

GetPodcasts response (PodcastChannel) gains:
  podcastGuid, locked, medium, fundingUrl, fundingText, updateFrequency,
  complete, usesPodping, person[], podroll[], liveItem{}

GetPodcastEpisode response (PodcastEpisode) gains:
  season, seasonName, episode, episodeDisplay, chaptersUrl, soundbiteStart,
  soundbiteDur, transcript[], person[]

GetPodcastEpisode now loads transcripts and persons from their repositories
(previously only read the base episode row).

New OpenSubsonic extensions declared:
  podcastChapters, podcastTranscripts, podcastSeason, podcastPerson,
  podcastFunding, podcastMedium, podcastPodroll, podcastLiveItem, podcastPodping

- core/podcasts/rss_test.go: 41 new specs covering all namespace tags,
  default value handling (role→"host", group→"cast"), backward compatibility
- core/podcasts/podcasts_test.go: 26 new service specs covering AddChannel
  field persistence, transcript/person saving, podroll/liveItem saving,
  RefreshChannels podping skip behaviour
- persistence/podcast_transcript_repository_test.go: 10 specs
- persistence/podcast_person_repository_test.go: 12 specs
- persistence/podcast_podroll_repository_test.go: 10 specs
- persistence/podcast_live_item_repository_test.go: 8 specs
- server/subsonic/podcasts_test.go: 15 new handler specs for Tier 2 and
  Tier 3 fields in GetPodcasts and GetPodcastEpisode responses
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
be3c271c9c refactor(podcast): inject server shutdown context into podcast service
Pass the server root context (ctx) to NewPodcastService so that background
download goroutines are tied to the server lifecycle and will be cancelled
on shutdown, matching the pattern used by scanner.New.

Signed-off-by: ji-ho lee <search5@gmail.com>
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
5279f23bc8 fix(podcast): address code review feedback
- Add 30s timeout to episode download HTTP client
- Add 15s timeout to RSS feed fetch HTTP client
- Fix N+1 query in GetAll(withEpisodes): fetch all episodes in a single
  query using IN clause via GetByChannels
- Add sanitizeMetadata helper to strip null bytes from ffmpeg tag values
- Add TODO comment on background goroutine context for server shutdown

Signed-off-by: ji-ho lee <search5@gmail.com>
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
cff5a2acb0 test(podcast): fix test failures after API changes
- Add ExistsByURL to MockPodcastChannelRepo
- Add channel mock data to DownloadEpisode error handling and timestamp tests
- Fix DeleteEpisode test to match actual behavior (resets to new status)
- Pass podcasts.Podcasts to subsonic.New in e2e test suite

Signed-off-by: ji-ho lee <search5@gmail.com>
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
544929bae2 chore: add podcasts/ to .gitignore (downloaded audio files should not be tracked)
Signed-off-by: ji-ho lee <search5@gmail.com>
2026-09-19 21:07:17 +09:00
Jiho Andrew Lee
775747264b feat(podcast): add podcast feature with UX improvements - #5420
Backend
- Add podcast data model (PodcastChannel, PodcastEpisode) with migrations
- Implement Subsonic API endpoints: getPodcasts, getNewestPodcasts,
  createPodcastChannel, refreshPodcasts, deletePodcastChannel,
  deletePodcastEpisode, downloadPodcastEpisode, getPodcastEpisode
- Add native REST API endpoints: GET/DELETE /api/podcast,
  GET /api/podcast/preview (feed info without creating channel)
- Inject events.Broker into podcast service for SSE support
- Emit PodcastEpisodeProgress SSE events during download (every 512 KB)
  and on completion/error with status field
- Use HTTP Content-Length as fallback when RSS feed omits enclosure size
- Write ID3 tags (title, album, genre=Podcast) to downloaded files via
  ffmpeg so the library scanner reads correct metadata
- Set MediaFile fields (Title, Album, AlbumID=channelId, AlbumArtist,
  Genre) on episode registration
- Add duplicate URL check in AddChannel
- Add ExistsByURL to PodcastChannelRepository

Frontend - Podcast list
- Add grid/list view toggle (Redux podcastViewReducer) matching album list
- New PodcastGridView component with responsive column count (2-6 cols)
- Cover image 100px in table view
- Remove Feed URL column; add inline copy-to-clipboard button

Frontend - Podcast show (episode list)
- Real-time download progress (%) in Status column via SSE, no polling
  - Spinner only before first SSE event; N% once data arrives
  - Size column removed; Downloading badge replaced with progress
- Completed episodes play on row click; separate play button removed
- Play / Shuffle / Play Next / Add to Queue buttons above episode list
  (only shown when completed episodes exist)
- On download completion, reload episodes to obtain streamId for
  immediate playback without page refresh
- Clicking album name in AudioTitle navigates to podcast channel page

Frontend - Podcast create
- Full-width URL input with Fetch Feed Info button and Enter key support
- Preview card (cover image, title, episode count, description) before
  committing channel creation
- Add Channel button appears only after preview; shows already-registered
  message if channel URL exists

Frontend - Playlist
- Album link navigates to podcast channel page for podcast tracks
  (identified by genre=Podcast)
- Artist column shows '-' for podcast tracks with empty artist field

Closes #5420

Signed-off-by: ji-ho lee <search5@gmail.com>
2026-09-19 21:07:04 +09: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
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)
* 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.
v0.64.0
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