mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-05 17:58:37 +01:00
* fix(jellyfin): honor Filters=IsFavorite on /Artists and /Artists/AlbumArtists listArtistsByRole hand-built its itemsQuery and never set favOnly, so the favorites filter was silently dropped on both artist routes while /Items honored it. Finamp's home screen asks for favorite artists once per load and was served the entire artist list instead: 10,298 artists, 6.15 MB, 2.7s on a real library, and the wrong data on screen. Extract the favOnly parsing that parseItemsQuery already did into parseFavOnly and use it in both places. listArtists now adds the starred predicate to notMissing rather than replacing it, matching listAlbums and listSongs, so a favorite artist whose files are gone stays excluded. * fix(jellyfin): map SortBy=Runtime to duration for albums and songs sortColumnsByType had no runtime/runtimeticks key for any type, so Finamp's "Duration" sort silently misbehaved in two different ways. Albums: Finamp sends a bare SortBy=Runtime. Nothing matched, opts.Sort stayed empty, and applyOptions skips OrderBy entirely when Sort is empty — so the query ran with no ORDER BY at all and Ascending and Descending returned identical lists. Songs: Finamp sends SortBy=Runtime,AlbumArtist,Album,SortName. applySort takes the first *recognized* key, so Runtime was skipped and the list came back sorted by album artist while looking correct. Both repos already accept a duration sort (mediafile_repository maps it explicitly; album_repository falls through to the column name), so no migration is needed. Sorting 97k songs by duration costs a temp B-tree (~114ms on a prod-sized copy) — the same cost the Subsonic and UI duration sorts already pay, and correct where the previous behaviour was merely fast. * fix(jellyfin): apply the played/unplayed filters and MaxHeight image bound Filters was matched with a substring test for IsFavorite, so every other token Jellyfin defines was silently dropped and the response kept rows it should have excluded. Finamp sends Filters=IsUnplayed in normal use. Replace the bool with a parsed itemFilters carrying nullable favorite and played flags, so isFavorite=false and isPlayed=false are real filters rather than indistinguishable from an absent param. Standalone params are read first and the Filters list overrides them, the precedence real Jellyfin has. IsFavoriteOrLikes now maps to favorites deliberately instead of by substring accident; Likes, Dislikes, IsFolder, IsNotFolder and IsResumable have no Navidrome equivalent and are dropped rather than half-applied. The negative cases match NULL as well, since annotations are LEFT JOINed and an untouched item has no row. getItemImage read only maxwidth, so a client sending just MaxHeight got the full-size original: measured against a real cover, maxHeight=100 returned 82,570 bytes where maxWidth=100 returned 3,316. Use the tighter of the two bounds. * refactor(jellyfin): share the plain-param parser between /Items and /Artists listArtistsByRole hand-listed the itemsQuery fields it happened to need, which is exactly how the favorites filter went missing: the literal has been amended in four of the five commits that touched it. Extract listParams for the fields that come straight from query params so both paths read one parser, and the next supported param reaches every list path instead of only /Items. Also from the cleanup pass: collapse imageSize to a single clamped comparison and read its bounds through req.Params like the rest of the package, which drops the strconv import; build the artist and playlist filter lists with the flat append shape the album and song paths already use, instead of re-wrapping opts.Filters into a nested And per predicate; drop a nil guard in listPlaylists that no caller can reach, since both paths into queryItemsOfType build QueryOptions without Filters. applySort now logs when no SortBy key resolves at all — a miss inside a fallback list is normal, but none matching means a silently ignored sort, the failure mode that hid the Runtime bug. Its doc comment records why the remaining keys cannot simply be joined. Folds three duplicated test bodies into the tables that already parameterize them, and covers the artist-parent album branch, which reaches notMissing through filter.AlbumsByArtistID rather than the default branch. * docs(jellyfin): correct how applySort describes Jellyfin's SortBy semantics The comment claimed SortBy is a comma-separated fallback list. It is not: RequestHelpers.GetOrderBy (10.10) builds one (ItemSortBy, SortOrder) pair per key, so Jellyfin orders by every key in turn. Navidrome applies only the first recognized one, which is a real divergence — secondary keys never break ties — not the intended reading of the parameter. The assertion that the keys cannot be joined was also wrong. buildSortOrder does split its input on commas; what it maps is the whole string, so joining raw Jellyfin key names misses the mappings. Mapping each key first and joining the results would work, which makes multi-key sorting a real option rather than a blocked one. Documenting the current behaviour as a known divergence until then. * fix(jellyfin): order by every recognized SortBy key, not just the first Jellyfin orders by each SortBy key in turn, so "DatePlayed,SortName" means break ties by name. Navidrome applied only the first recognized key and dropped the rest, which is 28% of the sort traffic on a real server (23 of 82 requests in 12h carry 2-5 keys). Most were harmless because the primary key dominates, but PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName came back unordered within a year. The keys cannot simply be joined: sortMapping keyed on the whole Sort string, so a joined value missed every mapping and fell through to raw column names. Make it resolve a comma list per part, but only when every part is a known key — the four existing callers that pass raw column lists (core/matcher, core/lyrics, core/maintenance, subsonic/browsing) all carry a part that is not a mapping key, several with their own direction, so they keep falling through exactly as before. Verified each one. applySort now collects every recognized key, skipping duplicates so ParentIndexNumber,IndexNumber does not repeat a column. random stays alone: the repo matches it by exact string equality, so joining it would both break that path and emit a bare 'random' column into the ORDER BY. Verified against a prod-sized copy: every multi-key combination seen in real traffic returns 200, and a secondary key now changes the order within a tied year for songs. Albums are unchanged there, because their max_year mapping already ended in ", name". * fix(persistence): resolve sort mappings exactly once Making sortMapping resolve a comma list per part broke an invariant it had been relying on: idempotence. sanitizeSort mapped the sort key up front and applyOptions then ran buildSortOrder over the result, so sortMapping was already being handed its own output. That was harmless only while a mapped value could never look like a key list. media_file's rated_at maps to "rating, rated_at", and both parts are keys, so the second pass expanded it to "rating, rating, rated_at". Found by round-tripping every mapping in all four repositories; it was the only collision, and the duplicate sort key was benign in SQL, but any future mapping of that shape would silently change meaning. sanitizeSort now validates without resolving, leaving buildSortOrder as the single mapping point. The generated SQL is unchanged — the whole suite passes apart from the two specs that asserted the old return value, which are updated and joined by a round-trip guard covering exactly the rated_at shape. Also use the paren-aware splitFunc that buildSortOrder already uses, so an expression carrying commas inside its parentheses cannot be split apart. * refactor(jellyfin,persistence): flatten the sort resolution paths Cleanup pass over the branch, no behavior change. sortMapping loses the len(parts)>1 guard, which existed only to pick between two identical toSnakeCase exits; the single-key case now falls through the same loop. lookupSortMapping hands back the snake_case form it had to derive so the fallback stops recomputing it — toSnakeCase is two regexps, and on a miss it was running twice per call. sanitizeSort now asks lookupSortMapping instead of probing the map itself, so "is this a known sort key" has one answer; the two had already drifted, since sanitizeSort tried one casing where the resolver tries three. applySort folds the nested random branch into the skip condition and the two trailing length tests into one switch. setSortMappings documents the invariant the comma-list rule depends on, where someone adding a mapping will read it. The README line describing SortBy still said only the first key applied, which the commit before last made false. Tests: the twelve near-identical sorting specs become one DescribeTable of (itemType, SortBy, want) triples, 124 lines to 36, and the applyOptions round-trip assertion collapses to the buildSortOrder call its sibling uses. * fix(jellyfin): keep annotation filters out of search, resolve sorts per part Two findings from the Codex review on #5981. The played/unplayed filters turned working requests into 500s when combined with SearchTerm. Search runs a two-phase FTS query whose first phase selects rowids with no annotation join, so a starred or play_count predicate there is "no such column", not a filter. Measured against master: MusicAlbum with SearchTerm and Filters=IsUnplayed went 200 -> 500, likewise IsPlayed and the Audio equivalents. listAlbums and listSongs now skip those predicates on the search path, matching what listArtists already did. That also clears the same 500 master already had for Filters=IsFavorite with SearchTerm. sortMapping resolved a comma list only while every part was a known key, so a list mixing a plain column with a mapped key kept neither: MusicAlbum SortBy=Runtime,SortName arrives as "duration, name", and duration is a plain album column, so name stayed raw instead of expanding to order_album_name. Albums whose name differs from its sort form — 1,366 of 6,987 on a real library — then ordered by the wrong secondary key, and PreferSortTags was ignored. Each part is now resolved on its own, which is what setSortMappings already documents for a single field. Verified every in-tree caller that passes a raw column list still produces its original ORDER BY. Codex also asked for the artist search path to apply the same filters. It would 500 for the reason above, and wrapping the library scope in a compound filter makes requestedLibraryIDs stop recognizing it, silently widening the search past the requested ParentId. * fix(jellyfin): honor the first SortOrder value for a multi-key sort applySort compared the whole SortOrder string with "Descending", so a per-key list like SortOrder=Descending,Ascending failed the match and every key, including the primary, sorted ascending — the exact opposite of the request. Take the first comma-separated value, which Jellyfin also uses for any key past the end of the SortOrder list. True per-key directions can't be expressed through the single opts.Sort string and are left out; no observed client sends a SortOrder list.
356 lines
22 KiB
Markdown
356 lines
22 KiB
Markdown
# Jellyfin API
|
||
|
||
This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of
|
||
Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets
|
||
Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp),
|
||
[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without
|
||
requiring a real Jellyfin server.
|
||
|
||
It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music
|
||
library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report
|
||
playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's
|
||
admin/dashboard APIs are out of scope.
|
||
|
||
## Enabling
|
||
|
||
The Jellyfin API is disabled by default. Enable it via `navidrome.toml`:
|
||
|
||
```toml
|
||
[Jellyfin]
|
||
Enabled = true
|
||
# Optional: override the server name reported to clients (defaults to "Navidrome <version>")
|
||
ServerName = "My Music Server"
|
||
# Optional: usernames to show in the client login user-picker (default: none). See "Public user list".
|
||
ExposedPublicUsers = "alice, bob"
|
||
# Optional: max collection responses streaming at once (default: half the DB connection pool,
|
||
# min 2). Each streaming response holds a DB connection for its whole duration; excess requests
|
||
# queue rather than fail.
|
||
MaxConcurrentStreams = 4
|
||
```
|
||
|
||
or via environment variables:
|
||
|
||
```bash
|
||
ND_JELLYFIN_ENABLED=true
|
||
ND_JELLYFIN_SERVERNAME="My Music Server"
|
||
ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob"
|
||
```
|
||
|
||
Once enabled, the API is mounted at:
|
||
|
||
```
|
||
http://<host>:<port>/jellyfin
|
||
```
|
||
|
||
All the paths below are relative to that base URL (e.g. `System/Info/Public` means
|
||
`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**,
|
||
since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths.
|
||
|
||
## Authentication
|
||
|
||
Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome
|
||
username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on
|
||
every subsequent request as the `X-Emby-Token` header (or embedded in the
|
||
`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey`
|
||
query param — all forms are accepted, matching what different clients do).
|
||
|
||
`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native
|
||
`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force
|
||
surface.
|
||
|
||
### Public user list (login picker)
|
||
|
||
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the
|
||
password) instead of a blank username field. It's **unauthenticated**, so by default it exposes
|
||
**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise:
|
||
|
||
```toml
|
||
[Jellyfin]
|
||
ExposedPublicUsers = "alice, bob"
|
||
```
|
||
|
||
Only the named users are listed (never the full user table), resolved live per request; a configured
|
||
name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`)
|
||
with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no
|
||
avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images).
|
||
|
||
## Players and sessions
|
||
|
||
Every authenticated request registers (or refreshes) the calling device as a Navidrome player,
|
||
mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling
|
||
has a player) as soon as it makes any authenticated call, not only when it reports playback. The
|
||
player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is
|
||
`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them
|
||
(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no
|
||
client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is
|
||
skipped, so it doesn't create a nameless player.
|
||
|
||
## Multi-library behavior
|
||
|
||
Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each
|
||
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.
|
||
|
||
### Browsing filters
|
||
|
||
`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id
|
||
for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into
|
||
an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or
|
||
tracks — Finamp's artist screen sends these *alongside* `ParentId=<libraryId>`); `AlbumIds` (an
|
||
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`;
|
||
`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
|
||
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
||
unrecognized keys are skipped, and `Random` always sorts alone);
|
||
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
||
returns direct children only (no tracks — no track is a library's direct child).
|
||
|
||
## Implemented endpoints
|
||
|
||
| Area | Endpoints |
|
||
|---|---|
|
||
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` |
|
||
| 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/{itemId}/InstantMix` |
|
||
| 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` |
|
||
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
||
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` |
|
||
| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` |
|
||
| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) |
|
||
| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` |
|
||
|
||
Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level
|
||
as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint
|
||
that isn't in the table above, check the server logs for these lines to see exactly what it's
|
||
requesting.
|
||
|
||
## Playlist management
|
||
|
||
Playlists are the main writable surface of this API:
|
||
|
||
- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`)
|
||
or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album,
|
||
artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before
|
||
the write, matching how Jellyfin clients populate these lists. A bare song id passes through.
|
||
- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients
|
||
spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a
|
||
single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one
|
||
track of an expanded album.
|
||
- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp
|
||
uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an
|
||
omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to
|
||
Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`.
|
||
- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body,
|
||
JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it.
|
||
Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning,
|
||
so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're
|
||
bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins.
|
||
- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the
|
||
playlist-track row id, distinct from the song id) so a client can echo it back via
|
||
`DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more
|
||
than once in the same playlist.
|
||
|
||
Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if
|
||
it is visible to them (public) or `404` if it is not (private) — the API never reveals that
|
||
someone else's private playlist exists.
|
||
|
||
## Images
|
||
|
||
The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive,
|
||
matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is
|
||
therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache
|
||
warmer uses — so user-scoped items like private playlists still resolve their cover instead of
|
||
falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to
|
||
their Navidrome `ArtworkID`.
|
||
|
||
## Item ids are GUIDs
|
||
|
||
Jellyfin item ids are GUIDs, serialized as 32 lowercase hex chars with no dashes
|
||
(`Guid.ToString("N")`). Navidrome ids are canonical 22-char base62 encodings of a 128-bit value,
|
||
so `dto.EncodeID`/`dto.DecodeID` map between the two via `model/id` — losslessly except for the
|
||
~2⁻⁹⁶ chance an id's 128-bit value falls in the reserved space below (leading 12 bytes all zero).
|
||
|
||
Three emitted ids aren't 128-bit values: integer library ids, the synthetic playlists folder, and
|
||
`PlaylistItemId` (a playlist *entry position* — `playlist_tracks.id` is an `integer` column).
|
||
They use a reserved GUID space — 12 zero bytes, a non-zero kind tag, a 24-bit payload — so
|
||
library `1` is `00000000000000000000000001000001`. The tag is never zero, because Jellyfin
|
||
serializes the all-zero GUID as `null`.
|
||
|
||
`DecodeID` accepts dashed and uppercase GUIDs (Jellyfin's `Guid.Parse` does) and returns
|
||
`ok=false` for anything malformed — including "" — which handlers surface as a 404.
|
||
|
||
The wire format must stay GUID-shaped for this reason: Finamp's saved-queue persistence bit-packs
|
||
each item id into exactly 16 bytes (`packIds()` in `lib/models/finamp_models.dart`), so a 32-hex
|
||
GUID round-trips exactly, whereas a longer id would be silently truncated.
|
||
|
||
## Streaming and transcoding
|
||
|
||
The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint:
|
||
|
||
- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the
|
||
`.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`.
|
||
`audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true`
|
||
forces direct play (raw), never a transcode.
|
||
- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin.
|
||
Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g.
|
||
DSF) can't be rescued server-side on this path.
|
||
- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting
|
||
is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive
|
||
transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused.
|
||
Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`,
|
||
`mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic
|
||
transcoded streams.
|
||
- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings →
|
||
Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as
|
||
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.
|
||
|
||
## AudioMuse-AI compatible endpoints
|
||
|
||
Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin)
|
||
— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when
|
||
connected as a Jellyfin client.
|
||
Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no
|
||
external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity`
|
||
plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension.
|
||
|
||
- `GET /AudioMuseAI/info` — returns `{"Version": <navidrome version>, "AvailableEndpoints": [...]}` (200).
|
||
`AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty.
|
||
- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404.
|
||
- `GET /AudioMuseAI/similar_tracks?item_id=<id>&n=10&eliminate_duplicates=true` — 404 when no provider is
|
||
loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match
|
||
or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist.
|
||
- `GET /AudioMuseAI/find_path?start_song_id=<id>&end_song_id=<id>&max_steps=25` — 404 when no provider is
|
||
loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400
|
||
with `start_song_id and end_song_id are required.` when either id is missing.
|
||
|
||
`item_id`/`start_song_id`/`end_song_id` are the GUID-form ids Navidrome hands Jellyfin clients.
|
||
`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features
|
||
(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library
|
||
setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries
|
||
the caller can access, since hops through inaccessible libraries are filtered out of the result.
|
||
|
||
## curl walkthrough
|
||
|
||
This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the
|
||
library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist.
|
||
|
||
```bash
|
||
BASE=http://localhost:4533/jellyfin
|
||
|
||
# 1. Handshake (no auth required)
|
||
curl -s "$BASE/System/Info/Public" | jq .
|
||
|
||
# 2. Login - capture the AccessToken
|
||
TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken)
|
||
|
||
AUTH=(-H "X-Emby-Token: $TOKEN")
|
||
|
||
# 3. List the user's views (one per accessible library)
|
||
curl -s "${AUTH[@]}" "$BASE/UserViews" | jq .
|
||
|
||
# 4. Browse artists
|
||
curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq .
|
||
ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id')
|
||
|
||
# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum)
|
||
ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id')
|
||
|
||
# 6. List the album's songs
|
||
USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id)
|
||
SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \
|
||
| jq -r '.Items[0].Id')
|
||
|
||
# 7. Ask for playback info, then stream the song
|
||
curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq .
|
||
curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio
|
||
|
||
# 8. Favorite the song
|
||
curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq .
|
||
|
||
# 9. Report playback start/stop (also drives scrobbling)
|
||
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||
-d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing"
|
||
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||
-d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped"
|
||
|
||
# 10. Create a playlist from a whole album (the album id is expanded to its tracks)
|
||
PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||
-d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id)
|
||
|
||
# 11. Make it public, then remove one entry
|
||
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||
-d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID"
|
||
ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId')
|
||
curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID"
|
||
|
||
# 12. Delete the playlist
|
||
curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID"
|
||
```
|
||
|
||
## Testing
|
||
|
||
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:
|
||
|
||
```bash
|
||
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 — the reason `jellyfinVersion` is 10.9.11.
|
||
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).
|