fix(metadata): cap oversized cover art and support QuickTime/MP4 tags

Re-encode/downscale cover art that exceeds the FLAC 24-bit picture block limit (go-flac silently truncated it into a corrupt file). Locate ilst in both ISO and QuickTime-style meta atoms, and skip freeform tags gracefully when no iTunes container exists.
This commit is contained in:
zarzet
2026-06-23 02:41:42 +07:00
parent 897388853b
commit 26987459f3
+112 -19
View File
@@ -6,7 +6,7 @@ import (
"fmt"
stdimage "image"
_ "image/gif"
_ "image/jpeg"
"image/jpeg"
_ "image/png"
"io"
"math"
@@ -71,11 +71,83 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
return "image/jpeg"
}
// maxFlacPictureBytes keeps cover art below the 24-bit length field of a FLAC
// metadata block; go-flac silently truncates oversized blocks into a corrupt file.
const maxFlacPictureBytes = 16 * 1000 * 1000
// fitCoverForFlac returns cover bytes that fit inside a FLAC PICTURE block,
// re-encoding and downscaling when needed. Returns false if the data cannot be
// decoded as an image.
func fitCoverForFlac(coverData []byte) ([]byte, bool) {
if len(coverData) <= maxFlacPictureBytes {
return coverData, true
}
img, _, err := stdimage.Decode(bytes.NewReader(coverData))
if err != nil {
return nil, false
}
for _, quality := range []int{90, 80, 70, 60} {
if encoded, ok := encodeJPEGUnder(img, quality, maxFlacPictureBytes); ok {
return encoded, true
}
}
for _, maxDim := range []int{1500, 1200, 1000, 800} {
scaled := downscaleImage(img, maxDim)
if encoded, ok := encodeJPEGUnder(scaled, 85, maxFlacPictureBytes); ok {
return encoded, true
}
}
return nil, false
}
func encodeJPEGUnder(img stdimage.Image, quality, limit int) ([]byte, bool) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, false
}
if buf.Len() > limit {
return nil, false
}
return buf.Bytes(), true
}
func downscaleImage(img stdimage.Image, maxDim int) stdimage.Image {
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDim && height <= maxDim {
return img
}
scale := float64(maxDim) / float64(max(width, height))
newWidth := max(1, int(float64(width)*scale))
newHeight := max(1, int(float64(height)*scale))
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
srcY := bounds.Min.Y + int(float64(y)/scale)
for x := 0; x < newWidth; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
if len(coverData) == 0 {
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
}
fitted, ok := fitCoverForFlac(coverData)
if !ok {
return flac.MetaDataBlock{}, fmt.Errorf("cover too large for FLAC picture block and could not be resized")
}
coverData = fitted
mime := detectCoverMIME(coverPath, coverData)
picture := &flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
@@ -175,10 +247,11 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -230,10 +303,11 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
return f.Save(filePath)
@@ -1123,9 +1197,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
return ilst, nil
}
}
@@ -1133,9 +1205,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
// Path 2: moov > meta > ilst (no udta wrapper)
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return ilst, nil
}
}
@@ -1143,6 +1213,26 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
// findIlstInMeta locates the ilst atom inside a meta atom, handling both
// layouts: ISO-BMFF (4-byte version/flags before the child atoms, written by
// FFmpeg's mp4 muxer) and QuickTime (no version/flags, written by the mov muxer
// used for AC-4 passthrough).
func findIlstInMeta(f *os.File, meta atomHeader, fileSize int64) (atomHeader, bool) {
// ISO-BMFF: skip the 4-byte version/flags that precede the child atoms.
isoStart := meta.offset + meta.headerSize + 4
isoSize := meta.size - meta.headerSize - 4
if ilst, ok, _ := findAtomInRange(f, isoStart, isoSize, "ilst", fileSize); ok {
return ilst, true
}
// QuickTime: child atoms begin immediately after the meta header.
qtStart := meta.offset + meta.headerSize
qtSize := meta.size - meta.headerSize
if ilst, ok, _ := findAtomInRange(f, qtStart, qtSize, "ilst", fileSize); ok {
return ilst, true
}
return atomHeader{}, false
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
@@ -1280,9 +1370,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
@@ -1295,9 +1383,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
@@ -1490,6 +1576,13 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
// MOV-style containers (e.g. AC-4 passthrough) store tags as QuickTime
// atoms under udta with no iTunes meta>ilst structure. There is nowhere
// to write freeform tags, so skip gracefully instead of failing.
if strings.Contains(err.Error(), "ilst not found") {
GoLog("[Metadata] No iTunes ilst container; skipping freeform tags")
return nil
}
return err
}