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:"-"` // 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 { 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 }