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.
516 lines
16 KiB
Go
516 lines
16 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
type playlistRepository struct {
|
|
sqlRepository
|
|
}
|
|
|
|
type dbPlaylist struct {
|
|
model.Playlist `structs:",flatten"`
|
|
Rules sql.NullString `structs:"-"`
|
|
}
|
|
|
|
func (p *dbPlaylist) PostScan() error {
|
|
if p.Rules.String != "" {
|
|
return json.Unmarshal([]byte(p.Rules.String), &p.Playlist.Rules)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p dbPlaylist) PostMapArgs(args map[string]any) error {
|
|
var err error
|
|
if p.Playlist.IsSmartPlaylist() {
|
|
args["rules"], err = json.Marshal(p.Playlist.Rules)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid criteria expression: %w", err)
|
|
}
|
|
// Smart playlist counters are owned by refreshCounters (evaluation), never by callers
|
|
delete(args, "song_count")
|
|
delete(args, "duration")
|
|
delete(args, "size")
|
|
return nil
|
|
}
|
|
delete(args, "rules")
|
|
return nil
|
|
}
|
|
|
|
func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRepository {
|
|
r := &playlistRepository{}
|
|
r.ctx = ctx
|
|
r.db = db
|
|
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
|
"id": idFilter("playlist"),
|
|
"q": playlistFilter,
|
|
"smart": smartPlaylistFilter,
|
|
"starred": annotationBoolFilter("starred"),
|
|
})
|
|
r.setSortMappings(map[string]string{
|
|
"name": naturalSort("playlist.name"),
|
|
"owner_name": naturalSort("owner_name"),
|
|
})
|
|
return r
|
|
}
|
|
|
|
func playlistFilter(_ string, value any) Sqlizer {
|
|
return Or{
|
|
substringFilter("playlist.name", value),
|
|
substringFilter("playlist.comment", value),
|
|
}
|
|
}
|
|
|
|
func smartPlaylistFilter(string, any) Sqlizer {
|
|
return Or{
|
|
Eq{"rules": ""},
|
|
Eq{"rules": nil},
|
|
}
|
|
}
|
|
|
|
func (r *playlistRepository) userFilter() Sqlizer {
|
|
user := loggedUser(r.ctx)
|
|
if user.IsAdmin {
|
|
return And{}
|
|
}
|
|
return Or{
|
|
Eq{"public": true},
|
|
Eq{"owner_id": user.ID},
|
|
}
|
|
}
|
|
|
|
func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
|
query := Select().Where(r.userFilter())
|
|
if filtersNeedAnnotation(r.applyFilters(query, options...)) {
|
|
query = r.withAnnotation(query, "playlist.id")
|
|
}
|
|
return r.count(query, options...)
|
|
}
|
|
|
|
func (r *playlistRepository) Exists(id string) (bool, error) {
|
|
return r.exists(And{Eq{"id": id}, r.userFilter()})
|
|
}
|
|
|
|
func (r *playlistRepository) Delete(id string) error {
|
|
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
|
}
|
|
|
|
func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error {
|
|
pls := dbPlaylist{Playlist: *p}
|
|
if len(cols) > 0 {
|
|
if pls.ID == "" {
|
|
return errors.New("playlist id is required for partial update")
|
|
}
|
|
_, err := r.put(pls.ID, pls, cols...)
|
|
return err
|
|
}
|
|
isNew := pls.ID == ""
|
|
if isNew {
|
|
pls.CreatedAt = time.Now()
|
|
}
|
|
pls.UpdatedAt = time.Now()
|
|
|
|
id, err := r.put(pls.ID, pls)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.ID = id
|
|
|
|
if p.IsSmartPlaylist() {
|
|
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
|
|
return nil
|
|
}
|
|
// Only update tracks if they were specified
|
|
if len(pls.Tracks) > 0 {
|
|
return r.updateTracks(id, p.MediaFiles())
|
|
}
|
|
pls.ID = id // r.put assigns the generated id to p, not to this copy
|
|
if isNew {
|
|
// Even a trackless new playlist has art to find (an imported m3u can carry an
|
|
// ExternalImageURL); an update landing here changed only metadata, so leave its cover be.
|
|
r.enqueueCoverRebuild(id)
|
|
}
|
|
return r.refreshCounters(&pls.Playlist)
|
|
}
|
|
|
|
func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
|
return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()})
|
|
}
|
|
|
|
func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) {
|
|
pls, err := r.Get(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if refreshSmartPlaylist {
|
|
r.refreshSmartPlaylist(pls)
|
|
}
|
|
tracks, err := r.loadTracks(Select().From("playlist_tracks").
|
|
Where(Eq{"missing": false}).
|
|
OrderBy("playlist_tracks.id"), id)
|
|
if err != nil {
|
|
log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err)
|
|
return nil, err
|
|
}
|
|
pls.SetTracks(tracks)
|
|
return pls, nil
|
|
}
|
|
|
|
func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) {
|
|
return r.findBy(Eq{"path": path})
|
|
}
|
|
|
|
func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) {
|
|
sel := r.selectPlaylist().Where(sql)
|
|
var pls []dbPlaylist
|
|
err := r.queryAll(sel, &pls)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(pls) == 0 {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
list := model.Playlists{pls[0].Playlist}
|
|
r.hydrateArtwork(list)
|
|
return &list[0], nil
|
|
}
|
|
|
|
func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) {
|
|
hydrateItems(r.ctx, r.db, model.KindPlaylistArtwork, playlists,
|
|
func(p *model.Playlist) (string, *model.ItemImage) { return p.ID, &p.ItemImage })
|
|
}
|
|
|
|
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
|
sel := r.selectPlaylist(options...).Where(r.userFilter())
|
|
var res []dbPlaylist
|
|
err := r.queryAll(sel, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
playlists := make(model.Playlists, len(res))
|
|
for i, p := range res {
|
|
playlists[i] = p.Playlist
|
|
}
|
|
r.hydrateArtwork(playlists)
|
|
return playlists, err
|
|
}
|
|
|
|
// getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing.
|
|
func (r *playlistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) {
|
|
// Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY
|
|
// on the playlist's own ambiguous.
|
|
sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name").
|
|
Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter())
|
|
if filtersNeedAnnotation(sq) {
|
|
sq = r.withAnnotation(sq, "playlist.id")
|
|
}
|
|
ids := []string{}
|
|
err := r.queryAllSlice(sq, &ids)
|
|
return ids, err
|
|
}
|
|
|
|
func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) {
|
|
// Both passes apply userFilter, so a visibility change between them cannot widen the cursor.
|
|
ids, err := r.getAllIDs(options...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts := chunkOptions(options, "playlist.id")
|
|
return model.PlaylistCursor(streamByIDs(ids, func(chunk []string) (model.Playlists, error) {
|
|
return r.GetAll(opts(chunk))
|
|
})), nil
|
|
}
|
|
|
|
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
|
|
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
|
|
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
|
|
Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()})
|
|
var res []dbPlaylist
|
|
err := r.queryAll(sel, &res)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return model.Playlists{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
playlists := make(model.Playlists, len(res))
|
|
for i, p := range res {
|
|
playlists[i] = p.Playlist
|
|
}
|
|
r.hydrateArtwork(playlists)
|
|
return playlists, nil
|
|
}
|
|
|
|
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
|
sel := r.newSelect(options...).Join("user on user.id = owner_id").
|
|
Columns(r.tableName+".*", "user.user_name as owner_name")
|
|
return r.withAnnotation(sel, r.tableName+".id")
|
|
}
|
|
|
|
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
|
|
ids := make([]string, len(tracks))
|
|
for i := range tracks {
|
|
ids[i] = tracks[i].ID
|
|
}
|
|
return r.updatePlaylist(id, ids)
|
|
}
|
|
|
|
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
|
|
// Remove old tracks
|
|
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
|
|
_, err := r.executeSQL(del)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = r.addTracks(playlistId, 1, mediaFileIds)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
for chunk := range slices.Chunk(mediaFileIds, 200) {
|
|
ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id")
|
|
for _, t := range chunk {
|
|
ins = ins.Values(playlistId, t, pos)
|
|
pos++
|
|
}
|
|
if _, err := r.executeSQL(ins); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
r.enqueueCoverRebuild(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
|
|
func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
|
|
statsSql := Select(
|
|
"coalesce(sum(duration), 0) as duration",
|
|
"coalesce(sum(size), 0) as size",
|
|
"count(*) as count",
|
|
).
|
|
From("media_file").
|
|
Join("playlist_tracks f on f.media_file_id = media_file.id").
|
|
Where(Eq{"playlist_id": pls.ID})
|
|
var res struct{ Duration, Size, Count float32 }
|
|
err := r.queryOne(statsSql, &res)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update playlist's total duration, size and count
|
|
now := time.Now()
|
|
upd := Update("playlist").
|
|
Set("duration", res.Duration).
|
|
Set("size", res.Size).
|
|
Set("song_count", res.Count).
|
|
Set("updated_at", now).
|
|
Where(Eq{"id": pls.ID})
|
|
_, err = r.executeSQL(upd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pls.SongCount = int(res.Count)
|
|
pls.Duration = res.Duration
|
|
pls.Size = int64(res.Size)
|
|
pls.UpdatedAt = now
|
|
return nil
|
|
}
|
|
|
|
// enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes:
|
|
// the grid samples albums at random, so rebuilding after a mere rename would change the cover.
|
|
func (r *playlistRepository) enqueueCoverRebuild(id string) {
|
|
item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id,
|
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan}
|
|
if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil {
|
|
log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", id, err)
|
|
}
|
|
}
|
|
|
|
// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically.
|
|
func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder {
|
|
query = r.applyLibraryFilter(query, "f")
|
|
userID := loggedUser(r.ctx).ID
|
|
return query.
|
|
Columns(
|
|
"coalesce(starred, 0) as starred",
|
|
"starred_at",
|
|
"coalesce(play_count, 0) as play_count",
|
|
"play_date",
|
|
"coalesce(rating, 0) as rating",
|
|
"rated_at",
|
|
"f.*",
|
|
"playlist_tracks.*",
|
|
"library.path as library_path",
|
|
"library.name as library_name",
|
|
).
|
|
LeftJoin("annotation on (" +
|
|
"annotation.item_id = media_file_id" +
|
|
" AND annotation.item_type = 'media_file'" +
|
|
" AND annotation.user_id = '" + userID + "')").
|
|
Join("media_file f on f.id = media_file_id").
|
|
Join("library on f.library_id = library.id").
|
|
Where(Eq{"playlist_id": id})
|
|
}
|
|
|
|
func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) {
|
|
tracks := dbPlaylistTracks{}
|
|
err := r.queryAll(r.tracksQuery(query, id), &tracks)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res := tracks.toModels()
|
|
hydratePlaylistTrackArtwork(r.ctx, r.db, res)
|
|
return res, err
|
|
}
|
|
|
|
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
|
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
|
}
|
|
|
|
func (r *playlistRepository) Read(id string) (any, error) {
|
|
return r.Get(id)
|
|
}
|
|
|
|
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
|
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
|
}
|
|
|
|
func (r *playlistRepository) EntityName() string {
|
|
return "playlist"
|
|
}
|
|
|
|
func (r *playlistRepository) NewInstance() any {
|
|
return &model.Playlist{}
|
|
}
|
|
|
|
func (r *playlistRepository) Save(entity any) (string, error) {
|
|
pls := entity.(*model.Playlist)
|
|
pls.ID = "" // Force new creation
|
|
err := r.Put(pls)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return pls.ID, err
|
|
}
|
|
|
|
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
|
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
|
pls.ID = id
|
|
pls.UpdatedAt = time.Now()
|
|
_, err := r.put(id, pls, append(cols, "updatedAt")...)
|
|
return err
|
|
}
|
|
|
|
func (r *playlistRepository) removeOrphans() error {
|
|
sel := Select("playlist_tracks.playlist_id as id", "p.name").From("playlist_tracks").
|
|
Join("playlist p on playlist_tracks.playlist_id = p.id").
|
|
LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id").
|
|
Where(Eq{"mf.id": nil}).
|
|
GroupBy("playlist_tracks.playlist_id")
|
|
|
|
var pls []struct{ Id, Name string }
|
|
err := r.queryAll(sel, &pls)
|
|
if err != nil {
|
|
return fmt.Errorf("fetching playlists with orphan tracks: %w", err)
|
|
}
|
|
|
|
for _, pl := range pls {
|
|
log.Debug(r.ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name)
|
|
del := Delete("playlist_tracks").Where(And{
|
|
ConcatExpr("media_file_id not in (select id from media_file)"),
|
|
Eq{"playlist_id": pl.Id},
|
|
})
|
|
n, err := r.executeSQL(del)
|
|
if n == 0 || err != nil {
|
|
return fmt.Errorf("deleting orphan tracks from playlist %s: %w", pl.Name, err)
|
|
}
|
|
log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n)
|
|
|
|
// Renumber the playlist if any track was removed
|
|
if err := r.renumber(pl.Id); err != nil {
|
|
return fmt.Errorf("renumbering playlist %s: %w", pl.Name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
|
|
// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
|
|
// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
|
|
func (r *playlistRepository) renumber(id string) error {
|
|
// Step 1: Negate all IDs to clear the positive ID space
|
|
_, err := r.executeSQL(Expr(
|
|
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
|
|
// The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
|
|
// ORDER BY id DESC restores original order since IDs are now negative.
|
|
_, err = r.executeSQL(Expr(
|
|
`WITH new_ids AS (
|
|
SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
|
|
FROM playlist_tracks WHERE playlist_id = ?
|
|
)
|
|
UPDATE playlist_tracks SET id = new_ids.new_id
|
|
FROM new_ids
|
|
WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.enqueueCoverRebuild(id)
|
|
return r.refreshCounters(&model.Playlist{ID: id})
|
|
}
|
|
|
|
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
|
var _ rest.Repository = (*playlistRepository)(nil)
|
|
var _ rest.Persistable = (*playlistRepository)(nil)
|