Files
minstrel/internal/library/vorbisgenre.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00

216 lines
6.1 KiB
Go

package library
import (
"bufio"
"bytes"
"encoding/binary"
"errors"
"io"
"strings"
)
// Why this file exists: Vorbis comments (FLAC, Ogg Vorbis, Opus) say a field
// has several values by repeating it:
//
// GENRE=Boom Bap
// GENRE=Downtempo
//
// dhowden/tag stores comments in a map keyed by field name
// (vorbis.go: m.c[strings.ToLower(k)] = v), so each repeat overwrites the last
// and only the final value survives. An album that declares eight genres
// would be stored under the last alone; nothing would look corrupt, and the
// other seven would never be seen by browse or the taste profile.
//
// The comment block is read here directly, as the ID3v2 genre frame is in
// id3v2genre.go. Every other field still comes from dhowden/tag.
// maxVorbisCommentSize caps a comment block or packet. Comments are kilobytes;
// an embedded METADATA_BLOCK_PICTURE can take them to a few megabytes. The cap
// only stops a corrupt length from making the scanner allocate wildly.
const maxVorbisCommentSize = 16 << 20
var errMalformedComment = errors.New("library: malformed Vorbis comment")
// readFLACGenreValues returns every GENRE value in a FLAC file's comment block.
// (nil, nil) means the file is readable and declares no genre. rs is seeked to
// the start, so it is safe to call after dhowden/tag has consumed the reader.
func readFLACGenreValues(rs io.ReadSeeker) ([]string, error) {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return nil, err
}
var magic [4]byte
if _, err := io.ReadFull(rs, magic[:]); err != nil {
return nil, errNoGenreFrame
}
// Some taggers prepend an ID3v2 tag to a FLAC. The FLAC stream starts after
// it.
if string(magic[:3]) == "ID3" {
var rest [6]byte
if _, err := io.ReadFull(rs, rest[:]); err != nil {
return nil, errNoGenreFrame
}
size := syncsafeInt(rest[2:6])
if size < 0 {
return nil, errNoGenreFrame
}
skip := int64(10 + size)
if rest[1]&0x10 != 0 {
skip += 10 // 2.4 footer
}
if _, err := rs.Seek(skip, io.SeekStart); err != nil {
return nil, errNoGenreFrame
}
if _, err := io.ReadFull(rs, magic[:]); err != nil {
return nil, errNoGenreFrame
}
}
if string(magic[:]) != "fLaC" {
return nil, errNoGenreFrame
}
for {
var hdr [4]byte
if _, err := io.ReadFull(rs, hdr[:]); err != nil {
return nil, errNoGenreFrame
}
last := hdr[0]&0x80 != 0
blockType := hdr[0] & 0x7F
size := int64(hdr[1])<<16 | int64(hdr[2])<<8 | int64(hdr[3])
switch blockType {
case 4: // VORBIS_COMMENT
body := make([]byte, size)
if _, err := io.ReadFull(rs, body); err != nil {
return nil, errNoGenreFrame
}
return vorbisCommentGenres(body)
case 127: // invalid by spec; nothing after it can be trusted
return nil, errNoGenreFrame
}
if last {
return nil, nil // no comment block at all
}
if _, err := rs.Seek(size, io.SeekCurrent); err != nil {
return nil, errNoGenreFrame
}
}
}
// readOggGenreValues returns every GENRE value in an Ogg Vorbis or Opus file.
// The comments are the stream's second packet, which can span several pages
// when it carries cover art, so pages are reassembled until it is complete.
// Only the first logical stream is read; pages of any other are skipped.
func readOggGenreValues(rs io.ReadSeeker) ([]string, error) {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return nil, err
}
br := bufio.NewReader(rs)
var (
serial uint32
haveFirst bool
packet []byte
packetIdx int
)
for {
var hdr [27]byte
if _, err := io.ReadFull(br, hdr[:]); err != nil {
return nil, errNoGenreFrame
}
if string(hdr[0:4]) != "OggS" {
return nil, errNoGenreFrame
}
pageSerial := binary.LittleEndian.Uint32(hdr[14:18])
segments := make([]byte, hdr[26])
if _, err := io.ReadFull(br, segments); err != nil {
return nil, errNoGenreFrame
}
if !haveFirst {
serial, haveFirst = pageSerial, true
}
if pageSerial != serial {
var n int64
for _, l := range segments {
n += int64(l)
}
if _, err := io.CopyN(io.Discard, br, n); err != nil {
return nil, errNoGenreFrame
}
continue
}
for _, l := range segments {
if len(packet)+int(l) > maxVorbisCommentSize {
return nil, errNoGenreFrame
}
seg := make([]byte, l)
if _, err := io.ReadFull(br, seg); err != nil {
return nil, errNoGenreFrame
}
packet = append(packet, seg...)
if l == 255 {
continue // the packet carries on in the next segment
}
if packetIdx == 1 {
return oggCommentGenres(packet)
}
packetIdx++
packet = packet[:0]
}
}
}
// oggCommentGenres strips the codec's comment-packet prefix. Ogg FLAC and Speex
// are rare enough to leave to dhowden/tag.
func oggCommentGenres(packet []byte) ([]string, error) {
switch {
case bytes.HasPrefix(packet, []byte("\x03vorbis")):
return vorbisCommentGenres(packet[7:])
case bytes.HasPrefix(packet, []byte("OpusTags")):
return vorbisCommentGenres(packet[8:])
}
return nil, errNoGenreFrame
}
// vorbisCommentGenres reads a comment block (vendor string, then a count of
// length-prefixed "KEY=value" entries, little-endian lengths throughout) and
// returns the GENRE values in file order. Field names are case-insensitive by
// spec. Anything after the last entry, such as Vorbis's framing bit, is
// ignored.
func vorbisCommentGenres(b []byte) ([]string, error) {
next := func() ([]byte, bool) {
if len(b) < 4 {
return nil, false
}
n := binary.LittleEndian.Uint32(b)
if uint64(n) > uint64(len(b)-4) {
return nil, false
}
field := b[4 : 4+n]
b = b[4+n:]
return field, true
}
if _, ok := next(); !ok { // vendor string
return nil, errMalformedComment
}
if len(b) < 4 {
return nil, errMalformedComment
}
count := binary.LittleEndian.Uint32(b)
b = b[4:]
var out []string
// Each entry costs at least four bytes, so a corrupt count runs out of
// input long before it runs up a loop.
for i := uint32(0); i < count; i++ {
field, ok := next()
if !ok {
return nil, errMalformedComment
}
key, value, found := strings.Cut(string(field), "=")
if !found || !strings.EqualFold(key, "GENRE") {
continue
}
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out, nil
}