diff --git a/go_backend/metadata.go b/go_backend/metadata.go index caea5664..6576e2f0 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -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 }