mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-22 15:59:46 +02:00
d99a1b1c21
- Refactor EmbedM4AMetadata to use streaming instead of loading entire file - Use os.Open + ReadAt instead of os.ReadFile for memory efficiency - Atomic file replacement via temp file + rename for safer writes - New helper functions: findAtomInRange, readAtomHeaderAt, copyRange, buildUdtaAtom - Refactor GetM4AQuality to use streaming with findAudioSampleEntry - Use NewHTTPClientWithTimeout helper in lyrics.go, qobuz.go, tidal.go - Update CHANGELOG with performance improvements and MP3 metadata support
1103 lines
28 KiB
Go
1103 lines
28 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-flac/flacpicture"
|
|
"github.com/go-flac/flacvorbis"
|
|
"github.com/go-flac/go-flac"
|
|
)
|
|
|
|
type Metadata struct {
|
|
Title string
|
|
Artist string
|
|
Album string
|
|
AlbumArtist string
|
|
Date string
|
|
TrackNumber int
|
|
TotalTracks int
|
|
DiscNumber int
|
|
ISRC string
|
|
Description string
|
|
Lyrics string
|
|
Genre string
|
|
Label string
|
|
Copyright string
|
|
}
|
|
|
|
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
var cmtIdx int = -1
|
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
|
|
|
for idx, meta := range f.Meta {
|
|
if meta.Type == flac.VorbisComment {
|
|
cmtIdx = idx
|
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if cmt == nil {
|
|
cmt = flacvorbis.New()
|
|
}
|
|
|
|
setComment(cmt, "TITLE", metadata.Title)
|
|
setComment(cmt, "ARTIST", metadata.Artist)
|
|
setComment(cmt, "ALBUM", metadata.Album)
|
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
|
setComment(cmt, "DATE", metadata.Date)
|
|
|
|
if metadata.TrackNumber > 0 {
|
|
if metadata.TotalTracks > 0 {
|
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
|
} else {
|
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
|
}
|
|
}
|
|
|
|
if metadata.DiscNumber > 0 {
|
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
|
}
|
|
|
|
if metadata.ISRC != "" {
|
|
setComment(cmt, "ISRC", metadata.ISRC)
|
|
}
|
|
|
|
if metadata.Description != "" {
|
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
|
}
|
|
|
|
if metadata.Lyrics != "" {
|
|
setComment(cmt, "LYRICS", metadata.Lyrics)
|
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
|
}
|
|
|
|
if metadata.Genre != "" {
|
|
setComment(cmt, "GENRE", metadata.Genre)
|
|
}
|
|
|
|
if metadata.Label != "" {
|
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
|
}
|
|
|
|
if metadata.Copyright != "" {
|
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
|
}
|
|
|
|
cmtBlock := cmt.Marshal()
|
|
if cmtIdx >= 0 {
|
|
f.Meta[cmtIdx] = &cmtBlock
|
|
} else {
|
|
f.Meta = append(f.Meta, &cmtBlock)
|
|
}
|
|
|
|
if coverPath != "" {
|
|
if fileExists(coverPath) {
|
|
coverData, err := os.ReadFile(coverPath)
|
|
if err != nil {
|
|
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
|
} else {
|
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
|
if f.Meta[i].Type == flac.Picture {
|
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
|
}
|
|
}
|
|
|
|
picture, err := flacpicture.NewFromImageData(
|
|
flacpicture.PictureTypeFrontCover,
|
|
"Front Cover",
|
|
coverData,
|
|
"image/jpeg",
|
|
)
|
|
if err != nil {
|
|
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
|
} else {
|
|
picBlock := picture.Marshal()
|
|
f.Meta = append(f.Meta, &picBlock)
|
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
|
}
|
|
}
|
|
|
|
return f.Save(filePath)
|
|
}
|
|
|
|
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
var cmtIdx int = -1
|
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
|
|
|
for idx, meta := range f.Meta {
|
|
if meta.Type == flac.VorbisComment {
|
|
cmtIdx = idx
|
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if cmt == nil {
|
|
cmt = flacvorbis.New()
|
|
}
|
|
|
|
setComment(cmt, "TITLE", metadata.Title)
|
|
setComment(cmt, "ARTIST", metadata.Artist)
|
|
setComment(cmt, "ALBUM", metadata.Album)
|
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
|
setComment(cmt, "DATE", metadata.Date)
|
|
|
|
if metadata.TrackNumber > 0 {
|
|
if metadata.TotalTracks > 0 {
|
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
|
} else {
|
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
|
}
|
|
}
|
|
|
|
if metadata.DiscNumber > 0 {
|
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
|
}
|
|
|
|
if metadata.ISRC != "" {
|
|
setComment(cmt, "ISRC", metadata.ISRC)
|
|
}
|
|
|
|
if metadata.Description != "" {
|
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
|
}
|
|
|
|
if metadata.Lyrics != "" {
|
|
setComment(cmt, "LYRICS", metadata.Lyrics)
|
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
|
}
|
|
|
|
if metadata.Genre != "" {
|
|
setComment(cmt, "GENRE", metadata.Genre)
|
|
}
|
|
|
|
if metadata.Label != "" {
|
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
|
}
|
|
|
|
if metadata.Copyright != "" {
|
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
|
}
|
|
|
|
cmtBlock := cmt.Marshal()
|
|
if cmtIdx >= 0 {
|
|
f.Meta[cmtIdx] = &cmtBlock
|
|
} else {
|
|
f.Meta = append(f.Meta, &cmtBlock)
|
|
}
|
|
|
|
if len(coverData) > 0 {
|
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
|
if f.Meta[i].Type == flac.Picture {
|
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
|
}
|
|
}
|
|
|
|
picture, err := flacpicture.NewFromImageData(
|
|
flacpicture.PictureTypeFrontCover,
|
|
"Front Cover",
|
|
coverData,
|
|
"image/jpeg",
|
|
)
|
|
if err != nil {
|
|
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
|
} else {
|
|
picBlock := picture.Marshal()
|
|
f.Meta = append(f.Meta, &picBlock)
|
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
}
|
|
}
|
|
|
|
return f.Save(filePath)
|
|
}
|
|
|
|
// ReadMetadata reads metadata from a FLAC file
|
|
func ReadMetadata(filePath string) (*Metadata, error) {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
metadata := &Metadata{}
|
|
|
|
for _, meta := range f.Meta {
|
|
if meta.Type == flac.VorbisComment {
|
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
metadata.Title = getComment(cmt, "TITLE")
|
|
metadata.Artist = getComment(cmt, "ARTIST")
|
|
metadata.Album = getComment(cmt, "ALBUM")
|
|
metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST")
|
|
metadata.Date = getComment(cmt, "DATE")
|
|
metadata.ISRC = getComment(cmt, "ISRC")
|
|
metadata.Description = getComment(cmt, "DESCRIPTION")
|
|
|
|
metadata.Lyrics = getComment(cmt, "LYRICS")
|
|
if metadata.Lyrics == "" {
|
|
metadata.Lyrics = getComment(cmt, "UNSYNCEDLYRICS")
|
|
}
|
|
|
|
trackNum := getComment(cmt, "TRACKNUMBER")
|
|
if trackNum != "" {
|
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
|
}
|
|
if metadata.TrackNumber == 0 {
|
|
trackNum = getComment(cmt, "TRACK")
|
|
if trackNum != "" {
|
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
|
}
|
|
}
|
|
|
|
discNum := getComment(cmt, "DISCNUMBER")
|
|
if discNum != "" {
|
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
|
}
|
|
if metadata.DiscNumber == 0 {
|
|
discNum = getComment(cmt, "DISC")
|
|
if discNum != "" {
|
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
|
}
|
|
}
|
|
|
|
if metadata.Date == "" {
|
|
metadata.Date = getComment(cmt, "YEAR")
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
keyUpper := strings.ToUpper(key)
|
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
|
comment := cmt.Comments[i]
|
|
eqIdx := strings.Index(comment, "=")
|
|
if eqIdx > 0 {
|
|
existingKey := strings.ToUpper(comment[:eqIdx])
|
|
if existingKey == keyUpper {
|
|
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
|
}
|
|
}
|
|
}
|
|
cmt.Comments = append(cmt.Comments, key+"="+value)
|
|
}
|
|
|
|
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|
keyUpper := strings.ToUpper(key) + "="
|
|
for _, comment := range cmt.Comments {
|
|
if len(comment) > len(key) {
|
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
|
if commentUpper == keyUpper {
|
|
return comment[len(key)+1:]
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
func EmbedLyrics(filePath string, lyrics string) error {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
var cmtIdx int = -1
|
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
|
|
|
for idx, meta := range f.Meta {
|
|
if meta.Type == flac.VorbisComment {
|
|
cmtIdx = idx
|
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if cmt == nil {
|
|
cmt = flacvorbis.New()
|
|
}
|
|
|
|
setComment(cmt, "LYRICS", lyrics)
|
|
setComment(cmt, "UNSYNCEDLYRICS", lyrics)
|
|
|
|
cmtBlock := cmt.Marshal()
|
|
if cmtIdx >= 0 {
|
|
f.Meta[cmtIdx] = &cmtBlock
|
|
} else {
|
|
f.Meta = append(f.Meta, &cmtBlock)
|
|
}
|
|
|
|
return f.Save(filePath)
|
|
}
|
|
|
|
func EmbedGenreLabel(filePath string, genre, label string) error {
|
|
if genre == "" && label == "" {
|
|
return nil
|
|
}
|
|
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
var cmtIdx int = -1
|
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
|
|
|
for idx, meta := range f.Meta {
|
|
if meta.Type == flac.VorbisComment {
|
|
cmtIdx = idx
|
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if cmt == nil {
|
|
cmt = flacvorbis.New()
|
|
}
|
|
|
|
if genre != "" {
|
|
setComment(cmt, "GENRE", genre)
|
|
}
|
|
if label != "" {
|
|
setComment(cmt, "ORGANIZATION", label)
|
|
}
|
|
|
|
cmtBlock := cmt.Marshal()
|
|
if cmtIdx >= 0 {
|
|
f.Meta[cmtIdx] = &cmtBlock
|
|
} else {
|
|
f.Meta = append(f.Meta, &cmtBlock)
|
|
}
|
|
|
|
return f.Save(filePath)
|
|
}
|
|
|
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
|
func ExtractLyrics(filePath string) (string, error) {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
for _, meta := range f.Meta {
|
|
if meta.Type == flac.VorbisComment {
|
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
lyrics, err := cmt.Get("LYRICS")
|
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
|
return lyrics[0], nil
|
|
}
|
|
|
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
|
return lyrics[0], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no lyrics found in file")
|
|
}
|
|
|
|
type AudioQuality struct {
|
|
BitDepth int `json:"bit_depth"`
|
|
SampleRate int `json:"sample_rate"`
|
|
TotalSamples int64 `json:"total_samples"`
|
|
}
|
|
|
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
marker := make([]byte, 4)
|
|
if _, err := file.Read(marker); err != nil {
|
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
|
}
|
|
|
|
if string(marker) == "fLaC" {
|
|
header := make([]byte, 4)
|
|
if _, err := file.Read(header); err != nil {
|
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
|
}
|
|
|
|
blockType := header[0] & 0x7F
|
|
if blockType != 0 {
|
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
|
}
|
|
|
|
streamInfo := make([]byte, 34)
|
|
if _, err := file.Read(streamInfo); err != nil {
|
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
|
}
|
|
|
|
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 AudioQuality{
|
|
BitDepth: bitsPerSample,
|
|
SampleRate: sampleRate,
|
|
TotalSamples: totalSamples,
|
|
}, nil
|
|
}
|
|
|
|
file.Seek(0, 0)
|
|
header8 := make([]byte, 8)
|
|
if _, err := file.Read(header8); err != nil {
|
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
|
}
|
|
|
|
if string(header8[4:8]) == "ftyp" {
|
|
file.Close()
|
|
return GetM4AQuality(filePath)
|
|
}
|
|
|
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
|
}
|
|
|
|
// ========================================
|
|
// M4A (MP4/AAC) Metadata Embedding
|
|
// ========================================
|
|
|
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
|
input, err := os.Open(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open M4A file: %w", err)
|
|
}
|
|
defer input.Close()
|
|
|
|
info, err := input.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stat M4A file: %w", err)
|
|
}
|
|
fileSize := info.Size()
|
|
|
|
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find moov atom: %w", err)
|
|
}
|
|
if !moovFound {
|
|
return fmt.Errorf("moov atom not found in M4A file")
|
|
}
|
|
|
|
moovContentStart := moovHeader.offset + moovHeader.headerSize
|
|
moovContentSize := moovHeader.size - moovHeader.headerSize
|
|
|
|
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to locate udta atom: %w", err)
|
|
}
|
|
|
|
var metaHeader atomHeader
|
|
metaFound := false
|
|
if udtaFound {
|
|
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
|
|
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
|
|
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to locate meta atom: %w", err)
|
|
}
|
|
}
|
|
|
|
metaAtom := buildMetaAtom(metadata, coverData)
|
|
metaSize := int64(len(metaAtom))
|
|
|
|
var delta int64
|
|
var newUdtaSize int64
|
|
switch {
|
|
case udtaFound && metaFound:
|
|
delta = metaSize - metaHeader.size
|
|
newUdtaSize = udtaHeader.size + delta
|
|
case udtaFound && !metaFound:
|
|
delta = metaSize
|
|
newUdtaSize = udtaHeader.size + delta
|
|
case !udtaFound:
|
|
newUdtaSize = int64(8 + len(metaAtom))
|
|
delta = newUdtaSize
|
|
}
|
|
|
|
newMoovSize := moovHeader.size + delta
|
|
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
|
|
return fmt.Errorf("moov atom exceeds 32-bit size after update")
|
|
}
|
|
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
|
|
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
|
}
|
|
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
|
|
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
|
}
|
|
|
|
tempPath := filePath + ".tmp"
|
|
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
cleanupTemp := true
|
|
defer func() {
|
|
_ = output.Close()
|
|
if cleanupTemp {
|
|
_ = os.Remove(tempPath)
|
|
}
|
|
}()
|
|
|
|
switch {
|
|
case udtaFound && metaFound:
|
|
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
return err
|
|
}
|
|
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
|
return err
|
|
}
|
|
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := output.Write(metaAtom); err != nil {
|
|
return fmt.Errorf("failed to write meta atom: %w", err)
|
|
}
|
|
metaEnd := metaHeader.offset + metaHeader.size
|
|
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
|
|
return err
|
|
}
|
|
case udtaFound && !metaFound:
|
|
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
return err
|
|
}
|
|
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
|
return err
|
|
}
|
|
insertPos := udtaHeader.offset + udtaHeader.size
|
|
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := output.Write(metaAtom); err != nil {
|
|
return fmt.Errorf("failed to write meta atom: %w", err)
|
|
}
|
|
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
|
|
return err
|
|
}
|
|
case !udtaFound:
|
|
newUdtaAtom := buildUdtaAtom(metaAtom)
|
|
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
return err
|
|
}
|
|
moovEnd := moovHeader.offset + moovHeader.size
|
|
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := output.Write(newUdtaAtom); err != nil {
|
|
return fmt.Errorf("failed to write udta atom: %w", err)
|
|
}
|
|
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := output.Close(); err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
_ = input.Close()
|
|
if err := os.Remove(filePath); err != nil {
|
|
return fmt.Errorf("failed to replace original file: %w", err)
|
|
}
|
|
if err := os.Rename(tempPath, filePath); err != nil {
|
|
return fmt.Errorf("failed to move temp file: %w", err)
|
|
}
|
|
cleanupTemp = false
|
|
|
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
|
return nil
|
|
}
|
|
|
|
func findAtom(data []byte, name string, offset int) int {
|
|
for i := offset; i < len(data)-8; {
|
|
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
|
if size < 8 {
|
|
break
|
|
}
|
|
atomName := string(data[i+4 : i+8])
|
|
if atomName == name {
|
|
return i
|
|
}
|
|
i += size
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|
var ilst []byte
|
|
|
|
if metadata.Title != "" {
|
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
|
}
|
|
|
|
if metadata.Artist != "" {
|
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
|
}
|
|
|
|
if metadata.Album != "" {
|
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
|
}
|
|
|
|
if metadata.AlbumArtist != "" {
|
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
|
}
|
|
|
|
if metadata.Date != "" {
|
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
|
}
|
|
|
|
if metadata.TrackNumber > 0 {
|
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
|
}
|
|
|
|
if metadata.DiscNumber > 0 {
|
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
|
}
|
|
|
|
if metadata.Lyrics != "" {
|
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
|
}
|
|
|
|
if len(coverData) > 0 {
|
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
|
}
|
|
|
|
ilstSize := 8 + len(ilst)
|
|
ilstAtom := make([]byte, 4)
|
|
ilstAtom[0] = byte(ilstSize >> 24)
|
|
ilstAtom[1] = byte(ilstSize >> 16)
|
|
ilstAtom[2] = byte(ilstSize >> 8)
|
|
ilstAtom[3] = byte(ilstSize)
|
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
|
ilstAtom = append(ilstAtom, ilst...)
|
|
|
|
hdlr := []byte{
|
|
0, 0, 0, 33, // size = 33
|
|
'h', 'd', 'l', 'r',
|
|
0, 0, 0, 0, // version + flags
|
|
0, 0, 0, 0, // predefined
|
|
'm', 'd', 'i', 'r', // handler type
|
|
'a', 'p', 'p', 'l', // manufacturer
|
|
0, 0, 0, 0, // component flags
|
|
0, 0, 0, 0, // component flags mask
|
|
0, // null terminator
|
|
}
|
|
|
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
|
metaContent = append(metaContent, ilstAtom...)
|
|
|
|
metaSize := 8 + len(metaContent)
|
|
metaAtom := make([]byte, 4)
|
|
metaAtom[0] = byte(metaSize >> 24)
|
|
metaAtom[1] = byte(metaSize >> 16)
|
|
metaAtom[2] = byte(metaSize >> 8)
|
|
metaAtom[3] = byte(metaSize)
|
|
metaAtom = append(metaAtom, []byte("meta")...)
|
|
metaAtom = append(metaAtom, metaContent...)
|
|
|
|
return metaAtom
|
|
}
|
|
|
|
func buildTextAtom(name, value string) []byte {
|
|
valueBytes := []byte(value)
|
|
|
|
dataSize := 16 + len(valueBytes)
|
|
dataAtom := make([]byte, 4)
|
|
dataAtom[0] = byte(dataSize >> 24)
|
|
dataAtom[1] = byte(dataSize >> 16)
|
|
dataAtom[2] = byte(dataSize >> 8)
|
|
dataAtom[3] = byte(dataSize)
|
|
dataAtom = append(dataAtom, []byte("data")...)
|
|
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
|
dataAtom = append(dataAtom, valueBytes...)
|
|
|
|
atomSize := 8 + len(dataAtom)
|
|
atom := make([]byte, 4)
|
|
atom[0] = byte(atomSize >> 24)
|
|
atom[1] = byte(atomSize >> 16)
|
|
atom[2] = byte(atomSize >> 8)
|
|
atom[3] = byte(atomSize)
|
|
atom = append(atom, []byte(name)...)
|
|
atom = append(atom, dataAtom...)
|
|
|
|
return atom
|
|
}
|
|
|
|
// buildTrackNumberAtom builds trkn atom
|
|
func buildTrackNumberAtom(track, total int) []byte {
|
|
dataAtom := []byte{
|
|
0, 0, 0, 24, // size
|
|
'd', 'a', 't', 'a',
|
|
0, 0, 0, 0, // type = implicit
|
|
0, 0, 0, 0, // locale
|
|
0, 0, // padding
|
|
byte(track >> 8), byte(track), // track number
|
|
byte(total >> 8), byte(total), // total tracks
|
|
0, 0, // padding
|
|
}
|
|
|
|
atomSize := 8 + len(dataAtom)
|
|
atom := make([]byte, 4)
|
|
atom[0] = byte(atomSize >> 24)
|
|
atom[1] = byte(atomSize >> 16)
|
|
atom[2] = byte(atomSize >> 8)
|
|
atom[3] = byte(atomSize)
|
|
atom = append(atom, []byte("trkn")...)
|
|
atom = append(atom, dataAtom...)
|
|
|
|
return atom
|
|
}
|
|
|
|
func buildDiscNumberAtom(disc, total int) []byte {
|
|
dataAtom := []byte{
|
|
0, 0, 0, 22, // size
|
|
'd', 'a', 't', 'a',
|
|
0, 0, 0, 0, // type = implicit
|
|
0, 0, 0, 0, // locale
|
|
0, 0, // padding
|
|
byte(disc >> 8), byte(disc), // disc number
|
|
byte(total >> 8), byte(total), // total discs
|
|
}
|
|
|
|
atomSize := 8 + len(dataAtom)
|
|
atom := make([]byte, 4)
|
|
atom[0] = byte(atomSize >> 24)
|
|
atom[1] = byte(atomSize >> 16)
|
|
atom[2] = byte(atomSize >> 8)
|
|
atom[3] = byte(atomSize)
|
|
atom = append(atom, []byte("disk")...)
|
|
atom = append(atom, dataAtom...)
|
|
|
|
return atom
|
|
}
|
|
|
|
// buildCoverAtom builds covr atom with image data
|
|
func buildCoverAtom(coverData []byte) []byte {
|
|
imageType := byte(13)
|
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
|
imageType = 14
|
|
}
|
|
|
|
dataSize := 16 + len(coverData)
|
|
dataAtom := make([]byte, 4)
|
|
dataAtom[0] = byte(dataSize >> 24)
|
|
dataAtom[1] = byte(dataSize >> 16)
|
|
dataAtom[2] = byte(dataSize >> 8)
|
|
dataAtom[3] = byte(dataSize)
|
|
dataAtom = append(dataAtom, []byte("data")...)
|
|
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
|
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
|
dataAtom = append(dataAtom, coverData...)
|
|
|
|
atomSize := 8 + len(dataAtom)
|
|
atom := make([]byte, 4)
|
|
atom[0] = byte(atomSize >> 24)
|
|
atom[1] = byte(atomSize >> 16)
|
|
atom[2] = byte(atomSize >> 8)
|
|
atom[3] = byte(atomSize)
|
|
atom = append(atom, []byte("covr")...)
|
|
atom = append(atom, dataAtom...)
|
|
|
|
return atom
|
|
}
|
|
|
|
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()
|
|
|
|
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
|
|
|
|
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
|
if err != nil {
|
|
return AudioQuality{}, err
|
|
}
|
|
|
|
buf := make([]byte, 24)
|
|
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
|
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
|
}
|
|
|
|
sampleRate := int(buf[22])<<8 | int(buf[23])
|
|
bitDepth := 16
|
|
if atomType == "alac" {
|
|
bitDepth = 24
|
|
}
|
|
|
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
|
}
|
|
|
|
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 writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
|
|
if len(typ) != 4 {
|
|
return fmt.Errorf("invalid atom type: %s", typ)
|
|
}
|
|
|
|
if headerSize == 16 {
|
|
header := make([]byte, 16)
|
|
binary.BigEndian.PutUint32(header[0:4], 1)
|
|
copy(header[4:8], []byte(typ))
|
|
binary.BigEndian.PutUint64(header[8:16], uint64(size))
|
|
_, err := w.Write(header)
|
|
return err
|
|
}
|
|
|
|
if size > int64(^uint32(0)) {
|
|
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
|
|
}
|
|
|
|
header := make([]byte, 8)
|
|
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
|
copy(header[4:8], []byte(typ))
|
|
_, err := w.Write(header)
|
|
return err
|
|
}
|
|
|
|
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
|
|
if length <= 0 {
|
|
return nil
|
|
}
|
|
if _, err := src.Seek(offset, io.SeekStart); err != nil {
|
|
return fmt.Errorf("failed to seek source: %w", err)
|
|
}
|
|
if _, err := io.CopyN(dst, src, length); err != nil {
|
|
return fmt.Errorf("failed to copy data: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildUdtaAtom(metaAtom []byte) []byte {
|
|
size := 8 + len(metaAtom)
|
|
header := make([]byte, 8)
|
|
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
|
copy(header[4:8], []byte("udta"))
|
|
return append(header, metaAtom...)
|
|
}
|
|
|
|
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
|
const chunkSize = 64 * 1024
|
|
patternMP4A := []byte("mp4a")
|
|
patternALAC := []byte("alac")
|
|
|
|
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]...)
|
|
mp4aIdx := bytes.Index(data, patternMP4A)
|
|
alacIdx := bytes.Index(data, patternALAC)
|
|
|
|
bestIdx := -1
|
|
bestType := ""
|
|
switch {
|
|
case mp4aIdx >= 0 && alacIdx >= 0:
|
|
if mp4aIdx <= alacIdx {
|
|
bestIdx = mp4aIdx
|
|
bestType = "mp4a"
|
|
} else {
|
|
bestIdx = alacIdx
|
|
bestType = "alac"
|
|
}
|
|
case mp4aIdx >= 0:
|
|
bestIdx = mp4aIdx
|
|
bestType = "mp4a"
|
|
case alacIdx >= 0:
|
|
bestIdx = alacIdx
|
|
bestType = "alac"
|
|
}
|
|
|
|
if bestIdx >= 0 {
|
|
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
|
if absolute+24 > 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")
|
|
}
|