An album the user holds 2 of 10 tracks of showed a green tick reading "is in your library", and the mechanism was our own writer. tagwriter wrote track and disc *numbers* and dropped the totals, so autotagging a folder made the release MBID-matched -- which is what earns the tick -- while erasing the one field GetAlbumCompleteness reads. The evidence for "2 of 10" was destroyed by the act that produced the tick. FieldTotalTracks and FieldTotalDiscs are written as the ID3 "n/N" form and as Vorbis TRACKTOTAL/DISCTOTAL; the autotag apply pass and the download importer fill them from the release's own tracklist; and dbsync persists the track total to audio_files.total_tracks so the album page agrees with the file without waiting for a rescan. Five things about it are load-bearing, and four fail silently: - The total is per *disc*, not per release, because that is what the tag form declares and what GetAlbumCompleteness sums per disc. A release total on every file multiplies a two-disc album's expectation by two, which no library can satisfy. backend/tagtotals is that derivation once, since the two callers must not import the writer or each other. - The Vorbis names are TRACKTOTAL and DISCTOTAL and no other spelling. dhowden/tag reads exactly those two keys, so TOTALTRACKS -- which xiph lists and several taggers write -- or a "1/12" packed into TRACKNUMBER writes successfully and reads back as no total at all. The tests therefore assert the round trip through the reader the scan uses, not through the bytes. - ID3's number and total share one frame, so writing either alone must read the other off the existing tag or discard it. A total with no number is not written: "/12" parses as track 0. - The totals are written unconditionally rather than on a diff. The case this exists for is a file declaring no total at all, which compares equal to nothing and is exactly what a "only if it changed" guard skips. - A single-track download is not totalled. A RecordingMBID anchor resolves Expected to that one track, so the same code would tag a track off a twelve-track album "1 of 1" -- and a declared total outranks the catalog total that would have answered correctly. autotag's field constants are a second copy of tagwriter's, deliberately so autotag stays out of the write pipeline's import graph. A key that drifts neither fails to compile nor fails to write -- the writer simply finds nothing under the name it looks for -- so autotagservice, the one package importing both, now pins them. Steps 2 and 3 of the issue stay open under #38: the catalog fallback already landed as completenessAnswer(), and the badge call-site audit is the part that overlaps it. Closes #16
202 lines
4.9 KiB
Go
202 lines
4.9 KiB
Go
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},
|
|
// TRACKTOTAL/DISCTOTAL and no other spelling: dhowden/tag's
|
|
// Vorbis reader looks at exactly these two keys, so TOTALTRACKS
|
|
// or a "1/12" inside TRACKNUMBER reads back as no total at all.
|
|
{FieldTotalTracks, "TRACKTOTAL", true},
|
|
{FieldTotalDiscs, "DISCTOTAL", true},
|
|
{FieldComposer, "COMPOSER", false},
|
|
}
|
|
|
|
for _, m := range mappings {
|
|
v, ok := changes[m.key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
var val string
|
|
|
|
if m.isInt {
|
|
n, ok := asInt(v)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
val = strconv.Itoa(n)
|
|
} else {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
val = s
|
|
}
|
|
|
|
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 := asBytes(v)
|
|
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
|
|
}
|