mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
319 lines
10 KiB
Go
319 lines
10 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
// ErrNotMissing is returned when a remap is attempted from a file not marked as missing.
|
|
ErrNotMissing = errors.New("file is not marked as missing")
|
|
// ErrTargetMissing is returned when the remap target is itself a missing file.
|
|
ErrTargetMissing = errors.New("target file is missing")
|
|
// ErrSameFile is returned when the remap source and target are the same file.
|
|
ErrSameFile = errors.New("missing and target are the same file")
|
|
)
|
|
|
|
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
|
|
// RemapMissingFile moves a missing file's identity onto an existing file, the manual
|
|
// counterpart to the scanner's move detection (phaseMissingTracks.moveMatched).
|
|
RemapMissingFile(ctx context.Context, missingID, targetID string) 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)
|
|
}
|
|
|
|
func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error {
|
|
if missingID == targetID {
|
|
return fmt.Errorf("%w: %q", ErrSameFile, missingID)
|
|
}
|
|
|
|
missing, err := s.ds.MediaFile().Get(ctx, missingID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading missing file %q: %w", missingID, err)
|
|
}
|
|
if !missing.Missing {
|
|
return fmt.Errorf("%w: %q", ErrNotMissing, missingID)
|
|
}
|
|
|
|
target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading target file %q: %w", targetID, err)
|
|
}
|
|
if target.Missing {
|
|
return fmt.Errorf("%w: %q", ErrTargetMissing, targetID)
|
|
}
|
|
|
|
oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID
|
|
|
|
err = s.ds.WithTx(func(tx model.DataStore) error {
|
|
discardedID := target.ID
|
|
|
|
// Preserve the original created_at so the remapped track doesn't resurface in "Recently Added"
|
|
target.CreatedAt = missing.CreatedAt
|
|
target.ID = missing.ID
|
|
if err := tx.MediaFile().Put(ctx, target); err != nil {
|
|
return fmt.Errorf("update matched track: %w", err)
|
|
}
|
|
// Unlike the scanner's freshly-imported target, this one may carry history of its own
|
|
if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil {
|
|
return fmt.Errorf("reassign target references: %w", err)
|
|
}
|
|
if err := tx.MediaFile().Delete(ctx, discardedID); err != nil {
|
|
return fmt.Errorf("delete discarded track: %w", err)
|
|
}
|
|
|
|
if oldAlbumID != newAlbumID {
|
|
oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}})
|
|
if err != nil {
|
|
return fmt.Errorf("get old album tracks: %w", err)
|
|
}
|
|
if oldAlbumTracks == 0 {
|
|
if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil {
|
|
return fmt.Errorf("reassign album annotations: %w", err)
|
|
}
|
|
if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return fmt.Errorf("copy album attributes: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err)
|
|
return err
|
|
}
|
|
|
|
if err := s.ds.GC(ctx); err != nil {
|
|
log.Error(ctx, "Error running GC after remapping missing file", err)
|
|
return err
|
|
}
|
|
|
|
// Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits.
|
|
// album/artist play count aggregates are not recalculated here; they are refreshed by the next scan.
|
|
if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil {
|
|
log.Error(ctx, "Error refreshing artist stats after remapping missing file", err)
|
|
}
|
|
affectedAlbumIDs := []string{newAlbumID}
|
|
if oldAlbumID != newAlbumID {
|
|
affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID)
|
|
}
|
|
if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil {
|
|
log.Error(ctx, "Error refreshing album stats after remapping missing file", err)
|
|
}
|
|
return 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().DeleteAllMissing(ctx)
|
|
return err
|
|
}
|
|
return tx.MediaFile().DeleteMissing(ctx, 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. album/artist play count aggregates are not recalculated
|
|
// here; they are refreshed by the next scan.
|
|
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()
|
|
mfRepo := s.ds.MediaFile()
|
|
|
|
// Batch load existing albums
|
|
albums, err := albumRepo.GetAll(ctx, 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(ctx, 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(ctx, &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().GetAll(ctx, 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.Go(func() {
|
|
bgCtx := request.AddValues(context.Background(), ctx)
|
|
if _, err := s.ds.Artist().RefreshStats(bgCtx, 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()
|
|
}
|