From a115726e7161e1e0cdc436f07b09427d7febe4c6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 23:11:14 -0400 Subject: [PATCH] feat(model): add blur_hash columns and ArtworkUpdatedAt version methods --- .../20260716030719_add_artwork_blur_hash.sql | 15 ++++++++++++++ model/album.go | 17 ++++++++++++++++ model/album_test.go | 20 +++++++++++++++++++ model/artist.go | 16 +++++++++++++++ model/artist_test.go | 16 +++++++++++++++ model/playlist.go | 7 +++++++ model/playlist_test.go | 9 +++++++++ 7 files changed, 100 insertions(+) create mode 100644 db/migrations/20260716030719_add_artwork_blur_hash.sql diff --git a/db/migrations/20260716030719_add_artwork_blur_hash.sql b/db/migrations/20260716030719_add_artwork_blur_hash.sql new file mode 100644 index 000000000..a7b75dfc5 --- /dev/null +++ b/db/migrations/20260716030719_add_artwork_blur_hash.sql @@ -0,0 +1,15 @@ +-- +goose Up +alter table album add column blur_hash varchar; +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_updated_at datetime; +alter table playlist add column blur_hash varchar; +alter table playlist add column blur_hash_updated_at datetime; + +-- +goose Down +alter table album drop column blur_hash; +alter table album drop column blur_hash_updated_at; +alter table artist drop column blur_hash; +alter table artist drop column blur_hash_updated_at; +alter table playlist drop column blur_hash; +alter table playlist drop column blur_hash_updated_at; diff --git a/model/album.go b/model/album.go index ade7f6ee0..6030e033e 100644 --- a/model/album.go +++ b/model/album.go @@ -67,12 +67,29 @@ type Album struct { ImportedAt time.Time `structs:"imported_at" json:"importedAt" hash:"ignore"` // When this album was imported/updated CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album + + // BlurHash of the album cover, computed asynchronously from the served artwork. + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` } func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } +// ArtworkUpdatedAt is the album's artwork version: the newest row timestamp that can affect +// which cover image is served (scan updates, imports, agent-fetched external images). +func (a Album) ArtworkUpdatedAt() time.Time { + t := a.UpdatedAt + if a.ImportedAt.After(t) { + t = a.ImportedAt + } + if a.ExternalInfoUpdatedAt != nil && a.ExternalInfoUpdatedAt.After(t) { + t = *a.ExternalInfoUpdatedAt + } + return t +} + func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..3caa359f9 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -2,6 +2,7 @@ package model_test import ( "encoding/json" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -50,3 +51,22 @@ var _ = Describe("Albums", func() { }) }) }) + +var _ = Describe("Album.ArtworkUpdatedAt", func() { + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + later := base.Add(24 * time.Hour) + latest := base.Add(48 * time.Hour) + + It("returns UpdatedAt when it is the newest", func() { + al := Album{UpdatedAt: later, ImportedAt: base} + Expect(al.ArtworkUpdatedAt()).To(Equal(later)) + }) + It("returns ImportedAt when it is the newest", func() { + al := Album{UpdatedAt: base, ImportedAt: later} + Expect(al.ArtworkUpdatedAt()).To(Equal(later)) + }) + It("returns ExternalInfoUpdatedAt when it is the newest", func() { + al := Album{UpdatedAt: base, ImportedAt: later, ExternalInfoUpdatedAt: &latest} + Expect(al.ArtworkUpdatedAt()).To(Equal(latest)) + }) +}) diff --git a/model/artist.go b/model/artist.go index f9c4bffd5..865355623 100644 --- a/model/artist.go +++ b/model/artist.go @@ -41,6 +41,9 @@ type Artist struct { CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` + + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-"` } type ArtistStats struct { @@ -63,6 +66,19 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } +// ArtworkUpdatedAt is the artist's artwork version; images often arrive via external agents, +// which bump ExternalInfoUpdatedAt rather than UpdatedAt. +func (a Artist) ArtworkUpdatedAt() time.Time { + var t time.Time + if a.UpdatedAt != nil { + t = *a.UpdatedAt + } + if a.ExternalInfoUpdatedAt != nil && a.ExternalInfoUpdatedAt.After(t) { + t = *a.ExternalInfoUpdatedAt + } + return t +} + func (a Artist) UploadedImagePath() string { return UploadedImagePath(consts.EntityArtist, a.UploadedImage) } diff --git a/model/artist_test.go b/model/artist_test.go index db897d3d5..135576e81 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -2,6 +2,7 @@ package model_test import ( "path/filepath" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -28,3 +29,18 @@ var _ = Describe("Artist", func() { }) }) }) + +var _ = Describe("Artist.ArtworkUpdatedAt", func() { + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + later := base.Add(24 * time.Hour) + + It("handles nil timestamps", func() { + Expect(model.Artist{}.ArtworkUpdatedAt()).To(Equal(time.Time{})) + }) + It("returns UpdatedAt when newest", func() { + Expect(model.Artist{UpdatedAt: &later, ExternalInfoUpdatedAt: &base}.ArtworkUpdatedAt()).To(Equal(later)) + }) + It("returns ExternalInfoUpdatedAt when newest", func() { + Expect(model.Artist{UpdatedAt: &base, ExternalInfoUpdatedAt: &later}.ArtworkUpdatedAt()).To(Equal(later)) + }) +}) diff --git a/model/playlist.go b/model/playlist.go index 40adb8d0a..60ca96a78 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -30,6 +30,9 @@ type Playlist struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-"` + // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"` @@ -39,6 +42,10 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +func (pls Playlist) ArtworkUpdatedAt() time.Time { + return pls.UpdatedAt +} + func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil diff --git a/model/playlist_test.go b/model/playlist_test.go index 9ed24f00f..5a51f7ef6 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,6 +1,8 @@ package model_test import ( + "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -44,3 +46,10 @@ var _ = Describe("Playlist", func() { }) }) }) + +var _ = Describe("Playlist.ArtworkUpdatedAt", func() { + It("returns UpdatedAt", func() { + now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + Expect(model.Playlist{UpdatedAt: now}.ArtworkUpdatedAt()).To(Equal(now)) + }) +})