Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
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()
|
|
}
|