package core import ( "context" "errors" "fmt" "slices" "sync" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" ) var ( // ErrNotMissing is returned when a remap is attempted from a file not marked as missing. ErrNotMissing = errors.New("file is not marked as missing") // ErrTargetMissing is returned when the remap target is itself a missing file. ErrTargetMissing = errors.New("target file is missing") // ErrSameFile is returned when the remap source and target are the same file. ErrSameFile = errors.New("missing and target are the same file") ) type Maintenance interface { // DeleteMissingFiles deletes specific missing files by their IDs DeleteMissingFiles(ctx context.Context, ids []string) error // DeleteAllMissingFiles deletes all files marked as missing DeleteAllMissingFiles(ctx context.Context) error // RemapMissingFile moves a missing file's identity onto an existing file, the manual // counterpart to the scanner's move detection (phaseMissingTracks.moveMatched). RemapMissingFile(ctx context.Context, missingID, targetID string) error } type maintenanceService struct { ds model.DataStore wg sync.WaitGroup } func NewMaintenance(ds model.DataStore) Maintenance { return &maintenanceService{ ds: ds, } } func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error { return s.deleteMissing(ctx, ids) } func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error { return s.deleteMissing(ctx, nil) } func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error { if missingID == targetID { return fmt.Errorf("%w: %q", ErrSameFile, missingID) } missing, err := s.ds.MediaFile().Get(ctx, missingID) if err != nil { return fmt.Errorf("loading missing file %q: %w", missingID, err) } if !missing.Missing { return fmt.Errorf("%w: %q", ErrNotMissing, missingID) } target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID) if err != nil { return fmt.Errorf("loading target file %q: %w", targetID, err) } if target.Missing { return fmt.Errorf("%w: %q", ErrTargetMissing, targetID) } oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID err = s.ds.WithTx(func(tx model.DataStore) error { discardedID := target.ID // Preserve the original created_at so the remapped track doesn't resurface in "Recently Added" target.CreatedAt = missing.CreatedAt target.ID = missing.ID if err := tx.MediaFile().Put(ctx, target); err != nil { return fmt.Errorf("update matched track: %w", err) } // Unlike the scanner's freshly-imported target, this one may carry history of its own if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil { return fmt.Errorf("reassign target references: %w", err) } if err := tx.MediaFile().Delete(ctx, discardedID); err != nil { return fmt.Errorf("delete discarded track: %w", err) } if oldAlbumID != newAlbumID { oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) if err != nil { return fmt.Errorf("get old album tracks: %w", err) } if oldAlbumTracks == 0 { if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { return fmt.Errorf("reassign album annotations: %w", err) } if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { return fmt.Errorf("copy album attributes: %w", err) } } } return nil }) if err != nil { log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err) return err } if err := s.ds.GC(ctx); err != nil { log.Error(ctx, "Error running GC after remapping missing file", err) return err } // Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits. // album/artist play count aggregates are not recalculated here; they are refreshed by the next scan. if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil { log.Error(ctx, "Error refreshing artist stats after remapping missing file", err) } affectedAlbumIDs := []string{newAlbumID} if oldAlbumID != newAlbumID { affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID) } if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil { log.Error(ctx, "Error refreshing album stats after remapping missing file", err) } return nil } // deleteMissing handles the deletion of missing files and triggers necessary cleanup operations func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error { // Track affected album IDs before deletion for refresh affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids) if err != nil { log.Warn(ctx, "Error tracking affected albums for refresh", err) // Don't fail the operation, just log the warning } // Delete missing files within a transaction err = s.ds.WithTx(func(tx model.DataStore) error { if len(ids) == 0 { _, err := tx.MediaFile().DeleteAllMissing(ctx) return err } return tx.MediaFile().DeleteMissing(ctx, ids) }) if err != nil { log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) return err } // Run garbage collection to clean up orphaned records if err := s.ds.GC(ctx); err != nil { log.Error(ctx, "Error running GC after deleting missing tracks", err) return err } // Refresh statistics in background. album/artist play count aggregates are not recalculated // here; they are refreshed by the next scan. s.refreshStatsAsync(ctx, affectedAlbumIDs) return nil } // refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files. // It uses batch queries to minimize database round-trips for efficiency. func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error { if len(albumIDs) == 0 { return nil } log.Debug(ctx, "Refreshing albums", "count", len(albumIDs)) // Process in chunks to avoid query size limits const chunkSize = 100 for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) { if err := s.refreshAlbumChunk(ctx, chunk); err != nil { return fmt.Errorf("refreshing album chunk: %w", err) } } log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs)) return nil } // refreshAlbumChunk processes a single chunk of album IDs func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error { albumRepo := s.ds.Album() mfRepo := s.ds.MediaFile() // Batch load existing albums albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album.id": albumIDs}, }) if err != nil { return fmt.Errorf("loading albums: %w", err) } // Create a map for quick lookup albumMap := make(map[string]*model.Album, len(albums)) for i := range albums { albumMap[albums[i].ID] = &albums[i] } // Batch load all media files for these albums mediaFiles, err := mfRepo.GetAll(ctx, model.QueryOptions{ Filters: squirrel.Eq{"album_id": albumIDs}, Sort: "album_id, path", }) if err != nil { return fmt.Errorf("loading media files: %w", err) } // Group media files by album ID filesByAlbum := make(map[string]model.MediaFiles) for i := range mediaFiles { albumID := mediaFiles[i].AlbumID filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i]) } // Recalculate each album from its media files for albumID, oldAlbum := range albumMap { mfs, hasTracks := filesByAlbum[albumID] if !hasTracks { // Album has no tracks anymore, skip (will be cleaned up by GC) log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID) continue } // Recalculate album from media files newAlbum := mfs.ToAlbum() // Only update if something changed (avoid unnecessary writes) if !oldAlbum.Equals(newAlbum) { // Preserve original timestamps newAlbum.UpdatedAt = time.Now() newAlbum.CreatedAt = oldAlbum.CreatedAt if err := albumRepo.Put(ctx, &newAlbum); err != nil { log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err) // Continue with other albums instead of failing entirely continue } log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name) } } return nil } // getAffectedAlbumIDs returns distinct album IDs from missing media files func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) { var filters squirrel.Sqlizer = squirrel.Eq{"missing": true} if len(ids) > 0 { filters = squirrel.And{ squirrel.Eq{"missing": true}, squirrel.Eq{"media_file.id": ids}, } } mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{ Filters: filters, }) if err != nil { return nil, err } // Extract unique album IDs albumIDMap := make(map[string]struct{}, len(mfs)) for _, mf := range mfs { if mf.AlbumID != "" { albumIDMap[mf.AlbumID] = struct{}{} } } albumIDs := make([]string, 0, len(albumIDMap)) for id := range albumIDMap { albumIDs = append(albumIDs, id) } return albumIDs, nil } // refreshStatsAsync refreshes artist and album statistics in background goroutines func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) { // Refresh artist stats in background s.wg.Go(func() { bgCtx := request.AddValues(context.Background(), ctx) if _, err := s.ds.Artist().RefreshStats(bgCtx, true); err != nil { log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) } else { log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") } // Refresh album stats in background if we have affected albums if len(affectedAlbumIDs) > 0 { if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil { log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err) } else { log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) } } }) } // Wait waits for all background goroutines to complete. // WARNING: This method is ONLY for testing. Never call this in production code. // Calling Wait() in production will block until ALL background operations complete // and may cause race conditions with new operations starting. func (s *maintenanceService) wait() { s.wg.Wait() }