- Map FieldAlbumArtist to TPE2 frame via CommonID - Follows same pattern as FieldComposer → TCOM - Fixes latent gap in MP3 writing, enables WAV reuse
145 lines
3.8 KiB
Go
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
|
|
}
|