2026-07-22 20:25:17 -04:00
|
|
|
package persistence
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
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:08:58 -04:00
|
|
|
"iter"
|
|
|
|
|
"slices"
|
2026-07-22 20:25:17 -04:00
|
|
|
|
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:08:58 -04:00
|
|
|
. "github.com/Masterminds/squirrel"
|
2026-07-25 10:20:04 -04:00
|
|
|
"github.com/navidrome/navidrome/conf"
|
2026-07-22 20:25:17 -04:00
|
|
|
"github.com/navidrome/navidrome/log"
|
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
|
|
|
"github.com/pocketbase/dbx"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-24 10:46:59 -04:00
|
|
|
// artworkChunkSize bounds each chunk fetch's 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
|
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:08:58 -04:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 20:25:17 -04:00
|
|
|
// 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.
|
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
|
|
|
func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo {
|
2026-07-22 20:25:17 -04:00
|
|
|
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()
|
2026-07-23 19:48:46 -04:00
|
|
|
img.BlurHash = info.BlurHash
|
2026-07-22 20:25:17 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-25 10:20:04 -04:00
|
|
|
|
|
|
|
|
// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own
|
|
|
|
|
// art uses it, else it falls back to the album's. Two batched item_artwork lookups, never a join.
|
|
|
|
|
func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) {
|
|
|
|
|
if len(mfs) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
albumIDs := make([]string, len(mfs))
|
|
|
|
|
var eligibleIDs []string
|
|
|
|
|
for i := range mfs {
|
|
|
|
|
albumIDs[i] = mfs[i].AlbumID
|
|
|
|
|
if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt {
|
|
|
|
|
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.HasCoverArt && conf.Server.EnableMediaFileCoverArt
|
|
|
|
|
ownInfo, ownResolved := mfInfos[mf.ID]
|
|
|
|
|
if eligible && ownResolved && !ownInfo.Absent() {
|
|
|
|
|
mf.ImageHash = ownInfo.Hash // own resolved art wins
|
|
|
|
|
mf.BlurHash = ownInfo.BlurHash
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-25 10:25:46 -04:00
|
|
|
ownWontResolve := !eligible || (ownResolved && ownInfo.Absent())
|
2026-07-25 10:20:04 -04:00
|
|
|
// Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching,
|
2026-07-25 10:25:46 -04:00
|
|
|
// but only when the album's bytes are what serving will actually return. A multi-disc track
|
|
|
|
|
// emits a dc- id served from disc art, and an eligible-but-unresolved track still extracts
|
|
|
|
|
// its own embedded image — stamping the album hash on either advertises a content-version
|
|
|
|
|
// (and a blurhash) belonging to a different image.
|
2026-07-25 10:20:04 -04:00
|
|
|
if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() {
|
2026-07-25 10:25:46 -04:00
|
|
|
if mf.DiscNumber == 0 && ownWontResolve {
|
2026-07-25 10:20:04 -04:00
|
|
|
mf.ImageHash = album.Hash
|
|
|
|
|
mf.BlurHash = album.BlurHash
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Nothing found. Mark absent only when serving would definitively yield a placeholder:
|
|
|
|
|
// a single-disc track whose album is known-absent and whose own art won't resolve. A
|
|
|
|
|
// multi-disc track resolves disc art provisionally (never known-absent), and an
|
|
|
|
|
// eligible-but-unresolved track can still extract its own embedded art — both stay
|
|
|
|
|
// requestable.
|
|
|
|
|
if mf.DiscNumber > 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve {
|
|
|
|
|
mf.ImageAbsent = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// hydrateCursor buffers a streamed page into batches and hydrates each before yielding, so a
|
|
|
|
|
// cursor carries the same artwork state as a fetched page without 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, so a track
|
|
|
|
|
// reached through a playlist carries the same artwork state as one reached through the songs list.
|
|
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
}
|