package scanner
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type missingTracks struct {
lib model.Library
pid string
missing model.MediaFiles
matched model.MediaFiles
}
type phaseMissingTracks struct {
ctx context.Context
ds model.DataStore
totalMatched atomic.Uint32
state *scanState
processedAlbumAnnotations map[string]bool
annotationMutex sync.RWMutex
}
func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks {
return &phaseMissingTracks{
ctx: ctx,
ds: ds,
state: state,
processedAlbumAnnotations: make(map[string]bool),
}
}
func (p *phaseMissingTracks) description() string {
return "Process missing files, checking for moves"
}
func (p *phaseMissingTracks) producer() ppl.Producer[*missingTracks] {
return ppl.NewProducer(p.produce, ppl.Name("load missing tracks from db"))
}
func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
count := 0
var putIfMatched = func(mt missingTracks) {
if mt.pid != "" && len(mt.missing) > 0 {
log.Trace(p.ctx, "Scanner: Found missing tracks", "pid", mt.pid, "missing", "title", mt.missing[0].Title,
len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name,
)
count++
put(&mt)
}
}
for _, lib := range p.state.libraries {
log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name)
cursor, err := p.ds.MediaFile().GetMissingAndMatching(p.ctx, lib.ID)
if err != nil {
return fmt.Errorf("loading missing tracks for library %s: %w", lib.Name, err)
}
mt := missingTracks{lib: lib}
for mf, err := range cursor {
if err != nil {
return fmt.Errorf("loading missing tracks for library %s: %w", lib.Name, err)
}
if mt.pid != mf.PID {
putIfMatched(mt)
mt.pid = mf.PID
mt.missing = nil
mt.matched = nil
}
if mf.Missing {
mt.missing = append(mt.missing, mf)
} else {
mt.matched = append(mt.matched, mf)
}
}
putIfMatched(mt)
if count == 0 {
log.Debug(p.ctx, "Scanner: No potential moves found", "libraryId", lib.ID, "libraryName", lib.Name)
} else {
log.Debug(p.ctx, "Scanner: Found potential moves", "libraryId", lib.ID, "count", count)
}
}
return nil
}
func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] {
return []ppl.Stage[*missingTracks]{
ppl.NewStage(p.processMissingTracks, ppl.Name("process missing tracks")),
ppl.NewStage(p.processCrossLibraryMoves, ppl.Name("process cross-library moves")),
}
}
func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) {
hasMatches := false
usedMatched := make(map[string]bool, len(in.matched))
for _, ms := range in.missing {
var exactMatch model.MediaFile
var equivalentMatch model.MediaFile
for _, mt := range in.matched {
if usedMatched[mt.ID] {
continue
}
if ms.Equals(mt) {
exactMatch = mt
break
}
if ms.IsEquivalent(mt) {
equivalentMatch = mt
}
}
if exactMatch.ID != "" {
log.Debug(p.ctx, "Scanner: Found missing track in a new place", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(exactMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error moving matched track", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name, err)
return nil, err
}
usedMatched[exactMatch.ID] = true
p.totalMatched.Add(1)
hasMatches = true
continue
}
if len(in.missing) == 1 && len(in.matched) == 1 && !usedMatched[in.matched[0].ID] {
singleMatch := in.matched[0]
log.Debug(p.ctx, "Scanner: Found track with same persistent ID in a new place", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(singleMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name, err)
return nil, err
}
usedMatched[singleMatch.ID] = true
p.totalMatched.Add(1)
hasMatches = true
continue
}
if equivalentMatch.ID != "" {
log.Debug(p.ctx, "Scanner: Found missing track with same base path", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(equivalentMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name, err)
return nil, err
}
usedMatched[equivalentMatch.ID] = true
p.totalMatched.Add(1)
hasMatches = true
}
}
if hasMatches {
return nil, nil
}
return in, nil
}
func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missingTracks, error) {
if in == nil {
return nil, nil
}
if p.state.totalLibraryCount == 1 {
log.Debug(p.ctx, "Scanner: Skipping cross-library move detection (single library)")
return in, nil
}
log.Debug(p.ctx, "Scanner: Processing cross-library moves", "pid", in.pid, "missing", len(in.missing), "lib", in.lib.Name)
for _, missing := range in.missing {
found, err := p.findCrossLibraryMatch(missing)
if err != nil {
log.Error(p.ctx, "Scanner: Error searching for cross-library matches", "missing", missing.Path, "lib", in.lib.Name, err)
continue
}
if found.ID != "" {
log.Debug(p.ctx, "Scanner: Found cross-library moved track", "missing", missing.Path, "movedTo", found.Path, "fromLib", in.lib.Name, "toLib", found.LibraryName)
err := p.moveMatched(found, missing)
if err != nil {
log.Error(p.ctx, "Scanner: Error moving cross-library track", "missing", missing.Path, "movedTo", found.Path, err)
continue
}
p.totalMatched.Add(1)
}
}
return in, nil
}
func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) {
if missing.MbzReleaseTrackID != "" {
matches, err := p.ds.MediaFile().FindRecentFilesByMBZTrackID(p.ctx, missing, missing.CreatedAt)
if err != nil {
log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err)
} else {
for _, match := range matches {
if missing.Equals(match) {
return match, nil
}
}
if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
return matches[0], nil
}
}
}
matches, err := p.ds.MediaFile().FindRecentFilesByProperties(p.ctx, missing, missing.CreatedAt)
if err != nil {
log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err)
return model.MediaFile{}, err
}
for _, match := range matches {
if missing.Equals(match) {
return match, nil
}
}
if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
return matches[0], nil
}
return model.MediaFile{}, nil
}
func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error {
oldAlbumID := missing.AlbumID
newAlbumID := target.AlbumID
reassignAlbum := oldAlbumID != newAlbumID
if reassignAlbum {
p.annotationMutex.Lock()
reassignAlbum = !p.processedAlbumAnnotations[newAlbumID]
p.processedAlbumAnnotations[newAlbumID] = true
p.annotationMutex.Unlock()
if !reassignAlbum {
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
}
}
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
moved := target
moved.CreatedAt = missing.CreatedAt
moved.ID = missing.ID
if err := tx.MediaFile().Put(ctx, &moved); err != nil {
return fmt.Errorf("update matched track: %w", err)
}
if err := tx.MediaFile().Delete(ctx, target.ID); err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
if reassignAlbum {
log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil {
log.Warn(ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
}
}
}
return nil
}, "scanner: move matched track")
if err != nil {
if reassignAlbum {
p.annotationMutex.Lock()
delete(p.processedAlbumAnnotations, newAlbumID)
p.annotationMutex.Unlock()
}
return err
}
p.state.changesDetected.Store(true)
return nil
}
func (p *phaseMissingTracks) finalize(err error) error {
matched := p.totalMatched.Load()
if matched > 0 {
log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err)
}
if err != nil {
return err
}
if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) {
if err = p.purgeMissing(); err != nil {
log.Error(p.ctx, "Scanner: Error purging missing items", err)
}
}
return err
}
func (p *phaseMissingTracks) purgeMissing() error {
var deletedCount int64
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
var err error
deletedCount, err = tx.MediaFile().DeleteAllMissing(ctx)
return err
}, "scanner: purge missing")
if err != nil {
return fmt.Errorf("error deleting missing files: %w", err)
}
if deletedCount > 0 {
log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount)
p.state.changesDetected.Store(true)
} else {
log.Debug(p.ctx, "Scanner: No missing items to purge")
}
return nil
}
var _ phase[*missingTracks] = (*phaseMissingTracks)(nil)