mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
381 lines
14 KiB
Go
381 lines
14 KiB
Go
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
|
|
}
|
|
|
|
// phaseMissingTracks is responsible for processing missing media files during the scan process.
|
|
// It identifies media files that are marked as missing and attempts to find matching files that
|
|
// may have been moved or renamed. This phase helps in maintaining the integrity of the media
|
|
// library by ensuring that moved or renamed files are correctly updated in the database.
|
|
//
|
|
// The phaseMissingTracks phase performs the following steps:
|
|
// 1. Loads all libraries and their missing media files from the database.
|
|
// 2. For each library, it sorts the missing files by their PID (persistent identifier).
|
|
// 3. Groups missing and matched files by their PID and processes them to find exact or equivalent matches.
|
|
// 4. Updates the database with the new locations of the matched files and removes the old entries.
|
|
// 5. Logs the results and finalizes the phase by reporting the total number of matched files.
|
|
type phaseMissingTracks struct {
|
|
ctx context.Context //nolint:containedctx // phase runs under a single scan ctx
|
|
ds model.DataStore
|
|
totalMatched atomic.Uint32
|
|
state *scanState
|
|
processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments
|
|
annotationMutex sync.RWMutex // Protects processedAlbumAnnotations
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Group missing and matched tracks by PID
|
|
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
|
|
// Track which matched entries have already been consumed, so each matched track
|
|
// is only used once. Without this, the same matched track could be paired with
|
|
// multiple missing tracks, creating duplicate records with the same path.
|
|
usedMatched := make(map[string]bool, len(in.matched))
|
|
|
|
for _, ms := range in.missing {
|
|
var exactMatch model.MediaFile
|
|
var equivalentMatch model.MediaFile
|
|
|
|
// Identify exact and equivalent matches
|
|
for _, mt := range in.matched {
|
|
if usedMatched[mt.ID] {
|
|
continue
|
|
}
|
|
if ms.Equals(mt) {
|
|
exactMatch = mt
|
|
break // Prioritize exact match
|
|
}
|
|
if ms.IsEquivalent(mt) {
|
|
equivalentMatch = mt
|
|
}
|
|
}
|
|
|
|
// Use the exact match if found
|
|
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 there is only one missing and one matched track, consider them equivalent (same PID)
|
|
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
|
|
}
|
|
|
|
// Use the equivalent match if no other better match was found
|
|
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 any matches were found in this missingTracks group, return nil
|
|
// This signals the next stage to skip processing this group
|
|
if hasMatches {
|
|
return nil, nil
|
|
}
|
|
|
|
// If no matches found, pass through to next stage
|
|
return in, nil
|
|
}
|
|
|
|
// processCrossLibraryMoves processes files that weren't matched within their library
|
|
// and attempts to find matches in other libraries
|
|
func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missingTracks, error) {
|
|
// Skip if input is nil (meaning previous stage found matches)
|
|
if in == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Skip cross-library move detection when only one library is configured
|
|
// since there are no other libraries to search in.
|
|
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
|
|
}
|
|
|
|
// findCrossLibraryMatch searches for a missing file in other libraries using two-tier matching
|
|
func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) {
|
|
// First tier: Search by MusicBrainz Track ID if available
|
|
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 {
|
|
// Apply the same matching logic as within-library matching
|
|
for _, match := range matches {
|
|
if missing.Equals(match) {
|
|
return match, nil // Exact match found
|
|
}
|
|
}
|
|
|
|
// If only one match and it's equivalent, use it
|
|
if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
|
|
return matches[0], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Second tier: Search by intrinsic properties (title, size, suffix, etc.)
|
|
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
|
|
}
|
|
|
|
// Apply the same matching logic as within-library matching
|
|
for _, match := range matches {
|
|
if missing.Equals(match) {
|
|
return match, nil // Exact match found
|
|
}
|
|
}
|
|
|
|
// If only one match and it's equivalent, use it
|
|
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
|
|
// Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target.
|
|
// Claimed before the transaction so a concurrent move skips it, and released if the move fails.
|
|
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 {
|
|
// A rerun must start from the original target, not the one the rolled-back attempt changed
|
|
moved := target
|
|
// Preserve the original created_at from the missing file, so moved tracks
|
|
// don't appear in "Recently Added"
|
|
moved.CreatedAt = missing.CreatedAt
|
|
|
|
// Update the target media file with the missing file's ID. This effectively "moves" the track
|
|
// to the new location while keeping its annotations and references intact.
|
|
moved.ID = missing.ID
|
|
if err := tx.MediaFile().Put(ctx, &moved); err != nil {
|
|
return fmt.Errorf("update matched track: %w", err)
|
|
}
|
|
|
|
// Discard the new mediafile row (the one that was moved to)
|
|
if err := tx.MediaFile().Delete(ctx, target.ID); err != nil {
|
|
return fmt.Errorf("delete discarded track: %w", err)
|
|
}
|
|
|
|
if reassignAlbum {
|
|
// Reassign direct album annotations (starred, rating)
|
|
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)
|
|
}
|
|
|
|
// Keep created_at field from previous instance of the album, so moved albums
|
|
// don't appear in "Recently Added"
|
|
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)
|
|
}
|
|
}
|
|
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
|
|
}
|
|
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
|
|
}
|
|
|
|
// Check if we should purge missing items
|
|
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)
|
|
// Set changesDetected to true so that garbage collection will run at the end of the scan process
|
|
p.state.changesDetected.Store(true)
|
|
} else {
|
|
log.Debug(p.ctx, "Scanner: No missing items to purge")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var _ phase[*missingTracks] = (*phaseMissingTracks)(nil)
|