package scanner
import (
"context"
"errors"
"fmt"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/pl"
"github.com/navidrome/navidrome/utils/singleton"
"golang.org/x/time/rate"
)
var (
ErrAlreadyScanning = model.ErrAlreadyScanning
)
func New(rootCtx context.Context, ds model.DataStore, broker events.Broker,
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
c := &controller{
rootCtx: rootCtx,
ds: ds,
broker: broker,
pls: pls,
metrics: m,
devExternalScanner: conf.Server.DevExternalScanner,
}
if !c.devExternalScanner {
c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate})
}
return c
}
func (s *controller) getScanner() scanner {
if s.devExternalScanner {
return &scannerExternal{}
}
return &scannerImpl{ds: s.ds, pls: s.pls}
}
func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
}
defer release()
ctx = auth.WithAdminUser(ctx, ds)
progress := make(chan *ProgressInfo, 100)
go func() {
defer close(progress)
scanner := &scannerImpl{ds: ds, pls: pls}
scanner.scanFolders(ctx, fullScan, targets, progress)
}()
return progress, nil
}
func IsScanning() bool {
return running.Load()
}
type ProgressInfo struct {
LibID int
FileCount uint32
Path string
Phase string
ChangesDetected bool
Warning string
Error string
ForceUpdate bool
}
type scanner interface {
scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo)
}
type controller struct {
rootCtx context.Context
ds model.DataStore
broker events.Broker
metrics metrics.Metrics
pls playlists.Playlists
limiter *rate.Sometimes
devExternalScanner bool
count atomic.Uint32
folderCount atomic.Uint32
changesDetected bool
}
func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) {
libs, err := s.ds.Library().GetAll(ctx, model.QueryOptions{
Sort: "last_scan_at",
Order: "desc",
Max: 1,
})
if err != nil {
return time.Time{}, fmt.Errorf("getting libraries: %w", err)
}
if len(libs) == 0 {
return time.Time{}, nil
}
return libs[0].LastScanAt, nil
}
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
lastErr, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "")
scanType, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "")
startTimeStr, _ := s.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "")
if startTimeStr != "" {
startTime, err := time.Parse(time.RFC3339, startTimeStr)
if err == nil {
if running.Load() {
elapsed = time.Since(startTime)
} else {
lastScanTime, err := s.getLastScanTime(ctx)
if err == nil && !lastScanTime.IsZero() {
elapsed = lastScanTime.Sub(startTime)
}
}
}
}
return scanType, elapsed, lastErr
}
func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) {
lastScanTime, err := s.getLastScanTime(ctx)
if err != nil {
return nil, fmt.Errorf("getting last scan time: %w", err)
}
scanType, elapsed, lastErr := s.getScanInfo(ctx)
if running.Load() {
status := &model.ScannerStatus{
Scanning: true,
LastScan: lastScanTime,
Count: s.count.Load(),
FolderCount: s.folderCount.Load(),
LastError: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}
return status, nil
}
count, folderCount, err := s.getCounters(ctx)
if err != nil {
return nil, fmt.Errorf("getting library stats: %w", err)
}
return &model.ScannerStatus{
Scanning: false,
LastScan: lastScanTime,
Count: uint32(count),
FolderCount: uint32(folderCount),
LastError: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}, nil
}
func (s *controller) getCounters(ctx context.Context) (int64, int64, error) {
libs, err := s.ds.Library().GetAll(ctx)
if err != nil {
return 0, 0, fmt.Errorf("library count: %w", err)
}
var count, folderCount int64
for _, l := range libs {
count += int64(l.TotalSongs)
folderCount += int64(l.TotalFolders)
}
return count, folderCount, nil
}
func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]string, error) {
return s.ScanFolders(requestCtx, fullScan, nil)
}
func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
release, err := lockScan(requestCtx)
if err != nil {
return nil, err
}
defer release()
ctx := request.AddValues(s.rootCtx, requestCtx)
ctx = auth.WithAdminUser(ctx, s.ds)
effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets)
if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) {
if err := db.MarkOptimizePending(ctx); err != nil {
log.Error(ctx, "Scanner: Error marking DB analysis pending", err)
}
}
s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0})
progress := make(chan *ProgressInfo, 100)
go func() {
defer close(progress)
scanner := s.getScanner()
scanner.scanFolders(ctx, fullScan, targets, progress)
}()
scanWarnings, scanError := s.trackProgress(ctx, progress)
for _, w := range scanWarnings {
log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w))
}
if scanError != nil {
_ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, scanError.Error())
}
if effectiveFullScan && scanError == nil {
if err := db.Optimize(ctx); err != nil {
log.Error(ctx, "Scanner: Error analyzing DB", err)
}
}
if s.changesDetected {
log.Debug(ctx, "Library changes imported. Sending refresh event")
s.broker.SendBroadcastMessage(ctx, &events.RefreshResource{})
}
if count, folderCount, err := s.getCounters(ctx); err != nil {
s.metrics.WriteAfterScanMetrics(ctx, false)
return scanWarnings, err
} else {
scanType, elapsed, lastErr := s.getScanInfo(ctx)
s.metrics.WriteAfterScanMetrics(ctx, true)
s.sendMessage(ctx, &events.ScanStatus{
Scanning: false,
Count: count,
FolderCount: folderCount,
Error: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
})
}
return scanWarnings, scanError
}
var (
running atomic.Bool
scanMaintenanceMux sync.Mutex
)
func lockScan(ctx context.Context) (func(), error) {
if !running.CompareAndSwap(false, true) {
log.Debug(ctx, "Scanner already running, ignoring request")
return func() {}, ErrAlreadyScanning
}
scanMaintenanceMux.Lock()
return func() {
scanMaintenanceMux.Unlock()
running.Store(false)
}, nil
}
func LockForMaintenance() (func(), bool) {
if !scanMaintenanceMux.TryLock() {
return func() {}, false
}
if running.Load() {
scanMaintenanceMux.Unlock()
return func() {}, false
}
return scanMaintenanceMux.Unlock, true
}
func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool {
if fullScan {
return true
}
return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool {
return library.FullScanInProgress || library.NeedsPIDRescan()
})
}
func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool {
return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool {
return library.LastScanAt.IsZero()
})
}
func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool {
libraries, err := ds.Library().GetAll(ctx)
if err != nil {
return false
}
if len(targets) == 0 {
return slices.ContainsFunc(libraries, pred)
}
targeted := make(map[int]struct{}, len(targets))
for _, target := range targets {
targeted[target.LibraryID] = struct{}{}
}
return slices.ContainsFunc(libraries, func(library model.Library) bool {
_, ok := targeted[library.ID]
return ok && pred(library)
})
}
func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) {
s.count.Store(0)
s.folderCount.Store(0)
s.changesDetected = false
var warnings []string
var errs []error
for p := range pl.ReadOrDone(ctx, progress) {
if p.Error != "" {
errs = append(errs, errors.New(p.Error))
continue
}
if p.Warning != "" {
warnings = append(warnings, p.Warning)
continue
}
if p.ChangesDetected {
s.changesDetected = true
continue
}
s.count.Add(p.FileCount)
if p.FileCount > 0 {
s.folderCount.Add(1)
}
scanType, elapsed, lastErr := s.getScanInfo(ctx)
status := &events.ScanStatus{
Scanning: true,
Count: int64(s.count.Load()),
FolderCount: int64(s.folderCount.Load()),
Error: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}
if s.limiter != nil && !p.ForceUpdate {
s.limiter.Do(func() { s.sendMessage(ctx, status) })
} else {
s.sendMessage(ctx, status)
}
}
return warnings, errors.Join(errs...)
}
func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) {
s.broker.SendBroadcastMessage(ctx, status)
}
func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker,
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
return singleton.GetInstance(func() *controller {
return New(rootCtx, ds, broker, pls, m).(*controller)
})
}