From 9dc44ecd88dae2558b45963c206d45e93126ff44 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 22:39:58 +0700 Subject: [PATCH] refactor(go): split Ogg parsing and cover-art extraction out of audio_metadata.go --- go_backend/audio_metadata.go | 827 ----------------------------- go_backend/audio_metadata_cover.go | 402 ++++++++++++++ go_backend/audio_metadata_ogg.go | 446 ++++++++++++++++ 3 files changed, 848 insertions(+), 827 deletions(-) create mode 100644 go_backend/audio_metadata_cover.go create mode 100644 go_backend/audio_metadata_ogg.go diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 6e80411f..5d6565cf 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -2,13 +2,10 @@ package gobackend import ( "bytes" - "encoding/base64" "encoding/binary" "fmt" "io" - "math" "os" - "path/filepath" "strconv" "strings" ) @@ -775,440 +772,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) { return quality, nil } -func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer file.Close() - - metadata := &AudioMetadata{} - - packets, err := collectOggPackets(file, 30, 80) - if err != nil && len(packets) == 0 { - return nil, err - } - - streamType := detectOggStreamType(packets) - for _, pkt := range packets { - if streamType == oggStreamOpus { - if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { - parseVorbisComments(pkt[8:], metadata) - break - } - continue - } - if streamType == oggStreamVorbis || streamType == oggStreamUnknown { - if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { - parseVorbisComments(pkt[7:], metadata) - break - } - } - if streamType == oggStreamUnknown { - if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { - parseVorbisComments(pkt[8:], metadata) - break - } - } - } - - if metadata.Title == "" && metadata.Artist == "" { - return nil, fmt.Errorf("no Vorbis comments found") - } - - return metadata, nil -} - -type oggPage struct { - headerType byte - segmentTable []byte - data []byte -} - -func readOggPageWithHeader(file *os.File) (*oggPage, error) { - header := make([]byte, 27) - if _, err := io.ReadFull(file, header); err != nil { - return nil, err - } - - if string(header[0:4]) != "OggS" { - return nil, fmt.Errorf("not an Ogg page") - } - - headerType := header[5] - numSegments := int(header[26]) - - segmentTable := make([]byte, numSegments) - if _, err := io.ReadFull(file, segmentTable); err != nil { - return nil, err - } - - var pageSize int - for _, seg := range segmentTable { - pageSize += int(seg) - } - - pageData := make([]byte, pageSize) - if _, err := io.ReadFull(file, pageData); err != nil { - return nil, err - } - - return &oggPage{ - headerType: headerType, - segmentTable: segmentTable, - data: pageData, - }, nil -} - -func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) { - const maxPacketSize = 10 * 1024 * 1024 - var packets [][]byte - var cur []byte - skipPacket := false - - for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ { - page, err := readOggPageWithHeader(file) - if err != nil { - if len(packets) > 0 { - return packets, nil - } - return nil, err - } - - if page.headerType&0x01 == 0 && len(cur) > 0 { - cur = nil - skipPacket = false - } - - offset := 0 - for _, seg := range page.segmentTable { - segLen := int(seg) - if offset+segLen > len(page.data) { - return packets, fmt.Errorf("invalid ogg segment size") - } - - if skipPacket { - offset += segLen - if segLen < 255 { - skipPacket = false - } - continue - } - - if len(cur)+segLen > maxPacketSize { - cur = nil - skipPacket = true - offset += segLen - if segLen < 255 { - skipPacket = false - } - continue - } - - cur = append(cur, page.data[offset:offset+segLen]...) - offset += segLen - - if segLen < 255 { - if len(cur) > 0 { - packets = append(packets, cur) - } - cur = nil - if len(packets) >= maxPackets { - return packets, nil - } - } - } - } - - return packets, nil -} - -type oggStreamType int - -const ( - oggStreamUnknown oggStreamType = iota - oggStreamOpus - oggStreamVorbis -) - -func detectOggStreamType(packets [][]byte) oggStreamType { - for _, p := range packets { - if len(p) >= 8 && string(p[0:8]) == "OpusHead" { - return oggStreamOpus - } - if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" { - return oggStreamVorbis - } - } - return oggStreamUnknown -} - -func parseVorbisComments(data []byte, metadata *AudioMetadata) { - if len(data) < 4 { - return - } - - reader := bytes.NewReader(data) - artistValues := make([]string, 0, 1) - albumArtistValues := make([]string, 0, 1) - - var vendorLen uint32 - if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { - return - } - - if vendorLen > uint32(len(data)-4) { - return - } - vendor := make([]byte, vendorLen) - if _, err := reader.Read(vendor); err != nil { - return - } - - var commentCount uint32 - if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil { - return - } - - for i := uint32(0); i < commentCount && i < 100; i++ { - var commentLen uint32 - if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil { - break - } - - remaining := uint32(reader.Len()) - if commentLen > remaining { - break - } - if commentLen > 512*1024 { - reader.Seek(int64(commentLen), io.SeekCurrent) - continue - } - - comment := make([]byte, commentLen) - if _, err := reader.Read(comment); err != nil { - break - } - - parts := strings.SplitN(string(comment), "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.ToUpper(parts[0]) - value := parts[1] - - switch key { - case "TITLE": - metadata.Title = value - case "ARTIST": - artistValues = append(artistValues, value) - case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST": - albumArtistValues = append(albumArtistValues, value) - case "ALBUM": - metadata.Album = value - case "DATE", "YEAR": - metadata.Date = value - if len(value) >= 4 { - metadata.Year = value[:4] - } - case "GENRE": - metadata.Genre = value - case "TRACKNUMBER", "TRACK": - metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) - case "DISCNUMBER", "DISC": - metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) - case "ISRC": - metadata.ISRC = value - case "COMPOSER": - metadata.Composer = value - case "COMMENT", "DESCRIPTION": - metadata.Comment = value - case "LYRICS", "UNSYNCEDLYRICS": - if metadata.Lyrics == "" { - metadata.Lyrics = value - } - case "ORGANIZATION", "LABEL", "PUBLISHER": - metadata.Label = value - case "COPYRIGHT": - metadata.Copyright = 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 - // Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS - // reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so - // consumers see one representation; explicit REPLAYGAIN_* wins. - case "R128_TRACK_GAIN": - if metadata.ReplayGainTrackGain == "" { - if db, ok := r128ToReplayGainDb(value); ok { - metadata.ReplayGainTrackGain = db - } - } - case "R128_ALBUM_GAIN": - if metadata.ReplayGainAlbumGain == "" { - if db, ok := r128ToReplayGainDb(value); ok { - metadata.ReplayGainAlbumGain = db - } - } - } - } - - if len(artistValues) > 0 { - metadata.Artist = joinVorbisCommentValues(artistValues) - } - if len(albumArtistValues) > 0 { - metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues) - } -} - -// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps, -// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference): -// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128. -func r128ToReplayGainDb(raw string) (string, bool) { - q, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil { - return "", false - } - return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true -} - -func GetOggQuality(filePath string) (*OggQuality, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer file.Close() - - quality := &OggQuality{} - - packets, err := collectOggPackets(file, 5, 10) - if err != nil && len(packets) == 0 { - return nil, err - } - - streamType := detectOggStreamType(packets) - if streamType == oggStreamUnknown { - if strings.HasSuffix(strings.ToLower(filePath), ".opus") { - streamType = oggStreamOpus - } else { - streamType = oggStreamVorbis - } - } - - isOpus := streamType == oggStreamOpus - var preSkip int - - if isOpus { - for _, pkt := range packets { - if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" { - quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) - if quality.SampleRate == 0 { - quality.SampleRate = 48000 - } - preSkip = int(binary.LittleEndian.Uint16(pkt[10:12])) - break - } - } - } else { - for _, pkt := range packets { - if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" { - quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) - break - } - } - } - - stat, err := file.Stat() - if err != nil { - return quality, nil - } - fileSize := stat.Size() - - granule := readLastOggGranulePosition(file, fileSize) - if granule > 0 { - if isOpus { - totalSamples := granule - int64(preSkip) - if totalSamples > 0 { - durationSec := float64(totalSamples) / 48000.0 - if durationSec > 0 { - quality.Duration = int(math.Round(durationSec)) - quality.Bitrate = int(float64(fileSize*8) / durationSec) - } - } - } else if quality.SampleRate > 0 { - durationSec := float64(granule) / float64(quality.SampleRate) - if durationSec > 0 { - quality.Duration = int(math.Round(durationSec)) - quality.Bitrate = int(float64(fileSize*8) / durationSec) - } - } - } - - if quality.Bitrate <= 0 && quality.Duration > 0 { - quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) - } - if quality.Duration > 24*60*60 { - quality.Duration = 0 - quality.Bitrate = 0 - } - if quality.Bitrate > 0 && quality.Bitrate < 8000 { - quality.Bitrate = 0 - } - - return quality, nil -} - -func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { - searchSize := int64(65536) - if searchSize > fileSize { - searchSize = fileSize - } - - buf := make([]byte, searchSize) - offset := fileSize - searchSize - if offset < 0 { - offset = 0 - } - n, err := file.ReadAt(buf, offset) - if err != nil && n == 0 { - return 0 - } - buf = buf[:n] - - for i := n - 4; i >= 0; i-- { - if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' { - continue - } - if i+27 > n { - continue - } - version := buf[i+4] - headerType := buf[i+5] - if version != 0 || headerType > 0x07 { - continue - } - segmentCount := int(buf[i+26]) - headerLen := 27 + segmentCount - if i+headerLen > n { - continue - } - payloadLen := 0 - for s := 0; s < segmentCount; s++ { - payloadLen += int(buf[i+27+s]) - } - if i+headerLen+payloadLen > n { - continue - } - return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14])) - } - return 0 -} - var id3v1Genres = []string{ "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", @@ -1238,393 +801,3 @@ var id3v1Genres = []string{ "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Thrash Metal", "Anime", "J-Pop", "Synthpop", } - -func extractMP3CoverArt(filePath string) ([]byte, string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, "", err - } - defer file.Close() - - 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] - 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 - } - - pos := 0 - var frameIDLen, headerLen int - if majorVersion == 2 { - frameIDLen = 3 - headerLen = 6 - } else { - frameIDLen = 4 - headerLen = 10 - } - - for pos+headerLen < len(tagData) { - frameID := string(tagData[pos : pos+frameIDLen]) - if frameID[0] == 0 { - break - } - - var frameSize int - switch majorVersion { - case 2: - frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5]) - case 4: - frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7]) - default: - frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7]) - } - - if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) { - break - } - - if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") { - frameData := tagData[pos+headerLen : pos+headerLen+frameSize] - imageData, mimeType := parseAPICFrame(frameData, majorVersion) - if len(imageData) > 0 { - return imageData, mimeType, nil - } - } - - pos += headerLen + frameSize - } - - return nil, "", fmt.Errorf("no cover art found") -} - -func parseAPICFrame(data []byte, version byte) ([]byte, string) { - if len(data) < 4 { - return nil, "" - } - - pos := 0 - encoding := data[pos] - pos++ - - var mimeType string - if version == 2 { - if pos+3 > len(data) { - return nil, "" - } - format := string(data[pos : pos+3]) - pos += 3 - switch format { - case "JPG": - mimeType = "image/jpeg" - case "PNG": - mimeType = "image/png" - default: - mimeType = "image/jpeg" - } - } else { - end := pos - for end < len(data) && data[end] != 0 { - end++ - } - mimeType = string(data[pos:end]) - pos = end + 1 - } - - if pos >= len(data) { - return nil, "" - } - - pos++ - - if encoding == 0 || encoding == 3 { - for pos < len(data) && data[pos] != 0 { - pos++ - } - pos++ - } else { - for pos+1 < len(data) { - if data[pos] == 0 && data[pos+1] == 0 { - pos += 2 - break - } - pos++ - } - } - - if pos >= len(data) { - return nil, "" - } - - return data[pos:], mimeType -} - -func extractOggCoverArt(filePath string) ([]byte, string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, "", err - } - defer file.Close() - - packets, err := collectOggPackets(file, 30, 80) - if err != nil && len(packets) == 0 { - return nil, "", err - } - - streamType := detectOggStreamType(packets) - for _, pkt := range packets { - var comments []byte - if streamType == oggStreamOpus { - if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { - comments = pkt[8:] - } - } else { - if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { - comments = pkt[7:] - } - } - if len(comments) == 0 && streamType == oggStreamUnknown { - if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { - comments = pkt[8:] - } else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { - comments = pkt[7:] - } - } - - if len(comments) > 0 { - imageData, mimeType := extractPictureFromVorbisComments(comments) - if len(imageData) > 0 { - return imageData, mimeType, nil - } - } - } - - return nil, "", fmt.Errorf("no cover art found") -} - -func extractPictureFromVorbisComments(data []byte) ([]byte, string) { - if len(data) < 8 { - return nil, "" - } - - reader := bytes.NewReader(data) - - var vendorLen uint32 - if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { - return nil, "" - } - if vendorLen > uint32(len(data)-4) { - return nil, "" - } - reader.Seek(int64(vendorLen), io.SeekCurrent) - - var commentCount uint32 - if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil { - return nil, "" - } - - for i := uint32(0); i < commentCount && i < 100; i++ { - var commentLen uint32 - if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil { - break - } - if commentLen > 10000000 { - break - } - - comment := make([]byte, commentLen) - if _, err := reader.Read(comment); err != nil { - break - } - - key := "METADATA_BLOCK_PICTURE=" - if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key { - cleaned := strings.Map(func(r rune) rune { - switch r { - case '\n', '\r', ' ', '\t': - return -1 - } - return r - }, string(comment[len(key):])) - decoded, err := base64.StdEncoding.DecodeString(cleaned) - if err != nil { - decoded, err = base64.RawStdEncoding.DecodeString(cleaned) - } - if err != nil { - continue - } - - imageData, mimeType := parseFLACPictureBlock(decoded) - if len(imageData) > 0 { - return imageData, mimeType - } - } - } - - return nil, "" -} - -func parseFLACPictureBlock(data []byte) ([]byte, string) { - if len(data) < 32 { - return nil, "" - } - - reader := bytes.NewReader(data) - - var pictureType uint32 - binary.Read(reader, binary.BigEndian, &pictureType) - - var mimeLen uint32 - binary.Read(reader, binary.BigEndian, &mimeLen) - if mimeLen > 256 { - return nil, "" - } - - mimeBytes := make([]byte, mimeLen) - reader.Read(mimeBytes) - mimeType := string(mimeBytes) - - var descLen uint32 - binary.Read(reader, binary.BigEndian, &descLen) - if descLen > 10000 { - return nil, "" - } - - reader.Seek(int64(descLen), io.SeekCurrent) - - reader.Seek(16, io.SeekCurrent) - - var dataLen uint32 - binary.Read(reader, binary.BigEndian, &dataLen) - if dataLen > 10000000 { - return nil, "" - } - - imageData := make([]byte, dataLen) - reader.Read(imageData) - - return imageData, mimeType -} - -func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) { - ext := strings.ToLower(filepath.Ext(filePath)) - if ext == "" { - ext = strings.ToLower(filepath.Ext(displayNameHint)) - } - - switch ext { - case ".flac": - data, err := ExtractCoverArt(filePath) - if err != nil { - return nil, "", err - } - mimeType := "image/jpeg" - if len(data) > 8 && string(data[1:4]) == "PNG" { - mimeType = "image/png" - } - return data, mimeType, nil - - case ".mp3": - return extractMP3CoverArt(filePath) - - case ".opus", ".ogg": - return extractOggCoverArt(filePath) - - case ".m4a": - data, err := extractCoverFromM4A(filePath) - if err != nil { - return nil, "", err - } - mimeType := "image/jpeg" - if len(data) >= 8 && - data[0] == 0x89 && - data[1] == 0x50 && - data[2] == 0x4E && - data[3] == 0x47 { - mimeType = "image/png" - } - return data, mimeType, nil - - case ".wav", ".aiff", ".aif", ".aifc": - return extractWAVAIFFCover(filePath) - - default: - return nil, "", fmt.Errorf("unsupported format: %s", ext) - } -} - -func resolveLibraryCoverCacheKey(filePath, explicitKey string) string { - explicitKey = strings.TrimSpace(explicitKey) - if explicitKey != "" { - return explicitKey - } - - cacheKey := filePath - if stat, err := os.Stat(filePath); err == nil { - cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) - } - return cacheKey -} - -func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) { - hash := hashString(cacheKey) - jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash)) - pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash)) - return jpgPath, pngPath -} - -func existingLibraryCoverCachePath(cacheDir, cacheKey string) string { - jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey) - - if _, err := os.Stat(jpgPath); err == nil { - return jpgPath - } - if _, err := os.Stat(pngPath); err == nil { - return pngPath - } - return "" -} - -func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) { - if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" { - return existing, nil - } - if len(imageData) == 0 { - return "", fmt.Errorf("cover data is empty") - } - if err := os.MkdirAll(cacheDir, 0755); err != nil { - return "", fmt.Errorf("failed to create cache dir: %w", err) - } - - jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey) - cachePath := jpgPath - if strings.Contains(mimeType, "png") { - cachePath = pngPath - } - if err := os.WriteFile(cachePath, imageData, 0644); err != nil { - return "", fmt.Errorf("failed to write cover: %w", err) - } - return cachePath, nil -} - -func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) { - cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey) - if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" { - return existing, nil - } - - imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint) - if err != nil { - return "", err - } - return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType) -} diff --git a/go_backend/audio_metadata_cover.go b/go_backend/audio_metadata_cover.go new file mode 100644 index 00000000..4af4525d --- /dev/null +++ b/go_backend/audio_metadata_cover.go @@ -0,0 +1,402 @@ +package gobackend + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func extractMP3CoverArt(filePath string) ([]byte, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", err + } + defer file.Close() + + 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] + 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 + } + + pos := 0 + var frameIDLen, headerLen int + if majorVersion == 2 { + frameIDLen = 3 + headerLen = 6 + } else { + frameIDLen = 4 + headerLen = 10 + } + + for pos+headerLen < len(tagData) { + frameID := string(tagData[pos : pos+frameIDLen]) + if frameID[0] == 0 { + break + } + + var frameSize int + switch majorVersion { + case 2: + frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5]) + case 4: + frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7]) + default: + frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7]) + } + + if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) { + break + } + + if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") { + frameData := tagData[pos+headerLen : pos+headerLen+frameSize] + imageData, mimeType := parseAPICFrame(frameData, majorVersion) + if len(imageData) > 0 { + return imageData, mimeType, nil + } + } + + pos += headerLen + frameSize + } + + return nil, "", fmt.Errorf("no cover art found") +} + +func parseAPICFrame(data []byte, version byte) ([]byte, string) { + if len(data) < 4 { + return nil, "" + } + + pos := 0 + encoding := data[pos] + pos++ + + var mimeType string + if version == 2 { + if pos+3 > len(data) { + return nil, "" + } + format := string(data[pos : pos+3]) + pos += 3 + switch format { + case "JPG": + mimeType = "image/jpeg" + case "PNG": + mimeType = "image/png" + default: + mimeType = "image/jpeg" + } + } else { + end := pos + for end < len(data) && data[end] != 0 { + end++ + } + mimeType = string(data[pos:end]) + pos = end + 1 + } + + if pos >= len(data) { + return nil, "" + } + + pos++ + + if encoding == 0 || encoding == 3 { + for pos < len(data) && data[pos] != 0 { + pos++ + } + pos++ + } else { + for pos+1 < len(data) { + if data[pos] == 0 && data[pos+1] == 0 { + pos += 2 + break + } + pos++ + } + } + + if pos >= len(data) { + return nil, "" + } + + return data[pos:], mimeType +} + +func extractOggCoverArt(filePath string) ([]byte, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", err + } + defer file.Close() + + packets, err := collectOggPackets(file, 30, 80) + if err != nil && len(packets) == 0 { + return nil, "", err + } + + streamType := detectOggStreamType(packets) + for _, pkt := range packets { + var comments []byte + if streamType == oggStreamOpus { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + comments = pkt[8:] + } + } else { + if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { + comments = pkt[7:] + } + } + if len(comments) == 0 && streamType == oggStreamUnknown { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + comments = pkt[8:] + } else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { + comments = pkt[7:] + } + } + + if len(comments) > 0 { + imageData, mimeType := extractPictureFromVorbisComments(comments) + if len(imageData) > 0 { + return imageData, mimeType, nil + } + } + } + + return nil, "", fmt.Errorf("no cover art found") +} + +func extractPictureFromVorbisComments(data []byte) ([]byte, string) { + if len(data) < 8 { + return nil, "" + } + + reader := bytes.NewReader(data) + + var vendorLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { + return nil, "" + } + if vendorLen > uint32(len(data)-4) { + return nil, "" + } + reader.Seek(int64(vendorLen), io.SeekCurrent) + + var commentCount uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil { + return nil, "" + } + + for i := uint32(0); i < commentCount && i < 100; i++ { + var commentLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil { + break + } + if commentLen > 10000000 { + break + } + + comment := make([]byte, commentLen) + if _, err := reader.Read(comment); err != nil { + break + } + + key := "METADATA_BLOCK_PICTURE=" + if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', ' ', '\t': + return -1 + } + return r + }, string(comment[len(key):])) + decoded, err := base64.StdEncoding.DecodeString(cleaned) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(cleaned) + } + if err != nil { + continue + } + + imageData, mimeType := parseFLACPictureBlock(decoded) + if len(imageData) > 0 { + return imageData, mimeType + } + } + } + + return nil, "" +} + +func parseFLACPictureBlock(data []byte) ([]byte, string) { + if len(data) < 32 { + return nil, "" + } + + reader := bytes.NewReader(data) + + var pictureType uint32 + binary.Read(reader, binary.BigEndian, &pictureType) + + var mimeLen uint32 + binary.Read(reader, binary.BigEndian, &mimeLen) + if mimeLen > 256 { + return nil, "" + } + + mimeBytes := make([]byte, mimeLen) + reader.Read(mimeBytes) + mimeType := string(mimeBytes) + + var descLen uint32 + binary.Read(reader, binary.BigEndian, &descLen) + if descLen > 10000 { + return nil, "" + } + + reader.Seek(int64(descLen), io.SeekCurrent) + + reader.Seek(16, io.SeekCurrent) + + var dataLen uint32 + binary.Read(reader, binary.BigEndian, &dataLen) + if dataLen > 10000000 { + return nil, "" + } + + imageData := make([]byte, dataLen) + reader.Read(imageData) + + return imageData, mimeType +} + +func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == "" { + ext = strings.ToLower(filepath.Ext(displayNameHint)) + } + + switch ext { + case ".flac": + data, err := ExtractCoverArt(filePath) + if err != nil { + return nil, "", err + } + mimeType := "image/jpeg" + if len(data) > 8 && string(data[1:4]) == "PNG" { + mimeType = "image/png" + } + return data, mimeType, nil + + case ".mp3": + return extractMP3CoverArt(filePath) + + case ".opus", ".ogg": + return extractOggCoverArt(filePath) + + case ".m4a": + data, err := extractCoverFromM4A(filePath) + if err != nil { + return nil, "", err + } + mimeType := "image/jpeg" + if len(data) >= 8 && + data[0] == 0x89 && + data[1] == 0x50 && + data[2] == 0x4E && + data[3] == 0x47 { + mimeType = "image/png" + } + return data, mimeType, nil + + case ".wav", ".aiff", ".aif", ".aifc": + return extractWAVAIFFCover(filePath) + + default: + return nil, "", fmt.Errorf("unsupported format: %s", ext) + } +} + +func resolveLibraryCoverCacheKey(filePath, explicitKey string) string { + explicitKey = strings.TrimSpace(explicitKey) + if explicitKey != "" { + return explicitKey + } + + cacheKey := filePath + if stat, err := os.Stat(filePath); err == nil { + cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) + } + return cacheKey +} + +func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) { + hash := hashString(cacheKey) + jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash)) + pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash)) + return jpgPath, pngPath +} + +func existingLibraryCoverCachePath(cacheDir, cacheKey string) string { + jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey) + + if _, err := os.Stat(jpgPath); err == nil { + return jpgPath + } + if _, err := os.Stat(pngPath); err == nil { + return pngPath + } + return "" +} + +func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) { + if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" { + return existing, nil + } + if len(imageData) == 0 { + return "", fmt.Errorf("cover data is empty") + } + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return "", fmt.Errorf("failed to create cache dir: %w", err) + } + + jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey) + cachePath := jpgPath + if strings.Contains(mimeType, "png") { + cachePath = pngPath + } + if err := os.WriteFile(cachePath, imageData, 0644); err != nil { + return "", fmt.Errorf("failed to write cover: %w", err) + } + return cachePath, nil +} + +func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) { + cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey) + if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" { + return existing, nil + } + + imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint) + if err != nil { + return "", err + } + return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType) +} diff --git a/go_backend/audio_metadata_ogg.go b/go_backend/audio_metadata_ogg.go new file mode 100644 index 00000000..581ca0dc --- /dev/null +++ b/go_backend/audio_metadata_ogg.go @@ -0,0 +1,446 @@ +package gobackend + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "os" + "strconv" + "strings" +) + +func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + metadata := &AudioMetadata{} + + packets, err := collectOggPackets(file, 30, 80) + if err != nil && len(packets) == 0 { + return nil, err + } + + streamType := detectOggStreamType(packets) + for _, pkt := range packets { + if streamType == oggStreamOpus { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + parseVorbisComments(pkt[8:], metadata) + break + } + continue + } + if streamType == oggStreamVorbis || streamType == oggStreamUnknown { + if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { + parseVorbisComments(pkt[7:], metadata) + break + } + } + if streamType == oggStreamUnknown { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + parseVorbisComments(pkt[8:], metadata) + break + } + } + } + + if metadata.Title == "" && metadata.Artist == "" { + return nil, fmt.Errorf("no Vorbis comments found") + } + + return metadata, nil +} + +type oggPage struct { + headerType byte + segmentTable []byte + data []byte +} + +func readOggPageWithHeader(file *os.File) (*oggPage, error) { + header := make([]byte, 27) + if _, err := io.ReadFull(file, header); err != nil { + return nil, err + } + + if string(header[0:4]) != "OggS" { + return nil, fmt.Errorf("not an Ogg page") + } + + headerType := header[5] + numSegments := int(header[26]) + + segmentTable := make([]byte, numSegments) + if _, err := io.ReadFull(file, segmentTable); err != nil { + return nil, err + } + + var pageSize int + for _, seg := range segmentTable { + pageSize += int(seg) + } + + pageData := make([]byte, pageSize) + if _, err := io.ReadFull(file, pageData); err != nil { + return nil, err + } + + return &oggPage{ + headerType: headerType, + segmentTable: segmentTable, + data: pageData, + }, nil +} + +func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) { + const maxPacketSize = 10 * 1024 * 1024 + var packets [][]byte + var cur []byte + skipPacket := false + + for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ { + page, err := readOggPageWithHeader(file) + if err != nil { + if len(packets) > 0 { + return packets, nil + } + return nil, err + } + + if page.headerType&0x01 == 0 && len(cur) > 0 { + cur = nil + skipPacket = false + } + + offset := 0 + for _, seg := range page.segmentTable { + segLen := int(seg) + if offset+segLen > len(page.data) { + return packets, fmt.Errorf("invalid ogg segment size") + } + + if skipPacket { + offset += segLen + if segLen < 255 { + skipPacket = false + } + continue + } + + if len(cur)+segLen > maxPacketSize { + cur = nil + skipPacket = true + offset += segLen + if segLen < 255 { + skipPacket = false + } + continue + } + + cur = append(cur, page.data[offset:offset+segLen]...) + offset += segLen + + if segLen < 255 { + if len(cur) > 0 { + packets = append(packets, cur) + } + cur = nil + if len(packets) >= maxPackets { + return packets, nil + } + } + } + } + + return packets, nil +} + +type oggStreamType int + +const ( + oggStreamUnknown oggStreamType = iota + oggStreamOpus + oggStreamVorbis +) + +func detectOggStreamType(packets [][]byte) oggStreamType { + for _, p := range packets { + if len(p) >= 8 && string(p[0:8]) == "OpusHead" { + return oggStreamOpus + } + if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" { + return oggStreamVorbis + } + } + return oggStreamUnknown +} + +func parseVorbisComments(data []byte, metadata *AudioMetadata) { + if len(data) < 4 { + return + } + + reader := bytes.NewReader(data) + artistValues := make([]string, 0, 1) + albumArtistValues := make([]string, 0, 1) + + var vendorLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { + return + } + + if vendorLen > uint32(len(data)-4) { + return + } + vendor := make([]byte, vendorLen) + if _, err := reader.Read(vendor); err != nil { + return + } + + var commentCount uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil { + return + } + + for i := uint32(0); i < commentCount && i < 100; i++ { + var commentLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil { + break + } + + remaining := uint32(reader.Len()) + if commentLen > remaining { + break + } + if commentLen > 512*1024 { + reader.Seek(int64(commentLen), io.SeekCurrent) + continue + } + + comment := make([]byte, commentLen) + if _, err := reader.Read(comment); err != nil { + break + } + + parts := strings.SplitN(string(comment), "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.ToUpper(parts[0]) + value := parts[1] + + switch key { + case "TITLE": + metadata.Title = value + case "ARTIST": + artistValues = append(artistValues, value) + case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST": + albumArtistValues = append(albumArtistValues, value) + case "ALBUM": + metadata.Album = value + case "DATE", "YEAR": + metadata.Date = value + if len(value) >= 4 { + metadata.Year = value[:4] + } + case "GENRE": + metadata.Genre = value + case "TRACKNUMBER", "TRACK": + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) + case "DISCNUMBER", "DISC": + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) + case "ISRC": + metadata.ISRC = value + case "COMPOSER": + metadata.Composer = value + case "COMMENT", "DESCRIPTION": + metadata.Comment = value + case "LYRICS", "UNSYNCEDLYRICS": + if metadata.Lyrics == "" { + metadata.Lyrics = value + } + case "ORGANIZATION", "LABEL", "PUBLISHER": + metadata.Label = value + case "COPYRIGHT": + metadata.Copyright = 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 + // Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS + // reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so + // consumers see one representation; explicit REPLAYGAIN_* wins. + case "R128_TRACK_GAIN": + if metadata.ReplayGainTrackGain == "" { + if db, ok := r128ToReplayGainDb(value); ok { + metadata.ReplayGainTrackGain = db + } + } + case "R128_ALBUM_GAIN": + if metadata.ReplayGainAlbumGain == "" { + if db, ok := r128ToReplayGainDb(value); ok { + metadata.ReplayGainAlbumGain = db + } + } + } + } + + if len(artistValues) > 0 { + metadata.Artist = joinVorbisCommentValues(artistValues) + } + if len(albumArtistValues) > 0 { + metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues) + } +} + +// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps, +// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference): +// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128. +func r128ToReplayGainDb(raw string) (string, bool) { + q, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return "", false + } + return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true +} + +func GetOggQuality(filePath string) (*OggQuality, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + quality := &OggQuality{} + + packets, err := collectOggPackets(file, 5, 10) + if err != nil && len(packets) == 0 { + return nil, err + } + + streamType := detectOggStreamType(packets) + if streamType == oggStreamUnknown { + if strings.HasSuffix(strings.ToLower(filePath), ".opus") { + streamType = oggStreamOpus + } else { + streamType = oggStreamVorbis + } + } + + isOpus := streamType == oggStreamOpus + var preSkip int + + if isOpus { + for _, pkt := range packets { + if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" { + quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) + if quality.SampleRate == 0 { + quality.SampleRate = 48000 + } + preSkip = int(binary.LittleEndian.Uint16(pkt[10:12])) + break + } + } + } else { + for _, pkt := range packets { + if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" { + quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) + break + } + } + } + + stat, err := file.Stat() + if err != nil { + return quality, nil + } + fileSize := stat.Size() + + granule := readLastOggGranulePosition(file, fileSize) + if granule > 0 { + if isOpus { + totalSamples := granule - int64(preSkip) + if totalSamples > 0 { + durationSec := float64(totalSamples) / 48000.0 + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } + } + } else if quality.SampleRate > 0 { + durationSec := float64(granule) / float64(quality.SampleRate) + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } + } + } + + if quality.Bitrate <= 0 && quality.Duration > 0 { + quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) + } + if quality.Duration > 24*60*60 { + quality.Duration = 0 + quality.Bitrate = 0 + } + if quality.Bitrate > 0 && quality.Bitrate < 8000 { + quality.Bitrate = 0 + } + + return quality, nil +} + +func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { + searchSize := int64(65536) + if searchSize > fileSize { + searchSize = fileSize + } + + buf := make([]byte, searchSize) + offset := fileSize - searchSize + if offset < 0 { + offset = 0 + } + n, err := file.ReadAt(buf, offset) + if err != nil && n == 0 { + return 0 + } + buf = buf[:n] + + for i := n - 4; i >= 0; i-- { + if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' { + continue + } + if i+27 > n { + continue + } + version := buf[i+4] + headerType := buf[i+5] + if version != 0 || headerType > 0x07 { + continue + } + segmentCount := int(buf[i+26]) + headerLen := 27 + segmentCount + if i+headerLen > n { + continue + } + payloadLen := 0 + for s := 0; s < segmentCount; s++ { + payloadLen += int(buf[i+27+s]) + } + if i+headerLen+payloadLen > n { + continue + } + return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14])) + } + return 0 +}