mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-03 00:47:48 +01:00
Builds the removeOrphans fixture with a single INSERT instead of shifting positions in two UPDATE passes, and has the Insert test keep the playlist id instead of reading a private repository field. Two comments now say that addTracks owns the existence check as well as the library check, and that Insert closes holes left by any id keepAccessible drops. No behavior change.
326 lines
11 KiB
Go
326 lines
11 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"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(ctx context.Context, playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository {
|
|
p := &playlistTrackRepository{}
|
|
p.playlistRepo = r
|
|
p.playlistId = playlistId
|
|
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(ctx, playlistId)
|
|
if err != nil {
|
|
log.Warn(ctx, "Error getting playlist's tracks", "playlistId", playlistId, err)
|
|
return nil
|
|
}
|
|
if refreshSmartPlaylist {
|
|
r.refreshSmartPlaylist(ctx, pls)
|
|
}
|
|
p.playlist = pls
|
|
return p
|
|
}
|
|
|
|
func (r *playlistTrackRepository) CountAll(ctx context.Context, 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(ctx, query, "f")
|
|
return r.count(ctx, query, options...)
|
|
}
|
|
|
|
func (r *playlistTrackRepository) Count(ctx context.Context, 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(ctx, query, "f")
|
|
return r.count(ctx, query, r.parseRestOptions(ctx, options...))
|
|
}
|
|
|
|
func (r *playlistTrackRepository) Read(ctx context.Context, id string) (*model.PlaylistTrack, error) {
|
|
userID := loggedUser(ctx).ID
|
|
sel := r.newSelect(ctx).
|
|
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(ctx, sel, "f")
|
|
var trk dbPlaylistTrack
|
|
err := r.queryOne(ctx, sel, &trk)
|
|
return trk.PlaylistTrack, err
|
|
}
|
|
|
|
func (r *playlistTrackRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) {
|
|
tracks, err := r.playlistRepo.loadTracks(ctx, r.newSelect(ctx, options...), r.playlistId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tracks, err
|
|
}
|
|
|
|
func (r *playlistTrackRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) {
|
|
sel := r.playlistRepo.tracksQuery(ctx, r.newSelect(ctx, options...), r.playlistId)
|
|
cursor, err := queryWithStableResults[dbPlaylistTrack](ctx, 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(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(ctx context.Context, options ...model.QueryOptions) ([]string, error) {
|
|
query := r.newSelect(ctx, options...).Columns("media_file_id").
|
|
Join("media_file f on f.id = media_file_id").
|
|
Where(Eq{"playlist_id": r.playlistId})
|
|
query = r.applyLibraryFilter(ctx, query, "f")
|
|
var ids []string
|
|
if err := r.queryAllSlice(ctx, query, &ids); err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (r *playlistTrackRepository) GetAlbumIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) {
|
|
query := r.newSelect(ctx, 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(ctx, query, "mf")
|
|
var ids []string
|
|
err := r.queryAllSlice(ctx, query, &ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (r *playlistTrackRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.PlaylistTrack, error) {
|
|
return r.GetAll(ctx, r.parseRestOptions(ctx, options...))
|
|
}
|
|
|
|
func (r *playlistTrackRepository) Add(ctx context.Context, mediaFileIds []string) (int, error) {
|
|
if len(mediaFileIds) > 0 {
|
|
log.Debug(ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
|
} else {
|
|
return 0, nil
|
|
}
|
|
|
|
// Get next pos (ID) in playlist
|
|
sq := r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId})
|
|
var res struct{ Max sql.NullInt32 }
|
|
if err := r.queryOne(ctx, sq, &res); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return r.playlistRepo.addTracks(ctx, 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(ctx context.Context, 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(ctx, 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(ctx, 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(ctx, mediaFileIds)
|
|
}
|
|
inserted, err := r.playlistRepo.addTracks(ctx, 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 keepAccessible
|
|
// leave a hole. Close it.
|
|
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
|
}
|
|
|
|
func (r *playlistTrackRepository) addMediaFileIds(ctx context.Context, 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(ctx, sq, &ids)
|
|
if err != nil {
|
|
log.Error(ctx, "Error getting tracks to add to playlist", err)
|
|
return 0, err
|
|
}
|
|
return r.Add(ctx, ids)
|
|
}
|
|
|
|
func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []string) (int, error) {
|
|
return r.addMediaFileIds(ctx, Eq{"album_id": albumIds})
|
|
}
|
|
|
|
func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) {
|
|
// Match by album-artist participation, not the deprecated album_artist_id
|
|
// column, which only holds the first album artist.
|
|
return r.addMediaFileIds(ctx, ParticipantIDFilter("media_file", artistIds, model.RoleAlbumArtist))
|
|
}
|
|
|
|
func (r *playlistTrackRepository) AddDiscs(ctx context.Context, 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(ctx, clauses)
|
|
}
|
|
|
|
// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks.
|
|
const deleteChunkSize = 200
|
|
|
|
func (r *playlistTrackRepository) Delete(ctx context.Context, ids ...string) error {
|
|
for chunk := range slices.Chunk(ids, deleteChunkSize) {
|
|
if err := r.delete(ctx, And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return r.playlistRepo.renumber(ctx, r.playlistId)
|
|
}
|
|
|
|
func (r *playlistTrackRepository) DeleteAll(ctx context.Context) error {
|
|
err := r.delete(ctx, Eq{"playlist_id": r.playlistId})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.playlistRepo.renumber(ctx, 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(ctx context.Context, pos int, newPos int) error {
|
|
var res struct{ Max sql.NullInt32 }
|
|
if err := r.queryOne(ctx, r.newSelect(ctx).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(ctx, 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(ctx, Expr(
|
|
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
|
|
pid, pos, newPos))
|
|
} else {
|
|
_, err = r.executeSQL(ctx, 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(ctx, 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(ctx, Expr(
|
|
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
|
|
return err
|
|
}
|
|
|
|
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|