mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
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.
333 lines
10 KiB
Go
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)
|