mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Song art URLs (mf- ids: player bar, queue) were cache-busted only by the song's own updatedAt, and cover responses carry a 10-year max-age — so after an album cover upload/delete the browser kept serving the stale cached image (including the placeholder, for previously artless albums) until the file itself was rescanned. Expose the album's cover_art_updated_at on song payloads via a scalar subquery (a join would make unqualified references ambiguous — media_file and album share column names), in both the media_file select and the playlist tracks query, and pass it through the player queue builder so getCoverArtUrl picks it up.
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"iter"
|
|
"slices"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"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) {
|
|
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
|
|
}
|
|
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) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) {
|
|
// Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists.
|
|
sel := r.selectPlaylist(options...).Where(r.userFilter())
|
|
cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wrapPlaylistCursor(cursor), nil
|
|
}
|
|
|
|
// dbPlaylist embeds a value, not a pointer, so its model is never nil.
|
|
func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor {
|
|
return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist }))
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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",
|
|
"(select cover_art_updated_at from album where album.id = f.album_id) as cover_art_updated_at",
|
|
).
|
|
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
|
|
}
|
|
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)
|