mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 03:37:56 +02:00
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:
+112
-19
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user