navidrome/server/jellyfin/e2e/playlists_test.go
Deluan Quintão c66ef04dd3
refactor(jellyfin): emit real 128-bit GUIDs as item ids (#5942)
* test(jellyfin): use canonical ids in dto fixtures

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

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

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

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

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

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

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

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

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

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

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

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

BREAKING: Jellyfin clients see entirely new item ids.

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

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

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

* refactor(jellyfin): drop the id truncation workaround

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed
ids now also 404 on the image and similar/instant-mix routes, which previously answered with a
placeholder or an empty list.
2026-08-12 19:02:34 -04:00

356 lines
15 KiB
Go

package e2e
import (
"bytes"
"image"
jpeglib "image/jpeg"
"net/http"
"os"
"github.com/navidrome/navidrome/model"
"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)
})
// 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() {
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)
})
// Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a
// comma-joined value; all ids must be added, not just the first.
It("adds multiple songs sent as repeated ids params", func() {
plID := createPlaylist("Multi", nil)
url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) +
"&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!"))
Expect(post(url, "").Code).To(Equal(http.StatusNoContent))
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
Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent))
Expect(playlistItems(plID).TotalRecordCount).To(Equal(1))
})
It("removes multiple entries sent as repeated entryIds params", func() {
plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))})
items := playlistItems(plID).Items
url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId
Expect(del(url).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(enc(testID("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"))
})
It("sorts playlists by name when SortBy=SortName", func() {
createPlaylist("Charlie", nil)
createPlaylist("Alpha", nil)
createPlaylist("Bravo", nil)
q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName"))
Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"}))
})
})
// Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists
// playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item
// whose CollectionType is "playlists", its query resolves undefined and React Query retries in a
// backoff loop that stalls the home screen.
Describe("playlists library folder (ManualPlaylistsFolder)", func() {
It("returns a synthetic playlists folder with CollectionType=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(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=" + 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".
Expect(q.Items[0].Path).To(ContainSubstring("data"))
})
It("resolves the synthetic playlists folder by its own advertised id", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item)
Expect(item.Type).To(Equal("ManualPlaylistsFolder"))
Expect(item.CollectionType).To(Equal("playlists"))
Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID))
})
})
// Real Jellyfin returns a playlist's children for /Items?ParentId=<playlistId> with no
// IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way.
Describe("browsing a playlist via the generic /Items path", func() {
It("lists the playlist's tracks for a typeless ParentId query", func() {
plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))})
q := queryResult(get("/Items?parentId=" + enc(plID)))
Expect(q.TotalRecordCount).To(Equal(2))
Expect(names(q.Items)).To(ConsistOf("Come Together", "So What"))
Expect(q.Items[0].Type).To(Equal("Audio"))
})
It("pages the playlist's tracks", func() {
plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))})
q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1"))
Expect(q.Items).To(HaveLen(1))
Expect(q.TotalRecordCount).To(Equal(2))
})
// Jellify opens a playlist with ParentId=<playlist>&IncludeItemTypes=Audio&Recursive=false.
// The playlist id must resolve to its tracks, not be treated as an album id (which returns none).
It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() {
plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))})
q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false"))
Expect(q.TotalRecordCount).To(Equal(2))
Expect(names(q.Items)).To(ConsistOf("Come Together", "So What"))
})
})
Describe("cover art", func() {
// A real (decodable) image: the upload endpoint validates by decoding, like the native one.
var jpeg []byte
BeforeEach(func() {
var buf bytes.Buffer
Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed())
jpeg = buf.Bytes()
})
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))
})
// An upload must clear the resolved artwork state, or clients keep serving the stale cover
// from their tag-keyed cache until the next scan.
It("clears the resolved image tag after a cover upload", func() {
plID := createPlaylist("Cover Tag", nil)
Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{
ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: plID, Hash: "1111111111111111",
})).To(Succeed())
imageTag := func() string {
q := queryResult(get("/Items?ids=" + enc(plID)))
Expect(q.Items).To(HaveLen(1))
return q.Items[0].ImageTags["Primary"]
}
Expect(imageTag()).To(Equal("1111111111111111"))
Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code).
To(Equal(http.StatusNoContent))
// The upload re-queues resolution instead of resolving inline, so the tag goes bare.
Expect(imageTag()).ToNot(Equal("1111111111111111"))
})
})
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))
var info dto.PlaylistInfo
parseInto(get("/Playlists/"+enc(plID)), &info)
Expect(info.OpenAccess).To(BeTrue())
// Now visible to other users.
Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1))
})
It("renames a playlist", func() {
plID := createPlaylist("Old Name", nil)
Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent))
pls, _ := ds.Playlist(ctx).Get(plID)
Expect(pls.Name).To(Equal("New Name"))
})
It("replaces the track list when Ids are provided", func() {
plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))})
// Replace with a single different track.
Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent))
q := playlistItems(plID)
Expect(q.TotalRecordCount).To(Equal(1))
Expect(q.Items[0].Name).To(Equal("So What"))
})
It("clears the track list when an explicit empty Ids array is sent", func() {
plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))})
Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent))
Expect(playlistItems(plID).TotalRecordCount).To(Equal(0))
})
It("leaves the track list intact when Ids is omitted (metadata-only update)", func() {
plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))})
Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent))
Expect(playlistItems(plID).TotalRecordCount).To(Equal(2))
})
It("applies Name and IsPublic sent together with a track replacement", func() {
plID := createPlaylist("Combo", []string{enc(songID("Come Together"))})
body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}`
Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent))
q := playlistItems(plID)
Expect(q.TotalRecordCount).To(Equal(1))
Expect(q.Items[0].Name).To(Equal("So What"))
pls, _ := ds.Playlist(ctx).Get(plID)
Expect(pls.Name).To(Equal("Combo Renamed"))
Expect(pls.Public).To(BeTrue())
})
It("forbids a non-owner from updating a public playlist", func() {
plID := createPlaylist("Owned", nil)
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() {
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))
})
})
})