navidrome/persistence/artwork_hydration_test.go
Deluan 7b12c41fb1 perf(scanner): take the picture fingerprint from go-taglib instead of hashing in Go
Reading the embedded picture out of the WASM module to hash it in Go cost
+28% in tag extraction and doubled allocations (93 real MP3s: 32.5ms -> 41.7ms,
91MiB -> 188MiB per pass), because the fork instantiates a module per file
and the image read grows its memory and copies the bytes twice.

go-taglib now fingerprints each embedded picture inside the module during the
existing properties call (length, head, tail and 64 sampled stripes) and
exposes it as ImageDesc.Hash. The extractor stores that value, so no picture
bytes cross the boundary. Extraction is back to master speed (32.0ms, p=0.24)
and the scanner benchmark is unchanged (10.88ms vs 10.89ms).

Also adds tests for albums whose only art is an external file: no fingerprint
is stored, tracks keep the album id, and hydration skips the album lookup.
2026-09-07 00:59:32 -04:00

822 lines
32 KiB
Go

package persistence
import (
"context"
"errors"
"fmt"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
// seedAnnotations gives each id distinct annotation values, writing them directly so that
// SetRating's average_rating update does not outlive the cleanup.
func seedAnnotations(itemType string, ids ...string) {
GinkgoHelper()
when := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
for i, id := range ids {
_, err := GetDBXBuilder().NewQuery(`insert or replace into annotation
(user_id, item_id, item_type, play_count, play_date, rating, rated_at, starred, starred_at)
values ({:u}, {:id}, {:t}, {:pc}, {:d}, {:r}, {:d}, 1, {:d})`).
Bind(dbx.Params{"u": adminUser.ID, "id": id, "t": itemType,
"pc": i + 1, "r": 4 + i, "d": when.Add(time.Duration(i) * time.Hour)}).Execute()
Expect(err).ToNot(HaveOccurred())
}
DeferCleanup(func() {
for _, id := range ids {
_, err := GetDBXBuilder().NewQuery("delete from annotation where user_id={:u} and item_type={:t} and item_id={:id}").
Bind(dbx.Params{"u": adminUser.ID, "t": itemType, "id": id}).Execute()
Expect(err).ToNot(HaveOccurred())
}
})
}
var _ = Describe("Artwork hydration", func() {
var ctx context.Context
var aw model.ArtworkRepository
putInfo := func(kind, id, hash string) {
Expect(aw.PutItemArtwork(&model.ItemArtwork{
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash,
})).To(Succeed())
}
BeforeEach(func() {
clearArtworkTables()
DeferCleanup(clearArtworkTables)
ctx = request.WithUser(log.NewContext(context.Background()), adminUser)
aw = NewArtworkRepository(ctx, GetDBXBuilder())
})
Describe("albums", func() {
var repo model.AlbumRepository
BeforeEach(func() { repo = NewAlbumRepository(ctx, GetDBXBuilder()) })
It("hydrates the found / known-absent / unresolved states", func() {
putInfo("al", albumSgtPeppers.ID, "althash11111111")
putInfo("al", albumAbbeyRoad.ID, "")
// albumRadioactivity: no row -> unresolved
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
byID := slice.ToMap(all, func(a model.Album) (string, model.Album) { return a.ID, a })
Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111"))
Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse())
Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty())
Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue())
Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty())
Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse())
})
It("hydrates Get", func() {
putInfo("al", albumSgtPeppers.ID, "gethash22222222")
got, err := repo.Get(albumSgtPeppers.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ImageHash).To(Equal("gethash22222222"))
})
It("hydrates Search", func() {
putInfo("al", albumSgtPeppers.ID, "srchash33333333")
res, err := repo.Search("Peppers", model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeEmpty())
Expect(res[0].ImageHash).To(Equal("srchash33333333"))
})
It("does not persist ImageHash/ImageAbsent on Put", func() {
al := albumSgtPeppers
al.ImageHash = "shouldnotpersist"
al.ImageAbsent = true
Expect(repo.(*albumRepository).Put(&al)).To(Succeed())
got, err := repo.Get(al.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ImageHash).To(BeEmpty())
Expect(got.ImageAbsent).To(BeFalse())
})
})
Describe("artists", func() {
var repo model.ArtistRepository
BeforeEach(func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) })
It("hydrates the found / known-absent / unresolved states", func() {
putInfo("ar", artistBeatles.ID, "arhash444444444")
putInfo("ar", artistKraftwerk.ID, "")
// artistCJK: no row -> unresolved
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
byID := slice.ToMap(all, func(a model.Artist) (string, model.Artist) { return a.ID, a })
Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444"))
Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse())
Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty())
Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue())
Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty())
Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse())
})
It("hydrates Get", func() {
putInfo("ar", artistBeatles.ID, "arget5555555555")
got, err := repo.Get(artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ImageHash).To(Equal("arget5555555555"))
})
It("hydrates Search", func() {
putInfo("ar", artistBeatles.ID, "arsrch666666666")
res, err := repo.Search("Beatles", model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeEmpty())
Expect(res[0].ImageHash).To(Equal("arsrch666666666"))
})
})
Describe("playlists", func() {
var repo model.PlaylistRepository
BeforeEach(func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) })
It("hydrates the found / known-absent states", func() {
putInfo("pl", plsBest.ID, "plhash777777777")
putInfo("pl", plsCool.ID, "")
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
byID := slice.ToMap(all, func(p model.Playlist) (string, model.Playlist) { return p.ID, p })
Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777"))
Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse())
Expect(byID[plsCool.ID].ImageHash).To(BeEmpty())
Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue())
})
It("hydrates Get", func() {
putInfo("pl", plsBest.ID, "plget8888888888")
got, err := repo.Get(plsBest.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ImageHash).To(Equal("plget8888888888"))
})
It("hydrates the tracks reached through a playlist", func() {
Expect(aw.PutImage(&model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed())
putInfo("al", songDayInALife.AlbumID, "pltrackhash1234")
pls, err := repo.GetWithTracks(plsBest.ID, true, false)
Expect(err).ToNot(HaveOccurred())
tracks := pls.Tracks
Expect(tracks).ToNot(BeEmpty())
byID := slice.ToMap(tracks, func(t model.PlaylistTrack) (string, model.PlaylistTrack) { return t.MediaFile.ID, t })
Expect(byID).To(HaveKey(songDayInALife.ID))
Expect(byID[songDayInALife.ID].AlbumImage.ImageHash).To(Equal("pltrackhash1234"))
Expect(byID[songDayInALife.ID].BlurHash).To(Equal("LPLBLURhash"))
cursor, err := repo.Tracks(plsBest.ID, true).GetCursor()
Expect(err).ToNot(HaveOccurred())
var streamed *model.PlaylistTrack
for t, err := range cursor {
Expect(err).ToNot(HaveOccurred())
if t.MediaFile.ID == songDayInALife.ID {
streamed = &t
}
}
Expect(streamed).ToNot(BeNil())
Expect(streamed.AlbumImage.ImageHash).To(Equal("pltrackhash1234"),
"the streamed cursor Jellyfin uses must hydrate too")
})
})
Describe("radios", func() {
var repo model.RadioRepository
BeforeEach(func() { repo = NewRadioRepository(ctx, GetDBXBuilder()) })
It("hydrates the found / known-absent states", func() {
putInfo("ra", radioWithHomePage.ID, "rahash999999999")
putInfo("ra", radioWithoutHomePage.ID, "")
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
byID := slice.ToMap(all, func(rd model.Radio) (string, model.Radio) { return rd.ID, rd })
Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999"))
Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse())
Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty())
Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue())
})
It("hydrates Get", func() {
putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa")
got, err := repo.Get(radioWithHomePage.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa"))
})
})
Describe("mediafiles", func() {
var repo model.MediaFileRepository
setCover := func(id string, v bool) {
_, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}").
Bind(dbx.Params{"v": v, "id": id}).Execute()
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())
return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf })
}
BeforeEach(func() {
repo = NewMediaFileRepository(ctx, GetDBXBuilder())
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
It("resolves the embedded-eligible fallback matrix", func() {
setCover("1001", true) // eligible, own hash
setCover("1002", true) // eligible, but embedded art absent -> album
DeferCleanup(func() { setCover("1001", false); setCover("1002", false) })
putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found)
putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found)
putInfo("al", "103", "") // songs 1003/1004 album known-absent
putInfo("mf", "1001", "mfh1001xxxxxxxx")
putInfo("mf", "1002", "") // embedded resolved absent
byID := getByID()
Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx"))
Expect(byID["1001"].ImageAbsent).To(BeFalse())
Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx"))
Expect(byID["1002"].ImageAbsent).To(BeFalse())
Expect(byID["1003"].ImageHash).To(BeEmpty())
Expect(byID["1003"].ImageAbsent).To(BeTrue())
// 2002: not eligible, and its album has no row at all -> unresolved
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
})
It("skips the album lookup when no track on the page embeds a picture", func() {
mfs := model.MediaFiles{{ID: "x1", AlbumID: "101"}, {ID: "x2", AlbumID: "102", HasCoverArt: true}}
// A nil builder panics on any query, so reaching the assertions proves no lookup ran.
Expect(func() { hydrateAlbumEmbedArtHashes(ctx, nil, mfs) }).ToNot(Panic())
Expect(mfs[0].AlbumEmbedArtHash).To(BeEmpty())
Expect(mfs[1].AlbumEmbedArtHash).To(BeEmpty())
})
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) })
putInfo("al", "101", "alh101albimgxxxx") // 1001's album: own-art-wins branch
putInfo("al", "102", "alh102albimgxxxx") // 1002's album: single-disc inheritance branch
putInfo("al", "104", "") // 2002's album: known-absent, multi-disc branch
putInfo("mf", "1001", "mfh1001albimgxxx")
byID := getByID()
Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx"))
Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx"))
Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx"))
Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx"))
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue())
})
It("carries both hashes and the dimensions alongside the hash in the own-art and inherited branches", func() {
setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch
DeferCleanup(func() { setCover("1001", false) })
Expect(aw.PutImage(&model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed())
Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed())
putInfo("mf", "1001", "mfh1001blurxxxxx")
putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch
byID := getByID()
Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx"))
Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur"))
Expect(byID["1001"].ThumbHash).To(Equal("THtrack"))
Expect(byID["1001"].ImageWidth).To(Equal(640))
Expect(byID["1001"].ImageHeight).To(Equal(480))
Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx"))
Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur"))
Expect(byID["1002"].ThumbHash).To(Equal("THalbum"))
Expect(byID["1002"].ImageWidth).To(Equal(1200))
Expect(byID["1002"].ImageHeight).To(Equal(800))
})
It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() {
setCover("1004", true)
DeferCleanup(func() { setCover("1004", false) })
putInfo("al", "103", "") // album known-absent
byID := getByID()
// 1004's own embedded art is still unresolved, so coverArt must stay requestable.
Expect(byID["1004"].ImageAbsent).To(BeFalse())
Expect(byID["1004"].ImageHash).To(BeEmpty())
// 1003 is not eligible, so it still inherits the album's absence.
Expect(byID["1003"].ImageAbsent).To(BeTrue())
})
It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() {
putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found
byID := getByID()
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
})
It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() {
putInfo("al", "104", "") // songs 2002/2004 album known-absent
byID := getByID()
Expect(byID["2002"].ImageAbsent).To(BeFalse())
Expect(byID["2002"].ImageHash).To(BeEmpty())
})
// serveMediaFile serves this track's own embedded art, so the album's hash would
// advertise a content-version for bytes nobody will serve.
It("leaves the hash bare for an eligible file whose own art is unresolved", func() {
setCover("1004", true)
DeferCleanup(func() { setCover("1004", false) })
putInfo("al", "103", "alh103found11111")
byID := getByID()
Expect(byID["1004"].ImageAbsent).To(BeFalse())
Expect(byID["1004"].ImageHash).To(BeEmpty())
Expect(byID["1004"].AlbumImage.ImageHash).To(Equal("alh103found11111"),
"the album's own hash still hydrates, for AlbumCoverArtID")
})
It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() {
conf.Server.EnableMediaFileCoverArt = false
setCover("1001", true)
DeferCleanup(func() { setCover("1001", false) })
putInfo("al", "101", "alh101offxxxxxxx")
putInfo("mf", "1001", "mfh1001offxxxxx")
byID := getByID()
Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx"))
Expect(byID["1001"].ImageAbsent).To(BeFalse())
})
It("hydrates Search", func() {
putInfo("al", "101", "alsrchhhhhhhhhhh")
res, err := repo.Search("A Day In A Life", model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeEmpty())
Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh"))
})
})
Describe("GetCursor", func() {
var albumRepo model.AlbumRepository
var artistRepo model.ArtistRepository
var playlistRepo model.PlaylistRepository
var onlyAlbums, onlyArtists, onlyPlaylists squirrel.Eq
scoped := func(opts model.QueryOptions, only squirrel.Eq) model.QueryOptions {
if opts.Filters == nil {
opts.Filters = only
} else {
opts.Filters = squirrel.And{only, opts.Filters}
}
return opts
}
BeforeEach(func() {
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder())
artistRepo = NewArtistRepository(ctx, GetDBXBuilder())
playlistRepo = NewPlaylistRepository(ctx, GetDBXBuilder())
// Other specs leave rows behind, so scope every cursor spec to the fixtures.
onlyAlbums = squirrel.Eq{"album.id": []string{albumSgtPeppers.ID, albumAbbeyRoad.ID,
albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}}
onlyArtists = squirrel.Eq{"artist.id": []string{artistKraftwerk.ID, artistBeatles.ID,
artistCJK.ID, artistPunctuation.ID}}
// Both fixture playlists share an owner, leaving the owner_name sort a single value to
// order by; this one is also private, which the non-admin visibility spec needs.
foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName}
Expect(playlistRepo.Put(&foreign)).To(Succeed())
DeferCleanup(func() { Expect(playlistRepo.Delete(foreign.ID)).To(Succeed()) })
onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}}
// The suite annotates a single album and artist, leaving the annotation-backed sorts
// nothing to order.
seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID)
seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID)
Expect(aw.PutImage(&model.Artwork{
Hash: "curhash11111111", Mime: "image/jpeg", BlurHash: "LEHV6nWB2yk8",
})).To(Succeed())
putInfo("al", albumSgtPeppers.ID, "curhash11111111")
putInfo("al", albumAbbeyRoad.ID, "")
putInfo("ar", artistBeatles.ID, "curhash11111111")
putInfo("pl", plsBest.ID, "curhash11111111")
})
It("hydrates every streamed album, like GetAll", func() {
opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums}
want, err := albumRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
got := collectCursor(albumRepo.GetCursor(opts))
Expect(got).To(ConsistOf(want))
byID := map[string]model.Album{}
for _, al := range got {
byID[al.ID] = al
}
Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("curhash11111111"))
Expect(byID[albumSgtPeppers.ID].BlurHash).To(Equal("LEHV6nWB2yk8"))
Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue())
Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty())
Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse())
})
It("hydrates every streamed artist, like GetAll", func() {
opts := model.QueryOptions{Sort: "name", Filters: onlyArtists}
want, err := artistRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
got := collectCursor(artistRepo.GetCursor(opts))
Expect(got).To(ConsistOf(want))
Expect(slice.Map(got, func(a model.Artist) string { return a.ImageHash })).
To(ContainElement("curhash11111111"))
})
It("hydrates every streamed playlist, like GetAll", func() {
opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists}
want, err := playlistRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
got := collectCursor(playlistRepo.GetCursor(opts))
Expect(got).To(ConsistOf(want))
Expect(slice.Map(got, func(p model.Playlist) string { return p.ImageHash })).
To(ContainElement("curhash11111111"))
})
It("honors Max and Offset exactly once", func() {
opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums, Max: 2, Offset: 1}
want, err := albumRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).To(HaveLen(2))
got := collectCursor(albumRepo.GetCursor(opts))
Expect(slice.Map(got, func(a model.Album) string { return a.ID })).
To(Equal(slice.Map(want, func(a model.Album) string { return a.ID })))
})
// The sorts the Jellyfin list endpoints issue; comparing sort keys, not ids, keeps ties out.
DescribeTable("orders albums like GetAll",
func(opts model.QueryOptions, key func(model.Album) string) {
opts = scoped(opts, onlyAlbums)
want, err := albumRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).ToNot(BeEmpty())
got := collectCursor(albumRepo.GetCursor(opts))
Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key)))
Expect(slice.Map(got, func(a model.Album) string { return a.ID })).
To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID })))
},
Entry("by name", model.QueryOptions{Sort: "name"},
func(a model.Album) string { return a.OrderAlbumName }),
Entry("by artist", model.QueryOptions{Sort: "artist"},
func(a model.Album) string { return a.OrderAlbumArtistName }),
Entry("by recently added", model.QueryOptions{Sort: "recently_added", Order: "desc"},
func(a model.Album) string { return fmt.Sprint(a.CreatedAt) }),
Entry("by year", model.QueryOptions{Sort: "max_year"},
func(a model.Album) string { return fmt.Sprint(a.MaxYear) }),
Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"},
func(a model.Album) string { return fmt.Sprint(a.PlayCount) }),
Entry("by last played", model.QueryOptions{Sort: "play_date", Order: "desc"},
func(a model.Album) string { return fmt.Sprint(a.PlayDate) }),
Entry("by rating", model.QueryOptions{Sort: "rating", Order: "desc"},
func(a model.Album) string { return fmt.Sprint(a.Rating) }),
Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc",
Filters: squirrel.Eq{"starred": true}},
func(a model.Album) string { return fmt.Sprint(a.StarredAt) }),
)
DescribeTable("orders artists like GetAll",
func(opts model.QueryOptions, key func(model.Artist) string) {
opts = scoped(opts, onlyArtists)
want, err := artistRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).ToNot(BeEmpty())
got := collectCursor(artistRepo.GetCursor(opts))
Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key)))
Expect(slice.Map(got, func(a model.Artist) string { return a.ID })).
To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID })))
},
Entry("by name", model.QueryOptions{Sort: "name"},
func(a model.Artist) string { return a.OrderArtistName }),
Entry("by album count", model.QueryOptions{Sort: "album_count", Order: "desc"},
func(a model.Artist) string { return fmt.Sprint(a.AlbumCount) }),
Entry("by song count", model.QueryOptions{Sort: "song_count", Order: "desc"},
func(a model.Artist) string { return fmt.Sprint(a.SongCount) }),
Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"},
func(a model.Artist) string { return fmt.Sprint(a.PlayCount) }),
Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc",
Filters: squirrel.Eq{"starred": true}},
func(a model.Artist) string { return fmt.Sprint(a.StarredAt) }),
)
DescribeTable("orders playlists like GetAll",
func(opts model.QueryOptions, key func(model.Playlist) string) {
opts = scoped(opts, onlyPlaylists)
want, err := playlistRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).ToNot(BeEmpty())
got := collectCursor(playlistRepo.GetCursor(opts))
Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key)))
Expect(slice.Map(got, func(p model.Playlist) string { return p.ID })).
To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID })))
},
Entry("by name", model.QueryOptions{Sort: "name"},
func(p model.Playlist) string { return p.Name }),
Entry("by creation date", model.QueryOptions{Sort: "created_at", Order: "desc"},
func(p model.Playlist) string { return fmt.Sprint(p.CreatedAt) }),
Entry("by owner", model.QueryOptions{Sort: "owner_name"},
func(p model.Playlist) string { return p.OwnerName }),
)
It("streams every album exactly once when sorted randomly", func() {
opts := model.QueryOptions{Sort: "random", Filters: onlyAlbums}
want, err := albumRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
got := collectCursor(albumRepo.GetCursor(opts))
Expect(slice.Map(got, func(a model.Album) string { return a.ID })).
To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID })))
})
It("keeps a non-admin from streaming another user's private playlists", func() {
otherCtx := request.WithUser(log.NewContext(context.Background()), regularUser)
repo := NewPlaylistRepository(otherCtx, GetDBXBuilder())
opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists}
// Both phases must filter on their own: the id pre-pass and the chunk fetch.
Expect(repo.(*playlistRepository).getAllIDs(opts)).To(ConsistOf(plsBest.ID))
all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID))
got := collectCursor(repo.GetCursor(opts))
Expect(slice.Map(got, func(p model.Playlist) string { return p.Name })).
To(ConsistOf(plsBest.Name))
})
})
Describe("GetCursorWithArtwork", func() {
var mfRepo model.MediaFileRepository
var onlySongs squirrel.Eq
BeforeEach(func() {
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
putInfo("al", albumSgtPeppers.ID, "curhash11111111")
// Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which
// would make the positional comparisons against GetAll pass by tie-order coincidence.
onlySongs = squirrel.Eq{"media_file.id": []string{songDayInALife.ID, songComeTogether.ID,
songRadioactivity.ID, songAntenna.ID, songDisc1Track01.ID, songCJK.ID, songPunctuation.ID}}
})
It("hydrates artwork onto every streamed track, unlike GetCursor", func() {
opts := model.QueryOptions{Sort: "title", Filters: onlySongs}
want, err := mfRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).ToNot(BeEmpty())
cursor, err := mfRepo.GetCursorWithArtwork(opts)
Expect(err).ToNot(HaveOccurred())
var got model.MediaFiles
cursor(func(mf model.MediaFile, err error) bool {
Expect(err).ToNot(HaveOccurred())
got = append(got, mf)
return true
})
Expect(got).To(HaveLen(len(want)))
for i := range want {
Expect(got[i].ID).To(Equal(want[i].ID))
Expect(got[i].ImageHash).To(Equal(want[i].ImageHash))
Expect(got[i].ImageAbsent).To(Equal(want[i].ImageAbsent))
Expect(got[i].AlbumImage.ImageHash).To(Equal(want[i].AlbumImage.ImageHash))
Expect(got[i].AlbumImage.BlurHash).To(Equal(want[i].AlbumImage.BlurHash))
}
Expect(want).To(ContainElement(HaveField("AlbumImage.ImageHash", Not(BeEmpty()))),
"fixture must include at least one track with album artwork, or this proves nothing")
})
It("leaves the scanner's GetCursor unhydrated", func() {
cursor, err := mfRepo.GetCursor(model.QueryOptions{Sort: "title"})
Expect(err).ToNot(HaveOccurred())
var seen int
cursor(func(mf model.MediaFile, err error) bool {
Expect(err).ToNot(HaveOccurred())
Expect(mf.AlbumImage.ImageHash).To(BeEmpty(), "GetCursor must stay unhydrated for the scanner")
seen++
return true
})
Expect(seen).To(BeNumerically(">", 0))
})
It("streams the same ids in the same order as GetAll", func() {
opts := model.QueryOptions{Sort: "title", Filters: onlySongs}
want, err := mfRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).ToNot(BeEmpty())
got := collectCursor(mfRepo.GetCursorWithArtwork(opts))
Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })).
To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID })))
})
It("honors Max and Offset exactly once", func() {
opts := model.QueryOptions{Sort: "title", Filters: onlySongs, Max: 2, Offset: 1}
want, err := mfRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).To(HaveLen(2))
got := collectCursor(mfRepo.GetCursorWithArtwork(opts))
Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })).
To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID })))
})
})
Describe("chunkOptions", func() {
It("carries Sort, Order and the caller's filters, but never Max/Offset", func() {
base := model.QueryOptions{Sort: "name", Order: "desc", Max: 10, Offset: 20,
Filters: squirrel.Eq{"missing": false}}
got := chunkOptions([]model.QueryOptions{base}, "album.id")([]string{"al-1", "al-2"})
Expect(got.Max).To(BeZero())
Expect(got.Offset).To(BeZero())
Expect(got.Sort).To(Equal("name"))
Expect(got.Order).To(Equal("desc"))
Expect(got.Filters).To(Equal(squirrel.And{
base.Filters, squirrel.Eq{"album.id": []string{"al-1", "al-2"}},
}))
})
It("filters by ids alone when the caller passed no filters", func() {
got := chunkOptions(nil, "artist.id")([]string{"ar-1"})
Expect(got.Filters).To(Equal(squirrel.Eq{"artist.id": []string{"ar-1"}}))
})
})
Describe("streamByIDs", func() {
It("fetches in chunks and yields every row in order", func() {
ids := make([]string, artworkChunkSize+3)
for i := range ids {
ids[i] = fmt.Sprintf("id-%d", i)
}
var chunks [][]string
got := collectCursor(streamByIDs(ids, func(chunk []string) ([]string, error) {
chunks = append(chunks, chunk)
return chunk, nil
}), nil)
Expect(chunks).To(HaveLen(2))
Expect(chunks[0]).To(HaveLen(artworkChunkSize))
Expect(chunks[1]).To(HaveLen(3))
Expect(got).To(Equal(ids))
})
It("yields the fetch error and stops", func() {
ids := make([]string, artworkChunkSize+1)
calls := 0
var errs []error
for _, err := range streamByIDs(ids, func(chunk []string) ([]string, error) {
calls++
return nil, errors.New("boom")
}) {
errs = append(errs, err)
}
Expect(calls).To(Equal(1))
Expect(errs).To(HaveLen(1))
Expect(errs[0]).To(MatchError("boom"))
})
It("stops fetching when the consumer breaks", func() {
ids := make([]string, artworkChunkSize+1)
calls := 0
for range streamByIDs(ids, func(chunk []string) ([]string, error) {
calls++
return chunk, nil
}) {
break
}
Expect(calls).To(Equal(1))
})
})
Describe("applyItemImage", func() {
It("copies hash, absence, blurhash and dimensions onto the item", func() {
infos := map[string]model.ItemArtworkInfo{
"al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", ThumbHash: "1QcSHQRn", Width: 1200, Height: 800},
}
var img model.ItemImage
applyItemImage(infos, "al-1", &img)
Expect(img.ImageHash).To(Equal("0123456789abcdef"))
Expect(img.ImageAbsent).To(BeFalse())
Expect(img.BlurHash).To(Equal("LEHV6nWB2yk8"))
Expect(img.ThumbHash).To(Equal("1QcSHQRn"))
Expect(img.ImageWidth).To(Equal(1200))
Expect(img.ImageHeight).To(Equal(800))
})
It("marks a hashless entry absent and carries no blurhash", func() {
infos := map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2"}}
var img model.ItemImage
applyItemImage(infos, "al-2", &img)
Expect(img.ImageAbsent).To(BeTrue())
Expect(img.BlurHash).To(BeEmpty())
Expect(img.ThumbHash).To(BeEmpty())
})
It("leaves an unresolved item zero-valued", func() {
var img model.ItemImage
applyItemImage(map[string]model.ItemArtworkInfo{}, "al-3", &img)
Expect(img.ImageHash).To(BeEmpty())
Expect(img.ImageAbsent).To(BeFalse())
Expect(img.BlurHash).To(BeEmpty())
})
})
})