navidrome/scanner/scanner.go

258 lines
6.7 KiB
Go

package scanner
import (
"context"
"errors"
"fmt"
"sync"
"time"
"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/server/events"
"github.com/navidrome/navidrome/utils/singleton"
)
type Scanner interface {
RescanAll(ctx context.Context, fullRescan bool) error
Status(library string) (*StatusInfo, error)
}
type StatusInfo struct {
Library string
Scanning bool
LastScan time.Time
Count uint32
FolderCount uint32
}
var (
ErrAlreadyScanning = errors.New("already scanning")
ErrScanError = errors.New("scan error")
)
type FolderScanner interface {
// Scan process finds any changes after `lastModifiedSince` and returns the number of changes found
Scan(ctx context.Context, fullRescan bool, progress chan uint32) (int64, error)
}
var isScanning sync.Mutex
type scanner struct {
once sync.Once
folders map[string]FolderScanner
libs map[string]model.Library
status map[string]*scanStatus
lock *sync.RWMutex
ds model.DataStore
pls core.Playlists
broker events.Broker
cacheWarmer artwork.CacheWarmer
}
type scanStatus struct {
active bool
fileCount uint32
folderCount uint32
lastUpdate time.Time
}
func GetInstance(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, broker events.Broker) Scanner {
return singleton.GetInstance(func() *scanner {
s := &scanner{
ds: ds,
pls: playlists,
broker: broker,
folders: map[string]FolderScanner{},
libs: map[string]model.Library{},
status: map[string]*scanStatus{},
lock: &sync.RWMutex{},
cacheWarmer: cacheWarmer,
}
s.loadFolders()
return s
})
}
func (s *scanner) rescan(ctx context.Context, library string, fullRescan bool) error {
folderScanner := s.folders[library]
start := time.Now()
lib, ok := s.libs[library]
if !ok {
log.Error(ctx, "Folder not a valid library path", "folder", library)
return fmt.Errorf("folder %s not a valid library path", library)
}
s.setStatusStart(library)
defer s.setStatusEnd(library, start)
if fullRescan {
log.Debug("Scanning folder (full scan)", "folder", library)
} else {
log.Debug("Scanning folder", "folder", library, "lastScan", lib.LastScanAt)
}
progress, cancel := s.startProgressTracker(library)
defer cancel()
changeCount, err := folderScanner.Scan(ctx, fullRescan, progress)
if err != nil {
log.Error("Error scanning Library", "folder", library, err)
}
if changeCount > 0 {
log.Debug(ctx, "Detected changes in the music folder. Sending refresh event",
"folder", library, "changeCount", changeCount)
// Don't use real context, forcing a refresh in all open windows, including the one that triggered the scan
s.broker.SendMessage(context.Background(), &events.RefreshResource{})
}
s.updateLastModifiedSince(ctx, library, start)
return err
}
func (s *scanner) startProgressTracker(library string) (chan uint32, context.CancelFunc) {
// Must be a new context (not the one passed to the scan method) to allow broadcasting the scan status to all clients
ctx, cancel := context.WithCancel(context.Background())
progress := make(chan uint32, 100)
go func() {
s.broker.SendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0})
defer func() {
if status, ok := s.getStatus(library); ok {
s.broker.SendMessage(ctx, &events.ScanStatus{
Scanning: false,
Count: int64(status.fileCount),
FolderCount: int64(status.folderCount),
})
}
}()
for {
select {
case <-ctx.Done():
return
case count := <-progress:
if count == 0 {
continue
}
totalFolders, totalFiles := s.incStatusCounter(library, count)
s.broker.SendMessage(ctx, &events.ScanStatus{
Scanning: true,
Count: int64(totalFiles),
FolderCount: int64(totalFolders),
})
}
}
}()
return progress, cancel
}
func (s *scanner) getStatus(folder string) (scanStatus, bool) {
s.lock.RLock()
defer s.lock.RUnlock()
status, ok := s.status[folder]
return *status, ok
}
func (s *scanner) incStatusCounter(folder string, numFiles uint32) (totalFolders uint32, totalFiles uint32) {
s.lock.Lock()
defer s.lock.Unlock()
if status, ok := s.status[folder]; ok {
status.fileCount += numFiles
status.folderCount++
totalFolders = status.folderCount
totalFiles = status.fileCount
}
return
}
func (s *scanner) setStatusStart(folder string) {
s.lock.Lock()
defer s.lock.Unlock()
if status, ok := s.status[folder]; ok {
status.active = true
status.fileCount = 0
status.folderCount = 0
}
}
func (s *scanner) setStatusEnd(folder string, lastUpdate time.Time) {
s.lock.Lock()
defer s.lock.Unlock()
if status, ok := s.status[folder]; ok {
status.active = false
status.lastUpdate = lastUpdate
}
}
func (s *scanner) RescanAll(ctx context.Context, fullRescan bool) error {
ctx = context.WithoutCancel(ctx)
s.once.Do(s.loadFolders)
if !isScanning.TryLock() {
log.Debug(ctx, "Scanner already running, ignoring request for rescan.")
return ErrAlreadyScanning
}
defer isScanning.Unlock()
var hasError bool
for folder := range s.folders {
err := s.rescan(ctx, folder, fullRescan)
hasError = hasError || err != nil
}
if hasError {
log.Error(ctx, "Errors while scanning media. Please check the logs")
core.WriteAfterScanMetrics(ctx, s.ds, false)
return ErrScanError
}
core.WriteAfterScanMetrics(ctx, s.ds, true)
return nil
}
func (s *scanner) Status(library string) (*StatusInfo, error) {
s.once.Do(s.loadFolders)
status, ok := s.getStatus(library)
if !ok {
return nil, errors.New("library not found")
}
return &StatusInfo{
Library: library,
Scanning: status.active,
LastScan: status.lastUpdate,
Count: status.fileCount,
FolderCount: status.folderCount,
}, nil
}
func (s *scanner) updateLastModifiedSince(ctx context.Context, folder string, t time.Time) {
lib := s.libs[folder]
id := lib.ID
if err := s.ds.Library(ctx).UpdateLastScan(id, t); err != nil {
log.Error("Error updating DB after scan", err)
}
lib.LastScanAt = t
s.libs[folder] = lib
}
func (s *scanner) loadFolders() {
ctx := context.TODO()
libs, _ := s.ds.Library(ctx).GetAll()
for _, lib := range libs {
log.Info("Configuring Media Folder", "name", lib.Name, "path", lib.Path)
s.folders[lib.Path] = s.newScanner(lib)
s.libs[lib.Path] = lib
s.status[lib.Path] = &scanStatus{
active: false,
fileCount: 0,
folderCount: 0,
lastUpdate: lib.LastScanAt,
}
}
}
func (s *scanner) newScanner(f model.Library) FolderScanner {
return NewTagScanner(f, s.ds, s.pls, s.cacheWarmer)
}