mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
176 lines
5.6 KiB
Go
176 lines
5.6 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
)
|
|
|
|
type phasePlaylists struct {
|
|
ctx context.Context
|
|
scanState *scanState
|
|
ds model.DataStore
|
|
pls playlists.Playlists
|
|
refreshed atomic.Uint32
|
|
pendingImport bool
|
|
}
|
|
|
|
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists {
|
|
return &phasePlaylists{
|
|
ctx: ctx,
|
|
scanState: scanState,
|
|
ds: ds,
|
|
pls: pls,
|
|
}
|
|
}
|
|
|
|
func (p *phasePlaylists) description() string {
|
|
return "Import/update playlists"
|
|
}
|
|
|
|
func (p *phasePlaylists) producer() ppl.Producer[*model.Folder] {
|
|
return ppl.NewProducer(p.produce, ppl.Name("load folders with playlists from db"))
|
|
}
|
|
|
|
func (p *phasePlaylists) produce(put func(entry *model.Folder)) error {
|
|
if !conf.Server.AutoImportPlaylists {
|
|
log.Info(p.ctx, "Playlists will not be imported, AutoImportPlaylists is set to false")
|
|
return nil
|
|
}
|
|
|
|
// Resolve the admin at phase time (the producer runs late in the scan), so an
|
|
// admin created while the scan was in progress is picked up. Assigned once,
|
|
// before any put() below, so the channel send synchronizes it with the stages.
|
|
admin, err := p.ds.User(p.ctx).FindFirstAdmin()
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return fmt.Errorf("finding admin user: %w", err)
|
|
}
|
|
noAdmin := admin == nil || admin.ID == ""
|
|
if noAdmin {
|
|
return p.deferImport()
|
|
}
|
|
p.ctx = request.WithUser(p.ctx, *admin)
|
|
|
|
// When recovering a deferred import, scan all playlist folders, not just touched ones.
|
|
pending, err := p.importPending()
|
|
if err != nil {
|
|
return fmt.Errorf("checking pending playlist import: %w", err)
|
|
}
|
|
p.pendingImport = pending
|
|
var cursor model.FolderCursor
|
|
if p.pendingImport {
|
|
cursor, err = p.ds.Folder(p.ctx).GetAllWithPlaylists()
|
|
} else {
|
|
cursor, err = p.ds.Folder(p.ctx).GetTouchedWithPlaylists()
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("loading folders with playlists: %w", err)
|
|
}
|
|
|
|
count := 0
|
|
log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh", "pendingImport", p.pendingImport)
|
|
for folder, err := range cursor {
|
|
if err != nil {
|
|
return fmt.Errorf("loading folder with playlists: %w", err)
|
|
}
|
|
count++
|
|
put(&folder)
|
|
}
|
|
if count == 0 {
|
|
log.Debug(p.ctx, "Scanner: No playlists need refreshing")
|
|
} else {
|
|
log.Debug(p.ctx, "Scanner: Found folders with playlists that may need refreshing", "count", count)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deferImport records the pending-import flag so a later scan with an admin can
|
|
// import the playlists, and returns an error if the flag can't be persisted (so
|
|
// the scan does not complete as successful without recording the recovery).
|
|
func (p *phasePlaylists) deferImport() error {
|
|
if err := p.ds.Property(p.ctx).Put(consts.PlaylistsImportPendingFlagKey, "1"); err != nil {
|
|
return fmt.Errorf("recording pending playlist import: %w", err)
|
|
}
|
|
log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+
|
|
"They will be imported automatically once an admin user is created.")
|
|
return nil
|
|
}
|
|
|
|
func (p *phasePlaylists) importPending() (bool, error) {
|
|
v, err := p.ds.Property(p.ctx).DefaultGet(consts.PlaylistsImportPendingFlagKey, "0")
|
|
return v == "1", err
|
|
}
|
|
|
|
func (p *phasePlaylists) stages() []ppl.Stage[*model.Folder] {
|
|
return []ppl.Stage[*model.Folder]{
|
|
ppl.NewStage(p.processPlaylistsInFolder, ppl.Name("process playlists in folder"), ppl.Concurrency(3)),
|
|
}
|
|
}
|
|
|
|
func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model.Folder, error) {
|
|
files, err := os.ReadDir(folder.AbsolutePath())
|
|
if err != nil {
|
|
log.Error(p.ctx, "Scanner: Error reading files", "folder", folder, err)
|
|
p.scanState.sendWarning(err.Error())
|
|
return folder, nil
|
|
}
|
|
for _, f := range files {
|
|
started := time.Now()
|
|
if strings.HasPrefix(f.Name(), ".") {
|
|
continue
|
|
}
|
|
if !model.IsValidPlaylist(f.Name()) {
|
|
continue
|
|
}
|
|
// BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc)
|
|
pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if pls.IsSmartPlaylist() {
|
|
log.Debug("Scanner: Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "elapsed", time.Since(started))
|
|
} else {
|
|
log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started))
|
|
}
|
|
item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary,
|
|
Priority: model.ArtworkPriorityScan}
|
|
if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil {
|
|
log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err)
|
|
}
|
|
p.refreshed.Add(1)
|
|
}
|
|
return folder, nil
|
|
}
|
|
|
|
func (p *phasePlaylists) finalize(err error) error {
|
|
refreshed := p.refreshed.Load()
|
|
logF := log.Info
|
|
if refreshed == 0 {
|
|
logF = log.Debug
|
|
} else {
|
|
p.scanState.changesDetected.Store(true)
|
|
}
|
|
if p.pendingImport && err == nil {
|
|
if derr := p.ds.Property(p.ctx).Delete(consts.PlaylistsImportPendingFlagKey); derr != nil {
|
|
log.Warn(p.ctx, "Scanner: Could not clear pending playlist-import flag", derr)
|
|
}
|
|
}
|
|
logF(p.ctx, "Scanner: Finished refreshing playlists", "refreshed", refreshed, err)
|
|
return err
|
|
}
|
|
|
|
var _ phase[*model.Folder] = (*phasePlaylists)(nil)
|