fix(library): read every genre of FLAC, Ogg, Opus and MP4 files (#2500)
release / govulncheck (push) Successful in 50s
release / web (push) Successful in 2m3s
release / go (push) Successful in 2m18s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 6m30s
release / Build signed APK (releases and dev) (push) Successful in 6m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 50s
release / web (push) Successful in 2m3s
release / go (push) Successful in 2m18s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 6m30s
release / Build signed APK (releases and dev) (push) Successful in 6m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
Vorbis comments repeat a field to give it several values (GENRE=Boom Bap, GENRE=Downtempo, ...). dhowden/tag keeps comments in a map keyed by field name, so each repeat overwrote the previous one and only the last genre was stored. MP4 has the same gap: several data atoms in one ©gen atom, or repeated ©gen atoms, collapse to one value. On the operator's library 3,658 of 4,092 FLACs declare more than one genre. Kupla's Life Forms carries eight and was stored as "Instrumental Hip Hop" alone, so browse and the taste profile never saw the other seven. - vorbisgenre.go reads the comment block directly: FLAC's metadata block (including FLACs behind an ID3v2 tag), and the comment packet of Ogg Vorbis and Opus, reassembled across pages when cover art makes it span several. - mp4genre.go walks moov > udta > meta > ilst and returns every ©gen text value. It handles ISO and QuickTime meta layouts and a moov placed after mdat. A file with only the numeric gnre atom still falls back to dhowden, which resolves it. - extractGenres routes VORBIS and MP4 through them, as #2499 did for ID3v2. - tagReadVersion 3 -> 4, so the next scan re-reads the tags of files already indexed. Unchanged files keep their duration and fingerprint, so the pass costs tag reads only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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. On the operator's library that was 3,658
|
||||
// of 4,092 FLAC files: Kupla's Life Forms declares eight genres and was stored
|
||||
// as "Instrumental Hip Hop" alone (#2500). Nothing looked corrupt; the other
|
||||
// seven were simply never 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
|
||||
}
|
||||
Reference in New Issue
Block a user