refactor(artwork): fold originals package into core/artwork as ImageStore

This commit is contained in:
Deluan 2026-07-21 23:09:31 -04:00
parent 3e7685adc2
commit b16ef725c9
5 changed files with 30 additions and 50 deletions

View File

@ -1,6 +1,4 @@
// Package originals is the content-addressed store for artwork images that have
// no library file backing them (external downloads, embedded extractions, generated).
package originals
package artwork
import (
"errors"
@ -14,7 +12,7 @@ import (
"github.com/zeebo/xxh3"
)
func Hash(r io.Reader) (string, error) {
func HashImage(r io.Reader) (string, error) {
d := xxh3.New()
if _, err := io.Copy(d, r); err != nil {
return "", err
@ -22,12 +20,14 @@ func Hash(r io.Reader) (string, error) {
return fmt.Sprintf("%016x", d.Sum64()), nil
}
type Store struct {
// ImageStore is the content-addressed store for artwork images that have no
// library file backing them (external downloads, embedded extractions, generated).
type ImageStore struct {
root string
}
func New(rootDir string) *Store {
return &Store{root: rootDir}
func NewImageStore(rootDir string) *ImageStore {
return &ImageStore{root: rootDir}
}
// extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into
@ -46,11 +46,11 @@ func extForMime(m string) string {
return ".img"
}
func (s *Store) path(hash, mimeType string) string {
func (s *ImageStore) path(hash, mimeType string) string {
return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType))
}
func (s *Store) Write(hash, mimeType string, r io.Reader) error {
func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
dst := s.path(hash, mimeType)
if _, err := os.Stat(dst); err == nil {
return nil
@ -73,11 +73,11 @@ func (s *Store) Write(hash, mimeType string, r io.Reader) error {
return os.Rename(tmp.Name(), dst)
}
func (s *Store) Open(hash, mimeType string) (io.ReadCloser, error) {
func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) {
return os.Open(s.path(hash, mimeType))
}
func (s *Store) Remove(hash, mimeType string) error {
func (s *ImageStore) Remove(hash, mimeType string) error {
err := os.Remove(s.path(hash, mimeType))
if errors.Is(err, fs.ErrNotExist) {
return nil
@ -85,7 +85,7 @@ func (s *Store) Remove(hash, mimeType string) error {
return err
}
func (s *Store) Sweep(keep func(hash string) bool) (int, error) {
func (s *ImageStore) Sweep(keep func(hash string) bool) (int, error) {
removed := 0
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {

View File

@ -1,4 +1,4 @@
package originals_test
package artwork
import (
"bytes"
@ -6,33 +6,32 @@ import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/core/artwork/originals"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Originals Store", func() {
var store *originals.Store
var _ = Describe("ImageStore", func() {
var store *ImageStore
var root string
BeforeEach(func() {
root = GinkgoT().TempDir()
store = originals.New(root)
store = NewImageStore(root)
})
It("hashes deterministically", func() {
h1, err := originals.Hash(bytes.NewReader([]byte("some image bytes")))
h1, err := HashImage(bytes.NewReader([]byte("some image bytes")))
Expect(err).ToNot(HaveOccurred())
h2, _ := originals.Hash(bytes.NewReader([]byte("some image bytes")))
h2, _ := HashImage(bytes.NewReader([]byte("some image bytes")))
Expect(h1).To(Equal(h2))
Expect(h1).To(HaveLen(16))
h3, _ := originals.Hash(bytes.NewReader([]byte("other bytes")))
h3, _ := HashImage(bytes.NewReader([]byte("other bytes")))
Expect(h3).ToNot(Equal(h1))
})
It("writes sharded and reads back", func() {
data := []byte("jpeg-bytes")
h, _ := originals.Hash(bytes.NewReader(data))
h, _ := HashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile())
@ -46,7 +45,7 @@ var _ = Describe("Originals Store", func() {
It("is idempotent on duplicate writes", func() {
data := []byte("dup")
h, _ := originals.Hash(bytes.NewReader(data))
h, _ := HashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
})
@ -62,10 +61,10 @@ var _ = Describe("Originals Store", func() {
It("sweeps unknown files, keeps known ones", func() {
d1 := []byte("keep-me")
h1, _ := originals.Hash(bytes.NewReader(d1))
h1, _ := HashImage(bytes.NewReader(d1))
Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed())
d2 := []byte("orphan")
h2, _ := originals.Hash(bytes.NewReader(d2))
h2, _ := HashImage(bytes.NewReader(d2))
Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed())
removed, err := store.Sweep(func(h string) bool { return h == h1 })

View File

@ -1,17 +0,0 @@
package originals_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestOriginals(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Originals Store Suite")
}

View File

@ -5,7 +5,6 @@ import (
"errors"
"time"
"github.com/navidrome/navidrome/core/artwork/originals"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
@ -13,7 +12,7 @@ import (
// 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 {
func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
repo := ds.Artwork(ctx)
orphans, err := repo.GetOrphanHashes(time.Now().Add(-pruneMinAge))
if err != nil {

View File

@ -7,7 +7,6 @@ import (
"os"
"time"
"github.com/navidrome/navidrome/core/artwork/originals"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -24,25 +23,25 @@ func (f *flakyGetArtworkRepo) Get(string) (*model.Artwork, error) {
var _ = Describe("Prune", func() {
var ds *tests.MockDataStore
var store *originals.Store
var store *ImageStore
var awRepo *tests.MockArtworkRepo
BeforeEach(func() {
ds = &tests.MockDataStore{}
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
store = originals.New(GinkgoT().TempDir())
store = NewImageStore(GinkgoT().TempDir())
})
It("deletes orphan rows and their store files, keeps referenced ones", func() {
data := []byte("orphan-bytes")
h, _ := originals.Hash(bytes.NewReader(data))
h, _ := HashImage(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))
hk, _ := HashImage(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())
@ -59,7 +58,7 @@ var _ = Describe("Prune", func() {
It("sweeps store files that have no artwork row", func() {
stray := []byte("no-row-bytes")
h, _ := originals.Hash(bytes.NewReader(stray))
h, _ := HashImage(bytes.NewReader(stray))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed())
Expect(Prune(context.Background(), ds, store)).To(Succeed())
@ -72,7 +71,7 @@ var _ = Describe("Prune", func() {
ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()}
data := []byte("live-bytes")
h, _ := originals.Hash(bytes.NewReader(data))
h, _ := HashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
Expect(Prune(context.Background(), ds, store)).To(Succeed())