mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat: add optional natural sort order for names and titles Album, artist, song and playlist lists sort with a plain text comparison, so names containing numbers come out as "Foo 1, Foo 10, Foo 2" instead of "Foo 1, Foo 2, Foo 10" (issue #4554). Adds an EnableNaturalSorting option, default off, that switches those sorts to a NATSORT collation registered on every connection and backed by natural.CompareFold. natural.Compare gained an ASCII case-folding variant because it replaces 'collate nocase': sort_* columns hold raw tag values, so without folding they would order uppercase before lowercase. Applying the collation only inside mapSortOrder would have missed the default configuration entirely, since that mapper runs only when PreferSortTags is on. setSortMappings now also rewrites the order_* columns when natural sorting is enabled on its own. Sorts over plain text columns that are not order_* columns (playlist.name, album.name, media_file.title, playlist_tracks title) are wrapped explicitly, and qualified with their table because 'user' is joined and also has a 'name' column. The option defaults to off because the collation cannot use the existing indexes: measured on a synthetic 110k album library, the first page of an album-by-name listing goes from 0.03ms to 14ms. Indexing the expression was rejected outright - an index declared with a custom collation makes the whole database unreadable to any tool that does not register it, including the sqlite3 CLI, which fails even on 'select count(*)' and 'pragma integrity_check'. * refactor: fold the two sort-order mappers into one mapSortOrder and mapNaturalOrder shared the same regex and loop, differing only in the expression they substituted, and setSortMappings picked between them with a two-case switch. mapSortOrder now selects the column shape itself and defers to collatedSort for the collation, so the 'collate' clause is emitted in one place and the caller only has to decide whether any mapping is needed at all. The mapper tests were three near-identical cases that each hard-coded one flag combination; they are now a DescribeTable covering all four combinations of PreferSortTags and EnableNaturalSorting, which the previous set did not. The album sorting specs collapse the same way. Behavior is unchanged. * fix: leave plain sort columns alone when natural sorting is off collatedSort wrapped its column unconditionally, so the tiebreakers added for plain text columns picked up 'collate nocase' even with EnableNaturalSorting off. media_file.title, the playlist_tracks alias of it, and user.user_name are all declared without a collation, so a default install would have silently switched those tiebreaks from binary to case-insensitive ordering. Only playlist.name was already NOCASE and genuinely unaffected. The helper is now naturalSort and returns the column untouched unless the option is on, so the default path keeps the collation each column was declared with. sortCollation had a single remaining caller and folded into mapSortOrder. Tests: the CompareFold table body was a verbatim copy of the Compare one, so both now go through one expectOrder helper, and the album sorting specs inline two single-use closures. * fix(natural): defer the leading-zero tie-break to keep ordering transitive Compare applied the padding difference between numerically equal digit runs only when one side ended at the digit boundary, and ignored it mid-string. That made the relation intransitive: CompareFold("1","1a") < 0 and CompareFold("1a","01a") == 0, yet CompareFold("1","01a") > 0. SQLite requires a collating function to be transitive and leaves ORDER BY undefined otherwise, so registering this as NATSORT was not safe. Reproduced with the real driver on three artist names that occur in practice - "3", "3 doors down" and "03 greedo" - where paging one row at a time returned "03 greedo" twice and dropped "3" entirely. The padding difference is now carried as a tie-break that is applied only when the strings are otherwise equal, which restores transitivity while keeping the documented intent (a01 < a1, a0 < a00). Three existing entries changed: each asserted that two distinct strings compare equal, which was the same defect seen from the other side. Found by the Codex review on #6015.
487 lines
14 KiB
Go
487 lines
14 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/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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
r.enqueueCoverRebuild(playlistId)
|
|
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
|
|
}
|
|
|
|
// 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")...)
|
|
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
|
|
}
|
|
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)
|