diff --git a/go_backend/metadata.go b/go_backend/metadata.go index bf832061..962bd875 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -2,18 +2,14 @@ package gobackend import ( "bytes" - "encoding/binary" "fmt" stdimage "image" _ "image/gif" "image/jpeg" _ "image/png" - "io" - "math" "os" "path/filepath" "regexp" - "strconv" "strings" "github.com/go-flac/flacpicture/v2" @@ -883,706 +879,6 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } -func ReadM4ATags(filePath string) (*AudioMetadata, error) { - f, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer f.Close() - - fi, err := f.Stat() - if err != nil { - return nil, err - } - - ilst, err := findM4AIlstAtom(f, fi.Size()) - if err != nil { - return nil, err - } - return readM4ATagsFromIlst(f, fi.Size(), ilst) -} - -func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMetadata, error) { - metadata := &AudioMetadata{} - start := ilst.offset + ilst.headerSize - end := ilst.offset + ilst.size - for pos := start; pos+8 <= end; { - header, err := readAtomHeaderAt(f, pos, fileSize) - if err != nil { - return nil, err - } - if header.size == 0 { - header.size = end - pos - } - if header.size < header.headerSize { - return nil, fmt.Errorf("invalid atom size for %s", header.typ) - } - - switch header.typ { - case "\xa9nam": - metadata.Title, _ = readM4ATextValue(f, header, fileSize) - case "\xa9ART": - metadata.Artist, _ = readM4ATextValue(f, header, fileSize) - case "\xa9alb": - metadata.Album, _ = readM4ATextValue(f, header, fileSize) - case "aART": - metadata.AlbumArtist, _ = readM4ATextValue(f, header, fileSize) - case "\xa9day": - metadata.Date, _ = readM4ATextValue(f, header, fileSize) - metadata.Year = metadata.Date - case "\xa9gen": - metadata.Genre, _ = readM4ATextValue(f, header, fileSize) - case "\xa9wrt": - metadata.Composer, _ = readM4ATextValue(f, header, fileSize) - case "\xa9cmt": - metadata.Comment, _ = readM4ATextValue(f, header, fileSize) - case "cprt": - metadata.Copyright, _ = readM4ATextValue(f, header, fileSize) - case "\xa9lyr": - metadata.Lyrics, _ = readM4ATextValue(f, header, fileSize) - case "trkn": - metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fileSize) - case "disk": - metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fileSize) - case "----": - name, value, freeformErr := readM4AFreeformValue(f, header, fileSize) - if freeformErr == nil { - switch strings.ToUpper(strings.TrimSpace(name)) { - case "ISRC": - metadata.ISRC = value - case "LABEL", "ORGANIZATION": - metadata.Label = value - case "COMMENT": - if metadata.Comment == "" { - metadata.Comment = value - } - case "COMPOSER": - if metadata.Composer == "" { - metadata.Composer = value - } - case "COPYRIGHT": - if metadata.Copyright == "" { - metadata.Copyright = value - } - case "LYRICS", "UNSYNCEDLYRICS": - if metadata.Lyrics == "" { - metadata.Lyrics = value - } - case "REPLAYGAIN_TRACK_GAIN": - metadata.ReplayGainTrackGain = value - case "REPLAYGAIN_TRACK_PEAK": - metadata.ReplayGainTrackPeak = value - case "REPLAYGAIN_ALBUM_GAIN": - metadata.ReplayGainAlbumGain = value - case "REPLAYGAIN_ALBUM_PEAK": - metadata.ReplayGainAlbumPeak = value - } - } - } - - pos += header.size - } - - if metadata.Title == "" && - metadata.Artist == "" && - metadata.Album == "" && - metadata.AlbumArtist == "" && - metadata.Lyrics == "" && - metadata.TrackNumber == 0 && - metadata.DiscNumber == 0 { - return nil, fmt.Errorf("no M4A tags found") - } - - return metadata, nil -} - -func extractLyricsFromM4A(filePath string) (string, error) { - metadata, err := ReadM4ATags(filePath) - if err != nil { - return "", err - } - if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" { - return "", fmt.Errorf("no lyrics found in file") - } - return metadata.Lyrics, nil -} - -func extractCoverFromM4A(filePath string) ([]byte, error) { - f, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer f.Close() - - fi, err := f.Stat() - if err != nil { - return nil, err - } - fileSize := fi.Size() - - ilst, err := findM4AIlstAtom(f, fileSize) - if err != nil { - return nil, err - } - return extractCoverFromM4AIlst(f, fileSize, ilst) -} - -func extractCoverFromM4AIlst(f *os.File, fileSize int64, ilst atomHeader) ([]byte, error) { - bodyStart := ilst.offset + ilst.headerSize - bodySize := ilst.size - ilst.headerSize - - covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize) - if err != nil || !found { - return nil, fmt.Errorf("cover atom not found") - } - - dataStart := covr.offset + covr.headerSize - dataSize := covr.size - covr.headerSize - - dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) - if err != nil || !found { - return nil, fmt.Errorf("data atom not found in cover") - } - - // data atom: header + 4 bytes type indicator + 4 bytes locale - imgStart := dataAtom.offset + dataAtom.headerSize + 8 - imgLen := dataAtom.size - dataAtom.headerSize - 8 - if imgLen <= 0 { - return nil, fmt.Errorf("empty cover data") - } - - buf := make([]byte, imgLen) - if _, err := f.ReadAt(buf, imgStart); err != nil { - return nil, err - } - - return buf, nil -} - -// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags. -// It tries two common layouts: -// 1. moov > udta > meta > ilst (iTunes, FFmpeg default) -// 2. moov > meta > ilst (some encoders omit the udta wrapper) -func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) { - moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) - if err != nil || !found { - return atomHeader{}, fmt.Errorf("moov not found") - } - - moovBodyStart := moov.offset + moov.headerSize - moovBodySize := moov.size - moov.headerSize - - // Path 1: moov > udta > meta > ilst - if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok { - udtaBodyStart := udta.offset + udta.headerSize - udtaBodySize := udta.size - udta.headerSize - if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 { - if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 { - return ilst, nil - } - } - } - - // Path 2: moov > meta > ilst (no udta wrapper) - if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok { - if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 { - return ilst, nil - } - } - - 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 - if payloadLen <= 0 { - return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ) - } - - buf := make([]byte, payloadLen) - if _, err := f.ReadAt(buf, payloadStart); err != nil { - return nil, err - } - return buf, nil -} - -func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) { - dataStart := parent.offset + parent.headerSize - dataSize := parent.size - parent.headerSize - - dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) - if err != nil || !found { - return nil, fmt.Errorf("data atom not found in %s", parent.typ) - } - return readM4ADataAtomPayload(f, dataAtom) -} - -func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) { - payload, err := readM4ADataPayload(f, parent, fileSize) - if err != nil { - return "", err - } - return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil -} - -func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) { - payload, err := readM4ADataPayload(f, parent, fileSize) - if err != nil { - return 0, 0, err - } - if len(payload) < 6 { - return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ) - } - return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil -} - -func parsePositiveInt(value string) int { - value = strings.TrimSpace(value) - if value == "" { - return 0 - } - n, _ := strconv.Atoi(value) - return n -} - -func formatIndexValue(number, total int) string { - if number <= 0 { - return "" - } - if total > 0 { - return fmt.Sprintf("%d/%d", number, total) - } - return strconv.Itoa(number) -} - -func hasMapKey(fields map[string]string, key string) bool { - _, ok := fields[key] - return ok -} - -func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) { - start := parent.offset + parent.headerSize - end := parent.offset + parent.size - - var nameValue string - var dataValue string - for pos := start; pos+8 <= end; { - header, err := readAtomHeaderAt(f, pos, fileSize) - if err != nil { - return "", "", err - } - if header.size == 0 { - header.size = end - pos - } - if header.size < header.headerSize { - return "", "", fmt.Errorf("invalid atom size for %s", header.typ) - } - - switch header.typ { - case "mean": - // Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip. - case "name": - // The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text. - // It does NOT contain a nested "data" atom, so read the payload directly. - payloadStart := header.offset + header.headerSize + 4 - payloadLen := header.size - header.headerSize - 4 - if payloadLen > 0 { - buf := make([]byte, payloadLen) - if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil { - nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00")) - } - } - case "data": - payload, payloadErr := readM4ADataAtomPayload(f, header) - if payloadErr == nil { - dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00")) - } - } - - pos += header.size - } - - if nameValue == "" || dataValue == "" { - return "", "", fmt.Errorf("freeform M4A tag incomplete") - } - - return nameValue, dataValue, nil -} - -type m4aMetadataPath struct { - moov atomHeader - udta *atomHeader - meta atomHeader - ilst atomHeader -} - -func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) { - moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) - if err != nil || !found { - return m4aMetadataPath{}, fmt.Errorf("moov not found") - } - - moovBodyStart := moov.offset + moov.headerSize - moovBodySize := moov.size - moov.headerSize - - if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok { - udtaBodyStart := udta.offset + udta.headerSize - udtaBodySize := udta.size - udta.headerSize - if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 { - if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 { - udtaCopy := udta - return m4aMetadataPath{ - moov: moov, - udta: &udtaCopy, - meta: meta, - ilst: ilst, - }, nil - } - } - } - - if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok { - if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 { - return m4aMetadataPath{ - moov: moov, - meta: meta, - ilst: ilst, - }, nil - } - } - - return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)") -} - -func buildM4AAtom(typ string, payload []byte) []byte { - size := int64(8 + len(payload)) - buf := make([]byte, 8+len(payload)) - binary.BigEndian.PutUint32(buf[0:4], uint32(size)) - copy(buf[4:8], []byte(typ)) - copy(buf[8:], payload) - return buf -} - -func buildM4AFreeformAtom(name, value string) []byte { - meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...) - namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...) - dataPayload := make([]byte, 8+len(value)) - binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text - copy(dataPayload[8:], []byte(value)) - - payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...) - payload = append(payload, buildM4AAtom("name", namePayload)...) - payload = append(payload, buildM4AAtom("data", dataPayload)...) - return buildM4AAtom("----", payload) -} - -func buildITunNORMTag(trackGain, trackPeak string) string { - gainDb, ok := parseReplayGainDb(trackGain) - if !ok { - return "" - } - peakLinear, ok := parseReplayGainPeak(trackPeak) - if !ok { - return "" - } - - clamp := func(v int64) int64 { - if v < 0 { - return 0 - } - if v > 65534 { - return 65534 - } - return v - } - - g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0))) - g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0))) - peak := clamp(int64(math.Round(peakLinear * 32768.0))) - values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0} - parts := make([]string, 0, len(values)) - for _, value := range values { - parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value))) - } - return strings.Join(parts, " ") -} - -var replayGainNumberPattern = regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`) - -func parseReplayGainDb(value string) (float64, bool) { - match := replayGainNumberPattern.FindStringSubmatch(strings.TrimSpace(value)) - if len(match) < 2 { - return 0, false - } - parsed, err := strconv.ParseFloat(match[1], 64) - if err != nil { - return 0, false - } - return parsed, true -} - -func parseReplayGainPeak(value string) (float64, bool) { - parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) - if err != nil || parsed <= 0 { - return 0, false - } - return parsed, true -} - -func collectM4AReplayGainFields(fields map[string]string) map[string]string { - result := map[string]string{} - if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" { - result["replaygain_track_gain"] = value - } - if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" { - result["replaygain_track_peak"] = value - } - if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" { - result["replaygain_album_gain"] = value - } - if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" { - result["replaygain_album_peak"] = value - } - - if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" { - result["iTunNORM"] = norm - } - - return result -} - -func writeAtomSize(buf []byte, header atomHeader, newSize int64) error { - if newSize <= 0 { - return fmt.Errorf("invalid size for %s", header.typ) - } - if header.headerSize == 16 { - if int(header.offset)+16 > len(buf) { - return io.ErrUnexpectedEOF - } - binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1) - binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize)) - return nil - } - if newSize > math.MaxUint32 { - return fmt.Errorf("atom %s too large for 32-bit header", header.typ) - } - if int(header.offset)+8 > len(buf) { - return io.ErrUnexpectedEOF - } - binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize)) - return nil -} - -func EditM4AReplayGain(filePath string, fields map[string]string) error { - replayGainFields := collectM4AReplayGainFields(fields) - if len(replayGainFields) == 0 { - return nil - } - - remove := map[string]struct{}{ - "REPLAYGAIN_TRACK_GAIN": {}, - "REPLAYGAIN_TRACK_PEAK": {}, - "REPLAYGAIN_ALBUM_GAIN": {}, - "REPLAYGAIN_ALBUM_PEAK": {}, - "ITUNNORM": {}, - } - - order := []string{ - "replaygain_track_gain", - "replaygain_track_peak", - "replaygain_album_gain", - "replaygain_album_peak", - "iTunNORM", - } - tags := make([]m4aFreeformTag, 0, len(order)) - for _, key := range order { - value := strings.TrimSpace(replayGainFields[key]) - if value == "" { - continue - } - name := key - if key != "iTunNORM" { - name = strings.ToLower(key) - } - tags = append(tags, m4aFreeformTag{name: name, value: value}) - } - - return writeM4AFreeformTags(filePath, remove, tags) -} - -type m4aFreeformTag struct { - name string - value string -} - -// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing -// freeform ("----") atom whose uppercased name is in `remove`, then appends the -// supplied tags (empty values are skipped, which effectively clears the field). -// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain. -// -// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so -// fields like ISRC and LABEL are silently dropped when written via -metadata. -// Writing them as iTunes freeform atoms natively is the only way they persist. -func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error { - f, err := os.Open(filePath) - if err != nil { - return err - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return err - } - - 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 - } - - // Only the moov box is buffered; the audio bulk is streamed on write. - base := path.moov.offset - moovBuf := make([]byte, path.moov.size) - if _, err := f.ReadAt(moovBuf, base); err != nil { - return err - } - - bodyStart := path.ilst.offset + path.ilst.headerSize - bodyEnd := path.ilst.offset + path.ilst.size - newBody := make([]byte, 0, int(path.ilst.size)) - - for pos := bodyStart; pos+8 <= bodyEnd; { - header, readErr := readAtomHeaderAt(f, pos, info.Size()) - if readErr != nil { - return readErr - } - if header.size == 0 { - header.size = bodyEnd - pos - } - if header.size < header.headerSize { - return fmt.Errorf("invalid atom size for %s", header.typ) - } - - keep := true - if header.typ == "----" { - name, _, freeformErr := readM4AFreeformValue(f, header, info.Size()) - if freeformErr == nil { - if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok { - keep = false - } - } - } - if keep { - newBody = append(newBody, moovBuf[pos-base:pos-base+header.size]...) - } - - pos += header.size - } - - for _, tag := range tags { - if strings.TrimSpace(tag.value) == "" { - continue - } - newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...) - } - - newIlst := buildM4AAtom("ilst", newBody) - ilstRel := path.ilst.offset - base - updated := append([]byte{}, moovBuf[:ilstRel]...) - updated = append(updated, newIlst...) - updated = append(updated, moovBuf[ilstRel+path.ilst.size:]...) - - // The path headers carry absolute file offsets; rebase them onto the - // moov-rooted buffer before patching sizes. - rel := func(h atomHeader) atomHeader { - h.offset -= base - return h - } - delta := int64(len(newIlst)) - path.ilst.size - if err := writeAtomSize(updated, rel(path.ilst), path.ilst.size+delta); err != nil { - return err - } - if err := writeAtomSize(updated, rel(path.meta), path.meta.size+delta); err != nil { - return err - } - if path.udta != nil { - if err := writeAtomSize(updated, rel(*path.udta), path.udta.size+delta); err != nil { - return err - } - } - if err := writeAtomSize(updated, rel(path.moov), path.moov.size+delta); err != nil { - return err - } - // Keep sample pointers valid when moov precedes mdat: every stco/co64 - // entry at or beyond the resized ilst must shift with it. Entries hold - // absolute file offsets, so compare against the absolute ilst position. - if delta != 0 { - if moov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok { - shiftChunkOffsets(updated, moov, path.ilst.offset, delta) - } - } - - // Release the read handle before replacing the file (required on Windows). - f.Close() - return replaceFileSectionsStreaming(filePath, []fileSection{ - {start: base, end: base + path.moov.size, data: updated}, - }) -} - -// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes -// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so -// they must be written natively for the values to actually persist. An empty -// value clears the corresponding tag. Other (recognized) tags are left intact. -func EditM4AFreeformText(filePath string, fields map[string]string) error { - _, hasISRC := fields["isrc"] - _, hasLabel := fields["label"] - if !hasISRC && !hasLabel { - return nil - } - - remove := map[string]struct{}{} - tags := make([]m4aFreeformTag, 0, 2) - if hasISRC { - remove["ISRC"] = struct{}{} - tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])}) - } - if hasLabel { - remove["LABEL"] = struct{}{} - remove["ORGANIZATION"] = struct{}{} - tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])}) - } - - return writeM4AFreeformTags(filePath, remove, tags) -} - func extractLyricsFromSidecarLRC(filePath string) (string, error) { ext := filepath.Ext(filePath) base := strings.TrimSuffix(filePath, ext) @@ -1730,471 +1026,3 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") } - -func GetM4AQuality(filePath string) (AudioQuality, error) { - f, err := os.Open(filePath) - if err != nil { - return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err) - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err) - } - fileSize := info.Size() - return m4aQualityFromFile(f, fileSize) -} - -func m4aQualityFromFile(f *os.File, fileSize int64) (AudioQuality, error) { - moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) - if err != nil { - return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err) - } - if !moovFound { - return AudioQuality{}, fmt.Errorf("moov atom not found") - } - - moovStart := moovHeader.offset - moovEnd := moovHeader.offset + moovHeader.size - duration := readM4ADurationSeconds(f, moovHeader, fileSize) - - sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize) - if err != nil { - return AudioQuality{}, err - } - - buf := make([]byte, 32) - if _, err := f.ReadAt(buf, sampleOffset); err != nil { - return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) - } - - // AudioSampleEntry layout from the box type field: - // [0:4] type ("mp4a"/"alac") - // [4:10] SampleEntry.reserved - // [10:12] data_reference_index - // [12:20] reserved[8] - // [20:22] channelcount - // [22:24] samplesize (bit depth) - // [24:26] pre_defined - // [26:28] reserved - // [28:32] samplerate (16.16 fixed-point) - sampleRate := int(buf[28])<<8 | int(buf[29]) - bitDepth := 0 - codec := normalizeM4AAudioCodec(atomType) - - switch atomType { - case "alac": - bitDepth = int(buf[22])<<8 | int(buf[23]) - if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok { - if alacBitDepth > 0 { - bitDepth = alacBitDepth - } - if alacSampleRate > 0 { - sampleRate = alacSampleRate - } - } - case "fLaC": - bitDepth = int(buf[22])<<8 | int(buf[23]) - if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok { - if flacBitDepth > 0 { - bitDepth = flacBitDepth - } - if flacSampleRate > 0 { - sampleRate = flacSampleRate - } - if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 { - duration = int(flacTotalSamples / int64(sampleRate)) - } - } - } - - bitrate := estimateAudioBitrateKbps(fileSize, duration) - if bitrate > 0 && bitrate < 16 { - bitrate = 0 - } - return AudioQuality{ - BitDepth: bitDepth, - SampleRate: sampleRate, - Duration: duration, - Bitrate: bitrate, - Codec: codec, - }, nil -} - -func normalizeM4AAudioCodec(atomType string) string { - switch atomType { - case "mp4a": - return "aac" - case "alac": - return "alac" - case "fLaC": - return "flac" - case "ec-3": - return "eac3" - case "ac-3": - return "ac3" - case "ac-4": - return "ac4" - default: - return strings.TrimSpace(atomType) - } -} - -func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int { - if fileSize <= 0 || durationSeconds <= 0 { - return 0 - } - return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0)) -} - -func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int { - childStart := moovHeader.offset + moovHeader.headerSize - childSize := moovHeader.size - moovHeader.headerSize - mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize) - if err == nil && found { - if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 { - return duration - } - } - - return readM4ATrackDurationSeconds(f, moovHeader, fileSize) -} - -func readMP4DurationAtomSeconds(f *os.File, header atomHeader, _ int64) int { - payloadOffset := header.offset + header.headerSize - versionBuf := make([]byte, 1) - if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil { - return 0 - } - - if versionBuf[0] == 1 { - buf := make([]byte, 32) - if _, err := f.ReadAt(buf, payloadOffset); err != nil { - return 0 - } - timescale := binary.BigEndian.Uint32(buf[20:24]) - duration := binary.BigEndian.Uint64(buf[24:32]) - if timescale == 0 || duration == 0 { - return 0 - } - return int(math.Round(float64(duration) / float64(timescale))) - } - - buf := make([]byte, 20) - if _, err := f.ReadAt(buf, payloadOffset); err != nil { - return 0 - } - timescale := binary.BigEndian.Uint32(buf[12:16]) - duration := binary.BigEndian.Uint32(buf[16:20]) - if timescale == 0 || duration == 0 { - return 0 - } - return int(math.Round(float64(duration) / float64(timescale))) -} - -func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int { - childStart := moovHeader.offset + moovHeader.headerSize - childSize := moovHeader.size - moovHeader.headerSize - bestDuration := 0 - _ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool { - if header.typ == "mdhd" { - if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration { - bestDuration = duration - } - return false - } - return header.typ == "trak" || header.typ == "mdia" - }) - return bestDuration -} - -func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error { - if size <= 0 { - return nil - } - - end := start + size - for pos := start; pos+8 <= end; { - header, err := readAtomHeaderAt(f, pos, fileSize) - if err != nil { - return err - } - atomSize := header.size - if atomSize == 0 { - atomSize = end - pos - } - if atomSize < header.headerSize { - return fmt.Errorf("invalid atom size for %s", header.typ) - } - header.size = atomSize - if visit(header) { - childStart := header.offset + header.headerSize - childSize := header.size - header.headerSize - if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil { - return err - } - } - pos += atomSize - } - return nil -} - -func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) { - if sampleOffset < 4 { - return 0, 0, false - } - - sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize) - if err != nil { - return 0, 0, false - } - - childStart := sampleOffset + 32 - childEnd := sampleEntryHeader.offset + sampleEntryHeader.size - if childStart >= childEnd { - return 0, 0, false - } - - configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize) - if err != nil || !found { - return 0, 0, false - } - - payloadSize := configHeader.size - configHeader.headerSize - if payloadSize <= 0 { - return 0, 0, false - } - - payload := make([]byte, payloadSize) - if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil { - return 0, 0, false - } - - return parseALACSpecificConfig(payload) -} - -func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) { - if sampleOffset < 4 { - return 0, 0, 0, false - } - - sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize) - if err != nil { - return 0, 0, 0, false - } - - childStart := sampleOffset + 32 - childEnd := sampleEntryHeader.offset + sampleEntryHeader.size - if childStart >= childEnd { - return 0, 0, 0, false - } - - configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize) - if err != nil || !found { - return 0, 0, 0, false - } - - payloadSize := configHeader.size - configHeader.headerSize - if payloadSize <= 0 { - return 0, 0, 0, false - } - - payload := make([]byte, payloadSize) - if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil { - return 0, 0, 0, false - } - - return parseMP4FLACSpecificConfig(payload) -} - -func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) { - if len(payload) >= 4 && string(payload[:4]) == "fLaC" { - payload = payload[4:] - } else if len(payload) >= 4 { - // FLACSpecificBox starts with a full-box version/flags field. - payload = payload[4:] - } - - for len(payload) >= 4 { - blockType := payload[0] & 0x7F - blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) - if blockLen < 0 || len(payload) < 4+blockLen { - return 0, 0, 0, false - } - block := payload[4 : 4+blockLen] - if blockType == 0 && len(block) >= 34 { - bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34]) - return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0 - } - payload = payload[4+blockLen:] - } - - return 0, 0, 0, false -} - -func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) { - if len(streamInfo) < 18 { - return 0, 0, 0 - } - sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4) - bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1 - totalSamples := int64(streamInfo[13]&0x0F)<<32 | - int64(streamInfo[14])<<24 | - int64(streamInfo[15])<<16 | - int64(streamInfo[16])<<8 | - int64(streamInfo[17]) - return bitsPerSample, sampleRate, totalSamples -} - -func parseALACSpecificConfig(payload []byte) (int, int, bool) { - if len(payload) < 24 { - return 0, 0, false - } - - bitDepth := int(payload[5]) - sampleRate := int(binary.BigEndian.Uint32(payload[20:24])) - if bitDepth > 0 && sampleRate > 0 { - return bitDepth, sampleRate, true - } - - // Some encoders prepend 4 bytes before the ALACSpecificConfig payload. - if len(payload) >= 28 { - bitDepth = int(payload[9]) - sampleRate = int(binary.BigEndian.Uint32(payload[24:28])) - if bitDepth > 0 && sampleRate > 0 { - return bitDepth, sampleRate, true - } - } - - return 0, 0, false -} - -type atomHeader struct { - offset int64 - size int64 - headerSize int64 - typ string -} - -func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) { - if offset+8 > fileSize { - return atomHeader{}, io.ErrUnexpectedEOF - } - - headerBuf := make([]byte, 8) - if _, err := f.ReadAt(headerBuf, offset); err != nil { - return atomHeader{}, err - } - - size32 := binary.BigEndian.Uint32(headerBuf[0:4]) - typ := string(headerBuf[4:8]) - - if size32 == 1 { - if offset+16 > fileSize { - return atomHeader{}, io.ErrUnexpectedEOF - } - extBuf := make([]byte, 8) - if _, err := f.ReadAt(extBuf, offset+8); err != nil { - return atomHeader{}, err - } - size64 := binary.BigEndian.Uint64(extBuf) - return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil - } - - return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil -} - -func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) { - if size <= 0 { - return atomHeader{}, false, nil - } - - end := start + size - pos := start - - for pos+8 <= end { - header, err := readAtomHeaderAt(f, pos, fileSize) - if err != nil { - return atomHeader{}, false, err - } - - atomSize := header.size - if atomSize == 0 { - atomSize = end - pos - } - - if atomSize < header.headerSize { - return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ) - } - - header.size = atomSize - if header.typ == target { - return header, true, nil - } - - pos += atomSize - } - - return atomHeader{}, false, nil -} - -func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { - const chunkSize = 64 * 1024 - patterns := [][]byte{ - []byte("mp4a"), - []byte("alac"), - []byte("fLaC"), - []byte("ec-3"), - []byte("ac-3"), - []byte("ac-4"), - } - - var tail []byte - readPos := start - - for readPos < end { - toRead := end - readPos - if toRead > chunkSize { - toRead = chunkSize - } - - buf := make([]byte, toRead) - n, err := f.ReadAt(buf, readPos) - if err != nil && err != io.EOF { - return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err) - } - if n == 0 { - break - } - - data := append(tail, buf[:n]...) - bestIdx := -1 - bestType := "" - for _, pattern := range patterns { - idx := bytes.Index(data, pattern) - if idx >= 0 && (bestIdx < 0 || idx < bestIdx) { - bestIdx = idx - bestType = string(pattern) - } - } - - if bestIdx >= 0 { - absolute := readPos - int64(len(tail)) + int64(bestIdx) - if absolute+32 > fileSize { - return 0, "", fmt.Errorf("audio info not found in M4A file") - } - return absolute, bestType, nil - } - - if len(data) >= 3 { - tail = append([]byte{}, data[len(data)-3:]...) - } else { - tail = append([]byte{}, data...) - } - - readPos += int64(n) - } - - return 0, "", fmt.Errorf("audio info not found in M4A file") -} diff --git a/go_backend/metadata_m4a.go b/go_backend/metadata_m4a.go new file mode 100644 index 00000000..f6af58d0 --- /dev/null +++ b/go_backend/metadata_m4a.go @@ -0,0 +1,1181 @@ +package gobackend + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "os" + "regexp" + "strconv" + "strings" +) + +func ReadM4ATags(filePath string) (*AudioMetadata, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + + ilst, err := findM4AIlstAtom(f, fi.Size()) + if err != nil { + return nil, err + } + return readM4ATagsFromIlst(f, fi.Size(), ilst) +} + +func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMetadata, error) { + metadata := &AudioMetadata{} + start := ilst.offset + ilst.headerSize + end := ilst.offset + ilst.size + for pos := start; pos+8 <= end; { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return nil, err + } + if header.size == 0 { + header.size = end - pos + } + if header.size < header.headerSize { + return nil, fmt.Errorf("invalid atom size for %s", header.typ) + } + + switch header.typ { + case "\xa9nam": + metadata.Title, _ = readM4ATextValue(f, header, fileSize) + case "\xa9ART": + metadata.Artist, _ = readM4ATextValue(f, header, fileSize) + case "\xa9alb": + metadata.Album, _ = readM4ATextValue(f, header, fileSize) + case "aART": + metadata.AlbumArtist, _ = readM4ATextValue(f, header, fileSize) + case "\xa9day": + metadata.Date, _ = readM4ATextValue(f, header, fileSize) + metadata.Year = metadata.Date + case "\xa9gen": + metadata.Genre, _ = readM4ATextValue(f, header, fileSize) + case "\xa9wrt": + metadata.Composer, _ = readM4ATextValue(f, header, fileSize) + case "\xa9cmt": + metadata.Comment, _ = readM4ATextValue(f, header, fileSize) + case "cprt": + metadata.Copyright, _ = readM4ATextValue(f, header, fileSize) + case "\xa9lyr": + metadata.Lyrics, _ = readM4ATextValue(f, header, fileSize) + case "trkn": + metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fileSize) + case "disk": + metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fileSize) + case "----": + name, value, freeformErr := readM4AFreeformValue(f, header, fileSize) + if freeformErr == nil { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "ISRC": + metadata.ISRC = value + case "LABEL", "ORGANIZATION": + metadata.Label = value + case "COMMENT": + if metadata.Comment == "" { + metadata.Comment = value + } + case "COMPOSER": + if metadata.Composer == "" { + metadata.Composer = value + } + case "COPYRIGHT": + if metadata.Copyright == "" { + metadata.Copyright = value + } + case "LYRICS", "UNSYNCEDLYRICS": + if metadata.Lyrics == "" { + metadata.Lyrics = value + } + case "REPLAYGAIN_TRACK_GAIN": + metadata.ReplayGainTrackGain = value + case "REPLAYGAIN_TRACK_PEAK": + metadata.ReplayGainTrackPeak = value + case "REPLAYGAIN_ALBUM_GAIN": + metadata.ReplayGainAlbumGain = value + case "REPLAYGAIN_ALBUM_PEAK": + metadata.ReplayGainAlbumPeak = value + } + } + } + + pos += header.size + } + + if metadata.Title == "" && + metadata.Artist == "" && + metadata.Album == "" && + metadata.AlbumArtist == "" && + metadata.Lyrics == "" && + metadata.TrackNumber == 0 && + metadata.DiscNumber == 0 { + return nil, fmt.Errorf("no M4A tags found") + } + + return metadata, nil +} + +func extractLyricsFromM4A(filePath string) (string, error) { + metadata, err := ReadM4ATags(filePath) + if err != nil { + return "", err + } + if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" { + return "", fmt.Errorf("no lyrics found in file") + } + return metadata.Lyrics, nil +} + +func extractCoverFromM4A(filePath string) ([]byte, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + fileSize := fi.Size() + + ilst, err := findM4AIlstAtom(f, fileSize) + if err != nil { + return nil, err + } + return extractCoverFromM4AIlst(f, fileSize, ilst) +} + +func extractCoverFromM4AIlst(f *os.File, fileSize int64, ilst atomHeader) ([]byte, error) { + bodyStart := ilst.offset + ilst.headerSize + bodySize := ilst.size - ilst.headerSize + + covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("cover atom not found") + } + + dataStart := covr.offset + covr.headerSize + dataSize := covr.size - covr.headerSize + + dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("data atom not found in cover") + } + + // data atom: header + 4 bytes type indicator + 4 bytes locale + imgStart := dataAtom.offset + dataAtom.headerSize + 8 + imgLen := dataAtom.size - dataAtom.headerSize - 8 + if imgLen <= 0 { + return nil, fmt.Errorf("empty cover data") + } + + buf := make([]byte, imgLen) + if _, err := f.ReadAt(buf, imgStart); err != nil { + return nil, err + } + + return buf, nil +} + +// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags. +// It tries two common layouts: +// 1. moov > udta > meta > ilst (iTunes, FFmpeg default) +// 2. moov > meta > ilst (some encoders omit the udta wrapper) +func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) { + moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil || !found { + return atomHeader{}, fmt.Errorf("moov not found") + } + + moovBodyStart := moov.offset + moov.headerSize + moovBodySize := moov.size - moov.headerSize + + // Path 1: moov > udta > meta > ilst + if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok { + udtaBodyStart := udta.offset + udta.headerSize + udtaBodySize := udta.size - udta.headerSize + if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 { + if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 { + return ilst, nil + } + } + } + + // Path 2: moov > meta > ilst (no udta wrapper) + if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok { + if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 { + return ilst, nil + } + } + + 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 + if payloadLen <= 0 { + return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ) + } + + buf := make([]byte, payloadLen) + if _, err := f.ReadAt(buf, payloadStart); err != nil { + return nil, err + } + return buf, nil +} + +func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) { + dataStart := parent.offset + parent.headerSize + dataSize := parent.size - parent.headerSize + + dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("data atom not found in %s", parent.typ) + } + return readM4ADataAtomPayload(f, dataAtom) +} + +func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) { + payload, err := readM4ADataPayload(f, parent, fileSize) + if err != nil { + return "", err + } + return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil +} + +func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) { + payload, err := readM4ADataPayload(f, parent, fileSize) + if err != nil { + return 0, 0, err + } + if len(payload) < 6 { + return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ) + } + return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil +} + +func parsePositiveInt(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + n, _ := strconv.Atoi(value) + return n +} + +func formatIndexValue(number, total int) string { + if number <= 0 { + return "" + } + if total > 0 { + return fmt.Sprintf("%d/%d", number, total) + } + return strconv.Itoa(number) +} + +func hasMapKey(fields map[string]string, key string) bool { + _, ok := fields[key] + return ok +} + +func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) { + start := parent.offset + parent.headerSize + end := parent.offset + parent.size + + var nameValue string + var dataValue string + for pos := start; pos+8 <= end; { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return "", "", err + } + if header.size == 0 { + header.size = end - pos + } + if header.size < header.headerSize { + return "", "", fmt.Errorf("invalid atom size for %s", header.typ) + } + + switch header.typ { + case "mean": + // Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip. + case "name": + // The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text. + // It does NOT contain a nested "data" atom, so read the payload directly. + payloadStart := header.offset + header.headerSize + 4 + payloadLen := header.size - header.headerSize - 4 + if payloadLen > 0 { + buf := make([]byte, payloadLen) + if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil { + nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00")) + } + } + case "data": + payload, payloadErr := readM4ADataAtomPayload(f, header) + if payloadErr == nil { + dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00")) + } + } + + pos += header.size + } + + if nameValue == "" || dataValue == "" { + return "", "", fmt.Errorf("freeform M4A tag incomplete") + } + + return nameValue, dataValue, nil +} + +type m4aMetadataPath struct { + moov atomHeader + udta *atomHeader + meta atomHeader + ilst atomHeader +} + +func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) { + moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil || !found { + return m4aMetadataPath{}, fmt.Errorf("moov not found") + } + + moovBodyStart := moov.offset + moov.headerSize + moovBodySize := moov.size - moov.headerSize + + if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok { + udtaBodyStart := udta.offset + udta.headerSize + udtaBodySize := udta.size - udta.headerSize + if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 { + if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 { + udtaCopy := udta + return m4aMetadataPath{ + moov: moov, + udta: &udtaCopy, + meta: meta, + ilst: ilst, + }, nil + } + } + } + + if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok { + if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 { + return m4aMetadataPath{ + moov: moov, + meta: meta, + ilst: ilst, + }, nil + } + } + + return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)") +} + +func buildM4AAtom(typ string, payload []byte) []byte { + size := int64(8 + len(payload)) + buf := make([]byte, 8+len(payload)) + binary.BigEndian.PutUint32(buf[0:4], uint32(size)) + copy(buf[4:8], []byte(typ)) + copy(buf[8:], payload) + return buf +} + +func buildM4AFreeformAtom(name, value string) []byte { + meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...) + namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...) + dataPayload := make([]byte, 8+len(value)) + binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text + copy(dataPayload[8:], []byte(value)) + + payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...) + payload = append(payload, buildM4AAtom("name", namePayload)...) + payload = append(payload, buildM4AAtom("data", dataPayload)...) + return buildM4AAtom("----", payload) +} + +func buildITunNORMTag(trackGain, trackPeak string) string { + gainDb, ok := parseReplayGainDb(trackGain) + if !ok { + return "" + } + peakLinear, ok := parseReplayGainPeak(trackPeak) + if !ok { + return "" + } + + clamp := func(v int64) int64 { + if v < 0 { + return 0 + } + if v > 65534 { + return 65534 + } + return v + } + + g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0))) + g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0))) + peak := clamp(int64(math.Round(peakLinear * 32768.0))) + values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0} + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value))) + } + return strings.Join(parts, " ") +} + +var replayGainNumberPattern = regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`) + +func parseReplayGainDb(value string) (float64, bool) { + match := replayGainNumberPattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(match) < 2 { + return 0, false + } + parsed, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0, false + } + return parsed, true +} + +func parseReplayGainPeak(value string) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || parsed <= 0 { + return 0, false + } + return parsed, true +} + +func collectM4AReplayGainFields(fields map[string]string) map[string]string { + result := map[string]string{} + if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" { + result["replaygain_track_gain"] = value + } + if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" { + result["replaygain_track_peak"] = value + } + if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" { + result["replaygain_album_gain"] = value + } + if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" { + result["replaygain_album_peak"] = value + } + + if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" { + result["iTunNORM"] = norm + } + + return result +} + +func writeAtomSize(buf []byte, header atomHeader, newSize int64) error { + if newSize <= 0 { + return fmt.Errorf("invalid size for %s", header.typ) + } + if header.headerSize == 16 { + if int(header.offset)+16 > len(buf) { + return io.ErrUnexpectedEOF + } + binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1) + binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize)) + return nil + } + if newSize > math.MaxUint32 { + return fmt.Errorf("atom %s too large for 32-bit header", header.typ) + } + if int(header.offset)+8 > len(buf) { + return io.ErrUnexpectedEOF + } + binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize)) + return nil +} + +func EditM4AReplayGain(filePath string, fields map[string]string) error { + replayGainFields := collectM4AReplayGainFields(fields) + if len(replayGainFields) == 0 { + return nil + } + + remove := map[string]struct{}{ + "REPLAYGAIN_TRACK_GAIN": {}, + "REPLAYGAIN_TRACK_PEAK": {}, + "REPLAYGAIN_ALBUM_GAIN": {}, + "REPLAYGAIN_ALBUM_PEAK": {}, + "ITUNNORM": {}, + } + + order := []string{ + "replaygain_track_gain", + "replaygain_track_peak", + "replaygain_album_gain", + "replaygain_album_peak", + "iTunNORM", + } + tags := make([]m4aFreeformTag, 0, len(order)) + for _, key := range order { + value := strings.TrimSpace(replayGainFields[key]) + if value == "" { + continue + } + name := key + if key != "iTunNORM" { + name = strings.ToLower(key) + } + tags = append(tags, m4aFreeformTag{name: name, value: value}) + } + + return writeM4AFreeformTags(filePath, remove, tags) +} + +type m4aFreeformTag struct { + name string + value string +} + +// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing +// freeform ("----") atom whose uppercased name is in `remove`, then appends the +// supplied tags (empty values are skipped, which effectively clears the field). +// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain. +// +// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so +// fields like ISRC and LABEL are silently dropped when written via -metadata. +// Writing them as iTunes freeform atoms natively is the only way they persist. +func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + + 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 + } + + // Only the moov box is buffered; the audio bulk is streamed on write. + base := path.moov.offset + moovBuf := make([]byte, path.moov.size) + if _, err := f.ReadAt(moovBuf, base); err != nil { + return err + } + + bodyStart := path.ilst.offset + path.ilst.headerSize + bodyEnd := path.ilst.offset + path.ilst.size + newBody := make([]byte, 0, int(path.ilst.size)) + + for pos := bodyStart; pos+8 <= bodyEnd; { + header, readErr := readAtomHeaderAt(f, pos, info.Size()) + if readErr != nil { + return readErr + } + if header.size == 0 { + header.size = bodyEnd - pos + } + if header.size < header.headerSize { + return fmt.Errorf("invalid atom size for %s", header.typ) + } + + keep := true + if header.typ == "----" { + name, _, freeformErr := readM4AFreeformValue(f, header, info.Size()) + if freeformErr == nil { + if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok { + keep = false + } + } + } + if keep { + newBody = append(newBody, moovBuf[pos-base:pos-base+header.size]...) + } + + pos += header.size + } + + for _, tag := range tags { + if strings.TrimSpace(tag.value) == "" { + continue + } + newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...) + } + + newIlst := buildM4AAtom("ilst", newBody) + ilstRel := path.ilst.offset - base + updated := append([]byte{}, moovBuf[:ilstRel]...) + updated = append(updated, newIlst...) + updated = append(updated, moovBuf[ilstRel+path.ilst.size:]...) + + // The path headers carry absolute file offsets; rebase them onto the + // moov-rooted buffer before patching sizes. + rel := func(h atomHeader) atomHeader { + h.offset -= base + return h + } + delta := int64(len(newIlst)) - path.ilst.size + if err := writeAtomSize(updated, rel(path.ilst), path.ilst.size+delta); err != nil { + return err + } + if err := writeAtomSize(updated, rel(path.meta), path.meta.size+delta); err != nil { + return err + } + if path.udta != nil { + if err := writeAtomSize(updated, rel(*path.udta), path.udta.size+delta); err != nil { + return err + } + } + if err := writeAtomSize(updated, rel(path.moov), path.moov.size+delta); err != nil { + return err + } + // Keep sample pointers valid when moov precedes mdat: every stco/co64 + // entry at or beyond the resized ilst must shift with it. Entries hold + // absolute file offsets, so compare against the absolute ilst position. + if delta != 0 { + if moov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok { + shiftChunkOffsets(updated, moov, path.ilst.offset, delta) + } + } + + // Release the read handle before replacing the file (required on Windows). + f.Close() + return replaceFileSectionsStreaming(filePath, []fileSection{ + {start: base, end: base + path.moov.size, data: updated}, + }) +} + +// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes +// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so +// they must be written natively for the values to actually persist. An empty +// value clears the corresponding tag. Other (recognized) tags are left intact. +func EditM4AFreeformText(filePath string, fields map[string]string) error { + _, hasISRC := fields["isrc"] + _, hasLabel := fields["label"] + if !hasISRC && !hasLabel { + return nil + } + + remove := map[string]struct{}{} + tags := make([]m4aFreeformTag, 0, 2) + if hasISRC { + remove["ISRC"] = struct{}{} + tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])}) + } + if hasLabel { + remove["LABEL"] = struct{}{} + remove["ORGANIZATION"] = struct{}{} + tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])}) + } + + return writeM4AFreeformTags(filePath, remove, tags) +} + +func GetM4AQuality(filePath string) (AudioQuality, error) { + f, err := os.Open(filePath) + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err) + } + fileSize := info.Size() + return m4aQualityFromFile(f, fileSize) +} + +func m4aQualityFromFile(f *os.File, fileSize int64) (AudioQuality, error) { + moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err) + } + if !moovFound { + return AudioQuality{}, fmt.Errorf("moov atom not found") + } + + moovStart := moovHeader.offset + moovEnd := moovHeader.offset + moovHeader.size + duration := readM4ADurationSeconds(f, moovHeader, fileSize) + + sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize) + if err != nil { + return AudioQuality{}, err + } + + buf := make([]byte, 32) + if _, err := f.ReadAt(buf, sampleOffset); err != nil { + return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) + } + + // AudioSampleEntry layout from the box type field: + // [0:4] type ("mp4a"/"alac") + // [4:10] SampleEntry.reserved + // [10:12] data_reference_index + // [12:20] reserved[8] + // [20:22] channelcount + // [22:24] samplesize (bit depth) + // [24:26] pre_defined + // [26:28] reserved + // [28:32] samplerate (16.16 fixed-point) + sampleRate := int(buf[28])<<8 | int(buf[29]) + bitDepth := 0 + codec := normalizeM4AAudioCodec(atomType) + + switch atomType { + case "alac": + bitDepth = int(buf[22])<<8 | int(buf[23]) + if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok { + if alacBitDepth > 0 { + bitDepth = alacBitDepth + } + if alacSampleRate > 0 { + sampleRate = alacSampleRate + } + } + case "fLaC": + bitDepth = int(buf[22])<<8 | int(buf[23]) + if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok { + if flacBitDepth > 0 { + bitDepth = flacBitDepth + } + if flacSampleRate > 0 { + sampleRate = flacSampleRate + } + if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 { + duration = int(flacTotalSamples / int64(sampleRate)) + } + } + } + + bitrate := estimateAudioBitrateKbps(fileSize, duration) + if bitrate > 0 && bitrate < 16 { + bitrate = 0 + } + return AudioQuality{ + BitDepth: bitDepth, + SampleRate: sampleRate, + Duration: duration, + Bitrate: bitrate, + Codec: codec, + }, nil +} + +func normalizeM4AAudioCodec(atomType string) string { + switch atomType { + case "mp4a": + return "aac" + case "alac": + return "alac" + case "fLaC": + return "flac" + case "ec-3": + return "eac3" + case "ac-3": + return "ac3" + case "ac-4": + return "ac4" + default: + return strings.TrimSpace(atomType) + } +} + +func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int { + if fileSize <= 0 || durationSeconds <= 0 { + return 0 + } + return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0)) +} + +func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int { + childStart := moovHeader.offset + moovHeader.headerSize + childSize := moovHeader.size - moovHeader.headerSize + mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize) + if err == nil && found { + if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 { + return duration + } + } + + return readM4ATrackDurationSeconds(f, moovHeader, fileSize) +} + +func readMP4DurationAtomSeconds(f *os.File, header atomHeader, _ int64) int { + payloadOffset := header.offset + header.headerSize + versionBuf := make([]byte, 1) + if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil { + return 0 + } + + if versionBuf[0] == 1 { + buf := make([]byte, 32) + if _, err := f.ReadAt(buf, payloadOffset); err != nil { + return 0 + } + timescale := binary.BigEndian.Uint32(buf[20:24]) + duration := binary.BigEndian.Uint64(buf[24:32]) + if timescale == 0 || duration == 0 { + return 0 + } + return int(math.Round(float64(duration) / float64(timescale))) + } + + buf := make([]byte, 20) + if _, err := f.ReadAt(buf, payloadOffset); err != nil { + return 0 + } + timescale := binary.BigEndian.Uint32(buf[12:16]) + duration := binary.BigEndian.Uint32(buf[16:20]) + if timescale == 0 || duration == 0 { + return 0 + } + return int(math.Round(float64(duration) / float64(timescale))) +} + +func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int { + childStart := moovHeader.offset + moovHeader.headerSize + childSize := moovHeader.size - moovHeader.headerSize + bestDuration := 0 + _ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool { + if header.typ == "mdhd" { + if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration { + bestDuration = duration + } + return false + } + return header.typ == "trak" || header.typ == "mdia" + }) + return bestDuration +} + +func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error { + if size <= 0 { + return nil + } + + end := start + size + for pos := start; pos+8 <= end; { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return err + } + atomSize := header.size + if atomSize == 0 { + atomSize = end - pos + } + if atomSize < header.headerSize { + return fmt.Errorf("invalid atom size for %s", header.typ) + } + header.size = atomSize + if visit(header) { + childStart := header.offset + header.headerSize + childSize := header.size - header.headerSize + if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil { + return err + } + } + pos += atomSize + } + return nil +} + +func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) { + if sampleOffset < 4 { + return 0, 0, false + } + + sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize) + if err != nil { + return 0, 0, false + } + + childStart := sampleOffset + 32 + childEnd := sampleEntryHeader.offset + sampleEntryHeader.size + if childStart >= childEnd { + return 0, 0, false + } + + configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize) + if err != nil || !found { + return 0, 0, false + } + + payloadSize := configHeader.size - configHeader.headerSize + if payloadSize <= 0 { + return 0, 0, false + } + + payload := make([]byte, payloadSize) + if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil { + return 0, 0, false + } + + return parseALACSpecificConfig(payload) +} + +func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) { + if sampleOffset < 4 { + return 0, 0, 0, false + } + + sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize) + if err != nil { + return 0, 0, 0, false + } + + childStart := sampleOffset + 32 + childEnd := sampleEntryHeader.offset + sampleEntryHeader.size + if childStart >= childEnd { + return 0, 0, 0, false + } + + configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize) + if err != nil || !found { + return 0, 0, 0, false + } + + payloadSize := configHeader.size - configHeader.headerSize + if payloadSize <= 0 { + return 0, 0, 0, false + } + + payload := make([]byte, payloadSize) + if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil { + return 0, 0, 0, false + } + + return parseMP4FLACSpecificConfig(payload) +} + +func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) { + if len(payload) >= 4 && string(payload[:4]) == "fLaC" { + payload = payload[4:] + } else if len(payload) >= 4 { + // FLACSpecificBox starts with a full-box version/flags field. + payload = payload[4:] + } + + for len(payload) >= 4 { + blockType := payload[0] & 0x7F + blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) + if blockLen < 0 || len(payload) < 4+blockLen { + return 0, 0, 0, false + } + block := payload[4 : 4+blockLen] + if blockType == 0 && len(block) >= 34 { + bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34]) + return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0 + } + payload = payload[4+blockLen:] + } + + return 0, 0, 0, false +} + +func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) { + if len(streamInfo) < 18 { + return 0, 0, 0 + } + sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4) + bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1 + totalSamples := int64(streamInfo[13]&0x0F)<<32 | + int64(streamInfo[14])<<24 | + int64(streamInfo[15])<<16 | + int64(streamInfo[16])<<8 | + int64(streamInfo[17]) + return bitsPerSample, sampleRate, totalSamples +} + +func parseALACSpecificConfig(payload []byte) (int, int, bool) { + if len(payload) < 24 { + return 0, 0, false + } + + bitDepth := int(payload[5]) + sampleRate := int(binary.BigEndian.Uint32(payload[20:24])) + if bitDepth > 0 && sampleRate > 0 { + return bitDepth, sampleRate, true + } + + // Some encoders prepend 4 bytes before the ALACSpecificConfig payload. + if len(payload) >= 28 { + bitDepth = int(payload[9]) + sampleRate = int(binary.BigEndian.Uint32(payload[24:28])) + if bitDepth > 0 && sampleRate > 0 { + return bitDepth, sampleRate, true + } + } + + return 0, 0, false +} + +type atomHeader struct { + offset int64 + size int64 + headerSize int64 + typ string +} + +func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) { + if offset+8 > fileSize { + return atomHeader{}, io.ErrUnexpectedEOF + } + + headerBuf := make([]byte, 8) + if _, err := f.ReadAt(headerBuf, offset); err != nil { + return atomHeader{}, err + } + + size32 := binary.BigEndian.Uint32(headerBuf[0:4]) + typ := string(headerBuf[4:8]) + + if size32 == 1 { + if offset+16 > fileSize { + return atomHeader{}, io.ErrUnexpectedEOF + } + extBuf := make([]byte, 8) + if _, err := f.ReadAt(extBuf, offset+8); err != nil { + return atomHeader{}, err + } + size64 := binary.BigEndian.Uint64(extBuf) + return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil + } + + return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil +} + +func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) { + if size <= 0 { + return atomHeader{}, false, nil + } + + end := start + size + pos := start + + for pos+8 <= end { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return atomHeader{}, false, err + } + + atomSize := header.size + if atomSize == 0 { + atomSize = end - pos + } + + if atomSize < header.headerSize { + return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ) + } + + header.size = atomSize + if header.typ == target { + return header, true, nil + } + + pos += atomSize + } + + return atomHeader{}, false, nil +} + +func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { + const chunkSize = 64 * 1024 + patterns := [][]byte{ + []byte("mp4a"), + []byte("alac"), + []byte("fLaC"), + []byte("ec-3"), + []byte("ac-3"), + []byte("ac-4"), + } + + var tail []byte + readPos := start + + for readPos < end { + toRead := end - readPos + if toRead > chunkSize { + toRead = chunkSize + } + + buf := make([]byte, toRead) + n, err := f.ReadAt(buf, readPos) + if err != nil && err != io.EOF { + return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err) + } + if n == 0 { + break + } + + data := append(tail, buf[:n]...) + bestIdx := -1 + bestType := "" + for _, pattern := range patterns { + idx := bytes.Index(data, pattern) + if idx >= 0 && (bestIdx < 0 || idx < bestIdx) { + bestIdx = idx + bestType = string(pattern) + } + } + + if bestIdx >= 0 { + absolute := readPos - int64(len(tail)) + int64(bestIdx) + if absolute+32 > fileSize { + return 0, "", fmt.Errorf("audio info not found in M4A file") + } + return absolute, bestType, nil + } + + if len(data) >= 3 { + tail = append([]byte{}, data[len(data)-3:]...) + } else { + tail = append([]byte{}, data...) + } + + readPos += int64(n) + } + + return 0, "", fmt.Errorf("audio info not found in M4A file") +}