feat(artwork): add artwork prune (orphan cleanup)

This commit is contained in:
Deluan 2026-07-21 22:58:20 -04:00
commit db16b3de9a
3 changed files with 113 additions and 3 deletions

48
core/artwork/prune.go Normal file
View file

@ -0,0 +1,48 @@
package artwork
import (
"context"
"time"
"github.com/navidrome/navidrome/core/artwork/originals"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// pruneMinAge guards the window between artwork insert and item_artwork upsert.
const pruneMinAge = time.Hour
func Prune(ctx context.Context, ds model.DataStore, store *originals.Store) error {
repo := ds.Artwork(ctx)
orphans, err := repo.GetOrphanHashes(time.Now().Add(-pruneMinAge))
if err != nil {
return err
}
if len(orphans) > 0 {
arts, err := repo.GetBatch(orphans)
if err != nil {
return err
}
if err := repo.Delete(orphans...); err != nil {
return err
}
for _, a := range arts {
if err := store.Remove(a.Hash, a.Mime); err != nil {
log.Warn(ctx, "Prune: could not remove artwork file", "hash", a.Hash, err)
}
}
log.Info(ctx, "Prune: removed orphan artwork", "count", len(orphans))
}
removed, err := store.Sweep(func(hash string) bool {
_, err := repo.Get(hash)
return err == nil
})
if err != nil {
return err
}
if removed > 0 {
log.Info(ctx, "Prune: swept stray artwork files", "count", removed)
}
return nil
}

View file

@ -0,0 +1,61 @@
package artwork
import (
"bytes"
"context"
"os"
"time"
"github.com/navidrome/navidrome/core/artwork/originals"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Prune", func() {
var ds *tests.MockDataStore
var store *originals.Store
var awRepo *tests.MockArtworkRepo
BeforeEach(func() {
ds = &tests.MockDataStore{}
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
store = originals.New(GinkgoT().TempDir())
})
It("deletes orphan rows and their store files, keeps referenced ones", func() {
data := []byte("orphan-bytes")
h, _ := originals.Hash(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
Expect(awRepo.Put(&model.Artwork{Hash: h, Mime: "image/jpeg",
CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed())
awRepo.OrphanHashes = []string{h}
kept := []byte("kept-bytes")
hk, _ := originals.Hash(bytes.NewReader(kept))
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
Expect(awRepo.Put(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
Expect(Prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.Get(h)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = store.Open(h, "image/jpeg")
Expect(os.IsNotExist(err)).To(BeTrue())
rc, err := store.Open(hk, "image/jpeg")
Expect(err).ToNot(HaveOccurred())
rc.Close()
})
It("sweeps store files that have no artwork row", func() {
stray := []byte("no-row-bytes")
h, _ := originals.Hash(bytes.NewReader(stray))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed())
Expect(Prune(context.Background(), ds, store)).To(Succeed())
_, err := store.Open(h, "image/jpeg")
Expect(os.IsNotExist(err)).To(BeTrue())
})
})

View file

@ -8,8 +8,9 @@ import (
type MockArtworkRepo struct {
model.ArtworkRepository
Data map[string]model.Artwork
Err error
Data map[string]model.Artwork
OrphanHashes []string
Err error
}
func CreateMockArtworkRepo() *MockArtworkRepo {
@ -51,7 +52,7 @@ func (m *MockArtworkRepo) GetBatch(hashes []string) (map[string]model.Artwork, e
}
func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, error) {
return nil, m.Err
return m.OrphanHashes, m.Err
}
func (m *MockArtworkRepo) Delete(hashes ...string) error {