navidrome/persistence/playlist_track_repository_test.go
Deluan 803b2da8ee refactor(playlists): simplify #6310 tests and update check comments
Builds the removeOrphans fixture with a single INSERT instead of shifting positions in two UPDATE passes, and has the Insert test keep the playlist id instead of reading a private repository field. Two comments now say that addTracks owns the existence check as well as the library check, and that Insert closes holes left by any id keepAccessible drops. No behavior change.
2026-10-10 11:35:43 -04:00

443 lines
17 KiB
Go

package persistence
import (
"context"
"strconv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver
const sqliteMaxVariables = 32766
var _ = Describe("PlaylistTrackRepository", func() {
var repo model.PlaylistTrackRepository
var ctx context.Context
BeforeEach(func() {
ctx = log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(GetDBXBuilder()).Tracks(ctx, plsBest.ID, true)
})
Describe("GetCursor", func() {
It("yields the same tracks as GetAll", func() {
opts := model.QueryOptions{Sort: "id"}
want, err := repo.GetAll(ctx, opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).To(HaveLen(2))
Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want)))
})
It("honors Max and Offset", func() {
opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1}
want, err := repo.GetAll(ctx, opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).To(HaveLen(1))
Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want)))
})
})
Describe("GetAll", func() {
It("returns every row under a random sort, despite the integer id", func() {
// playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to
// TEXT, and it fails silently: no error, just no rows.
all, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random"})
Expect(err).ToNot(HaveOccurred())
Expect(all).To(HaveLen(2), "a random sort must not silently drop rows")
got, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random", Max: 1})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(HaveLen(1))
})
})
Describe("CountAll", func() {
It("returns the number of tracks in the playlist", func() {
Expect(repo.CountAll(ctx)).To(Equal(int64(2)))
})
It("ignores Max and Offset", func() {
Expect(repo.CountAll(ctx, model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2)))
})
})
Describe("GetMediaFileIDs", func() {
It("returns the song ids in playlist order", func() {
Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"})).
To(Equal([]string{songDayInALife.ID, songRadioactivity.ID}))
})
It("honors Max and Offset", func() {
Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Max: 1, Offset: 1})).
To(Equal([]string{songRadioactivity.ID}))
})
})
Describe("Insert", func() {
var tracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
plsID = pls.ID
tracks = plsRepo.Tracks(ctx, pls.ID, false)
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
})
order := func() []string {
ids, err := tracks.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
return ids
}
DescribeTable("inserts before a 1-based position, keeping the new tracks' order",
func(pos int, want func() []string) {
Expect(tracks.Insert(ctx, []string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2))
Expect(order()).To(Equal(want()))
Expect(tracks.CountAll(ctx)).To(Equal(int64(4)))
},
Entry("in the middle", 2, func() []string {
return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID}
}),
Entry("at the start, for zero or less", 0, func() []string {
return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID}
}),
Entry("at the end, past the last position", 9, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID}
}),
)
It("renumbers positions contiguously", func() {
Expect(tracks.Insert(ctx, []string{songComeTogether.ID}, 1)).To(Equal(1))
all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
})
It("drops ids that match no track, keeping positions contiguous", func() {
Expect(tracks.Insert(ctx, []string{"no-such-track", songComeTogether.ID}, 1)).To(Equal(1))
Expect(storedRows(plsID)).To(Equal([]string{
"1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID,
}))
})
})
Describe("Add", func() {
var tracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Add", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
plsID = pls.ID
tracks = plsRepo.Tracks(ctx, pls.ID, false)
})
It("drops ids that match no track and counts only the tracks added", func() {
Expect(tracks.Add(ctx, []string{songDayInALife.ID, "no-such-track", songComeTogether.ID, "!!not-an-id!!"})).To(Equal(2))
Expect(storedRows(plsID)).To(Equal([]string{"1:" + songDayInALife.ID, "2:" + songComeTogether.ID}))
})
It("adds nothing when no id matches a track", func() {
Expect(tracks.Add(ctx, []string{"no-such-track", "!!not-an-id!!"})).To(BeZero())
Expect(storedRows(plsID)).To(BeEmpty())
})
})
Describe("Reorder", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(ctx, pls.ID, false)
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3))
})
rows := func() ([]string, []string) {
all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
var ids, songs []string
for _, t := range all {
ids = append(ids, t.ID)
songs = append(songs, t.MediaFileID)
}
return ids, songs
}
DescribeTable("clamps the destination to the playlist",
func(newPos int, want func() []string) {
Expect(tracks.Reorder(ctx, 1, newPos)).To(Succeed())
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal(want()))
},
Entry("past the end moves to the end", 9, func() []string {
return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID}
}),
Entry("below 1 stays first", -4, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}
}),
)
DescribeTable("rejects a source position outside the playlist, leaving rows untouched",
func(pos int) {
Expect(tracks.Reorder(ctx, pos, 1)).To(MatchError(model.ErrNotFound))
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}))
},
Entry("past the end", 4),
Entry("zero", 0),
)
})
Describe("Delete", func() {
var tracks model.PlaylistTrackRepository
const numTracks = deleteChunkSize*2 + 1
positionsUpTo := func(n int) []string {
positions := make([]string, 0, n)
for i := 1; i <= n; i++ {
positions = append(positions, strconv.Itoa(i))
}
return positions
}
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(ctx, pls.ID, false)
songIds := make([]string, numTracks)
for i := range songIds {
songIds[i] = songDayInALife.ID
}
Expect(tracks.Add(ctx, songIds)).To(Equal(numTracks))
})
It("removes positions spanning several chunks, and renumbers what is left", func() {
Expect(tracks.Delete(ctx, positionsUpTo(numTracks-1)...)).To(Succeed())
Expect(tracks.CountAll(ctx)).To(Equal(int64(1)))
remaining, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1")
})
It("accepts more ids than SQLite allows as bind variables", func() {
Expect(tracks.Delete(ctx, positionsUpTo(sqliteMaxVariables+100)...)).To(Succeed())
Expect(tracks.CountAll(ctx)).To(BeZero())
})
})
Describe("AddArtists", func() {
var tracks model.PlaylistTrackRepository
var joint model.MediaFile
BeforeEach(func() {
mfRepo := NewMediaFileRepository(GetDBXBuilder())
joint = mf(model.MediaFile{ID: "pls-coartist-track", Title: "Joint Track", ArtistID: artistPunctuation.ID,
Artist: artistPunctuation.Name, AlbumID: "pls-coartist-album", Album: "Joint Album",
AlbumArtistID: artistKraftwerk.ID, AlbumArtist: artistKraftwerk.Name, Path: p("joint/track.mp3")})
joint.Participants[model.RoleAlbumArtist] = model.ParticipantList{
{Artist: artistKraftwerk},
{Artist: artistBeatles},
}
Expect(mfRepo.Put(ctx, &joint)).To(Succeed())
DeferCleanup(func() { _ = mfRepo.Delete(ctx, joint.ID) })
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Co-album-artist", OwnerID: adminUser.ID, OwnerName: adminUser.UserName}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = plsRepo.Delete(ctx, pls.ID) })
tracks = plsRepo.Tracks(ctx, pls.ID, false)
})
It("adds tracks where the artist is the first album artist", func() {
Expect(tracks.AddArtists(ctx, []string{artistKraftwerk.ID})).To(Equal(1))
Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID))
})
It("adds tracks where the artist is not the first album artist", func() {
Expect(tracks.AddArtists(ctx, []string{artistBeatles.ID})).To(Equal(1))
Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID))
})
It("does not add tracks where the artist is only the track artist", func() {
Expect(tracks.AddArtists(ctx, []string{artistPunctuation.ID})).To(Equal(0))
Expect(tracks.GetMediaFileIDs(ctx)).To(BeEmpty())
})
})
Describe("library access", func() {
var otherLib model.Library
var restrictedUser model.User
var adminCtx, userCtx context.Context
var userTracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
adminCtx, otherLib, restrictedUser = restrictedFixture("pls")
userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
db := GetDBXBuilder()
adminMr := NewMediaFileRepository(db)
Expect(adminMr.Put(adminCtx, &model.MediaFile{
ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album",
Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist",
})).To(Succeed())
DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "pls-otherlib-track") })
adminPls := NewPlaylistRepository(db)
pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true}
Expect(adminPls.Put(adminCtx, &pls)).To(Succeed())
plsID = pls.ID
DeferCleanup(func() { _ = adminPls.Delete(adminCtx, plsID) })
Expect(adminPls.Tracks(adminCtx, plsID, false).Add(adminCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2))
userTracks = NewPlaylistRepository(db).Tracks(userCtx, plsID, false)
})
It("Read does not return a track outside the user's libraries", func() {
_, err := userTracks.Read(userCtx, "2")
Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access")
})
It("Read still returns a track inside the user's libraries", func() {
trk, err := userTracks.Read(userCtx, "1")
Expect(err).ToNot(HaveOccurred())
Expect(trk.MediaFile.ID).To(Equal(songDayInALife.ID))
})
It("Count excludes tracks outside the user's libraries", func() {
Expect(userTracks.Count(userCtx)).To(Equal(int64(1)), "Count must agree with the filtered listing")
})
It("GetAlbumIDs excludes albums outside the user's libraries", func() {
Expect(userTracks.GetAlbumIDs(userCtx)).ToNot(ContainElement("pls-hidden-album"))
})
Describe("Add", func() {
var ownTracks model.PlaylistTrackRepository
BeforeEach(func() {
userPls := NewPlaylistRepository(GetDBXBuilder())
own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName}
Expect(userPls.Put(userCtx, &own)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, own.ID) })
ownTracks = userPls.Tracks(userCtx, own.ID, false)
})
It("drops ids outside the user's libraries", func() {
Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs(userCtx)).To(ConsistOf(songDayInALife.ID))
})
It("drops them when reached through AddAlbums", func() {
Expect(ownTracks.AddAlbums(userCtx, []string{"pls-hidden-album"})).To(BeZero())
})
It("drops them when reached through Insert", func() {
Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID})).To(Equal(1))
Expect(ownTracks.Insert(userCtx, []string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs(userCtx)).To(Equal([]string{songComeTogether.ID, songDayInALife.ID}))
trks, err := ownTracks.GetAll(userCtx, model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}),
"positions must stay contiguous when an id is dropped")
})
})
Describe("Put", func() {
storedIDs := func(id string) []string {
ids, err := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, id, false).GetMediaFileIDs(adminCtx)
Expect(err).ToNot(HaveOccurred())
return ids
}
put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string {
pls.OwnerID = owner.ID
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
Expect(NewPlaylistRepository(GetDBXBuilder()).Put(ctx, pls)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, pls.ID) })
return pls.ID
}
It("drops ids outside the user's libraries when creating a playlist", func() {
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track")
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID}))
})
It("drops them when replacing the tracks of an existing playlist", func() {
pls := &model.Playlist{Name: "Replaced"}
put(userCtx, restrictedUser, pls, songDayInALife.ID)
put(userCtx, restrictedUser, pls, "pls-otherlib-track")
Expect(storedIDs(pls.ID)).To(BeEmpty())
})
It("does not count a dropped id, so it cannot be told apart from an unknown one", func() {
hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track")
unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track")
userPls := NewPlaylistRepository(GetDBXBuilder())
h, err := userPls.Get(userCtx, hidden)
Expect(err).ToNot(HaveOccurred())
u, err := userPls.Get(userCtx, unknown)
Expect(err).ToNot(HaveOccurred())
Expect(h.SongCount).To(Equal(u.SongCount))
Expect(h.Duration).To(Equal(u.Duration))
Expect(h.Size).To(Equal(u.Size))
})
It("keeps order and duplicates of the accessible ids", func() {
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"},
songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID)
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID}))
})
It("keeps every id when run as an admin, as the scanner's playlist sync does", func() {
id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track")
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"}))
})
})
It("still shows everything to an admin", func() {
adminTracks := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, plsID, false)
Expect(adminTracks.Count(adminCtx)).To(Equal(int64(2)))
_, err := adminTracks.Read(adminCtx, "2")
Expect(err).ToNot(HaveOccurred())
})
})
})