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
174 lines
4.4 KiB
Go
174 lines
4.4 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
|
|
|
|
// 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.
|
|
type AudioFormat string
|
|
|
|
const (
|
|
// FormatMP3 is the MP3 audio format.
|
|
FormatMP3 AudioFormat = "mp3"
|
|
// FormatFLAC is the FLAC audio format.
|
|
FormatFLAC AudioFormat = "flac"
|
|
// FormatWAV is the WAV audio format.
|
|
FormatWAV AudioFormat = "wav"
|
|
// FormatOGG is the OGG Vorbis audio format.
|
|
FormatOGG AudioFormat = "ogg"
|
|
)
|
|
|
|
// 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
|
|
case ".wav":
|
|
return FormatWAV, nil
|
|
case ".ogg":
|
|
return FormatOGG, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
|
|
}
|
|
}
|
|
|
|
// asInt extracts an integer from a TagChanges value. JSON numbers from
|
|
// Wails arrive as float64; Go callers may pass int. Returns (value, true)
|
|
// on success or (0, false) if the value is not a recognised numeric type.
|
|
func asInt(v any) (int, bool) {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n, true
|
|
case float64:
|
|
return int(n), true
|
|
case int64:
|
|
return int(n), true
|
|
case float32:
|
|
return int(n), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// asBytes extracts a byte slice from a TagChanges value. JSON arrays
|
|
// from Wails arrive as []interface{} of float64; Go callers may pass
|
|
// []byte directly. Returns (data, true) on success or (nil, false).
|
|
func asBytes(v any) ([]byte, bool) {
|
|
if v == nil {
|
|
return nil, false
|
|
}
|
|
|
|
if b, ok := v.([]byte); ok {
|
|
return b, true
|
|
}
|
|
|
|
arr, ok := v.([]interface{})
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
out := make([]byte, len(arr))
|
|
|
|
for i, elem := range arr {
|
|
f, ok := elem.(float64)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
out[i] = byte(f)
|
|
}
|
|
|
|
return out, true
|
|
}
|
|
|
|
// 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])
|
|
}
|