Files
yellowjacket/backend/tagwriter/mp3.go
T
yonlu 8f4c4a0c2b fix(19-01): add album_artist TPE2 mapping to applyTextChanges
- Map FieldAlbumArtist to TPE2 frame via CommonID
- Follows same pattern as FieldComposer → TCOM
- Fixes latent gap in MP3 writing, enables WAV reuse
2026-03-19 08:42:53 -04:00

145 lines
3.8 KiB
Go

package tagwriter
import (
"fmt"
"io"
"log/slog"
"os"
"strconv"
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/fileutil"
)
// writeMp3Tags applies the given TagChanges to an MP3 file's ID3v2 tag
// and writes the result atomically via fileutil.AtomicWrite.
func writeMp3Tags(logger *slog.Logger, filePath string, changes TagChanges) error {
// Snapshot the original tag size before opening the tag for editing.
// We need this later to locate the start of the audio data in the
// original file so we can copy it into the new temp file.
originalTagSize, err := id3v2OriginalTagSize(filePath)
if err != nil {
return fmt.Errorf("read original tag size: %w", err)
}
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("open mp3 for tag writing: %w", err)
}
defer func() { _ = tag.Close() }()
applyTextChanges(tag, changes)
applyCoverArtChanges(tag, changes)
return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error {
// Write the new ID3v2 tag to the temp file.
if _, wErr := tag.WriteTo(tmp); wErr != nil {
return fmt.Errorf("write id3v2 tag: %w", wErr)
}
// Copy the audio data from the original file.
return copyAudioData(filePath, originalTagSize, tmp)
})
}
// applyTextChanges maps diff-map fields to ID3v2 setter calls.
func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
if v, ok := changes[FieldTitle].(string); ok {
tag.SetTitle(v)
}
if v, ok := changes[FieldArtist].(string); ok {
tag.SetArtist(v)
}
if v, ok := changes[FieldAlbum].(string); ok {
tag.SetAlbum(v)
}
if v, ok := changes[FieldGenre].(string); ok {
tag.SetGenre(v)
}
if v, ok := asInt(changes[FieldYear]); ok {
tag.SetYear(strconv.Itoa(v))
}
if v, ok := asInt(changes[FieldTrackNumber]); ok {
trckID := tag.CommonID("Track number/Position in set")
tag.DeleteFrames(trckID)
tag.AddTextFrame(trckID, id3v2.EncodingUTF8, strconv.Itoa(v))
}
if v, ok := asInt(changes[FieldDiscNumber]); ok {
tposID := tag.CommonID("Part of a set")
tag.DeleteFrames(tposID)
tag.AddTextFrame(tposID, id3v2.EncodingUTF8, strconv.Itoa(v))
}
if v, ok := changes[FieldComposer].(string); ok {
tag.DeleteFrames("TCOM")
tag.AddTextFrame("TCOM", id3v2.EncodingUTF8, v)
}
if v, ok := changes[FieldAlbumArtist].(string); ok {
tpe2ID := tag.CommonID("Band/Orchestra/Accompaniment")
tag.DeleteFrames(tpe2ID)
tag.AddTextFrame(tpe2ID, id3v2.EncodingUTF8, v)
}
}
// applyCoverArtChanges handles the FieldCoverArt entry in the diff map.
//
// - []byte with len > 0: embed the given image as front cover.
// - nil (key present): clear all attached pictures.
func applyCoverArtChanges(tag *id3v2.Tag, changes TagChanges) {
val, present := changes[FieldCoverArt]
if !present {
return
}
apicID := tag.CommonID("Attached picture")
data, isBytes := asBytes(val)
if isBytes && len(data) > 0 {
tag.DeleteFrames(apicID)
tag.AddAttachedPicture(id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: detectMIME(data),
PictureType: id3v2.PTFrontCover,
Description: "Front cover",
Picture: data,
})
return
}
// Key is present with nil or empty slice — clear art.
tag.DeleteFrames(apicID)
}
// copyAudioData opens the original MP3, seeks past the ID3v2 tag, and
// copies the remaining audio data into dst.
func copyAudioData(originalPath string, tagSize int64, dst *os.File) error {
src, err := os.Open(originalPath)
if err != nil {
return fmt.Errorf("open original for audio copy: %w", err)
}
defer func() { _ = src.Close() }()
if tagSize > 0 {
if _, err := src.Seek(tagSize, io.SeekStart); err != nil {
return fmt.Errorf("seek past original tag: %w", err)
}
}
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("copy audio data: %w", err)
}
return nil
}