navidrome/persistence/radio_repository_test.go
Deluan 9ce51cf575 perf(artwork): fetch only IDs for backfill enumeration
Backfill enumerated every album, artist, playlist and radio via GetAll
and mapped out just the ID. GetAll materializes full entities (library
joins, participant/stats/tags JSON, annotation, artwork hydration), so on
a large library it loaded tens of thousands of heavy structs only to read
one field each — spiking transient RSS to ~1GB during the one-time
upgrade backfill, a memory risk on small NAS/Pi hardware.

Add GetAllIDs to the album, artist, playlist and radio repositories: it
reuses each repo's base row-set filter (library visibility, artist
content join, playlist userFilter) but projects only id, skipping the
heavy columns and post-processing. A per-repo parity test asserts
GetAllIDs returns exactly the same id set as GetAll.

Verified on a 727MB / 29k-artist production DB copy: peak RSS during
backfill dropped from ~1012MB to ~89MB, file descriptors flat, same
36,138 items enqueued.
2026-07-23 13:20:22 -04:00

207 lines
5.0 KiB
Go

package persistence
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("RadioRepository", func() {
var repo model.RadioRepository
Describe("Admin User", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewRadioRepository(ctx, GetDBXBuilder())
_ = repo.Put(&radioWithHomePage)
})
AfterEach(func() {
all, _ := repo.GetAll()
for _, radio := range all {
_ = repo.Delete(radio.ID)
}
for i := range testRadios {
err := repo.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
}
})
Describe("Count", func() {
It("returns the number of radios in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
})
})
Describe("Delete", func() {
It("deletes existing item", func() {
err := repo.Delete(radioWithHomePage.ID)
Expect(err).To(BeNil())
_, err = repo.Get(radioWithHomePage.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Get", func() {
It("returns an existing item", func() {
res, err := repo.Get(radioWithHomePage.ID)
Expect(err).To(BeNil())
Expect(res.ID).To(Equal(radioWithHomePage.ID))
})
It("errors when missing", func() {
_, err := repo.Get("notanid")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
It("returns all items from the DB", func() {
all, err := repo.GetAll()
Expect(err).To(BeNil())
Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID))
Expect(all[1].ID).To(Equal(radioWithHomePage.ID))
})
})
Describe("GetAllIDs", func() {
It("returns the same id set as GetAll", func() {
want, err := repo.GetAll()
Expect(err).To(BeNil())
Expect(want).ToNot(BeEmpty())
wantIDs := make([]string, 0, len(want))
for _, r := range want {
wantIDs = append(wantIDs, r.ID)
}
ids, err := repo.GetAllIDs()
Expect(err).To(BeNil())
Expect(ids).To(ConsistOf(wantIDs))
})
})
Describe("Put", func() {
It("successfully updates item", func() {
err := repo.Put(&model.Radio{
ID: radioWithHomePage.ID,
Name: "New Name",
StreamUrl: "https://example.com:4533/app",
})
Expect(err).To(BeNil())
item, err := repo.Get(radioWithHomePage.ID)
Expect(err).To(BeNil())
Expect(item.HomePageUrl).To(Equal(""))
})
It("successfully creates item", func() {
err := repo.Put(&model.Radio{
Name: "New radio",
StreamUrl: "https://example.com:4533/app",
})
Expect(err).To(BeNil())
Expect(repo.CountAll()).To(Equal(int64(3)))
all, err := repo.GetAll()
Expect(err).To(BeNil())
Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app"))
})
It("enqueues artwork resolution for the saved radio", func() {
err := repo.Put(&model.Radio{
Name: "Artwork radio",
StreamUrl: "https://example.com:4533/artwork",
})
Expect(err).To(BeNil())
all, err := repo.GetAll()
Expect(err).To(BeNil())
created := all[len(all)-1]
queueRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder())
queued, err := queueRepo.DequeueBatch(1000)
Expect(err).To(BeNil())
Expect(queued).To(ContainElement(SatisfyAll(
HaveField("ItemKind", "ra"),
HaveField("ItemID", created.ID),
HaveField("Priority", model.ArtworkPriorityScan),
)))
})
})
})
Describe("Regular User", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
repo = NewRadioRepository(ctx, GetDBXBuilder())
})
Describe("Count", func() {
It("returns the number of radios in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
})
})
Describe("Delete", func() {
It("fails to delete items", func() {
err := repo.Delete(radioWithHomePage.ID)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
})
Describe("Get", func() {
It("returns an existing item", func() {
res, err := repo.Get(radioWithHomePage.ID)
Expect(err).To(BeNil())
Expect(res.ID).To(Equal(radioWithHomePage.ID))
})
It("errors when missing", func() {
_, err := repo.Get("notanid")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
It("returns all items from the DB", func() {
all, err := repo.GetAll()
Expect(err).To(BeNil())
Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID))
Expect(all[1].ID).To(Equal(radioWithHomePage.ID))
})
})
Describe("Put", func() {
It("fails to update item", func() {
err := repo.Put(&model.Radio{
ID: radioWithHomePage.ID,
Name: "New Name",
StreamUrl: "https://example.com:4533/app",
})
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
})
})
})