refactor(artwork): thread model.Kind through the artwork API

Entity-level artwork queries now take a typed model.Kind instead of a bare
prefix string. GetItemArtwork, DeleteForItem(s), GetInfoForItems,
EnqueueStaleAbsent, hydrateItemImages, enqueueBackfillKind and artwork.Refresh
convert to the prefix string only at the two real boundaries: the SQL
item_kind column (kind.Prefix() inside each repo) and external string inputs
(a new model.ParseKind for the nativeapi URL param, which also validates it).

The Backfill/stale-absent kind slices, the resolve.go dispatch switch, and the
kind→resource / kind→table lookup maps now use the Kind vars directly. The
queue lifecycle methods (MarkFailed/Delete*) keep string kinds — they operate
on a dequeued item's raw ItemKind column, which stays a string field, always
populated via kind.Prefix().

Removes every bare "al"/"ar"/… prefix literal from non-test code (27 -> 0);
behavior is unchanged.
This commit is contained in:
Deluan 2026-07-24 16:58:23 -04:00
parent 53babc7a2a
commit 61cbd2f5f9
34 changed files with 164 additions and 149 deletions

View File

@ -42,13 +42,13 @@ var _ = Describe("Acquisition → serve loop", func() {
)
// itemFound reports whether the worker has persisted a resolved (hash-bearing) state row.
itemFound := func(kind, id string) func() bool {
itemFound := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
return err == nil && ia.Hash != ""
}
}
itemAbsent := func(kind, id string) func() bool {
itemAbsent := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
return err == nil && ia.Hash == ""
@ -115,9 +115,9 @@ var _ = Describe("Acquisition → serve loop", func() {
It("acquires album folder art and serves the exact bytes under its hash", func() {
seedFolderAlbum("al1")
worker.Bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound("al", "al1"))
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
@ -132,9 +132,9 @@ var _ = Describe("Acquisition → serve loop", func() {
name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}})
worker.Bump("ar", "ar1")
runWorkerUntil(ctx, worker, itemFound("ar", "ar1"))
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
ia, err := artRepo.GetItemArtwork("ar", "ar1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
@ -149,9 +149,9 @@ var _ = Describe("Acquisition → serve loop", func() {
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}}
worker.Bump("pl", "pl1")
runWorkerUntil(ctx, worker, itemFound("pl", "pl1"))
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
ia, err := artRepo.GetItemArtwork("pl", "pl1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("generated"))
@ -169,9 +169,9 @@ var _ = Describe("Acquisition → serve loop", func() {
name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture)
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
worker.Bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound("ra", "ra1"))
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork("ra", "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
@ -194,12 +194,12 @@ var _ = Describe("Acquisition → serve loop", func() {
provisionalBytes := readAll(provisional)
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
_, err = artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
// The provisional read enqueued a Bump; drain it and confirm the persisted hash matches.
runWorkerUntil(ctx, worker, itemFound("mf", "mf1"))
ia, err := artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary)
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).To(Equal(provisional.Hash))
@ -214,7 +214,7 @@ var _ = Describe("Acquisition → serve loop", func() {
It("records an absent state for an entity with no art and reports it unavailable", func() {
albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}})
worker.Bump("al", "alx")
runWorkerUntil(ctx, worker, itemAbsent("al", "alx"))
runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx"))
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))

View File

@ -22,7 +22,9 @@ const FingerprintPropertyKey = "artwork.fingerprint"
const staleAbsentAge = 24 * time.Hour
// staleAbsentKinds are the item kinds eligible for the periodic stale-absent recheck.
var staleAbsentKinds = []string{"ar", "al", "pl", "ra"}
var staleAbsentKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
// Fingerprint summarizes the config knobs that affect artwork resolution outcomes; a
// change means previously resolved (or absent) state may no longer be correct.
@ -50,13 +52,13 @@ func Backfill(ctx context.Context, ds model.DataStore) (bool, error) {
// Artists first: few entities, most external-dependent, so they get queue headstart.
kinds := []struct {
kind string
kind model.Kind
fetch func() ([]string, error)
}{
{"ar", func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
{"al", func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
{"pl", func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
{"ra", func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
{model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
{model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
}
for _, k := range kinds {
ids, err := k.fetch()
@ -75,14 +77,14 @@ func Backfill(ctx context.Context, ds model.DataStore) (bool, error) {
return true, nil
}
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind string, ids []string) error {
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
if len(ids) == 0 {
return nil
}
items := make([]model.ArtworkQueueItem, len(ids))
for i, id := range ids {
items[i] = model.ArtworkQueueItem{
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
}
}
return ds.ArtworkQueue(ctx).Enqueue(items...)

View File

@ -86,7 +86,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).ToNot(BeEmpty())
Expect(ia.Source).To(Equal("folder"))
@ -108,7 +108,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork("al", "al2", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
@ -131,7 +131,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
Expect(out).To(Equal(outcomeAbsent))
ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
Expect(ia.Source).To(BeEmpty())
@ -148,7 +148,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork("al", "al4", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -166,7 +166,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
Expect(out).To(Equal(outcomeFoundStale))
ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).ToNot(BeEmpty())
Expect(ia.Source).To(Equal("folder"))
@ -187,7 +187,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork("al", "alext", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("external:deezerFake"))
Expect(ia.Hash).ToNot(BeEmpty())
@ -212,7 +212,7 @@ var _ = Describe("processItem", func() {
out1 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
Expect(out1).To(Equal(outcomeFound))
ia1, err := artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary)
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
// Poison the stored blurhash: if the second item re-decodes instead of
@ -223,7 +223,7 @@ var _ = Describe("processItem", func() {
out2 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
Expect(out2).To(Equal(outcomeFound))
ia2, err := artRepo.GetItemArtwork("al", "al6", model.ImageTypePrimary)
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia2.Hash).To(Equal(ia1.Hash))
@ -253,7 +253,7 @@ var _ = Describe("processItem", func() {
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})).To(Equal(outcomeFound))
iaA, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary)
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(iaA.Source).To(Equal("folder"))
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
@ -266,14 +266,14 @@ var _ = Describe("processItem", func() {
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})).To(Equal(outcomeFound))
iaB, err := artRepo.GetItemArtwork("al", "alB", model.ImageTypePrimary)
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(iaB.Hash).To(Equal(iaA.Hash))
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
// The first item's provenance survives the second item processing identical bytes.
iaAafter, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary)
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
@ -299,7 +299,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork("ra", "ra1", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -320,7 +320,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
Expect(out).To(Equal(outcomeFailed))
_, err = artRepo.GetItemArtwork("ra", "big", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -345,7 +345,7 @@ var _ = Describe("processItem", func() {
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})

View File

@ -51,11 +51,11 @@ var _ = Describe("Prune", func() {
Expect(Prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.GetItemArtwork("al", "gone-album", model.ImageTypePrimary)
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = awRepo.GetItemArtwork("ar", "gone-artist", model.ImageTypePrimary)
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = awRepo.GetItemArtwork("ar", "live-artist", model.ImageTypePrimary)
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
})

View File

@ -9,11 +9,11 @@ import (
// Refresh clears an item's resolved artwork state and re-queues it at Bump priority, so a
// deliberate refresh (image upload or manual re-resolve) drops the current pick and re-resolves it.
func Refresh(ctx context.Context, ds model.DataStore, kind, id string) error {
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
if err := ds.Artwork(ctx).DeleteForItem(kind, id); err != nil {
return fmt.Errorf("clearing artwork state: %w", err)
}
item := model.ArtworkQueueItem{ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
return fmt.Errorf("enqueuing artwork refresh: %w", err)
}

View File

@ -50,16 +50,17 @@ func resolveItemMode(ctx context.Context, ds model.DataStore, ag *agents.Agents,
if gate == nil {
gate = passthroughGate
}
switch item.ItemKind {
case "al":
kind, _ := model.ParseKind(item.ItemKind)
switch kind {
case model.KindAlbumArtwork:
return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "ar":
case model.KindArtistArtwork:
return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "pl":
case model.KindPlaylistArtwork:
return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "ra":
case model.KindRadioArtwork:
return resolveRadio(ctx, ds, item.ItemID)
case "mf":
case model.KindMediaFileArtwork:
return resolveMediaFile(ctx, ds, ffmpeg, item.ItemID)
default:
return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind)

View File

@ -97,7 +97,7 @@ const requestRecheckAge = time.Hour
// found row serves its hash, absent row is unavailable (promoting a stale recheck on view),
// missing row reads through provisionally.
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind.Prefix(), artID.ID, model.ImageTypePrimary)
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
switch {
case errors.Is(err, model.ErrNotFound):
return s.provisional(ctx, artID, size, square)
@ -255,7 +255,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
}
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
ia, err := s.ds.Artwork(ctx).GetItemArtwork("mf", artID.ID, model.ImageTypePrimary)
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
switch {
case err == nil && ia.Hash != "":
return s.serveHash(ctx, artID, ia, size, square)

View File

@ -163,7 +163,7 @@ var _ = Describe("Service", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
})
@ -202,7 +202,7 @@ var _ = Describe("Service", func() {
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
// The absent state row is left intact; only a recheck is scheduled.
ia, err := artRepo.GetItemArtwork("al", "al4b", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
})
@ -218,7 +218,7 @@ var _ = Describe("Service", func() {
Expect(readAll(img)).To(Equal(coverBytes))
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -229,7 +229,7 @@ var _ = Describe("Service", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork("al", "al6", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
@ -290,7 +290,7 @@ var _ = Describe("Service", func() {
Expect(err).ToNot(HaveOccurred())
Expect(len(readAll(img))).To(BeNumerically(">", 0))
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork("mf", "mf4", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})

View File

@ -37,10 +37,10 @@ type Uploader interface {
// uploadEntityKind maps an upload's entity type to its artwork kind prefix, so a
// successful upload can clear and re-queue that item's artwork state.
var uploadEntityKind = map[string]string{
consts.EntityArtist: model.KindArtistArtwork.Prefix(),
consts.EntityPlaylist: model.KindPlaylistArtwork.Prefix(),
consts.EntityRadio: model.KindRadioArtwork.Prefix(),
var uploadEntityKind = map[string]model.Kind{
consts.EntityArtist: model.KindArtistArtwork,
consts.EntityPlaylist: model.KindPlaylistArtwork,
consts.EntityRadio: model.KindRadioArtwork,
}
type uploader struct {

View File

@ -87,7 +87,7 @@ var _ = Describe("Uploader", func() {
// SetImage only writes the file; the state row survives and nothing is queued until
// the caller has persisted the new filename and called EnqueueArtwork.
_, err = artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
})
@ -102,7 +102,7 @@ var _ = Describe("Uploader", func() {
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
_, err := artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, err := queueRepo.DequeueBatch(1000)

View File

@ -165,14 +165,14 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) {
return len(items), nil
}
// artworkKindToResource maps a queue item's kind to the UI resource name carried
// in the refresh event.
var artworkKindToResource = map[string]string{
"al": "album",
"ar": "artist",
"pl": "playlist",
"ra": "radio",
"mf": "song",
// artworkKindToResource maps an artwork kind to the UI resource name carried in the refresh
// event; note media_file maps to "song", so this can't derive from Kind.String().
var artworkKindToResource = map[model.Kind]string{
model.KindAlbumArtwork: "album",
model.KindArtistArtwork: "artist",
model.KindPlaylistArtwork: "playlist",
model.KindRadioArtwork: "radio",
model.KindMediaFileArtwork: "song",
}
// broadcastRefresh emits one coalesced RefreshResource for the batch's newly-acquired
@ -184,7 +184,8 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu
event := &events.RefreshResource{}
byResource := map[string][]string{}
for _, it := range found {
if res, ok := artworkKindToResource[it.ItemKind]; ok {
kind, _ := model.ParseKind(it.ItemKind)
if res, ok := artworkKindToResource[kind]; ok {
byResource[res] = append(byResource[res], it.ItemID)
}
}
@ -235,7 +236,8 @@ func (w *Worker) precache(ctx context.Context, item model.ArtworkQueueItem) {
imageType = model.ImageTypePrimary
}
repo := w.deps.ds.Artwork(ctx)
ia, err := repo.GetItemArtwork(item.ItemKind, item.ItemID, imageType)
kind, _ := model.ParseKind(item.ItemKind)
ia, err := repo.GetItemArtwork(kind, item.ItemID, imageType)
if err != nil || ia.Hash == "" {
return
}

View File

@ -105,7 +105,8 @@ var _ = Describe("Worker soak", func() {
// "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would
// use after acquisition, not the old serving pipeline.
if out == outcomeFound {
ia, err := artRepo.GetItemArtwork(it.ItemKind, it.ItemID, model.ImageTypePrimary)
kind, _ := model.ParseKind(it.ItemKind)
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)

View File

@ -172,7 +172,7 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
@ -195,7 +195,7 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
ia, err := artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).ToNot(BeEmpty())
@ -229,7 +229,7 @@ var _ = Describe("Worker", func() {
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
_, err = artRepo.GetItemArtwork("al", "al4", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
})
@ -254,7 +254,7 @@ var _ = Describe("Worker", func() {
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
@ -284,7 +284,7 @@ var _ = Describe("Worker", func() {
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
ia, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
@ -327,7 +327,7 @@ var _ = Describe("Worker", func() {
// Resolved as absent (no art) and removed — not stuck failing on ErrNotFound forever.
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
ia, err := artRepo.GetItemArtwork("pl", "plPriv", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
})
@ -381,7 +381,7 @@ var _ = Describe("Worker", func() {
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
})

View File

@ -428,7 +428,7 @@ var _ = Describe("Playlists", func() {
Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed())
_, err := ds.Artwork(ctx).GetItemArtwork("pl", "pls-1", model.ImageTypePrimary)
_, err := ds.Artwork(ctx).GetItemArtwork(model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100)
Expect(queued).To(ContainElement(SatisfyAll(

View File

@ -78,13 +78,13 @@ type ArtworkRepository interface {
// DeleteOrphans deletes the given hashes only if still unreferenced and older than cutoff (atomic re-check).
DeleteOrphans(createdBefore time.Time, hashes []string) error
// Per-item state (item_artwork table)
GetItemArtwork(kind, id, imageType string) (*ItemArtwork, error)
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
PutItemArtwork(ia *ItemArtwork) error
DeleteForItem(kind, id string) error
DeleteForItem(kind Kind, id string) error
// DeleteForItems removes state rows for the given ids of one kind, in chunks.
DeleteForItems(kind string, ids []string) error
DeleteForItems(kind Kind, ids []string) error
// GetInfoForItems hydrates a page: one batched query, item_artwork joined to artwork.
GetInfoForItems(kind string, ids []string) (map[string]ItemArtworkInfo, error)
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
// GetAllMimes returns hash -> current mime for every stored artwork, for sweep retention checks.
GetAllMimes() (map[string]string, error)
// PurgeDanglingItemArtwork removes state rows whose entity no longer exists.
@ -111,7 +111,7 @@ type ArtworkQueueRepository interface {
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff.
EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error)
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
}

View File

@ -40,6 +40,13 @@ var artworkKindMap = map[string]Kind{
KindRadioArtwork.prefix: KindRadioArtwork,
}
// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known.
// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain.
func ParseKind(prefix string) (Kind, bool) {
k, ok := artworkKindMap[prefix]
return k, ok
}
type ArtworkID struct {
Kind Kind
ID string

View File

@ -262,7 +262,7 @@ func (r *albumRepository) hydrateArtwork(albums model.Albums) {
return
}
ids := slice.Map(albums, func(a model.Album) string { return a.ID })
infos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), ids)
infos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork, ids)
for i := range albums {
applyItemImage(infos, albums[i].ID, &albums[i].ItemImage)
}

View File

@ -284,7 +284,7 @@ func (r *artistRepository) hydrateArtwork(artists model.Artists) {
return
}
ids := slice.Map(artists, func(a model.Artist) string { return a.ID })
infos := hydrateItemImages(r.ctx, r.db, model.KindArtistArtwork.Prefix(), ids)
infos := hydrateItemImages(r.ctx, r.db, model.KindArtistArtwork, ids)
for i := range artists {
applyItemImage(infos, artists[i].ID, &artists[i].ItemImage)
}

View File

@ -54,7 +54,7 @@ func chunkOptions(options []model.QueryOptions, idField string) func([]string) m
// hydrateItemImages returns per-item artwork info for a fetched page via one batched query per kind
// (never a join, see spec §6). On error it logs and returns an empty map so the page still renders.
func hydrateItemImages(ctx context.Context, db dbx.Builder, kind string, ids []string) map[string]model.ItemArtworkInfo {
func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo {
if len(ids) == 0 {
return map[string]model.ItemArtworkInfo{}
}

View File

@ -109,14 +109,14 @@ func (r *artworkQueueRepository) Count() (int64, error) {
return res.Count, err
}
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) {
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
now := time.Now()
// DO NOTHING is deliberate: rechecks must not bump priority/retry_at of already-queued items.
ins := Expr(`INSERT INTO `+r.tableName+` (item_kind, item_id, image_type, priority, attempts, retry_at, enqueued_at)
SELECT item_kind, item_id, image_type, ?, 0, ?, ?
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?
ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`,
model.ArtworkPriorityRecheck, now, now, kind, attemptedBefore)
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore)
return r.executeSQL(ins)
}

View File

@ -174,7 +174,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed())
n, err := repo.EnqueueStaleAbsent("ar", time.Now().Add(-24*time.Hour))
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))

View File

@ -114,13 +114,13 @@ func (r *artworkRepository) DeleteOrphans(createdBefore time.Time, hashes []stri
return nil
}
// danglingItemArtworkKinds maps item_kind prefixes to the table that owns the entity.
var danglingItemArtworkKinds = map[string]string{
"al": "album",
"ar": "artist",
"pl": "playlist",
"ra": "radio",
"mf": "media_file",
// danglingItemArtworkKinds maps an artwork kind to the table that owns the entity.
var danglingItemArtworkKinds = map[model.Kind]string{
model.KindAlbumArtwork: "album",
model.KindArtistArtwork: "artist",
model.KindPlaylistArtwork: "playlist",
model.KindRadioArtwork: "radio",
model.KindMediaFileArtwork: "media_file",
}
// purgeDangling deletes rows in table whose owning entity is gone, one statement per kind.
@ -128,7 +128,7 @@ func purgeDangling(execute func(Sqlizer) (int64, error), table string) (int64, e
var total int64
for kind, entityTable := range danglingItemArtworkKinds {
del := Delete(table).Where(And{
Eq{"item_kind": kind},
Eq{"item_kind": kind.Prefix()},
Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"),
})
c, err := execute(del)
@ -144,9 +144,9 @@ func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) {
return purgeDangling(r.items.executeSQL, itemArtworkTable)
}
func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) {
func (r *artworkRepository) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) {
sel := Select("*").From(itemArtworkTable).
Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType})
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType})
var res model.ItemArtwork
if err := r.items.queryOne(sel, &res); err != nil {
return nil, err
@ -174,27 +174,27 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error {
return err
}
func (r *artworkRepository) DeleteForItem(kind, id string) error {
return r.items.delete(Eq{"item_kind": kind, "item_id": id})
func (r *artworkRepository) DeleteForItem(kind model.Kind, id string) error {
return r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": id})
}
func (r *artworkRepository) DeleteForItems(kind string, ids []string) error {
func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error {
for chunk := range slices.Chunk(ids, artworkBatchSize) {
if err := r.items.delete(Eq{"item_kind": kind, "item_id": chunk}); err != nil {
if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil {
return err
}
}
return nil
}
func (r *artworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) {
func (r *artworkRepository) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) {
res := map[string]model.ItemArtworkInfo{}
for chunk := range slices.Chunk(ids, artworkBatchSize) {
sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash").
From(itemArtworkTable + " ia").
LeftJoin("artwork a ON a.hash = ia.hash").
Where(And{
Eq{"ia.item_kind": kind},
Eq{"ia.item_kind": kind.Prefix()},
Eq{"ia.image_type": model.ImageTypePrimary},
Eq{"ia.item_id": chunk},
})

View File

@ -161,7 +161,8 @@ var _ = Describe("ArtworkRepository", func() {
{ItemKind: "ra", ItemID: radioWithHomePage.ID},
{ItemKind: "mf", ItemID: songDayInALife.ID},
} {
_, err := repo.GetItemArtwork(kept.ItemKind, kept.ItemID, model.ImageTypePrimary)
k, _ := model.ParseKind(kept.ItemKind)
_, err := repo.GetItemArtwork(k, kept.ItemID, model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
}
for _, gone := range []model.ItemArtwork{
@ -171,7 +172,8 @@ var _ = Describe("ArtworkRepository", func() {
{ItemKind: "ra", ItemID: "no-such-radio"},
{ItemKind: "mf", ItemID: "no-such-mediafile"},
} {
_, err := repo.GetItemArtwork(gone.ItemKind, gone.ItemID, model.ImageTypePrimary)
k, _ := model.ParseKind(gone.ItemKind)
_, err := repo.GetItemArtwork(k, gone.ItemID, model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
}
})
@ -187,7 +189,7 @@ var _ = Describe("ArtworkRepository", func() {
ia.RefMtime = 222
Expect(repo.PutItemArtwork(ia)).To(Succeed())
got, err := repo.GetItemArtwork("al", "al1", model.ImageTypePrimary)
got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Source).To(Equal("embedded"))
Expect(got.SourcePath).To(Equal("/music/a/track.mp3"))
@ -199,7 +201,7 @@ var _ = Describe("ArtworkRepository", func() {
before := time.Now().Add(-time.Second)
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt",
ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed())
got, err := repo.GetItemArtwork("ar", "noattempt", model.ImageTypePrimary)
got, err := repo.GetItemArtwork(model.KindArtistArtwork, "noattempt", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.AttemptedAt).To(BeTemporally(">", before))
})
@ -207,7 +209,7 @@ var _ = Describe("ArtworkRepository", func() {
It("represents known-absent as empty hash", func() {
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1",
ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
got, err := repo.GetItemArtwork("ar", "ar1", model.ImageTypePrimary)
got, err := repo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Hash).To(BeEmpty())
})
@ -217,7 +219,7 @@ var _ = Describe("ArtworkRepository", func() {
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed())
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed())
info, err := repo.GetInfoForItems("al", []string{"x1", "x2", "x3"})
info, err := repo.GetInfoForItems(model.KindAlbumArtwork, []string{"x1", "x2", "x3"})
Expect(err).ToNot(HaveOccurred())
Expect(info).To(HaveLen(2))
Expect(info["x1"].Hash).To(Equal("h9"))
@ -230,8 +232,8 @@ var _ = Describe("ArtworkRepository", func() {
It("deletes all rows for an item", func() {
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed())
Expect(repo.DeleteForItem("pl", "p1")).To(Succeed())
_, err := repo.GetItemArtwork("pl", "p1", model.ImageTypePrimary)
Expect(repo.DeleteForItem(model.KindPlaylistArtwork, "p1")).To(Succeed())
_, err := repo.GetItemArtwork(model.KindPlaylistArtwork, "p1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -245,13 +247,13 @@ var _ = Describe("ArtworkRepository", func() {
}
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed())
Expect(repo.DeleteForItems("mf", ids)).To(Succeed())
Expect(repo.DeleteForItems(model.KindMediaFileArtwork, ids)).To(Succeed())
for _, id := range ids {
_, err := repo.GetItemArtwork("mf", id, model.ImageTypePrimary)
_, err := repo.GetItemArtwork(model.KindMediaFileArtwork, id, model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
}
kept, err := repo.GetItemArtwork("mf", "keep", model.ImageTypePrimary)
kept, err := repo.GetItemArtwork(model.KindMediaFileArtwork, "keep", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(kept.ItemID).To(Equal("keep"))
})

View File

@ -237,8 +237,8 @@ func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) {
eligibleIDs = append(eligibleIDs, mfs[i].ID)
}
}
albumInfos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), albumIDs)
mfInfos := hydrateItemImages(r.ctx, r.db, model.KindMediaFileArtwork.Prefix(), eligibleIDs)
albumInfos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork, albumIDs)
mfInfos := hydrateItemImages(r.ctx, r.db, model.KindMediaFileArtwork, eligibleIDs)
for i := range mfs {
mf := &mfs[i]
applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage)

View File

@ -184,7 +184,7 @@ func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) {
return
}
ids := slice.Map(playlists, func(p model.Playlist) string { return p.ID })
infos := hydrateItemImages(r.ctx, r.db, model.KindPlaylistArtwork.Prefix(), ids)
infos := hydrateItemImages(r.ctx, r.db, model.KindPlaylistArtwork, ids)
for i := range playlists {
applyItemImage(infos, playlists[i].ID, &playlists[i].ItemImage)
}
@ -330,7 +330,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
pls.Size = int64(res.Size)
// The generated 2x2 grid depends on the track set, so re-resolve the cover whenever it
// changes. No clear: the old cover keeps serving until the worker rebuilds (no flicker).
item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary,
item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan}
if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil {
log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", pls.ID, err)

View File

@ -75,7 +75,7 @@ func (r *radioRepository) hydrateArtwork(radios model.Radios) {
return
}
ids := slice.Map(radios, func(rd model.Radio) string { return rd.ID })
infos := hydrateItemImages(r.ctx, r.db, model.KindRadioArtwork.Prefix(), ids)
infos := hydrateItemImages(r.ctx, r.db, model.KindRadioArtwork, ids)
for i := range radios {
applyItemImage(infos, radios[i].ID, &radios[i].ItemImage)
}
@ -108,7 +108,7 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error
}
// Enqueue artwork resolution for the created/updated radio at Bump priority so a new
// radio's cover resolves proactively. Never fails the save.
item := model.ArtworkQueueItem{ItemKind: "ra", ItemID: radio.ID, ImageType: model.ImageTypePrimary,
item := model.ArtworkQueueItem{ItemKind: model.KindRadioArtwork.Prefix(), ItemID: radio.ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump}
if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil {
log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err)

View File

@ -369,7 +369,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
queueItems = append(queueItems, model.ArtworkQueueItem{
ItemKind: "ar", ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary,
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
})
}
@ -384,7 +384,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
}
if entry.albums[i].Name != consts.UnknownAlbum {
queueItems = append(queueItems, model.ArtworkQueueItem{
ItemKind: "al", ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary,
ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
})
}
@ -405,7 +405,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
for i := range entry.tracks {
trackIDs[i] = entry.tracks[i].ID
}
if err := tx.Artwork(p.ctx).DeleteForItems("mf", trackIDs); err != nil {
if err := tx.Artwork(p.ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil {
log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err)
}
}

View File

@ -145,7 +145,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model.
} else {
log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started))
}
item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary,
item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan}
if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil {
log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err)

View File

@ -226,7 +226,7 @@ var _ = Describe("Scanner", Ordered, func() {
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"})
Expect(runScanner(ctx, true)).To(Succeed())
_, err = ds.Artwork(ctx).GetItemArtwork("mf", trackID, model.ImageTypePrimary)
_, err = ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, trackID, model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})

View File

@ -10,12 +10,12 @@ import (
)
// refreshableArtworkKinds are the entity kinds a manual re-resolve accepts.
var refreshableArtworkKinds = map[string]bool{
model.KindAlbumArtwork.Prefix(): true,
model.KindArtistArtwork.Prefix(): true,
model.KindPlaylistArtwork.Prefix(): true,
model.KindRadioArtwork.Prefix(): true,
model.KindMediaFileArtwork.Prefix(): true,
var refreshableArtworkKinds = map[model.Kind]bool{
model.KindAlbumArtwork: true,
model.KindArtistArtwork: true,
model.KindPlaylistArtwork: true,
model.KindRadioArtwork: true,
model.KindMediaFileArtwork: true,
}
func (api *Router) addArtworkRoute(r chi.Router) {
@ -27,7 +27,7 @@ func (api *Router) addArtworkRoute(r chi.Router) {
func (api *Router) refreshArtwork() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
kind := chi.URLParam(r, "kind")
kind, _ := model.ParseKind(chi.URLParam(r, "kind"))
id := chi.URLParam(r, "id")
if !refreshableArtworkKinds[kind] {
http.Error(w, "invalid artwork kind", http.StatusBadRequest)

View File

@ -56,7 +56,7 @@ var _ = Describe("Artwork API", func() {
Expect(w.Code).To(Equal(http.StatusNoContent))
_, err := artRepo.GetItemArtwork("al", "al-1", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, err := queueRepo.DequeueBatch(1000)

View File

@ -151,14 +151,14 @@ var _ = Describe("Artwork Serving", Ordered, func() {
worker.Bump("al", artfulID)
worker.Bump("al", artlessID)
runWorkerUntil(ctx, worker, func() bool {
found, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary)
found, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary)
if err != nil || found.Hash == "" {
return false
}
absent, err := ds.Artwork(ctx).GetItemArtwork("al", artlessID, model.ImageTypePrimary)
absent, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artlessID, model.ImageTypePrimary)
return err == nil && absent.Hash == ""
})
ia, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary)
ia, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
artfulHash = ia.Hash

View File

@ -186,7 +186,7 @@ func (m *MockArtworkQueueRepo) EnqueueBump(items ...model.ArtworkQueueItem) erro
return nil
}
func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) {
func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil || m.ItemArtworkSource == nil {
@ -195,7 +195,7 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore t
now := time.Now()
var inserted int64
for _, ia := range m.ItemArtworkSource.ItemData {
if ia.ItemKind != kind || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) {
if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) {
continue
}
k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)

View File

@ -132,13 +132,13 @@ func (m *MockArtworkRepo) referenced(hash string) bool {
return false
}
func (m *MockArtworkRepo) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) {
func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return nil, m.Err
}
if ia, ok := m.ItemData[iaKey(kind, id, imageType)]; ok {
if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, imageType)]; ok {
return &ia, nil
}
return nil, model.ErrNotFound
@ -161,21 +161,21 @@ func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error {
return nil
}
func (m *MockArtworkRepo) DeleteForItem(kind, id string) error {
func (m *MockArtworkRepo) DeleteForItem(kind model.Kind, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return m.Err
}
for k, ia := range m.ItemData {
if ia.ItemKind == kind && ia.ItemID == id {
if ia.ItemKind == kind.Prefix() && ia.ItemID == id {
delete(m.ItemData, k)
}
}
return nil
}
func (m *MockArtworkRepo) DeleteForItems(kind string, ids []string) error {
func (m *MockArtworkRepo) DeleteForItems(kind model.Kind, ids []string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
@ -186,14 +186,14 @@ func (m *MockArtworkRepo) DeleteForItems(kind string, ids []string) error {
idSet[id] = true
}
for k, ia := range m.ItemData {
if ia.ItemKind == kind && idSet[ia.ItemID] {
if ia.ItemKind == kind.Prefix() && idSet[ia.ItemID] {
delete(m.ItemData, k)
}
}
return nil
}
func (m *MockArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) {
func (m *MockArtworkRepo) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
@ -201,7 +201,7 @@ func (m *MockArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string
}
res := map[string]model.ItemArtworkInfo{}
for _, id := range ids {
if ia, ok := m.ItemData[iaKey(kind, id, model.ImageTypePrimary)]; ok {
if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, model.ImageTypePrimary)]; ok {
info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash}
if a, ok := m.Data[ia.Hash]; ok {
info.BlurHash = a.BlurHash