navidrome/persistence/artwork_hydration_test.go
Deluan d48c7b04da fix(artwork): stop advertising a hash for bytes that won't be served
Hydration stamped the album's hash and blurhash onto an embedded-eligible
track that had no state row yet. Serving takes provisionalEmbedded for
exactly that case and returns the track's own embedded image, so the id
carried a content-version belonging to a different picture: every such
request fell back to no-cache instead of immutable, and a client keying
its cover cache on the blurhash paired the album's with the track's art.

AlbumCoverArtID had the mirror-image problem, building the album id from
the track's own ItemImage. It happened to work only because hydration
overwrote ImageHash in precisely the fallback cases; a track with its own
resolved art would have stamped that hash onto the album's id.
2026-07-25 10:25:46 -04:00

804 lines
31 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 a distinct rating, play count, play date and starred_at for the
// current user, dropping the rows again when the spec ends. It writes 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
byID := map[string]model.Album{}
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
for _, a := range all {
byID[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())
// No item_artwork rows exist, so a fresh read must observe zero values.
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
byID := map[string]model.Artist{}
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
for _, a := range all {
byID[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, "")
byID := map[string]model.Playlist{}
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
for _, p := range all {
byID[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"))
})
// A track reached through a playlist must carry the same artwork state as one reached
// through the songs list, or its cover id has no hash to serve immutably or blur.
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 := map[string]model.PlaylistTrack{}
for _, t := range tracks {
byID[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, "")
byID := map[string]model.Radio{}
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
for _, rd := range all {
byID[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())
}
getByID := func() map[string]model.MediaFile {
byID := map[string]model.MediaFile{}
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
for _, mf := range all {
byID[mf.ID] = mf
}
return byID
}
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()
// eligible + own hash -> own hash
Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx"))
Expect(byID["1001"].ImageAbsent).To(BeFalse())
// eligible + embedded absent -> falls through to album 102 info
Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx"))
Expect(byID["1002"].ImageAbsent).To(BeFalse())
// not eligible (no embedded cover) -> album 103 info (known-absent)
Expect(byID["1003"].ImageHash).To(BeEmpty())
Expect(byID["1003"].ImageAbsent).To(BeTrue())
// not eligible, album has no row -> zero values (unresolved)
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
})
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()
// own-art-wins: ImageHash is the track's own, but AlbumImage still carries the album's.
Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx"))
Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx"))
// single-disc album-inheritance: not eligible, so ImageHash mirrors the album's hash.
Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx"))
Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx"))
// multi-disc bailout: ImageHash stays bare, but AlbumImage still reflects the absence.
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue())
})
It("carries the blurhash alongside the hash in both 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"})).To(Succeed())
Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur"})).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["1002"].ImageHash).To(Equal("alh102blurxxxxxx"))
Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur"))
})
It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() {
// 1004 is eligible (has embedded cover) with no mf state row yet; its album (103) is absent.
setCover("1004", true)
DeferCleanup(func() { setCover("1004", false) })
putInfo("al", "103", "") // album known-absent
byID := getByID()
// The track's own embedded art is still unresolved, so it must NOT inherit the album's
// absence: coverArt stays requestable so serving can extract the embedded art.
Expect(byID["1004"].ImageAbsent).To(BeFalse())
Expect(byID["1004"].ImageHash).To(BeEmpty())
// A non-eligible sibling on the same absent album still inherits the 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()
// 2002 is multi-disc (DiscNumber>0); CoverArtID emits a dc- id served from disc art of
// unknown identity, so it must not advertise the album's hash as its content-version.
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()
// 2002 is a multi-disc track (DiscNumber>0); CoverArtID points at disc art, which
// resolves provisionally, so it must never inherit the album's absence.
Expect(byID["2002"].ImageAbsent).To(BeFalse())
Expect(byID["2002"].ImageHash).To(BeEmpty())
})
// serveMediaFile extracts this track's own embedded art (provisionalEmbedded), 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 belong to the same owner, so the owner_name sort would have a
// single value to order by. This one is also private and owned by a third user, which
// is what the non-admin visibility spec needs to see hidden.
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, so the annotation-backed sorts would
// have nothing to order. Seed distinct values, and drop them again after each spec.
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 and filters the Jellyfin list endpoints issue: the id pre-pass must resolve each
// of them exactly like the full query. Comparing sort keys, not ids, keeps ties out of it.
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 that would
// otherwise re-read an id whose visibility changed in between.
Expect(repo.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 keep the ordering/paging specs tie-free; other fixture songs share
// titles (e.g. "Antenna") or albums, which would make row order ambiguous.
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() {
// Scoped to onlySongs (distinct titles): the full fixture has title ties (e.g. "Antenna"
// x3), so positional comparison against GetAll would only pass by tie-order coincidence.
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 and blurhash onto the item", func() {
infos := map[string]model.ItemArtworkInfo{
"al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8"},
}
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"))
})
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())
})
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())
})
})
})