mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
Merge branch 'master' into fix/es-translations
This commit is contained in:
commit
ea7b19d3a9
4 changed files with 67 additions and 50 deletions
2
Makefile
2
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/*")
|
||||
|
||||
|
|
|
|||
|
|
@ -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").
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue