fix(tagwriter): declare the track and disc totals when tagging
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
This commit is contained in:
@@ -183,6 +183,15 @@ func syncDatabase(
|
||||
discNum = toNullInt64(v)
|
||||
}
|
||||
|
||||
// The completeness evidence. Without this the row keeps whatever
|
||||
// the last scan read while the file on disk now declares a total,
|
||||
// so the album stays "unknown" until a full rescan -- which is the
|
||||
// state the report describes.
|
||||
totalTracks := old.TotalTracks
|
||||
if v, ok := asInt(params.changes[FieldTotalTracks]); ok {
|
||||
totalTracks = toNullInt64(v)
|
||||
}
|
||||
|
||||
composer := old.Composer
|
||||
if v, ok := params.changes[FieldComposer].(string); ok {
|
||||
composer = v
|
||||
@@ -207,7 +216,7 @@ func syncDatabase(
|
||||
AlbumID: albumID,
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
TotalTracks: old.TotalTracks,
|
||||
TotalTracks: totalTracks,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
Comment: old.Comment,
|
||||
|
||||
@@ -101,6 +101,11 @@ func applyFlacTextChanges(cmt *flacvorbis.MetaDataBlockVorbisComment, changes Ta
|
||||
{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},
|
||||
}
|
||||
|
||||
|
||||
+63
-11
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
|
||||
@@ -66,17 +67,10 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
|
||||
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))
|
||||
}
|
||||
applyPositionFrame(tag, "Track number/Position in set", changes,
|
||||
FieldTrackNumber, FieldTotalTracks)
|
||||
applyPositionFrame(tag, "Part of a set", changes,
|
||||
FieldDiscNumber, FieldTotalDiscs)
|
||||
|
||||
if v, ok := changes[FieldComposer].(string); ok {
|
||||
tag.DeleteFrames("TCOM")
|
||||
@@ -90,6 +84,64 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyPositionFrame writes an ID3v2 position frame (TRCK or TPOS) in
|
||||
// the "n/N" form the readers parse.
|
||||
//
|
||||
// The number and the total are separate diff entries and either may be
|
||||
// absent, so the frame's *existing* value is the base: writing a total
|
||||
// alone must not discard the number that is already there, and writing
|
||||
// a number alone must not discard a total the file already declared.
|
||||
// A total with no number at all is not written, since "/12" says
|
||||
// nothing a reader can use.
|
||||
func applyPositionFrame(
|
||||
tag *id3v2.Tag, description string, changes TagChanges, numKey, totalKey string,
|
||||
) {
|
||||
_, hasNum := changes[numKey]
|
||||
_, hasTotal := changes[totalKey]
|
||||
|
||||
if !hasNum && !hasTotal {
|
||||
return
|
||||
}
|
||||
|
||||
frameID := tag.CommonID(description)
|
||||
|
||||
num, total := parseXofN(
|
||||
strings.TrimRight(tag.GetTextFrame(frameID).Text, "\x00 \t\n\r"),
|
||||
)
|
||||
|
||||
if v, ok := asInt(changes[numKey]); ok {
|
||||
num = v
|
||||
}
|
||||
|
||||
if v, ok := asInt(changes[totalKey]); ok {
|
||||
total = v
|
||||
}
|
||||
|
||||
if num <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
value := strconv.Itoa(num)
|
||||
if total > 0 {
|
||||
value += "/" + strconv.Itoa(total)
|
||||
}
|
||||
|
||||
tag.DeleteFrames(frameID)
|
||||
tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value)
|
||||
}
|
||||
|
||||
// parseXofN splits an ID3v2 "n/N" position value. A bare "n" yields a
|
||||
// zero total, and anything unparseable yields zeros — the same reading
|
||||
// dhowden/tag gives the frame.
|
||||
func parseXofN(s string) (int, int) {
|
||||
numText, totalText, _ := strings.Cut(s, "/")
|
||||
|
||||
num, _ := strconv.Atoi(strings.TrimSpace(numText))
|
||||
total, _ := strconv.Atoi(strings.TrimSpace(totalText))
|
||||
|
||||
return num, total
|
||||
}
|
||||
|
||||
// applyCoverArtChanges handles the FieldCoverArt entry in the diff map.
|
||||
//
|
||||
// - []byte with len > 0: embed the given image as front cover.
|
||||
|
||||
@@ -166,6 +166,8 @@ var oggFieldMappings = []struct { //nolint:gochecknoglobals // field mapping tab
|
||||
{FieldYear, "DATE", true},
|
||||
{FieldTrackNumber, "TRACKNUMBER", true},
|
||||
{FieldDiscNumber, "DISCNUMBER", true},
|
||||
{FieldTotalTracks, "TRACKTOTAL", true},
|
||||
{FieldTotalDiscs, "DISCTOTAL", true},
|
||||
{FieldComposer, "COMPOSER", false},
|
||||
}
|
||||
|
||||
|
||||
@@ -333,3 +333,31 @@ func TestWriteTrackTags_DBSync(t *testing.T) {
|
||||
t.Error("expected FTS5 result for 'New Title'")
|
||||
}
|
||||
}
|
||||
|
||||
// The row is what the album page reads, and it is only refreshed by a
|
||||
// scan. Leaving total_tracks at whatever the last scan saw means an
|
||||
// album autotagged just now stays "unknown" -- a plain tick on an album
|
||||
// the user holds two tracks of -- until a full rescan happens to run.
|
||||
func TestWriteTrackTags_PersistsTheTotal(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
dir := t.TempDir()
|
||||
trackID := seedTestTrack(t, db, createPipelineTestMP3(t, dir))
|
||||
|
||||
tw := NewTagWriter(testLogger(), db, &mockPlayer{}, &mockPipelineLocker{})
|
||||
|
||||
if err := tw.WriteTrackTags(trackID, TagChanges{
|
||||
FieldTrackNumber: 2,
|
||||
FieldTotalTracks: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteTrackTags: %v", err)
|
||||
}
|
||||
|
||||
af, err := db.Queries.GetAudioFile(context.Background(), trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get audio file: %v", err)
|
||||
}
|
||||
|
||||
if !af.TotalTracks.Valid || af.TotalTracks.Int64 != 10 {
|
||||
t.Errorf("total_tracks: got %v, want 10", af.TotalTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ const (
|
||||
FieldDiscNumber = "disc_number"
|
||||
FieldComposer = "composer"
|
||||
FieldCoverArt = "cover_art" // []byte for set, nil for clear
|
||||
|
||||
// FieldTotalTracks is how many tracks are on *this file's disc*, not
|
||||
// in the whole release. That is what the "5/12" form declares and
|
||||
// what GetAlbumCompleteness sums per disc; a release total written
|
||||
// here would multiply the expectation by the number of discs.
|
||||
FieldTotalTracks = "total_tracks"
|
||||
|
||||
// FieldTotalDiscs is how many discs the release has.
|
||||
FieldTotalDiscs = "total_discs"
|
||||
)
|
||||
|
||||
// AudioFormat represents a supported audio file format.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/metadata"
|
||||
)
|
||||
|
||||
// The totals are the evidence GetAlbumCompleteness reads, and every way
|
||||
// of getting them wrong is silent: a tag written under a name the
|
||||
// reader does not look at reads back as no total at all, which is
|
||||
// indistinguishable from never having written one. So these assert the
|
||||
// round trip through the *reader the scan uses*, not the bytes.
|
||||
//
|
||||
// WAV is the exception and it is not this change's: dhowden/tag has no
|
||||
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3
|
||||
// chunk -- which is why every other test here reads that chunk itself.
|
||||
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
changes := TagChanges{
|
||||
FieldTitle: "Some Song",
|
||||
FieldTrackNumber: 2,
|
||||
FieldTotalTracks: 10,
|
||||
FieldDiscNumber: 1,
|
||||
FieldTotalDiscs: 2,
|
||||
}
|
||||
|
||||
viaScanner := func(t *testing.T, path string) *metadata.TrackMetadata {
|
||||
t.Helper()
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
write func(t *testing.T, dir string) string
|
||||
read func(t *testing.T, path string) *metadata.TrackMetadata
|
||||
}{
|
||||
{
|
||||
name: "mp3",
|
||||
read: viaScanner,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := createTestMP3(t, dir, "totals.mp3", nil)
|
||||
if err := writeMp3Tags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flac",
|
||||
read: viaScanner,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, "totals.flac")
|
||||
makeMinimalFLAC(t, path)
|
||||
|
||||
if err := writeFlacTags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeFlacTags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ogg",
|
||||
read: viaScanner,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, "totals.ogg")
|
||||
createTestOGG(t, path)
|
||||
|
||||
if err := writeOggTags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeOggTags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wav",
|
||||
read: readWavID3Tags,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := createTestWAV(t, dir, "totals.wav", nil)
|
||||
|
||||
if err := writeWavTags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeWavTags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
meta := tc.read(t, tc.write(t, t.TempDir()))
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 2)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 10)
|
||||
assertIntField(t, "DiscNumber", meta.DiscNumber, 1)
|
||||
assertIntField(t, "TotalDiscs", meta.TotalDiscs, 2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A number and a total are separate diff entries, so writing one must
|
||||
// not discard the other. For ID3v2 they share a single "n/N" frame,
|
||||
// which is the only place this can go wrong -- and it goes wrong by
|
||||
// silently zeroing a total the file already declared.
|
||||
func TestWriteMp3Totals_PartialUpdateKeepsTheOtherHalf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("writing the number keeps the total", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := createTestMP3(t, dir, "seeded.mp3", TagChanges{
|
||||
FieldTrackNumber: 2,
|
||||
FieldTotalTracks: 10,
|
||||
})
|
||||
|
||||
if err := writeMp3Tags(testLogger(), path, TagChanges{
|
||||
FieldTrackNumber: 4,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 4)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 10)
|
||||
})
|
||||
|
||||
t.Run("writing the total keeps the number", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := createTestMP3(t, dir, "seeded.mp3", TagChanges{
|
||||
FieldTrackNumber: 7,
|
||||
})
|
||||
|
||||
if err := writeMp3Tags(testLogger(), path, TagChanges{
|
||||
FieldTotalTracks: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 7)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 12)
|
||||
})
|
||||
|
||||
// "/12" says nothing a reader can use, and dhowden/tag reads it as
|
||||
// track 0 -- which the scan would store as a real track number.
|
||||
t.Run("a total with no number writes nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := createTestMP3(t, dir, "bare.mp3", nil)
|
||||
|
||||
if err := writeMp3Tags(testLogger(), path, TagChanges{
|
||||
FieldTotalTracks: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 0)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 0)
|
||||
})
|
||||
}
|
||||
@@ -522,19 +522,20 @@ func readWavID3Tags(
|
||||
}
|
||||
}
|
||||
|
||||
// Track number (TRCK).
|
||||
// Track number and total (TRCK), disc number and total (TPOS).
|
||||
// Both carry the "n/N" form, so they are read the way a reader
|
||||
// reads them rather than with Atoi -- which sees "2/10" as 0.
|
||||
trckID := parsed.CommonID("Track number/Position in set")
|
||||
if frames := parsed.GetFrames(trckID); len(frames) > 0 {
|
||||
if tf, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
meta.TrackNumber = atoiSafe(tf.Text)
|
||||
meta.TrackNumber, meta.TotalTracks = parseXofN(tf.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Disc number (TPOS).
|
||||
tposID := parsed.CommonID("Part of a set")
|
||||
if frames := parsed.GetFrames(tposID); len(frames) > 0 {
|
||||
if tf, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
meta.DiscNumber = atoiSafe(tf.Text)
|
||||
meta.DiscNumber, meta.TotalDiscs = parseXofN(tf.Text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user