diff --git a/tag.go b/tag.go index d7c9750..d188f09 100644 --- a/tag.go +++ b/tag.go @@ -51,6 +51,11 @@ func (tag *Tag) AddAttachedPicture(pf PictureFrame) { tag.AddFrame(tag.CommonID("Attached picture"), pf) } +// AddChapterFrame adds the chapter frame to tag. +func (tag *Tag) AddChapterFrame(cf ChapterFrame) { + tag.AddFrame(tag.CommonID("Chapters"), cf) +} + // AddCommentFrame adds the comment frame to tag. func (tag *Tag) AddCommentFrame(cf CommentFrame) { tag.AddFrame(tag.CommonID("Comments"), cf) diff --git a/v2/chapter_toc_frame.go b/v2/chapter_toc_frame.go new file mode 100644 index 0000000..8ee2920 --- /dev/null +++ b/v2/chapter_toc_frame.go @@ -0,0 +1,141 @@ +package id3v2 + +import ( + "encoding/binary" + "errors" + "fmt" + "io" +) + +const ( + maskOrdered = byte(1 << 0) + maskToplevel = byte(1 << 1) +) + +var ErrUnexpectedId = errors.New("unexpected ID") + +type ChapterTocFrame struct { + ElementID string + // This frame is the root of the Table of Contents tree and is not a child of any other "CTOC" frame. + TopLevel bool + // This provides a hint as to whether the elements should be played as a continuous ordered sequence or played individually. + Ordered bool + ChapterIds []string + Description *TextFrame +} + +func (ctf ChapterTocFrame) Size() int { + size := encodedSize(ctf.ElementID, EncodingISO) + size += 1 // trailing zero after ElementID + size += 1 // CTOC Flags + // The Entry count is the number of entries in the Child Element ID + // list that follows and must be greater than zero. + size += 1 // Entrycount + + // entries + for _, id := range ctf.ChapterIds { + size += encodedSize(id, EncodingISO) + size += 1 // trailing zero after ID + } + + // (optional) descriptive data + if ctf.Description != nil { + size += frameHeaderSize // Description frame header size + size += ctf.Description.Size() + } + + return size +} + +func (ctf ChapterTocFrame) UniqueIdentifier() string { + return ctf.ElementID +} + +func (ctf ChapterTocFrame) WriteTo(w io.Writer) (n int64, err error) { + return useBufWriter(w, func(bw *bufWriter) { + bw.EncodeAndWriteText(ctf.ElementID, EncodingISO) + bw.WriteByte(0) + + ctocFlags := byte(0) + if ctf.TopLevel { + ctocFlags |= maskToplevel + } + if ctf.Ordered { + ctocFlags |= maskOrdered + } + + binary.Write(bw, binary.BigEndian, ctocFlags) + + binary.Write(bw, binary.BigEndian, uint8(len(ctf.ChapterIds))) + + for _, id := range ctf.ChapterIds { + bw.EncodeAndWriteText(id, EncodingISO) + bw.WriteByte(0) + } + + if ctf.Description != nil { + writeFrame(bw, "TIT2", *ctf.Description, true) + } + }) +} + +func parseChapterTocFrame(br *bufReader, version byte) (Framer, error) { + elementID := string(br.ReadText(EncodingISO)) + synchSafe := version == 4 + var ctocFlags byte + if err := binary.Read(br, binary.BigEndian, &ctocFlags); err != nil { + return nil, err + } + + var elements uint8 + if err := binary.Read(br, binary.BigEndian, &elements); err != nil { + return nil, err + } + + chaptersIDs := make([]string, elements) + for i := uint8(0); i < elements; i++ { + chaptersIDs[i] = string(br.ReadText(EncodingISO)) + } + + var description TextFrame + + // borrowed from parse.go + buf := getByteSlice(32 * 1024) + defer putByteSlice(buf) + + for { + header, err := parseFrameHeader(buf, br, synchSafe) + if err == io.EOF || err == errBlankFrame || err == ErrInvalidSizeFormat { + break + } + + if err != nil { + return nil, err + } + + if header.ID != "TIT2" { + return nil, fmt.Errorf("expected: '%s', got: '%s' : %w", "TIT2", header.ID, ErrUnexpectedId) + } + + bodyRd := getLimitedReader(br, header.BodySize) + br := newBufReader(bodyRd) + frame, err := parseTextFrame(br) + if err != nil { + putLimitedReader(bodyRd) + return nil, err + } + description = frame.(TextFrame) + + putLimitedReader(bodyRd) + } + + tocFrame := ChapterTocFrame{ + ElementID: elementID, + TopLevel: (ctocFlags & maskToplevel) == maskToplevel, + Ordered: (ctocFlags & maskOrdered) == maskOrdered, + ChapterIds: chaptersIDs, + Description: &description, + } + + return tocFrame, nil +} diff --git a/v2/chapter_toc_frame_test.go b/v2/chapter_toc_frame_test.go new file mode 100644 index 0000000..d17da28 --- /dev/null +++ b/v2/chapter_toc_frame_test.go @@ -0,0 +1,116 @@ +package id3v2 + +import ( + "bytes" + "fmt" + "log" + "testing" + "time" +) + +const ( + testChapterTocSampleTitle = "Chapter TOC title" +) + +func newChapterFrames(noOfChapters int) []ChapterFrame { + var start time.Duration + offset := time.Duration(1000 * nanosInMillis) + + chapters := make([]ChapterFrame, noOfChapters) + + for i := 0; i < noOfChapters; i++ { + end := start + offset + + chapters[i] = ChapterFrame{ + ElementID: fmt.Sprintf("ch%d", i), + StartTime: start, + EndTime: end, + StartOffset: IgnoredOffset, + EndOffset: IgnoredOffset, + Title: &TextFrame{ + Encoding: EncodingUTF8, + Text: fmt.Sprintf("Chapter %d", i), + }, + } + + start = end + } + + return chapters +} + +func TestAddChapterTocFrame(t *testing.T) { + const noOfChapters = 5 + buf := &bytes.Buffer{} + tag := NewEmptyTag() + + chapters := newChapterFrames(noOfChapters) + + chapterIds := make([]string, len(chapters)) + for i, c := range chapters { + tag.AddChapterFrame(c) + + chapterIds[i] = c.ElementID + } + + chapterToc := ChapterTocFrame{ + ElementID: "Main TOC", + TopLevel: true, + Ordered: true, + ChapterIds: chapterIds, + Description: &TextFrame{ + Encoding: EncodingUTF8, + Text: testChapterTocSampleTitle, + }, + } + + tag.AddChapterTocFrame(chapterToc) + tag.WriteTo(buf) + + // Read back + + tagBack, err := ParseReader(buf, Options{Parse: true}) + if err != nil { + log.Fatal("Error parsing mp3 content: ", err) + } + + if !tagBack.HasFrames() { + log.Fatal("No tags in content in mp3 content") + } + + chapterTocBackFrame := tag.GetLastFrame("CTOC") + if chapterTocBackFrame == nil { + log.Fatal("Error getting chapter TOC frame: ", err) + } + + chapterTocBack, ok := chapterTocBackFrame.(ChapterTocFrame) + if !ok { + log.Fatal("Error casting chapter TOC frame") + } + + if chapterToc.ElementID != chapterTocBack.ElementID { + t.Errorf("Expected element ID: %s, but got %s", chapterToc.ElementID, chapterTocBack.ElementID) + } + + if chapterToc.TopLevel != chapterTocBack.TopLevel { + t.Errorf("Expected top level: %v, but got %v", chapterToc.TopLevel, chapterTocBack.TopLevel) + } + + if chapterToc.Ordered != chapterTocBack.Ordered { + t.Errorf("Expected ordered: %v, but got %v", chapterToc.Ordered, chapterTocBack.Ordered) + } + + if expected, actual := len(chapterToc.ChapterIds), len(chapterTocBack.ChapterIds); expected != actual { + t.Errorf("Expected ordered: %v, but got %v", expected, actual) + } + + for i := 0; i < len(chapterToc.ChapterIds); i++ { + if expected, actual := chapterToc.ChapterIds[i], chapterTocBack.ChapterIds[i]; expected != actual { + t.Errorf("Expected chapter reference at index: %d: %s, but got %s", i, expected, actual) + } + } + + if chapterToc.Description != nil && chapterToc.Description.Text != chapterTocBack.Description.Text { + t.Errorf("Expected description: %s, but got %s", chapterToc.Description.Text, chapterTocBack.Description.Text) + } +} diff --git a/v2/common_ids.go b/v2/common_ids.go index aeac14d..30e35c1 100644 --- a/v2/common_ids.go +++ b/v2/common_ids.go @@ -11,6 +11,7 @@ var ( V23CommonIDs = map[string]string{ "Attached picture": "APIC", "Chapters": "CHAP", + "Chapters TOC": "CTOC", "Comments": "COMM", "Album/Movie/Show title": "TALB", "BPM": "TBPM", @@ -64,6 +65,7 @@ var ( V24CommonIDs = map[string]string{ "Attached picture": "APIC", "Chapters": "CHAP", + "Chapters TOC": "CTOC", "Comments": "COMM", "Album/Movie/Show title": "TALB", "BPM": "TBPM", @@ -140,6 +142,7 @@ var ( var parsers = map[string]func(*bufReader, byte) (Framer, error){ "APIC": parsePictureFrame, "CHAP": parseChapterFrame, + "CTOC": parseChapterTocFrame, "COMM": parseCommentFrame, "POPM": parsePopularimeterFrame, "TXXX": parseUserDefinedTextFrame, diff --git a/v2/tag.go b/v2/tag.go index d188f09..ed6042c 100644 --- a/v2/tag.go +++ b/v2/tag.go @@ -56,6 +56,10 @@ func (tag *Tag) AddChapterFrame(cf ChapterFrame) { tag.AddFrame(tag.CommonID("Chapters"), cf) } +func (tag *Tag) AddChapterTocFrame(ctf ChapterTocFrame) { + tag.AddFrame(tag.CommonID("Chapters TOC"), ctf) +} + // AddCommentFrame adds the comment frame to tag. func (tag *Tag) AddCommentFrame(cf CommentFrame) { tag.AddFrame(tag.CommonID("Comments"), cf)