package scanner import ( "context" "fmt" "os" "strings" "sync/atomic" "time" ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "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 core.Playlists cw artwork.CacheWarmer refreshed atomic.Uint32 } func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, ds: ds, pls: pls, cw: cw, } } 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 } u, _ := request.UserFrom(p.ctx) if !u.IsAdmin { log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ "Please create an admin user first, and then update the playlists for them to be imported") return nil } count := 0 cursor, err := p.ds.Folder(p.ctx).GetTouchedWithPlaylists() if err != nil { return fmt.Errorf("loading touched folders: %w", err) } log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh") for folder, err := range cursor { if err != nil { return fmt.Errorf("loading touched folder: %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 } 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.ImportFile(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)) } p.cw.PreCache(pls.CoverArtID()) 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) } logF(p.ctx, "Scanner: Finished refreshing playlists", "refreshed", refreshed, err) return err } var _ phase[*model.Folder] = (*phasePlaylists)(nil)