From db16b3de9a6f6eb5f0fe8087fc41e9a45e4329c4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:58:20 -0400 Subject: [PATCH] feat(artwork): add artwork prune (orphan cleanup) --- core/artwork/prune.go | 48 ++++++++++++++++++++++++++++++ core/artwork/prune_test.go | 61 ++++++++++++++++++++++++++++++++++++++ tests/mock_artwork_repo.go | 7 +++-- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 core/artwork/prune.go create mode 100644 core/artwork/prune_test.go diff --git a/core/artwork/prune.go b/core/artwork/prune.go new file mode 100644 index 000000000..7d66bd8fe --- /dev/null +++ b/core/artwork/prune.go @@ -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 +} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go new file mode 100644 index 000000000..ae6901da4 --- /dev/null +++ b/core/artwork/prune_test.go @@ -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()) + }) +}) diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index c8b95a025..a30ffe97b 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -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 {