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>
289 lines
9.1 KiB
Go
289 lines
9.1 KiB
Go
package library
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/dhowden/tag"
|
|
)
|
|
|
|
// vorbisComment builds a comment block: vendor, count, then each "KEY=value".
|
|
func vorbisComment(fields ...string) []byte {
|
|
var b bytes.Buffer
|
|
vendor := "minstrel-test"
|
|
_ = binary.Write(&b, binary.LittleEndian, uint32(len(vendor)))
|
|
b.WriteString(vendor)
|
|
_ = binary.Write(&b, binary.LittleEndian, uint32(len(fields)))
|
|
for _, f := range fields {
|
|
_ = binary.Write(&b, binary.LittleEndian, uint32(len(f)))
|
|
b.WriteString(f)
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
// buildFLAC assembles a STREAMINFO block and a comment block, optionally
|
|
// behind an ID3v2 tag the way some taggers write them.
|
|
func buildFLAC(withID3 bool, fields ...string) []byte {
|
|
var b bytes.Buffer
|
|
if withID3 {
|
|
pad := make([]byte, 20)
|
|
b.WriteString("ID3")
|
|
b.Write([]byte{4, 0, 0})
|
|
b.Write(synchsafeBytes(len(pad)))
|
|
b.Write(pad)
|
|
}
|
|
b.WriteString("fLaC")
|
|
b.Write([]byte{0x00, 0, 0, 34}) // STREAMINFO, not last
|
|
b.Write(make([]byte, 34))
|
|
vc := vorbisComment(fields...)
|
|
b.Write([]byte{0x84, byte(len(vc) >> 16), byte(len(vc) >> 8), byte(len(vc))}) // last, type 4
|
|
b.Write(vc)
|
|
return b.Bytes()
|
|
}
|
|
|
|
// oggCRC is the Ogg page checksum: CRC-32, polynomial 0x04c11db7, no
|
|
// reflection, zero initial value. dhowden/tag verifies it, so the end-to-end
|
|
// tests need real pages.
|
|
func oggCRC(p []byte) uint32 {
|
|
var crc uint32
|
|
for _, c := range p {
|
|
crc ^= uint32(c) << 24
|
|
for i := 0; i < 8; i++ {
|
|
if crc&0x80000000 != 0 {
|
|
crc = crc<<1 ^ 0x04c11db7
|
|
} else {
|
|
crc <<= 1
|
|
}
|
|
}
|
|
}
|
|
return crc
|
|
}
|
|
|
|
// buildOgg lays packets out as Ogg pages for one logical stream, splitting a
|
|
// packet across pages when it needs more than 255 lacing values.
|
|
func buildOgg(serial uint32, packets ...[]byte) []byte {
|
|
var out bytes.Buffer
|
|
var seq uint32
|
|
for _, p := range packets {
|
|
var lacing []byte
|
|
for n := len(p); ; n -= 255 {
|
|
if n >= 255 {
|
|
lacing = append(lacing, 255)
|
|
continue
|
|
}
|
|
lacing = append(lacing, byte(n))
|
|
break
|
|
}
|
|
continued := false
|
|
for off := 0; len(lacing) > 0; {
|
|
segs := lacing
|
|
if len(segs) > 255 {
|
|
segs = segs[:255]
|
|
}
|
|
lacing = lacing[len(segs):]
|
|
n := 0
|
|
for _, s := range segs {
|
|
n += int(s)
|
|
}
|
|
var page bytes.Buffer
|
|
page.WriteString("OggS")
|
|
page.WriteByte(0)
|
|
flags := byte(0)
|
|
if continued {
|
|
flags |= 0x01
|
|
}
|
|
if seq == 0 {
|
|
flags |= 0x02
|
|
}
|
|
page.WriteByte(flags)
|
|
page.Write(make([]byte, 8)) // granule position
|
|
_ = binary.Write(&page, binary.LittleEndian, serial)
|
|
_ = binary.Write(&page, binary.LittleEndian, seq)
|
|
page.Write(make([]byte, 4)) // checksum, filled below
|
|
page.WriteByte(byte(len(segs)))
|
|
page.Write(segs)
|
|
page.Write(p[off : off+n])
|
|
raw := page.Bytes()
|
|
binary.LittleEndian.PutUint32(raw[22:26], oggCRC(raw))
|
|
out.Write(raw)
|
|
off += n
|
|
seq++
|
|
continued = true
|
|
}
|
|
}
|
|
return out.Bytes()
|
|
}
|
|
|
|
func vorbisIdent() []byte { return append([]byte("\x01vorbis"), make([]byte, 23)...) }
|
|
|
|
// mp4Box wraps children in an atom of the given kind.
|
|
func mp4Box(kind string, children ...[]byte) []byte {
|
|
body := bytes.Join(children, nil)
|
|
b := make([]byte, 8, 8+len(body))
|
|
binary.BigEndian.PutUint32(b, uint32(8+len(body)))
|
|
copy(b[4:], kind)
|
|
return append(b, body...)
|
|
}
|
|
|
|
func mp4Text(value string) []byte {
|
|
return mp4Box("data", []byte{0, 0, 0, 1, 0, 0, 0, 0}, []byte(value))
|
|
}
|
|
|
|
// buildMP4 places the ©gen atoms under moov/udta/meta/ilst. isoMeta adds the
|
|
// four version/flags bytes ISO files carry before meta's children.
|
|
func buildMP4(isoMeta bool, genAtoms ...[]byte) []byte {
|
|
ilst := mp4Box("ilst", append([][]byte{mp4Box("\xa9nam", mp4Text("A Song"))}, genAtoms...)...)
|
|
hdlr := mp4Box("hdlr", make([]byte, 25))
|
|
var meta []byte
|
|
if isoMeta {
|
|
meta = mp4Box("meta", []byte{0, 0, 0, 0}, hdlr, ilst)
|
|
} else {
|
|
meta = mp4Box("meta", hdlr, ilst)
|
|
}
|
|
ftyp := mp4Box("ftyp", []byte("M4A \x00\x00\x00\x00M4A mp42isom"))
|
|
mdat := mp4Box("mdat", make([]byte, 64))
|
|
// moov after mdat, as many encoders write it.
|
|
return bytes.Join([][]byte{ftyp, mdat, mp4Box("moov", mp4Box("udta", meta))}, nil)
|
|
}
|
|
|
|
var lifeForms = []string{
|
|
"Boom Bap", "Downtempo", "Hip Hop", "Instrumental",
|
|
"Lo-Fi", "Lo-Fi Hip Hop", "Chillwave", "Instrumental Hip Hop",
|
|
}
|
|
|
|
func genreFields(values ...string) []string {
|
|
out := make([]string, len(values))
|
|
for i, v := range values {
|
|
out[i] = "GENRE=" + v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Shaped like a real multi-genre FLAC:
|
|
// eight GENRE fields, of which dhowden/tag keeps the last.
|
|
func TestExtractGenres_FLACRepeatedFields(t *testing.T) {
|
|
data := buildFLAC(false, append([]string{"TITLE=Eons"}, genreFields(lifeForms...)...)...)
|
|
rs := bytes.NewReader(data)
|
|
meta, err := tag.ReadFrom(rs)
|
|
if err != nil {
|
|
t.Fatalf("tag.ReadFrom: %v", err)
|
|
}
|
|
// Confirm the upstream behaviour this fix exists for is still present.
|
|
if got := meta.Genre(); got != "Instrumental Hip Hop" {
|
|
t.Logf("note: dhowden/tag no longer keeps only the last GENRE (got %q)", got)
|
|
}
|
|
genres, fellBack := extractGenres(meta, rs)
|
|
if fellBack {
|
|
t.Error("fellBack = true, want false")
|
|
}
|
|
if !equalStrings(genres, lifeForms) {
|
|
t.Errorf("genres = %q, want %q", genres, lifeForms)
|
|
}
|
|
}
|
|
|
|
func TestReadFLACGenreValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data []byte
|
|
want []string
|
|
err bool
|
|
}{
|
|
{"field names are case-insensitive", buildFLAC(false, "genre=Shoegaze", "Genre=Dream Pop"), []string{"Shoegaze", "Dream Pop"}, false},
|
|
{"behind an ID3v2 tag", buildFLAC(true, "GENRE=Jazz", "GENRE=Bop"), []string{"Jazz", "Bop"}, false},
|
|
{"no genre is not an error", buildFLAC(false, "TITLE=x"), nil, false},
|
|
{"empty values dropped", buildFLAC(false, "GENRE= ", "GENRE=Rock"), []string{"Rock"}, false},
|
|
{"a key that only starts with GENRE", buildFLAC(false, "GENRES=Rock", "GENRE=Jazz"), []string{"Jazz"}, false},
|
|
{"not a FLAC", []byte("RIFF....WAVEfmt "), nil, true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := readFLACGenreValues(bytes.NewReader(tc.data))
|
|
if (err != nil) != tc.err {
|
|
t.Fatalf("err = %v, want error: %v", err, tc.err)
|
|
}
|
|
if !equalStrings(got, tc.want) {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadFLACGenreValues_TruncatedCommentIsAnError(t *testing.T) {
|
|
data := buildFLAC(false, "GENRE=Jazz", "GENRE=Bop")
|
|
if _, err := readFLACGenreValues(bytes.NewReader(data[:len(data)-2])); err == nil {
|
|
t.Error("err = nil for a cut-off comment block")
|
|
}
|
|
}
|
|
|
|
func TestExtractGenres_OggVorbisRepeatedFields(t *testing.T) {
|
|
comment := append([]byte("\x03vorbis"), vorbisComment(genreFields("Shoegaze", "Dream Pop")...)...)
|
|
comment = append(comment, 1) // framing bit
|
|
rs := bytes.NewReader(buildOgg(7, vorbisIdent(), comment))
|
|
meta, err := tag.ReadFrom(rs)
|
|
if err != nil {
|
|
t.Fatalf("tag.ReadFrom: %v", err)
|
|
}
|
|
genres, fellBack := extractGenres(meta, rs)
|
|
if fellBack || !equalStrings(genres, []string{"Shoegaze", "Dream Pop"}) {
|
|
t.Errorf("genres = %q (fellBack %v), want [Shoegaze Dream Pop]", genres, fellBack)
|
|
}
|
|
}
|
|
|
|
func TestReadOggGenreValues_Opus(t *testing.T) {
|
|
head := append([]byte("OpusHead"), make([]byte, 11)...)
|
|
tags := append([]byte("OpusTags"), vorbisComment(genreFields("Ambient", "Drone")...)...)
|
|
got, err := readOggGenreValues(bytes.NewReader(buildOgg(3, head, tags)))
|
|
if err != nil || !equalStrings(got, []string{"Ambient", "Drone"}) {
|
|
t.Errorf("got %q, %v; want [Ambient Drone]", got, err)
|
|
}
|
|
}
|
|
|
|
// Cover art makes the comment packet span pages. A reader that stopped at the
|
|
// first page boundary would read a truncated block.
|
|
func TestReadOggGenreValues_PacketSpansPages(t *testing.T) {
|
|
fields := append([]string{"METADATA_BLOCK_PICTURE=" + strings.Repeat("A", 70000)}, genreFields("Jazz", "Bop")...)
|
|
comment := append([]byte("\x03vorbis"), vorbisComment(fields...)...)
|
|
got, err := readOggGenreValues(bytes.NewReader(buildOgg(9, vorbisIdent(), comment)))
|
|
if err != nil || !equalStrings(got, []string{"Jazz", "Bop"}) {
|
|
t.Errorf("got %q, %v; want [Jazz Bop]", got, err)
|
|
}
|
|
}
|
|
|
|
func TestReadMP4GenreValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data []byte
|
|
want []string
|
|
}{
|
|
{"several data atoms in one ©gen", buildMP4(true, mp4Box("\xa9gen", mp4Text("Hip Hop"), mp4Text("Lo-Fi"))), []string{"Hip Hop", "Lo-Fi"}},
|
|
{"repeated ©gen atoms", buildMP4(true, mp4Box("\xa9gen", mp4Text("Hip Hop")), mp4Box("\xa9gen", mp4Text("Lo-Fi"))), []string{"Hip Hop", "Lo-Fi"}},
|
|
{"QuickTime meta without version bytes", buildMP4(false, mp4Box("\xa9gen", mp4Text("Jazz"), mp4Text("Bop"))), []string{"Jazz", "Bop"}},
|
|
{"no ©gen is not an error", buildMP4(true), nil},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := readMP4GenreValues(bytes.NewReader(tc.data))
|
|
if err != nil {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if !equalStrings(got, tc.want) {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractGenres_MP4MultiValue(t *testing.T) {
|
|
rs := bytes.NewReader(buildMP4(true, mp4Box("\xa9gen", mp4Text("Hip Hop"), mp4Text("Lo-Fi"))))
|
|
meta, err := tag.ReadFrom(rs)
|
|
if err != nil {
|
|
t.Fatalf("tag.ReadFrom: %v", err)
|
|
}
|
|
genres, fellBack := extractGenres(meta, rs)
|
|
if fellBack || !equalStrings(genres, []string{"Hip Hop", "Lo-Fi"}) {
|
|
t.Errorf("genres = %q (fellBack %v), want [Hip Hop Lo-Fi]", genres, fellBack)
|
|
}
|
|
}
|