refactor(jellyfin): emit real 128-bit GUIDs as item ids (#5942)

* test(jellyfin): use canonical ids in dto fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.

* test(jellyfin): use canonical ids in handler fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions
stay decimal, matching the integer playlist_tracks.id column.

* test(jellyfin): use canonical ids in e2e fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.

* test(jellyfin): use canonical ids in audiomuse fixtures

audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than
via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as
the rest of the fixtures: fake labels through id.NewHash via testID.

* test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests

Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not
exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from
"resolves to nothing" to "empty path segment".

* refactor(jellyfin): emit real 128-bit GUIDs as item ids

Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map
losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing
44 hex chars where Jellyfin uses 32.

Integer library ids, the synthetic playlists folder, and playlist entry positions (a
playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space
tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of
passing through unchanged.

BREAKING: Jellyfin clients see entirely new item ids.

* fix(jellyfin): 404 malformed playlist ids instead of silently creating

updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create
uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A
malformed id now 404s before reaching Create.

Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which
now decodes to "" and only passed because the fakes ignore the id argument; and a batch of
not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path
instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space.

* refactor(jellyfin): drop the id truncation workaround

Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only
because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the
server-side prefix recovery is dead code.

Removes an indexed range scan per restored queue and the ambiguous-prefix path that could
resolve to the wrong item.

* fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format

Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A
ServerId persisted before this change is normalized on read rather than rewritten.

PlaySessionId was emitting a raw internal id instead of the encoded form.

BREAKING: the ServerId change makes clients treat the server as new, so users re-login once.

* fix(jellyfin): 404 on undecodable id filters instead of widening the query

DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream
an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter
silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead
of a scoped result. Every existing client hits this on first launch after the id format changes.

Scalar id params now distinguish the two cases and report not-found. List-valued params already
failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not
happen post-migration and would otherwise ship an unaddressable item silently.

* test(jellyfin): drop comments that restate the spec names

* refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings

DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the
hex string and parsing it a second time. Reading them off the byte slice matches how the format is
specified and removes the duplicate parse.

Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only
EncodePlaylistEntryID enforced before.

Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was
being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a
playlist_tracks position.

Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit
once per item per request on exactly the bad-data population it was meant to surface.

* refactor(jellyfin): make DecodeID report whether the id was decodable

DecodeID returned the empty string for both an absent param and an undecodable one, and
downstream an empty id means no filter. That conflation is what let a stale id widen /Items to
the whole library; it had been patched at two call sites, leaving three different policies for an
undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail.

Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the
~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through
DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its
len() > 0 filter gate vanish — the original bug by another route.

A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed
ids now also 404 on the image and similar/instant-mix routes, which previously answered with a
placeholder or an empty list.
This commit is contained in:
Deluan Quintão 2026-08-12 19:02:34 -04:00 • committed by GitHub
commit c66ef04dd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1548 additions and 940 deletions

View file

@ -85,15 +85,6 @@ player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the p
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.
## ID encoding
Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id
is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients
parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`,
which chokes on Navidrome's base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can
itself be valid hex, correctness depends on every emit path encoding and every receive path
decoding — see `dto/ids.go`.
## Multi-library behavior
Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each
@ -180,26 +171,25 @@ warmer uses — so user-scoped items like private playlists still resolve their
falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to
their Navidrome `ArtworkID`.
## Finamp saved-queue id truncation
## Item ids are GUIDs
Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that
when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16
bytes. Navidrome ids are 22-character base62 strings, not 32-hex GUIDs, which means Finamp silently
stores only the first 16 characters of each id and asks for those **truncated ids** back when
restoring the queue — item lookups, then streaming, images, favorites and playback reports for the
restored tracks.
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).
This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome
id uses — is resolved to the full id by unique-prefix lookup (an indexed range scan;
ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as
requested**, because Finamp matches restored items back to its stored ids, and the other item
endpoints accept truncated ids transparently.
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`.
**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`)
should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the
queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids,
so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer
can be removed.
`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
@ -242,7 +232,7 @@ plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension.
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 hex-encoded ids Navidrome hands Jellyfin clients.
`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

View file

@ -5,7 +5,6 @@ import (
"math"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
@ -49,7 +48,10 @@ func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id s
// fetches this per item to render played/favourite indicators; resolveItemByID enforces the
// library-access gate.
func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
item, ok := api.resolveItemByID(r.Context(), id, nil)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
@ -64,7 +66,10 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
}
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return
@ -84,7 +89,10 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
}
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return

View file

@ -21,7 +21,7 @@ var _ = Describe("Annotations", func() {
var broker *fakeEventBroker
// alice has access to library 1 only.
ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
}
BeforeEach(func() {
@ -33,98 +33,98 @@ var _ = Describe("Annotations", func() {
Describe("markFavorite / unmarkFavorite", func() {
It("stars a song and returns IsFavorite=true", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeTrue())
Expect(mfRepo.Data["s1"].Starred).To(BeTrue())
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
})
It("stars an album and returns IsFavorite=true", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeTrue())
Expect(albumRepo.Data["a1"].Starred).To(BeTrue())
Expect(albumRepo.Data[testID("a1")].Starred).To(BeTrue())
})
It("stars an artist without checking library access (artists span multiple libraries)", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
// alice only has access to library 1, but artists aren't gated per-library.
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "ar1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("ar1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeTrue())
Expect(artistRepo.Data["ar1"].Starred).To(BeTrue())
Expect(artistRepo.Data[testID("ar1")].Starred).To(BeTrue())
})
It("stars a visible playlist", func() {
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}})
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(playlistRepo.Starred["p1"]).To(BeTrue())
Expect(playlistRepo.Starred[testID("p1")]).To(BeTrue())
})
It("unstars a song and returns IsFavorite=false", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.unmarkFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeFalse())
Expect(mfRepo.Data["s1"].Starred).To(BeFalse())
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
})
It("returns 404 and does not star an album in a library the user can't access", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(albumRepo.Data["a1"].Starred).To(BeFalse())
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
})
It("returns 404 and does not star a song in a library the user can't access", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(mfRepo.Data["s1"].Starred).To(BeFalse())
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
})
It("returns 404 when the id doesn't match any entity", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
@ -133,39 +133,39 @@ var _ = Describe("Annotations", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true)
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "x1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("emits a refreshResource event when starring a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
})
It("emits a refreshResource event when starring an album", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`))
})
It("does not emit an event when the item is not accessible", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(broker.Events).To(BeEmpty())
@ -175,13 +175,13 @@ var _ = Describe("Annotations", func() {
Describe("setRating / removeRating", func() {
It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(4))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.Rating).NotTo(BeNil())
@ -190,35 +190,35 @@ var _ = Describe("Annotations", func() {
It("rates an album", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Data["a1"].Rating).To(Equal(5))
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(5))
})
It("rates a visible playlist", func() {
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}})
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(playlistRepo.Ratings["p1"]).To(Equal(4))
Expect(playlistRepo.Ratings[testID("p1")]).To(Equal(4))
})
It("removes a rating", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.removeRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.Rating).To(BeNil())
@ -226,79 +226,79 @@ var _ = Describe("Annotations", func() {
It("returns 404 and does not rate an album in a library the user can't access", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(albumRepo.Data["a1"].Rating).To(Equal(0))
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
})
It("rounds an odd rating to the nearest star instead of truncating", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
})
It("stores the minimum star for Rating=1 instead of clearing the rating", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(1))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
})
It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(4))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
})
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}})
mfRepo.SetData(model.MediaFiles{{ID: testID("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")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
Expect(mfRepo.Data[testID("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}})
mfRepo.SetData(model.MediaFiles{{ID: testID("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")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
})
It("emits a refreshResource event when rating a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
})
})
})

View file

@ -48,8 +48,8 @@ var _ = Describe("Router", func() {
ds := &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"})
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"})
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}

View file

@ -62,12 +62,13 @@ func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request
tracks := []audioMuseSimilarTrack{}
itemID := p.StringOr("item_id", "")
if itemID == "" {
id, ok := dto.DecodeID(itemID)
if !ok {
// Every other failure here (no provider, no match) degrades to an empty list rather than an
// error, so a malformed id does too instead of being the one path that 404s.
api.ok(w, r, tracks)
return
}
id := api.resolveItemID(ctx, dto.DecodeID(itemID))
n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit
eliminateDuplicates := p.BoolOr("eliminate_duplicates", true)
@ -129,11 +130,16 @@ func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) {
}
resp := audioMusePathResponse{Path: []audioMusePathTrack{}}
startDecoded, startOk := dto.DecodeID(startID)
endDecoded, endOk := dto.DecodeID(endID)
if !startOk || !endOk {
// This endpoint already 400s when an endpoint id is absent, so an unusable one reports the
// same way rather than looking like a successful search that found no path.
http.Error(w, "start_song_id and end_song_id must be valid item ids.", http.StatusBadRequest)
return
}
maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count
matches, err := api.sonic.FindSonicPath(ctx,
api.resolveItemID(ctx, dto.DecodeID(startID)),
api.resolveItemID(ctx, dto.DecodeID(endID)),
maxSteps)
matches, err := api.sonic.FindSonicPath(ctx, startDecoded, endDecoded, maxSteps)
if err != nil {
api.ok(w, r, resp)
return

View file

@ -118,28 +118,28 @@ var _ = Describe("AudioMuse similar_tracks", func() {
It("maps matches, decodes the seed id, encodes item ids, copies distance", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5},
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotID).To(Equal("seed"))
Expect(fake.gotID).To(Equal(testID("seed")))
Expect(fake.gotCount).To(Equal(5))
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(2))
Expect(body[0]).To(Equal(audioMuseSimilarTrack{
Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1",
Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("mf1")), Title: "T1",
}))
})
It("collapses to one track per artist when eliminate_duplicates defaults on", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5},
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(1))
@ -147,18 +147,18 @@ var _ = Describe("AudioMuse similar_tracks", func() {
It("keeps same-artist tracks when eliminate_duplicates=false", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5},
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed"))+"&eliminate_duplicates=false", model.User{IsAdmin: true})
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(2))
})
It("filters out tracks in libraries the user cannot access", func() {
fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}})
fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}})
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
@ -169,16 +169,25 @@ var _ = Describe("AudioMuse similar_tracks", func() {
Expect(fake.gotID).To(Equal(""))
})
// A malformed item_id degrades the same way as a missing one (200, empty array) rather than
// 404ing: every other failure on this endpoint already degrades gracefully.
It("returns an empty array without calling the engine when item_id is malformed", func() {
w := call("item_id=not-a-valid-id", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
Expect(fake.gotID).To(Equal(""))
})
It("returns 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
It("returns an empty array when the engine errors", func() {
fake.similarErr = errors.New("boom")
fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true})
fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
@ -198,53 +207,60 @@ var _ = Describe("AudioMuse find_path", func() {
})
It("returns 400 with the exact message when start_song_id is missing", func() {
w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true})
w := call("end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(400))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required."))
})
It("returns 400 when end_song_id is missing", func() {
w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true})
w := call("start_song_id="+dto.EncodeID(testID("s")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(400))
})
It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() {
bpm := 120
withBPM := mf("mf1", "A", "T1", 1)
withBPM := mf(testID("mf1"), "A", "T1", 1)
withBPM.BPM = &bpm
fake.path = []sonic.SimilarMatch{
{MediaFile: withBPM, Similarity: 1.5},
{MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0},
{MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 2.0},
}
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true})
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e"))+"&max_steps=10", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotStart).To(Equal("s"))
Expect(fake.gotEnd).To(Equal("e"))
Expect(fake.gotStart).To(Equal(testID("s")))
Expect(fake.gotEnd).To(Equal(testID("e")))
Expect(fake.gotCount).To(Equal(10))
var body audioMusePathResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Path).To(HaveLen(2))
Expect(body.TotalDistance).To(Equal(3.5))
Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1")))
Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID(testID("mf1"))))
Expect(*body.Path[0].Tempo).To(Equal(120.0))
Expect(body.Path[1].Tempo).To(BeNil())
})
It("returns 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true})
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
It("returns an empty path object when the engine errors", func() {
fake.pathErr = errors.New("boom")
fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}}
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true})
fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}}
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
var body audioMusePathResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Path).To(BeEmpty())
Expect(body.TotalDistance).To(Equal(0.0))
})
It("400s without calling the engine when an id is malformed", func() {
w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(fake.gotStart).To(Equal(""))
Expect(fake.gotEnd).To(Equal(""))
})
})

View file

@ -22,7 +22,7 @@ var _ = Describe("AuthenticateByName", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
api = &Router{ds: ds}
})
@ -68,7 +68,7 @@ var _ = Describe("AuthenticateByName", func() {
It("reflects an administrator in the User.Policy", func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",
@ -92,7 +92,7 @@ var _ = Describe("AuthenticateByName", func() {
It("rejects an empty password even for a user with an empty stored password with 401", func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",

View file

@ -28,12 +28,21 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol
opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)}
applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", ""))
scopeIDs, _ := parentIDScope(ctx, r)
scopeIDs, _, ok := parentIDScope(ctx, r)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
genreIds, ok := decodedQueryIDs(r, "genreids")
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false.
// Finamp's artist tab sends GenreIds when a genre filter is active.
q := itemsQuery{
scopeIDs: scopeIDs,
genreIds: decodedQueryIDs(r, "genreids"),
genreIds: genreIds,
search: searchTerm(p),
fields: dto.ParseFields(p.Strings("fields")...),
}
@ -67,7 +76,11 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) {
func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
scope, _ := parentIDScope(ctx, r)
scope, _, ok := parentIDScope(ctx, r)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)}
labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts)
if err != nil {
@ -83,7 +96,11 @@ func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) {
// library when accessible. Tags/OfficialRatings have no music source, so they are always empty.
func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
scope, _ := parentIDScope(ctx, r)
scope, _, ok := parentIDScope(ctx, r)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)}
genres, err := api.ds.Genre(ctx).GetAll(genreOpts)
if err != nil {

View file

@ -18,12 +18,12 @@ var _ = Describe("Browsing", func() {
var api *Router
var ds *tests.MockDataStore
ctxUser := func(libs model.Libraries) context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
}
// admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership.
ctxAdmin := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil})
return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil})
}
BeforeEach(func() {
@ -33,7 +33,7 @@ var _ = Describe("Browsing", func() {
Describe("getArtists", func() {
It("lists artists via /Artists", func() {
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}})
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -45,7 +45,7 @@ var _ = Describe("Browsing", func() {
})
It("handles /Artists/AlbumArtists the same way", func() {
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}})
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -57,7 +57,7 @@ var _ = Describe("Browsing", func() {
It("scopes results to the user's accessible libraries", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs))
@ -71,10 +71,10 @@ var _ = Describe("Browsing", func() {
It("scopes to a single library when ParentId is an accessible library id", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs))
r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(2), nil).WithContext(ctxUser(libs))
invoke(api.getArtists, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := artistRepo.Options.Filters.ToSql()
@ -86,10 +86,10 @@ var _ = Describe("Browsing", func() {
It("does not let ParentId=<inaccessible library id> narrow the scope", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}} // no access to library 99
r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs))
r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(99), nil).WithContext(ctxUser(libs))
invoke(api.getArtists, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := artistRepo.Options.Filters.ToSql()
@ -101,7 +101,7 @@ var _ = Describe("Browsing", func() {
It("forwards SearchTerm to the repo's Search method", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -113,7 +113,7 @@ var _ = Describe("Browsing", func() {
It("bounds a search the client left unbounded, and clamps an oversized one", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
@ -130,7 +130,7 @@ var _ = Describe("Browsing", func() {
It("forwards StartIndex/Limit as Offset/Max", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -141,7 +141,7 @@ var _ = Describe("Browsing", func() {
It("does not restrict results for an admin user", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin())
invoke(api.getArtists, w, r)
@ -155,6 +155,15 @@ var _ = Describe("Browsing", func() {
Expect(err).NotTo(HaveOccurred())
Expect(sql).NotTo(ContainSubstring("library_artist.library_id"))
})
It("404s a malformed ParentId instead of listing every library's artists", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("getGenres", func() {
@ -199,6 +208,13 @@ var _ = Describe("Browsing", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(tagRepo.Options.Filters).To(BeNil())
})
It("404s a malformed ParentId instead of listing every library's studios", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Studios?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getStudios, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("getQueryFiltersLegacy", func() {
@ -222,5 +238,12 @@ var _ = Describe("Browsing", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(genreRepo.Options.Filters).To(BeNil())
})
It("404s a malformed ParentId instead of listing every library's filters", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/Filters?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getQueryFiltersLegacy, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})

View file

@ -4,6 +4,7 @@ import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -15,3 +16,7 @@ func TestDto(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Jellyfin DTO Suite")
}
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
// id shape production uses.
func testID(label string) string { return id.NewHash("jellyfin-test", label) }

View file

@ -1,23 +1,143 @@
package dto
import "encoding/hex"
import (
"bytes"
"encoding/hex"
"strconv"
"strings"
// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g.
// Finamp's queue packing) and crash on Navidrome's base62 ids if emitted as-is.
func EncodeID(id string) string {
if id == "" {
"github.com/navidrome/navidrome/model/id"
)
// guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes
// (what Guid.ToString("N") produces).
const guidLen = 32
// Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag,
// then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null.
const (
kindIdx = 12
maxPayload = 1<<24 - 1
)
const (
kindLibrary byte = iota + 1
kindPlaylistsFolder
kindPlaylistEntry
)
var zeroPrefix [kindIdx]byte
// PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be
// mistaken for a real id, which is always 22-char base62.
const PlaylistsFolderID = "playlists"
// PlaylistsFolderGUID is the wire form of PlaylistsFolderID.
var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0)
// EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes
// to "" rather than to a shape clients can't parse.
func EncodeID(ndID string) string {
b, err := id.Decode(ndID)
if err != nil {
return ""
}
return hex.EncodeToString([]byte(id))
return hex.EncodeToString(b)
}
// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id.
func DecodeID(id string) string {
if id == "" {
// EncodeLibraryID renders a library's integer id in the reserved GUID space.
func EncodeLibraryID(libID int) string {
return encodeReserved(kindLibrary, libID)
}
// EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer
// column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song.
func EncodePlaylistEntryID(entryID string) string {
n, err := strconv.Atoi(entryID)
if err != nil {
return ""
}
if b, err := hex.DecodeString(id); err == nil && len(b) > 0 {
return string(b)
}
return id
return encodeReserved(kindPlaylistEntry, n)
}
func encodeReserved(kind byte, payload int) string {
if payload < 0 || payload > maxPayload {
return ""
}
var b [16]byte
b[kindIdx] = kind
b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload)
return hex.EncodeToString(b[:])
}
// DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id,
// a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed
// GUID — including the empty string — so an undecodable id can't reach a caller as "no filter".
// Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries
// decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an
// entity id.
func DecodeID(guid string) (string, bool) {
b, ok := decodeGUID(guid)
if !ok {
return "", false
}
kind, payload, reserved := reservedFields(b)
if !reserved {
return id.Encode(b), true
}
switch kind {
case kindLibrary:
return strconv.Itoa(payload), true
case kindPlaylistsFolder:
return PlaylistsFolderID, true
}
return "", false
}
// DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a
// caller can't mistake "every entry failed" for "no filter" (see DecodeID).
func DecodeIDs(guids []string) ([]string, bool) {
out := make([]string, len(guids))
for i, guid := range guids {
decoded, ok := DecodeID(guid)
if !ok {
return nil, false
}
out[i] = decoded
}
return out, true
}
// DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind,
// so an entity id can't be taken for a playlist_tracks row.
func DecodePlaylistEntryID(guid string) (string, bool) {
b, ok := decodeGUID(guid)
if !ok {
return "", false
}
if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry {
return strconv.Itoa(payload), true
}
return "", false
}
func decodeGUID(guid string) ([16]byte, bool) {
guid = strings.ToLower(strings.ReplaceAll(guid, "-", ""))
if len(guid) != guidLen {
return [16]byte{}, false
}
bs, err := hex.DecodeString(guid)
if err != nil {
return [16]byte{}, false
}
return [16]byte(bs), true
}
// reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the
// entity GUIDs that make up almost all traffic.
func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) {
if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) {
return 0, 0, false
}
return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true
}

View file

@ -1,35 +1,161 @@
package dto
import (
"encoding/hex"
"strconv"
"strings"
"github.com/navidrome/navidrome/model/id"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("id codec", func() {
It("round-trips a base62 id", func() {
id := "5QFKvMsJrd57QE2Le2dKKo"
Expect(DecodeID(EncodeID(id))).To(Equal(id))
Describe("canonical ids", func() {
It("round-trips a random id", func() {
ndID := id.NewRandom()
decoded, ok := DecodeID(EncodeID(ndID))
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
It("round-trips a hash id", func() {
ndID := id.NewHash("artist", "Weird Al")
decoded, ok := DecodeID(EncodeID(ndID))
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
It("emits exactly 32 lowercase hex chars", func() {
for range 20 {
Expect(EncodeID(id.NewRandom())).To(MatchRegexp("^[0-9a-f]{32}$"))
}
})
It("accepts a dashed GUID, matching Jellyfin's Guid.Parse", func() {
ndID := id.NewRandom()
guid := EncodeID(ndID)
dashed := guid[0:8] + "-" + guid[8:12] + "-" + guid[12:16] + "-" + guid[16:20] + "-" + guid[20:32]
decoded, ok := DecodeID(dashed)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
It("accepts an uppercase GUID", func() {
ndID := id.NewRandom()
decoded, ok := DecodeID(strings.ToUpper(EncodeID(ndID)))
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
})
It("passes a raw (non-hex) id through DecodeID unchanged", func() {
Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo"))
Describe("reserved space", func() {
DescribeTable("round-trips library ids",
func(libID int, guid string) {
Expect(EncodeLibraryID(libID)).To(Equal(guid))
decoded, ok := DecodeID(guid)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(strconv.Itoa(libID)))
},
Entry("first library", 1, "00000000000000000000000001000001"),
Entry("double digit", 42, "0000000000000000000000000100002a"),
Entry("max payload", 0xffffff, "00000000000000000000000001ffffff"),
)
It("round-trips the playlists folder", func() {
Expect(PlaylistsFolderGUID).To(Equal("00000000000000000000000002000000"))
decoded, ok := DecodeID(PlaylistsFolderGUID)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(PlaylistsFolderID))
})
DescribeTable("round-trips playlist entry positions",
func(entryID, guid string) {
Expect(EncodePlaylistEntryID(entryID)).To(Equal(guid))
decoded, ok := DecodePlaylistEntryID(guid)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(entryID))
},
Entry("first entry", "1", "00000000000000000000000003000001"),
Entry("later entry", "300", "0000000000000000000000000300012c"),
)
It("rejects a non-integer playlist entry id", func() {
Expect(EncodePlaylistEntryID("s1")).To(Equal(""))
Expect(EncodePlaylistEntryID("")).To(Equal(""))
Expect(EncodePlaylistEntryID("-1")).To(Equal(""))
})
It("rejects a payload wider than the reserved 24 bits", func() {
Expect(EncodeLibraryID(1 << 24)).To(Equal(""))
Expect(EncodeLibraryID(-1)).To(Equal(""))
Expect(EncodePlaylistEntryID("16777216")).To(Equal(""))
})
It("keeps library and playlist-entry GUIDs distinct for the same number", func() {
Expect(EncodeLibraryID(3)).ToNot(Equal(EncodePlaylistEntryID("3")))
})
It("does not let one reserved kind decode as another", func() {
_, ok := DecodeID(EncodePlaylistEntryID("3"))
Expect(ok).To(BeFalse())
_, ok = DecodePlaylistEntryID(EncodeLibraryID(3))
Expect(ok).To(BeFalse())
_, ok = DecodePlaylistEntryID(PlaylistsFolderGUID)
Expect(ok).To(BeFalse())
})
It("rejects an unknown kind tag", func() {
_, ok := DecodeID("000000000000000000000000ff000000")
Expect(ok).To(BeFalse())
})
It("never emits the all-zero GUID, which Jellyfin serializes as null", func() {
Expect(EncodeLibraryID(0)).ToNot(Equal("00000000000000000000000000000000"))
Expect(PlaylistsFolderGUID).ToNot(Equal("00000000000000000000000000000000"))
})
})
It("produces valid lowercase hex", func() {
encoded := EncodeID("song-1")
Expect(encoded).To(MatchRegexp("^[0-9a-f]+$"))
Expect(encoded).To(HaveLen(len("song-1") * 2))
Describe("malformed input", func() {
DescribeTable("DecodeID reports ok=false",
func(input string) {
decoded, ok := DecodeID(input)
Expect(ok).To(BeFalse())
Expect(decoded).To(BeEmpty())
},
Entry("empty", ""),
Entry("too short", "abc123"),
Entry("too long", "000000000000000000000000010000011"),
Entry("non-hex chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"),
Entry("a raw base62 id", id.NewRandom()),
Entry("the old 44-char hex format", hex.EncodeToString([]byte("5QFKvMsJrd57QE2Le2dKKo"))),
)
It("encodes a non-canonical id to the empty string", func() {
Expect(EncodeID("")).To(Equal(""))
Expect(EncodeID("playlists")).To(Equal(""))
Expect(EncodeID("42")).To(Equal(""))
})
})
It("round-trips the empty string", func() {
Expect(EncodeID("")).To(Equal(""))
Expect(DecodeID("")).To(Equal(""))
})
Describe("DecodeIDs", func() {
It("decodes every entry when all are well-formed", func() {
a, b := id.NewRandom(), id.NewRandom()
decoded, ok := DecodeIDs([]string{EncodeID(a), EncodeID(b)})
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal([]string{a, b}))
})
It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() {
// "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex
// string apart from one we encoded. Callers must always encode ids on emission and
// decode them on receipt so this ambiguity never surfaces in practice.
Expect(DecodeID(EncodeID("a1"))).To(Equal("a1"))
It("is all-or-nothing: one malformed entry fails the whole list", func() {
decoded, ok := DecodeIDs([]string{EncodeID(id.NewRandom()), "not-a-guid"})
Expect(ok).To(BeFalse())
Expect(decoded).To(BeNil())
})
It("succeeds on an empty list", func() {
decoded, ok := DecodeIDs(nil)
Expect(ok).To(BeTrue())
Expect(decoded).To(BeEmpty())
})
})
})

View file

@ -138,6 +138,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a
// plain list response); a nil fields set means neither.
func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
albumID := EncodeID(mf.AlbumID)
item := BaseItemDto{
Name: mf.Title,
Id: EncodeID(mf.ID),
@ -146,9 +147,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
IsFolder: false,
LocationType: "FileSystem",
HasLyrics: mf.HasEmbeddedLyrics(),
ParentId: EncodeID(mf.AlbumID),
ParentId: albumID,
Album: mf.Album,
AlbumId: EncodeID(mf.AlbumID),
AlbumId: albumID,
AlbumArtist: mf.AlbumArtist,
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),

View file

@ -14,10 +14,10 @@ import (
var _ = Describe("mappers", func() {
It("maps a song to an Audio BaseItemDto", func() {
mf := model.MediaFile{
ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1",
ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}},
Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
}
mf.PlayCount = 2
mf.Starred = true
@ -26,24 +26,24 @@ var _ = Describe("mappers", func() {
Expect(item.MediaType).To(Equal("Audio"))
Expect(item.IsFolder).To(BeFalse())
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Id).To(Equal(EncodeID("song-1")))
Expect(item.AlbumId).To(Equal(EncodeID("alb-1")))
Expect(item.ParentId).To(Equal(EncodeID("alb-1")))
Expect(item.Id).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.RunTimeTicks).To(Equal(int64(600_000_000)))
Expect(*item.IndexNumber).To(Equal(3))
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(item.UserData.Played).To(BeTrue())
Expect(item.UserData.Key).To(Equal(EncodeID("song-1")))
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1"))
Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1"))))
Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
Describe("Fields gating (matches real Jellyfin)", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources when Fields does not ask for them", func() {
@ -59,9 +59,9 @@ var _ = Describe("mappers", func() {
// SortName must match the server sort order — see the sortName helper.
Describe("SortName", func() {
song := model.MediaFile{ID: "s1", Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"}
ar := model.Artist{ID: "art-1", Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"}
al := model.Album{ID: "alb-1", Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"}
song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"}
ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"}
al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -91,85 +91,85 @@ var _ = Describe("mappers", func() {
})
It("falls back to the order name when there is no sort tag", func() {
Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "The X", OrderArtistName: "x"},
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"},
ParseFields("SortName")).SortName).To(Equal("x"))
})
})
It("falls back to the display name when order name and sort tag are empty", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s", Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T"))
Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N"))
Expect(AlbumToBaseItem(model.Album{ID: "al", Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A"))
Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T"))
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N"))
Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A"))
})
})
It("sets HasLyrics from the media file's lyrics", func() {
Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
// "[]" is the no-lyrics sentinel, not a truthy value.
Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
It("omits ImageBlurHashes when a song has no album", func() {
mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60}
mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60}
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("sets DateCreated from the media file's CreatedAt", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty())
})
It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() {
mf := model.MediaFile{
ID: "s1", Title: "Song",
Artist: "The Band", ArtistID: "ar-1",
AlbumArtist: "Various", AlbumArtistID: "ar-2",
ID: testID("s1"), Title: "Song",
Artist: "The Band", ArtistID: testID("ar-1"),
AlbumArtist: "Various", AlbumArtistID: testID("ar-2"),
}
item := SongToBaseItem(mf, nil)
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}}))
})
It("omits ArtistItems when the track has no artist id", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
})
It("omits Artists when the track has no artist name or participants", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: "s1", Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul",
AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul",
ID: testID("s1"), Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"),
AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"),
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}},
{Artist: model.Artist{ID: "ar-redman", Name: "Redman"}},
{Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}},
{Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}},
},
}
item := SongToBaseItem(mf, nil)
Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{
{Name: "De La Soul", Id: EncodeID("ar-delasoul")},
{Name: "Redman", Id: EncodeID("ar-redman")},
{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))},
{Name: "Redman", Id: EncodeID(testID("ar-redman"))},
}))
// AlbumArtists stays single, matching real Jellyfin.
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}}))
})
It("serializes normalization gains with Jellyfin's exact key casing", func() {
mf := model.MediaFile{ID: "s1", Title: "Song",
mf := model.MediaFile{ID: testID("s1"), Title: "Song",
RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)}
b, err := json.Marshal(SongToBaseItem(mf, nil))
Expect(err).ToNot(HaveOccurred())
@ -178,16 +178,16 @@ var _ = Describe("mappers", func() {
})
It("omits normalization gains when the file has no ReplayGain tags", func() {
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil))
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil))
Expect(err).ToNot(HaveOccurred())
// Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain).
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
It("builds a MediaSourceInfo from a media file", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
Expect(src.Id).To(Equal(EncodeID("s1")))
Expect(src.Id).To(Equal(EncodeID(testID("s1"))))
Expect(src.Size).To(Equal(int64(5242880)))
Expect(src.Container).To(Equal("mp3"))
Expect(src.Bitrate).To(Equal(320_000))
@ -198,7 +198,7 @@ var _ = Describe("mappers", func() {
It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() {
mf := model.MediaFile{
ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
Channels: 2, SampleRate: 44100, Codec: "mp3",
}
src := MediaSourceFromMediaFile(mf)
@ -213,7 +213,7 @@ var _ = Describe("mappers", func() {
})
It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
b, err := json.Marshal(src)
Expect(err).ToNot(HaveOccurred())
@ -241,7 +241,7 @@ var _ = Describe("mappers", func() {
Describe("Lyric media stream advertising", func() {
It("adds a Lyric media stream when the file has embedded lyrics", func() {
mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`}
mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(2))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
@ -251,20 +251,20 @@ var _ = Describe("mappers", func() {
})
It("emits only the Audio stream without lyrics", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"})
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")})
Expect(src.MediaStreams).To(HaveLen(1))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
})
It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"})
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"})
Expect(src.MediaStreams).To(HaveLen(1))
})
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1",
ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0,
Duration: 60,
}
@ -276,7 +276,7 @@ var _ = Describe("mappers", func() {
It("maps PlayDate to UserData.LastPlayedDate", func() {
playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC)
mf := model.MediaFile{
ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1",
ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", Duration: 60,
}
mf.PlayDate = &playDate
@ -286,25 +286,25 @@ var _ = Describe("mappers", func() {
})
It("maps an album to a MusicAlbum folder item", func() {
al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}}
al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}}
item := AlbumToBaseItem(al, nil)
Expect(item.Type).To(Equal("MusicAlbum"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("alb-1")))
Expect(item.ParentId).To(Equal(EncodeID("art-1")))
Expect(item.Id).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("art-1"))))
Expect(item.AlbumArtists).To(HaveLen(1))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1")))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1"))))
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1"))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
It("populates album Studios from record-label tags only when Fields=Studios", func() {
al := model.Album{ID: "alb-2", Name: "Alb2"}
al := model.Album{ID: testID("alb-2"), Name: "Alb2"}
al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}}
Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty())
@ -317,7 +317,7 @@ var _ = Describe("mappers", func() {
})
It("sets NormalizationGain on the album from its ReplayGain", func() {
al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)}
al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)}
b, err := json.Marshal(AlbumToBaseItem(al, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`))
@ -326,14 +326,14 @@ var _ = Describe("mappers", func() {
})
It("omits NormalizationGain when the album has no ReplayGain", func() {
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil))
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
Describe("PrimaryImageAspectRatio", func() {
nonSquare := func() model.Album {
al := model.Album{ID: "al1", Name: "Album"}
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800
return al
}
@ -351,14 +351,14 @@ var _ = Describe("mappers", func() {
})
It("is omitted when the dimensions are unknown, rather than guessing square", func() {
al := model.Album{ID: "al1", Name: "Album"}
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageHash = "abc"
item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio"))
Expect(item.PrimaryImageAspectRatio).To(BeNil())
})
It("is omitted when the item has no image at all", func() {
al := model.Album{ID: "al1", Name: "Album"}
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageAbsent = true
al.ImageWidth, al.ImageHeight = 1200, 800
item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio"))
@ -366,21 +366,21 @@ var _ = Describe("mappers", func() {
})
It("carries the ratio for an artist", func() {
ar := model.Artist{ID: "ar1", Name: "Artist"}
ar := model.Artist{ID: testID("ar1"), Name: "Artist"}
ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500
Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 2.0, 0.0001))
})
It("carries the ratio for a playlist", func() {
pl := model.Playlist{ID: "pl1", Name: "Playlist"}
pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"}
pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800
Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
})
It("carries the ratio for a song with its own art", func() {
mf := model.MediaFile{ID: "mf1", Title: "Song"}
mf := model.MediaFile{ID: testID("mf1"), Title: "Song"}
mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600
Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
@ -388,7 +388,7 @@ var _ = Describe("mappers", func() {
// A track without its own art shows the album's, so the ratio has to describe that image.
It("uses the album's dimensions for a track falling back to album art", func() {
mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"}
mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")}
mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800
item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio"))
Expect(item.AlbumPrimaryImageTag).To(Equal("abc"))
@ -397,73 +397,73 @@ var _ = Describe("mappers", func() {
})
It("maps an artist to a MusicArtist folder item", func() {
ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20}
ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20}
item := ArtistToBaseItem(ar, nil)
Expect(item.Type).To(Equal("MusicArtist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("art-1")))
Expect(item.Id).To(Equal(EncodeID(testID("art-1"))))
Expect(*item.AlbumCount).To(Equal(2))
})
It("maps a genre to a MusicGenre folder item", func() {
g := model.Genre{ID: "genre-1", Name: "Rock"}
g := model.Genre{ID: testID("genre-1"), Name: "Rock"}
item := GenreToBaseItem(g)
Expect(item.Type).To(Equal("MusicGenre"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("genre-1")))
Expect(item.Id).To(Equal(EncodeID(testID("genre-1"))))
Expect(item.Name).To(Equal("Rock"))
})
It("maps a tag to a Studio BaseItemDto", func() {
item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"})
item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"})
Expect(item.Type).To(Equal("Studio"))
Expect(item.Name).To(Equal("Blue Note"))
Expect(item.Id).To(Equal(EncodeID("t1")))
Expect(item.Id).To(Equal(EncodeID(testID("t1"))))
})
Describe("premiereDate", func() {
// Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily.
It("serializes a full date", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007}
item := SongToBaseItem(mf, nil)
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
})
It("pads a year-only date so clients can parse it", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z"))
})
It("pads a year-month date", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
})
It("falls back to the year when no date tag exists", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z"))
})
It("is omitted when the track has no date at all", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil())
})
It("is set on albums from their date, falling back to MaxYear", func() {
Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil())
Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil())
})
})
It("maps a playlist to a Playlist BaseItemDto", func() {
p := model.Playlist{
ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120,
ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120,
Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2},
}
item := PlaylistToBaseItem(p, nil)
Expect(item.Type).To(Equal("Playlist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("pl-1")))
Expect(item.Id).To(Equal(EncodeID(testID("pl-1"))))
Expect(item.Name).To(Equal("Chill"))
Expect(item.MediaType).To(Equal("Audio"))
Expect(*item.ChildCount).To(Equal(7))
@ -471,12 +471,12 @@ var _ = Describe("mappers", func() {
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(*item.UserData.Rating).To(Equal(8.0))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1"))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("changes the playlist image tag when the cover content changes", func() {
p := model.Playlist{ID: "pl-1", Name: "Chill"}
p := model.Playlist{ID: testID("pl-1"), Name: "Chill"}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.ImageHash = "2222222222222222"
@ -487,7 +487,7 @@ var _ = Describe("mappers", func() {
})
It("keeps the playlist image tag stable across a metadata-only edit", func() {
p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
@ -505,17 +505,17 @@ var _ = Describe("mappers", func() {
})
It("advertises the track id so the client triggers the read-through", func() {
mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true}
mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1"))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1")))
Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send")
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
})
It("falls back to the album when the track has no art of its own", func() {
mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false}
mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
@ -524,7 +524,7 @@ var _ = Describe("mappers", func() {
})
It("falls back to the album once the track's art is known absent", func() {
mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true}
mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.ItemImage.ImageAbsent = true
mf.AlbumImage.ImageHash = "0123456789abcdef"
@ -535,7 +535,7 @@ var _ = Describe("mappers", func() {
It("falls back to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true}
mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
@ -546,7 +546,7 @@ var _ = Describe("mappers", func() {
Describe("primary image tags", func() {
It("uses the content hash as the tag and emits the real blurhash", func() {
al := model.Album{ID: "alb-1", Name: "Album"}
al := model.Album{ID: testID("alb-1"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
al.BlurHash = "LEHV6nWB2yk8"
@ -556,7 +556,7 @@ var _ = Describe("mappers", func() {
})
It("omits the blurhash entirely when none was computed", func() {
al := model.Album{ID: "alb-2", Name: "Album"}
al := model.Album{ID: testID("alb-2"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
item := AlbumToBaseItem(al, nil)
@ -565,7 +565,7 @@ var _ = Describe("mappers", func() {
})
It("omits tags for known-absent artwork", func() {
al := model.Album{ID: "alb-3", Name: "Album"}
al := model.Album{ID: testID("alb-3"), Name: "Album"}
al.ImageAbsent = true
item := AlbumToBaseItem(al, nil)
@ -574,13 +574,13 @@ var _ = Describe("mappers", func() {
})
It("falls back to the entity id while artwork is still unresolved", func() {
item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4"))
item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions an artist's tag by content hash", func() {
ar := model.Artist{ID: "art-1", Name: "Artist"}
ar := model.Artist{ID: testID("art-1"), Name: "Artist"}
ar.ImageHash = "fedcba9876543210"
ar.BlurHash = "L6PZfSi_.AyE"
@ -592,7 +592,7 @@ var _ = Describe("mappers", func() {
Describe("song and playlist image tags", func() {
It("versions a song's album tag by the album's content hash", func() {
mf := model.MediaFile{ID: "song-1", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
mf.AlbumImage.BlurHash = "LEHV6nWB2yk8"
@ -602,7 +602,7 @@ var _ = Describe("mappers", func() {
})
It("never synthesizes a song blurhash when the album has none", func() {
mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"}
mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
@ -611,7 +611,7 @@ var _ = Describe("mappers", func() {
})
It("omits a song's album tag when the album art is known absent", func() {
mf := model.MediaFile{ID: "song-3", Title: "Song", AlbumID: "alb-3"}
mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")}
mf.AlbumImage.ImageAbsent = true
item := SongToBaseItem(mf, nil)
@ -620,7 +620,7 @@ var _ = Describe("mappers", func() {
})
It("versions a playlist tag by content hash instead of UpdatedAt", func() {
pl := model.Playlist{ID: "pl-1", Name: "Playlist"}
pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"}
pl.ImageHash = "abcdef0123456789"
item := PlaylistToBaseItem(pl, nil)
@ -630,7 +630,7 @@ var _ = Describe("mappers", func() {
Describe("per-song artwork", func() {
It("emits the track's own Primary tag when it has distinct art", func() {
mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "aaaaaaaaaaaaaaaa"
mf.BlurHash = "LTRACKblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
@ -644,7 +644,7 @@ var _ = Describe("mappers", func() {
})
It("falls back to the album tag when the track has no distinct art", func() {
mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "bbbbbbbbbbbbbbbb"
mf.BlurHash = "LALBUMblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
@ -658,7 +658,7 @@ var _ = Describe("mappers", func() {
})
It("omits the track tag when its own art is known absent", func() {
mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageAbsent = true
mf.AlbumImage.ImageAbsent = true
@ -673,7 +673,7 @@ var _ = Describe("mappers", func() {
var _ = Describe("LyricDtoFromLyrics", func() {
ms := func(v int64) *int64 { return &v }
mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
It("maps synced lyrics with tick conversion", func() {
l := model.Lyrics{

View file

@ -79,7 +79,7 @@ var _ = Describe("Annotations", func() {
})
It("returns 404 when favoriting an unknown item", func() {
Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound))
Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound))
})
})
@ -102,7 +102,7 @@ var _ = Describe("Annotations", func() {
})
It("returns 404 for an unknown item", func() {
Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound))
Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound))
})
})

View file

@ -61,7 +61,9 @@ var _ = Describe("AudioMuse endpoints", func() {
parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body)
Expect(body).To(HaveLen(2))
Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What"))
Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title)))
decoded, ok := dto.DecodeID(body[0].ItemID)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(songID(body[0].Title)))
})
It("collapses to one track per artist by default", func() {

View file

@ -27,9 +27,9 @@ var _ = Describe("Authentication", func() {
Expect(res.AccessToken).ToNot(BeEmpty())
Expect(res.User).ToNot(BeNil())
Expect(res.User.Name).To(Equal("admin"))
Expect(res.User.Id).To(Equal(enc("admin-1")))
Expect(res.User.Id).To(Equal(enc(testID("admin-1"))))
Expect(res.User.Policy.IsAdministrator).To(BeTrue())
Expect(res.ServerId).ToNot(BeEmpty())
Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$"))
// The returned token must actually authenticate a protected request.
r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() {
users := publicUsers()
Expect(users).To(HaveLen(1))
Expect(users[0].Name).To(Equal("regular"))
Expect(users[0].Id).To(Equal(enc("regular-1")))
Expect(users[0].Id).To(Equal(enc(testID("regular-1"))))
Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login
})
})
@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() {
var u dto.UserDto
parseInto(getAs(regularUser, "/Users/Me"), &u)
Expect(u.Name).To(Equal("regular"))
Expect(u.Id).To(Equal(enc("regular-1")))
Expect(u.Id).To(Equal(enc(testID("regular-1"))))
})
It("returns the caller from GET /Users/{userId}", func() {

View file

@ -46,7 +46,7 @@ var _ = Describe("Browsing", func() {
Expect(it.Type).To(Equal("Audio"))
Expect(it.MediaType).To(Equal("Audio"))
Expect(it.LocationType).To(Equal("FileSystem"))
Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it
Expect(it.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form
Expect(it.AlbumId).ToNot(BeEmpty())
}
})
@ -151,7 +151,7 @@ var _ = Describe("Browsing", func() {
// Finamp's download sync asks a library for the tracks outside any album this way; answering
// with every track would stream the whole library.
Describe("Recursive=false", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("returns no songs for a library parent", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false"))
@ -173,7 +173,7 @@ var _ = Describe("Browsing", func() {
// Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds
// for the actual artist filter, not ParentId=<artistId>.
Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("filters albums by AlbumArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles"))))
@ -209,6 +209,87 @@ var _ = Describe("Browsing", func() {
})
})
// A malformed id must 404, not silently drop the filter and widen the query to the whole
// library; a well-formed but unknown one must still fail closed to zero results.
Describe("stale and malformed id filtering", func() {
lib1 := dto.EncodeLibraryID(1)
It("404s a malformed ParentId instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed AlbumArtistIds instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed ArtistIds instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed Ids entry instead of batch-fetching nothing", func() {
w := get("/Items?Ids=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s when only one of several Ids entries is malformed", func() {
w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() {
q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed GenreIds entry instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s when only one of several GenreIds entries is malformed", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed AlbumIds entry instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed StudioIds entry instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
})
// Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true.
Describe("album filtering (AlbumIds)", func() {
It("filters songs by AlbumIds", func() {
@ -224,7 +305,7 @@ var _ = Describe("Browsing", func() {
})
It("returns nothing for an unknown album id", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album")))
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album"))))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
@ -261,16 +342,16 @@ var _ = Describe("Browsing", func() {
It("returns filter lists scoped to a ParentId library", func() {
var filters dto.QueryFiltersLegacy
parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters)
parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters)
Expect(filters.Years).To(ContainElements(1959, 1965))
studios := queryResult(get("/Studios?ParentId=" + enc("1")))
studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1)))
Expect(names(studios.Items)).To(ContainElement("Columbia"))
})
})
// Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>.
Describe("genre filtering (GenreIds)", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("filters albums by GenreIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz"))))
@ -295,7 +376,7 @@ var _ = Describe("Browsing", func() {
})
It("returns nothing for an unknown genre id", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre")))
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre"))))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
@ -317,7 +398,7 @@ var _ = Describe("Browsing", func() {
})
It("returns no artists for an unknown genre id", func() {
q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre")))
q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre"))))
Expect(q.Items).To(BeEmpty())
})
})
@ -327,7 +408,7 @@ var _ = Describe("Browsing", func() {
// binds them case-insensitively; these guard that our dispatcher does too, and that browsing an
// album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks.
Describe("camelCase query params (Jellify / JS SDK)", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("filters albums by camelCase albumArtistIds", func() {
q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles"))))
@ -364,35 +445,6 @@ var _ = Describe("Browsing", func() {
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV"))
})
// Finamp restores its saved queue with ids truncated to 16 bytes (see README).
Describe("Finamp-truncated ids (saved queue restore)", func() {
It("resolves a truncated id by unique prefix and echoes the requested id", func() {
full := songID("Come Together")
truncated := full[:16]
q := queryResult(get("/Items?ids=" + enc(truncated)))
Expect(names(q.Items)).To(ConsistOf("Come Together"))
// Finamp matches restored items by its stored ids, so the requested id must be echoed.
Expect(q.Items[0].Id).To(Equal(enc(truncated)))
})
It("batch-resolves a mixed list of truncated and full ids, keeping order", func() {
ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16])
q := queryResult(get("/Items?ids=" + ids))
Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"}))
})
It("streams a track by its truncated id", func() {
full := songID("So What")
w := get("/Audio/" + enc(full[:16]) + "/stream")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastMediaFile.ID).To(Equal(full))
})
It("still 404s for a truncated id matching nothing", func() {
Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound))
})
})
It("applies Limit while reporting the full TotalRecordCount", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2"))
Expect(q.Items).To(HaveLen(2))
@ -496,7 +548,7 @@ var _ = Describe("Browsing", func() {
})
It("returns 404 for an unknown id", func() {
Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound))
Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound))
})
})

View file

@ -51,6 +51,7 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/server/events"
@ -70,6 +71,10 @@ func TestJellyfinE2E(t *testing.T) {
RunSpecs(t, "Jellyfin API E2E Suite")
}
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
// id shape production uses.
func testID(label string) string { return id.NewHash("jellyfin-test", label) }
// Easy aliases for the storagetest package
type _t = map[string]any
@ -91,14 +96,14 @@ var (
dataFolder string
adminUser = model.User{
ID: "admin-1",
ID: testID("admin-1"),
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
}
regularUser = model.User{
ID: "regular-1",
ID: testID("regular-1"),
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
@ -219,7 +224,9 @@ func createPlaylistAs(user model.User, name string, encodedIds ...string) string
var res map[string]string
parseInto(postAs(user, "/Playlists", string(body)), &res)
Expect(res["Id"]).ToNot(BeEmpty())
return dto.DecodeID(res["Id"])
id, ok := dto.DecodeID(res["Id"])
Expect(ok).To(BeTrue())
return id
}
// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) ---

View file

@ -53,7 +53,7 @@ var _ = Describe("Lyrics", func() {
})
It("returns 404 for a fabricated id", func() {
Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound))
Expect(get("/Audio/" + enc(testID("nope")) + "/Lyrics").Code).To(Equal(http.StatusNotFound))
})
})

View file

@ -18,7 +18,7 @@ var _ = Describe("Multi-user access control", func() {
})
It("hides all content from a user with no library access", func() {
noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"}
noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"}
Expect(ds.User(ctx).Put(&noAccess)).To(Succeed())
loaded, err := ds.User(ctx).FindByUsername("noaccess")
Expect(err).ToNot(HaveOccurred())

View file

@ -46,6 +46,20 @@ var _ = Describe("Playlists", func() {
plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))})
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1)
})
// dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get
// dropped while the well-formed entries are still used to create a playlist.
It("404s when one of the Ids is malformed, without creating a playlist", func() {
before, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}`
Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound))
after, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(after).To(Equal(before))
})
})
Describe("items", func() {
@ -81,6 +95,13 @@ var _ = Describe("Playlists", func() {
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3))
})
It("404s when one of the ids to add is malformed, without adding any track", func() {
plID := createPlaylist("AddMalformed", nil)
url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + ",not-a-valid-id"
Expect(post(url, "").Code).To(Equal(http.StatusNotFound))
Expect(playlistItems(plID).TotalRecordCount).To(BeZero())
})
It("removes an entry by its PlaylistItemId", func() {
plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))})
entryID := playlistItems(plID).Items[0].PlaylistItemId
@ -103,7 +124,7 @@ var _ = Describe("Playlists", func() {
var perms []dto.PlaylistUserPermissions
parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms)
Expect(perms).To(HaveLen(1))
Expect(perms[0].UserId).To(Equal(enc("admin-1")))
Expect(perms[0].UserId).To(Equal(enc(testID("admin-1"))))
Expect(perms[0].CanEdit).To(BeTrue())
})
})
@ -135,12 +156,12 @@ var _ = Describe("Playlists", func() {
q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder"))
Expect(q.Items).To(HaveLen(1))
Expect(q.Items[0].CollectionType).To(Equal("playlists"))
Expect(q.Items[0].Id).To(Equal(enc("playlists")))
Expect(q.Items[0].Id).To(Equal(dto.PlaylistsFolderGUID))
})
It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() {
createPlaylist("My Mix", nil)
q := queryResult(get("/Items?parentId=" + enc("playlists")))
q := queryResult(get("/Items?parentId=" + dto.PlaylistsFolderGUID))
Expect(names(q.Items)).To(ContainElement("My Mix"))
Expect(q.Items[0].Type).To(Equal("Playlist"))
// Jellify keeps only playlists whose Path contains "data".
@ -149,10 +170,10 @@ var _ = Describe("Playlists", func() {
It("resolves the synthetic playlists folder by its own advertised id", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc("playlists")), &item)
parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item)
Expect(item.Type).To(Equal("ManualPlaylistsFolder"))
Expect(item.CollectionType).To(Equal("playlists"))
Expect(item.Id).To(Equal(enc("playlists")))
Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID))
})
})
@ -239,6 +260,15 @@ var _ = Describe("Playlists", func() {
})
Describe("update", func() {
It("404s when one of the replacement Ids is malformed, leaving the track list unchanged", func() {
plID := createPlaylist("UpdateMalformed", []string{enc(songID("Come Together"))})
body := `{"Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}`
Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNotFound))
q := playlistItems(plID)
Expect(q.TotalRecordCount).To(Equal(1))
Expect(names(q.Items)).To(ConsistOf("Come Together"))
})
It("makes a playlist public", func() {
plID := createPlaylist("Make Public", nil)
Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
@ -295,6 +325,20 @@ var _ = Describe("Playlists", func() {
post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user
Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden))
})
// An id that decodes to "" would tell Create to make a new playlist instead of updating one —
// itemIDParam must 404 before that decode ever runs, not silently create one.
It("404s for a malformed playlist id, without creating a playlist", func() {
before, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`)
Expect(w.Code).To(Equal(http.StatusNotFound))
after, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(after).To(Equal(before))
})
})
Describe("delete", func() {

View file

@ -3,6 +3,7 @@ package e2e
import (
"fmt"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -12,7 +13,7 @@ import (
var _ = Describe("Search", func() {
BeforeEach(func() { setupTestDB() })
lib1 := func() string { return enc("1") } // Library id 1 encodes to "31"
lib1 := func() string { return dto.EncodeLibraryID(1) }
Describe("artists", func() {
It("searches all album artists", func() {

View file

@ -1,6 +1,8 @@
package e2e
import (
"net/http"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -12,7 +14,7 @@ var _ = Describe("Similar", func() {
Describe("GET /Artists/{id}/Similar", func() {
It("returns the provider's similar artists, excluding ones not in the library", func() {
providerFake.similarArtists = model.Artists{
{ID: "z", Name: "Led Zeppelin"},
{ID: testID("z"), Name: "Led Zeppelin"},
{ID: "", Name: "Not In Library"}, // no id -> not present -> excluded
}
q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar"))
@ -25,11 +27,18 @@ var _ = Describe("Similar", func() {
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
// Unlike an unresolvable-but-well-formed id (empty result above), a malformed itemId never
// reaches provider lookup at all — itemIDParam rejects it first.
It("404s a malformed itemId", func() {
w := get("/Artists/not-a-valid-id/Similar")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("GET /Items/{id}/Similar", func() {
It("returns similar songs for a track", func() {
providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}}
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar"))
Expect(names(q.Items)).To(ConsistOf("Similar Song"))
Expect(q.Items[0].Type).To(Equal("Audio"))
@ -37,8 +46,8 @@ var _ = Describe("Similar", func() {
It("excludes similar songs from libraries the user can't access", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", Title: "In Library", LibraryID: 1},
{ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access
{ID: testID("x1"), Title: "In Library", LibraryID: 1},
{ID: testID("x2"), Title: "Other Library", LibraryID: 2}, // regularUser has no access
}
q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar"))
Expect(names(q.Items)).To(ConsistOf("In Library"))
@ -46,9 +55,9 @@ var _ = Describe("Similar", func() {
It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", AlbumID: albumID("IV")},
{ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once
{ID: "x3", AlbumID: albumID("Kind of Blue")},
{ID: testID("x1"), AlbumID: albumID("IV")},
{ID: testID("x2"), AlbumID: albumID("IV")}, // same album -> counted once
{ID: testID("x3"), AlbumID: albumID("Kind of Blue")},
}
q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar"))
Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"}))
@ -60,27 +69,32 @@ var _ = Describe("Similar", func() {
// provider similar-song at it.
otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"}
Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed())
otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2}
otherAlbum := model.Album{ID: testID("other-album"), Name: "Other Album", LibraryID: 2}
Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed())
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible
{ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser
{ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible
{ID: testID("x2"), AlbumID: testID("other-album")}, // library 2 -> filtered for regularUser
}
q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar"))
Expect(names(q.Items)).To(ConsistOf("IV"))
})
It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() {
q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar"))
q := queryResult(get("/Items/" + enc(testID("does-not-exist")) + "/Similar"))
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed itemId", func() {
w := get("/Items/not-a-valid-id/Similar")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
// Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix.
Describe("GET /Items/{id}/InstantMix", func() {
It("returns the seed track first, followed by similar songs", func() {
providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}}
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19"))
Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"}))
Expect(q.Items[0].Type).To(Equal("Audio"))
@ -89,7 +103,7 @@ var _ = Describe("Similar", func() {
It("does not duplicate the seed when the provider returns it", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: songID("So What"), Title: "So What", LibraryID: 1},
{ID: "x1", Title: "Similar Song", LibraryID: 1},
{ID: testID("x1"), Title: "Similar Song", LibraryID: 1},
}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"}))
@ -97,9 +111,9 @@ var _ = Describe("Similar", func() {
It("caps the mix at the requested limit", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", Title: "S1", LibraryID: 1},
{ID: "x2", Title: "S2", LibraryID: 1},
{ID: "x3", Title: "S3", LibraryID: 1},
{ID: testID("x1"), Title: "S1", LibraryID: 1},
{ID: testID("x2"), Title: "S2", LibraryID: 1},
{ID: testID("x3"), Title: "S3", LibraryID: 1},
}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2"))
Expect(names(q.Items)).To(Equal([]string{"So What", "S1"}))
@ -107,21 +121,21 @@ var _ = Describe("Similar", func() {
It("excludes similar songs from libraries the user can't access", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", Title: "In Library", LibraryID: 1},
{ID: "x2", Title: "Other Library", LibraryID: 2},
{ID: testID("x1"), Title: "In Library", LibraryID: 1},
{ID: testID("x2"), Title: "Other Library", LibraryID: 2},
}
q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"}))
})
It("returns a mix of the provider's similar songs for an artist seed", func() {
providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}}
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Artist Mix Song", LibraryID: 1}}
q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"}))
})
It("returns an empty result (not 404) for an unknown item", func() {
w := get("/Items/" + enc("does-not-exist") + "/InstantMix")
w := get("/Items/" + enc(testID("does-not-exist")) + "/InstantMix")
Expect(w.Code).To(Equal(200))
Expect(queryResult(w).Items).To(BeEmpty())
})
@ -130,5 +144,10 @@ var _ = Describe("Similar", func() {
q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"Help!"}))
})
It("404s a malformed itemId", func() {
w := get("/Items/not-a-valid-id/InstantMix")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})

View file

@ -43,7 +43,7 @@ var _ = Describe("Streaming", func() {
})
It("returns 404 for an unknown track", func() {
Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound))
Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound))
})
})
@ -98,7 +98,7 @@ var _ = Describe("Streaming", func() {
parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info)
Expect(info.MediaSources).To(HaveLen(1))
Expect(info.MediaSources[0].Id).ToNot(BeEmpty())
Expect(info.PlaySessionId).ToNot(BeEmpty())
Expect(info.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$"))
})
It("returns a MediaSource via POST", func() {

View file

@ -14,14 +14,12 @@ import (
"strconv"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/imghttp"
"github.com/navidrome/navidrome/server/jellyfin/dto"
_ "golang.org/x/image/webp"
)
@ -29,7 +27,10 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
// Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials
// and item ids are unguessable, so resolution runs elevated to bypass the visibility filter.
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId")))
itemId, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth"))
artID := api.resolveArtworkID(ctx, itemId)
@ -84,11 +85,14 @@ func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string {
}
// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come
// from scanning. The body is always drained first (even on the not-implemented path) because
// Finamp writes it synchronously and sees a broken pipe if we respond before reading it.
// from scanning. Past the auth and id gates the body is drained before answering — including on the
// not-implemented path — because Finamp writes it synchronously and would see a broken pipe.
func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "itemId"))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
// Honor the same artwork-upload gate and size cap as the native endpoint.
u, _ := request.UserFrom(ctx)
@ -143,7 +147,10 @@ func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) {
// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported.
func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "itemId"))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
if _, err := api.playlists.Get(ctx, id); err != nil {
http.Error(w, "Not Implemented", http.StatusNotImplemented)

View file

@ -63,16 +63,29 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request)
var _ = Describe("Images", func() {
It("streams album artwork", func() {
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("a1"))
w, r := newImageRequest(dto.EncodeID(testID("a1")))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("IMG"))
Expect(fa.recvId).To(ContainSubstring("a1"))
Expect(fa.recvId).To(ContainSubstring(testID("a1")))
})
// A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image.
It("404s a malformed itemId instead of serving a placeholder", func() {
ds := &tests.MockDataStore{}
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest("not-a-valid-id")
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id")
})
// resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all.
@ -81,22 +94,23 @@ var _ = Describe("Images", func() {
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("deleted-item"))
w, r := newImageRequest(dto.EncodeID(testID("deleted-item")))
api.getItemImage(w, r)
// 200 here is what separates a well-formed unknown id from a malformed one, which 404s.
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder")
Expect(w.Body.String()).ToNot(ContainSubstring("deleted-item"))
})
It("sniffs the Content-Type instead of hardcoding it", func() {
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...)
fa := &fakeArtwork{data: png}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("a1"))
w, r := newImageRequest(dto.EncodeID(testID("a1")))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -105,15 +119,15 @@ var _ = Describe("Images", func() {
It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() {
ds := &tests.MockDataStore{}
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}})
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}})
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("pl1"))
w, r := newImageRequest(dto.EncodeID(testID("pl1")))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.recvId).To(ContainSubstring("pl1"))
Expect(fa.recvId).To(ContainSubstring(testID("pl1")))
})
// This endpoint is public (no user in the request), so artwork must be resolved under an
@ -121,11 +135,11 @@ var _ = Describe("Images", func() {
// silently falls back to the placeholder.
It("resolves artwork under an elevated admin context", func() {
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("a1"))
w, r := newImageRequest(dto.EncodeID(testID("a1")))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -137,11 +151,11 @@ var _ = Describe("Images", func() {
It("serves immutable when the tag param asserts the current hash", func() {
const hash = "0123456789abcdef"
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
fa := &fakeArtwork{hash: hash}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("a1"))
w, r := newImageRequest(dto.EncodeID(testID("a1")))
q := r.URL.Query()
q.Set("tag", hash)
r.URL.RawQuery = q.Encode()
@ -155,11 +169,11 @@ var _ = Describe("Images", func() {
It("revalidates via no-cache when no tag is provided", func() {
const hash = "0123456789abcdef"
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
fa := &fakeArtwork{hash: hash}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("a1"))
w, r := newImageRequest(dto.EncodeID(testID("a1")))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -199,21 +213,21 @@ var _ = Describe("postItemImage", func() {
var fp *fakePlaylists
BeforeEach(func() {
fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}}
fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}}
api = &Router{playlists: fp}
})
It("uploads a raw JPEG body and returns 204", func() {
body := jpegBytes()
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.setImagePlaylistID).To(Equal("pl1"))
Expect(fp.setImagePlaylistID).To(Equal(testID("pl1")))
Expect(fp.setImageBytes).To(Equal(body))
Expect(fp.setImageExt).To(Equal(".jpeg"))
})
@ -222,9 +236,9 @@ var _ = Describe("postItemImage", func() {
raw := pngBytes()
encoded := base64.StdEncoding.EncodeToString(raw)
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded)))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded)))
r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -238,9 +252,9 @@ var _ = Describe("postItemImage", func() {
fp.getByIDErr = model.ErrNotFound
bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained"))
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader)
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader)
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("al1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1")))
api.postItemImage(w, r)
@ -251,9 +265,9 @@ var _ = Describe("postItemImage", func() {
It("returns 500 when the service fails", func() {
fp.setImageErr = errors.New("boom")
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes()))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -263,9 +277,9 @@ var _ = Describe("postItemImage", func() {
It("accepts a raw WebP body", func() {
body := webpBytes()
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/webp")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -277,9 +291,9 @@ var _ = Describe("postItemImage", func() {
It("accepts a raw GIF body", func() {
body := gifBytes()
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/gif")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -292,9 +306,9 @@ var _ = Describe("postItemImage", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.MaxImageUploadSize = "16" // 16 bytes
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes()))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -310,9 +324,9 @@ var _ = Describe("postItemImage", func() {
body := []byte(base64.StdEncoding.EncodeToString(img))
Expect(len(body)).To(BeNumerically(">", len(img)))
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/png")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -326,9 +340,9 @@ var _ = Describe("postItemImage", func() {
conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1)
body := []byte(base64.StdEncoding.EncodeToString(img))
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/png")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -338,9 +352,9 @@ var _ = Describe("postItemImage", func() {
It("rejects a body that is neither an image nor base64 with 400", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!"))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!"))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -351,9 +365,9 @@ var _ = Describe("postItemImage", func() {
It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() {
body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.postItemImage(w, r)
@ -365,10 +379,10 @@ var _ = Describe("postItemImage", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableArtworkUpload = false
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes()))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false}))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false}))
api.postItemImage(w, r)
@ -380,10 +394,10 @@ var _ = Describe("postItemImage", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableArtworkUpload = false
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes()))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true}))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true}))
api.postItemImage(w, r)
@ -393,24 +407,24 @@ var _ = Describe("postItemImage", func() {
var _ = Describe("deleteItemImage", func() {
It("removes the playlist image and returns 204", func() {
fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}}
fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}}
api := &Router{playlists: fp}
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.deleteItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removeImagePlaylistID).To(Equal("pl1"))
Expect(fp.removeImagePlaylistID).To(Equal(testID("pl1")))
})
It("returns 501 for a non-playlist item", func() {
fp := &fakePlaylists{getByIDErr: model.ErrNotFound}
api := &Router{playlists: fp}
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID("al1"))
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1")))
api.deleteItemImage(w, r)
@ -418,11 +432,11 @@ var _ = Describe("deleteItemImage", func() {
})
It("returns 500 when the service fails", func() {
fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")}
fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}, removeImageErr: errors.New("boom")}
api := &Router{playlists: fp}
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
api.deleteItemImage(w, r)

View file

@ -2,6 +2,7 @@ package jellyfin
import (
"context"
"errors"
"io"
"iter"
"net/http"
@ -10,7 +11,6 @@ import (
"strings"
"github.com/Masterminds/squirrel"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -34,6 +34,10 @@ func searchTerm(p *req.Values) string {
func (api *Router) getItems(w http.ResponseWriter, r *http.Request) {
res, err := api.queryItems(r.Context(), r)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.internalError(w, r, err)
return
}
@ -229,12 +233,37 @@ type itemsQuery struct {
// parseItemsQuery also resolves the entity types (inferring them from the parent when
// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because
// normalizeQueryKeys folded them (Jellyfin binds case-insensitively).
func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery {
// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that
// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam).
func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) {
p := req.Params(r)
parentId, ok := decodeFilterParam(p.StringOr("parentid", ""))
if !ok {
return itemsQuery{}, model.ErrNotFound
}
// Any malformed entry in one of these id lists must 404, not silently drop out of the filter
// (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything.
ids, ok := decodedQueryIDs(r, "ids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
// Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre.
genreIds, ok := decodedQueryIDs(r, "genreids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
// Feishin fetches an album's tracks with AlbumIds instead of ParentId.
albumIds, ok := decodedQueryIDs(r, "albumids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
studioIds, ok := decodedQueryIDs(r, "studioids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
q := itemsQuery{
fields: dto.ParseFields(p.Strings("fields")...),
ids: decodedQueryIDs(r, "ids"),
ids: ids,
rawTypes: p.StringOr("includeitemtypes", ""),
search: searchTerm(p),
sortBy: p.StringOr("sortby", ""),
@ -243,20 +272,22 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu
limit: p.IntOr("limit", 0),
// Clients express "favorites only" two ways: Filters=IsFavorite and the standalone
// isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter).
favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false),
parentId: dto.DecodeID(p.StringOr("parentid", "")),
// Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre.
genreIds: decodedQueryIDs(r, "genreids"),
// Feishin fetches an album's tracks with AlbumIds instead of ParentId.
albumIds: decodedQueryIDs(r, "albumids"),
favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false),
parentId: parentId,
genreIds: genreIds,
albumIds: albumIds,
years: parseYears(r),
studioIds: decodedQueryIDs(r, "studioids"),
studioIds: studioIds,
}
// An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping
// plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist.
albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", ""))
contributingScope := p.StringOr("contributingartistids", "")
q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope))
artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope))
if !ok {
return itemsQuery{}, model.ErrNotFound
}
q.artistId = artistId
q.contributingOnly = albumArtistScope == "" && contributingScope != ""
q.types = parseTypes(q.rawTypes)
@ -272,7 +303,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu
// (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse
// its albums).
if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent {
if q.parentId == playlistsFolderID {
if q.parentId == dto.PlaylistsFolderID {
// Browsing into the synthetic playlists folder lists the user's playlists.
q.types = []string{"Playlist"}
} else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil {
@ -285,14 +316,17 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu
if q.isLibraryParent || len(q.types) > 1 {
q.entityParent = ""
}
return q
return q, nil
}
// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via
// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites
// screen requests).
func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) {
q := api.parseItemsQuery(ctx, r)
q, err := api.parseItemsQuery(ctx, r)
if err != nil {
return itemsResult{}, err
}
switch {
// /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch.
case len(q.ids) > 0:
@ -322,7 +356,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult
// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type
// dispatch: ParentId is usually an album or artist.
func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) {
if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID {
if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID {
return nil, false
}
// Tracks enforces visibility.
@ -450,18 +484,20 @@ func firstNonEmpty(vals ...string) string {
return ""
}
// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list.
func firstDecodedID(s string) string {
// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting
// whether it decoded successfully (see decodeFilterParam).
func firstDecodedID(s string) (string, bool) {
if s == "" {
return ""
return "", true
}
first, _, _ := strings.Cut(s, ",")
return dto.DecodeID(strings.TrimSpace(first))
return decodeFilterParam(strings.TrimSpace(first))
}
// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id.
func decodedQueryIDs(r *http.Request, key string) []string {
return slice.Map(queryIDs(r, key), dto.DecodeID)
// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if
// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter.
func decodedQueryIDs(r *http.Request, key string) ([]string, bool) {
return dto.DecodeIDs(queryIDs(r, key))
}
// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params.
@ -740,7 +776,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q
// to their library, so an id can't probe content outside the user's libraries.
func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) {
// The synthetic playlists folder must resolve by the id we advertised, not 404.
if id == playlistsFolderID {
if id == dto.PlaylistsFolderID {
return playlistsFolder(), true
}
u, _ := request.UserFrom(ctx)
@ -802,14 +838,11 @@ func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]mode
}
// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids.
// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored
// queue items against its stored (truncated) ids.
func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult {
u, _ := request.UserFrom(ctx)
fullIDs := api.resolveItemIDs(ctx, ids)
songs := api.songsByIDs(ctx, fullIDs)
songs := api.songsByIDs(ctx, ids)
var items []dto.BaseItemDto
for i, id := range fullIDs {
for _, id := range ids {
var item dto.BaseItemDto
if mf, ok := songs[id]; ok {
if !u.HasLibraryAccess(mf.LibraryID) {
@ -819,16 +852,16 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel
} else if item, ok = api.resolveItemByID(ctx, id, fields); !ok {
continue
}
if id != ids[i] {
item.Id = dto.EncodeID(ids[i])
}
items = append(items, item)
}
return result(items, len(items), 0)
}
func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
fields := dto.ParseFields(req.Params(r).Strings("fields")...)
if item, ok := api.resolveItemByID(r.Context(), id, fields); ok {
api.ok(w, r, item)
@ -841,7 +874,10 @@ func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership.
func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "itemId"))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
if err := api.playlists.Delete(ctx, id); err != nil {
api.playlistError(w, r, err)
return

View file

@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
@ -30,14 +32,14 @@ var _ = Describe("Items", func() {
var fp *fakePlaylists
// alice has access to library 1 only; used by tests that don't care about scoping.
ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
}
ctxUserWithLibraries := func(libs model.Libraries) context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
}
// admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership.
ctxAdmin := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil})
return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil})
}
BeforeEach(func() {
ds = &tests.MockDataStore{}
@ -47,7 +49,7 @@ var _ = Describe("Items", func() {
Describe("getItems", func() {
It("lists albums when IncludeItemTypes=MusicAlbum", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -60,44 +62,44 @@ var _ = Describe("Items", func() {
})
It("lists an album's songs when ParentId is an album and type is Audio", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser())
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("Audio"))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
})
It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() {
fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
}}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(2))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1")))
Expect(res.TotalRecordCount).To(Equal(2))
})
It("pages a playlist parent's tracks in the query, not in memory", func() {
fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
{ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}},
fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
{ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}},
}}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -105,24 +107,24 @@ var _ = Describe("Items", func() {
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.TotalRecordCount).To(Equal(3))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2"))))
Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
Expect(fp.tracksRepo.Options.Max).To(Equal(1))
})
It("falls through to the type dispatch when ParentId is not a playlist", func() {
fp.getErr = model.ErrNotFound
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
})
It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() {
@ -137,13 +139,13 @@ var _ = Describe("Items", func() {
// looking for tracks outside any album; answering with every track streams the whole library.
Describe("Recursive=false", func() {
BeforeEach(func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}})
})
It("returns no songs for a library parent, as tracks are never its direct children", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -155,7 +157,7 @@ var _ = Describe("Items", func() {
It("drops only Audio from a multi-type library query", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
@ -166,7 +168,7 @@ var _ = Describe("Items", func() {
It("still lists albums for a library parent, as they are its direct children", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
@ -177,13 +179,13 @@ var _ = Describe("Items", func() {
It("still lists an album's tracks, as they are its direct children", func() {
fp.getErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
})
It("keeps returning every song when no parent scopes the query", func() {
@ -199,7 +201,7 @@ var _ = Describe("Items", func() {
// `recursive ?? false`, so an omitted Recursive is a non-recursive request.
It("treats an omitted Recursive as false, like Jellyfin", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil).
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
@ -209,9 +211,9 @@ var _ = Describe("Items", func() {
})
It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser())
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
@ -223,7 +225,7 @@ var _ = Describe("Items", func() {
})
It("lists artists when IncludeItemTypes=MusicArtist", func() {
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -244,7 +246,7 @@ var _ = Describe("Items", func() {
})
It("lists playlists when IncludeItemTypes=Playlist", func() {
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}})
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -253,13 +255,13 @@ var _ = Describe("Items", func() {
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("Playlist"))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1"))))
Expect(res.TotalRecordCount).To(Equal(1))
})
It("merges results from every requested type in IncludeItemTypes", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -274,11 +276,11 @@ var _ = Describe("Items", func() {
It("merges favorite songs, albums, and playlists", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}})
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -298,8 +300,8 @@ var _ = Describe("Items", func() {
})
It("applies StartIndex/Limit to the merged multi-type result set", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -313,9 +315,9 @@ var _ = Describe("Items", func() {
It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -327,7 +329,7 @@ var _ = Describe("Items", func() {
It("applies a starred filter when Filters=IsFavorite", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -339,7 +341,7 @@ var _ = Describe("Items", func() {
It("forwards SearchTerm to the repo's Search method", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -351,7 +353,7 @@ var _ = Describe("Items", func() {
It("caps a search the client left unbounded", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -361,7 +363,7 @@ var _ = Describe("Items", func() {
It("honors an explicit search Limit up to the ceiling", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil).
WithContext(ctxUser())
@ -372,7 +374,7 @@ var _ = Describe("Items", func() {
It("clamps a search Limit that would materialize the library", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil).
WithContext(ctxUser())
@ -383,7 +385,7 @@ var _ = Describe("Items", func() {
It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil).
WithContext(ctxUser())
@ -398,10 +400,10 @@ var _ = Describe("Items", func() {
It("reports a multi-type search total past the page, so clients keep paging", func() {
songs := make(model.MediaFiles, defaultSearchLimit*2)
for i := range songs {
songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"}
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil).
WithContext(ctxUser())
@ -415,8 +417,8 @@ var _ = Describe("Items", func() {
It("bounds the multi-type search window however large StartIndex is", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil).
WithContext(ctxUser())
@ -431,10 +433,10 @@ var _ = Describe("Items", func() {
// past that window the merged order is no longer the true one.
songs := make(model.MediaFiles, maxSearchLimit+1)
for i := range songs {
songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"}
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit),
@ -450,10 +452,12 @@ var _ = Describe("Items", func() {
It("serves the last page below the ceiling in full", func() {
songs := make(model.MediaFiles, maxSearchLimit+1)
for i := range songs {
songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"}
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
}
// The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold.
slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) })
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1),
@ -470,10 +474,10 @@ var _ = Describe("Items", func() {
It("bounds an unbounded multi-type search to the default in total, not per type", func() {
songs := make(model.MediaFiles, defaultSearchLimit*2)
for i := range songs {
songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"}
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil).
WithContext(ctxUser())
@ -487,10 +491,12 @@ var _ = Describe("Items", func() {
It("pages an unbounded multi-type search past the default without dropping matches", func() {
songs := make(model.MediaFiles, defaultSearchLimit*2)
for i := range songs {
songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"}
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
}
// The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold.
slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) })
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50),
@ -506,7 +512,7 @@ var _ = Describe("Items", func() {
It("reports a search total beyond the fetched page instead of the page length", func() {
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{
{ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"},
{ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser())
@ -520,7 +526,7 @@ var _ = Describe("Items", func() {
It("forwards StartIndex/Limit as Offset/Max", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -533,31 +539,31 @@ var _ = Describe("Items", func() {
// Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; without
// this, queryItems ignored Ids and returned the default type-dispatched list instead.
It("returns exactly the requested item when Ids has a single id", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser())
r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
Expect(res.Items[0].Name).To(Equal("Song"))
Expect(res.TotalRecordCount).To(Equal(1))
})
It("returns items of different types for a lowercase ids param with multiple ids", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser())
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(2))
ids := []string{res.Items[0].Id, res.Items[1].Id}
Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1")))
Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1"))))
types := []string{res.Items[0].Type, res.Items[1].Type}
Expect(types).To(ConsistOf("MusicAlbum", "Audio"))
Expect(res.TotalRecordCount).To(Equal(2))
@ -565,9 +571,9 @@ var _ = Describe("Items", func() {
It("resolves song ids with one batched IN query, not a Get per id", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser())
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
@ -576,20 +582,20 @@ var _ = Describe("Items", func() {
sql, args, err := mfRepo.Options.Filters.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.id IN"))
Expect(args).To(ConsistOf("s1", "s2"))
Expect(args).To(ConsistOf(testID("s1"), testID("s2")))
})
It("omits an id in a library the user can't access, without erroring the whole batch", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser())
r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1"))))
Expect(res.TotalRecordCount).To(Equal(1))
})
})
@ -597,7 +603,7 @@ var _ = Describe("Items", func() {
Describe("sorting", func() {
It("maps SortBy=PlayCount to the play_count column", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -607,7 +613,7 @@ var _ = Describe("Items", func() {
It("maps SortBy=DatePlayed to the play_date column", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -617,7 +623,7 @@ var _ = Describe("Items", func() {
It("uses the first recognized key in a comma-separated SortBy list", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -627,7 +633,7 @@ var _ = Describe("Items", func() {
It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -637,7 +643,7 @@ var _ = Describe("Items", func() {
It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -647,7 +653,7 @@ var _ = Describe("Items", func() {
It("leaves Sort at the repo default when no SortBy key is recognized", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
@ -659,7 +665,7 @@ var _ = Describe("Items", func() {
Describe("library scoping", func() {
It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
@ -673,7 +679,7 @@ var _ = Describe("Items", func() {
It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs))
@ -687,7 +693,7 @@ var _ = Describe("Items", func() {
It("scopes a MusicArtist listing to the user's accessible libraries", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs))
@ -701,10 +707,10 @@ var _ = Describe("Items", func() {
It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := albumRepo.Options.Filters.ToSql()
@ -716,10 +722,10 @@ var _ = Describe("Items", func() {
It("does not let ParentId=<inaccessible library id> scope results to that library", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}} // no access to library 99
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := albumRepo.Options.Filters.ToSql()
@ -734,7 +740,7 @@ var _ = Describe("Items", func() {
It("does not restrict a default MusicAlbum listing for an admin user", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin())
invoke(api.getItems, w, r)
@ -750,10 +756,63 @@ var _ = Describe("Items", func() {
})
})
// A malformed id must 404, not silently drop the filter; a well-formed but unknown one must
// still reach the entity filter, not the unfiltered default.
Describe("stale and malformed id filtering", func() {
It("404s a malformed ParentId instead of listing every song", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed AlbumArtistIds instead of listing every album", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed ArtistIds instead of listing every song", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, _, err := albumRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("album_artists"))
})
It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := mfRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("album_id"))
Expect(args).To(ContainElement(testID("no-such-album")))
})
})
Describe("mixed IncludeItemTypes merge", func() {
BeforeEach(func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "S1"}, {ID: "s2", Title: "S2"}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}})
})
It("returns a mix of both types, not all of one", func() {
@ -815,54 +874,54 @@ var _ = Describe("Items", func() {
Describe("getItem", func() {
It("returns an album by id", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("a1")))
Expect(item.Id).To(Equal(dto.EncodeID(testID("a1"))))
Expect(item.Type).To(Equal("MusicAlbum"))
})
It("returns 404 when the id doesn't match any entity", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 for an album in a library the user can't access", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 for a song in a library the user can't access", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID("s1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns an album to an admin even when it's outside their (empty) Libraries", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("a1")))
Expect(item.Id).To(Equal(dto.EncodeID(testID("a1"))))
})
// Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the
@ -870,13 +929,13 @@ var _ = Describe("Items", func() {
It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() {
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1, Name: "Music Library"}}
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", dto.EncodeID("1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("1")))
Expect(item.Id).To(Equal(dto.EncodeLibraryID(1)))
Expect(item.Name).To(Equal("Music Library"))
Expect(item.Type).To(Equal("CollectionFolder"))
Expect(item.CollectionType).To(Equal("music"))
@ -886,8 +945,8 @@ var _ = Describe("Items", func() {
It("does not resolve a library-view id the user has no access to", func() {
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", dto.EncodeID("1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
@ -895,15 +954,15 @@ var _ = Describe("Items", func() {
// Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it
// 404s with "Could not fetch BaseItemDto <playlist> from server."
It("resolves a playlist id via the playlists service", func() {
fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5}
fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("p1")))
Expect(item.Id).To(Equal(dto.EncodeID(testID("p1"))))
Expect(item.Name).To(Equal("My Mix"))
Expect(item.Type).To(Equal("Playlist"))
})
@ -911,23 +970,23 @@ var _ = Describe("Items", func() {
It("returns 404 for a non-owned or absent playlist id", func() {
fp.getByIDErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
// Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID).
It("resolves a genre id as a MusicGenre item", func() {
Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: "g1", Name: "Rock"})).To(Succeed())
Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("g1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("g1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1")))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("g1")))
Expect(item.Id).To(Equal(dto.EncodeID(testID("g1"))))
Expect(item.Name).To(Equal("Rock"))
Expect(item.Type).To(Equal("MusicGenre"))
})
@ -935,13 +994,13 @@ var _ = Describe("Items", func() {
It("resolves a library-view id for an admin even though their Libraries slice is empty", func() {
ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin())
r = withChiURLParam(r, "itemId", dto.EncodeID("1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin())
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("1")))
Expect(item.Id).To(Equal(dto.EncodeLibraryID(1)))
Expect(item.Name).To(Equal("Music Library"))
Expect(item.Type).To(Equal("CollectionFolder"))
})
@ -949,7 +1008,7 @@ var _ = Describe("Items", func() {
Describe("getLatest", func() {
It("returns a bare array of the newest albums", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser())
invoke(api.getLatest, w, r)
@ -957,12 +1016,12 @@ var _ = Describe("Items", func() {
var items []dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed())
Expect(items).To(HaveLen(1))
Expect(items[0].Id).To(Equal(dto.EncodeID("a1")))
Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1"))))
})
It("scopes to the user's accessible libraries", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs))
@ -1014,4 +1073,47 @@ var _ = Describe("Items", func() {
Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"}))
})
})
Describe("decodeFilterParam", func() {
It("reports ok for an absent param, decoding to \"\"", func() {
id, ok := decodeFilterParam("")
Expect(id).To(BeEmpty())
Expect(ok).To(BeTrue())
})
It("reports ok for a well-formed id, whether or not it exists", func() {
id, ok := decodeFilterParam(dto.EncodeID(testID("a1")))
Expect(id).To(Equal(testID("a1")))
Expect(ok).To(BeTrue())
})
It("reports not ok for a non-empty param that fails to decode", func() {
id, ok := decodeFilterParam("not-a-valid-id")
Expect(id).To(BeEmpty())
Expect(ok).To(BeFalse())
})
})
Describe("decodedQueryIDs", func() {
It("decodes every entry when all are well-formed", func() {
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil)
ids, ok := decodedQueryIDs(r, "ids")
Expect(ok).To(BeTrue())
Expect(ids).To(Equal([]string{testID("a1"), testID("a2")}))
})
It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() {
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil)
ids, ok := decodedQueryIDs(r, "ids")
Expect(ok).To(BeFalse())
Expect(ids).To(BeEmpty())
})
It("reports ok for an absent param, decoding to an empty list", func() {
r := httptest.NewRequest("GET", "/Items", nil)
ids, ok := decodedQueryIDs(r, "ids")
Expect(ok).To(BeTrue())
Expect(ids).To(BeEmpty())
})
})
})

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -17,6 +18,10 @@ func TestJellyfinApi(t *testing.T) {
RunSpecs(t, "Jellyfin API Suite")
}
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
// id shape production uses.
func testID(label string) string { return id.NewHash("jellyfin-test", label) }
// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call
// handlers directly (with withChiURLParam for path params) instead of routing, so without this the
// case-insensitive query folding real requests get would be skipped and PascalCase params dropped.

View file

@ -6,6 +6,7 @@ import (
"strconv"
"github.com/Masterminds/squirrel"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
@ -33,9 +34,36 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int,
return accessibleLibraryIDs(ctx), false
}
// decodeFilterParam separates an absent param from an undecodable one: dropping the filter for a
// stale id would silently widen the query to the whole library. It is the only place that opts
// into treating "absent" as fine — every other caller of dto.DecodeID must 404 on ok=false.
func decodeFilterParam(raw string) (id string, ok bool) {
if raw == "" {
return "", true
}
return dto.DecodeID(raw)
}
// itemIDParam decodes a chi URL id param, writing 404 and reporting false when it isn't a
// well-formed GUID. Handlers must return immediately when ok is false.
func itemIDParam(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
id, ok := dto.DecodeID(chi.URLParam(r, key))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return "", false
}
return id, true
}
// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope).
func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) {
return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", "")))
// ok is false when a non-empty ParentId fails to decode (see decodeFilterParam).
func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool, ok bool) {
parentId, ok := decodeFilterParam(req.Params(r).StringOr("parentid", ""))
if !ok {
return nil, false, false
}
scopeIDs, isLibraryParent = resolveLibraryScope(ctx, parentId)
return scopeIDs, isLibraryParent, true
}
// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means
@ -51,7 +79,7 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer {
// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item.
func libraryView(lib model.Library) dto.BaseItemDto {
return dto.BaseItemDto{
Id: dto.EncodeID(strconv.Itoa(lib.ID)),
Id: dto.EncodeLibraryID(lib.ID),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",

View file

@ -53,8 +53,8 @@ var _ = Describe("getLyrics", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", LibraryID: 1},
{ID: "s2", Title: "Silent Song", LibraryID: 1},
{ID: testID("s1"), Title: "Song", LibraryID: 1},
{ID: testID("s2"), Title: "Silent Song", LibraryID: 1},
})
fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}}
api = &Router{
@ -66,7 +66,7 @@ var _ = Describe("getLyrics", func() {
doRequest := func(id string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})
// Clients send hex-encoded ids (matching real traffic and the other handler tests).
enc := dto.EncodeID(id)
r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx)
@ -76,10 +76,10 @@ var _ = Describe("getLyrics", func() {
}
It("returns 200 with a LyricDto for a track with synced lyrics", func() {
fake.lyrics["s1"] = model.LyricList{
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
w := doRequest("s1")
w := doRequest(testID("s1"))
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.LyricDto
@ -91,11 +91,11 @@ var _ = Describe("getLyrics", func() {
})
It("serves the main-kind lyric when a translation is also present", func() {
fake.lyrics["s1"] = model.LyricList{
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}},
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
w := doRequest("s1")
w := doRequest(testID("s1"))
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.LyricDto
@ -105,13 +105,13 @@ var _ = Describe("getLyrics", func() {
})
It("returns 404 when the service returns no lyrics", func() {
w := doRequest("s2")
w := doRequest(testID("s2"))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 when the main lyric has no lines", func() {
fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}}
w := doRequest("s1")
fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}}
w := doRequest(testID("s1"))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
@ -121,35 +121,35 @@ var _ = Describe("getLyrics", func() {
})
It("caches results so a second request doesn't re-invoke the service", func() {
fake.lyrics["s1"] = model.LyricList{
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
Expect(doRequest("s1").Code).To(Equal(http.StatusOK))
Expect(doRequest("s1").Code).To(Equal(http.StatusOK))
Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK))
Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK))
Expect(fake.calls).To(Equal(1))
})
It("caches empty results too", func() {
Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound))
Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound))
Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound))
Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound))
Expect(fake.calls).To(Equal(1))
})
It("completes and caches the fetch even when the request context is cancelled", func() {
fake.lyrics["s1"] = model.LyricList{
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"})
list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")})
Expect(list).ToNot(BeEmpty())
Expect(doRequest("s1").Code).To(Equal(http.StatusOK))
Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK))
Expect(fake.calls).To(Equal(1))
})
It("bounds the detached fetch with a timeout", func() {
Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound))
Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound))
Expect(fake.hadDeadline).To(BeTrue())
})
})

View file

@ -24,12 +24,12 @@ var _ = Describe("authenticate middleware", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
api = &Router{ds: ds}
})
tokenFor := func(name string) string {
t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name})
t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name})
Expect(err).ToNot(HaveOccurred())
return t
}
@ -129,7 +129,7 @@ var _ = Describe("withPlayer middleware", func() {
})
It("injects the player's server-forced transcoding into the context", func() {
players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"}
players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"}
_, trc, hasTrc := callWith()
Expect(hasTrc).To(BeTrue())
Expect(trc.TargetFormat).To(Equal("opus"))

View file

@ -17,16 +17,11 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve
// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal
// can't collide with real ids (those are hashes).
const playlistsFolderID = "playlists"
// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be
// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops.
func playlistsFolder() dto.BaseItemDto {
return dto.BaseItemDto{
Id: dto.EncodeID(playlistsFolderID),
Id: dto.PlaylistsFolderGUID,
Name: "Playlists",
Type: "ManualPlaylistsFolder",
CollectionType: "playlists",
@ -61,7 +56,12 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID))
decoded, ok := dto.DecodeIDs(body.Ids)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(r.Context(), decoded)
id, err := api.playlists.Create(r.Context(), "", body.Name, ids)
if err != nil {
api.internalError(w, r, err)
@ -80,7 +80,10 @@ type updatePlaylistRequest struct {
func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
var body updatePlaylistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
@ -96,7 +99,12 @@ func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) {
return
}
} else {
ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID))
decoded, ok := dto.DecodeIDs(*body.Ids)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(ctx, decoded)
if _, err := api.playlists.Create(ctx, id, "", ids); err != nil {
api.playlistError(w, r, err)
return
@ -146,7 +154,7 @@ func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields
// individually removable.
func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto {
item := dto.SongToBaseItem(t.MediaFile, fields)
item.PlaylistItemId = dto.EncodeID(t.ID)
item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID)
return item
}
@ -155,7 +163,10 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto {
// be probed.
func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
@ -184,7 +195,10 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) {
// playlist id can't probe for private playlists.
func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
repo, err := api.playlists.Tracks(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
@ -232,7 +246,7 @@ func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []strin
} else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil {
out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...)
} else {
out = append(out, id) // unknown id — pass through unchanged
out = append(out, id) // well-formed but unresolved — left for the caller's write to handle
}
}
return out
@ -251,8 +265,16 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin
// AddTracks enforces ownership; any error maps to 404.
func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
ids := api.expandContainerIDs(ctx, slice.Map(queryIDs(r, "ids"), dto.DecodeID))
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
decoded, ok := dto.DecodeIDs(queryIDs(r, "ids"))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(ctx, decoded)
if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
@ -265,8 +287,20 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
// ownership; any error maps to 404.
func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
ids := slice.Map(queryIDs(r, "entryids"), dto.DecodeID)
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
raw := queryIDs(r, "entryids")
ids := make([]string, 0, len(raw))
for _, entryGUID := range raw {
entry, ok := dto.DecodePlaylistEntryID(entryGUID)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids = append(ids, entry)
}
if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return

View file

@ -67,7 +67,7 @@ func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []s
if f.createErr != nil {
return "", f.createErr
}
return "pl-new", nil
return testID("pl-new"), nil
}
// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real
@ -145,15 +145,15 @@ var _ = Describe("Playlists", func() {
Describe("createPlaylist", func() {
It("creates a playlist and returns its id", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)).
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("s2"))+`"]}`)).
WithContext(context.Background())
invoke(api.createPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res map[string]string
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new")))
Expect(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new"))))
Expect(fp.createdName).To(Equal("Mix"))
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")}))
})
It("returns 400 on an invalid JSON body", func() {
@ -177,48 +177,48 @@ var _ = Describe("Playlists", func() {
Describe("getPlaylistItems", func() {
It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() {
fp.getPls = &model.Playlist{
ID: "pl1",
ID: testID("pl1"),
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}},
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}},
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
api.getPlaylistItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.TotalRecordCount).To(Equal(2))
Expect(res.Items).To(HaveLen(2))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
Expect(res.Items[0].Type).To(Equal("Audio"))
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1")))
Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2")))
Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2")))
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1")))
Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2"))))
Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2")))
})
It("pages with StartIndex/Limit, pushing them down to the query", func() {
fp.getPls = &model.Playlist{
ID: "pl1",
ID: testID("pl1"),
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
{ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}},
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
{ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil).
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil).
WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.getPlaylistItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.TotalRecordCount).To(Equal(3))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2"))))
Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
Expect(fp.tracksRepo.Options.Max).To(Equal(1))
})
@ -227,7 +227,7 @@ var _ = Describe("Playlists", func() {
fp.getErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "missing")
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing")))
api.getPlaylistItems(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
@ -245,73 +245,73 @@ var _ = Describe("Playlists", func() {
createWith := func(id string) {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)).
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(id)+`"]}`)).
WithContext(ctx)
invoke(api.createPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
}
It("passes a bare song id through unchanged", func() {
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}})
createWith("s1")
Expect(fp.createdIds).To(Equal([]string{"s1"}))
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}})
createWith(testID("s1"))
Expect(fp.createdIds).To(Equal([]string{testID("s1")}))
})
It("expands an album id into its songs, filtered by album", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}})
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"},
{ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")},
})
createWith("al1")
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters))
createWith(testID("al1"))
Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")}))
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters))
})
It("expands an artist id into its songs", func() {
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}})
createWith("ar1")
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters))
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}})
createWith(testID("ar1"))
Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")}))
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters))
})
It("expands a playlist id into its tracks' media file ids", func() {
fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"},
fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")},
}}
createWith("pl9")
Expect(fp.createdIds).To(Equal([]string{"s3", "s4"}))
createWith(testID("pl9"))
Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")}))
})
})
Describe("getPlaylist", func() {
It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() {
pls := &model.Playlist{
ID: "pl1",
ID: testID("pl1"),
Public: true,
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
},
}
fp.getPls, fp.getByIDPls = pls, pls
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.getPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.PlaylistInfo
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.OpenAccess).To(BeTrue())
Expect(res.Shares).To(BeEmpty())
Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")}))
Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("s2"))}))
})
It("returns 404 for a non-owned or absent playlist", func() {
fp.getErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "missing")
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing")))
invoke(api.getPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
@ -325,29 +325,29 @@ var _ = Describe("Playlists", func() {
It("deletes the playlist and returns 204", func() {
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("pl1"))
api.deleteItem(w, deleteReq(testID("pl1")))
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.deletePlaylistID).To(Equal("pl1"))
Expect(fp.deletePlaylistID).To(Equal(testID("pl1")))
})
It("returns 403 when the user doesn't own the playlist", func() {
fp.deleteErr = model.ErrNotAuthorized
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("pl1"))
api.deleteItem(w, deleteReq(testID("pl1")))
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("returns 404 for a missing playlist or non-playlist id", func() {
fp.deleteErr = model.ErrNotFound
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("al1"))
api.deleteItem(w, deleteReq(testID("al1")))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 500 on an unexpected error", func() {
fp.deleteErr = errors.New("boom")
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("pl1"))
api.deleteItem(w, deleteReq(testID("pl1")))
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
@ -355,39 +355,39 @@ var _ = Describe("Playlists", func() {
Describe("addToPlaylist", func() {
It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.addPlaylistID).To(Equal("pl1"))
Expect(fp.addIds).To(Equal([]string{"s1", "s2"}))
Expect(fp.addPlaylistID).To(Equal(testID("pl1")))
Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")}))
})
It("accepts a PascalCase Ids param (case-folded by the middleware)", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.addIds).To(Equal([]string{"s1", "s2"}))
Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")}))
})
It("returns 404 when the service rejects the request (not found/not owned)", func() {
fp.addErr = model.ErrNotAuthorized
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("passes no ids (not a spurious empty string) when the ids param is absent", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.addPlaylistID).To(Equal("pl1"))
Expect(fp.addPlaylistID).To(Equal(testID("pl1")))
Expect(fp.addIds).To(BeEmpty())
})
})
@ -395,39 +395,48 @@ var _ = Describe("Playlists", func() {
Describe("removeFromPlaylist", func() {
It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removePlaylistID).To(Equal("pl1"))
Expect(fp.removePlaylistID).To(Equal(testID("pl1")))
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
})
It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
})
It("404s an entryId that is a song id rather than a playlist-entry position", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(fp.removeIds).To(BeEmpty())
})
It("returns 404 when the service rejects the request (not found/not owned)", func() {
fp.removeErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removePlaylistID).To(Equal("pl1"))
Expect(fp.removePlaylistID).To(Equal(testID("pl1")))
Expect(fp.removeIds).To(BeEmpty())
})
})
@ -435,30 +444,30 @@ var _ = Describe("Playlists", func() {
Describe("getPlaylistUsers", func() {
It("returns the current user with CanEdit true", func() {
w := httptest.NewRecorder()
ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"})
r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx)
r = withChiURLParam(r, "playlistId", "pl1")
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"})
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx)
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
api.getPlaylistUsers(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res []dto.PlaylistUserPermissions
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}}))
Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}}))
})
})
Describe("getPlaylistUser", func() {
It("returns CanEdit true for the requested user", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background())
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background())
rctx := chi.NewRouteContext()
rctx.URLParams.Add("playlistId", "pl1")
rctx.URLParams.Add("userId", "u1")
rctx.URLParams.Add("playlistId", testID("pl1"))
rctx.URLParams.Add("userId", testID("u1"))
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
api.getPlaylistUser(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.PlaylistUserPermissions
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true}))
Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true}))
})
})
})

View file

@ -21,14 +21,19 @@ type playbackReport struct {
// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send
// it there) and is decoded here since it flows straight into scrobbler lookups by media file id.
// Finamp reports restored-queue playback with truncated ids, hence resolveItemID.
// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request
// failure; it just can't identify a track, same as an absent one.
func (api *Router) decodeReport(r *http.Request) playbackReport {
var body playbackReport
_ = json.NewDecoder(r.Body).Decode(&body)
if body.ItemId == "" {
body.ItemId = r.URL.Query().Get("itemid")
}
body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId))
if id, ok := dto.DecodeID(body.ItemId); ok {
body.ItemId = id
} else {
body.ItemId = ""
}
return body
}

View file

@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -57,8 +58,8 @@ var _ = Describe("Sessions", func() {
var pt *fakePlayTracker
authed := func(r *http.Request) *http.Request {
ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"})
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"})
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"})
ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"})
return r.WithContext(ctx)
}
@ -70,35 +71,46 @@ var _ = Describe("Sessions", func() {
Describe("reportPlaybackStart", func() {
It("reports playback start with the item id and position", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`)))
r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":10000000}`)))
invoke(api.reportPlaybackStart, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(pt.reported).To(HaveLen(1))
Expect(pt.reported[0].MediaId).To(Equal("s1"))
Expect(pt.reported[0].MediaId).To(Equal(testID("s1")))
Expect(pt.reported[0].PositionMs).To(Equal(int64(1000)))
Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying))
Expect(pt.reported[0].ClientId).To(Equal("p1"))
Expect(pt.reported[0].ClientId).To(Equal(testID("p1")))
Expect(pt.reported[0].ClientName).To(Equal("Finamp"))
})
It("falls back to the ItemId query param when the body has none", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil))
r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("s2")), nil))
invoke(api.reportPlaybackStart, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(pt.reported).To(HaveLen(1))
Expect(pt.reported[0].MediaId).To(Equal("s2"))
Expect(pt.reported[0].MediaId).To(Equal(testID("s2")))
})
It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","PositionTicks":10000000}`)))
invoke(api.reportPlaybackStart, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(pt.reported).To(HaveLen(1))
Expect(pt.reported[0].MediaId).To(BeEmpty())
})
})
Describe("reportPlaybackProgress", func() {
It("reports the playing state when not paused", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`)))
r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`)))
invoke(api.reportPlaybackProgress, w, r)
@ -110,7 +122,7 @@ var _ = Describe("Sessions", func() {
It("reports the paused state when IsPaused is true", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`)))
r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`)))
invoke(api.reportPlaybackProgress, w, r)
@ -123,14 +135,14 @@ var _ = Describe("Sessions", func() {
Describe("reportPlaybackStopped", func() {
It("reports the stopped state and lets the scrobbler apply its play threshold", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`)))
r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":600000000}`)))
invoke(api.reportPlaybackStopped, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(pt.reported).To(HaveLen(1))
Expect(pt.reported[0].MediaId).To(Equal("s1"))
Expect(pt.reported[0].MediaId).To(Equal(testID("s1")))
Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped))
Expect(pt.reported[0].PositionMs).To(Equal(int64(60000)))
// IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides

View file

@ -6,7 +6,6 @@ import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -56,7 +55,10 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch
// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are
// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying.
func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit)
api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult {
return api.similarArtists(ctx, id, limit)
@ -68,7 +70,10 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) {
// result (not 404) so the client stops retrying.
func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit)
entity, err := model.GetEntityByID(ctx, api.ds, id)
@ -93,7 +98,10 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) {
// results, never a 404 the client would surface as an error.
func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit)
entity, err := model.GetEntityByID(ctx, api.ds, id)

View file

@ -34,7 +34,7 @@ var _ = Describe("awaitSimilar", func() {
})
It("returns the fetch result when it completes within the wait", func() {
res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult {
res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult {
return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0)
})
Expect(res.Items).To(HaveLen(1))
@ -45,7 +45,7 @@ var _ = Describe("awaitSimilar", func() {
shortenWait()
release := make(chan struct{})
DeferCleanup(func() { close(release) })
res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult {
res := api.awaitSimilar(ctxFor(testID("u1")), "id2", 20, func(context.Context) dto.QueryResult {
<-release // hung provider; would finish caching in the background
return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0)
})
@ -64,8 +64,8 @@ var _ = Describe("awaitSimilar", func() {
}
// Both calls time out, but the flight can't complete before release closes, so the
// second call must join it rather than start a new fetch.
api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch)
api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch)
api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch)
api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch)
close(release)
Eventually(calls.Load).Should(Equal(int32(1)))
Consistently(calls.Load, "50ms").Should(Equal(int32(1)))
@ -77,15 +77,15 @@ var _ = Describe("awaitSimilar", func() {
calls.Add(1)
return result(nil, 0, 0)
}
api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch)
api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch)
api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch)
api.awaitSimilar(ctxFor(testID("u2")), "id4", 20, fetch)
Expect(calls.Load()).To(Equal(int32(2)))
})
It("hands the fetch a deadline-bounded background context", func() {
var deadline time.Time
var hasDeadline bool
api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult {
api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult {
deadline, hasDeadline = ctx.Deadline()
return result(nil, 0, 0)
})
@ -123,16 +123,16 @@ var _ = Describe("getInstantMix", func() {
ds := &tests.MockDataStore{}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Seed Song", LibraryID: 1},
{ID: testID("s1"), Title: "Seed Song", LibraryID: 1},
})
release := make(chan struct{})
DeferCleanup(func() { close(release) })
api := &Router{ds: ds, provider: &blockingProvider{release: release}}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil).
WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}))
r = withChiURLParam(r, "itemId", dto.EncodeID("s1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil).
WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.getInstantMix(w, r)
var res dto.QueryResult
@ -145,18 +145,18 @@ var _ = Describe("getInstantMix", func() {
// queue, so InstantMix gets its own, higher ceiling.
It("honors a mix-sized limit above the Similar ceiling", func() {
const want = 250
songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}}
songs := model.MediaFiles{{ID: testID("s1"), Title: "Seed Song", LibraryID: 1}}
for i := range want + 50 { // more than requested, so only the limit bounds the result
songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1})
songs = append(songs, model.MediaFile{ID: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1})
}
ds := &tests.MockDataStore{}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil).
WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}))
r = withChiURLParam(r, "itemId", dto.EncodeID("s1"))
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil).
WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.getInstantMix(w, r)
var res dto.QueryResult

View file

@ -88,9 +88,9 @@ var _ = Describe("handleSocket", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"})
t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"})
Expect(err).ToNot(HaveOccurred())
token = t

View file

@ -23,7 +23,10 @@ import (
// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library.
func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) {
ctx := r.Context()
id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return nil, false
}
mf, err := api.ds.MediaFile(ctx).Get(id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
@ -62,7 +65,7 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) {
src.TranscodingSubProtocol = "http"
src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token)
}
api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID})
api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)})
}
// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal,

View file

@ -26,7 +26,7 @@ var _ = Describe("Stream", func() {
// alice has access to library 1 only.
ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
}
BeforeEach(func() {
@ -43,30 +43,30 @@ var _ = Describe("Stream", func() {
Describe("getPlaybackInfo", func() {
It("returns a media source for an accessible track", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("s1"))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.getPlaybackInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.PlaybackInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.MediaSources).To(HaveLen(1))
Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
Expect(res.MediaSources[0].Container).To(Equal("mp3"))
Expect(res.MediaSources[0].Size).To(Equal(int64(1000)))
Expect(res.PlaySessionId).ToNot(BeEmpty())
Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$"))
})
It("returns 404 for a track in a library the user can't access", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.getPlaybackInfo(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
@ -75,7 +75,7 @@ var _ = Describe("Stream", func() {
It("returns 404 when the id doesn't match any media file", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
api.getPlaybackInfo(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
@ -83,8 +83,8 @@ var _ = Describe("Stream", func() {
playbackInfo := func() dto.PlaybackInfoResponse {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("s1"))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.getPlaybackInfo(w, r)
var res dto.PlaybackInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
@ -103,10 +103,10 @@ var _ = Describe("Stream", func() {
It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
})
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
"s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
}}
Expect(lyricStreams(playbackInfo())).To(HaveLen(1))
@ -114,7 +114,7 @@ var _ = Describe("Stream", func() {
It("advertises no Lyric stream when the pipeline finds nothing", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
})
Expect(lyricStreams(playbackInfo())).To(BeEmpty())
@ -122,10 +122,10 @@ var _ = Describe("Stream", func() {
It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
})
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
"s1": {{Kind: "main", Lang: "eng"}},
testID("s1"): {{Kind: "main", Lang: "eng"}},
}}
Expect(lyricStreams(playbackInfo())).To(BeEmpty())
@ -133,10 +133,10 @@ var _ = Describe("Stream", func() {
It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`},
})
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
"s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
}}
Expect(lyricStreams(playbackInfo())).To(HaveLen(1))
@ -146,20 +146,20 @@ var _ = Describe("Stream", func() {
// Own ID: an erroring loader isn't cached, but a shared ID could still pick up
// another test's cached (non-error) result and mask this assertion.
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
{ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
})
api.lyrics = &fakeLyricsService{err: errors.New("boom")}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("s-err"))
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err")))
api.getPlaybackInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.PlaybackInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.MediaSources).To(HaveLen(1))
Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s-err")))
Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s-err"))))
Expect(lyricStreams(res)).To(BeEmpty())
})
})
@ -167,12 +167,12 @@ var _ = Describe("Stream", func() {
Describe("streamAudio", func() {
It("invokes the transcode decider and streamer for an accessible track", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
})
streamer.content = "audio-bytes"
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -183,11 +183,11 @@ var _ = Describe("Stream", func() {
It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
@ -198,7 +198,7 @@ var _ = Describe("Stream", func() {
It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
invoke(api.streamAudio, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
@ -208,11 +208,11 @@ var _ = Describe("Stream", func() {
It("converts the bps audioBitRate param to kbps", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
Expect(decider.req.BitRate).To(Equal(320))
@ -220,11 +220,11 @@ var _ = Describe("Stream", func() {
It("uses the audioCodec param as target format when no container is given", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
Expect(decider.req.Format).To(Equal("aac"))
@ -232,12 +232,12 @@ var _ = Describe("Stream", func() {
It("returns 500 and logs when the streamer fails", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
})
streamer.err = errors.New("boom")
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
@ -247,14 +247,14 @@ var _ = Describe("Stream", func() {
Describe("streamHls", func() {
BeforeEach(func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1},
})
})
hls := func(query string, ctx context.Context) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx)
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx)
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamHls, w, r)
return w
}
@ -302,7 +302,7 @@ var _ = Describe("Stream", func() {
It("returns 404 for a track in a library the user can't access", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2},
{ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2},
})
Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound))
})
@ -316,12 +316,12 @@ var _ = Describe("Stream", func() {
Describe("streamFile", func() {
It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
})
streamer.content = "audio-bytes"
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.streamFile(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -333,11 +333,11 @@ var _ = Describe("Stream", func() {
It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2},
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2},
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", "s1")
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
api.streamFile(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
@ -348,7 +348,7 @@ var _ = Describe("Stream", func() {
It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
api.streamFile(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))

View file

@ -2,10 +2,12 @@ package jellyfin
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/http"
"path"
"strings"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
@ -42,25 +44,32 @@ func (api *Router) serverID(ctx context.Context) string {
return api.serverIDVal
}
if api.ds == nil {
api.serverIDVal = uuid.NewString()
api.serverIDVal = newServerID()
return api.serverIDVal
}
id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey)
switch {
case errors.Is(err, model.ErrNotFound):
id = uuid.NewString()
id = newServerID()
if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil {
log.Error(ctx, "Jellyfin API: could not persist server id", err)
return id
}
case err != nil:
log.Error(ctx, "Jellyfin API: could not read server id", err)
return uuid.NewString()
return newServerID()
}
api.serverIDVal = id
// Ids persisted before this change are dashed; normalize on read rather than rewriting the DB.
api.serverIDVal = strings.ReplaceAll(id, "-", "")
return api.serverIDVal
}
// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")).
func newServerID() string {
u := uuid.New()
return hex.EncodeToString(u[:])
}
func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo {
return dto.PublicSystemInfo{
LocalAddress: localAddress(r),

View file

@ -125,21 +125,33 @@ var _ = Describe("System", func() {
})
It("does not overwrite or pin over a stored id when the property read fails transiently", func() {
Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed())
Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed())
r := &Router{ds: ds}
props := ds.Property(ctx).(*tests.MockedPropertyRepo)
props.Error = errors.New("database is locked")
degraded := r.serverID(ctx)
Expect(degraded).ToNot(BeEmpty())
Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one
Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one
props.Error = nil
// Once the DB recovers, the stored id is intact and served again.
Expect(r.serverID(ctx)).To(Equal("stable-id"))
Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8"))
stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey)
Expect(err).ToNot(HaveOccurred())
Expect(stored).To(Equal("stable-id"))
Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8"))
})
It("returns Jellyfin's no-dash GUID form", func() {
r := &Router{ds: ds}
Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$"))
})
It("strips dashes from an already-persisted id", func() {
Expect(ds.Property(ctx).Put(
consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed())
r := &Router{ds: ds}
Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427"))
})
})
})

View file

@ -1,113 +0,0 @@
package jellyfin
import (
"context"
"slices"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming
// Jellyfin GUIDs). All Navidrome ids are 22 chars (share ids 10), so length alone flags a
// truncated id. See README.
//
// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers
// and ParentId scoping don't (a restored queue never edits playlists or browses by container id).
const truncatedIDLen = 16
// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is
// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous.
func (api *Router) resolveItemID(ctx context.Context, id string) string {
if len(id) != truncatedIDLen {
return id
}
probes := []func() []string{
func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) },
func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) },
func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) },
func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) },
}
for _, probe := range probes {
switch ids := probe(); len(ids) {
case 0:
continue
case 1:
log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0])
return ids[0]
default:
log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id)
return id
}
}
return id
}
// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of
// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the
// leftovers (containers, unknowns) fall back to the per-id probes.
func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string {
var truncated []string
for _, id := range ids {
if len(id) == truncatedIDLen {
truncated = append(truncated, id)
}
}
if len(truncated) == 0 {
return ids
}
byPrefix := make(map[string][]string, len(truncated))
for chunk := range slice.CollectChunks(slices.Values(truncated), 100) {
ranges := make(squirrel.Or, len(chunk))
for i, p := range chunk {
ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}}
}
mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges})
if err != nil {
log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err)
break
}
for _, mf := range mfs {
p := mf.ID[:truncatedIDLen]
byPrefix[p] = append(byPrefix[p], mf.ID)
}
}
out := make([]string, len(ids))
for i, id := range ids {
switch full := byPrefix[id]; {
case len(full) == 1:
out[i] = full[0]
case len(id) == truncatedIDLen:
out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide
default:
out[i] = id
}
}
return out
}
// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to
// detect ambiguity). '\x7f' is above every character the id alphabets use.
func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string {
rows, err := getAll(model.QueryOptions{
Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}},
Max: 2,
})
if err != nil {
return nil
}
ids := make([]string, len(rows))
for i, row := range rows {
ids[i] = id(row)
}
return ids
}
func mediaFileID(mf model.MediaFile) string { return mf.ID }
func albumID(al model.Album) string { return al.ID }
func artistID(ar model.Artist) string { return ar.ID }
func playlistID(pl model.Playlist) string { return pl.ID }

View file

@ -19,7 +19,7 @@ import (
var _ = Describe("Users", func() {
var api *Router
authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request {
ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs})
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
return r.WithContext(ctx)
}
BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} })
@ -35,13 +35,13 @@ var _ = Describe("Users", func() {
Expect(res.Items).To(HaveLen(2))
Expect(res.TotalRecordCount).To(Equal(2))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1)))
Expect(res.Items[0].Name).To(Equal("Music"))
Expect(res.Items[0].Type).To(Equal("CollectionFolder"))
Expect(res.Items[0].CollectionType).To(Equal("music"))
Expect(res.Items[0].IsFolder).To(BeTrue())
Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2")))
Expect(res.Items[1].Id).To(Equal(dto.EncodeLibraryID(2)))
Expect(res.Items[1].Name).To(Equal("Podcasts"))
})
@ -53,7 +53,7 @@ var _ = Describe("Users", func() {
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1")))
Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1)))
})
It("returns no views for a user with no library access", func() {
@ -92,8 +92,8 @@ var _ = Describe("Users", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ur = api.ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed())
Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed())
})
It("returns an empty list when the config is unset", func() {
@ -106,7 +106,7 @@ var _ = Describe("Users", func() {
users := publicUsers()
Expect(users).To(HaveLen(2))
Expect(users[0].Name).To(Equal("bob"))
Expect(users[0].Id).To(Equal(dto.EncodeID("u2")))
Expect(users[0].Id).To(Equal(dto.EncodeID(testID("u2"))))
Expect(users[1].Name).To(Equal("alice"))
// The public list must not expose Policy/Configuration to unauthenticated callers.
Expect(users[0].Policy).To(BeNil())