fix(jellyfin): fold folder image mtimes into the album blurhash version

An in-place cover-file swap followed by a quick scan updates only the folder's
images_updated_at: no tracks are imported, so the album row never moves and
ArtworkUpdatedAt() stayed at the old value. The Jellyfin DTO then kept emitting
the previous stored blurhash, and clients that key their cover caches on it never
refetched the image, so the served-bytes recompute could never run.

The album select now surfaces the newest folder images_updated_at (bare-column
correlated subquery over json_each(folder_ids), so the datetime decltype survives
and scans as time.Time) and ArtworkUpdatedAt() folds it in. Benchmarked on a 96K
track production copy: ~90us/page added, no query-plan change; the subquery is a
PK point lookup per folder with at most two rows to order. Artist images and
playlist sidecars have the same theoretical gap but their timestamps cannot be
derived in SQL; they remain a documented follow-up.
This commit is contained in:
Deluan 2026-07-17 22:14:13 -04:00
commit 07af29fbb4
6 changed files with 104 additions and 2 deletions

View file

@ -224,7 +224,11 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name").
sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name",
// Folds folder image mtimes into the artwork version: an in-place cover swap moves them without
// touching the album row. Bare column (not max()) keeps the decltype so the driver scans time.Time.
"(select f.images_updated_at from folder f, json_each(album.folder_ids) je where f.id = je.value"+
" order by f.images_updated_at desc limit 1) as folder_images_updated_at").
LeftJoin("library on album.library_id = library.id")
sql = r.withAnnotation(sql, "album.id")
return r.applyLibraryFilter(sql)

View file

@ -900,6 +900,48 @@ func _p(id, name string, sortName ...string) model.Participant {
return p
}
var _ = Describe("AlbumRepository folder images version", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
repo = NewAlbumRepository(ctx, GetDBXBuilder())
var origFolderIDs string
Expect(GetDBXBuilder().NewQuery("select folder_ids from album where id = '103'").
Row(&origFolderIDs)).To(Succeed())
DeferCleanup(func() {
_, err := GetDBXBuilder().NewQuery("delete from folder where id = 'fold-blur-1'").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery("update album set folder_ids = {:f} where id = '103'").
Bind(map[string]any{"f": origFolderIDs}).Execute()
Expect(err).ToNot(HaveOccurred())
})
})
It("surfaces the newest folder images_updated_at on the selected album", func() {
// Newer than any fixture row timestamp, so it must win as the artwork version.
imagesAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC)
_, err := GetDBXBuilder().NewQuery(
"insert into folder (id, library_id, path, name, images_updated_at) values ('fold-blur-1', 1, '.', 'Radioactivity', {:t})").
Bind(map[string]any{"t": imagesAt}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute()
Expect(err).ToNot(HaveOccurred())
al, err := repo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).ToNot(BeNil())
Expect(al.FolderImagesUpdatedAt.Equal(imagesAt)).To(BeTrue())
Expect(al.ArtworkUpdatedAt().Equal(imagesAt)).To(BeTrue(), "folder image changes must advance the artwork version")
})
It("leaves FolderImagesUpdatedAt nil when the album has no folders", func() {
al, err := repo.Get("101")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).To(BeNil())
})
})
var _ = Describe("AlbumRepository.UpdateBlurHash", func() {
var repo model.AlbumRepository