mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02:00
Compare commits
23 commits
master
...
album-cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d509d8b2f8 | ||
|
|
a5d3d615d8 | ||
|
|
5a90e17693 | ||
|
|
a79e670740 | ||
|
|
b0aff3c9d6 | ||
|
|
5418e88f8e | ||
|
|
7c27e9d5a3 | ||
|
|
f4c1431bdf | ||
|
|
ee7c4d3b88 | ||
|
|
cbc12034a4 | ||
|
|
83ae195c10 | ||
|
|
f498dfde7d | ||
|
|
0ffcb38eae | ||
|
|
223efdf020 | ||
|
|
e8946b8810 | ||
|
|
35d08a25a3 | ||
|
|
319662174e | ||
|
|
27a0841065 | ||
|
|
940dbe4c0a | ||
|
|
37c837425c | ||
|
|
75c11cab81 | ||
|
|
720993fe7c | ||
|
|
0d8ba90b13 |
38 changed files with 983 additions and 72 deletions
|
|
@ -124,6 +124,7 @@ const (
|
|||
// Entity types
|
||||
const (
|
||||
EntityArtist = "artist"
|
||||
EntityAlbum = "album"
|
||||
EntityPlaylist = "playlist"
|
||||
EntityRadio = "radio"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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), ",") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
22
core/artwork/reader_mediafile_test.go
Normal file
22
core/artwork/reader_mediafile_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
32
model/share_test.go
Normal file
32
model/share_test.go
Normal file
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
101
server/nativeapi/albums.go
Normal file
101
server/nativeapi/albums.go
Normal file
|
|
@ -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, "")
|
||||
})
|
||||
}
|
||||
131
server/nativeapi/albums_test.go
Normal file
131
server/nativeapi/albums_test.go
Normal file
|
|
@ -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-"))
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
<ImageUploadOverlay
|
||||
entityType="album"
|
||||
entityId={record.id}
|
||||
hasUploadedImage={!!record.uploadedImage}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
<CardContent className={classes.content}>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ const mapToAudioLists = (item) => {
|
|||
{
|
||||
id: trackId,
|
||||
updatedAt: item.updatedAt,
|
||||
coverArtUpdatedAt: item.coverArtUpdatedAt,
|
||||
album: item.album,
|
||||
},
|
||||
300,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue