feat(jellyfin): unknown-route logging, generic 500s, rating clamp, docs

Hardening pass ahead of real client testing: unmatched routes and
unsupported methods now return a logged, JSON 404 instead of chi's
default plain-text response, so a missing endpoint a client needs is
easy to spot in the logs. Internal errors (ffmpeg output, file paths,
etc.) no longer leak into 500 response bodies -- a shared
internalError helper logs the real error server-side and always
returns a generic message. Inbound Jellyfin ratings are clamped to
0-10 before being halved into Navidrome's 0-5 scale, and /System/Ping
now replies with a bare plain-text body as real Jellyfin servers do.
Adds a README with an enable/curl walkthrough and known limitations.
This commit is contained in:
Deluan 2026-07-05 17:33:41 -04:00
commit 106a1e2501
12 changed files with 238 additions and 16 deletions

151
server/jellyfin/README.md Normal file
View file

@ -0,0 +1,151 @@
# 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, 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"
```
or via environment variables:
```bash
ND_JELLYFIN_ENABLED=true
ND_JELLYFIN_SERVERNAME="My Music Server"
```
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`).
## 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` query
param — all three forms are accepted, matching what different clients do).
## 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.
## Implemented endpoints
| Area | Endpoints |
|---|---|
| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `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` |
| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` |
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public, not library-scoped) |
| Favorites / ratings | `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating` |
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET`/`POST Items/{itemId}/PlaybackInfo` |
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` |
| Playlists | `POST Playlists`, `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` |
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.
## 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, add the song, then remove it
PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
-d "{\"Name\":\"My Playlist\",\"Ids\":[\"$SONG_ID\"]}" "$BASE/Playlists" | jq -r .Id)
curl -s -X POST "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?Ids=$SONG_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"
```
## 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.
- **`PlaylistItemId` round-trip is untested against a live client.** `GET
Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the playlist-track row id, not
the song id) specifically 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. This is exercised by unit tests, but hasn't been
confirmed against a real Jellyfin client's actual request shape — worth a manual smoke test
before relying on it.
- **ID handling is pass-through only.** Navidrome ids are used verbatim as Jellyfin item ids;
there's no hex/GUID-reversible id scheme. If a client turns out to mangle ids in a way that
requires one (e.g. expecting a GUID shape), that will need a follow-up change.

View file

@ -49,7 +49,7 @@ func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred b
return
}
if err := repo.SetStar(starred, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: id, ItemId: id})
@ -67,7 +67,7 @@ func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating
return
}
if err := repo.SetRating(rating, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
d := &dto.UserItemDataDto{Key: id, ItemId: id}
@ -79,8 +79,9 @@ func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating
}
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
rating := req.Params(r).IntOr("Rating", 0) / 2 // Jellyfin 0-10 -> Navidrome 0-5
api.setItemRating(w, r, rating)
jfRating := req.Params(r).IntOr("Rating", 0)
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain Navidrome rating
api.setItemRating(w, r, jfRating/2) // Jellyfin 0-10 -> Navidrome 0-5
}
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {

View file

@ -167,5 +167,27 @@ var _ = Describe("Annotations", func() {
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(albumRepo.Data["a1"].Rating).To(Equal(0))
})
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
api.setRating(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
})
It("clamps a negative Rating to Navidrome's min (0)", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
api.setRating(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
})
})
})

View file

@ -101,6 +101,11 @@ func (api *Router) routes() http.Handler {
r.Post("/Sessions/Capabilities/Full", api.postCapabilities)
})
// Logged at Debug (not Warn/Error) because a real client probing for optional/legacy
// endpoints is expected traffic, not a problem; this just surfaces what's missing.
r.NotFound(api.notFound)
r.MethodNotAllowed(api.notFound)
return r
}
@ -111,3 +116,20 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
log.Error(r.Context(), "Jellyfin API: error encoding response", err)
}
}
// notFound handles both unmatched routes and unsupported methods on known routes, so any
// endpoint a real client needs that we haven't implemented shows up in the logs instead of
// silently confusing the client with chi's default plain-text 404/405.
func (api *Router) notFound(w http.ResponseWriter, r *http.Request) {
log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{}`))
}
// internalError logs the real error server-side and writes a generic 500 response, so ffmpeg
// output, file paths or other internal detail in err never reaches the client.
func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) {
log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}

View file

@ -18,4 +18,23 @@ var _ = Describe("Router", func() {
api.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("returns 404 JSON for unknown routes", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
Expect(w.Body.String()).To(Equal("{}"))
})
It("returns 404 JSON for a known path with an unsupported method", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(w.Body.String()).To(Equal("{}"))
})
})

View file

@ -33,7 +33,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
token, err := auth.CreateToken(usr)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
api.internalError(w, r, err)
return
}

View file

@ -20,7 +20,7 @@ func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) {
res, err := api.listArtists(ctx, opts, scopeIDs, p.StringOr("SearchTerm", ""), false)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
api.ok(w, r, res)
@ -34,7 +34,7 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) {
opts := model.QueryOptions{Offset: p.IntOr("StartIndex", 0), Max: p.IntOr("Limit", 0)}
res, err := api.listGenres(ctx, opts)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
api.ok(w, r, res)

View file

@ -23,7 +23,7 @@ var notMissing = squirrel.Eq{"missing": false}
func (api *Router) getItems(w http.ResponseWriter, r *http.Request) {
res, err := api.queryItems(r.Context(), r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
api.ok(w, r, res)
@ -204,7 +204,7 @@ func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx))
albums, err := api.ds.Album(ctx).GetAll(opts)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array

View file

@ -27,7 +27,7 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) {
}
id, err := api.playlists.Create(r.Context(), "", body.Name, body.Ids)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
api.ok(w, r, map[string]string{"Id": id})

View file

@ -84,7 +84,7 @@ func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) {
streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0)
s, err := api.streamer.NewStream(ctx, mf, streamReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.internalError(w, r, err)
return
}
defer s.Close()

View file

@ -63,8 +63,12 @@ func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, api.publicInfo(r.Context()))
}
// ping answers /System/Ping with a bare plain-text server name, not a JSON-quoted string:
// Jellyfin's own server does this, and clients parse the raw body.
func (api *Router) ping(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, api.serverName())
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(api.serverName()))
}
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {

View file

@ -36,15 +36,18 @@ var _ = Describe("System", func() {
Expect(info.ServerName).To(HavePrefix("Navidrome"))
})
It("responds to ping with the server name", func() {
It("responds to ping with the server name as plain text", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ServerName = ""
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Ping", nil)
api.ping(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var name string
Expect(json.Unmarshal(w.Body.Bytes(), &name)).To(Succeed())
Expect(name).ToNot(BeEmpty())
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain"))
// Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name.
Expect(w.Body.String()).To(HavePrefix("Navidrome"))
})
It("reports quick connect as disabled", func() {