mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-03 08:57:53 +01:00
feat(artwork): advertise album art for tracks whose embedded cover is the album's
Most albums embed the same picture in every track, so clients requesting per-track cover art (mf- ids) download and cache the same image once per song. This change hashes the embedded picture at scan time (XXH3 of the bytes, the same digest the artwork pipeline uses) and stores it on media_file.embed_art_hash. ToAlbum stores the hash of the track it already picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash. A new MediaFile.HasOwnCoverArt replaces the scattered "HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals its album's now returns the disc/album artwork id from CoverArtID, so the Subsonic, Jellyfin and native responses point every matching song at one cached image. Serving a stale mf- URL follows the same rule, so what is advertised and what is served agree. Tracks with a different embedded picture (compilations, per-track art) keep their own ids. Rows scanned before this change have an empty hash and keep the previous behavior until a full scan; no config option is added. The album hash is hydrated onto tracks with one batched PK lookup per page, only when the page has hashed tracks. BestImageIndex is exported from core/artwork so the scanner hashes the same picture the resolver serves.
This commit is contained in:
parent
1ceb25c6c1
commit
af984bc817
21 changed files with 280 additions and 82 deletions
|
|
@ -21,6 +21,7 @@ type Album struct {
|
|||
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
EmbedArtPath string `structs:"embed_art_path" json:"-"`
|
||||
EmbedArtHash string `structs:"embed_art_hash" json:"-"` // Picture hash of the EmbedArtPath track
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -110,6 +110,11 @@ func ParseArtworkID(id string) (ArtworkID, error) {
|
|||
return parsedID, nil
|
||||
}
|
||||
|
||||
// FormatImageHash renders an XXH3-64 digest as the 16-hex image hash used in artwork ids and rows.
|
||||
func FormatImageHash(sum uint64) string {
|
||||
return fmt.Sprintf("%016x", sum)
|
||||
}
|
||||
|
||||
// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash.
|
||||
func isImageHash(s string) bool {
|
||||
if len(s) != 16 {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ type MediaFile struct {
|
|||
// AlbumImage is the parent album's artwork state, hydrated alongside the track's own so a
|
||||
// song's Jellyfin album-art tag can be pixel-versioned without a second query.
|
||||
AlbumImage ItemImage `structs:"-" json:"-" hash:"ignore"`
|
||||
// AlbumEmbedArtHash is the album's embedded-cover hash, hydrated so HasOwnCoverArt can compare.
|
||||
AlbumEmbedArtHash string `structs:"-" json:"-" hash:"ignore"`
|
||||
|
||||
ID string `structs:"id" json:"id" hash:"ignore"`
|
||||
PID string `structs:"pid" json:"-" hash:"ignore"`
|
||||
|
|
@ -48,6 +50,7 @@ type MediaFile struct {
|
|||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
EmbedArtHash string `structs:"embed_art_hash" json:"-"` // XXH3 of the embedded picture bytes
|
||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
||||
DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
|
||||
|
|
@ -132,12 +135,19 @@ func (mf MediaFile) ContentType() string {
|
|||
return mime.TypeByExtension("." + mf.Suffix)
|
||||
}
|
||||
|
||||
// HasOwnCoverArt reports whether the track serves its own embedded art. A picture identical to the
|
||||
// album's embedded cover defers to the album id instead, so clients cache one image per album.
|
||||
func (mf MediaFile) HasOwnCoverArt() bool {
|
||||
if !mf.HasCoverArt || !conf.Server.EnableMediaFileCoverArt {
|
||||
return false
|
||||
}
|
||||
return mf.EmbedArtHash == "" || mf.EmbedArtHash != mf.AlbumEmbedArtHash
|
||||
}
|
||||
|
||||
func (mf MediaFile) CoverArtID() ArtworkID {
|
||||
// If it has a cover art, return it (if feature is disabled, skip)
|
||||
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
|
||||
if mf.HasOwnCoverArt() {
|
||||
return artworkIDFromMediaFile(mf)
|
||||
}
|
||||
// Otherwise fallback to disc (if available) or album cover
|
||||
return mf.DiscCoverArtID()
|
||||
}
|
||||
|
||||
|
|
@ -337,9 +347,9 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
||||
|
||||
a.Missing = true
|
||||
embedArtPath := ""
|
||||
embedArtDisc := 0
|
||||
for _, m := range mfs {
|
||||
var embedArt *MediaFile
|
||||
for i := range mfs {
|
||||
m := &mfs[i]
|
||||
// We assume these attributes are all the same for all songs in an album
|
||||
a.ID = m.AlbumID
|
||||
a.LibraryID = m.LibraryID
|
||||
|
|
@ -375,8 +385,7 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||
tags = append(tags, m.Tags.FlattenAll()...)
|
||||
a.Participants.Merge(m.Participants)
|
||||
|
||||
// Find the MediaFile with cover art and the lowest disc number to use for album cover
|
||||
embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m)
|
||||
embedArt = firstArtTrack(embedArt, m)
|
||||
|
||||
if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" {
|
||||
a.ExplicitStatus = "c"
|
||||
|
|
@ -389,7 +398,10 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||
a.Missing = a.Missing && m.Missing
|
||||
}
|
||||
|
||||
a.EmbedArtPath = embedArtPath
|
||||
if embedArt != nil {
|
||||
a.EmbedArtPath = embedArt.Path
|
||||
a.EmbedArtHash = embedArt.EmbedArtHash
|
||||
}
|
||||
a.SetTags(tags)
|
||||
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
|
||||
a.Date, _ = allOrNothing(dates)
|
||||
|
|
@ -495,26 +507,19 @@ func fixAlbumArtist(a *Album) {
|
|||
}
|
||||
}
|
||||
|
||||
// firstArtPath determines which media file path should be used for album artwork
|
||||
// based on disc number (preferring lower disc numbers) and path (for consistency)
|
||||
func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) {
|
||||
// firstArtTrack picks the media file whose embedded picture serves as the album cover, preferring
|
||||
// lower disc numbers and then path order for consistency.
|
||||
func firstArtTrack(current, m *MediaFile) *MediaFile {
|
||||
if !m.HasCoverArt {
|
||||
return currentPath, currentDisc
|
||||
return current
|
||||
}
|
||||
|
||||
// If current has no disc number (currentDisc == 0) or new file has lower disc number
|
||||
if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) {
|
||||
return m.Path, m.DiscNumber
|
||||
if current == nil || current.DiscNumber == 0 || (m.DiscNumber < current.DiscNumber && m.DiscNumber > 0) {
|
||||
return m
|
||||
}
|
||||
|
||||
// If disc numbers are equal, use path for ordering
|
||||
if m.DiscNumber == currentDisc {
|
||||
if m.Path < currentPath || currentPath == "" {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
if m.DiscNumber == current.DiscNumber && m.Path < current.Path {
|
||||
return m
|
||||
}
|
||||
|
||||
return currentPath, currentDisc
|
||||
return current
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ var _ = Describe("MediaFiles", func() {
|
|||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
MbzReleaseGroupID: "MbzReleaseGroupID",
|
||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2",
|
||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, EmbedArtHash: "picturehash2", Path: "music2/file2.mp3", FolderID: "Folder2",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -53,6 +53,7 @@ var _ = Describe("MediaFiles", func() {
|
|||
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
||||
Expect(album.Compilation).To(BeTrue())
|
||||
Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3"))
|
||||
Expect(album.EmbedArtHash).To(Equal("picturehash2"))
|
||||
Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
|
||||
})
|
||||
})
|
||||
|
|
@ -587,6 +588,28 @@ var _ = Describe("MediaFile", func() {
|
|||
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
It("returns its album id if its embedded picture is the album's cover", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, EmbedArtHash: "samepic", AlbumEmbedArtHash: "samepic"}
|
||||
Expect(mf.HasOwnCoverArt()).To(BeFalse())
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
It("returns disc art id if its embedded picture is the album's cover and DiscNumber > 0", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 2, EmbedArtHash: "samepic", AlbumEmbedArtHash: "samepic"}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindDiscArtwork))
|
||||
Expect(id.ID).To(Equal("1:2"))
|
||||
})
|
||||
It("returns its own id if its embedded picture differs from the album's cover", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, EmbedArtHash: "ownpic", AlbumEmbedArtHash: "samepic"}
|
||||
Expect(mf.HasOwnCoverArt()).To(BeTrue())
|
||||
Expect(mf.CoverArtID().Kind).To(Equal(KindMediaFileArtwork))
|
||||
})
|
||||
It("returns its own id if its picture hash is unknown", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, AlbumEmbedArtHash: "samepic"}
|
||||
Expect(mf.CoverArtID().Kind).To(Equal(KindMediaFileArtwork))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AudioCodec", func() {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
|||
|
||||
// General properties
|
||||
mf.HasCoverArt = md.HasPicture()
|
||||
mf.EmbedArtHash = md.PictureHash()
|
||||
mf.Duration = md.Length()
|
||||
mf.BitRate = md.AudioProperties().BitRate
|
||||
mf.SampleRate = md.AudioProperties().SampleRate
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type Info struct {
|
|||
Tags model.RawTags
|
||||
AudioProperties AudioProperties
|
||||
HasPicture bool
|
||||
PictureHash string
|
||||
}
|
||||
|
||||
type FileInfo interface {
|
||||
|
|
@ -69,20 +70,22 @@ func NewPair(key, value string) string {
|
|||
|
||||
func New(filePath string, info Info) Metadata {
|
||||
return Metadata{
|
||||
filePath: filePath,
|
||||
fileInfo: info.FileInfo,
|
||||
tags: clean(filePath, info.Tags),
|
||||
audioProps: info.AudioProperties,
|
||||
hasPicture: info.HasPicture,
|
||||
filePath: filePath,
|
||||
fileInfo: info.FileInfo,
|
||||
tags: clean(filePath, info.Tags),
|
||||
audioProps: info.AudioProperties,
|
||||
hasPicture: info.HasPicture,
|
||||
pictureHash: info.PictureHash,
|
||||
}
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
filePath string
|
||||
fileInfo FileInfo
|
||||
tags model.Tags
|
||||
audioProps AudioProperties
|
||||
hasPicture bool
|
||||
filePath string
|
||||
fileInfo FileInfo
|
||||
tags model.Tags
|
||||
audioProps AudioProperties
|
||||
hasPicture bool
|
||||
pictureHash string
|
||||
}
|
||||
|
||||
func (md Metadata) FilePath() string { return md.filePath }
|
||||
|
|
@ -95,6 +98,7 @@ func (md Metadata) Suffix() string {
|
|||
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
|
||||
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
|
||||
func (md Metadata) HasPicture() bool { return md.hasPicture }
|
||||
func (md Metadata) PictureHash() string { return md.pictureHash }
|
||||
func (md Metadata) All() model.Tags { return md.tags }
|
||||
func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] }
|
||||
func (md Metadata) String(key model.TagName) string { return md.first(key) }
|
||||
|
|
|
|||
|
|
@ -36,8 +36,9 @@ var _ = Describe("Metadata", func() {
|
|||
Duration: time.Minute * 3,
|
||||
BitRate: 320,
|
||||
},
|
||||
HasPicture: true,
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
HasPicture: true,
|
||||
PictureHash: "0123456789abcdef",
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ var _ = Describe("Metadata", func() {
|
|||
Expect(md.AudioProperties()).To(Equal(props.AudioProperties))
|
||||
Expect(md.Length()).To(Equal(float32(3 * 60)))
|
||||
Expect(md.HasPicture()).To(Equal(props.HasPicture))
|
||||
Expect(md.PictureHash()).To(Equal(props.PictureHash))
|
||||
Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"}))
|
||||
Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist"))
|
||||
Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234)))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue