mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 21:40:57 +02:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user