🏡 index : quine-global/quintodrome.git

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())
		})
	})
})