mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 15:08:04 -05:00
In `getAffectedAlbumIDs`, when one or more IDs is added, it adds a filter `"id": ids`. This filter is ambiguous though, because the `getAll` query joins with library table, which _also_ has an `id` field. Clarify this by adding the table name to the filter. Note that this was not caught in testing, as it only uses mock db.
227 lines
6.7 KiB
Go
227 lines
6.7 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
type maintenanceService struct {
|
|
ds model.DataStore
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func NewMaintenance(ds model.DataStore) Maintenance {
|
|
return &maintenanceService{
|
|
ds: ds,
|
|
}
|
|
}
|
|
|
|
func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
|
|
return s.deleteMissing(ctx, ids)
|
|
}
|
|
|
|
func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
|
|
return s.deleteMissing(ctx, 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
|
|
affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
|
|
if err != nil {
|
|
log.Warn(ctx, "Error tracking affected albums for refresh", err)
|
|
// Don't fail the operation, just log the warning
|
|
}
|
|
|
|
// Delete missing files within a transaction
|
|
err = s.ds.WithTx(func(tx model.DataStore) error {
|
|
if len(ids) == 0 {
|
|
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
|
return err
|
|
}
|
|
return tx.MediaFile(ctx).DeleteMissing(ids)
|
|
})
|
|
if err != nil {
|
|
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
|
return err
|
|
}
|
|
|
|
// Run garbage collection to clean up orphaned records
|
|
if err := s.ds.GC(ctx); err != nil {
|
|
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
|
return err
|
|
}
|
|
|
|
// Refresh statistics in background
|
|
s.refreshStatsAsync(ctx, affectedAlbumIDs)
|
|
|
|
return nil
|
|
}
|
|
|
|
// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
|
|
// It uses batch queries to minimize database round-trips for efficiency.
|
|
func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
|
|
if len(albumIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
|
|
|
|
// Process in chunks to avoid query size limits
|
|
const chunkSize = 100
|
|
for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
|
|
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
|
|
return fmt.Errorf("refreshing album chunk: %w", err)
|
|
}
|
|
}
|
|
|
|
log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
|
|
return nil
|
|
}
|
|
|
|
// refreshAlbumChunk processes a single chunk of album IDs
|
|
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
|
albumRepo := s.ds.Album(ctx)
|
|
mfRepo := s.ds.MediaFile(ctx)
|
|
|
|
// Batch load existing albums
|
|
albums, err := albumRepo.GetAll(model.QueryOptions{
|
|
Filters: squirrel.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
|
|
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
|
|
Filters: squirrel.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(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 := albumRepo.Put(&newAlbum); err != nil {
|
|
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
|
// Continue with other albums instead of failing entirely
|
|
continue
|
|
}
|
|
log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getAffectedAlbumIDs returns distinct album IDs from missing media files
|
|
func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, 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{"media_file.id": ids},
|
|
}
|
|
}
|
|
|
|
mfs, err := s.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
|
|
}
|
|
|
|
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
|
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
|
// Refresh artist stats in background
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
bgCtx := request.AddValues(context.Background(), ctx)
|
|
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
|
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
|
} else {
|
|
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
|
}
|
|
|
|
// Refresh album stats in background if we have affected albums
|
|
if len(affectedAlbumIDs) > 0 {
|
|
if err := s.refreshAlbums(bgCtx, 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))
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Wait waits for all background goroutines to complete.
|
|
// WARNING: This method is ONLY for testing. Never call this in production code.
|
|
// Calling Wait() in production will block until ALL background operations complete
|
|
// and may cause race conditions with new operations starting.
|
|
func (s *maintenanceService) wait() {
|
|
s.wg.Wait()
|
|
}
|