navidrome/persistence/artwork_hydration.go
Deluan af984bc817 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.
2026-09-07 00:35:21 -04:00

216 lines
6.7 KiB
Go

package persistence
import (
"context"
"iter"
"slices"
. "github.com/Masterminds/squirrel"
"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"
)
// Keeps the id IN-list under SQLite's bound-parameter limit; a whole multiple of artworkBatchSize
// so a page re-chunks into even hydration batches.
const artworkChunkSize = artworkBatchSize * 3
// streamByIDs yields rows in id chunks through the caller's hydrating fetch. Resolving ids first
// keeps OFFSET out of the joined query.
func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, error)) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
for chunk := range slices.Chunk(ids, artworkChunkSize) {
rows, err := fetch(chunk)
if err != nil {
var zero T
yield(zero, err)
return
}
for _, row := range rows {
if !yield(row, nil) {
return
}
}
}
}
}
// chunkOptions narrows options to a chunk of ids, dropping Max/Offset (the id pre-pass already
// consumed them) and reusing Sort, which may be a seeded random expression by now.
func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions {
var base model.QueryOptions
if len(options) > 0 {
base = model.QueryOptions{Sort: options[0].Sort, Order: options[0].Order, Filters: options[0].Filters}
}
return func(chunk []string) model.QueryOptions {
opts := base
opts.Filters = Eq{idField: chunk}
if base.Filters != nil {
opts.Filters = And{base.Filters, opts.Filters}
}
return opts
}
}
// hydrateItemImages returns per-item artwork info in one batched query per kind. On error it logs
// and returns an empty map, so the page still renders.
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{}
}
infos, err := NewArtworkRepository(ctx, db).GetInfoForItems(kind, ids)
if err != nil {
log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err)
return map[string]model.ItemArtworkInfo{}
}
return infos
}
// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved).
func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) {
if info, ok := infos[id]; ok {
*img = info.Image()
}
}
// hydrateItems fills every item's embedded ItemImage from one batched lookup. ref yields an
// item's id and the ItemImage to fill, which is all that differs between the entity kinds.
func hydrateItems[T any](ctx context.Context, db dbx.Builder, kind model.Kind, items []T,
ref func(*T) (string, *model.ItemImage)) {
ids := make([]string, len(items))
for i := range items {
ids[i], _ = ref(&items[i])
}
infos := hydrateItemImages(ctx, db, kind, ids)
for i := range items {
id, img := ref(&items[i])
applyItemImage(infos, id, img)
}
}
// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own
// art uses it, else it falls back to the album's.
func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) {
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].HasOwnCoverArt() {
eligibleIDs = append(eligibleIDs, mfs[i].ID)
}
}
albumInfos := hydrateItemImages(ctx, db, model.KindAlbumArtwork, albumIDs)
mfInfos := hydrateItemImages(ctx, db, model.KindMediaFileArtwork, eligibleIDs)
for i := range mfs {
mf := &mfs[i]
applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage)
eligible := mf.HasOwnCoverArt()
ownInfo, ownResolved := mfInfos[mf.ID]
if eligible && ownResolved && !ownInfo.Absent() {
mf.ItemImage = ownInfo.Image()
continue
}
ownWontResolve := !eligible || (ownResolved && ownInfo.Absent())
// Inherit the album hash only when serving returns those exact bytes: a multi-disc track is
// served disc art, and an eligible-but-unresolved one still extracts its own embedded image.
if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() {
if mf.DiscNumber == 0 && ownWontResolve {
mf.ItemImage = album.Image()
}
continue
}
// Mark absent only when serving would definitively yield a placeholder; disc art and
// still-extractable embedded art both keep a track requestable.
if mf.DiscNumber > 0 {
continue
}
if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve {
mf.ImageAbsent = true
}
}
}
// 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) {
buf := make([]T, 0, artworkBatchSize)
flush := func() bool {
hydrate(buf)
for i := range buf {
if !yield(buf[i], nil) {
return false
}
}
buf = buf[:0]
return true
}
for row, err := range cursor {
if err != nil {
var zero T
yield(zero, err)
return
}
buf = append(buf, row)
if len(buf) == artworkBatchSize && !flush() {
return
}
}
if len(buf) > 0 {
flush()
}
}
}
// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track.
func hydratePlaylistTrackArtwork(ctx context.Context, db dbx.Builder, tracks model.PlaylistTracks) {
if len(tracks) == 0 {
return
}
mfs := make(model.MediaFiles, len(tracks))
for i := range tracks {
mfs[i] = tracks[i].MediaFile
}
hydrateMediaFileArtwork(ctx, db, mfs)
for i := range tracks {
tracks[i].MediaFile = mfs[i]
}
}