package model
import (
"context"
"iter"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/deluan/rest"
"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:"-"`
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
}
func (pls Playlist) TracksEditable() bool {
return !pls.IsSmartPlaylist() && !pls.Sync
}
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()
}
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)
}
func (pls Playlist) UploadedImagePath() string {
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
}
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
}
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
}
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 {
rest.Repository[Playlist]
rest.Persistable[Playlist]
AnnotatedRepository
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
Exists(ctx context.Context, id string) (bool, error)
Put(ctx context.Context, pls *Playlist, cols ...string) error
Get(ctx context.Context, id string) (*Playlist, error)
GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
GetAll(ctx context.Context, options ...QueryOptions) (Playlists, error)
GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistCursor, error)
FindByPath(ctx context.Context, path string) (*Playlist, error)
Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(ctx context.Context, 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 {
rest.Repository[PlaylistTrack]
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
GetAll(ctx context.Context, options ...QueryOptions) (PlaylistTracks, error)
GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistTrackCursor, error)
GetAlbumIDs(ctx context.Context, options ...QueryOptions) ([]string, error)
GetMediaFileIDs(ctx context.Context, options ...QueryOptions) ([]string, error)
Add(ctx context.Context, mediaFileIds []string) (int, error)
Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error)
AddAlbums(ctx context.Context, albumIds []string) (int, error)
AddArtists(ctx context.Context, artistIds []string) (int, error)
AddDiscs(ctx context.Context, discs []DiscID) (int, error)
Delete(ctx context.Context, id ...string) error
DeleteAll(ctx context.Context) error
Reorder(ctx context.Context, pos int, newPos int) error
}