From 8545cc4762c6252f3a592ec2ea2ac8ccb9fddd3c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 23:37:52 -0400 Subject: [PATCH] fix(jellyfin): hydrate artwork on the song cursor Jellyfin's listSongs streamed media files via GetCursor, which never hydrates artwork, so songs emitted entity-id image tags and no blurhash. media_file now uses the same id pre-pass as the other three cursors (album/artist/playlist), for consistency, but on a separate method, GetCursorWithArtwork: GetCursor itself must stay untouched, since it's also the scanner's hot path and the scanner never reads artwork. Measured on 1,000,000 tracks, the pre-pass over all ids costs +41.8 MB heap and +298 ms versus GetCursor's bounded +0.0 MB. The Jellyfin path is paginated, though, so in practice it only ever pre-passes a page's worth of ids, not the full library, and doesn't pay that cost. --- model/mediafile.go | 5 ++ persistence/artwork_hydration_test.go | 78 +++++++++++++++++++++++++++ persistence/mediafile_repository.go | 25 +++++++++ server/jellyfin/items.go | 2 +- tests/mock_mediafile_repo.go | 16 ++++++ 5 files changed, 125 insertions(+), 1 deletion(-) diff --git a/model/mediafile.go b/model/mediafile.go index 1e2dff782..e910bdcb9 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -549,6 +549,11 @@ type MediaFileRepository interface { GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) + // GetAllIDs returns just the media_file IDs for the same row set as GetAll. + GetAllIDs(options ...QueryOptions) ([]string, error) + // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't + // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. + GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error DeleteMissing(ids []string) error DeleteAllMissing() (int64, error) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 742056e69..b31358bcb 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -568,6 +568,84 @@ var _ = Describe("Artwork hydration", func() { }) }) + 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() { + opts := model.QueryOptions{Sort: "title"} + 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, diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index c90a28566..0685dd833 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -343,6 +343,31 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me return wrapMediaFileCursor(cursor), nil } +// GetAllIDs returns just the media_file IDs for the same row set as GetAll, skipping the heavy +// column projection. Used as GetCursorWithArtwork's id pre-pass. +func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "media_file.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + +// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via the id pre-pass used by +// the other cursors, rather than the scanner's bounded, unhydrated GetCursor itself. +func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { + ids, err := r.GetAllIDs(options...) + if err != nil { + return nil, err + } + opts := chunkOptions(options, "media_file.id") + return model.MediaFileCursor(streamByIDs(ids, func(chunk []string) (model.MediaFiles, error) { + return r.GetAll(opts(chunk)) + })), nil +} + // FindByPaths finds media files by their paths. // The paths can be library-qualified (format: "libraryID:path") or unqualified ("path"). // Library-qualified paths search within the specified library, while unqualified paths diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 7cf2a17ba..f7050249f 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -588,7 +588,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursorWithArtwork(opts) }, toItem) return streamed(open, int(total), opts.Offset), nil } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 990b91d7c..81837865e 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -123,6 +123,22 @@ func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFile }, nil } +func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + return m.GetCursor(qo...) +} + +func (m *MockMediaFileRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, mf := range all { + ids[i] = mf.ID + } + return ids, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error")