mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-12 21:28:37 +01:00
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:
parent
15f5ba16e8
commit
106a1e2501
12 changed files with 238 additions and 16 deletions
151
server/jellyfin/README.md
Normal file
151
server/jellyfin/README.md
Normal 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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("{}"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue