navidrome/persistence/playlist_track_repository.go
Deluan Quintão 8e784b6af7
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.
2026-09-20 12:37:18 -04:00

333 lines
10 KiB
Go

package persistence
import (
"database/sql"
"slices"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
type playlistTrackRepository struct {
sqlRepository
playlistId string
playlist *model.Playlist
playlistRepo *playlistRepository
}
type dbPlaylistTrack struct {
dbMediaFile
*model.PlaylistTrack `structs:",flatten"`
}
func (t *dbPlaylistTrack) PostScan() error {
if err := t.dbMediaFile.PostScan(); err != nil {
return err
}
t.PlaylistTrack.MediaFile = *t.dbMediaFile.MediaFile
t.PlaylistTrack.MediaFile.ID = t.MediaFileID
return nil
}
type dbPlaylistTracks []dbPlaylistTrack
func (t dbPlaylistTracks) toModels() model.PlaylistTracks {
return slice.Map(t, func(trk dbPlaylistTrack) model.PlaylistTrack {
return *trk.PlaylistTrack
})
}
func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository {
p := &playlistTrackRepository{}
p.playlistRepo = r
p.playlistId = playlistId
p.ctx = r.ctx
p.db = r.db
p.tableName = "playlist_tracks"
p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{
"missing": booleanFilter,
"library_id": libraryIdFilter,
})
p.setSortMappings(
map[string]string{
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"),
"title": "order_title",
"random": "random()",
// To make sure these fields will be whitelisted
"duration": "duration",
"year": "year",
"bpm": "bpm",
"channels": "channels",
},
"f") // TODO I don't like this solution, but I won't change it now as it's not the focus of BFR.
pls, err := r.Get(playlistId)
if err != nil {
log.Warn(r.ctx, "Error getting playlist's tracks", "playlistId", playlistId, err)
return nil
}
if refreshSmartPlaylist {
r.refreshSmartPlaylist(pls)
}
p.playlist = pls
return p
}
func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := Select().
Join("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
return r.count(query, options...)
}
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
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...))
}
func (r *playlistTrackRepository) Read(id string) (any, error) {
userID := loggedUser(r.ctx).ID
sel := r.newSelect().
LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = '"+userID+"')").
Columns(
"coalesce(starred, 0) as starred",
"coalesce(play_count, 0) as play_count",
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
"rated_at",
"f.*",
"playlist_tracks.*",
).
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
}
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {
tracks, err := r.playlistRepo.loadTracks(r.newSelect(options...), r.playlistId)
if err != nil {
return nil, err
}
return tracks, err
}
func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) {
sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId)
cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel)
if err != nil {
return nil, err
}
tracks := wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack {
return t.PlaylistTrack
})
return model.PlaylistTrackCursor(hydrateCursor(tracks, func(batch []model.PlaylistTrack) {
hydratePlaylistTrackArtwork(r.ctx, r.db, batch)
})), nil
}
// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data.
func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) {
query := r.newSelect(options...).Columns("media_file_id").
Join("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
var ids []string
if err := r.queryAllSlice(query, &ids); err != nil {
return nil, err
}
return ids, nil
}
func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) {
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 {
return nil, err
}
return ids, nil
}
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playlistTrackRepository) EntityName() string {
return "playlist_tracks"
}
func (r *playlistTrackRepository) NewInstance() any {
return &model.PlaylistTrack{}
}
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
if len(mediaFileIds) > 0 {
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
} else {
return 0, nil
}
// 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 }
if err := r.queryOne(sq, &res); err != nil {
return 0, err
}
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
// position past the end appends. Callers must run it in a transaction.
func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) {
if len(mediaFileIds) == 0 {
return 0, nil
}
pos = max(pos, 1)
n := len(mediaFileIds)
// Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index.
_, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos))
if err != nil {
return 0, err
}
res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId))
if err != nil {
return 0, err
}
if res == 0 {
return r.Add(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) {
sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number")
var ids []string
err := r.queryAllSlice(sq, &ids)
if err != nil {
log.Error(r.ctx, "Error getting tracks to add to playlist", err)
return 0, err
}
return r.Add(ids)
}
func (r *playlistTrackRepository) AddAlbums(albumIds []string) (int, error) {
return r.addMediaFileIds(Eq{"album_id": albumIds})
}
func (r *playlistTrackRepository) AddArtists(artistIds []string) (int, error) {
return r.addMediaFileIds(Eq{"album_artist_id": artistIds})
}
func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
if len(discs) == 0 {
return 0, nil
}
var clauses Or
for _, d := range discs {
clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"release_date": d.ReleaseDate}, Eq{"disc_number": d.DiscNumber}})
}
return r.addMediaFileIds(clauses)
}
// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks.
const deleteChunkSize = 200
func (r *playlistTrackRepository) Delete(ids ...string) error {
for chunk := range slices.Chunk(ids, deleteChunkSize) {
if err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil {
return err
}
}
return r.playlistRepo.renumber(r.playlistId)
}
func (r *playlistTrackRepository) DeleteAll() error {
err := r.delete(Eq{"playlist_id": r.playlistId})
if err != nil {
return err
}
return r.playlistRepo.renumber(r.playlistId)
}
// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped
// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
var res struct{ Max sql.NullInt32 }
if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil {
return err
}
last := int(res.Max.Int32)
if pos < 1 || pos > last {
return model.ErrNotFound
}
newPos = min(max(newPos, 1), last)
if pos == newPos {
return nil
}
pid := r.playlistId
// Step 1: Move the source track out of the way (temporary sentinel value)
_, err := r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
if err != nil {
return err
}
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
if pos < newPos {
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
pid, pos, newPos))
} else {
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
pid, newPos, pos))
}
if err != nil {
return err
}
// Step 3: Flip the shifted range back to positive
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
if err != nil {
return err
}
// Step 4: Place the source track at its new position
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
return err
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)