feat(artwork): advertise album art for tracks whose embedded cover is the album's

Most albums embed the same picture in every track, so clients requesting
per-track cover art (mf- ids) download and cache the same image once per
song. This change hashes the embedded picture at scan time (XXH3 of the
bytes, the same digest the artwork pipeline uses) and stores it on
media_file.embed_art_hash. ToAlbum stores the hash of the track it already
picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash.

A new MediaFile.HasOwnCoverArt replaces the scattered
"HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals
its album's now returns the disc/album artwork id from CoverArtID, so the
Subsonic, Jellyfin and native responses point every matching song at one
cached image. Serving a stale mf- URL follows the same rule, so what is
advertised and what is served agree. Tracks with a different embedded
picture (compilations, per-track art) keep their own ids.

Rows scanned before this change have an empty hash and keep the previous
behavior until a full scan; no config option is added. The album hash is
hydrated onto tracks with one batched PK lookup per page, only when the
page has hashed tracks. BestImageIndex is exported from core/artwork so the
scanner hashes the same picture the resolver serves.
This commit is contained in:
Deluan 2026-09-07 00:35:21 -04:00
commit af984bc817
21 changed files with 280 additions and 82 deletions

View file

@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -95,11 +96,12 @@ func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.Medi
if len(mfs) == 0 {
return
}
hydrateAlbumEmbedArtHashes(ctx, db, mfs)
albumIDs := make([]string, len(mfs))
var eligibleIDs []string
for i := range mfs {
albumIDs[i] = mfs[i].AlbumID
if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt {
if mfs[i].HasOwnCoverArt() {
eligibleIDs = append(eligibleIDs, mfs[i].ID)
}
}
@ -108,7 +110,7 @@ func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.Medi
for i := range mfs {
mf := &mfs[i]
applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage)
eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt
eligible := mf.HasOwnCoverArt()
ownInfo, ownResolved := mfInfos[mf.ID]
if eligible && ownResolved && !ownInfo.Absent() {
mf.ItemImage = ownInfo.Image()
@ -134,6 +136,39 @@ func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.Medi
}
}
// hydrateAlbumEmbedArtHashes fills AlbumEmbedArtHash for tracks with a hashed embedded picture, the
// only input HasOwnCoverArt needs beyond the row itself.
func hydrateAlbumEmbedArtHashes(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) {
if !conf.Server.EnableMediaFileCoverArt {
return
}
var albumIDs []string
for i := range mfs {
if mfs[i].EmbedArtHash != "" {
albumIDs = append(albumIDs, mfs[i].AlbumID)
}
}
if len(albumIDs) == 0 {
return
}
repo := sqlRepository{ctx: ctx, db: db, tableName: "album"}
hashes := map[string]string{}
for chunk := range slices.Chunk(slice.Unique(albumIDs), artworkChunkSize) {
var rows []struct{ ID, EmbedArtHash string }
sel := Select("id", "embed_art_hash").From("album").Where(Eq{"id": chunk})
if err := repo.queryAll(sel, &rows); err != nil {
log.Error(ctx, "Failed to hydrate album embedded art hashes onto page", err)
return
}
for _, row := range rows {
hashes[row.ID] = row.EmbedArtHash
}
}
for i := range mfs {
mfs[i].AlbumEmbedArtHash = hashes[mfs[i].AlbumID]
}
}
// hydrateCursor hydrates a streamed cursor in batches, avoiding a per-row query.
func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {

View file

@ -231,6 +231,19 @@ var _ = Describe("Artwork hydration", func() {
Expect(err).ToNot(HaveOccurred())
}
setEmbedHash := func(table, id, hash string) {
_, err := GetDBXBuilder().NewQuery("UPDATE " + table + " SET embed_art_hash={:h} WHERE id={:id}").
Bind(dbx.Params{"h": hash, "id": id}).Execute()
Expect(err).ToNot(HaveOccurred())
}
clearEmbedHashes := func() {
for _, table := range []string{"media_file", "album"} {
_, err := GetDBXBuilder().NewQuery("UPDATE " + table + " SET embed_art_hash=''").Execute()
Expect(err).ToNot(HaveOccurred())
}
}
getByID := func() map[string]model.MediaFile {
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
@ -267,6 +280,33 @@ var _ = Describe("Artwork hydration", func() {
Expect(byID["2002"].ImageAbsent).To(BeFalse())
})
It("defers a track to its album when its embedded picture is the album's cover", func() {
setCover("1001", true)
setCover("1002", true)
setEmbedHash("media_file", "1001", "samepicxxxxxxxxx")
setEmbedHash("album", "101", "samepicxxxxxxxxx")
setEmbedHash("media_file", "1002", "ownpicxxxxxxxxxx")
setEmbedHash("album", "102", "otherpicxxxxxxxx")
DeferCleanup(func() { setCover("1001", false); setCover("1002", false); clearEmbedHashes() })
putInfo("al", "101", "alh101xxxxxxxxxx")
putInfo("al", "102", "alh102xxxxxxxxxx")
putInfo("mf", "1001", "mfh1001xxxxxxxx") // resolved, but it is the album's picture
putInfo("mf", "1002", "mfh1002xxxxxxxx")
byID := getByID()
Expect(byID["1001"].AlbumEmbedArtHash).To(Equal("samepicxxxxxxxxx"))
Expect(byID["1001"].HasOwnCoverArt()).To(BeFalse())
Expect(byID["1001"].ImageHash).To(Equal("alh101xxxxxxxxxx"))
Expect(byID["1001"].CoverArtID().Kind).To(Equal(model.KindAlbumArtwork))
Expect(byID["1002"].AlbumEmbedArtHash).To(Equal("otherpicxxxxxxxx"))
Expect(byID["1002"].HasOwnCoverArt()).To(BeTrue())
Expect(byID["1002"].ImageHash).To(Equal("mfh1002xxxxxxxx"))
Expect(byID["1002"].CoverArtID().Kind).To(Equal(model.KindMediaFileArtwork))
})
It("populates AlbumImage from hydrateArtwork regardless of which continue branch a track takes", func() {
setCover("1001", true) // eligible, resolves its own art -> own-art-wins continue
DeferCleanup(func() { setCover("1001", false) })