navidrome/scanner/scanner.go

252 lines
6.6 KiB
Go

package scanner
import (
"context"
"errors"
"fmt"
"strconv"
"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"
)
type Scanner interface {
RescanAll(ctx context.Context, fullRescan bool) error
Status(mediaFolder string) (*StatusInfo, error)
}
type StatusInfo struct {
MediaFolder 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, lastModifiedSince time.Time, progress chan uint32) (int64, error)
}
var isScanning sync.Mutex
type scanner struct {
folders map[string]FolderScanner
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 New(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, broker events.Broker) Scanner {
s := &scanner{
ds: ds,
pls: playlists,
broker: broker,
folders: map[string]FolderScanner{},
status: map[string]*scanStatus{},
lock: &sync.RWMutex{},
cacheWarmer: cacheWarmer,
}
s.loadFolders()
return s
}
func (s *scanner) rescan(ctx context.Context, mediaFolder string, fullRescan bool) error {
folderScanner := s.folders[mediaFolder]
start := time.Now()
s.setStatusStart(mediaFolder)
defer s.setStatusEnd(mediaFolder, start)
lastModifiedSince := time.Time{}
if !fullRescan {
lastModifiedSince = s.getLastModifiedSince(ctx, mediaFolder)
log.Debug("Scanning folder", "folder", mediaFolder, "lastModifiedSince", lastModifiedSince)
} else {
log.Debug("Scanning folder (full scan)", "folder", mediaFolder)
}
progress, cancel := s.startProgressTracker(mediaFolder)
defer cancel()
changeCount, err := folderScanner.Scan(ctx, lastModifiedSince, progress)
if err != nil {
log.Error("Error importing MediaFolder", "folder", mediaFolder, err)
}
if changeCount > 0 {
log.Debug(ctx, "Detected changes in the music folder. Sending refresh event",
"folder", mediaFolder, "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(mediaFolder, start)
return err
}
func (s *scanner) startProgressTracker(mediaFolder string) (chan uint32, context.CancelFunc) {
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(mediaFolder); 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(mediaFolder, 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)
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(mediaFolder string) (*StatusInfo, error) {
status, ok := s.getStatus(mediaFolder)
if !ok {
return nil, errors.New("mediaFolder not found")
}
return &StatusInfo{
MediaFolder: mediaFolder,
Scanning: status.active,
LastScan: status.lastUpdate,
Count: status.fileCount,
FolderCount: status.folderCount,
}, nil
}
func (s *scanner) getLastModifiedSince(ctx context.Context, folder string) time.Time {
ms, err := s.ds.Property(ctx).Get(model.PropLastScan + "-" + folder)
if err != nil {
return time.Time{}
}
if ms == "" {
return time.Time{}
}
i, _ := strconv.ParseInt(ms, 10, 64)
return time.Unix(0, i*int64(time.Millisecond))
}
func (s *scanner) updateLastModifiedSince(folder string, t time.Time) {
millis := t.UnixNano() / int64(time.Millisecond)
if err := s.ds.Property(context.TODO()).Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis)); err != nil {
log.Error("Error updating DB after scan", err)
}
}
func (s *scanner) loadFolders() {
ctx := context.TODO()
fs, _ := s.ds.MediaFolder(ctx).GetAll()
for _, f := range fs {
log.Info("Configuring Media Folder", "name", f.Name, "path", f.Path)
s.folders[f.Path] = s.newScanner(f)
s.status[f.Path] = &scanStatus{
active: false,
fileCount: 0,
folderCount: 0,
lastUpdate: s.getLastModifiedSince(ctx, f.Path),
}
}
}
func (s *scanner) newScanner(f model.MediaFolder) FolderScanner {
return NewTagScanner(f.Path, s.ds, s.pls, s.cacheWarmer)
}