diff --git a/consts/consts.go b/consts/consts.go index f453ac125..b788be9e9 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -124,6 +124,7 @@ const ( // Entity types const ( EntityArtist = "artist" + EntityAlbum = "album" EntityPlaylist = "playlist" EntityRadio = "radio" ) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index ac0f63794..6306aa098 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -27,6 +27,14 @@ func (k *cacheKey) Key() string { ) } +// coverStamp renders the album's manual-cover timestamp for cache keys (0 when unset). +func coverStamp(t *time.Time) int64 { + if t == nil { + return 0 + } + return t.UnixMilli() +} + type imageCache struct { cache.FileCache } diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 8ad07773b..6048d2ca5 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -58,6 +58,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar if imagesUpdateAt != nil { a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) } + if al.CoverArtUpdatedAt != nil { + a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *al.CoverArtUpdatedAt) + } return a, nil } @@ -67,11 +70,14 @@ func (a *albumArtworkReader) Key() string { hashInput = conf.Server.Agents + hashInput } hash := md5.Sum([]byte(hashInput)) + // coverStamp is a separate component: folded into lastUpdate it could be masked + // by a newer updated_at (files with future mtimes). return fmt.Sprintf( - "%s.%x.%t", + "%s.%x.%t.%d", a.cacheKey.Key(), hash, conf.Server.EnableExternalServices, + coverStamp(a.album.CoverArtUpdatedAt), ) } func (a *albumArtworkReader) LastUpdated() time.Time { @@ -79,10 +85,15 @@ func (a *albumArtworkReader) LastUpdated() time.Time { } func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority) + ff := []sourceFunc{a.fromAlbumUploadedImage()} + ff = append(ff, a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)...) return selectImageReader(ctx, a.artID, ff...) } +func (a *albumArtworkReader) fromAlbumUploadedImage() sourceFunc { + return fromLocalFile(a.album.UploadedImagePath()) +} + func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { var ff []sourceFunc for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index fe4a1a545..71d833360 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,14 +3,56 @@ package artwork import ( "context" "errors" + "io" + "os" + "path/filepath" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Album Artwork Reader", func() { + Describe("fromAlbumUploadedImage", func() { + var ( + tempDir string + reader *albumArtworkReader + ) + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tempDir) + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "album"), 0755)).To(Succeed()) + reader = &albumArtworkReader{} + }) + When("the album has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "album", "al-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded album image"), 0600)).To(Succeed()) + reader.album = model.Album{ID: "al-1", UploadedImage: "al-1_test.jpg"} + r, path, err := reader.fromAlbumUploadedImage()() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("uploaded album image")) + r.Close() + }) + }) + When("the album has no uploaded image", func() { + It("returns a nil reader so the next source is tried", func() { + reader.album = model.Album{ID: "al-1"} + r, _, err := reader.fromAlbumUploadedImage()() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + }) + }) + }) + Describe("loadAlbumFoldersPaths", func() { var ( ctx context.Context diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 0f648c987..1c9d7ff78 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -110,15 +110,19 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID if imagesUpdatedAt != nil { r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) } + if al.CoverArtUpdatedAt != nil { + r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *al.CoverArtUpdatedAt) + } return r, nil } func (d *discArtworkReader) Key() string { hash := md5.Sum([]byte(conf.Server.DiscArtPriority)) return fmt.Sprintf( - "%s.%x", + "%s.%x.%d", d.cacheKey.Key(), hash, + coverStamp(d.album.CoverArtUpdatedAt), ) } diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index 8264ee27b..08f8c3ee3 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "time" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -11,6 +12,17 @@ import ( ) var _ = Describe("Disc Artwork Reader", func() { + Describe("Key", func() { + It("changes when the album's cover stamp changes", func() { + r := &discArtworkReader{} + r.album = model.Album{ID: "al-1"} + before := r.Key() + stamp := time.Now() + r.album.CoverArtUpdatedAt = &stamp + Expect(r.Key()).ToNot(Equal(before)) + }) + }) + Describe("extractDiscNumber", func() { DescribeTable("extracts disc number from filename based on glob pattern", func(pattern, filename string, expectedNum int, expectedOk bool) { diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index eac3c5e70..191266671 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -49,14 +49,18 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = *imagesUpdatedAt } + if al.CoverArtUpdatedAt != nil && al.CoverArtUpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *al.CoverArtUpdatedAt + } return a, nil } func (a *mediafileArtworkReader) Key() string { return fmt.Sprintf( - "%s.%t", + "%s.%t.%d", a.cacheKey.Key(), conf.Server.EnableMediaFileCoverArt, + coverStamp(a.album.CoverArtUpdatedAt), ) } func (a *mediafileArtworkReader) LastUpdated() time.Time { diff --git a/core/artwork/reader_mediafile_test.go b/core/artwork/reader_mediafile_test.go new file mode 100644 index 000000000..9976c7a13 --- /dev/null +++ b/core/artwork/reader_mediafile_test.go @@ -0,0 +1,22 @@ +package artwork + +import ( + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaFile Artwork Reader", func() { + Describe("Key", func() { + It("changes when the album's cover stamp changes", func() { + r := &mediafileArtworkReader{} + r.album = model.Album{ID: "al-1"} + before := r.Key() + stamp := time.Now() + r.album.CoverArtUpdatedAt = &stamp + Expect(r.Key()).ToNot(Equal(before)) + }) + }) +}) diff --git a/db/migrations/20260717224137_add_album_uploaded_image.sql b/db/migrations/20260717224137_add_album_uploaded_image.sql new file mode 100644 index 000000000..760ee3e0e --- /dev/null +++ b/db/migrations/20260717224137_add_album_uploaded_image.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE album ADD COLUMN uploaded_image varchar NOT NULL DEFAULT ''; +-- cover_art_updated_at tracks manual cover edits separately from updated_at, so +-- busting the artwork cache never disturbs updated_at-based Recently Added ordering. +ALTER TABLE album ADD COLUMN cover_art_updated_at datetime; + +-- +goose Down +ALTER TABLE album DROP COLUMN uploaded_image; +ALTER TABLE album DROP COLUMN cover_art_updated_at; diff --git a/model/album.go b/model/album.go index ade7f6ee0..f0454752f 100644 --- a/model/album.go +++ b/model/album.go @@ -8,6 +8,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/gohugoio/hashstructure" ) @@ -23,32 +24,34 @@ type Album struct { EmbedArtPath string `structs:"embed_art_path" json:"-"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants // AlbumArtist is the display name used for the album artist. - AlbumArtist string `structs:"album_artist" json:"albumArtist"` - MaxYear int `structs:"max_year" json:"maxYear"` - MinYear int `structs:"min_year" json:"minYear"` - Date string `structs:"date" json:"date,omitempty"` - MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"` - MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"` - OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` - ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` - Compilation bool `structs:"compilation" json:"compilation"` - Comment string `structs:"comment" json:"comment,omitempty"` - SongCount int `structs:"song_count" json:"songCount"` - Duration float32 `structs:"duration" json:"duration"` - Size int64 `structs:"size" json:"size"` - Discs Discs `structs:"discs" json:"discs,omitempty"` - SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` - SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` - OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` - OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` - CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` - MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` - MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` - MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` - MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` - FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album - ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + AlbumArtist string `structs:"album_artist" json:"albumArtist"` + MaxYear int `structs:"max_year" json:"maxYear"` + MinYear int `structs:"min_year" json:"minYear"` + Date string `structs:"date" json:"date,omitempty"` + MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"` + MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` + Compilation bool `structs:"compilation" json:"compilation"` + Comment string `structs:"comment" json:"comment,omitempty"` + SongCount int `structs:"song_count" json:"songCount"` + Duration float32 `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + Discs Discs `structs:"discs" json:"discs,omitempty"` + SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` + SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` + OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` + OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` + CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` + MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` + MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` + MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` + MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` + FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album + ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + UploadedImage string `structs:"-" json:"uploadedImage,omitempty" hash:"ignore"` + CoverArtUpdatedAt *time.Time `structs:"-" json:"coverArtUpdatedAt,omitempty" hash:"ignore"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` @@ -73,6 +76,10 @@ func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } +func (a Album) UploadedImagePath() string { + return UploadedImagePath(consts.EntityAlbum, a.UploadedImage) +} + 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]) @@ -139,6 +146,8 @@ type AlbumRepository interface { Exists(id string) (bool, error) Put(*Album) error UpdateExternalInfo(*Album) error + UpdateImage(id, filename string) error + CountByImage(filename string) (int64, error) Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..f8df981ba 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -2,6 +2,8 @@ package model_test import ( "encoding/json" + "path/filepath" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -25,6 +27,41 @@ var _ = Describe("Album", func() { Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), ) + + Describe("UploadedImagePath", func() { + BeforeEach(func() { + conf.Server.DataFolder = conf.NewDir("/data") + }) + It("returns empty when no image was uploaded", func() { + Expect(Album{ID: "al-1"}.UploadedImagePath()).To(BeEmpty()) + }) + It("returns the path under the data folder when set", func() { + a := Album{ID: "al-1", UploadedImage: "al-1_cover.jpg"} + Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "album", "al-1_cover.jpg"))) + }) + }) + + Describe("CoverArtID", func() { + It("changes when a cover is uploaded, even if updated_at is in the future", func() { + future := time.Now().Add(365 * 24 * time.Hour) + stamp := time.Now() + a := Album{ID: "al-1", UpdatedAt: future} + before := a.CoverArtID().String() + a.CoverArtUpdatedAt = &stamp + Expect(a.CoverArtID().String()).ToNot(Equal(before)) + }) + It("does not collide when updated_at decreases by the cover-stamp delta", func() { + // With an additive combination, updated_at dropping by N while the cover + // stamp advances by N re-emits the same id — the mix must not. + u1 := time.Unix(1_000_000_000, 0) + c1 := time.Unix(2_000_000_000, 0) + u2 := u1.Add(-100 * time.Second) + c2 := c1.Add(100 * time.Second) + id1 := Album{ID: "al-1", UpdatedAt: u1, CoverArtUpdatedAt: &c1}.CoverArtID().String() + id2 := Album{ID: "al-1", UpdatedAt: u2, CoverArtUpdatedAt: &c2}.CoverArtID().String() + Expect(id1).ToNot(Equal(id2)) + }) + }) }) var _ = Describe("Albums", func() { diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..6d595f8bf 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -112,10 +112,17 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { + // The suffix is a cache discriminator, not a date: mix (not max/add) the timestamps so + // the id changes whenever either does — updated_at can decrease and cancel a plain sum. + lastUpdate := al.UpdatedAt + if al.CoverArtUpdatedAt != nil { + mixed := (uint64(al.UpdatedAt.Unix()) ^ (uint64(al.CoverArtUpdatedAt.Unix()) * 0x9E3779B97F4A7C15)) & 0x3FFFFFFFFFFFFFFF + lastUpdate = time.Unix(int64(max(mixed, 1)), 0) + } return ArtworkID{ Kind: KindAlbumArtwork, ID: al.ID, - LastUpdate: al.UpdatedAt, + LastUpdate: lastUpdate, } } diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..8432379a0 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -30,11 +30,14 @@ type MediaFile struct { LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"` LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"` LibraryName string `structs:"-" json:"libraryName" hash:"ignore"` - FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` - Path string `structs:"path" json:"path" hash:"ignore"` - Title string `structs:"title" json:"title"` - Album string `structs:"album" json:"album"` - ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead + // CoverArtUpdatedAt mirrors the album's manual-cover timestamp so clients can + // bust song artwork URLs that resolve to the album cover. + CoverArtUpdatedAt *time.Time `structs:"-" json:"coverArtUpdatedAt,omitempty" hash:"ignore"` + FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` + Path string `structs:"path" json:"path" hash:"ignore"` + Title string `structs:"title" json:"title"` + Album string `structs:"album" json:"album"` + ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead // Artist is the display name used for the artist. Artist string `structs:"artist" json:"artist"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead @@ -129,13 +132,13 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), mf.CoverArtUpdatedAt) } return mf.AlbumCoverArtID() } func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID, CoverArtUpdatedAt: mf.CoverArtUpdatedAt}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..98de68ec2 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -549,6 +549,17 @@ var _ = Describe("MediaFile", func() { Expect(id.Kind).To(Equal(KindAlbumArtwork)) Expect(id.ID).To(Equal(mf.AlbumID)) }) + It("folds CoverArtUpdatedAt into disc and album fallback ids", func() { + stamp := time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC) + disc := MediaFile{ID: "111", AlbumID: "1", DiscNumber: 2} + album := MediaFile{ID: "111", AlbumID: "1"} + Expect(disc.CoverArtID().String()).To(HaveSuffix("_0")) + Expect(album.CoverArtID().String()).To(HaveSuffix("_0")) + disc.CoverArtUpdatedAt = &stamp + album.CoverArtUpdatedAt = &stamp + Expect(disc.CoverArtID().String()).ToNot(HaveSuffix("_0")) + Expect(album.CoverArtID().String()).ToNot(HaveSuffix("_0")) + }) }) Describe("AudioCodec", func() { diff --git a/model/share.go b/model/share.go index ce0846d60..3723f001c 100644 --- a/model/share.go +++ b/model/share.go @@ -2,6 +2,7 @@ package model import ( "cmp" + "slices" "strings" "time" @@ -37,6 +38,10 @@ func (s Share) CoverArtID() ArtworkID { } switch s.ResourceType { case "album": + // Use the loaded album when available, so the public URL busts on cover edits + if i := slices.IndexFunc(s.Albums, func(al Album) bool { return al.ID == ids[0] }); i >= 0 { + return s.Albums[i].CoverArtID() + } return Album{ID: ids[0]}.CoverArtID() case "playlist": return Playlist{ID: ids[0]}.CoverArtID() diff --git a/model/share_test.go b/model/share_test.go new file mode 100644 index 000000000..cf09043bf --- /dev/null +++ b/model/share_test.go @@ -0,0 +1,32 @@ +package model_test + +import ( + "time" + + . "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Share", func() { + Describe("CoverArtID", func() { + It("uses the loaded album, so the public URL busts on cover edits", func() { + s := Share{ResourceType: "album", ResourceIDs: "al-1"} + plain := s.CoverArtID().String() + Expect(s.CoverArtID().ID).To(Equal("al-1")) + + stamp := time.Now() + s.Albums = Albums{{ID: "al-1", CoverArtUpdatedAt: &stamp}} + Expect(s.CoverArtID().ID).To(Equal("al-1")) + Expect(s.CoverArtID().String()).ToNot(Equal(plain)) + }) + It("finds the shared album even when it is not first in the loaded list", func() { + stamp := time.Now() + s := Share{ResourceType: "album", ResourceIDs: "al-2,al-9"} + s.Albums = Albums{{ID: "al-9"}, {ID: "al-2", CoverArtUpdatedAt: &stamp}} + id := s.CoverArtID() + Expect(id.ID).To(Equal("al-2")) + Expect(id.String()).ToNot(HaveSuffix("_0")) + }) + }) +}) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..1eb1b4422 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,9 +3,11 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" + "os" "slices" "strings" "sync" @@ -14,6 +16,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -219,6 +222,31 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { return err } +// UpdateImage is the sole writer of uploaded_image (raw SQL: Put's structs.Map drops the +// structs:"-" field). Bumps cover_art_updated_at, not updated_at, to leave Recently Added put. +func (r *albumRepository) UpdateImage(id, filename string) error { + c, err := r.executeSQL(Update(r.tableName). + Set("uploaded_image", filename). + Set("cover_art_updated_at", time.Now()). + Where(Eq{"id": id})) + if err != nil { + return err + } + if c == 0 { + return model.ErrNotFound + } + return nil +} + +// CountByImage counts album rows referencing an uploaded image filename, unfiltered by +// library — CopyAttributes can leave two rows sharing one file across an album-ID change. +func (r *albumRepository) CountByImage(filename string) (int64, error) { + if filename == "" { + return 0, nil + } + return r.count(Select(), model.QueryOptions{Filters: Eq{"uploaded_image": filename}}) +} + func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on album.library_id = library.id") @@ -257,8 +285,14 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC } func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { + // The cover-stamp guard below needs the source's uploaded_image even when the + // caller didn't request it + selectCols := columns + if slices.Contains(columns, "cover_art_updated_at") && !slices.Contains(columns, "uploaded_image") { + selectCols = append(slices.Clone(columns), "uploaded_image") + } var from dbx.NullStringMap - err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) + err := r.queryOne(Select(selectCols...).From(r.tableName).Where(Eq{"id": fromID}), &from) if err != nil { return fmt.Errorf("getting album to copy fields from: %w", err) } @@ -271,6 +305,14 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) { continue } + // A source without an uploaded cover must not wipe one the destination has, + // nor contribute a stale cover stamp left behind by a cover removal. + if (col == "uploaded_image" || col == "cover_art_updated_at") && (!v.Valid || v.String == "") { + continue + } + if col == "cover_art_updated_at" && (!from["uploaded_image"].Valid || from["uploaded_image"].String == "") { + continue + } to[col] = v } if len(to) == 0 { @@ -353,11 +395,23 @@ on conflict (user_id, item_id, item_type) do update } func (r *albumRepository) purgeEmpty(libraryIDs ...int) error { - del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)") + orphanFilter := "id not in (select distinct(album_id) from media_file)" + + // Collect uploaded image filenames before deleting + sel := Select("uploaded_image").From(r.tableName). + Where(orphanFilter). + Where("uploaded_image <> ''") + del := Delete(r.tableName).Where(orphanFilter) // If libraryIDs are specified, only purge albums from those libraries if len(libraryIDs) > 0 { + sel = sel.Where(Eq{"library_id": libraryIDs}) del = del.Where(Eq{"library_id": libraryIDs}) } + var imageFiles []string + if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("collecting album images for cleanup: %w", err) + } + c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("purging empty albums: %w", err) @@ -365,6 +419,32 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error { if c > 0 { log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c) } + + if len(imageFiles) == 0 { + return nil + } + // CopyAttributes carries the filename (not the file) across album-ID changes, so a + // surviving album may still reference a purged album's image — keep those. + var stillUsed []string + if err := r.queryAllSlice(Select("uploaded_image").From(r.tableName).Where(Eq{"uploaded_image": imageFiles}), &stillUsed); err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("checking album images still in use: %w", err) + } + used := make(map[string]struct{}, len(stillUsed)) + for _, f := range stillUsed { + used[f] = struct{}{} + } + + // Best-effort cleanup of uploaded image files + log.Debug(r.ctx, "Cleaning up album images", "totalImages", len(imageFiles)) + for _, filename := range imageFiles { + if _, ok := used[filename]; ok { + continue + } + path := model.UploadedImagePath(consts.EntityAlbum, filename) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Warn(r.ctx, "Failed to remove album image during GC", "path", path, err) + } + } return nil } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..f30ebea31 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -3,11 +3,14 @@ package persistence import ( "errors" "fmt" + "os" + "path/filepath" "time" "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -41,6 +44,97 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("UpdateImage", func() { + BeforeEach(func() { + Expect(albumRepo.Put(&model.Album{ID: "img-1", Name: "img", LibraryID: 1})).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "img-1"})) + }) + }) + It("sets and clears the uploaded image filename", func() { + Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed()) + got, err := albumRepo.Get("img-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.UploadedImage).To(Equal("img-1_cover.jpg")) + + Expect(albumRepo.UpdateImage("img-1", "")).To(Succeed()) + got, err = albumRepo.Get("img-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.UploadedImage).To(BeEmpty()) + }) + It("is preserved across a full-row Put (structs:\"-\" contract)", func() { + Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed()) + // A scan-style refresh re-Puts the album with a zero-valued UploadedImage. + Expect(albumRepo.Put(&model.Album{ID: "img-1", Name: "img changed", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get("img-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.UploadedImage).To(Equal("img-1_cover.jpg")) + }) + It("returns ErrNotFound for a missing album", func() { + Expect(albumRepo.UpdateImage("does-not-exist", "x.jpg")).To(MatchError(model.ErrNotFound)) + }) + It("counts rows sharing an image filename, ignoring library filters", func() { + Expect(albumRepo.CountByImage("img-1_cover.jpg")).To(Equal(int64(0))) + Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed()) + Expect(albumRepo.CountByImage("img-1_cover.jpg")).To(Equal(int64(1))) + Expect(albumRepo.CountByImage("")).To(Equal(int64(0))) + }) + It("bumps cover_art_updated_at without touching updated_at", func() { + before, err := albumRepo.Get("img-1") + Expect(err).ToNot(HaveOccurred()) + Expect(before.CoverArtUpdatedAt).To(BeNil()) + + Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed()) + + after, err := albumRepo.Get("img-1") + Expect(err).ToNot(HaveOccurred()) + Expect(after.CoverArtUpdatedAt).ToNot(BeNil()) + Expect(*after.CoverArtUpdatedAt).To(BeTemporally("~", time.Now(), time.Minute)) + Expect(after.UpdatedAt).To(Equal(before.UpdatedAt)) + }) + }) + + Describe("purgeEmpty image cleanup", func() { + var artDir string + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tempDir) + artDir = filepath.Join(tempDir, "artwork", "album") + Expect(os.MkdirAll(artDir, 0755)).To(Succeed()) + + _, err := albumRepo.executeSQL(squirrel.Insert("library").Columns("id", "name", "path").Values(99, "purge-lib", "/tmp/purge-lib")) + Expect(err).ToNot(HaveOccurred()) + Expect(albumRepo.Put(&model.Album{ID: "purge-a", Name: "a", LibraryID: 99})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "purge-b", Name: "b", LibraryID: 99})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "purge-keeper", Name: "k", LibraryID: 1})).To(Succeed()) + Expect(albumRepo.UpdateImage("purge-a", "orphan.jpg")).To(Succeed()) + Expect(albumRepo.UpdateImage("purge-b", "shared.jpg")).To(Succeed()) + Expect(albumRepo.UpdateImage("purge-keeper", "shared.jpg")).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artDir, "orphan.jpg"), []byte("x"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artDir, "shared.jpg"), []byte("x"), 0600)).To(Succeed()) + + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"purge-a", "purge-b", "purge-keeper"}})) + _, _ = albumRepo.executeSQL(squirrel.Delete("library").Where(squirrel.Eq{"id": 99})) + }) + }) + + It("removes orphaned image files but keeps ones still referenced elsewhere", func() { + Expect(albumRepo.purgeEmpty(99)).To(Succeed()) + + var ids []string + Expect(albumRepo.queryAllSlice(squirrel.Select("id").From("album").Where(squirrel.Eq{"id": []string{"purge-a", "purge-b"}}), &ids)).To(Succeed()) + Expect(ids).To(BeEmpty(), "purged album rows should be gone") + Expect(albumRepo.queryAllSlice(squirrel.Select("id").From("album").Where(squirrel.Eq{"id": "purge-keeper"}), &ids)).To(Succeed()) + Expect(ids).To(HaveLen(1), "album in another library must survive") + + _, err := os.Stat(filepath.Join(artDir, "orphan.jpg")) + Expect(os.IsNotExist(err)).To(BeTrue(), "orphaned image file should be removed") + Expect(filepath.Join(artDir, "shared.jpg")).To(BeAnExistingFile(), "file still referenced by a surviving album must be kept") + }) + }) + Describe("CopyAttributes", func() { var srcTime, dstTime time.Time BeforeEach(func() { @@ -65,6 +159,52 @@ var _ = Describe("AlbumRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) }) + It("copies uploaded_image from source to destination", func() { + Expect(albumRepo.UpdateImage("copy-src", "copy-src_cover.jpg")).To(Succeed()) + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "uploaded_image")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.UploadedImage).To(Equal("copy-src_cover.jpg")) + }) + It("does not wipe the destination's cover when the source has none", func() { + Expect(albumRepo.UpdateImage("copy-dst", "copy-dst_cover.jpg")).To(Succeed()) + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "uploaded_image", "cover_art_updated_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.UploadedImage).To(Equal("copy-dst_cover.jpg")) + Expect(got.CoverArtUpdatedAt).ToNot(BeNil()) + }) + It("applies the cover-stamp guard even when uploaded_image is not requested", func() { + Expect(albumRepo.UpdateImage("copy-src", "copy-src_cover.jpg")).To(Succeed()) + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "cover_art_updated_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CoverArtUpdatedAt).ToNot(BeNil(), "stamp must copy when the source has a cover") + + Expect(albumRepo.UpdateImage("copy-src", "")).To(Succeed()) + Expect(albumRepo.UpdateImage("copy-zero", "z.jpg")).To(Succeed()) + Expect(albumRepo.UpdateImage("copy-zero", "")).To(Succeed()) + before, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "cover_art_updated_at")).To(Succeed()) + got, err = albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CoverArtUpdatedAt.Equal(*before.CoverArtUpdatedAt)).To(BeTrue(), "coverless source must not contribute a stamp") + }) + It("does not copy a stale cover stamp left behind by a cover removal", func() { + // A removal clears uploaded_image but keeps cover_art_updated_at set + Expect(albumRepo.UpdateImage("copy-src", "copy-src_cover.jpg")).To(Succeed()) + Expect(albumRepo.UpdateImage("copy-src", "")).To(Succeed()) + Expect(albumRepo.UpdateImage("copy-dst", "copy-dst_cover.jpg")).To(Succeed()) + before, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "uploaded_image", "cover_art_updated_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.UploadedImage).To(Equal("copy-dst_cover.jpg")) + Expect(got.CoverArtUpdatedAt.Equal(*before.CoverArtUpdatedAt)).To(BeTrue()) + }) }) Describe("GetCursor", func() { diff --git a/persistence/helpers.go b/persistence/helpers.go index fd6a9a4cd..efc676445 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -90,3 +90,9 @@ func mapSortOrder(tableName, order string) string { repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) return sortOrderRegex.ReplaceAllString(order, repl) } + +// coverArtUpdatedAtCol projects the album's manual-cover timestamp onto rows of the given +// table/alias as a scalar subquery — a join would clash with media_file's column names. +func coverArtUpdatedAtCol(alias string) string { + return "(select cover_art_updated_at from album where album.id = " + alias + ".album_id) as cover_art_updated_at" +} diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ace61610c..9d11b96b9 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -184,7 +184,8 @@ func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { } func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). + sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name", + coverArtUpdatedAtCol("media_file")). LeftJoin("library on media_file.library_id = library.id") sql = r.withAnnotation(sql, "media_file.id") sql = r.withBookmark(sql, "media_file.id") diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index f6a744d8d..8d2f45f1b 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -29,6 +29,27 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("CoverArtUpdatedAt", func() { + It("exposes the album's cover timestamp on its songs", func() { + albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()) + Expect(albumRepo.Put(&model.Album{ID: "cover-al", Name: "cover", LibraryID: 1})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "cover-mf", LibraryID: 1, AlbumID: "cover-al", Path: "test/cover-mf.mp3"})).To(Succeed()) + DeferCleanup(func() { + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": "cover-mf"})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "cover-al"})) + }) + + got, err := mr.Get("cover-mf") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CoverArtUpdatedAt).To(BeNil()) + + Expect(albumRepo.UpdateImage("cover-al", "cover-al_x.jpg")).To(Succeed()) + got, err = mr.Get("cover-mf") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CoverArtUpdatedAt).ToNot(BeNil()) + }) + }) + Describe("GetCursor", func() { It("yields the same media files as GetAll", func() { opts := model.QueryOptions{Sort: "title"} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..435823561 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -314,6 +314,7 @@ func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectB "playlist_tracks.*", "library.path as library_path", "library.name as library_name", + coverArtUpdatedAtCol("f"), ). LeftJoin("annotation on (" + "annotation.item_id = media_file_id" + diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index e51ff8ea6..6580643cd 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -108,6 +108,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) { "rated_at", "f.*", "playlist_tracks.*", + coverArtUpdatedAtCol("f"), ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 36f9ae4a9..8e0615521 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -37,6 +38,35 @@ var _ = Describe("PlaylistTrackRepository", func() { }) }) + Describe("Read", func() { + It("exposes the album's cover timestamp on a single track", func() { + tracks, err := repo.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(tracks).ToNot(BeEmpty()) + albumID := tracks[0].AlbumID + + albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()).(*albumRepository) + Expect(albumRepo.UpdateImage(albumID, "cover-read.jpg")).To(Succeed()) + DeferCleanup(func() { + // Restore both columns so fixture-equality tests stay untouched + _, _ = albumRepo.executeSQL(squirrel.Update("album"). + Set("uploaded_image", "").Set("cover_art_updated_at", nil). + Where(squirrel.Eq{"id": albumID})) + }) + + got, err := repo.Read(tracks[0].ID) + Expect(err).ToNot(HaveOccurred()) + trk, ok := got.(*model.PlaylistTrack) + Expect(ok).To(BeTrue()) + Expect(trk.CoverArtUpdatedAt).ToNot(BeNil()) + + // The list path (tracksQuery/loadTracks) must expose it too + all, err := repo.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(all[0].CoverArtUpdatedAt).ToNot(BeNil()) + }) + }) + Describe("CountAll", func() { It("returns the number of tracks in the playlist", func() { Expect(repo.CountAll()).To(Equal(int64(2))) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 19f16b231..16d97e6d9 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -100,7 +100,9 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { user, _ := request.UserFrom(r.ctx) idField := r.tableName + ".id" - sq := r.newSelect().Columns(r.tableName + ".*") + // Only media files are bookmarkable (see dbMediaFiles scan below), so the cover + // stamp projection is safe to add here + sq := r.newSelect().Columns(r.tableName+".*", coverArtUpdatedAtCol(r.tableName)) sq = r.withAnnotation(sq, idField) sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) var mfs dbMediaFiles // TODO Decouple from media_file diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index 712a928db..6757a04e5 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -70,5 +71,24 @@ var _ = Describe("sqlBookmarks", func() { Expect(mr.DeleteBookmark(songComeTogether.ID)).To(Succeed()) Expect(mr.GetBookmarks()).To(BeEmpty()) }) + + It("exposes the album's cover timestamp on bookmarked songs", func() { + albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()).(*albumRepository) + Expect(mr.AddBookmark(songAntenna.ID, "cover test", 1)).To(Succeed()) + DeferCleanup(func() { + _ = mr.DeleteBookmark(songAntenna.ID) + // Restore both columns so fixture-equality tests stay untouched + _, _ = albumRepo.executeSQL(squirrel.Update("album"). + Set("uploaded_image", "").Set("cover_art_updated_at", nil). + Where(squirrel.Eq{"id": songAntenna.AlbumID})) + }) + + Expect(albumRepo.UpdateImage(songAntenna.AlbumID, "cover-bm.jpg")).To(Succeed()) + + bms, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.CoverArtUpdatedAt).ToNot(BeNil()) + }) }) }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 5e898590b..7ac45d3fc 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -449,8 +449,8 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) } - // Keep created_at field from previous instance of the album - if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil { + // Keep created_at and any uploaded cover from the previous instance of the album + if err := repo.CopyAttributes(prevID, a.ID, "created_at", "uploaded_image", "cover_art_updated_at"); err != nil { // Silently ignore when the previous album is not found if !errors.Is(err, model.ErrNotFound) { log.Warn(p.ctx, "Scanner: Could not copy fields", "from", prevID, "to", a.ID, "album", a.Name, err) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 8c258b833..fc049b054 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -38,7 +38,8 @@ type phaseMissingTracks struct { totalMatched atomic.Uint32 state *scanState processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments - annotationMutex sync.RWMutex // Protects processedAlbumAnnotations + processedAlbumCopies map[string]bool // Track attribute copies, per old→new album pair + annotationMutex sync.RWMutex // Protects the two maps above } func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks { @@ -47,6 +48,7 @@ func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.Da ds: ds, state: state, processedAlbumAnnotations: make(map[string]bool), + processedAlbumCopies: make(map[string]bool), } } @@ -313,8 +315,7 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) } - // Keep created_at field from previous instance of the album, so moved albums - // don't appear in "Recently Added" + // Keep created_at once per target, so moved albums don't resurface in "Recently Added" if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil { if !errors.Is(err, model.ErrNotFound) { log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) @@ -328,6 +329,25 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error } else { log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) } + + // Copy the cover per old→new pair (not per target): with several old albums + // merging into one, any of them may hold it; empty sources never copy. + pairKey := oldAlbumID + "\x00" + newAlbumID + p.annotationMutex.RLock() + copyDone := p.processedAlbumCopies[pairKey] + p.annotationMutex.RUnlock() + if !copyDone { + p.annotationMutex.Lock() + if !p.processedAlbumCopies[pairKey] { + if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "uploaded_image", "cover_art_updated_at"); err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Warn(p.ctx, "Scanner: Could not copy album cover", "from", oldAlbumID, "to", newAlbumID, err) + } + } + p.processedAlbumCopies[pairKey] = true + } + p.annotationMutex.Unlock() + } } p.state.changesDetected.Store(true) diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index d54ceee40..638aa05a8 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -841,6 +841,60 @@ var _ = Describe("phaseMissingTracks", func() { Expect(newAlbum.CreatedAt).To(Equal(originalTime)) }) + It("should preserve an uploaded cover during moves with album change", func() { + missingTrack := model.MediaFile{ + ID: "missing-img", PID: "C", Path: "lib1/song.mp3", + AlbumID: "old-album", LibraryID: 1, + } + matchedTrack := model.MediaFile{ + ID: "matched-img", PID: "C", Path: "lib2/song.mp3", + AlbumID: "new-album", LibraryID: 2, + } + + albumRepo.SetData(model.Albums{ + {ID: "old-album", LibraryID: 1, UploadedImage: "old-album_cover.jpg"}, + {ID: "new-album", LibraryID: 2}, + }) + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed()) + + newAlbum, err := albumRepo.Get("new-album") + Expect(err).ToNot(HaveOccurred()) + Expect(newAlbum.UploadedImage).To(Equal("old-album_cover.jpg")) + }) + + It("should preserve a cover held by any of several old albums merged into one", func() { + // old-1 (no cover) merges first and marks the target as processed for annotations; + // old-2 carries the cover and must still contribute it. + missing1 := model.MediaFile{ID: "mg-1", PID: "MG1", Path: "lib1/a.mp3", AlbumID: "old-1", LibraryID: 1} + matched1 := model.MediaFile{ID: "mt-1", PID: "MG1", Path: "lib2/a.mp3", AlbumID: "new-album", LibraryID: 1} + missing2 := model.MediaFile{ID: "mg-2", PID: "MG2", Path: "lib1/b.mp3", AlbumID: "old-2", LibraryID: 1} + matched2 := model.MediaFile{ID: "mt-2", PID: "MG2", Path: "lib2/b.mp3", AlbumID: "new-album", LibraryID: 1} + + firstTime := time.Date(2018, 3, 1, 0, 0, 0, 0, time.UTC) + albumRepo.SetData(model.Albums{ + {ID: "old-1", LibraryID: 1, CreatedAt: firstTime}, + {ID: "old-2", LibraryID: 1, UploadedImage: "old-2_cover.jpg", CreatedAt: time.Date(2022, 9, 9, 0, 0, 0, 0, time.UTC)}, + {ID: "new-album", LibraryID: 1}, + }) + + for _, mf := range []*model.MediaFile{&missing1, &matched1, &missing2, &matched2} { + _ = ds.MediaFile(ctx).Put(mf) + } + + Expect(phase.moveMatched(matched1, missing1)).To(Succeed()) + Expect(phase.moveMatched(matched2, missing2)).To(Succeed()) + + newAlbum, err := albumRepo.Get("new-album") + Expect(err).ToNot(HaveOccurred()) + Expect(newAlbum.UploadedImage).To(Equal("old-2_cover.jpg")) + // created_at copies once per target: the second merged album must not overwrite it + Expect(newAlbum.CreatedAt).To(Equal(firstTime)) + }) + It("should not copy album created_at when album ID does not change", func() { originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) missingTrack := model.MediaFile{ diff --git a/server/nativeapi/albums.go b/server/nativeapi/albums.go new file mode 100644 index 000000000..469963fcc --- /dev/null +++ b/server/nativeapi/albums.go @@ -0,0 +1,101 @@ +package nativeapi + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addAlbumRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Album{}) + } + r.Route("/album", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Post("/image", api.uploadAlbumImage()) + r.Delete("/image", api.deleteAlbumImage()) + }) + }) +} + +func (api *Router) uploadAlbumImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + api.albumImgOps.Lock() + defer api.albumImgOps.Unlock() + albumID := chi.URLParamFromCtx(ctx, "id") + al, err := api.ds.Album(ctx).Get(albumID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath, err := api.albumImagePathToRemove(ctx, al) + if err != nil { + return err + } + name := al.Name + if oldPath == "" && al.UploadedImage != "" { + // Current file is shared (post album-ID copy): write under a unique name so + // SetImage can't truncate the path the other album still references. + name = fmt.Sprintf("%s-%d", al.Name, time.Now().UnixMilli()) + } + filename, err := api.imgUpload.SetImage(ctx, consts.EntityAlbum, al.ID, name, oldPath, reader, ext) + if err != nil { + return err + } + return api.ds.Album(ctx).UpdateImage(al.ID, filename) + }) +} + +// albumImagePathToRemove returns the album's current image path, or "" when the file is +// shared with another album row (post album-ID copy) and must be left on disk. +func (api *Router) albumImagePathToRemove(ctx context.Context, al *model.Album) (string, error) { + path := al.UploadedImagePath() + if path == "" { + return "", nil + } + refs, err := api.ds.Album(ctx).CountByImage(al.UploadedImage) + if err != nil { + return "", err + } + if refs > 1 { + return "", nil + } + return path, nil +} + +func (api *Router) deleteAlbumImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + api.albumImgOps.Lock() + defer api.albumImgOps.Unlock() + albumID := chi.URLParamFromCtx(ctx, "id") + al, err := api.ds.Album(ctx).Get(albumID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath, err := api.albumImagePathToRemove(ctx, al) + if err != nil { + return err + } + if err := api.imgUpload.RemoveImage(ctx, oldPath); err != nil { + return err + } + return api.ds.Album(ctx).UpdateImage(al.ID, "") + }) +} diff --git a/server/nativeapi/albums_test.go b/server/nativeapi/albums_test.go new file mode 100644 index 000000000..79ffd3a04 --- /dev/null +++ b/server/nativeapi/albums_test.go @@ -0,0 +1,131 @@ +package nativeapi + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album Image Endpoints", func() { + var api *Router + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + api = &Router{ds: &tests.MockDataStore{}, imgUpload: core.NewImageUploadService()} + }) + + DescribeTable("uploadAlbumImage guard", + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload + req := httptest.NewRequest("POST", "/album/al-1/image", nil) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) + w := httptest.NewRecorder() + api.uploadAlbumImage().ServeHTTP(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(expectedStatus)) + }, + Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest), + Entry("enabled, admin passes guard", true, true, http.StatusBadRequest), + Entry("disabled, admin passes guard", false, true, http.StatusBadRequest), + Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), + ) + + DescribeTable("deleteAlbumImage guard", + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload + req := httptest.NewRequest("DELETE", "/album/al-1/image", nil) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) + w := httptest.NewRecorder() + api.deleteAlbumImage().ServeHTTP(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(expectedStatus)) + }, + Entry("enabled, regular user passes guard", true, false, http.StatusNotFound), + Entry("enabled, admin passes guard", true, true, http.StatusNotFound), + Entry("disabled, admin passes guard", false, true, http.StatusNotFound), + Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), + ) +}) + +// tinyPNG is a valid 1x1 PNG, enough to pass handleImageUpload's image validation. +var tinyPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x64, 0x62, 0x06, + 0x00, 0x00, 0x0e, 0x00, 0x07, 0xd7, 0x6f, 0xe4, 0x78, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +} + +type fakeImgUpload struct { + entityID, name, oldPath string +} + +func (f *fakeImgUpload) SetImage(_ context.Context, _ string, entityID string, name string, oldPath string, _ io.Reader, _ string) (string, error) { + f.entityID, f.name, f.oldPath = entityID, name, oldPath + return "stored.png", nil +} + +func (f *fakeImgUpload) RemoveImage(_ context.Context, path string) error { + f.oldPath = path + return nil +} + +var _ = Describe("uploadAlbumImage shared-file handling", func() { + var api *Router + var fake *fakeImgUpload + + upload := func(albumID string) { + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + fw, err := w.CreateFormFile("image", "c.png") + Expect(err).ToNot(HaveOccurred()) + _, _ = fw.Write(tinyPNG) + Expect(w.Close()).To(Succeed()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", albumID) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "u", IsAdmin: true}) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + req := httptest.NewRequest("POST", "/album/"+albumID+"/image", body).WithContext(ctx) + req.Header.Set("Content-Type", w.FormDataContentType()) + + rec := httptest.NewRecorder() + api.uploadAlbumImage().ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusOK)) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds := &tests.MockDataStore{} + ds.Album(GinkgoT().Context()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al-1", Name: "Album One", LibraryID: 1, UploadedImage: "shared.jpg"}, + {ID: "al-2", Name: "Album Two", LibraryID: 1, UploadedImage: "shared.jpg"}, + {ID: "al-solo", Name: "Album Solo", LibraryID: 1, UploadedImage: "solo.jpg"}, + }) + fake = &fakeImgUpload{} + api = &Router{ds: ds, imgUpload: fake} + }) + + It("replaces in place when the file is not shared", func() { + upload("al-solo") + Expect(fake.oldPath).ToNot(BeEmpty(), "sole reference: old file should be removed") + Expect(fake.name).To(Equal("Album Solo")) + }) + + It("keeps the shared file and writes under a unique name", func() { + upload("al-1") + Expect(fake.oldPath).To(BeEmpty(), "shared file must not be removed") + Expect(fake.name).ToNot(Equal("Album One"), "name must be de-duplicated so the derived filename is unique") + Expect(fake.name).To(HavePrefix("Album One-")) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..671910a9b 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -6,6 +6,7 @@ import ( "html" "net/http" "strconv" + "sync" "time" "github.com/deluan/rest" @@ -45,6 +46,9 @@ type Router struct { maintenance core.Maintenance pluginManager PluginManager imgUpload core.ImageUploadService + // Serializes album image check-and-act sequences: shared-file ref-counting is + // check-then-act, and concurrent requests could orphan or clobber a shared file. + albumImgOps sync.Mutex } func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { @@ -66,7 +70,7 @@ func (api *Router) routes() http.Handler { r.Use(server.UpdateLastAccessMiddleware(api.ds)) api.RX(r, "/user", api.users.NewRepository, true) api.R(r, "/song", model.MediaFile{}, false) - api.R(r, "/album", model.Album{}, false) + api.addAlbumRoute(r) api.addArtistRoute(r) api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..2bb1ab3f0 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -134,6 +134,32 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { return nil } +func (m *MockAlbumRepo) UpdateImage(id, filename string) error { + if m.Err { + return errors.New("unexpected error") + } + if al, ok := m.Data[id]; ok { + al.UploadedImage = filename + now := time.Now() + al.CoverArtUpdatedAt = &now + return nil + } + return model.ErrNotFound +} + +func (m *MockAlbumRepo) CountByImage(filename string) (int64, error) { + if m.Err { + return 0, errors.New("unexpected error") + } + var n int64 + for _, al := range m.Data { + if filename != "" && al.UploadedImage == filename { + n++ + } + } + return n, nil +} + func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { m.SearchQuery = q if len(options) > 0 { @@ -176,6 +202,16 @@ func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) e switch col { case "created_at": to.CreatedAt = from.CreatedAt + case "uploaded_image": + // Mirrors the real repo: an empty source never wipes the destination's cover + if from.UploadedImage != "" { + to.UploadedImage = from.UploadedImage + } + case "cover_art_updated_at": + // Mirrors the real repo: a coverless source never contributes a stale stamp + if from.CoverArtUpdatedAt != nil && from.UploadedImage != "" { + to.CoverArtUpdatedAt = from.CoverArtUpdatedAt + } } } if m.CopyAttributesCalls == nil { diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index cec66eb8b..b0de608f9 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -26,6 +26,7 @@ import { CollapsibleComment, DurationField, formatRange, + ImageUploadOverlay, LoveButton, RatingField, SizeField, @@ -74,6 +75,7 @@ const useStyles = makeStyles( width: '15em', minWidth: '15em', }, + position: 'relative', backgroundColor: 'transparent', display: 'flex', alignItems: 'center', @@ -276,6 +278,11 @@ const AlbumDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> +
diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index d2c98bbe7..6909801ab 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -96,18 +96,9 @@ const DiscSubtitleRow = forwardRef( onClick(discNumber) } - const coverArtUrl = subsonic.getDiscCoverArtUrl( - record.albumId, - record.discNumber, - record.updatedAt, - 96, - ) + const coverArtUrl = subsonic.getDiscCoverArtUrl(record, 96) - const fullImageUrl = subsonic.getDiscCoverArtUrl( - record.albumId, - record.discNumber, - record.updatedAt, - ) + const fullImageUrl = subsonic.getDiscCoverArtUrl(record) const handleOpenLightbox = useCallback( (e) => { diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index d6ab7484b..4594343c4 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -91,6 +91,7 @@ const mapToAudioLists = (item) => { { id: trackId, updatedAt: item.updatedAt, + coverArtUpdatedAt: item.coverArtUpdatedAt, album: item.album, }, 300, diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..e4a965f3a 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -80,9 +80,16 @@ const getAvatarUrl = (username, size) => }), ) +// Cache-buster from both timestamps (cover uploads bump coverArtUpdatedAt); joined, +// not max'd, so the URL changes even when updatedAt is newer (future file mtimes). +const artCacheKey = (record) => + [record.updatedAt, record.coverArtUpdatedAt].filter(Boolean).join('|') || + undefined + const getCoverArtUrl = (record, size, square) => { + const cacheKey = artCacheKey(record) const options = { - ...(record.updatedAt && { _: record.updatedAt }), + ...(cacheKey && { _: cacheKey }), ...(size && { size }), ...(square && { square }), } @@ -103,13 +110,18 @@ const getCoverArtUrl = (record, size, square) => { } } -const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => { +const getDiscCoverArtUrl = (record, size) => { + const cacheKey = artCacheKey(record) const options = { - ...(updatedAt && { _: updatedAt }), + ...(cacheKey && { _: cacheKey }), ...(size && { size }), } return baseUrl( - url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options), + url( + 'getCoverArt', + 'dc-' + record.albumId + ':' + record.discNumber, + options, + ), ) } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index ad4764c24..0634e70c2 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -79,6 +79,22 @@ describe('getCoverArtUrl', () => { expect(url).toContain('square=true') }) + it('should include coverArtUpdatedAt in the cache key', () => { + const albumRecord = { + id: 'album-123', + albumArtist: 'Test Artist', + updatedAt: '2023-01-01T00:00:00Z', + coverArtUpdatedAt: '2024-06-01T00:00:00Z', + } + + const url = subsonic.getCoverArtUrl(albumRecord) + + expect(url).toContain('al-album-123') + expect(url).toContain( + '_=2023-01-01T00%3A00%3A00Z%7C2024-06-01T00%3A00%3A00Z', + ) + }) + it('should return media file cover art URL for records with album', () => { const songRecord = { id: 'song-123', @@ -139,9 +155,11 @@ describe('getDiscCoverArtUrl', () => { it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => { const url = subsonic.getDiscCoverArtUrl( - 'album-123', - 2, - '2023-01-01T00:00:00Z', + { + albumId: 'album-123', + discNumber: 2, + updatedAt: '2023-01-01T00:00:00Z', + }, 48, ) @@ -152,7 +170,10 @@ describe('getDiscCoverArtUrl', () => { }) it('should handle missing updatedAt', () => { - const url = subsonic.getDiscCoverArtUrl('album-123', 1, undefined, 48) + const url = subsonic.getDiscCoverArtUrl( + { albumId: 'album-123', discNumber: 1 }, + 48, + ) expect(url).toContain('id=dc-album-123%3A1') expect(url).toContain('size=48') @@ -160,16 +181,30 @@ describe('getDiscCoverArtUrl', () => { }) it('should handle missing size', () => { - const url = subsonic.getDiscCoverArtUrl( - 'album-123', - 1, - '2023-01-01T00:00:00Z', - ) + const url = subsonic.getDiscCoverArtUrl({ + albumId: 'album-123', + discNumber: 1, + updatedAt: '2023-01-01T00:00:00Z', + }) expect(url).toContain('id=dc-album-123%3A1') expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') expect(url).not.toContain('size=') }) + + it('should include coverArtUpdatedAt in the cache key', () => { + const url = subsonic.getDiscCoverArtUrl({ + albumId: 'album-123', + discNumber: 1, + updatedAt: '2023-01-01T00:00:00Z', + coverArtUpdatedAt: '2024-06-01T00:00:00Z', + }) + + expect(url).toContain('id=dc-album-123%3A1') + expect(url).toContain( + '_=2023-01-01T00%3A00%3A00Z%7C2024-06-01T00%3A00%3A00Z', + ) + }) }) describe('getAvatarUrl', () => {