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{}