quintodrome/persistence/playlist_repository.go
Deluan Quintão ca09070a6c
feat(smartplaylists): relax playlist visibility in inPlaylist/notInPlaylist rules (#5411)
* test(e2e): add end-to-end tests for smart playlists functionality

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: enforce playlist visibility in smart playlist InPlaylist/NotInPlaylist rules

Previously, the InPlaylist/NotInPlaylist smart playlist criteria only
allowed referencing public playlists, regardless of who owned the smart
playlist. This was too restrictive for owners referencing their own
private playlists and for admins who should have unrestricted access.

The fix passes the smart playlist owner's identity and admin status into
the criteria SQL builder, so that: admins can reference any playlist,
regular users can reference public playlists plus their own private ones,
and inaccessible referenced playlists produce a warning instead of a hard
error. Also prevents recursive refresh of child playlists the owner
cannot access.

* test(e2e): clarify user roles and fix playlist visibility tests

Renamed testUser/otherUser to adminUser/regularUser to make the admin
vs regular user distinction explicit in test code. Fixed three playlist
visibility tests that were evaluating as admin (bypassing all access
checks) instead of as a regular user, so the public playlist path is
now actually exercised. All playlist operator tests now use explicit
evaluateRuleAs calls with the appropriate user role.

* fix: sync rulesSQL criteria after limitPercent resolution

The rulesSQL struct captures a copy of rules at creation time. When
limitPercent is resolved later, rules.Limit is updated but rulesSQL
still holds the stale value. This caused percentage-based smart playlist
limits to be silently ignored. Fix by updating rulesSQL.criteria after
the resolution.

* refactor: convert inList to a method on smartPlaylistCriteria

The inList function already receives ownerID and ownerIsAdmin from the
smartPlaylistCriteria caller. Making it a method lets it access those
fields directly from the receiver, simplifying the signature and staying
consistent with exprSQL which was already converted to a method.

* refactor: simplify function signatures by removing type parameters in criteria_sql.go

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-25 14:59:06 -04:00

564 lines
17 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/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"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)
}
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{
"q": playlistFilter,
"smart": smartPlaylistFilter,
})
r.setSortMappings(map[string]string{
"owner_name": "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) {
sq := Select().Where(r.userFilter())
return r.count(sq, 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) error {
pls := dbPlaylist{Playlist: *p}
if pls.ID == "" {
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())
}
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
}
return &pls[0].Playlist, nil
}
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
}
return playlists, err
}
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
}
return playlists, nil
}
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).Join("user on user.id = owner_id").
Columns(r.tableName+".*", "user.user_name as owner_name")
}
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
return false
}
// Never refresh other users' playlists
usr := loggedUser(r.ctx)
if pls.OwnerID != usr.ID {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
// Remove old tracks
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
_, err := r.executeSQL(del)
if err != nil {
log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Re-populate playlist based on Smart Playlist criteria
rules := *pls.Rules
rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin))
// If the playlist depends on other playlists, recursively refresh them first
childPlaylistIds := rules.ChildPlaylistIds()
for _, id := range childPlaylistIds {
childPls, err := r.Get(id)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
continue
}
log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
return false
}
r.refreshSmartPlaylist(childPls)
}
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
// Only include media files from libraries the user has access to
sq = r.applyLibraryFilter(sq, "media_file")
// Resolve percentage-based limit to an absolute number before applying criteria
if rules.IsPercentageLimit() {
// Use only expression-based joins for the COUNT query (sort joins are unnecessary)
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file").
LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
countSq = countSq.Where(cond)
var res struct{ Count int64 }
err = r.queryOne(countSq, &res)
if err != nil {
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
return false
}
resolvedLimit := rules.EffectiveLimit(res.Count)
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit)
rules.Limit = resolvedLimit
rules.LimitPercent = 0
rulesSQL.criteria = rules
}
// Apply the criteria rules
sq, err = r.addCriteria(sq, rulesSQL)
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
if err != nil {
log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Update playlist stats
err = r.refreshCounters(pls)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Update when the playlist was last refreshed (for cache purposes)
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
_, err = r.executeSQL(updSql)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
}
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
return sq
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
cond, err := cSQL.Where()
if err != nil {
return sql, err
}
sql = sql.Where(cond)
if cSQL.criteria.Limit > 0 {
sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset))
}
if order := cSQL.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql, nil
}
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
}
return r.addTracks(playlistId, 1, mediaFileIds)
}
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
// 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++
}
_, err := r.executeSQL(ins)
if err != nil {
return err
}
}
return r.refreshCounters(&model.Playlist{ID: playlistId})
}
// 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
upd := Update("playlist").
Set("duration", res.Duration).
Set("size", res.Size).
Set("song_count", res.Count).
Set("updated_at", time.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)
return nil
}
func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) {
sel = r.applyLibraryFilter(sel, "f")
userID := loggedUser(r.ctx).ID
tracksQuery := sel.
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})
tracks := dbPlaylistTracks{}
err := r.queryAll(tracksQuery, &tracks)
if err != nil {
return nil, err
}
return tracks.toModels(), 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")...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
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
}
return r.refreshCounters(&model.Playlist{ID: id})
}
var _ model.PlaylistRepository = (*playlistRepository)(nil)
var _ rest.Repository = (*playlistRepository)(nil)
var _ rest.Persistable = (*playlistRepository)(nil)