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.
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
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>
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.
* 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.
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.
* 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.
* 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
* 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.
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.
* 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>
* 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>
* 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>
* 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.
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
* 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.
* 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.
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.
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.
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.
- 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
- 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
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
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>
- 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>
- 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>
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>
* 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.
* 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.
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
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.
* 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.
* 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".
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.
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.
* fix(plugins): align the Python HTTP example with the repo's host-call pattern
Bind http_send with raw memory offsets like nowplaying-py does, drop
guards for fields the host always sends, and document how plugins
without a PDK call host services and which built-in HTTP APIs are
disabled.
* docs(plugins): document the private-address rules for HTTP requiredHosts
Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can.
* docs(plugins): document the private-address rules for requiredHosts
Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can, for both
HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper.
* feat(plugins): derive Default for Rust host service structs
The ndpgen client.rs template now adds Default to the derive list of host
service structs, as the capability and shared types templates already do.
Plugin authors can now set only the fields they need, for example
HTTPRequest { method, url, ..Default::default() }. The webhook-rs and
discord-rich-presence-rs examples use this form now. The golden files and
the generated nd-pdk-host crate are updated to match.
* feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK
Navidrome no longer enables extism's http_request host function, so a
request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small
deprecation table and writes a Deprecated: paragraph for the listed extism
functions, in both the WASM wrapper and the native stub. Linters and IDEs
now point plugin authors to host.HTTPSend. The PDK example tests used to
teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock.
* docs(plugins): correct requiredHosts rules for websocket and private addresses
Two statements in the plugin docs did not match the code.
The WebSocket section claimed requiredHosts behaves like HTTP. It does not:
host_httpclient.go only consults the allowlist when the list is non-empty and
otherwise falls back to allowing public addresses, while host_websocket.go
always calls isHostInAllowlist, so an absent list blocks every connection.
The HTTP section claimed a named host can never reach a private address.
checkPrivateDial scans the whole requiredHosts list, so a named host does
reach a private address when the same list also holds a covering IP or CIDR.
Reworded both, plus the matching requiredHosts descriptions in
manifest-schema.json, and regenerated manifest_gen.go.
* fix(plugins): apply the private-address dial guard to WebSocket connections
The WebSocket host service only matched the host string against
requiredHosts, so an allowlisted name resolving (or rebinding) to a
private address was dialed. Share the HTTP client's resolved-IP check
and allowlist matching, so WebSocket follows the same rules: named hosts
can't reach private addresses, literal IP/CIDR entries and a bare "*"
can.
* refactor(plugins): drop redundant WebSocket dial timeout and tidy guard tests