diff --git a/cmd/missing.go b/cmd/missing.go new file mode 100644 index 000000000..1994011fd --- /dev/null +++ b/cmd/missing.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/spf13/cobra" +) + +var missingListFormat string + +func init() { + missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]") + missingCmd.AddCommand(missingListCmd) + missingCmd.AddCommand(missingFixCmd) + rootCmd.AddCommand(missingCmd) +} + +var ( + missingCmd = &cobra.Command{ + Use: "missing", + Short: "Manage missing files", + Long: "List files marked as missing and remap them onto existing files", + } + + missingListCmd = &cobra.Command{ + Use: "list", + Short: "List missing files", + Run: func(cmd *cobra.Command, _ []string) { + runMissingList(cmd.Context()) + }, + } + + missingFixCmd = &cobra.Command{ + Use: "fix ", + Short: "Remap a missing file onto an existing file", + Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" + + "the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" + + "a library-relative path, or a libraryID:path pair.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + runMissingFix(cmd.Context(), args[0], args[1]) + }, + } +) + +type displayMissingFile struct { + ID string `json:"id"` + LibraryID int `json:"libraryId"` + Path string `json:"path"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` +} + +func runMissingList(ctx context.Context) { + if missingListFormat != "csv" && missingListFormat != "json" { + log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat) + } + + ds, ctx := getAdminContext(ctx) + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + Sort: "path", + }) + if err != nil { + log.Fatal(ctx, "Failed to retrieve missing files", err) + } + + if missingListFormat == "json" { + display := make([]displayMissingFile, len(mfs)) + for i, mf := range mfs { + display[i] = displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Path: mf.Path, Title: mf.Title, Album: mf.Album, Artist: mf.Artist} + } + j, _ := json.Marshal(display) + fmt.Printf("%s\n", j) + } else { + w := csv.NewWriter(os.Stdout) + _ = w.Write([]string{"id", "library id", "path", "title", "album", "artist"}) + for _, mf := range mfs { + _ = w.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Path, mf.Title, mf.Album, mf.Artist}) + } + w.Flush() + } +} + +func runMissingFix(ctx context.Context, missingRef, targetRef string) { + ds, ctx := getAdminContext(ctx) + + missing := resolveMediaFile(ctx, ds, missingRef) + target := resolveMediaFile(ctx, ds, targetRef) + + if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil { + log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err) + } + fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path) +} + +// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path), +// following the same "try one, then the other" pattern used by findPlaylist. +func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile { + mf, err := ds.MediaFile(ctx).Get(ref) + if err == nil { + return mf + } + if !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Error looking up media file", "ref", ref, err) + } + + mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref}) + if err != nil { + log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err) + } + if len(mfs) == 0 { + log.Fatal(ctx, "No media file found", "ref", ref) + } + if len(mfs) > 1 { + log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs)) + } + return &mfs[0] +} diff --git a/core/maintenance.go b/core/maintenance.go index 13d1141d3..5994fb83a 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "fmt" "slices" "sync" @@ -14,11 +15,24 @@ import ( "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 relocates a missing file's identity onto an existing, non-missing + // media file. It is the manual counterpart to the scanner's automatic move detection + // (see scanner.phaseMissingTracks.moveMatched). + RemapMissingFile(ctx context.Context, missingID, targetID string) error } type maintenanceService struct { @@ -40,6 +54,94 @@ 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(ctx).Get(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(ctx).GetWithParticipants(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 + + // Mirrors scanner.phaseMissingTracks.moveMatched: move the missing track's identity + // (ID, annotations and references) onto the file found at the new location. + 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(ctx).Put(target); err != nil { + return fmt.Errorf("update matched track: %w", err) + } + // Discard the target's original row + if err := tx.MediaFile(ctx).Delete(discardedID); err != nil { + return fmt.Errorf("delete discarded track: %w", err) + } + + if oldAlbumID != newAlbumID { + // Reassign album annotations (starred, rating) if the old album is now empty + oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(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(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + return fmt.Errorf("reassign album annotations: %w", err) + } + // Copy across the create_at timestamp from the old album + if err := tx.Album(ctx).CopyAttributes(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 + } + + // Clean up now-orphaned records and refresh affected statistics, mirroring deleteMissing + if err := s.ds.GC(ctx); err != nil { + log.Error(ctx, "Error running GC after remapping missing file", err) + return err + } + + // Refresh artist stats + if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil { + log.Error(ctx, "Error refreshing artist stats after deleting missing files", err) + } else { + log.Debug(ctx, "Successfully refreshed artist stats after deleting missing files") + } + + // Refresh album stats if we have affected albums + affectedAlbumIDs := slice.Unique(slice.Filter([]string{oldAlbumID, newAlbumID}, func(id string) bool { return id != "" })) + if len(affectedAlbumIDs) > 0 { + if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil { + log.Error(ctx, "Error refreshing album stats after deleting missing files", err) + } else { + log.Debug(ctx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) + } + } + + 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 @@ -68,7 +170,8 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er return err } - // Refresh statistics in background + // 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 diff --git a/core/maintenance_test.go b/core/maintenance_test.go index 09b442438..9515e1963 100644 --- a/core/maintenance_test.go +++ b/core/maintenance_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "sync" + "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -250,6 +251,156 @@ var _ = Describe("Maintenance", func() { }) }) }) + + Describe("RemapMissingFile", func() { + It("relocates the missing file's identity onto the target and runs GC", func() { + created := time.Now().Add(-30 * 24 * time.Hour) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Path: "old/song.mp3", AlbumID: "album1", CreatedAt: created, Missing: true}, + {ID: "t1", Path: "new/song.mp3", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + got, err := mfRepo.Get("m1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location + Expect(got.Missing).To(BeFalse()) + Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved + exists, _ := mfRepo.Exists("t1") + Expect(exists).To(BeFalse()) // discarded row removed + Expect(ds.GCCalled).To(BeTrue()) + }) + + It("reassigns album annotations when the old album is emptied", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album2", Missing: false}, + }) + + mfRepo.SetCountAll(0) + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("album1", "album2")) + }) + + It("does not reassign album annotations when the old album is not emptied", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "m2", AlbumID: "album1", Missing: false}, + {ID: "t1", AlbumID: "album2", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + + It("does not reassign annotations when the album is unchanged", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + + It("returns ErrNotFound when the missing file does not exist", func() { + mfRepo.SetData(model.MediaFiles{{ID: "t1", Missing: false}}) + + Expect(service.RemapMissingFile(ctx, "nope", "t1")).To(MatchError(model.ErrNotFound)) + }) + + It("refuses to remap from a file that is not missing", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Missing: false}, + {ID: "t1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrNotMissing)) + }) + + It("refuses to remap a file onto itself", func() { + mfRepo.SetData(model.MediaFiles{{ID: "m1", Missing: true}}) + + Expect(service.RemapMissingFile(ctx, "m1", "m1")).To(MatchError(ErrSameFile)) + }) + + It("refuses to remap onto a target that is itself missing", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Missing: true}, + {ID: "t1", Missing: true}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrTargetMissing)) + }) + + It("refreshes artist and album stats right after the remap", func() { + artistRepo := ds.MockedArtist.(*extendedArtistRepo) + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Old Album", SongCount: 2, Size: 1100, Duration: 110}, + {ID: "album2", Name: "New Album", SongCount: 1, Size: 2000, Duration: 200}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Path: "old/1.mp3", Album: "Old Album", AlbumID: "album1", Missing: true, Size: 100, Duration: 10}, + {ID: "k1", Path: "old/2.mp3", Album: "Old Album", AlbumID: "album1", Missing: false, Size: 1000, Duration: 100}, + {ID: "t1", Path: "new/1.mp3", Album: "New Album", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") + + // The old album lost the remapped track, so its stats are recalculated from the remaining one + oldAlbum, err := albumRepo.Get("album1") + Expect(err).ToNot(HaveOccurred()) + Expect(oldAlbum.SongCount).To(Equal(1)) + Expect(oldAlbum.Size).To(Equal(int64(1000))) + Expect(oldAlbum.Duration).To(BeNumerically("==", 100)) + + // The target album keeps the track, now under the missing file's ID + newAlbum, err := albumRepo.Get("album2") + Expect(err).ToNot(HaveOccurred()) + Expect(newAlbum.SongCount).To(Equal(1)) + Expect(newAlbum.Size).To(Equal(int64(2000))) + Expect(newAlbum.Duration).To(BeNumerically("==", 200)) + }) + + It("returns an error if GC fails", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + ds.GCError = errors.New("gc failed") + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ContainSubstring("gc failed"))) + }) + + It("preserves the target's participants on the remapped track", func() { + participant := model.Participant{ + Artist: model.Artist{ID: "a1", Name: "Artist", OrderArtistName: "artist", MbzArtistID: "mbz-artist"}, + } + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album2", Missing: false, Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{participant}, + }}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + // The surviving row is the missing file's ID, holding the target's data + got, err := mfRepo.GetWithParticipants("m1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant})) + }) + }) }) // Test helper to create a mock DataStore with controllable behavior diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index d54ceee40..f93b166c1 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -311,7 +311,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'always'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - mr.CountAllValue = 3 + mr.SetCountAll(3) mr.DeleteAllMissingValue = 3 }) It("should purge missing files", func() { @@ -325,7 +325,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'full'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull - mr.CountAllValue = 2 + mr.SetCountAll(2) mr.DeleteAllMissingValue = 2 }) It("should not purge missing files if not a full scan", func() { @@ -346,7 +346,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'never'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever - mr.CountAllValue = 1 + mr.SetCountAll(1) mr.DeleteAllMissingValue = 1 }) It("should not purge missing files", func() { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 58e84785b..d4b444655 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -24,8 +24,9 @@ type MockMediaFileRepo struct { model.MediaFileRepository Data map[string]*model.MediaFile Err bool - // Add fields and methods for controlling CountAll and DeleteAllMissing in tests - CountAllValue int64 + // Add fields and methods for controlling CountAll and DeleteAllMissing in tests. + // A nil CountAllValue is unset, and CountAll falls back to counting rows in Data. + CountAllValue *int64 CountAllOptions model.QueryOptions DeleteAllMissingValue int64 Options model.QueryOptions @@ -40,6 +41,10 @@ func (m *MockMediaFileRepo) SetError(err bool) { m.Err = err } +func (m *MockMediaFileRepo) SetCountAll(count int64) { + m.CountAllValue = &count +} + func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { m.Data = make(map[string]*model.MediaFile) for i, mf := range mfs { @@ -259,11 +264,11 @@ func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) if m.Err { return 0, errors.New("error") } - if m.CountAllValue != 0 { - if len(opts) > 0 { - m.CountAllOptions = opts[0] - } - return m.CountAllValue, nil + if len(opts) > 0 { + m.CountAllOptions = opts[0] + } + if m.CountAllValue != nil { + return *m.CountAllValue, nil } return int64(len(m.Data)), nil }