Merge branch 'master' into fix/es-translations

This commit is contained in:
Adrián Sánchez Zapico 2026-09-25 08:07:13 +02:00 • committed by GitHub
commit ea7b19d3a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 67 additions and 50 deletions

View file

@ -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/*")

View file

@ -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=<libraryId>`
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").

View file

@ -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 {

View file

@ -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