mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 19:07:12 +02:00
An absent artwork state was revisited by an hourly job, by viewing the entity, and by the startup backfill on any artwork config change. On a large library the last one queued tens of thousands of external lookups at once and got the provider to rate-limit us for hours. Nothing revisits an absent state now. Retrying is explicit: `artwork reprocess` on the CLI, or the refresh button in the UI. The config fingerprint survives only as an advisory, warning at startup and naming the command that clears it. Since absent is terminal, `artwork status` splits it into two disjoint columns, and `--source failed` targets only the ones that gave up rather than being answered. Both read through the filter CountBySource and EnqueueBySource already share, so the reported number is the set the command acts on. Also fixes the last_failure default left by 20260819204637, which marked every pre-existing absent row as failed, and removes the code the deleted retry paths orphaned.
193 lines
5.8 KiB
Go
193 lines
5.8 KiB
Go
package model
|
|
|
|
import (
|
|
"iter"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"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)
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|