navidrome/persistence/artwork_repository.go
Deluan 61cbd2f5f9 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.
2026-07-24 16:58:23 -04:00

219 lines
6.3 KiB
Go

package persistence
import (
"context"
"slices"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
const (
itemArtworkTable = "item_artwork"
artworkBatchSize = 200
)
type itemArtworkSQL struct {
sqlRepository
}
type artworkRepository struct {
sqlRepository
items itemArtworkSQL
}
func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository {
r := &artworkRepository{}
r.ctx = ctx
r.db = db
r.tableName = "artwork"
r.items.ctx = ctx
r.items.db = db
r.items.tableName = itemArtworkTable
return r
}
func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) {
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) PutImage(a *model.Artwork) error {
// created_at is the last-acquisition-write time the prune grace window keys on.
a.CreatedAt = time.Now()
values, err := toSQLArgs(*a)
if err != nil {
return err
}
// created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window.
ins := Insert(r.tableName).SetMap(values).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, created_at=excluded.created_at`)
_, err = r.executeSQL(ins)
return err
}
func (r *artworkRepository) GetImages(hashes []string) (map[string]model.Artwork, error) {
res := map[string]model.Artwork{}
for chunk := range slices.Chunk(hashes, artworkBatchSize) {
sel := Select("*").From(r.tableName).Where(Eq{"hash": chunk})
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) GetAllMimes() (map[string]string, error) {
sel := Select("hash", "mime").From(r.tableName)
var rows []struct {
Hash string
Mime string
}
if err := r.queryAll(sel, &rows); err != nil {
return nil, err
}
res := make(map[string]string, len(rows))
for _, row := range rows {
res[row.Hash] = row.Mime
}
return res, nil
}
func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) {
sel := Select("hash").From(r.tableName).
Where(And{
Lt{"created_at": createdBefore},
Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"),
})
var hashes []string
err := r.queryAllSlice(sel, &hashes)
return hashes, err
}
func (r *artworkRepository) DeleteOrphans(createdBefore time.Time, hashes []string) error {
for chunk := range slices.Chunk(hashes, artworkBatchSize) {
del := Delete(r.tableName).Where(And{
Eq{"hash": chunk},
Lt{"created_at": createdBefore},
Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"),
})
if _, err := r.executeSQL(del); err != nil {
return err
}
}
return nil
}
// 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.
func purgeDangling(execute func(Sqlizer) (int64, error), table string) (int64, error) {
var total int64
for kind, entityTable := range danglingItemArtworkKinds {
del := Delete(table).Where(And{
Eq{"item_kind": kind.Prefix()},
Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"),
})
c, err := execute(del)
if err != nil {
return total, err
}
total += c
}
return total, nil
}
func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) {
return purgeDangling(r.items.executeSQL, itemArtworkTable)
}
func (r *artworkRepository) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) {
sel := Select("*").From(itemArtworkTable).
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
}
return &res, nil
}
func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error {
if ia.ImageType == "" {
ia.ImageType = model.ImageTypePrimary
}
ia.UpdatedAt = time.Now()
// PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now.
if ia.AttemptedAt.IsZero() {
ia.AttemptedAt = ia.UpdatedAt
}
values, err := toSQLArgs(*ia)
if err != nil {
return err
}
ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime,
attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`)
_, err = r.items.executeSQL(ins)
return err
}
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 model.Kind, ids []string) error {
for chunk := range slices.Chunk(ids, artworkBatchSize) {
if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil {
return err
}
}
return nil
}
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.Prefix()},
Eq{"ia.image_type": model.ImageTypePrimary},
Eq{"ia.item_id": chunk},
})
var rows []struct {
ItemID string
Hash string
BlurHash string
}
if err := r.items.queryAll(sel, &rows); err != nil {
return nil, err
}
for _, row := range rows {
res[row.ItemID] = model.ItemArtworkInfo{
ItemID: row.ItemID, Hash: row.Hash, BlurHash: row.BlurHash,
}
}
}
return res, nil
}
var _ model.ArtworkRepository = (*artworkRepository)(nil)