feat(metadata): add native M4A ilst tag editor

EditM4AFields edits standard iTunes atoms, freeform ISRC/LABEL, and
ReplayGain in a single atomic rewrite, preserving atoms it does not
control. Creates the moov>udta>meta>ilst chain (with an mdir hdlr) when a
raw stream lacks one, and shifts stco/co64 offsets for faststart files.
This commit is contained in:
zarzet
2026-07-11 12:45:38 +07:00
parent 2bd671d305
commit 2eab955f5f
+340
View File
@@ -0,0 +1,340 @@
package gobackend
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"strings"
)
// Native editor for the standard iTunes ilst atoms plus freeform tags, in one
// atomic pass. Unlike the ffmpeg remux path it preserves every atom it does
// not control, fixes stco/co64 chunk offsets when moov precedes mdat, and
// publishes via temp+fsync+rename.
// m4aTextAtoms maps editor field keys to iTunes text atom types.
var m4aTextAtoms = map[string]string{
"title": "\xa9nam",
"artist": "\xa9ART",
"album": "\xa9alb",
"album_artist": "aART",
"date": "\xa9day",
"genre": "\xa9gen",
"composer": "\xa9wrt",
"comment": "\xa9cmt",
"copyright": "cprt",
"lyrics": "\xa9lyr",
}
func buildM4ADataAtom(dataType uint32, payload []byte) []byte {
buf := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(buf[0:4], dataType)
copy(buf[8:], payload)
return buildM4AAtom("data", buf)
}
func buildM4ATextAtom(typ, value string) []byte {
return buildM4AAtom(typ, buildM4ADataAtom(1, []byte(value)))
}
func buildM4AIndexAtom(typ string, num, total int) []byte {
size := 8 // trkn: pad(2) num(2) total(2) pad(2)
if typ == "disk" {
size = 6
}
payload := make([]byte, size)
binary.BigEndian.PutUint16(payload[2:4], uint16(num))
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
return buildM4AAtom(typ, buildM4ADataAtom(0, payload))
}
func buildM4ACoverAtom(coverData []byte) []byte {
dataType := uint32(13) // JPEG
if bytes.HasPrefix(coverData, []byte("\x89PNG")) {
dataType = 14
}
return buildM4AAtom("covr", buildM4ADataAtom(dataType, coverData))
}
type m4aIlstLocation struct {
moov mp4Box
ilst mp4Box
// ancestors whose size field must grow when ilst changes size; all start
// before ilst so their offsets survive the splice.
ancestors []mp4Box
}
// locateM4AIlstInBuf finds moov > [udta >] meta > ilst. The meta box is a full
// box, so its children start 4 bytes into the body.
func locateM4AIlstInBuf(data []byte) (m4aIlstLocation, bool) {
n := int64(len(data))
moov, ok := findChildMP4(data, 0, n, "moov")
if !ok {
return m4aIlstLocation{}, false
}
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
if meta, ok := findChildMP4(data, udta.body(), udta.end(), "meta"); ok {
if ilst, ok := findChildMP4(data, meta.body()+4, meta.end(), "ilst"); ok {
return m4aIlstLocation{moov: moov, ilst: ilst, ancestors: []mp4Box{moov, udta, meta}}, true
}
}
}
if meta, ok := findChildMP4(data, moov.body(), moov.end(), "meta"); ok {
if ilst, ok := findChildMP4(data, meta.body()+4, meta.end(), "ilst"); ok {
return m4aIlstLocation{moov: moov, ilst: ilst, ancestors: []mp4Box{moov, meta}}, true
}
}
return m4aIlstLocation{}, false
}
// buildM4AMetaBox returns a meta full box containing the iTunes mdir hdlr and
// an empty ilst.
func buildM4AMetaBox() []byte {
hdlrPayload := make([]byte, 25) // ver/flags + pre_defined + handler + 3x reserved + empty name
copy(hdlrPayload[8:12], "mdir")
copy(hdlrPayload[12:16], "appl")
body := append([]byte{0, 0, 0, 0}, buildM4AAtom("hdlr", hdlrPayload)...)
body = append(body, buildM4AAtom("ilst", nil)...)
return buildM4AAtom("meta", body)
}
// ensureM4AIlstInBuf returns a buffer guaranteed to contain the
// moov>[udta>]meta>ilst chain, creating the missing tail at the end of the
// deepest existing ancestor. Chunk offsets are shifted for the inserted bytes.
func ensureM4AIlstInBuf(data []byte) ([]byte, m4aIlstLocation, error) {
if loc, ok := locateM4AIlstInBuf(data); ok {
return data, loc, nil
}
n := int64(len(data))
moov, ok := findChildMP4(data, 0, n, "moov")
if !ok {
return nil, m4aIlstLocation{}, fmt.Errorf("moov not found")
}
var insertPos int64
var insert []byte
var grow []mp4Box
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
if meta, ok := findChildMP4(data, udta.body(), udta.end(), "meta"); ok {
insertPos = meta.end()
insert = buildM4AAtom("ilst", nil)
grow = []mp4Box{moov, udta, meta}
} else {
insertPos = udta.end()
insert = buildM4AMetaBox()
grow = []mp4Box{moov, udta}
}
} else {
insertPos = moov.end()
insert = buildM4AAtom("udta", buildM4AMetaBox())
grow = []mp4Box{moov}
}
updated := make([]byte, 0, len(data)+len(insert))
updated = append(updated, data[:insertPos]...)
updated = append(updated, insert...)
updated = append(updated, data[insertPos:]...)
delta := int64(len(insert))
for _, b := range grow {
growBoxSize(updated, b, delta)
}
if newMoov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok {
shiftChunkOffsets(updated, newMoov, insertPos, delta)
}
loc, ok := locateM4AIlstInBuf(updated)
if !ok {
return nil, m4aIlstLocation{}, fmt.Errorf("failed to create ilst")
}
return updated, loc, nil
}
// m4aFreeformNameInBuf extracts the freeform ("----") atom's name from a
// buffer-backed child box.
func m4aFreeformNameInBuf(data []byte, box mp4Box) string {
pos := box.body()
for pos+8 <= box.end() {
child, ok := readMP4Box(data, pos)
if !ok {
return ""
}
if child.typ == "name" && child.size > child.hdr+4 {
raw := data[child.body()+4 : child.end()]
return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
}
pos = child.end()
}
return ""
}
// m4aIndexPairInBuf reads the (number, total) pair from a trkn/disk box.
func m4aIndexPairInBuf(data []byte, box mp4Box) (int, int) {
pos := box.body()
for pos+8 <= box.end() {
child, ok := readMP4Box(data, pos)
if !ok {
return 0, 0
}
if child.typ == "data" && child.size >= child.hdr+8+6 {
payload := data[child.body()+8 : child.end()]
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6]))
}
pos = child.end()
}
return 0, 0
}
// EditM4AFields updates only the ilst entries whose keys are explicitly
// present in the fields map (set-or-clear semantics, mirroring EditFlacFields)
// while preserving every other atom. Standard atoms, freeform ISRC/LABEL, and
// ReplayGain freeform tags are all written in a single file rewrite.
func EditM4AFields(filePath string, fields map[string]string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
data, loc, err := ensureM4AIlstInBuf(data)
if err != nil {
return err
}
dropStandard := map[string]bool{}
var appended []byte
for fieldKey, atomType := range m4aTextAtoms {
v, ok := fields[fieldKey]
if !ok {
continue
}
dropStandard[atomType] = true
if fieldKey == "genre" {
dropStandard["gnre"] = true // legacy numeric genre
}
if strings.TrimSpace(v) != "" {
appended = append(appended, buildM4ATextAtom(atomType, v)...)
}
}
// Freeform tags: ISRC, LABEL, ReplayGain (+iTunNORM).
removeFreeform := map[string]struct{}{}
var freeformTags []m4aFreeformTag
if _, ok := fields["isrc"]; ok {
removeFreeform["ISRC"] = struct{}{}
freeformTags = append(freeformTags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
}
if _, ok := fields["label"]; ok {
removeFreeform["LABEL"] = struct{}{}
removeFreeform["ORGANIZATION"] = struct{}{}
freeformTags = append(freeformTags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
}
replayGain := collectM4AReplayGainFields(fields)
if len(replayGain) > 0 {
for _, key := range []string{"replaygain_track_gain", "replaygain_track_peak", "replaygain_album_gain", "replaygain_album_peak"} {
removeFreeform[strings.ToUpper(key)] = struct{}{}
if value := replayGain[key]; value != "" {
freeformTags = append(freeformTags, m4aFreeformTag{name: key, value: value})
}
}
removeFreeform["ITUNNORM"] = struct{}{}
if norm := replayGain["iTunNORM"]; norm != "" {
freeformTags = append(freeformTags, m4aFreeformTag{name: "iTunNORM", value: norm})
}
}
for _, tag := range freeformTags {
if tag.value != "" {
appended = append(appended, buildM4AFreeformAtom(tag.name, tag.value)...)
}
}
editTrack := hasMapKey(fields, "track_number") || hasMapKey(fields, "track_total")
editDisc := hasMapKey(fields, "disc_number") || hasMapKey(fields, "disc_total")
coverPath := strings.TrimSpace(fields["cover_path"])
var coverData []byte
if coverPath != "" {
if b, err := os.ReadFile(coverPath); err == nil && len(b) > 0 {
coverData = b
dropStandard["covr"] = true
}
}
// Rebuild the ilst body, keeping untouched children verbatim.
var newBody []byte
curTrack, curTrackTotal := 0, 0
curDisc, curDiscTotal := 0, 0
for pos := loc.ilst.body(); pos+8 <= loc.ilst.end(); {
child, ok := readMP4Box(data, pos)
if !ok {
return fmt.Errorf("malformed ilst child at %d", pos)
}
keep := true
switch {
case dropStandard[child.typ]:
keep = false
case child.typ == "trkn":
curTrack, curTrackTotal = m4aIndexPairInBuf(data, child)
keep = !editTrack
case child.typ == "disk":
curDisc, curDiscTotal = m4aIndexPairInBuf(data, child)
keep = !editDisc
case child.typ == "----":
name := strings.ToUpper(m4aFreeformNameInBuf(data, child))
if _, remove := removeFreeform[name]; remove {
keep = false
}
}
if keep {
newBody = append(newBody, data[child.offset:child.end()]...)
}
pos = child.end()
}
if editTrack {
if v, ok := fields["track_number"]; ok {
curTrack = parsePositiveInt(v)
}
if v, ok := fields["track_total"]; ok {
curTrackTotal = parsePositiveInt(v)
}
if curTrack > 0 {
appended = append(appended, buildM4AIndexAtom("trkn", curTrack, curTrackTotal)...)
}
}
if editDisc {
if v, ok := fields["disc_number"]; ok {
curDisc = parsePositiveInt(v)
}
if v, ok := fields["disc_total"]; ok {
curDiscTotal = parsePositiveInt(v)
}
if curDisc > 0 {
appended = append(appended, buildM4AIndexAtom("disk", curDisc, curDiscTotal)...)
}
}
if len(coverData) > 0 {
appended = append(appended, buildM4ACoverAtom(coverData)...)
}
newBody = append(newBody, appended...)
newIlst := buildM4AAtom("ilst", newBody)
delta := int64(len(newIlst)) - loc.ilst.size
updated := make([]byte, 0, int64(len(data))+delta)
updated = append(updated, data[:loc.ilst.offset]...)
updated = append(updated, newIlst...)
updated = append(updated, data[loc.ilst.end():]...)
for _, b := range loc.ancestors {
growBoxSize(updated, b, delta)
}
if moov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok {
shiftChunkOffsets(updated, moov, loc.ilst.offset, delta)
}
return writeFileAtomic(filePath, updated, 0o644)
}