From f926539c044a87219e0c6be915ea4d2c2365f8b1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:44:24 -0400 Subject: [PATCH] feat(artwork): implement artwork repository --- persistence/artwork_repository.go | 49 +++++++++++++-- persistence/artwork_repository_test.go | 85 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 persistence/artwork_repository_test.go diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 4db47741b..a49f30597 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -4,6 +4,7 @@ import ( "context" "time" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -21,23 +22,61 @@ func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepo } func (r *artworkRepository) Get(hash string) (*model.Artwork, error) { - return nil, model.ErrNotFound + sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) + var res model.Artwork + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil } func (r *artworkRepository) Put(a *model.Artwork) error { - return nil + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now() + } + ins := Insert(r.tableName).SetMap(map[string]any{ + "hash": a.Hash, "mime": a.Mime, "width": a.Width, "height": a.Height, + "size_bytes": a.SizeBytes, "blur_hash": a.BlurHash, + "source_path": a.SourcePath, "ref_mtime": a.RefMtime, "created_at": a.CreatedAt, + }).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, + source_path=excluded.source_path, ref_mtime=excluded.ref_mtime`) + _, err := r.executeSQL(ins) + return err } func (r *artworkRepository) GetBatch(hashes []string) (map[string]model.Artwork, error) { - return nil, nil + res := map[string]model.Artwork{} + if len(hashes) == 0 { + return res, nil + } + sel := Select("*").From(r.tableName).Where(Eq{"hash": hashes}) + var all []model.Artwork + if err := r.queryAll(sel, &all); err != nil { + return nil, err + } + for _, a := range all { + res[a.Hash] = a + } + return res, nil } func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) { - return nil, nil + sel := Select("hash").From(r.tableName). + Where(And{ + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM item_artwork WHERE hash <> '')"), + }) + var hashes []string + err := r.queryAllSlice(sel, &hashes) + return hashes, err } func (r *artworkRepository) Delete(hashes ...string) error { - return nil + if len(hashes) == 0 { + return nil + } + return r.delete(Eq{"hash": hashes}) } var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go new file mode 100644 index 000000000..e158754a0 --- /dev/null +++ b/persistence/artwork_repository_test.go @@ -0,0 +1,85 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// clearArtworkTables resets the shared test DB's artwork tables so specs don't leak state. +func clearArtworkTables() { + db := GetDBXBuilder() + for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { + _, err := db.NewQuery("DELETE FROM " + t).Execute() + Expect(err).ToNot(HaveOccurred()) + } +} + +var _ = Describe("ArtworkRepository", func() { + var repo model.ArtworkRepository + + BeforeEach(func() { + clearArtworkTables() + repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) + }) + + It("stores and retrieves an artwork by hash", func() { + a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} + Expect(repo.Put(a)).To(Succeed()) + + got, err := repo.Get("abc123") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Mime).To(Equal("image/jpeg")) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.CreatedAt).ToNot(BeZero()) + }) + + It("is idempotent on Put (upsert by hash)", func() { + a := &model.Artwork{Hash: "dup1", Mime: "image/png"} + Expect(repo.Put(a)).To(Succeed()) + a.BlurHash = "XYZ" + Expect(repo.Put(a)).To(Succeed()) + got, _ := repo.Get("dup1") + Expect(got.BlurHash).To(Equal("XYZ")) + }) + + It("returns ErrNotFound for a missing hash", func() { + _, err := repo.Get("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("fetches a batch", func() { + Expect(repo.Put(&model.Artwork{Hash: "b1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.Put(&model.Artwork{Hash: "b2", Mime: "image/png"})).To(Succeed()) + got, err := repo.GetBatch([]string{"b1", "b2", "missing"}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(2)) + Expect(got["b2"].Mime).To(Equal("image/png")) + }) + + It("finds orphans older than cutoff, honoring item_artwork references", func() { + Expect(repo.Put(&model.Artwork{Hash: "orph1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.Put(&model.Artwork{Hash: "ref1", Mime: "image/jpeg"})).To(Succeed()) + iaRepo := NewItemArtworkRepository(context.Background(), GetDBXBuilder()) + Expect(iaRepo.Put(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", ImageType: model.ImageTypePrimary, Hash: "ref1", Source: "folder"})).To(Succeed()) + + orphans, err := repo.GetOrphanHashes(time.Now().Add(time.Minute)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(ContainElement("orph1")) + Expect(orphans).ToNot(ContainElement("ref1")) + + orphans, err = repo.GetOrphanHashes(time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(BeEmpty()) + }) + + It("deletes by hashes", func() { + Expect(repo.Put(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.Delete("d1")).To(Succeed()) + _, err := repo.Get("d1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +})