From 69b496383deb9e58e3930da7628ab563af938946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 24 Sep 2026 21:24:46 -0400 Subject: [PATCH 1/3] docs(jellyfin): refresh the README's known limitations (#6220) * docs(jellyfin): refresh the README's known limitations Rewrite the Known limitations list against the current code and Jellyfin 12.1, dropping stale entries (synthetic blurhash, unchecked artist access, global genres) and adding the real gaps: search skipping filters, the one-character minimum, the 2,000-item search cap, position-based playlist entry ids, the rating param, missing endpoints and unemitted Fields. Also move the lyrics description into its own section, add the missing routes and filters to the endpoint table, and drop the stale artist-access TODO in resolveItemByID. * docs(jellyfin): list MaxConcurrentStreams env var and all e2e stubs --- server/jellyfin/README.md | 109 ++++++++++++++++++++++---------------- server/jellyfin/items.go | 3 +- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index e38997569..9315ac4f9 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -40,6 +40,7 @@ ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" ND_JELLYFIN_AUTODISCOVERY=true ND_JELLYFIN_QUICKCONNECT=false +ND_JELLYFIN_MAXCONCURRENTSTREAMS=4 ``` Once enabled, the API is mounted at: @@ -140,8 +141,10 @@ Jellyfin has no native concept of multiple music libraries the way Navidrome doe Navidrome library the current user can access is exposed as its own top-level Jellyfin "CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the -authenticated user has access to; a library (or item within it) the user cannot access returns -`404`, never `403`, so ids can't be used as an existence oracle. +authenticated user has access to. Fetching an item the user cannot access returns `404`, never +`403`, so ids can't be used as an existence oracle. An inaccessible library id sent as `ParentId` +is not a `404`: it is simply not treated as a library, so none of that library's content is +returned. ### Browsing filters @@ -152,7 +155,7 @@ tracks — Finamp's artist screen sends these *alongside* `ParentId=` album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); -`SearchTerm`; +`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`; `Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone `isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome @@ -169,9 +172,9 @@ returns direct children only (no tracks — no track is a library's direct child | Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | -| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | -| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | +| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Items/Latest`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | +| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | | Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | | Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | @@ -267,6 +270,22 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are advertised and served but packed-audio players won't decode them. +## Lyrics + +`GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in 100ns ticks, +word-level `Cues` when present), resolved through the full `core/lyrics` pipeline (embedded, `.lrc` +sidecars, plugins per `LyricsPriority`). No lyrics returns `404`, never an empty `200`. + +Results, misses included, are cached for 5 minutes: Jellify fetches lyrics for every played track and +Feishin on every song change, so lyric-less tracks are the hot path. Concurrent misses on the same +track share one pipeline run. That run is detached from the request, so a cancelled request doesn't +fail it for other waiters, and its context has a one-minute deadline. + +Finamp opens its lyrics view only when the track has a `Lyric` `MediaStream` (`HasLyrics` is just a +list badge). Browse lists set both from embedded lyrics only. `PlaybackInfo` runs the full pipeline +per track, so sidecar and plugin lyrics show up there. Feishin also requires server version ≥ 10.9 +(we advertise 12.1.0). + ## AudioMuse-AI compatible endpoints Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) @@ -356,8 +375,9 @@ curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in [`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and -real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot -isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with: +real repositories (only artwork, streaming, ffmpeg, external metadata agents and sonic similarity +are stubbed), with per-`Describe` snapshot isolation — mirroring the Subsonic `server/subsonic/e2e` +suite. Run it with: ```bash make test PKG=./server/jellyfin/... @@ -365,42 +385,37 @@ make test PKG=./server/jellyfin/... ## Known limitations -- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's - libraries (genre tags aren't per-library entities in Navidrome's model). -- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each - belong to exactly one library and are checked against `user.HasLibraryAccess` on every - fetch), an artist can have content across multiple libraries via `library_artist`, so there's - no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against. - Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` - *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client - that already has an artist id from elsewhere is not re-checked against library membership. -- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is - populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** - blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a - multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and - stores it per image, so its placeholder approximates the art. Ours satisfies the protocol - (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash - warning) but renders as a flat color while art loads. A proper implementation would compute the - real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it - keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback - for art that hasn't been rendered yet. -- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a - `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a - working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up - would broadcast real session/playstate and library-change events over it (via `server/events`), - mirroring Jellyfin's session messages. -- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in - 100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline - (embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also - caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less - tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade - gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is - just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel - check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per - track so sidecar/plugin lyrics also light up. Feishin additionally requires server version - ≥ 10.9 (`jellyfinVersion` advertises 12.1.0). - Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` - is singleflighted), and the load runs detached from the request context with a one-minute bound, - so a cancelled request or hung plugin can't fail or pin the load for other waiters. - Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in - lists (request-time sources can't be known at list time without per-row I/O). +- **Genre lists ignore `ParentId`.** `GET Genres`/`MusicGenres` and + `Items?IncludeItemTypes=MusicGenre` list the genres of every library the user can access, never + only the `ParentId` library. `Items?IncludeItemTypes=MusicGenre` also ignores `SearchTerm`. + `GET Items/Filters` is the exception: its genres follow `ParentId`. +- **Search skips some filters.** With `SearchTerm`, `Filters=IsFavorite`, `IsPlayed`, `IsUnplayed` + and `isFavorite`/`isPlayed` are skipped, so the response holds every search match. The full-text + search's first phase has no annotation join to filter on. Artist searches also skip the role (album + artist vs. artist) and `GenreIds`. Playlist lists ignore `SearchTerm`. +- **Search pages are capped at 2,000 items.** A larger `Limit` is lowered to 2,000. Jellyfin has no + cap. +- **One-character searches return nothing.** The shared search layer ignores terms shorter than two + characters, so album, artist and song searches for `a` come back empty. Real Jellyfin has no + minimum. +- **Playlist entry ids are positions, not song ids.** `PlaylistItemId` encodes the entry's position + in the playlist, so ids change when entries are inserted or removed; re-read the list after an + edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work; + clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`. +- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating` + (0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends + only `likes` clears the rating. +- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy + `UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback + reporting. +- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are + accepted but never returned. +- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork + pipeline computes and stores. Until that happens for an item, the field is omitted: a fake value + would pin the wrong placeholder in clients that key their image cache on it. +- **The WebSocket only keeps alive; it pushes no events.** `GET socket` sends `ForceKeepAlive` and + answers `KeepAlive` pings, so real-time clients (Finamp) keep a working session instead of + reconnecting in a loop. It never pushes session, playstate or library-change events. +- **List lyric badges only see embedded lyrics.** Tracks with only sidecar or plugin lyrics show no + `HasLyrics` badge in lists, because those sources can't be known at list time without per-row I/O. + `PlaybackInfo` and `Audio/{id}/Lyrics` still find them (see "Lyrics"). diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 124d17920..23e35dd59 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -893,8 +893,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi return dto.AlbumToBaseItem(*al, fields), true } if ar, err := api.ds.Artist(ctx).Get(id); err == nil { - // TODO: an artist spans multiple libraries (library_artist), so there's no single - // LibraryID to gate here; artist access relies on list-time scoping and persistence. + // Artist.Get already scopes to the user's libraries via library_artist. return dto.ArtistToBaseItem(*ar, fields), true } if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { From a0c416037655a2d4476d3e055907540605f5bf87 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 24 Sep 2026 21:40:49 -0400 Subject: [PATCH 2/3] chore: update golangci-lint version to v2.14.0 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 81a609422..eccccbffb 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.13.2 +GOLANGCI_LINT_VERSION ?= v2.14.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") From 3f89baaec8f3285429496d5f0b796165034c70ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 24 Sep 2026 22:07:17 -0400 Subject: [PATCH 3/3] test(server): make the handleM3U spec independent of spec order (#6221) It relied on another spec having set auth.PublicTokenAuth, so it panicked whenever Ginkgo ran it first. --- server/public/handle_shares_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/public/handle_shares_test.go b/server/public/handle_shares_test.go index 1bf631fd4..4de2a75c1 100644 --- a/server/public/handle_shares_test.go +++ b/server/public/handle_shares_test.go @@ -4,7 +4,9 @@ import ( "net/http" "net/http/httptest" + "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -17,6 +19,7 @@ var _ = Describe("handleM3U", func() { var pub *Router BeforeEach(func() { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) ds = &tests.MockDataStore{} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo