navidrome/persistence/artwork_hydration.go
Deluan 1cc0df80b1 fix(artwork): hydrate cursor streams via an id pre-pass
The album, artist and playlist GetCursor built their own select and never
called hydrateArtwork, so every Jellyfin list endpoint (all six stream via
GetCursor) emitted entity-id image tags and no blurhash. Only GetAll
hydrated, which is why Subsonic and the native API were unaffected.

Each cursor now resolves its ordered/filtered/paginated id set with the
cheap id-only GetAllIDs query, then streams those ids in chunks through the
repo's existing GetAll, which already hydrates and applies the full select.
Max/Offset are consumed by the pre-pass alone; the chunk query carries only
the caller's filters, Sort and Order.

This also removes a pre-existing deep-pagination cost: keeping OFFSET out of
the joined query makes the pre-pass a covering index scan instead of paying
the library and annotation joins for every skipped row. Benchmarked on a
synthetic 100k-album DB with the real schema, page=500 at offset 90,000:
3.9ms via the id pre-pass, 52.5ms for the current shape, 192.7ms for a naive
join. An unpaginated full stream costs ~24% more, which is the trade.

GetAllIDs gains the annotation join whenever the caller's filters or sort
reference an annotation column (same gate CountAll uses), otherwise
Filters=IsFavorite and SortBy=PlayCount would fail in the pre-pass. The
playlist pre-pass repeats GetAll's columns so ORDER BY keeps resolving to
playlist.name rather than the joined user.name.
2026-07-23 22:41:54 -04:00

77 lines
2.5 KiB
Go

package persistence
import (
"context"
"iter"
"slices"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
// artworkChunkSize bounds the id IN-list of each chunk fetch, keeping it under SQLite's bound
// parameter limit.
const artworkChunkSize = 500
// streamByIDs yields the rows of ids in chunks, fetching each chunk through the caller's hydrating
// fetch. Resolving ids first keeps OFFSET out of the joined query (spec §6).
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 the caller's options to a chunk of ids, dropping Max/Offset (the id pre-pass
// already consumed them) and reusing its 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 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 {
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.ImageHash = info.Hash
img.ImageAbsent = info.Absent()
img.BlurHash = info.BlurHash
}
}