feat(16-02): add go-flac dependencies and implement FLAC tag writer
- Add go-flac/go-flac/v2, flacvorbis/v2, flacpicture/v2 dependencies - Create tagwriter.go with TagChanges type, field constants, format detection, MIME detection - Create flac.go with writeFlacTags using Vorbis Comments + PICTURE blocks + AtomicWrite - Implement replaceVorbisComment helper for case-insensitive field replacement - Handle cover art add/replace/clear via PICTURE metadata blocks
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-flac/flacpicture/v2"
|
||||
"github.com/go-flac/flacvorbis/v2"
|
||||
flac "github.com/go-flac/go-flac/v2"
|
||||
|
||||
"yellowjacket/backend/fileutil"
|
||||
)
|
||||
|
||||
// writeFlacTags writes metadata tags to a FLAC file using Vorbis Comments
|
||||
// and PICTURE metadata blocks, integrated with AtomicWrite for crash safety.
|
||||
func writeFlacTags(logger *slog.Logger, filePath string, changes TagChanges) error {
|
||||
// Check file size and warn for very large files.
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
const largeSizeThreshold = 500 * 1024 * 1024 // 500 MB
|
||||
if info.Size() > largeSizeThreshold {
|
||||
logger.Warn("large FLAC file may use significant memory",
|
||||
slog.String("path", filePath),
|
||||
slog.Int64("size", info.Size()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse flac: %w", err)
|
||||
}
|
||||
|
||||
// Find existing Vorbis Comment block.
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
cmtIdx := -1
|
||||
|
||||
for idx, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse vorbis comments: %w", err)
|
||||
}
|
||||
|
||||
cmtIdx = idx
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cmt == nil {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
// Apply text changes from the diff map.
|
||||
if err := applyFlacTextChanges(cmt, changes); err != nil {
|
||||
return fmt.Errorf("apply flac text changes: %w", err)
|
||||
}
|
||||
|
||||
// Marshal Vorbis Comment block back and update f.Meta.
|
||||
cmtMeta := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtMeta
|
||||
} else {
|
||||
f.Meta = append(f.Meta, &cmtMeta)
|
||||
}
|
||||
|
||||
// Handle cover art — PICTURE metadata block.
|
||||
if err := applyFlacCoverArt(f, changes); err != nil {
|
||||
return fmt.Errorf("apply flac cover art: %w", err)
|
||||
}
|
||||
|
||||
// Write atomically via AtomicWrite.
|
||||
return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error {
|
||||
_, writeErr := f.WriteTo(tmp)
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write flac: %w", writeErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// applyFlacTextChanges applies text field changes to a Vorbis Comment block.
|
||||
func applyFlacTextChanges(cmt *flacvorbis.MetaDataBlockVorbisComment, changes TagChanges) error {
|
||||
type fieldMapping struct {
|
||||
key string
|
||||
vorbisID string
|
||||
isInt bool
|
||||
}
|
||||
|
||||
mappings := []fieldMapping{
|
||||
{FieldTitle, flacvorbis.FIELD_TITLE, false},
|
||||
{FieldArtist, flacvorbis.FIELD_ARTIST, false},
|
||||
{FieldAlbum, flacvorbis.FIELD_ALBUM, false},
|
||||
{FieldAlbumArtist, "ALBUMARTIST", false},
|
||||
{FieldGenre, flacvorbis.FIELD_GENRE, false},
|
||||
{FieldYear, flacvorbis.FIELD_DATE, true},
|
||||
{FieldTrackNumber, flacvorbis.FIELD_TRACKNUMBER, true},
|
||||
{FieldDiscNumber, "DISCNUMBER", true},
|
||||
{FieldComposer, "COMPOSER", false},
|
||||
}
|
||||
|
||||
for _, m := range mappings {
|
||||
v, ok := changes[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var val string
|
||||
if m.isInt {
|
||||
val = strconv.Itoa(v.(int))
|
||||
} else {
|
||||
val = v.(string)
|
||||
}
|
||||
|
||||
replaceVorbisComment(cmt, m.vorbisID, val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceVorbisComment removes all existing entries for a field and adds a
|
||||
// new value. Vorbis Comment field names are case-insensitive per spec.
|
||||
func replaceVorbisComment(cmt *flacvorbis.MetaDataBlockVorbisComment, field string, value string) {
|
||||
// Remove all existing entries for this field (case-insensitive).
|
||||
prefix := strings.ToUpper(field) + "="
|
||||
filtered := make([]string, 0, len(cmt.Comments))
|
||||
|
||||
for _, c := range cmt.Comments {
|
||||
if !strings.HasPrefix(strings.ToUpper(c), prefix) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
|
||||
cmt.Comments = filtered
|
||||
|
||||
// Add the new value. Using the uppercase field name (Vorbis convention).
|
||||
_ = cmt.Add(strings.ToUpper(field), value)
|
||||
}
|
||||
|
||||
// applyFlacCoverArt handles adding, replacing, or clearing PICTURE metadata
|
||||
// blocks in a FLAC file.
|
||||
func applyFlacCoverArt(f *flac.File, changes TagChanges) error {
|
||||
v, ok := changes[FieldCoverArt]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove all existing PICTURE blocks.
|
||||
newMeta := make([]*flac.MetaDataBlock, 0, len(f.Meta))
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type != flac.Picture {
|
||||
newMeta = append(newMeta, meta)
|
||||
}
|
||||
}
|
||||
|
||||
f.Meta = newMeta
|
||||
|
||||
// If value is nil or empty, we've cleared the art — done.
|
||||
data, isBytes := v.([]byte)
|
||||
if !isBytes || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create new PICTURE block with the provided image data.
|
||||
pic, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front cover",
|
||||
data,
|
||||
detectMIME(data),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create flac picture: %w", err)
|
||||
}
|
||||
|
||||
picMeta := pic.Marshal()
|
||||
f.Meta = append(f.Meta, &picMeta)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package tagwriter writes metadata tags to audio files.
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TagChanges is a diff map of field name → new value. Only changed
|
||||
// fields are present. Callers specify changed fields; unchanged
|
||||
// fields are left as-is in the file.
|
||||
type TagChanges map[string]any
|
||||
|
||||
// Field name constants for the diff map.
|
||||
const (
|
||||
FieldTitle = "title"
|
||||
FieldArtist = "artist"
|
||||
FieldAlbum = "album"
|
||||
FieldAlbumArtist = "album_artist"
|
||||
FieldGenre = "genre"
|
||||
FieldYear = "year"
|
||||
FieldTrackNumber = "track_number"
|
||||
FieldDiscNumber = "disc_number"
|
||||
FieldComposer = "composer"
|
||||
FieldCoverArt = "cover_art" // []byte for set, nil for clear
|
||||
)
|
||||
|
||||
// AudioFormat represents a supported audio file format.
|
||||
type AudioFormat string
|
||||
|
||||
const (
|
||||
// FormatMP3 is the MP3 audio format.
|
||||
FormatMP3 AudioFormat = "mp3"
|
||||
// FormatFLAC is the FLAC audio format.
|
||||
FormatFLAC AudioFormat = "flac"
|
||||
)
|
||||
|
||||
// errUnsupportedFormat is returned when the audio format is not supported.
|
||||
var errUnsupportedFormat = errors.New("tagwriter: unsupported audio format")
|
||||
|
||||
// DetectFormat determines the audio format of a file from its extension.
|
||||
func DetectFormat(filePath string) (AudioFormat, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return FormatMP3, nil
|
||||
case ".flac":
|
||||
return FormatFLAC, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
|
||||
}
|
||||
}
|
||||
|
||||
// detectMIME returns the MIME type of image data by checking magic bytes.
|
||||
func detectMIME(data []byte) string {
|
||||
if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
if len(data) >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||
return "image/png"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// id3v2OriginalTagSize reads an MP3 file's ID3v2 header and returns the
|
||||
// total number of bytes occupied by the tag (10-byte header + body).
|
||||
// If the file does not start with an ID3v2 header it returns 0.
|
||||
func id3v2OriginalTagSize(path string) (int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open for tag size: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
// ID3v2 header: "ID3" (3 B) + version (2 B) + flags (1 B) + size (4 B synchsafe).
|
||||
var hdr [10]byte
|
||||
if _, err := f.Read(hdr[:]); err != nil {
|
||||
return 0, fmt.Errorf("read id3v2 header: %w", err)
|
||||
}
|
||||
|
||||
if string(hdr[:3]) != "ID3" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
size := decodeSynchSafe(hdr[6:10])
|
||||
|
||||
const headerBytes = 10
|
||||
|
||||
return headerBytes + int64(size), nil
|
||||
}
|
||||
|
||||
// decodeSynchSafe decodes a 4-byte synchsafe integer (7 bits per byte).
|
||||
func decodeSynchSafe(b []byte) uint32 {
|
||||
_ = b[3] // bounds check hint
|
||||
|
||||
return uint32(b[0])<<21 |
|
||||
uint32(b[1])<<14 |
|
||||
uint32(b[2])<<7 |
|
||||
uint32(b[3])
|
||||
}
|
||||
Reference in New Issue
Block a user