diff --git a/cmd/missing.go b/cmd/missing.go new file mode 100644 index 000000000..65f571661 --- /dev/null +++ b/cmd/missing.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "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"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + Path string `json:"path"` +} + +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).GetCursor(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + Sort: "path", + }) + if err == nil { + err = writeMissingList(os.Stdout, missingListFormat, mfs) + } + if err != nil { + log.Fatal(ctx, "Failed to retrieve missing files", err) + } +} + +// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory +func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error { + if format == "json" { + bw := bufio.NewWriter(w) + _, _ = io.WriteString(bw, "[") + sep := "" + for mf, err := range mfs { + if err != nil { + return err + } + j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path}) + _, _ = fmt.Fprintf(bw, "%s%s", sep, j) + sep = "," + } + _, _ = io.WriteString(bw, "]\n") + return bw.Flush() + } + + cw := csv.NewWriter(w) + _ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"}) + for mf, err := range mfs { + if err != nil { + return err + } + _ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path}) + } + cw.Flush() + return cw.Error() +} + +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). +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) + } + mfs = preferQualified(ref, mfs) + 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] +} + +// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path" +// reference both ways: an explicit library wins over a file literally named like one. +func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles { + id, path, ok := strings.Cut(ref, ":") + if !ok { + return mfs + } + libraryID, err := strconv.Atoi(id) + if err != nil { + return mfs + } + qualified := slice.Filter(mfs, func(mf model.MediaFile) bool { + return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path) + }) + if len(qualified) == 0 { + return mfs + } + return qualified +} diff --git a/cmd/missing_test.go b/cmd/missing_test.go new file mode 100644 index 000000000..2e96cf3c7 --- /dev/null +++ b/cmd/missing_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "errors" + "strings" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writeMissingList", func() { + cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor { + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range mfs { + if !yield(mf, nil) { + return + } + } + if err != nil { + yield(model.MediaFile{}, err) + } + } + } + song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"} + + It("writes csv with a header, quoting as needed", func() { + var out strings.Builder + Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed()) + Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n")) + }) + + It("writes a json array", func() { + var out strings.Builder + Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed()) + Expect(out.String()).To(MatchJSON(`[ + {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}, + {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"} + ]`)) + }) + + It("writes an empty json array when nothing is missing", func() { + var out strings.Builder + Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed()) + Expect(out.String()).To(MatchJSON(`[]`)) + }) + + It("returns the cursor's error", func() { + var out strings.Builder + Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom")) + }) +}) + +var _ = Describe("preferQualified", func() { + target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"} + decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"} + + It("picks the library-qualified match over a literal path that looks like one", func() { + Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target})) + }) + + It("picks the named library when the same path exists in two", func() { + other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"} + Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target})) + }) + + It("leaves an unqualified reference ambiguous", func() { + both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}} + Expect(preferQualified("foo.mp3", both)).To(Equal(both)) + }) + + It("leaves it alone when the prefix is not a library id", func() { + both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} + Expect(preferQualified("x:foo.mp3", both)).To(Equal(both)) + }) + + It("leaves it alone when no candidate matches the qualified form", func() { + both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} + Expect(preferQualified("9:nope.mp3", both)).To(Equal(both)) + }) +}) diff --git a/core/maintenance.go b/core/maintenance.go index 13d1141d3..56c0ac18d 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "fmt" "slices" "sync" @@ -14,11 +15,23 @@ 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 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 { @@ -40,6 +53,87 @@ 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 + + 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) + } + // Unlike the scanner's freshly-imported target, this one may carry history of its own + if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil { + return fmt.Errorf("reassign target references: %w", err) + } + if err := tx.MediaFile(ctx).Delete(discardedID); err != nil { + return fmt.Errorf("delete discarded track: %w", err) + } + + if oldAlbumID != newAlbumID { + 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) + } + 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 + } + + 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(ctx).RefreshStats(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 @@ -68,7 +162,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..56e6d13b5 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,167 @@ 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("moves the target's annotations, bookmarks and playlist entries onto the surviving id", func() { + 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(mfRepo.ReassignReferencesCalls).To(HaveKeyWithValue("t1", "m1")) + }) + + 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/model/mediafile.go b/model/mediafile.go index 2669018f3..7cbdb583c 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -563,6 +563,9 @@ type MediaFileRepository interface { DeleteMissing(ids []string) error DeleteAllMissing() (int64, error) FindByPaths(paths []string) (MediaFiles, error) + // ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID, + // keeping newID's own row wherever a user has both. + ReassignReferences(prevID, newID string) error // The following methods are used exclusively by the scanner: MarkMissing(bool, ...*MediaFile) error diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index da167b1a8..935a64bb9 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -362,19 +362,14 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err var unqualified []string for _, path := range paths { - parts := strings.SplitN(path, ":", 2) - if len(parts) == 2 { - // Library-qualified path: "libraryID:path" - libraryID, err := strconv.Atoi(parts[0]) - if err != nil { - // Invalid format, skip - continue + // A numeric prefix is ambiguous: "1:foo.mp3" qualifies a library, but "1999: A Life/01.mp3" + // is a plain path. Search both ways rather than guessing. + if id, rest, ok := strings.Cut(path, ":"); ok { + if libraryID, err := strconv.Atoi(id); err == nil { + byLibrary[libraryID] = append(byLibrary[libraryID], rest) } - byLibrary[libraryID] = append(byLibrary[libraryID], parts[1]) - } else { - // Unqualified path: search across all libraries - unqualified = append(unqualified, path) } + unqualified = append(unqualified, path) } query := Or{} @@ -405,6 +400,29 @@ func (r *mediaFileRepository) Delete(id string) error { return r.delete(Eq{"id": id}) } +func (r *mediaFileRepository) ReassignReferences(prevID, newID string) error { + if err := r.ReassignAnnotation(prevID, newID); err != nil { + return fmt.Errorf("reassigning annotations: %w", err) + } + if err := r.reassignBookmark(prevID, newID); err != nil { + return fmt.Errorf("reassigning bookmarks: %w", err) + } + upd := Update("playlist_tracks").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) + if _, err := r.executeSQL(upd); err != nil { + return fmt.Errorf("reassigning playlist tracks: %w", err) + } + upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) + if _, err := r.executeSQL(upd); err != nil { + return fmt.Errorf("reassigning scrobbles: %w", err) + } + // OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time) + buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID) + if _, err := r.executeSQL(buf); err != nil { + return fmt.Errorf("reassigning buffered scrobbles: %w", err) + } + return nil +} + func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { user := loggedUser(r.ctx) if !user.IsAdmin { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 8a492a813..1fb939415 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -918,6 +919,105 @@ var _ = Describe("MediaRepository", func() { }) }) + Describe("ReassignReferences", func() { + var prev, next model.MediaFile + var pr model.PlaylistRepository + var pls model.Playlist + + BeforeEach(func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + pr = NewPlaylistRepository(ctx, GetDBXBuilder()) + prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} + next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} + Expect(mr.Put(&prev)).To(Succeed()) + Expect(mr.Put(&next)).To(Succeed()) + pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} + pls.AddMediaFilesByID([]string{prev.ID}) + Expect(pr.Put(&pls)).To(Succeed()) + }) + + AfterEach(func() { + _ = pr.Delete(pls.ID) + _ = mr.Delete(prev.ID) + _ = mr.Delete(next.ID) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + }) + + It("moves annotations, bookmarks and playlist entries onto the new id", func() { + Expect(mr.SetRating(5, prev.ID)).To(Succeed()) + Expect(mr.AddBookmark(prev.ID, "here", 42)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(5)) + + bookmarks, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) + + withTracks, err := pr.GetWithTracks(pls.ID, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(withTracks.Tracks).To(HaveLen(1)) + Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) + }) + + It("moves scrobbles and buffered scrobbles onto the new id", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + scrobbles := NewScrobbleRepository(ctx, GetDBXBuilder()) + buffer := NewScrobbleBufferRepository(ctx, GetDBXBuilder()) + Expect(scrobbles.RecordScrobble(prev.ID, time.Now())).To(Succeed()) + Expect(buffer.Enqueue("lastfm", "userid", prev.ID, time.Now())).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + Expect(mr.Delete(prev.ID)).To(Succeed()) + + all, err := scrobbles.GetAll() + Expect(err).ToNot(HaveOccurred()) + mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { + return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID + }), func(sc model.Scrobble) string { return sc.MediaFileID }) + Expect(mine).To(ConsistOf(next.ID)) + + entry, err := buffer.Next("lastfm", "userid") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil()) + Expect(entry.MediaFile.ID).To(Equal(next.ID)) + }) + + It("recomputes the average rating after merging another user's annotation", func() { + other := NewMediaFileRepository(request.WithUser(log.NewContext(context.TODO()), model.User{ID: "2222"}), GetDBXBuilder()) + Expect(mr.SetRating(5, next.ID)).To(Succeed()) + Expect(other.SetRating(3, prev.ID)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AverageRating).To(Equal(4.0)) + }) + + It("keeps the new id's own annotation and bookmark when both exist", func() { + Expect(mr.SetRating(5, prev.ID)).To(Succeed()) + Expect(mr.SetRating(1, next.ID)).To(Succeed()) + Expect(mr.AddBookmark(prev.ID, "prev", 42)).To(Succeed()) + Expect(mr.AddBookmark(next.ID, "next", 7)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(1)) + bookmarks, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) + }) + }) + Describe("FindByPaths", func() { // Test fixtures for Unicode and case-sensitivity tests var testFiles []model.MediaFile @@ -930,6 +1030,8 @@ var _ = Describe("MediaRepository", func() { {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"}, // French diacritic: è (U+00E8, can decompose to e + combining grave) {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"}, + {ID: "findpath-5", LibraryID: 1, Path: "Bach: Goldberg Variations/01.mp3", Title: "Colon"}, + {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, } for _, mf := range testFiles { Expect(mr.Put(&mf)).To(Succeed()) @@ -942,6 +1044,27 @@ var _ = Describe("MediaRepository", func() { } }) + It("treats a path whose prefix is not a library id as unqualified", func() { + results, err := mr.FindByPaths([]string{"Bach: Goldberg Variations/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-5")) + }) + + It("finds a plain path whose colon prefix looks like a library id", func() { + results, err := mr.FindByPaths([]string{"1999: A Different Life/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-6")) + }) + + It("splits only the first colon of a library-qualified path", func() { + results, err := mr.FindByPaths([]string{"1:Bach: Goldberg Variations/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-5")) + }) + It("finds files by exact path", func() { results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 46ad6a0de..27445b886 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -185,12 +185,14 @@ func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error { if prevID == newID || prevID == "" || newID == "" { return nil } - upd := Update(annotationTable).Where(And{ - Eq{annotationTable + ".item_type": r.tableName}, - Eq{annotationTable + ".item_id": prevID}, - }).Set("item_id", newID) - _, err := r.executeSQL(upd) - return err + // OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement + upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?", + newID, r.tableName, prevID) + if _, err := r.executeSQL(upd); err != nil { + return err + } + // The moved rows change newID's rating population, so its cached average no longer matches + return r.updateAvgRating(newID) } func (r sqlRepository) cleanAnnotations() error { diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 5766f687f..79ad3c152 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -30,6 +30,53 @@ var _ = Describe("Annotation Filters", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) + Describe("ReassignAnnotation", func() { + var prev, next model.Album + + BeforeEach(func() { + prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1} + next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1} + Expect(albumRepo.Put(&prev)).To(Succeed()) + Expect(albumRepo.Put(&next)).To(Succeed()) + }) + + AfterEach(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) + }) + + It("moves the annotation when the new item has none", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(4)) + }) + + It("recomputes the new item's cached average rating", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AverageRating).To(Equal(4.0)) + }) + + It("keeps the new item's annotation when both exist", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + Expect(albumRepo.SetRating(2, next.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(2)) + }) + }) + Describe("annotationBoolFilter", func() { DescribeTable("creates correct SQL expressions", func(field, value string, expectedSQL string, expectedArgs []any) { diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 19f16b231..a9f53430d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -144,6 +144,13 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { return resp, nil } +func (r sqlRepository) reassignBookmark(prevID, newID string) error { + upd := Expr("update or ignore "+bookmarkTable+" set item_id = ? where item_type = ? and item_id = ?", + newID, r.tableName, prevID) + _, err := r.executeSQL(upd) + return err +} + func (r sqlRepository) cleanBookmarks() error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") c, err := r.executeSQL(del) 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 7a1a8f926..74225cd9b 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -24,11 +24,14 @@ 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 + // ReassignReferencesCalls records prevID -> newID + ReassignReferencesCalls map[string]string + Options model.QueryOptions // Add fields for cross-library move detection tests FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) @@ -40,6 +43,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 { @@ -163,6 +170,17 @@ func (m *MockMediaFileRepo) Delete(id string) error { return nil } +func (m *MockMediaFileRepo) ReassignReferences(prevID, newID string) error { + if m.Err { + return errors.New("error") + } + if m.ReassignReferencesCalls == nil { + m.ReassignReferencesCalls = make(map[string]string) + } + m.ReassignReferencesCalls[prevID] = newID + return nil +} + func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("error") @@ -251,11 +269,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 }