feat: add album refresh functionality after deleting missing files

Implemented RefreshAlbums method in AlbumRepository to recalculate album attributes (size, duration, song count) from their constituent media files. This method processes albums in batches to maintain efficiency with large datasets.

Added integration in deleteMissingFiles to automatically refresh affected albums in the background after deleting missing media files, ensuring album statistics remain accurate. Includes comprehensive test coverage for various scenarios including single/multiple albums, empty batches, and large batch processing.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-11-08 15:10:00 -05:00
parent 69527085db
commit 19365e5ad0
4 changed files with 270 additions and 0 deletions

View File

@ -139,6 +139,9 @@ type AlbumRepository interface {
RefreshPlayCounts() (int64, error)
CopyAttributes(fromID, toID string, columns ...string) error
// RefreshAlbums recalculates album attributes (size, duration, etc.) from media files
RefreshAlbums(albumIDs []string) error
AnnotatedRepository
SearchableRepository[Albums]
}

View File

@ -337,6 +337,94 @@ on conflict (user_id, item_id, item_type) do update
return r.executeSQL(query)
}
// RefreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
// It uses batch queries to minimize database round-trips for efficiency.
func (r *albumRepository) RefreshAlbums(albumIDs []string) error {
if len(albumIDs) == 0 {
return nil
}
log.Debug(r.ctx, "Refreshing albums", "count", len(albumIDs))
// Process in chunks to avoid query size limits
const chunkSize = 100
for i := 0; i < len(albumIDs); i += chunkSize {
end := i + chunkSize
if end > len(albumIDs) {
end = len(albumIDs)
}
chunk := albumIDs[i:end]
if err := r.refreshAlbumChunk(chunk); err != nil {
return fmt.Errorf("refreshing album chunk: %w", err)
}
}
log.Debug(r.ctx, "Successfully refreshed albums", "count", len(albumIDs))
return nil
}
// refreshAlbumChunk processes a single chunk of album IDs
func (r *albumRepository) refreshAlbumChunk(albumIDs []string) error {
// Batch load existing albums
albums, err := r.GetAll(model.QueryOptions{Filters: 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 using MediaFile repository
mfRepo := NewMediaFileRepository(r.ctx, r.db)
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
Filters: 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(r.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 := r.Put(&newAlbum); err != nil {
log.Error(r.ctx, "Error updating album during refresh", "albumID", albumID, err)
// Continue with other albums instead of failing entirely
continue
}
log.Trace(r.ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
}
}
return nil
}
func (r *albumRepository) purgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
c, err := r.executeSQL(del)

View File

@ -513,6 +513,123 @@ var _ = Describe("AlbumRepository", func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
})
})
Describe("RefreshAlbums", func() {
var mfRepo *mediaFileRepository
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), adminUser)
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()).(*mediaFileRepository)
})
It("recalculates size and duration after files are modified", func() {
// Get the initial album
album, err := albumRepo.Get("103") // Radioactivity album
Expect(err).ToNot(HaveOccurred())
initialSize := album.Size
initialDuration := album.Duration
// Modify the size and duration of one of the media files
mf, err := mfRepo.Get("1003") // Radioactivity song
Expect(err).ToNot(HaveOccurred())
mf.Size = 5000000 // 5MB
mf.Duration = 300.5 // 5 minutes
Expect(mfRepo.Put(mf)).To(Succeed())
// Refresh the album
err = albumRepo.RefreshAlbums([]string{"103"})
Expect(err).ToNot(HaveOccurred())
// Verify the album was refreshed with new values
refreshedAlbum, err := albumRepo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(refreshedAlbum.Size).ToNot(Equal(initialSize))
Expect(refreshedAlbum.Duration).ToNot(Equal(initialDuration))
})
It("handles multiple albums in a single call", func() {
// Modify files in two different albums
mf1, err := mfRepo.Get("1001") // Sgt Peppers song
Expect(err).ToNot(HaveOccurred())
mf1.Size = 3000000
Expect(mfRepo.Put(mf1)).To(Succeed())
mf2, err := mfRepo.Get("1002") // Abbey Road song
Expect(err).ToNot(HaveOccurred())
mf2.Size = 4000000
Expect(mfRepo.Put(mf2)).To(Succeed())
// Refresh both albums in one call
err = albumRepo.RefreshAlbums([]string{"101", "102"})
Expect(err).ToNot(HaveOccurred())
// Verify both were refreshed
album1, err := albumRepo.Get("101")
Expect(err).ToNot(HaveOccurred())
Expect(album1.Size).To(Equal(int64(3000000)))
album2, err := albumRepo.Get("102")
Expect(err).ToNot(HaveOccurred())
Expect(album2.Size).To(Equal(int64(4000000)))
})
It("handles empty album ID list gracefully", func() {
err := albumRepo.RefreshAlbums([]string{})
Expect(err).ToNot(HaveOccurred())
})
It("handles non-existent album IDs gracefully", func() {
err := albumRepo.RefreshAlbums([]string{"non-existent-id"})
Expect(err).ToNot(HaveOccurred())
})
It("recalculates song count correctly", func() {
album, err := albumRepo.Get("103") // Radioactivity album
Expect(err).ToNot(HaveOccurred())
initialSongCount := album.SongCount
// Add a new media file to this album
newSong := mf(model.MediaFile{
ID: "1099",
Title: "New Song",
ArtistID: "2",
Artist: "Kraftwerk",
AlbumID: "103",
Album: "Radioactivity",
Path: p("/kraft/radio/new-song.mp3"),
Size: 1000000,
Duration: 180,
})
Expect(mfRepo.Put(&newSong)).To(Succeed())
// Refresh the album
err = albumRepo.RefreshAlbums([]string{"103"})
Expect(err).ToNot(HaveOccurred())
// Verify song count increased
refreshedAlbum, err := albumRepo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(refreshedAlbum.SongCount).To(Equal(initialSongCount + 1))
// Clean up
_, _ = mfRepo.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": "1099"}))
})
It("processes large batches efficiently", func() {
// Test with all existing albums
allAlbums := []string{"101", "102", "103", "104"}
err := albumRepo.RefreshAlbums(allAlbums)
Expect(err).ToNot(HaveOccurred())
// Verify all albums still exist and have correct data
for _, albumID := range allAlbums {
album, err := albumRepo.Get(albumID)
Expect(err).ToNot(HaveOccurred())
Expect(album.ID).To(Equal(albumID))
}
})
})
})
func _p(id, name string, sortName ...string) model.Participant {

View File

@ -67,6 +67,22 @@ func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Reque
ctx := r.Context()
p := req.Params(r)
ids, _ := p.Strings("id")
// Track affected album IDs before deletion for refresh
var affectedAlbumIDs []string
var trackErr error
if len(ids) == 0 {
// Get all album IDs from missing files
affectedAlbumIDs, trackErr = getAlbumIDsFromMissing(ctx, ds, nil)
} else {
// Get album IDs from specific missing file IDs
affectedAlbumIDs, trackErr = getAlbumIDsFromMissing(ctx, ds, ids)
}
if trackErr != nil {
log.Warn(ctx, "Error tracking affected albums for refresh", trackErr)
// Don't fail the operation, just log the warning
}
err := ds.WithTx(func(tx model.DataStore) error {
if len(ids) == 0 {
_, err := tx.MediaFile(ctx).DeleteAllMissing()
@ -101,7 +117,53 @@ func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Reque
}
}()
// Refresh album stats in background after deleting missing files
if len(affectedAlbumIDs) > 0 {
go func() {
bgCtx := request.AddValues(context.Background(), r.Context())
if err := ds.Album(bgCtx).RefreshAlbums(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))
}
}()
}
writeDeleteManyResponse(w, r, ids)
}
// getAlbumIDsFromMissing returns distinct album IDs from missing media files
// Uses batch query for efficiency
func getAlbumIDsFromMissing(ctx context.Context, ds model.DataStore, 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{"id": ids},
}
}
mfs, err := ds.MediaFile(ctx).GetAll(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
}
var _ model.ResourceRepository = &missingRepository{}