* fix(playlist): block track edits on synced playlists across all APIs A synced playlist's tracks come from its source file, so any track edit made through the UI or an API was silently reverted on the next scan. Track mutations funnel through two service guards, checkTracksEditable (incremental edits) and Create (wholesale replace, used by Subsonic createPlaylist and Jellyfin's replace path), which each duplicated the smart-playlist check. Both now consult a shared model.Playlist.TracksEditable() predicate, so the native, Subsonic, and Jellyfin paths are all locked: track edits return ErrNotAuthorized (403, or Subsonic error 50) instead of being accepted and lost. Metadata-only edits (name, comment, public, the sync flag itself) still go through checkWritable and are unaffected. In the UI, a synced playlist's track list becomes read-only, mirroring how smart playlists already behave. * fix(playlist): return 409 Conflict for non-editable playlist track edits The previous commit rejected track edits on smart and synced playlists with ErrNotAuthorized (403). That conflates two different things: a 403 says the caller lacks permission, but a synced or smart playlist's tracks are immutable for everyone, including the owner and admins. It is a property of the resource, not the caller. Introduce ErrPlaylistNotEditable and return it from both track-edit guards. The Native and Jellyfin APIs now map it to 409 Conflict; Subsonic maps it to error 50, the closest code it has (it has no read-only concept). The Native track handlers previously mapped this rejection inconsistently (400 on add, 500 on remove, 403 on reorder) through a new shared writePlaylistError helper. Genuine authorization failures (non-owner, non-admin) still return ErrNotAuthorized. * fix(playlist): surface synced read-only state in picker, Jellyfin, and OpenSubsonic Follow-up to the track-edit lock: the read-only state was enforced but not advertised consistently, so clients still offered edits that the server rejects. - UI: the Add to Playlist picker filtered targets by isWritable only, offering synced playlists that then 409 on add. It now filters with canChangeTracks. - Jellyfin: addToPlaylist/removeFromPlaylist hard-coded every error to 404, so a locked playlist reported "not found" instead of 409. They now return 409 for ErrPlaylistNotEditable while keeping the deliberate anti-probing 404 for every other error (a non-owner never reaches ErrPlaylistNotEditable, so 409 leaks nothing). - OpenSubsonic: buildOSPlaylist marked only smart playlists readonly; owned synced playlists advertised readonly=false. Readonly now also covers !TracksEditable(), matching the existing smart-playlist treatment. * fix(jellyfin): report CanEdit from playlist editability in permission probes getPlaylistUsers and getPlaylistUser returned CanEdit: true unconditionally, so Finamp (which probes this before showing edit controls) offered track editing on synced/smart playlists whose add/remove requests now return 409. Both handlers now fetch the playlist and set CanEdit from TracksEditable(), keeping the deliberate non-owner looseness (CanEdit stays true for a normal playlist a non-owner views) and mapping any lookup error to 404 like the sibling probes. * fix(playlist): check ownership before editability when replacing tracks Create checked TracksEditable() before ownership, so a non-owner replacing another user's public smart/synced playlist (Jellyfin updatePlaylist with a non-empty Ids list) received a 409 read-only conflict instead of a 403 authorization failure. The incremental guards check ownership first via checkWritable; Create now matches that order. Subsonic is unaffected (both errors map to code 50). Owners of their own smart/synced playlists still get the read-only conflict. * fix(jellyfin): return 403 for locked playlists, matching Jellyfin Jellyfin itself refuses edits on its file-backed playlists with Forbid() (403): PlaylistsController gates every mutation on OwnerUserId == caller or a share with CanEdit, and playlists imported from .m3u files satisfy neither. Its CanEdit is an ACL field, not a read-only marker, and Jellyfin core has no server-managed playlist type at all. Our Jellyfin routes exist to imitate that API, so ErrPlaylistNotEditable now maps to 403 there instead of 409. The native API keeps 409 (a resource-state conflict is the accurate REST answer where we define the contract) and Subsonic keeps error 50, its closest code. * chore(playlist): trim comments added by this branch Several comments ran to three or four lines and carried rationale that belongs in the commit history rather than the code: what Jellyfin does with its own file-backed playlists, and restatements of the expressions directly below them. Each block is now one or two lines covering only the non-obvious why. |
||
|---|---|---|
| .. | ||
| dto | ||
| e2e | ||
| annotations.go | ||
| annotations_test.go | ||
| api.go | ||
| api_test.go | ||
| audiomuse.go | ||
| audiomuse_test.go | ||
| auth.go | ||
| auth_test.go | ||
| browsing.go | ||
| browsing_test.go | ||
| images.go | ||
| images_test.go | ||
| items.go | ||
| items_test.go | ||
| jellyfin_suite_test.go | ||
| library.go | ||
| lyrics.go | ||
| lyrics_test.go | ||
| middlewares.go | ||
| middlewares_test.go | ||
| playlists.go | ||
| playlists_test.go | ||
| README.md | ||
| response.go | ||
| response_test.go | ||
| routing_test.go | ||
| sessions.go | ||
| sessions_test.go | ||
| similar.go | ||
| similar_test.go | ||
| socket.go | ||
| socket_test.go | ||
| stream.go | ||
| stream_test.go | ||
| system.go | ||
| system_test.go | ||
| users.go | ||
| users_test.go | ||
Jellyfin API
This package implements a subset of the Jellyfin REST API on top of Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets Jellyfin-compatible clients (e.g. Finamp, 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:
[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:
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:
[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, theIdsmay 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}/Itemsaccept the id list both ways clients spell it: repeated params (ids=X&ids=Y, how Jellify's@jellyfin/sdkserializes 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}): withIdspresent, the track list is replaced (Finamp uses this for reordering) — an explicit emptyIds([]) clears the playlist, while an omittedIdsleaves the tracks untouched and only updatesName/IsPublic.IsPublicmaps to Navidrome'sPublicflag, surfaced to clients asOpenAccessonGET Playlists/{id}. - Cover art:
POST Items/{id}/Images/Primaryuploads a playlist cover (raw or base64 body, JPEG/PNG/WebP/GIF detected by magic number, extension fromContent-Type);DELETEremoves it. Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, so a non-playlist id returns501. Uploads honor the same gates as the native endpoint: they're bounded byMaxImageUploadSizeand requireEnableArtworkUploadfor non-admins. PlaylistItemId:GET Playlists/{id}/Itemstags each entry withPlaylistItemId(the playlist-track row id, distinct from the song id) so a client can echo it back viaDELETE 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, thecontainerparam, or (when neither is present)audioCodec.audioBitRate/maxStreamingBitrateare bits/sec, per Jellyfin convention.static=trueforces direct play (raw), never a transcode.GET Items/{id}/File/Download— always the original file bytes, matching real Jellyfin. Finamp plays throughFilewhen 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 honorsaudioCodecbut is limited to what HLS packed-audio can carry (aac,mp3); anything else falls back toaac. 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,universalandmain.m3u8— same override semantics as Subsonic.File/Downloadstay raw. For HLS clients, forceaacormp3; 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
— e.g. Symfonium 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).AvailableEndpointslists 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 noitem_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 withstart_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.
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/ 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:
make test PKG=./server/jellyfin/...
Known limitations
- Genres are global.
GET Genres/MusicGenresis 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.HasLibraryAccesson every fetch), an artist can have content across multiple libraries vialibrary_artist, so there's no single library id to gate a directGET Items/{artistId}or favorite/rating call against. Access control for artists is enforced by scoping theArtists/Items?IncludeItemTypes=MusicArtistlist 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).
ImageBlurHashesis populated bydto/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 thecore/artworkpipeline (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 socketsends aForceKeepAliveand answersKeepAlivepings 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 (viaserver/events), mirroring Jellyfin's session messages. - Lyrics.
GET Audio/{id}/Lyricsserves the main lyric track as aLyricDto(Startin 100ns ticks, word-levelCueswhen present), resolved through the fullcore/lyricspipeline (embedded,.lrcsidecars, plugins perLyricsPriority) 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 aLyricMediaStream(notHasLyrics, which is just a list badge): browse lists advertise it from embedded lyrics only (the"[]"sentinel check — the column is never""post-scan), whilePlaybackInforuns the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version ≥ 10.9 — the reasonjellyfinVersionis 10.9.11. Concurrent misses on the same track share one pipeline invocation (SimpleCache.GetWithLoaderis 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 noHasLyricsbadge in lists (request-time sources can't be known at list time without per-row I/O).