fix(thumbnails): pick audio cover art deterministically

Switch to the dschmidt/tag fork and select the front cover (picture type
0x03) when tagged, else the first available picture, instead of the lib's
single non-deterministic Picture().
This commit is contained in:
Dominik Schmidt
2026-07-30 16:06:35 +02:00
parent c7c0551dbb
commit a3845dd2dc
12 changed files with 115 additions and 7 deletions

2
go.mod
View File

@@ -412,3 +412,5 @@ replace go-micro.dev/v4 => github.com/butonic/go-micro/v4 v4.11.1-0.202411151126
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible
replace github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/opencloud-eu/go-micro-plugins/v4/store/nats-js-kv v0.0.0-20250512152754-23325793059a
replace github.com/dhowden/tag => github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973

4
go.sum
View File

@@ -297,8 +297,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
@@ -310,6 +308,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973 h1:XnAwTTbblPJI6OVkRwqVA2MGjre29B/+wsi16lQfJ+E=
github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ=

View File

@@ -89,7 +89,7 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
return nil, err
}
picture := m.Picture()
picture := selectCoverArt(m.Pictures())
if picture == nil {
return nil, thumbnailerErrors.ErrNoImageFromAudioFile
}
@@ -102,6 +102,27 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
return converter.Convert(bytes.NewReader(picture.Data))
}
// frontCoverType is the ID3/APIC picture type byte for a front cover, shared by
// the ID3v2, FLAC and OGG tag formats (see github.com/dhowden/tag pictureTypes).
const frontCoverType byte = 0x03
// selectCoverArt deterministically picks the cover art from an audio file's
// embedded pictures: the explicitly tagged front cover if present, otherwise
// the first available picture. Many taggers store the cover as type "Other"
// (0x00) instead of "Cover (front)" (0x03), so the fallback is the common case.
// It returns nil when there are no pictures.
func selectCoverArt(pictures []tag.Picture) *tag.Picture {
if len(pictures) == 0 {
return nil
}
for i := range pictures {
if pictures[i].RawType == frontCoverType {
return &pictures[i]
}
}
return &pictures[0]
}
// TxtToImageConverter is a converter for the text file
type TxtToImageConverter struct {
fontLoader *FontLoader

View File

@@ -6,6 +6,7 @@ import (
"os"
"testing"
"github.com/dhowden/tag"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
@@ -127,6 +128,28 @@ var _ = Describe("ImageDecoder", func() {
})
})
Describe("selectCoverArt", func() {
front := tag.Picture{RawType: frontCoverType, MIMEType: "image/jpeg", Data: []byte("front")}
back := tag.Picture{RawType: 0x04, MIMEType: "image/jpeg", Data: []byte("back")}
other := tag.Picture{RawType: 0x00, MIMEType: "image/png", Data: []byte("other")}
It("returns nil when there are no pictures", func() {
Expect(selectCoverArt(nil)).To(BeNil())
Expect(selectCoverArt([]tag.Picture{})).To(BeNil())
})
It("prefers the front cover regardless of order", func() {
got := selectCoverArt([]tag.Picture{back, other, front})
Expect(got).ToNot(BeNil())
Expect(got.RawType).To(Equal(frontCoverType))
Expect(got.Data).To(Equal([]byte("front")))
})
It("falls back to the first picture when none is a front cover", func() {
got := selectCoverArt([]tag.Picture{other, back})
Expect(got).ToNot(BeNil())
Expect(got.Data).To(Equal([]byte("other")))
})
})
Describe("should decode text", func() {
var decoder TxtToImageConverter
BeforeEach(func() {

View File

@@ -96,6 +96,10 @@ func (m metadataDSF) Picture() *Picture {
return m.id3.Picture()
}
func (m metadataDSF) Pictures() []Picture {
return m.id3.Pictures()
}
func (m metadataDSF) Lyrics() string {
return m.id3.Lyrics()
}

View File

@@ -140,5 +140,6 @@ func (m metadataID3v1) AlbumArtist() string { return "" }
func (m metadataID3v1) Composer() string { return "" }
func (metadataID3v1) Disc() (int, int) { return 0, 0 }
func (m metadataID3v1) Picture() *Picture { return nil }
func (m metadataID3v1) Pictures() []Picture { return nil }
func (m metadataID3v1) Lyrics() string { return "" }
func (m metadataID3v1) Comment() string { return m["comment"].(string) }

View File

@@ -546,6 +546,7 @@ type Picture struct {
Ext string // Extension of the picture file.
MIMEType string // MIMEType of the picture.
Type string // Type of the picture (see pictureTypes).
RawType byte // Raw picture type byte (see pictureTypes); 0x03 is the front cover.
Description string // Description.
Data []byte // Raw picture data.
}
@@ -596,6 +597,7 @@ func readPICFrame(b []byte) (*Picture, error) {
Ext: ext,
MIMEType: mimeType,
Type: pictureTypes[picType],
RawType: picType,
Description: desc,
Data: descDataSplit[1],
}, nil
@@ -649,6 +651,7 @@ func readAPICFrame(b []byte) (*Picture, error) {
Ext: ext,
MIMEType: mimeType,
Type: pictureTypes[picType],
RawType: picType,
Description: desc,
Data: descDataSplit[1],
}, nil

View File

@@ -150,3 +150,28 @@ func (m metadataID3v2) Picture() *Picture {
}
return v.(*Picture)
}
// Pictures returns all attached pictures. Duplicate picture frames are stored
// under suffixed keys ("APIC", "APIC_0", "APIC_1", ...) by readFrames, so we
// walk that sequence to return them in file order.
func (m metadataID3v2) Pictures() []Picture {
base := frames.Name("picture", m.Format())
if base == "" {
return nil
}
var pics []Picture
for i := -1; ; i++ {
key := base
if i >= 0 {
key = base + "_" + strconv.Itoa(i)
}
v, ok := m.frames[key]
if !ok {
break
}
if p, ok := v.(*Picture); ok {
pics = append(pics, *p)
}
}
return pics
}

10
vendor/github.com/dhowden/tag/mp4.go generated vendored
View File

@@ -377,3 +377,13 @@ func (m metadataMP4) Picture() *Picture {
p, _ := v.(*Picture)
return p
}
// Pictures returns the attached cover art. The MP4 "covr" atom carries no
// picture type, so at most a single untyped picture is returned.
func (m metadataMP4) Pictures() []Picture {
p := m.Picture()
if p == nil {
return nil
}
return []Picture{*p}
}

View File

@@ -135,6 +135,9 @@ type Metadata interface {
// Picture returns a picture, or nil if not available.
Picture() *Picture
// Pictures returns all attached pictures, or nil if none are available.
Pictures() []Picture
// Lyrics returns the lyrics, or an empty string if unavailable.
Lyrics() string

View File

@@ -22,8 +22,9 @@ func newMetadataVorbis() *metadataVorbis {
}
type metadataVorbis struct {
c map[string]string // the vorbis comments
p *Picture
c map[string]string // the vorbis comments
p *Picture
ps []*Picture
}
func (m *metadataVorbis) readVorbisComment(r io.Reader) error {
@@ -135,13 +136,16 @@ func (m *metadataVorbis) readPictureBlock(r io.Reader) error {
return err
}
m.p = &Picture{
pic := &Picture{
Ext: ext,
MIMEType: mime,
Type: pictureType,
RawType: byte(b),
Description: desc,
Data: data,
}
m.p = pic
m.ps = append(m.ps, pic)
return nil
}
@@ -261,6 +265,17 @@ func (m *metadataVorbis) Comment() string {
return m.c["description"]
}
func (m *metadataVorbis) Pictures() []Picture {
if len(m.ps) == 0 {
return nil
}
pics := make([]Picture, len(m.ps))
for i, p := range m.ps {
pics[i] = *p
}
return pics
}
func (m *metadataVorbis) Picture() *Picture {
return m.p
}

3
vendor/modules.txt vendored
View File

@@ -373,7 +373,7 @@ github.com/dgraph-io/ristretto/z/simd
# github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
## explicit
github.com/dgryski/go-rendezvous
# github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
# github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973
## explicit; go 1.20
github.com/dhowden/tag
# github.com/distribution/reference v0.6.0
@@ -2738,3 +2738,4 @@ stash.kopano.io/kgol/rndm
# github.com/unrolled/secure => github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4
# go-micro.dev/v4 => github.com/butonic/go-micro/v4 v4.11.1-0.20241115112658-b5d4de5ed9b3
# github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/opencloud-eu/go-micro-plugins/v4/store/nats-js-kv v0.0.0-20250512152754-23325793059a
# github.com/dhowden/tag => github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973