mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 10:57:08 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
773 lines
30 KiB
Go
773 lines
30 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(ctx, &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(GetDBXBuilder())
|
|
})
|
|
|
|
Describe("albums", func() {
|
|
var repo model.AlbumRepository
|
|
BeforeEach(func() { repo = NewAlbumRepository(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(ctx)
|
|
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(ctx, 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(ctx, "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(ctx, &al)).To(Succeed())
|
|
|
|
got, err := repo.Get(ctx, 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(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(ctx)
|
|
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(ctx, 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(ctx, "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(GetDBXBuilder()) })
|
|
|
|
It("hydrates the found / known-absent states", func() {
|
|
putInfo("pl", plsBest.ID, "plhash777777777")
|
|
putInfo("pl", plsCool.ID, "")
|
|
|
|
all, err := repo.GetAll(ctx)
|
|
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(ctx, plsBest.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.ImageHash).To(Equal("plget8888888888"))
|
|
})
|
|
|
|
It("hydrates the tracks reached through a playlist", func() {
|
|
Expect(aw.PutImage(ctx, &model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed())
|
|
putInfo("al", songDayInALife.AlbumID, "pltrackhash1234")
|
|
|
|
pls, err := repo.GetWithTracks(ctx, 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(ctx, plsBest.ID, true).GetCursor(ctx)
|
|
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(GetDBXBuilder()) })
|
|
|
|
It("hydrates the found / known-absent states", func() {
|
|
putInfo("ra", radioWithHomePage.ID, "rahash999999999")
|
|
putInfo("ra", radioWithoutHomePage.ID, "")
|
|
|
|
all, err := repo.GetAll(ctx)
|
|
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(ctx, 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 {
|
|
all, err := repo.GetAll(ctx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf })
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
repo = NewMediaFileRepository(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("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(ctx, &model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed())
|
|
Expect(aw.PutImage(ctx, &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(ctx, "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(GetDBXBuilder())
|
|
artistRepo = NewArtistRepository(GetDBXBuilder())
|
|
playlistRepo = NewPlaylistRepository(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(ctx, &foreign)).To(Succeed())
|
|
DeferCleanup(func() { Expect(playlistRepo.Delete(ctx, 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(ctx, &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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
got := collectCursor(albumRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
got := collectCursor(artistRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
got := collectCursor(playlistRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).To(HaveLen(2))
|
|
|
|
got := collectCursor(albumRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).ToNot(BeEmpty())
|
|
|
|
got := collectCursor(albumRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).ToNot(BeEmpty())
|
|
|
|
got := collectCursor(artistRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).ToNot(BeEmpty())
|
|
|
|
got := collectCursor(playlistRepo.GetCursor(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
got := collectCursor(albumRepo.GetCursor(ctx, 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(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(otherCtx, opts)).To(ConsistOf(plsBest.ID))
|
|
all, err := repo.GetAll(otherCtx, 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(otherCtx, 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(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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).ToNot(BeEmpty())
|
|
|
|
cursor, err := mfRepo.GetCursorWithArtwork(ctx, 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(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).ToNot(BeEmpty())
|
|
|
|
got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, 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(ctx, opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).To(HaveLen(2))
|
|
|
|
got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, 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())
|
|
})
|
|
})
|
|
})
|