diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 13ec79aa8..195173c2a 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -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": }` (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 diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index f2c2f546e..ec84f65e0 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -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 diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 9e811a379..dd1487011 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -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") + `"]}`)) }) }) }) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index e8e9cbd4b..fe69c8321 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -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{} diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go index b01a4bf92..4cc1a2cc5 100644 --- a/server/jellyfin/audiomuse.go +++ b/server/jellyfin/audiomuse.go @@ -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 diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go index e9d6d4e85..6815753fd 100644 --- a/server/jellyfin/audiomuse_test.go +++ b/server/jellyfin/audiomuse_test.go @@ -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("")) + }) }) diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index b51420f1a..5a613f828 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -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", diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index faf43d352..9acd8d5a8 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -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 { diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index e346b56cb..2825c03e8 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -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= 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)) + }) }) }) diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go index 1d8ec47e4..a4eebb0d3 100644 --- a/server/jellyfin/dto/dto_suite_test.go +++ b/server/jellyfin/dto/dto_suite_test.go @@ -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) } diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go index 2ac7e2a79..2c8b5317b 100644 --- a/server/jellyfin/dto/ids.go +++ b/server/jellyfin/dto/ids.go @@ -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 } diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go index d85786d99..c95dbd790 100644 --- a/server/jellyfin/dto/ids_test.go +++ b/server/jellyfin/dto/ids_test.go @@ -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()) + }) }) }) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 31cdba1dd..1ebf66ca1 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -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), diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 77b78e55f..576482e61 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -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{ diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go index b1ad850e3..182fd5436 100644 --- a/server/jellyfin/e2e/annotations_test.go +++ b/server/jellyfin/e2e/annotations_test.go @@ -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)) }) }) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go index 383dbab83..074c8e1a4 100644 --- a/server/jellyfin/e2e/audiomuse_test.go +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -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() { diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 7128972ba..806b0e5e7 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -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() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 14ab8b62b..004090de6 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -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= (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=. 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=&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= (scoping) plus GenreIds=. 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)) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ca1308e9e..1ee19ea01 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -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) --- diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go index 279f288b8..a49019bbf 100644 --- a/server/jellyfin/e2e/lyrics_test.go +++ b/server/jellyfin/e2e/lyrics_test.go @@ -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)) }) }) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go index 015d82d91..d3a521332 100644 --- a/server/jellyfin/e2e/multiuser_test.go +++ b/server/jellyfin/e2e/multiuser_test.go @@ -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()) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 08d6f4f6f..50c2e17f2 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -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() { diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go index 6c26569fc..fa14f6811 100644 --- a/server/jellyfin/e2e/search_test.go +++ b/server/jellyfin/e2e/search_test.go @@ -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() { diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 43ef857fa..5f1a88a21 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -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)) + }) }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index c096d904a..954981910 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -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() { diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index b844e7c74..65f02d7a0 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -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) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index bdd47e956..0e0d6220d 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -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) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index fed5bbbc8..de0da88eb 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -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= 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= 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= 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 diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 3213379e5..b227ab719 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -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=; 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= 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 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()) + }) + }) }) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go index aab9628a0..2ae7da6e2 100644 --- a/server/jellyfin/jellyfin_suite_test.go +++ b/server/jellyfin/jellyfin_suite_test.go @@ -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. diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2830f1105..b21603843 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -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", diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index 402d14ed0..2cf53a280 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -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()) }) }) diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index f3aa65d6f..b17b9a4ec 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -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")) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 9afee5ac3..805b369c8 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -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 diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 7770cb6db..3599cc784 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -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})) }) }) }) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f83e5182d..a145e7359 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -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 } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 24f945efd..05151c39a 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -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 diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 2698073bf..3e0b3b11b 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -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) diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index 302566195..dc8c1e605 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -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 diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 8c509fe91..401c791fd 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -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 diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 022c00c96..86746f89a 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -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, diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index f77c10ec1..d6220d33f 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -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)) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 6f0c359d4..ea553fd87 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -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), diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 7846e4a20..a7f7df6a4 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -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")) }) }) }) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go deleted file mode 100644 index bb3e4ce9b..000000000 --- a/server/jellyfin/truncated_ids.go +++ /dev/null @@ -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 } diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 6a1597b70..3e53d598f 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -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())