package gobackend import ( "bytes" "encoding/binary" "fmt" "io" "os" "strconv" "strings" ) type AudioMetadata struct { Title string Artist string Album string AlbumArtist string Genre string Year string Date string TrackNumber int TotalTracks int DiscNumber int TotalDiscs int ISRC string Lyrics string Label string Copyright string Composer string Comment string // ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831") ReplayGainTrackGain string ReplayGainTrackPeak string ReplayGainAlbumGain string ReplayGainAlbumPeak string } type MP3Quality struct { SampleRate int BitDepth int Duration int Bitrate int } type OggQuality struct { SampleRate int BitDepth int Duration int Bitrate int // estimated bitrate in bps } func ReadID3Tags(filePath string) (*AudioMetadata, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() metadata := &AudioMetadata{} id3v2, err := readID3v2(file) if err == nil && id3v2 != nil { metadata = id3v2 } if metadata.Title == "" || metadata.Artist == "" { id3v1, err := readID3v1(file) if err == nil && id3v1 != nil { if metadata.Title == "" { metadata.Title = id3v1.Title } if metadata.Artist == "" { metadata.Artist = id3v1.Artist } if metadata.Album == "" { metadata.Album = id3v1.Album } if metadata.Year == "" { metadata.Year = id3v1.Year } if metadata.Genre == "" { metadata.Genre = id3v1.Genre } } } if metadata.Title == "" && metadata.Artist == "" { return nil, fmt.Errorf("no ID3 tags found") } return metadata, nil } func readID3v2(file *os.File) (*AudioMetadata, error) { file.Seek(0, io.SeekStart) header := make([]byte, 10) if _, err := io.ReadFull(file, header); err != nil { return nil, err } if string(header[0:3]) != "ID3" { return nil, fmt.Errorf("no ID3v2 header") } majorVersion := header[3] flags := header[5] unsync := (flags & 0x80) != 0 extendedHeader := (flags & 0x40) != 0 footerPresent := (flags & 0x10) != 0 size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9]) tagData := make([]byte, size) if _, err := io.ReadFull(file, tagData); err != nil { return nil, err } if footerPresent && len(tagData) >= 10 { footerStart := len(tagData) - 10 if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" { tagData = tagData[:footerStart] } } if extendedHeader { if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) { tagData = tagData[skip:] } } metadata := &AudioMetadata{} if majorVersion == 2 { parseID3v22Frames(tagData, metadata, unsync) } else { parseID3v23Frames(tagData, metadata, majorVersion, unsync) } return metadata, nil } func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) { pos := 0 for pos+6 < len(data) { frameID := string(data[pos : pos+3]) if frameID[0] == 0 { break } frameSize := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5]) if frameSize <= 0 || pos+6+frameSize > len(data) { break } frameData := data[pos+6 : pos+6+frameSize] if tagUnsync { frameData = removeUnsync(frameData) } value := firstTextValue(extractTextFrame(frameData)) switch frameID { case "TT2": metadata.Title = value case "TP1": metadata.Artist = value case "TP2": metadata.AlbumArtist = value case "TAL": metadata.Album = value case "TYE": metadata.Year = value case "TCO": metadata.Genre = cleanGenre(value) case "TRK": metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) case "TPA": metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) case "TCM": metadata.Composer = value case "TPB": metadata.Label = value case "TCR": metadata.Copyright = value case "ULT": if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" { metadata.Lyrics = v } case "TXX": desc, userValue := extractUserTextFrame(frameData) if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" { metadata.Lyrics = userValue } } pos += 6 + frameSize } } func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUnsync bool) { pos := 0 for pos+10 < len(data) { frameID := string(data[pos : pos+4]) if frameID[0] == 0 { break } var frameSize int if version == 4 { frameSize = int(data[pos+4])<<21 | int(data[pos+5])<<14 | int(data[pos+6])<<7 | int(data[pos+7]) } else { frameSize = int(data[pos+4])<<24 | int(data[pos+5])<<16 | int(data[pos+6])<<8 | int(data[pos+7]) } if frameSize <= 0 || pos+10+frameSize > len(data) { break } frameData := data[pos+10 : pos+10+frameSize] statusFlags := data[pos+8] _ = statusFlags formatFlags := data[pos+9] if version == 3 { const ( id3v23FlagCompression = 0x80 id3v23FlagEncryption = 0x40 id3v23FlagGrouping = 0x20 ) if formatFlags&(id3v23FlagCompression|id3v23FlagEncryption) != 0 { pos += 10 + frameSize continue } if formatFlags&id3v23FlagGrouping != 0 { if len(frameData) < 1 { pos += 10 + frameSize continue } frameData = frameData[1:] } if tagUnsync { frameData = removeUnsync(frameData) } } else if version == 4 { const ( id3v24FlagGrouping = 0x40 id3v24FlagCompression = 0x08 id3v24FlagEncryption = 0x04 id3v24FlagUnsync = 0x02 id3v24FlagDataLen = 0x01 ) if formatFlags&id3v24FlagGrouping != 0 { if len(frameData) < 1 { pos += 10 + frameSize continue } frameData = frameData[1:] } if formatFlags&id3v24FlagDataLen != 0 { if len(frameData) < 4 { pos += 10 + frameSize continue } frameData = frameData[4:] } if formatFlags&id3v24FlagUnsync != 0 || tagUnsync { frameData = removeUnsync(frameData) } if formatFlags&(id3v24FlagCompression|id3v24FlagEncryption) != 0 { pos += 10 + frameSize continue } } value := firstTextValue(extractTextFrame(frameData)) switch frameID { case "TIT2": metadata.Title = value case "TPE1": metadata.Artist = value case "TPE2": metadata.AlbumArtist = value case "TALB": metadata.Album = value case "TYER", "TDRC": metadata.Year = value if len(value) >= 4 { metadata.Date = value } case "TCON": metadata.Genre = cleanGenre(value) case "TRCK": metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) case "TPOS": metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) case "TSRC": metadata.ISRC = value case "TCOM": metadata.Composer = value case "TPUB": metadata.Label = value case "TCOP": metadata.Copyright = value case "COMM": if v := extractLangTextFrame(frameData); v != "" { metadata.Comment = v } case "USLT": if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" { metadata.Lyrics = v } case "TXXX": desc, userValue := extractUserTextFrame(frameData) if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" { metadata.Lyrics = userValue } upperDesc := strings.ToUpper(desc) switch upperDesc { case "REPLAYGAIN_TRACK_GAIN": metadata.ReplayGainTrackGain = userValue case "REPLAYGAIN_TRACK_PEAK": metadata.ReplayGainTrackPeak = userValue case "REPLAYGAIN_ALBUM_GAIN": metadata.ReplayGainAlbumGain = userValue case "REPLAYGAIN_ALBUM_PEAK": metadata.ReplayGainAlbumPeak = userValue } } pos += 10 + frameSize } } func readID3v1(file *os.File) (*AudioMetadata, error) { if _, err := file.Seek(-128, io.SeekEnd); err != nil { return nil, err } tag := make([]byte, 128) if _, err := io.ReadFull(file, tag); err != nil { return nil, err } if string(tag[0:3]) != "TAG" { return nil, fmt.Errorf("no ID3v1 tag") } metadata := &AudioMetadata{ Title: strings.TrimRight(string(tag[3:33]), " \x00"), Artist: strings.TrimRight(string(tag[33:63]), " \x00"), Album: strings.TrimRight(string(tag[63:93]), " \x00"), Year: strings.TrimRight(string(tag[93:97]), " \x00"), } if tag[125] == 0 && tag[126] != 0 { metadata.TrackNumber = int(tag[126]) } genreIndex := int(tag[127]) if genreIndex < len(id3v1Genres) { metadata.Genre = id3v1Genres[genreIndex] } return metadata, nil } func extractTextFrame(data []byte) string { if len(data) == 0 { return "" } encoding := data[0] text := data[1:] switch encoding { case 0: // ISO-8859-1 return strings.TrimRight(string(text), "\x00") case 1: // UTF-16 with BOM return decodeUTF16(text) case 2: // UTF-16BE return decodeUTF16BE(text) case 3: // UTF-8 return strings.TrimRight(string(text), "\x00") default: return strings.TrimRight(string(text), "\x00") } } // extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte // language code, and null-terminated descriptor before the text (COMM, USLT). func extractLangTextFrame(data []byte) string { if len(data) < 5 { return "" } encoding := data[0] rest := data[4:] var text []byte switch encoding { case 1, 2: for i := 0; i+1 < len(rest); i += 2 { if rest[i] == 0 && rest[i+1] == 0 { text = rest[i+2:] break } } default: idx := bytes.IndexByte(rest, 0) if idx >= 0 && idx+1 < len(rest) { text = rest[idx+1:] } else { text = rest } } if len(text) == 0 { return "" } framed := make([]byte, 1+len(text)) framed[0] = encoding copy(framed[1:], text) return extractTextFrame(framed) } func extractUserTextFrame(data []byte) (string, string) { if len(data) < 2 { return "", "" } encoding := data[0] payload := data[1:] var descRaw, valueRaw []byte switch encoding { case 1, 2: for i := 0; i+1 < len(payload); i += 2 { if payload[i] == 0 && payload[i+1] == 0 { descRaw = payload[:i] valueRaw = payload[i+2:] break } } default: idx := bytes.IndexByte(payload, 0) if idx >= 0 { descRaw = payload[:idx] if idx+1 <= len(payload) { valueRaw = payload[idx+1:] } } } if len(valueRaw) == 0 { return "", "" } descFramed := make([]byte, 1+len(descRaw)) descFramed[0] = encoding copy(descFramed[1:], descRaw) valueFramed := make([]byte, 1+len(valueRaw)) valueFramed[0] = encoding copy(valueFramed[1:], valueRaw) return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed)) } func isLyricsDescription(description string) bool { switch strings.ToLower(strings.TrimSpace(description)) { case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "uslt", "lrc": return true default: return false } } func decodeUTF16(data []byte) string { if len(data) < 2 { return "" } var littleEndian bool if data[0] == 0xFF && data[1] == 0xFE { littleEndian = true data = data[2:] } else if data[0] == 0xFE && data[1] == 0xFF { littleEndian = false data = data[2:] } return decodeUTF16Data(data, littleEndian) } func decodeUTF16BE(data []byte) string { return decodeUTF16Data(data, false) } func decodeUTF16Data(data []byte, littleEndian bool) string { if len(data) < 2 { return "" } var runes []rune for i := 0; i+1 < len(data); i += 2 { var r uint16 if littleEndian { r = uint16(data[i]) | uint16(data[i+1])<<8 } else { r = uint16(data[i])<<8 | uint16(data[i+1]) } if r == 0 { break } runes = append(runes, rune(r)) } return string(runes) } func cleanGenre(genre string) string { if len(genre) == 0 { return "" } if genre[0] == '(' { end := strings.Index(genre, ")") if end > 0 { numStr := genre[1:end] if num, err := strconv.Atoi(numStr); err == nil && num < len(id3v1Genres) { if end+1 < len(genre) { return genre[end+1:] } return id3v1Genres[num] } } } return genre } func parseIndexPair(s string) (int, int) { s = strings.TrimSpace(s) if s == "" { return 0, 0 } first := s second := "" if idx := strings.Index(s, "/"); idx > 0 { first = s[:idx] second = s[idx+1:] } num, _ := strconv.Atoi(strings.TrimSpace(first)) total, _ := strconv.Atoi(strings.TrimSpace(second)) return num, total } func removeUnsync(data []byte) []byte { if len(data) == 0 { return data } out := make([]byte, 0, len(data)) for i := 0; i < len(data); i++ { b := data[i] out = append(out, b) if b == 0xFF && i+1 < len(data) && data[i+1] == 0x00 { i++ } } return out } func extendedHeaderSize(data []byte, version byte) int { if len(data) < 4 { return 0 } var size int switch version { case 3: size = int(binary.BigEndian.Uint32(data[:4])) case 4: size = syncsafeToInt(data[:4]) default: return 0 } if size <= 0 { return 0 } total := size + 4 if total <= len(data) { return total } if size <= len(data) { return size } return 0 } func syncsafeToInt(b []byte) int { if len(b) < 4 { return 0 } return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3]) } func firstTextValue(s string) string { if idx := strings.IndexByte(s, 0); idx >= 0 { return s[:idx] } return s } func GetMP3Quality(filePath string) (*MP3Quality, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() quality := &MP3Quality{} stat, err := file.Stat() if err != nil { return nil, err } fileSize := stat.Size() header := make([]byte, 10) if _, err := io.ReadFull(file, header); err != nil { return nil, err } var audioStart int64 = 0 if string(header[0:3]) == "ID3" { tagSize := int64(header[6])<<21 | int64(header[7])<<14 | int64(header[8])<<7 | int64(header[9]) audioStart = 10 + tagSize } file.Seek(audioStart, io.SeekStart) frameHeader := make([]byte, 4) var frameStart int64 = -1 for i := 0; i < 10000; i++ { if _, err := io.ReadFull(file, frameHeader); err != nil { break } if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 { pos, _ := file.Seek(0, io.SeekCurrent) frameStart = pos - 4 break } file.Seek(-3, io.SeekCurrent) } if frameStart < 0 { return quality, nil } version := (frameHeader[1] >> 3) & 0x03 layer := (frameHeader[1] >> 1) & 0x03 bitrateIdx := (frameHeader[2] >> 4) & 0x0F sampleRateIdx := (frameHeader[2] >> 2) & 0x03 channelMode := (frameHeader[3] >> 6) & 0x03 sampleRates := [][]int{ {11025, 12000, 8000}, {0, 0, 0}, {22050, 24000, 16000}, {44100, 48000, 32000}, } if version < 4 && sampleRateIdx < 3 { quality.SampleRate = sampleRates[version][sampleRateIdx] } if version == 3 && layer == 1 { bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0} if bitrateIdx < 16 { quality.Bitrate = bitrates[bitrateIdx] * 1000 } } if (version == 0 || version == 2) && layer == 1 { bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0} if bitrateIdx < 16 { quality.Bitrate = bitrates[bitrateIdx] * 1000 } } samplesPerFrame := 1152 // MPEG1 Layer III if version == 0 || version == 2 { samplesPerFrame = 576 // MPEG2/2.5 Layer III } var xingOffset int if version == 3 { // MPEG1 if channelMode == 3 { // Mono xingOffset = 17 } else { xingOffset = 32 } } else { // MPEG2/2.5 if channelMode == 3 { xingOffset = 9 } else { xingOffset = 17 } } xingBuf := make([]byte, 200) file.Seek(frameStart+4, io.SeekStart) n, _ := io.ReadFull(file, xingBuf) xingBuf = xingBuf[:n] vbrFrames := 0 vbrBytes := int64(0) isVBR := false if xingOffset+8 <= n { tag := string(xingBuf[xingOffset : xingOffset+4]) if tag == "Xing" || tag == "Info" { flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8]) off := xingOffset + 8 if flags&0x01 != 0 && off+4 <= n { // Frames flag vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4])) off += 4 } if flags&0x02 != 0 && off+4 <= n { // Bytes flag vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4])) } if vbrFrames > 0 { isVBR = true } } } if !isVBR && 36+26 <= n { if string(xingBuf[32:36]) == "VBRI" { vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10])) vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14])) if vbrFrames > 0 { isVBR = true } } } if isVBR && vbrFrames > 0 && quality.SampleRate > 0 { totalSamples := int64(vbrFrames) * int64(samplesPerFrame) quality.Duration = int(totalSamples / int64(quality.SampleRate)) if vbrBytes > 0 && quality.Duration > 0 { quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration)) } else if quality.Duration > 0 { audioSize := fileSize - audioStart quality.Bitrate = int(audioSize * 8 / int64(quality.Duration)) } } else if quality.Bitrate > 0 { audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag if audioSize > 0 { quality.Duration = int(audioSize * 8 / int64(quality.Bitrate)) } } return quality, nil } var id3v1Genres = []string{ "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock", "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebop", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", "BritPop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Thrash Metal", "Anime", "J-Pop", "Synthpop", }