From ebaf804dbf5f4556371a0314667f959dea741f04 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 00:05:57 -0400 Subject: [PATCH] fix(persistence): mirror the reader's remaining cheap album-root gates The parent fold now also requires a single common parent across the album's folders (a compilation spread over artist folders has no album root), excludes a parent that is itself one of the album's folders, and excludes the library root, matching albumRootParent. The subtree-audio gate stays unmirrored on purpose: it would need a per-row LIKE prefix scan over the folder table, and with omission plus the write-side clamp its absence can only suppress a hash briefly until the next serve. Plan verified on a 96K-track production copy: all folder accesses remain PK point lookups. --- persistence/album_repository.go | 16 +++++++++++----- persistence/album_repository_test.go | 25 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6b2d67964..96129fb8b 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -226,13 +226,19 @@ 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", // Folds folder image mtimes into the artwork version: an in-place cover swap moves them without - // touching the album row. Parents count only when the reader could serve the album-root cover - // (disc subfolders, or a single folder with no images of its own), mirroring albumRootParent's - // first gate. Bare column (not max()) keeps the decltype so time.Time scans. + // touching the album row. The parent counts only when albumRootParent could serve it: single + // common parent, not the library root, not an album folder, and disc subfolders or an imageless + // folder. The subtree-audio gate is deliberately unmirrored (a per-row LIKE scan): it can only + // suppress a hash briefly, healed on the next serve. Bare column keeps the datetime decltype. "(select f.images_updated_at from folder f where f.id in"+ " (select je.value from json_each(album.folder_ids) je"+ - " union select p.parent_id from folder p, json_each(album.folder_ids) je2 where p.id = je2.value"+ - " and (json_array_length(album.folder_ids) > 1 or json_array_length(p.image_files) = 0))"+ + " union select pf.id from json_each(album.folder_ids) je2"+ + " join folder p on p.id = je2.value join folder pf on pf.id = p.parent_id"+ + " where pf.parent_id <> ''"+ + " and (json_array_length(album.folder_ids) > 1 or json_array_length(p.image_files) = 0)"+ + " and pf.id not in (select je3.value from json_each(album.folder_ids) je3)"+ + " and (select count(distinct p2.parent_id) from folder p2, json_each(album.folder_ids) je4"+ + " where p2.id = je4.value) = 1)"+ " 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") diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index ff1ce5aa6..e8efab5b5 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -910,7 +910,7 @@ var _ = Describe("AlbumRepository folder images version", func() { 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 in ('fold-blur-1', 'fold-blur-root')").Execute() + _, err := GetDBXBuilder().NewQuery("delete from folder where id like 'fold-blur-%'").Execute() Expect(err).ToNot(HaveOccurred()) _, err = GetDBXBuilder().NewQuery("update album set folder_ids = {:f} where id = '103'"). Bind(map[string]any{"f": origFolderIDs}).Execute() @@ -938,9 +938,11 @@ var _ = Describe("AlbumRepository folder images version", func() { It("includes the parent folder's images (album-root cover with disc subfolders)", func() { discAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) rootAt := discAt.Add(time.Hour) // the root cover is the newest image + // The album root sits under an artist folder (non-empty parent_id): the library root never counts. _, err := GetDBXBuilder().NewQuery( "insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" + - " ('fold-blur-root', 1, '.', 'Album', '', {:root}), ('fold-blur-1', 1, './Album', 'CD1', 'fold-blur-root', {:disc})"). + " ('fold-blur-root', 1, './Artist', 'Album', 'fold-blur-artist', {:root})," + + " ('fold-blur-1', 1, './Artist/Album', 'CD1', 'fold-blur-root', {:disc})"). Bind(map[string]any{"root": rootAt, "disc": discAt}).Execute() Expect(err).ToNot(HaveOccurred()) _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute() @@ -952,6 +954,25 @@ var _ = Describe("AlbumRepository folder images version", func() { Expect(al.FolderImagesUpdatedAt.Equal(rootAt)).To(BeTrue(), "the parent folder's newer cover must win") }) + It("ignores parents when the album's folders do not share a single one (mixed parents)", func() { + // A compilation spread across artist folders has no album root; folding every artist's images + // would suppress the album's hash on any unrelated artist-image change. + at := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) + _, err := GetDBXBuilder().NewQuery( + "insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" + + " ('fold-blur-root', 1, '.', 'ArtistA', 'fold-blur-lib', {:parent})," + + " ('fold-blur-1', 1, './A', 'Songs', 'fold-blur-root', {:own})," + + " ('fold-blur-2', 1, './B', 'Songs', 'fold-blur-other', {:own})"). + Bind(map[string]any{"parent": at.Add(time.Hour), "own": at}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1","fold-blur-2"]' where id = '103'`).Execute() + Expect(err).ToNot(HaveOccurred()) + + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.FolderImagesUpdatedAt).To(HaveValue(Equal(at)), "only the albums' own folders must count") + }) + It("ignores the parent when the album's single folder has images of its own", func() { // Mirrors albumRootParent's first gate: the reader would serve the folder's own cover, so an // unrelated artist-level image must not advance (and suppress) this album's version.