diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 7d78ee103..4d0b869ee 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -185,6 +185,19 @@ func postAs(u model.User, path, body string) *httptest.ResponseRecorder { func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } +// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). +func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", path, bytes.NewReader(body)) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + r.Header.Set("Content-Type", contentType) + router.ServeHTTP(w, r) + return w +} + // parseInto asserts a 200 and unmarshals the JSON body into target. func parseInto(w *httptest.ResponseRecorder, target any) { Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) @@ -198,6 +211,20 @@ func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { return q } +// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a +// client would send) and returns its decoded Navidrome id. +func createPlaylist(name string, encodedIds []string) string { + if encodedIds == nil { + encodedIds = []string{} + } + body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) + Expect(err).ToNot(HaveOccurred()) + var res map[string]string + parseInto(post("/Playlists", string(body)), &res) + Expect(res["Id"]).ToNot(BeEmpty()) + return dto.DecodeID(res["Id"]) +} + // --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- func enc(id string) string { return dto.EncodeID(id) } diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go new file mode 100644 index 000000000..15856df2f --- /dev/null +++ b/server/jellyfin/e2e/images_test.go @@ -0,0 +1,50 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite +// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert +// resolution and elevation without needing real image processing. +var _ = Describe("Item images", func() { + BeforeEach(func() { setupTestDB() }) + + It("resolves an album's Primary image", func() { + id := albumID("Abbey Road") + w := get("/Items/" + enc(id) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves an artist's Primary image", func() { + id := artistID("Miles Davis") + Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves a private playlist's cover under an elevated admin context", func() { + // Regression: the public image route carries no user, so a private playlist would fail its + // visibility filter without the elevated-context resolution. + plID := createPlaylist("Private Mix", nil) + w := get("/Items/" + enc(plID) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + + u, ok := request.UserFrom(artworkSpy.lastCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) + + It("serves images without authentication (public route)", func() { + id := albumID("IV") + w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + }) +}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go new file mode 100644 index 000000000..368c89a5a --- /dev/null +++ b/server/jellyfin/e2e/playlists_test.go @@ -0,0 +1,136 @@ +package e2e + +import ( + "net/http" + "os" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + BeforeEach(func() { setupTestDB() }) + + playlistItems := func(plID string) dto.QueryResult { + return queryResult(get("/Playlists/" + enc(plID) + "/Items")) + } + + Describe("create", func() { + It("creates an empty playlist", func() { + plID := createPlaylist("Empty", nil) + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeFalse()) + Expect(info.Shares).To(BeEmpty()) + Expect(info.ItemIds).To(BeEmpty()) + }) + + It("creates a playlist from song ids", func() { + plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("expands an album id into its tracks", func() { + plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + + It("expands an artist id into its tracks", func() { + plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) + }) + }) + + Describe("items", func() { + It("tags each entry with a PlaylistItemId", func() { + plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) + q := playlistItems(plID) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].Type).To(Equal("Audio")) + Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) + }) + }) + + Describe("add and remove", func() { + It("adds a song by id", func() { + plID := createPlaylist("Add", nil) + Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("adds an album (expanding to its tracks)", func() { + plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) + post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) + }) + + 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 + Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("users", func() { + It("reports the current user as an editor", func() { + plID := createPlaylist("Perms", nil) + var perms []dto.PlaylistUserPermissions + parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) + Expect(perms).To(HaveLen(1)) + Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].CanEdit).To(BeTrue()) + }) + }) + + Describe("listing", func() { + It("lists a created playlist advertising a Primary image tag", func() { + createPlaylist("Listed", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Listed")) + Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) + }) + }) + + Describe("cover art", func() { + jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00} + + It("uploads and removes a playlist cover", func() { + plID := createPlaylist("Cover", nil) + + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + pls, err := ds.Playlist(ctx).Get(plID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.UploadedImage).ToNot(BeEmpty()) + _, statErr := os.Stat(pls.UploadedImagePath()) + Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") + + Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) + pls, _ = ds.Playlist(ctx).Get(plID) + Expect(pls.UploadedImage).To(BeEmpty()) + }) + + It("rejects cover upload for a non-playlist item", func() { + Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNotImplemented)) + }) + }) + + Describe("delete", func() { + It("deletes a playlist", func() { + plID := createPlaylist("ToDelete", nil) + Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + }) + + It("returns 404 when deleting a non-playlist item", func() { + Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) + }) + }) +})