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
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>
216 lines
6.1 KiB
Go
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
|
|
}
|