From 19365e5ad0a9887135045decc719d37ff436f07b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 8 Nov 2025 15:10:00 -0500 Subject: [PATCH] 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 --- model/album.go | 3 + persistence/album_repository.go | 88 ++++++++++++++++++++ persistence/album_repository_test.go | 117 +++++++++++++++++++++++++++ server/nativeapi/missing.go | 62 ++++++++++++++ 4 files changed, 270 insertions(+) diff --git a/model/album.go b/model/album.go index a8dcfe682..b391af591 100644 --- a/model/album.go +++ b/model/album.go @@ -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] } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6f9bb3b48..113e3cd0e 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -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) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index a062b4398..11d9a8d47 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -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 { diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 0d311f492..0c7b2688f 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -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{}