fix: apply the per-user library filter to bookmarks, playlists and now-playing (#6179)

On a multi-library instance, a few reads and writes built their own queries
without the per-user library filter that every other media read applies. A
user granted only some libraries could see, and store, tracks from libraries
they had no access to.

- getBookmarks now filters the query. It has to be the query and not the
  result: the loop below it pre-sizes the response from the bookmark count,
  so a row dropped afterwards would emit an empty bookmark entry.
- createBookmark rejects an id the caller cannot read, returning error 70 to
  match getSong. Stored rows are left alone rather than purged, so a
  temporary revoke does not lose saved playback positions.
- playlistTrackRepository Read, Count and GetAlbumIDs get the filter their
  siblings CountAll and GetMediaFileIDs already had. Read is the one that
  mattered most: its id is the integer playlist position, so it needed no
  track id at all.
- Playlist track writes are filtered in playlistRepository.addTracks, the
  only writer of playlist_tracks rows apart from smart playlists, so Add,
  Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all
  go through it. Insert reserves a slot per requested id, so when the filter
  drops one it renumbers to close the hole.
- playTracker.GetNowPlaying honours its context instead of discarding it.
  The cache is process-global, so the filter belongs in the tracker rather
  than in the Subsonic handler, and any future caller inherits it.

Admins and single-library installs are unaffected: applyLibraryFilter and
HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as
admin, and M3U and CLI imports already resolve tracks through FindByPaths as
the same user, so neither changes.
This commit is contained in:
Deluan Quintão 2026-09-20 12:37:18 -04:00 • committed by GitHub
commit 8e784b6af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 401 additions and 12 deletions

View file

@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/navidrome/navidrome/utils/slice"
)
const (
@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
return nil
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) {
// The cache is process-global, so it holds every user's playback, across all libraries.
res := p.playMap.Values()
if user, ok := request.UserFrom(ctx); ok {
res = slice.Filter(res, func(s PlaybackSession) bool {
return user.HasLibraryAccess(s.MediaFile.LibraryID)
})
}
slices.SortFunc(res, func(a, b PlaybackSession) int {
return b.Start.Compare(a.Start)
})

View file

@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
fake = &fakeScrobbler{Authorized: true}
@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() {
track = model.MediaFile{
ID: "123",
LibraryID: 1,
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() {
Expect(playing[1].Username).To(Equal("user-1"))
Expect(playing[1].MediaFile.ID).To(Equal("123"))
})
It("hides sessions playing from libraries the caller cannot access", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
)
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only")
})
It("shows every session to an admin", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
)
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true})
playing, err := tracker.GetNowPlaying(adminCtx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].MediaFile.ID).To(Equal("789"))
})
})
Describe("Expiration events", func() {

View file

@ -169,6 +169,28 @@ func p(path string) string {
return filepath.FromSlash(path)
}
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
// specs can assert that a query filters by library. Cleans itself up after the spec.
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
db := GetDBXBuilder()
lib := model.Library{Name: name + " Library", Path: "/" + name}
lr := NewLibraryRepository(adminCtx, db)
Expect(lr.Put(&lib)).To(Succeed())
user := createUserWithLibraries(name+"-restricted", []int{1})
ur := NewUserRepository(adminCtx, db)
Expect(ur.Put(&user)).To(Succeed())
Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed())
DeferCleanup(func() {
_ = NewUserRepository(adminCtx, db).Delete(user.ID)
_ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID})
})
return adminCtx, lib, user
}
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())

View file

@ -13,6 +13,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
return err
}
return r.addTracks(playlistId, 1, mediaFileIds)
_, err = r.addTracks(playlistId, 1, mediaFileIds)
return err
}
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
// the library check: every caller, including a full replace through Put, goes through it.
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) {
mediaFileIds, err := r.keepAccessible(mediaFileIds)
if err != nil {
return 0, err
}
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
// Add new tracks, chunk by chunk
pos := startingPos
@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media
ins = ins.Values(playlistId, t, pos)
pos++
}
_, err := r.executeSQL(ins)
if err != nil {
return err
if _, err := r.executeSQL(ins); err != nil {
return 0, err
}
}
r.enqueueCoverRebuild(playlistId)
return r.refreshCounters(&model.Playlist{ID: playlistId})
return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId})
}
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) {
if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) {
return mediaFileIds, nil
}
accessible := make(map[string]struct{}, len(mediaFileIds))
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
var found []string
if err := r.queryAllSlice(sq, &found); err != nil {
return nil, err
}
for _, id := range found {
accessible[id] = struct{}{}
}
}
return slice.Filter(mediaFileIds, func(id string) bool {
_, ok := accessible[id]
return ok
}), nil
}
// refreshCounters updates total playlist duration, size and count

View file

@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
query := Select().
LeftJoin("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
return r.count(query, r.parseRestOptions(r.ctx, options...))
}
@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) {
).
Join("media_file f on f.id = media_file_id").
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
sel = r.applyLibraryFilter(sel, "f")
var trk dbPlaylistTrack
err := r.queryOne(sel, &trk)
return trk.PlaylistTrack, err
@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
query := r.newSelect(options...).Columns("distinct mf.album_id").
Join("media_file mf on mf.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "mf")
var ids []string
err := r.queryAllSlice(query, &ids)
if err != nil {
@ -187,12 +190,11 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
// Get next pos (ID) in playlist
sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId})
var res struct{ Max sql.NullInt32 }
err := r.queryOne(sq, &res)
if err != nil {
if err := r.queryOne(sq, &res); err != nil {
return 0, err
}
return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
}
// Insert adds tracks before the 1-based position pos, shifting the following entries down; a
@ -215,7 +217,13 @@ func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, e
if res == 0 {
return r.Add(mediaFileIds)
}
return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
if err != nil || inserted == n {
return inserted, err
}
// The shift above reserved a slot per requested id, so ids dropped by the library filter
// leave a hole. Close it.
return inserted, r.playlistRepo.renumber(r.playlistId)
}
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {

View file

@ -1,11 +1,13 @@
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"
)
@ -221,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() {
Expect(tracks.CountAll()).To(BeZero())
})
})
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(adminCtx, db)
Expect(adminMr.Put(&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("pls-otherlib-track") })
adminPls := NewPlaylistRepository(adminCtx, db)
pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true}
Expect(adminPls.Put(&pls)).To(Succeed())
plsID = pls.ID
DeferCleanup(func() { _ = adminPls.Delete(plsID) })
Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2))
userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false)
})
It("Read does not return a track outside the user's libraries", func() {
_, err := userTracks.Read("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("1")
Expect(err).ToNot(HaveOccurred())
Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID))
})
It("Count excludes tracks outside the user's libraries", func() {
Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing")
})
It("GetAlbumIDs excludes albums outside the user's libraries", func() {
Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album"))
})
Describe("Add", func() {
var ownTracks model.PlaylistTrackRepository
BeforeEach(func() {
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName}
Expect(userPls.Put(&own)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) })
ownTracks = userPls.Tracks(own.ID, false)
})
It("drops ids outside the user's libraries", func() {
Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID))
})
It("drops them when reached through AddAlbums", func() {
Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero())
})
It("drops them when reached through Insert", func() {
Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1))
Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID}))
trks, err := ownTracks.GetAll(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(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs()
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(ctx, GetDBXBuilder()).Put(pls)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(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(userCtx, GetDBXBuilder())
h, err := userPls.Get(hidden)
Expect(err).ToNot(HaveOccurred())
u, err := userPls.Get(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(adminCtx, GetDBXBuilder()).Tracks(plsID, false)
Expect(adminTracks.Count()).To(Equal(int64(2)))
_, err := adminTracks.Read("2")
Expect(err).ToNot(HaveOccurred())
})
})
})

View file

@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
sq := r.newSelect().Columns(r.tableName + ".*")
sq = r.withAnnotation(sq, idField)
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
sq = r.applyLibraryFilter(sq)
var mfs dbMediaFiles // TODO Decouple from media_file
err := r.queryAll(sq, &mfs)
if err != nil {

View file

@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() {
Expect(mr.GetBookmarks()).To(BeEmpty())
})
})
Describe("library access", func() {
var otherLib model.Library
var restrictedUser model.User
var adminCtx context.Context
var userMr model.MediaFileRepository
BeforeEach(func() {
adminCtx, otherLib, restrictedUser = restrictedFixture("bmk")
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.Put(&model.MediaFile{
ID: "bmk-otherlib-track", LibraryID: otherLib.ID,
Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked",
})).To(Succeed())
DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") })
userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
userMr = NewMediaFileRepository(userCtx, GetDBXBuilder())
})
It("does not return bookmarks for tracks outside the user's libraries", func() {
Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed())
Expect(userMr.GetBookmarks()).To(BeEmpty())
})
It("still returns the bookmark for an admin", func() {
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed())
DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") })
bms, err := adminMr.GetBookmarks()
Expect(err).ToNot(HaveOccurred())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track"))
})
It("keeps returning bookmarks for tracks inside the user's libraries", func() {
Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed())
DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) })
bms, err := userMr.GetBookmarks()
Expect(err).ToNot(HaveOccurred())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
})
})
})

View file

@ -47,6 +47,14 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error)
position := p.Int64Or("position", 0)
repo := api.ds.MediaFile(r.Context())
ok, err := repo.Exists(id)
if err != nil {
return nil, err
}
if !ok {
return nil, newError(responses.ErrorDataNotFound, "Song not found")
}
err = repo.AddBookmark(id, comment, position)
if err != nil {
return nil, err

View file

@ -0,0 +1,48 @@
package subsonic
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Bookmarks", func() {
var router *Router
var ds *tests.MockDataStore
var mfRepo *tests.MockMediaFileRepo
var ctx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"})
mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "visible"}})
})
Describe("CreateBookmark", func() {
It("rejects an id the user cannot read", func() {
r := newGetRequest("id=hidden", "position=1").WithContext(ctx)
_, err := router.CreateBookmark(r)
Expect(err).To(HaveOccurred())
Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound))
Expect(mfRepo.BookmarksAdded).To(BeEmpty())
})
It("accepts an id the user can read", func() {
r := newGetRequest("id=visible", "position=1").WithContext(ctx)
_, err := router.CreateBookmark(r)
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible"))
})
})
})

View file

@ -224,6 +224,24 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
Expect(resp.Playlist.Entry).To(HaveLen(1))
Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID))
})
It("non-admin user cannot store a song from another library through createPlaylist", func() {
resp := doReqWithUser(userLib1Only, "createPlaylist",
"name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID)
Expect(resp.Status).To(Equal(responses.StatusOK))
ownID := resp.Playlist.Id
stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID)
Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted")
Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID))
By("replacing the tracks of the same playlist")
resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID)
Expect(resp.Status).To(Equal(responses.StatusOK))
stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID)
Expect(stored.Playlist.Entry).To(BeEmpty())
})
})
Describe("Cross-library shares", Ordered, func() {

View file

@ -37,6 +37,7 @@ type MockMediaFileRepo struct {
FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
MatchesCriteriaValue bool
MatchesCriteriaErr error
BookmarksAdded []string
}
func (m *MockMediaFileRepo) SetError(err bool) {
@ -76,6 +77,14 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
return nil, model.ErrNotFound
}
func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error {
if m.Err {
return errors.New("error")
}
m.BookmarksAdded = append(m.BookmarksAdded, id)
return nil
}
func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
if m.Err {
return nil, errors.New("error")