mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-30 16:30:22 +02:00
Add ReadM4ATags() that parses all standard iTunes atoms (title, artist, album, album artist, date, genre, composer, comment, copyright, lyrics, track/disc number) and freeform '----' atoms (ISRC, label, lyrics). Fix two pre-existing bugs in the M4A atom traversal: - findM4AIlstAtom: now tries moov>udta>meta>ilst first, then falls back to moov>meta>ilst so files from Tidal/Qobuz/Apple Music are handled - readM4AFreeformValue: 'name' atom payload is raw UTF-8 after 4-byte flags, not a nested 'data' atom; fix reads it directly so ISRC/label freeform tags are no longer silently dropped Refactor extractLyricsFromM4A and extractCoverFromM4A to reuse the new helpers (findM4AIlstAtom, readM4ADataAtomPayload) instead of duplicating the atom traversal logic. Add extractAnyCoverArtWithHint M4A case that previously returned a hardcoded 'not yet supported' error.
1208 lines
30 KiB
Go
1208 lines
30 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
stdimage "image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-flac/flacpicture/v2"
|
|
"github.com/go-flac/flacvorbis/v2"
|
|
"github.com/go-flac/go-flac/v2"
|
|
)
|
|
|
|
func detectCoverMIME(coverPath string, coverData []byte) string {
|
|
// Prefer magic-byte detection over file extension.
|
|
// Some providers return non-JPEG data behind .jpg URLs.
|
|
if len(coverData) >= 8 &&
|
|
coverData[0] == 0x89 &&
|
|
coverData[1] == 0x50 &&
|
|
coverData[2] == 0x4E &&
|
|
coverData[3] == 0x47 &&
|
|
coverData[4] == 0x0D &&
|
|
coverData[5] == 0x0A &&
|
|
coverData[6] == 0x1A &&
|
|
coverData[7] == 0x0A {
|
|
return "image/png"
|
|
}
|
|
if len(coverData) >= 3 &&
|
|
coverData[0] == 0xFF &&
|
|
coverData[1] == 0xD8 &&
|
|
coverData[2] == 0xFF {
|
|
return "image/jpeg"
|
|
}
|
|
if len(coverData) >= 6 {
|
|
header := string(coverData[:6])
|
|
if header == "GIF87a" || header == "GIF89a" {
|
|
return "image/gif"
|
|
}
|
|
}
|
|
if len(coverData) >= 12 &&
|
|
string(coverData[:4]) == "RIFF" &&
|
|
string(coverData[8:12]) == "WEBP" {
|
|
return "image/webp"
|
|
}
|
|
|
|
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
|
|
case ".png":
|
|
return "image/png"
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".gif":
|
|
return "image/gif"
|
|
}
|
|
|
|
return "image/jpeg"
|
|
}
|
|
|
|
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
|
if len(coverData) == 0 {
|
|
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
|
}
|
|
|
|
mime := detectCoverMIME(coverPath, coverData)
|
|
picture := &flacpicture.MetadataBlockPicture{
|
|
PictureType: flacpicture.PictureTypeFrontCover,
|
|
MIME: mime,
|
|
Description: "Front Cover",
|
|
ImageData: coverData,
|
|
}
|
|
|
|
// Width/height/depth are optional in practice; keep zero when decode fails.
|
|
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
|
|
picture.Width = uint32(cfg.Width)
|
|
picture.Height = uint32(cfg.Height)
|
|
switch format {
|
|
case "png":
|
|
picture.ColorDepth = 32
|
|
case "jpeg":
|
|
picture.ColorDepth = 24
|
|
default:
|
|
picture.ColorDepth = 0
|
|
}
|
|
}
|
|
|
|
return picture.Marshal(), nil
|
|
}
|
|
|
|
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
|
|
Composer string
|
|
Comment 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)
|
|
}
|
|
|
|
if metadata.Composer != "" {
|
|
setComment(cmt, "COMPOSER", metadata.Composer)
|
|
}
|
|
|
|
if metadata.Comment != "" {
|
|
setComment(cmt, "COMMENT", metadata.Comment)
|
|
}
|
|
|
|
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:]...)
|
|
}
|
|
}
|
|
|
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create picture block: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
|
|
if metadata.Composer != "" {
|
|
setComment(cmt, "COMPOSER", metadata.Composer)
|
|
}
|
|
|
|
if metadata.Comment != "" {
|
|
setComment(cmt, "COMMENT", metadata.Comment)
|
|
}
|
|
|
|
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:]...)
|
|
}
|
|
}
|
|
|
|
picBlock, err := buildPictureBlock("", coverData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create picture block: %w", err)
|
|
}
|
|
f.Meta = append(f.Meta, &picBlock)
|
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
}
|
|
|
|
return f.Save(filePath)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
metadata.Genre = getComment(cmt, "GENRE")
|
|
metadata.Label = getComment(cmt, "ORGANIZATION")
|
|
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
|
metadata.Composer = getComment(cmt, "COMPOSER")
|
|
metadata.Comment = getComment(cmt, "COMMENT")
|
|
|
|
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 ExtractCoverArt(filePath string) ([]byte, error) {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
for _, meta := range f.Meta {
|
|
if meta.Type == flac.Picture {
|
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
|
|
return pic.ImageData, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, meta := range f.Meta {
|
|
if meta.Type == flac.Picture {
|
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if len(pic.ImageData) > 0 {
|
|
return pic.ImageData, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("no cover art found in file")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func ExtractLyrics(filePath string) (string, error) {
|
|
lower := strings.ToLower(filePath)
|
|
|
|
if strings.HasSuffix(lower, ".flac") {
|
|
lyrics, err := extractLyricsFromFlac(filePath)
|
|
if err == nil && strings.TrimSpace(lyrics) != "" {
|
|
return lyrics, nil
|
|
}
|
|
return extractLyricsFromSidecarLRC(filePath)
|
|
}
|
|
|
|
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
|
lyrics, err := extractLyricsFromM4A(filePath)
|
|
if err == nil && strings.TrimSpace(lyrics) != "" {
|
|
return lyrics, nil
|
|
}
|
|
return extractLyricsFromSidecarLRC(filePath)
|
|
}
|
|
|
|
if strings.HasSuffix(lower, ".mp3") {
|
|
meta, err := ReadID3Tags(filePath)
|
|
if err == nil && meta != nil {
|
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
|
return meta.Lyrics, nil
|
|
}
|
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
|
return meta.Comment, nil
|
|
}
|
|
}
|
|
return extractLyricsFromSidecarLRC(filePath)
|
|
}
|
|
|
|
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
|
meta, err := ReadOggVorbisComments(filePath)
|
|
if err == nil && meta != nil {
|
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
|
return meta.Lyrics, nil
|
|
}
|
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
|
return meta.Comment, nil
|
|
}
|
|
}
|
|
return extractLyricsFromSidecarLRC(filePath)
|
|
}
|
|
|
|
return extractLyricsFromSidecarLRC(filePath)
|
|
}
|
|
|
|
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ilst, err := findM4AIlstAtom(f, fi.Size())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metadata := &AudioMetadata{}
|
|
start := ilst.offset + ilst.headerSize
|
|
end := ilst.offset + ilst.size
|
|
for pos := start; pos+8 <= end; {
|
|
header, err := readAtomHeaderAt(f, pos, fi.Size())
|
|
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, fi.Size())
|
|
case "\xa9ART":
|
|
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "\xa9alb":
|
|
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "aART":
|
|
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "\xa9day":
|
|
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
|
|
metadata.Year = metadata.Date
|
|
case "\xa9gen":
|
|
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "\xa9wrt":
|
|
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "\xa9cmt":
|
|
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "cprt":
|
|
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "\xa9lyr":
|
|
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
|
case "trkn":
|
|
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
|
case "disk":
|
|
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
|
case "----":
|
|
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
metaBodyStart := meta.offset + meta.headerSize + 4
|
|
metaBodySize := meta.size - meta.headerSize - 4
|
|
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
|
return ilst, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Path 2: moov > meta > ilst (no udta wrapper)
|
|
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
|
metaBodyStart := meta.offset + meta.headerSize + 4
|
|
metaBodySize := meta.size - meta.headerSize - 4
|
|
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
|
return ilst, nil
|
|
}
|
|
}
|
|
|
|
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
|
}
|
|
|
|
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 readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
|
|
payload, err := readM4ADataPayload(f, parent, fileSize)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(payload) < 4 {
|
|
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
|
}
|
|
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
|
ext := filepath.Ext(filePath)
|
|
base := strings.TrimSuffix(filePath, ext)
|
|
if strings.TrimSpace(base) == "" {
|
|
return "", fmt.Errorf("no lyrics found in file")
|
|
}
|
|
|
|
lrcPath := base + ".lrc"
|
|
data, err := os.ReadFile(lrcPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("no lyrics found in file")
|
|
}
|
|
|
|
lyrics := strings.TrimSpace(string(data))
|
|
if lyrics == "" {
|
|
return "", fmt.Errorf("no lyrics found in file")
|
|
}
|
|
return lyrics, nil
|
|
}
|
|
|
|
func extractLyricsFromFlac(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 {
|
|
continue
|
|
}
|
|
|
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
lyrics, err := cmt.Get("LYRICS")
|
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
|
return lyrics[0], nil
|
|
}
|
|
|
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
|
return lyrics[0], nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no lyrics found in file")
|
|
}
|
|
|
|
func looksLikeEmbeddedLyrics(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
|
|
lower := strings.ToLower(trimmed)
|
|
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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)")
|
|
}
|
|
|
|
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, 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 := int(buf[22])<<8 | int(buf[23])
|
|
if bitDepth <= 0 {
|
|
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 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+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")
|
|
}
|