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>
169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
package library
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// MP4 has the same gap as Vorbis comments (#2500). 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
|
|
}
|