Files
minstrel/internal/library/mp4genre.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

169 lines
4.9 KiB
Go

package library
import (
"encoding/binary"
"errors"
"io"
"strings"
)
// MP4 has the same gap as Vorbis comments. A tag writer stores several genres
// as several "data" atoms inside one ©gen atom (mutagen and Picard do), or
// occasionally as repeated ©gen atoms. dhowden/tag keeps atoms in a map keyed
// by name (mp4.go: m.data[name] = data), so one value survives either way.
//
// Path to the values: moov > udta > meta > ilst > ©gen > data. A file that
// carries only the numeric "gnre" atom has no ©gen; this reader returns (nil,
// nil) for it, and extractGenres falls back to dhowden/tag, which resolves gnre
// to a name.
// maxMP4GenreAtomSize caps how much of one ©gen atom is buffered. Genre text is
// bytes; the cap only stops a corrupt size from making the scanner allocate.
const maxMP4GenreAtomSize = 1 << 20
var errMalformedAtom = errors.New("library: malformed MP4 atom")
type mp4Atom struct {
kind string
body, limit int64 // body start and end offsets in the file
}
// readMP4Atom reads the atom header at pos, which must lie within [pos, limit).
func readMP4Atom(rs io.ReadSeeker, pos, limit int64) (mp4Atom, error) {
if _, err := rs.Seek(pos, io.SeekStart); err != nil {
return mp4Atom{}, err
}
var hdr [8]byte
if _, err := io.ReadFull(rs, hdr[:]); err != nil {
return mp4Atom{}, errMalformedAtom
}
size := int64(binary.BigEndian.Uint32(hdr[0:4]))
hdrLen := int64(8)
switch size {
case 0: // runs to the end of the enclosing atom
size = limit - pos
case 1: // 64-bit size follows the type
var large [8]byte
if _, err := io.ReadFull(rs, large[:]); err != nil {
return mp4Atom{}, errMalformedAtom
}
size = int64(binary.BigEndian.Uint64(large[:]))
hdrLen = 16
}
if size < hdrLen || size > limit-pos {
return mp4Atom{}, errMalformedAtom
}
return mp4Atom{kind: string(hdr[4:8]), body: pos + hdrLen, limit: pos + size}, nil
}
// findMP4Child returns the first child of the given kind in [start, end).
func findMP4Child(rs io.ReadSeeker, start, end int64, kind string) (mp4Atom, bool, error) {
for pos := start; pos+8 <= end; {
a, err := readMP4Atom(rs, pos, end)
if err != nil {
return mp4Atom{}, false, err
}
if a.kind == kind {
return a, true, nil
}
pos = a.limit
}
return mp4Atom{}, false, nil
}
// readMP4GenreValues returns every ©gen value, in file order. rs is seeked
// as needed, so it is safe to call after dhowden/tag has consumed the reader.
func readMP4GenreValues(rs io.ReadSeeker) ([]string, error) {
end, err := rs.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
// moov may sit after mdat; walking headers and seeking past bodies finds
// it either way without reading the audio.
cur := mp4Atom{body: 0, limit: end}
for _, kind := range []string{"moov", "udta", "meta"} {
next, found, err := findMP4Child(rs, cur.body, cur.limit, kind)
if err != nil {
return nil, errNoGenreFrame
}
if !found {
return nil, nil // no iTunes metadata at all
}
cur = next
}
// meta is a "full box" with four bytes of version and flags before its
// children in the ISO layout. QuickTime-style files omit them, so the
// first child (hdlr) starts straight away. Look before skipping.
if _, err := rs.Seek(cur.body, io.SeekStart); err != nil {
return nil, errNoGenreFrame
}
var peek [8]byte
if _, err := io.ReadFull(rs, peek[:]); err != nil {
return nil, errNoGenreFrame
}
if string(peek[4:8]) != "hdlr" {
cur.body += 4
}
ilst, found, err := findMP4Child(rs, cur.body, cur.limit, "ilst")
if err != nil {
return nil, errNoGenreFrame
}
if !found {
return nil, nil
}
var out []string
for pos := ilst.body; pos+8 <= ilst.limit; {
a, err := readMP4Atom(rs, pos, ilst.limit)
if err != nil {
return nil, errNoGenreFrame
}
pos = a.limit
if a.kind != "\xa9gen" {
continue
}
n := a.limit - a.body
if n > maxMP4GenreAtomSize {
return nil, errNoGenreFrame
}
if _, err := rs.Seek(a.body, io.SeekStart); err != nil {
return nil, errNoGenreFrame
}
body := make([]byte, n)
if _, err := io.ReadFull(rs, body); err != nil {
return nil, errNoGenreFrame
}
values, ok := mp4DataValues(body)
if !ok {
return nil, errNoGenreFrame
}
out = append(out, values...)
}
return out, nil
}
// mp4DataValues reads the "data" atoms in a ©gen body. Each is: size, "data",
// a 4-byte type indicator (1 = UTF-8 text), a 4-byte locale, then the value.
// Atoms of other types carry no text and are skipped.
func mp4DataValues(b []byte) ([]string, bool) {
var out []string
for len(b) >= 8 {
size := binary.BigEndian.Uint32(b[0:4])
if size < 8 || uint64(size) > uint64(len(b)) {
return nil, false
}
atom := b[:size]
b = b[size:]
if string(atom[4:8]) != "data" || len(atom) < 16 {
continue
}
if binary.BigEndian.Uint32(atom[8:12])&0x00FFFFFF != 1 {
continue
}
if v := strings.TrimSpace(string(atom[16:])); v != "" {
out = append(out, v)
}
}
return out, true
}