Files
yellowjacket/backend/tagwriter/tagwriter.go
T
yonlu 3642cbe0d5 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
2026-03-17 10:32:28 -04:00

108 lines
2.8 KiB
Go

// 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])
}