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>
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. 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
|
|
}
|