mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-15 06:38:50 +01:00
feat(cli): add missing file list and remap subcommands (#5928)
* feat(cli): add missing file list and remap subcommands Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com> * fix: prevent remapping from dropping participants on target track * fix: after remapping, refresh stats synchronously * fix: only move album annotations if moving a track would empty the old album * fix(persistence): keep the new item's annotation when reassigning onto an item the user already annotated ReassignAnnotation was a plain UPDATE; the annotation table is unique on (user_id, item_id, item_type), so when a user had annotated both items the statement aborted and none of the rows moved. In the scanner that surfaced as a warning; in the missing-file remap it rolled back the whole operation. UPDATE OR IGNORE moves what it can and leaves the conflicting rows for GC. * fix(core): keep the target track's history when remapping a missing file onto it The remap discards the target's row, and GC then dropped its play counts, stars, ratings, bookmarks and every playlist entry pointing at it. That is harmless in the scanner, whose target was imported seconds earlier, but the CLI lets the user pick any existing track. Move those references onto the surviving id first; where a user already has a row for both, theirs on the missing file wins. * fix(persistence): stop FindByPaths dropping plain paths that contain a colon Any colon was taken as the libraryID separator, and a non-numeric prefix made the whole path vanish from the lookup. 'missing fix' then rejected the very paths 'missing list' printed, and M3U imports silently skipped such tracks. Only a numeric prefix qualifies a path now. * perf(cli): stream 'missing list' instead of loading every missing file into memory GetAll materialised the whole result set before a single row was written; on a library with 97k missing files that peaked at 1.28 GB of RSS. Iterate the repository cursor and write rows as they arrive. * refactor(core): tidy the missing-file remap Drop the log lines copied from deleteMissing that still said 'after deleting missing files', the debug-on-success branches, and the what-comments; build the affected album list without slice helpers. * fix(cli): move path to the last column of 'missing list' Path is the only variable-width field, so leading with it misaligns every row that follows. Applies to both csv and json. * fix(persistence): also try a numeric colon prefix as a plain path '1999: A Different Life/01.mp3' parsed as library 1999 plus a truncated path and matched nothing. The prefix is ambiguous, so search both ways. Also buffer the json branch of 'missing list', which wrote a syscall per row. * fix(persistence): move scrobbles and buffered scrobbles off a discarded media file Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix' deleting the target erased its play history and dropped scrobbles still waiting on an external service. scrobble_buffer needs OR IGNORE for its unique (user_id, service, media_file_id, play_time). * fix(persistence): recompute the cached average rating after merging annotations Merging the discarded row's annotations grows the rating population of the surviving track, so media_file.average_rating no longer matched what the annotation rows say. Only reachable since the remap started merging those rows instead of deleting them. * fix(persistence): recompute the cached average rating inside ReassignAnnotation Moving annotation rows always changes the new item's rating population, so the recompute belongs with the move rather than at each call site. Covers the album reassign in the remap and the two scanner sites, and replaces the explicit call ReassignReferences was making. Album was the worse case: rate an album, move its files, and 'missing fix' handed the rating to an album still caching an average of 0. * fix(cli): let libraryID:path win over a file literally named like one FindByPaths searches a numeric-prefixed reference both ways, so a top-level file named '1:foo.mp3' can tie with library 1's 'foo.mp3'. The CLI then rejected the reference as ambiguous while advising the exact syntax the caller had used. Also disambiguates the same path in two libraries, which is what the qualified form is for. --------- Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
parent
2802c05a66
commit
c6732e1fdf
12 changed files with 756 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue