mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-05 09:48:37 +01:00
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly.
688 lines
25 KiB
Go
688 lines
25 KiB
Go
package e2e
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Playlist Endpoints", Ordered, func() {
|
|
var playlistID string
|
|
var songIDs []string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
// Look up song IDs from scanned data for playlist operations
|
|
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(songs)).To(BeNumerically(">=", 5))
|
|
for _, s := range songs {
|
|
songIDs = append(songIDs, s.ID)
|
|
}
|
|
})
|
|
|
|
It("getPlaylists returns empty list initially", func() {
|
|
resp := doReq("getPlaylists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlists).ToNot(BeNil())
|
|
Expect(resp.Playlists.Playlist).To(BeEmpty())
|
|
})
|
|
|
|
It("createPlaylist creates a new playlist with songs", func() {
|
|
resp := doReq("createPlaylist", "name", "Test Playlist",
|
|
"songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
|
playlistID = resp.Playlist.Id
|
|
})
|
|
|
|
It("getPlaylist returns playlist with tracks in order", func() {
|
|
resp := doReq("getPlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
|
})
|
|
|
|
It("createPlaylist without name or playlistId returns error", func() {
|
|
resp := doReq("createPlaylist", "songId", songIDs[0])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("createPlaylist with playlistId replaces tracks on existing playlist", func() {
|
|
// Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4]
|
|
resp := doReq("createPlaylist", "playlistId", playlistID,
|
|
"songId", songIDs[3], "songId", songIDs[4])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.Id).To(Equal(playlistID))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
|
})
|
|
|
|
It("updatePlaylist can rename the playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
// Verify the rename
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Name).To(Equal("Renamed Playlist"))
|
|
// Tracks should be unchanged
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
|
|
It("updatePlaylist can set comment", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Comment).To(Equal("My favorite songs"))
|
|
})
|
|
|
|
It("updatePlaylist can set public visibility", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Public).To(BeTrue())
|
|
})
|
|
|
|
It("updatePlaylist can add songs", func() {
|
|
// Playlist currently has [song3, song4], add song0
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0]))
|
|
})
|
|
|
|
It("updatePlaylist can add multiple songs at once", func() {
|
|
// Playlist currently has [song3, song4, song0], add song1 and song2
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(5)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(5))
|
|
})
|
|
|
|
It("updatePlaylist can remove songs by index and verifies correct songs remain", func() {
|
|
// Playlist has [song3, song4, song0, song1, song2]
|
|
// Remove index 0 (song3) and index 2 (song0)
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIndexToRemove", "0", "songIndexToRemove", "2")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
|
})
|
|
|
|
It("updatePlaylist can remove and add songs in a single call", func() {
|
|
// Playlist has [song4, song1, song2]
|
|
// Remove index 1 (song1) and add song3
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIndexToRemove", "1", "songIdToAdd", songIDs[3])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3]))
|
|
})
|
|
|
|
It("updatePlaylist can combine metadata change with track removal", func() {
|
|
// Playlist has [song4, song2, song3]
|
|
// Rename + remove index 0 (song4)
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"name", "Final Playlist", "songIndexToRemove", "0")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Name).To(Equal("Final Playlist"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3]))
|
|
})
|
|
|
|
It("updatePlaylist can remove all songs from playlist", func() {
|
|
// Playlist has [song2, song3] — remove both
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIndexToRemove", "0", "songIndexToRemove", "1")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
|
Expect(resp.Playlist.Entry).To(BeEmpty())
|
|
})
|
|
|
|
It("updatePlaylist can add songs to an empty playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIdToAdd", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(1)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(1))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
|
})
|
|
|
|
It("updatePlaylist without playlistId returns error", func() {
|
|
resp := doReq("updatePlaylist", "name", "No ID")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("getPlaylists shows the playlist", func() {
|
|
resp := doReq("getPlaylists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlists.Playlist).To(HaveLen(1))
|
|
Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID))
|
|
})
|
|
|
|
It("deletePlaylist removes the playlist", func() {
|
|
resp := doReq("deletePlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
It("getPlaylist on deleted playlist returns error", func() {
|
|
resp := doReq("getPlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("getPlaylists returns empty after deletion", func() {
|
|
resp := doReq("getPlaylists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlists.Playlist).To(BeEmpty())
|
|
})
|
|
|
|
Describe("Playlist Permissions", Ordered, func() {
|
|
var songIDs []string
|
|
var adminPrivateID string
|
|
var adminPublicID string
|
|
var regularPlaylistID string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(songs)).To(BeNumerically(">=", 3))
|
|
for _, s := range songs {
|
|
songIDs = append(songIDs, s.ID)
|
|
}
|
|
})
|
|
|
|
It("admin creates a private playlist", func() {
|
|
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private",
|
|
"songId", songIDs[0], "songId", songIDs[1])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
adminPrivateID = resp.Playlist.Id
|
|
})
|
|
|
|
It("admin creates a public playlist", func() {
|
|
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public",
|
|
"songId", songIDs[0], "songId", songIDs[1])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
adminPublicID = resp.Playlist.Id
|
|
|
|
// Make it public
|
|
resp = doReqWithUser(adminUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "public", "true")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
It("regular user creates a playlist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist",
|
|
"songId", songIDs[0])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
regularPlaylistID = resp.Playlist.Id
|
|
})
|
|
|
|
// --- Private playlist: regular user gets "not found" (repo hides it entirely) ---
|
|
|
|
It("regular user cannot see admin's private playlist", func() {
|
|
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("regular user cannot update admin's private playlist (not found)", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPrivateID, "name", "Hacked")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("regular user cannot delete admin's private playlist (not found)", func() {
|
|
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
// --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) ---
|
|
|
|
It("regular user can see admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
|
})
|
|
|
|
It("regular user cannot update admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "name", "Hacked")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot add songs to admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "songIdToAdd", songIDs[2])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot remove songs from admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "songIndexToRemove", "0")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot delete admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist",
|
|
"playlistId", adminPublicID, "songId", songIDs[2])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
// --- Regular user can manage their own playlists ---
|
|
|
|
It("regular user can update their own playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", regularPlaylistID, "name", "My Updated Playlist")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
|
Expect(resp.Playlist.Name).To(Equal("My Updated Playlist"))
|
|
})
|
|
|
|
It("regular user can add songs to their own playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", regularPlaylistID, "songIdToAdd", songIDs[1])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
|
|
It("regular user can delete their own playlist", func() {
|
|
resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
// --- Admin can manage any user's playlists ---
|
|
|
|
It("admin can update any user's playlist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited",
|
|
"songId", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
plsID := resp.Playlist.Id
|
|
|
|
resp = doReqWithUser(adminUser, "updatePlaylist",
|
|
"playlistId", plsID, "name", "Admin Edited")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Edited"))
|
|
})
|
|
|
|
It("admin can delete any user's playlist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted",
|
|
"songId", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
plsID := resp.Playlist.Id
|
|
|
|
resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
})
|
|
|
|
// --- Verify admin's playlists are unchanged ---
|
|
|
|
It("admin's private playlist is unchanged after failed regular user operations", func() {
|
|
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Private"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
|
|
It("admin's public playlist is unchanged after failed regular user operations", func() {
|
|
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
})
|
|
|
|
Describe("Smart Playlist Protection", Ordered, func() {
|
|
var smartPlaylistID string
|
|
var songID string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
// Look up a song ID for mutation tests
|
|
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
songID = songs[0].ID
|
|
|
|
// Insert a smart playlist directly into the DB
|
|
smartPls := &model.Playlist{
|
|
Name: "Smart Playlist",
|
|
OwnerID: adminUser.ID,
|
|
Public: false,
|
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed())
|
|
smartPlaylistID = smartPls.ID
|
|
})
|
|
|
|
It("getPlaylist returns smart playlist with readonly flag and validUntil", func() {
|
|
resp := doReq("getPlaylist", "id", smartPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Smart Playlist"))
|
|
Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil())
|
|
Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue())
|
|
expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay)
|
|
Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second))
|
|
})
|
|
|
|
It("createPlaylist rejects replacing tracks on smart playlist", func() {
|
|
resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("updatePlaylist rejects adding songs to smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"songIdToAdd", songID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("updatePlaylist rejects removing songs from smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"songIndexToRemove", "0")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("updatePlaylist allows renaming smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"name", "Renamed Smart")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Playlist.Name).To(Equal("Renamed Smart"))
|
|
})
|
|
|
|
It("updatePlaylist allows setting comment on smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"comment", "Auto-generated playlist")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist"))
|
|
})
|
|
|
|
It("deletePlaylist can delete smart playlist", func() {
|
|
resp := doReq("deletePlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
})
|
|
})
|
|
|
|
Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() {
|
|
var songID string
|
|
var boolPlaylistID, stringPlaylistID, nestedPlaylistID string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
songID = songs[0].ID
|
|
|
|
// Star the song via the Subsonic API
|
|
resp := doReq("star", "id", songID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
// Force immediate refresh for all smart playlists
|
|
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
|
|
|
// Create smart playlist with boolean true
|
|
boolPls := &model.Playlist{
|
|
Name: "Bool Loved",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed())
|
|
boolPlaylistID = boolPls.ID
|
|
|
|
// Create smart playlist with string "true"
|
|
stringPls := &model.Playlist{
|
|
Name: "String Loved",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed())
|
|
stringPlaylistID = stringPls.ID
|
|
|
|
// Create smart playlist with string "true" in nested any group (exact issue #4826 scenario)
|
|
nestedPls := &model.Playlist{
|
|
Name: "Nested String Loved",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{
|
|
criteria.Any{
|
|
criteria.Is{"loved": "true"},
|
|
},
|
|
}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed())
|
|
nestedPlaylistID = nestedPls.ID
|
|
})
|
|
|
|
It("smart playlist with bool loved=true returns starred song", func() {
|
|
resp := doReq("getPlaylist", "id", boolPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
|
entryIDs := make([]string, len(resp.Playlist.Entry))
|
|
for i, e := range resp.Playlist.Entry {
|
|
entryIDs[i] = e.Id
|
|
}
|
|
Expect(entryIDs).To(ContainElement(songID))
|
|
})
|
|
|
|
It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() {
|
|
boolResp := doReq("getPlaylist", "id", boolPlaylistID)
|
|
stringResp := doReq("getPlaylist", "id", stringPlaylistID)
|
|
|
|
Expect(stringResp.Status).To(Equal(responses.StatusOK))
|
|
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
|
|
})
|
|
|
|
It("nested any group with string loved='true' returns starred song (issue #4826)", func() {
|
|
resp := doReq("getPlaylist", "id", nestedPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
|
entryIDs := make([]string, len(resp.Playlist.Entry))
|
|
for i, e := range resp.Playlist.Entry {
|
|
entryIDs[i] = e.Id
|
|
}
|
|
Expect(entryIDs).To(ContainElement(songID))
|
|
})
|
|
|
|
It("isPresent with string 'true' matches songs that have the tag", func() {
|
|
pls := &model.Playlist{
|
|
Name: "Genre Present String",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
|
|
|
|
resp := doReq("getPlaylist", "id", pls.ID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
|
})
|
|
|
|
It("isMissing with string 'true' excludes songs that have the tag", func() {
|
|
pls := &model.Playlist{
|
|
Name: "Genre Missing String",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
|
|
|
|
resp := doReq("getPlaylist", "id", pls.ID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
|
})
|
|
|
|
It("isMissing with string 'true' returns same results as bool true", func() {
|
|
boolPls := &model.Playlist{
|
|
Name: "Genre Missing Bool",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed())
|
|
|
|
stringPls := &model.Playlist{
|
|
Name: "Genre Missing String2",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed())
|
|
|
|
boolResp := doReq("getPlaylist", "id", boolPls.ID)
|
|
stringResp := doReq("getPlaylist", "id", stringPls.ID)
|
|
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
|
|
})
|
|
|
|
DescribeTable("isMissing/isPresent partition all songs for nullable column fields",
|
|
func(fieldName string) {
|
|
allPls := &model.Playlist{
|
|
Name: "All Songs " + fieldName,
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(allPls)).To(Succeed())
|
|
missingPls := &model.Playlist{
|
|
Name: "Missing " + fieldName,
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(missingPls)).To(Succeed())
|
|
presentPls := &model.Playlist{
|
|
Name: "Present " + fieldName,
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}},
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(presentPls)).To(Succeed())
|
|
|
|
allResp := doReq("getPlaylist", "id", allPls.ID)
|
|
missingResp := doReq("getPlaylist", "id", missingPls.ID)
|
|
presentResp := doReq("getPlaylist", "id", presentPls.ID)
|
|
|
|
Expect(allResp.Status).To(Equal(responses.StatusOK))
|
|
Expect(allResp.Playlist.SongCount).To(BeNumerically(">", int32(0)))
|
|
Expect(missingResp.Playlist.SongCount + presentResp.Playlist.SongCount).
|
|
To(Equal(allResp.Playlist.SongCount))
|
|
},
|
|
Entry("bpm", "bpm"),
|
|
Entry("bitdepth", "bitdepth"),
|
|
Entry("lyrics", "lyrics"),
|
|
Entry("mbz_recording_id", "mbz_recording_id"),
|
|
Entry("album", "album"),
|
|
Entry("comment", "comment"),
|
|
)
|
|
})
|
|
})
|