navidrome/model/playlist.go
David Vedvick 49f626c00a
feat(smartplaylists): add support for referencing playlists using paths (#5187)
* feat: Add support for referencing playlists using paths

Signed-off-by: David <dvedvick@gmail.com>

* feat: Support relative playlist paths in smartlists

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): protect against nil panic

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): refreshing child playlists

Signed-off-by: David <dvedvick@gmail.com>

* chore(smartplaylists): log field parsing error

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): handle empty playlist paths

Signed-off-by: David <dvedvick@gmail.com>

* refactor(smartplaylists): make NormalizeChildPaths non-mutating

Signed-off-by: David <dvedvick@gmail.com>

* fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field

Rules that reference a playlist by id have no path field, and the reverse, so
the warning fired on every refresh. The log call also had a bad argument count.

* fix(smartplaylists): ignore empty inPlaylist id and path references

An empty path matched every playlist without a file path, including the
referencing playlist itself, so the refresh recursed until the stack overflowed.
An empty id also shadowed a valid path in the same rule.

* fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms

A playlist path is stored in the Unicode form the filesystem reports, which can
differ from the form typed in the .nsp file. The exact comparison then found no
playlist for names with accents.

* fix(smartplaylists): keep all criteria fields when normalizing child paths

The field-by-field copy dropped RefreshDelay.

* fix(smartplaylists): clean absolute inPlaylist path references

Only relative references were cleaned, so an absolute reference such as
/music/./child.nsp never matched the stored /music/child.nsp.

* fix(smartplaylists): stop infinite recursion on playlists that reference each other

Two smart playlists referencing each other, by id or by path, recursed until the
stack overflowed and the server died. The refresh now tracks visited playlists.

* fix(smartplaylists): resolve inPlaylist path references with OS-native separators

Playlist.Path is OS-native, but references in a .nsp file use forward slashes.
On Windows they never matched, and a leading slash was not seen as absolute.
The specs now build OS-native paths, so they also run on Windows.

* fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved

A playlist created in the UI has no file path, so a relative reference silently
matched nothing.

* refactor(smartplaylists): simplify child playlist reference handling

Share one extractor for child ids and paths, return only the normalized rules
instead of a playlist copy, and resolve each path reference in a single switch.

* test(smartplaylists): store the Unicode child path in OS-native form

Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched
the normalized reference.

---------

Signed-off-by: David <dvedvick@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-19 21:05:58 -04:00

258 lines
7.9 KiB
Go

package model
import (
"iter"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/criteria"
)
type Playlist struct {
Annotations `structs:"-"`
ItemImage `structs:"-"`
ID string `structs:"id" json:"id"`
Name string `structs:"name" json:"name"`
Comment string `structs:"comment" json:"comment"`
Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
SongCount int `structs:"song_count" json:"songCount"`
OwnerName string `structs:"-" json:"ownerName"`
OwnerID string `structs:"owner_id" json:"ownerId"`
Public bool `structs:"public" json:"public"`
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
Path string `structs:"path" json:"path"`
Sync bool `structs:"sync" json:"sync"`
UploadedImage string `structs:"uploaded_image" json:"uploadedImage"`
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ImportedHash string `structs:"imported_hash" json:"-"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
}
func (pls Playlist) IsSmartPlaylist() bool {
return pls.Rules != nil && pls.Rules.Expression != nil
}
// TracksEditable reports whether the track list is user-owned rather than server-managed.
func (pls Playlist) TracksEditable() bool {
return !pls.IsSmartPlaylist() && !pls.Sync
}
// RefreshDelay returns the playlist's own refresh window when set, falling
// back to the global SmartPlaylistRefreshDelay.
func (pls Playlist) RefreshDelay() time.Duration {
if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 {
return pls.Rules.RefreshDelay
}
return conf.Server.SmartPlaylistRefreshDelay
}
func (pls Playlist) MediaFiles() MediaFiles {
if len(pls.Tracks) == 0 {
return nil
}
return pls.Tracks.MediaFiles()
}
func (pls *Playlist) refreshStats() {
pls.SongCount = len(pls.Tracks)
pls.Duration = 0
pls.Size = 0
for _, t := range pls.Tracks {
pls.Duration += t.MediaFile.Duration
pls.Size += t.MediaFile.Size
}
}
func (pls *Playlist) SetTracks(tracks PlaylistTracks) {
pls.Tracks = tracks
pls.refreshStats()
}
func (pls *Playlist) RemoveTracks(idxToRemove []int) {
var newTracks PlaylistTracks
for i, t := range pls.Tracks {
if slices.Contains(idxToRemove, i) {
continue
}
newTracks = append(newTracks, t)
}
pls.Tracks = newTracks
pls.refreshStats()
}
// ToM3U8 exports the playlist to the Extended M3U8 format
func (pls *Playlist) ToM3U8() string {
return pls.MediaFiles().ToM3U8(pls.Name, true)
}
func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) {
pos := len(pls.Tracks)
for _, mfId := range mediaFileIds {
pos++
t := PlaylistTrack{
ID: strconv.Itoa(pos),
MediaFileID: mfId,
MediaFile: MediaFile{ID: mfId},
PlaylistID: pls.ID,
}
pls.Tracks = append(pls.Tracks, t)
}
pls.refreshStats()
}
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
pos := len(pls.Tracks)
for _, mf := range mfs {
pos++
t := PlaylistTrack{
ID: strconv.Itoa(pos),
MediaFileID: mf.ID,
MediaFile: mf,
PlaylistID: pls.ID,
}
pls.Tracks = append(pls.Tracks, t)
}
pls.refreshStats()
}
func (pls Playlist) CoverArtID() ArtworkID {
return artworkIDFromPlaylist(pls)
}
// UploadedImagePath returns the absolute filesystem path for a manually uploaded
// playlist cover image. Returns empty string if no image has been uploaded.
// This does NOT cover sidecar images or external URLs — those are resolved
// by the artwork reader's fallback chain.
func (pls Playlist) UploadedImagePath() string {
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
}
// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths.
func (pls Playlist) NormalizedRules() *criteria.Criteria {
if pls.Rules == nil || pls.Rules.Expression == nil {
return pls.Rules
}
rules := *pls.Rules
rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path)
return &rules
}
func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression {
switch rule := inputRule.(type) {
case criteria.Any:
anyCriteria := make(criteria.Any, len(rule))
for i, rules := range rule {
anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
}
return anyCriteria
case criteria.All:
allCriteria := make(criteria.All, len(rule))
for i, rules := range rule {
allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
}
return allCriteria
case criteria.InPlaylist:
return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
case criteria.NotInPlaylist:
return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
}
return inputRule
}
func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any {
path, ok := rule["path"].(string)
if !ok || path == "" {
return rule
}
// References use forward slashes to stay portable, while Playlist.Path is OS-native.
path = filepath.FromSlash(path)
switch {
case isAbsPlaylistRef(path):
path = filepath.Clean(path)
case referencingPlaylistPath != "":
path = filepath.Join(filepath.Dir(referencingPlaylistPath), path)
default:
log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path)
}
normalized := maps.Clone(rule)
normalized["path"] = path
return normalized
}
// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute.
func isAbsPlaylistRef(path string) bool {
return filepath.IsAbs(path) || os.IsPathSeparator(path[0])
}
type Playlists []Playlist
type PlaylistCursor iter.Seq2[Playlist, error]
type PlaylistRepository interface {
ResourceRepository
AnnotatedRepository
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(pls *Playlist, cols ...string) error
Get(id string) (*Playlist, error)
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
GetCursor(options ...QueryOptions) (PlaylistCursor, error)
FindByPath(path string) (*Playlist, error)
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(mediaFileId string) (Playlists, error)
}
type PlaylistTrack struct {
ID string `json:"id"`
MediaFileID string `json:"mediaFileId"`
PlaylistID string `json:"playlistId"`
MediaFile
}
type PlaylistTracks []PlaylistTrack
func (plt PlaylistTracks) MediaFiles() MediaFiles {
mfs := make(MediaFiles, len(plt))
for i, t := range plt {
mfs[i] = t.MediaFile
}
return mfs
}
type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error]
type PlaylistTrackRepository interface {
ResourceRepository
CountAll(options ...QueryOptions) (int64, error)
GetAll(options ...QueryOptions) (PlaylistTracks, error)
GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error)
GetAlbumIDs(options ...QueryOptions) ([]string, error)
GetMediaFileIDs(options ...QueryOptions) ([]string, error)
Add(mediaFileIds []string) (int, error)
Insert(mediaFileIds []string, pos int) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)
AddDiscs(discs []DiscID) (int, error)
Delete(id ...string) error
DeleteAll() error
Reorder(pos int, newPos int) error
}