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") }