diff --git a/db/migrations/20260716030719_add_artwork_blur_hash.sql b/db/migrations/20260716030719_add_artwork_blur_hash.sql index a7b75dfc5..40c0c4014 100644 --- a/db/migrations/20260716030719_add_artwork_blur_hash.sql +++ b/db/migrations/20260716030719_add_artwork_blur_hash.sql @@ -1,9 +1,10 @@ -- +goose Up -alter table album add column blur_hash varchar; +-- blur_hash is not null default '' so NULLs never reach the Go string field; '' means "not computed". +alter table album add column blur_hash varchar not null default ''; alter table album add column blur_hash_updated_at datetime; -alter table artist add column blur_hash varchar; +alter table artist add column blur_hash varchar not null default ''; alter table artist add column blur_hash_updated_at datetime; -alter table playlist add column blur_hash varchar; +alter table playlist add column blur_hash varchar not null default ''; alter table playlist add column blur_hash_updated_at datetime; -- +goose Down diff --git a/model/album.go b/model/album.go index 6030e033e..9122276ff 100644 --- a/model/album.go +++ b/model/album.go @@ -156,6 +156,7 @@ type AlbumRepository interface { Exists(id string) (bool, error) Put(*Album) error UpdateExternalInfo(*Album) error + UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) diff --git a/model/artist.go b/model/artist.go index 865355623..b917023f8 100644 --- a/model/artist.go +++ b/model/artist.go @@ -103,6 +103,7 @@ type ArtistRepository interface { Exists(id string) (bool, error) Put(m *Artist, colsToUpdate ...string) error UpdateExternalInfo(a *Artist) error + UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) diff --git a/model/playlist.go b/model/playlist.go index 60ca96a78..6c34b1a3d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -142,6 +142,7 @@ type PlaylistRepository interface { GetAll(options ...QueryOptions) (Playlists, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) + UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository GetPlaylists(mediaFileId string) (Playlists, error) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..35e5dba8f 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -213,6 +213,16 @@ func (r *albumRepository) Put(al *model.Album) error { return nil } +// UpdateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r *albumRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + // TODO Move external metadata to a separated table func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { _, err := r.put(al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..bbb821ab8 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -899,3 +899,34 @@ func _p(id, name string, sortName ...string) model.Participant { } return p } + +var _ = Describe("AlbumRepository.UpdateBlurHash", func() { + var repo model.AlbumRepository + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + repo = NewAlbumRepository(ctx, GetDBXBuilder()) + DeferCleanup(func() { + _, err := GetDBXBuilder().NewQuery("update album set blur_hash = '', blur_hash_updated_at = null").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + It("persists the hash and its artwork version snapshot", func() { + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.BlurHash).To(BeEmpty()) + + version := time.Date(2024, 5, 1, 10, 30, 0, 0, time.UTC) + Expect(repo.UpdateBlurHash(al.ID, "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", version)).To(Succeed()) + + updated, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.BlurHash).To(Equal("LKO2?U%2Tw=w]~RBVZRi};RPxuwH")) + Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) + // Round-trip through SQLite must preserve equality — the DTO layer compares with Equal. + Expect(updated.BlurHashUpdatedAt.Equal(version)).To(BeTrue()) + // The targeted update must not touch the row's own timestamps. + Expect(updated.UpdatedAt).To(Equal(al.UpdatedAt)) + }) +}) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..519e5cb94 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -232,6 +232,16 @@ func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { return err } +// UpdateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r *artistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error { dba := &dbArtist{Artist: a} _, err := r.put(a.ID, dba, diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..87af440c4 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -155,6 +155,16 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl return pls, nil } +// UpdateBlurHash is a targeted update: a full-row put would race with playlist sync. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r *playlistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { return r.findBy(Eq{"path": path}) } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..d68cf2904 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -29,6 +29,17 @@ func (m *MockAlbumRepo) SetError(err bool) { m.Err = err } +func (m *MockAlbumRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if al, ok := m.Data[id]; ok { + al.BlurHash = blurHash + al.BlurHashUpdatedAt = &artworkUpdatedAt + } + return nil +} + func (m *MockAlbumRepo) SetData(albums model.Albums) { m.Data = make(map[string]*model.Album, len(albums)) m.All = albums diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index e6ea7aea4..192b48ba5 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -32,6 +32,17 @@ func (m *MockArtistRepo) SetData(artists model.Artists) { } } +func (m *MockArtistRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if ar, ok := m.Data[id]; ok { + ar.BlurHash = blurHash + ar.BlurHashUpdatedAt = &artworkUpdatedAt + } + return nil +} + func (m *MockArtistRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("Error!") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 8f8842c8e..6d6efcda8 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -42,6 +42,17 @@ func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { } } +func (m *MockPlaylistRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if pl, ok := m.Data[id]; ok { + pl.BlurHash = blurHash + pl.BlurHashUpdatedAt = &artworkUpdatedAt + } + return nil +} + func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { if len(options) > 0 { m.Options = options[0]