diff --git a/chapter_frame.go b/chapter_frame.go new file mode 100644 index 0000000..801e584 --- /dev/null +++ b/chapter_frame.go @@ -0,0 +1,138 @@ +package id3v2 + +import ( + "encoding/binary" + "io" + "time" +) + +const ( + nanosInMillis = 1000000 + IgnoredOffset = 0xFFFFFFFF +) + +// ChapterFrame is used to work with CHAP frames +// according to spec from http://id3.org/id3v2-chapters-1.0 +// This implementation only supports single TIT2 subframe (Title field). +// All other subframes are ignored. +// If StartOffset or EndOffset == id3v2.IgnoredOffset, then it should be ignored +// and StartTime or EndTime should be utilized +type ChapterFrame struct { + ElementID string + StartTime time.Duration + EndTime time.Duration + StartOffset uint32 + EndOffset uint32 + Title *TextFrame + Description *TextFrame +} + +func (cf ChapterFrame) Size() int { + size := encodedSize(cf.ElementID, EncodingISO) + + 1 + // trailing zero after ElementID + 4 + 4 + 4 + 4 // (Start, End) (Time, Offset) + if cf.Title != nil { + size = size + + frameHeaderSize + // Title frame header size + cf.Title.Size() + } + if cf.Description != nil { + size = size + + frameHeaderSize + // Description frame header size + cf.Description.Size() + } + return size +} + +func (cf ChapterFrame) UniqueIdentifier() string { + return cf.ElementID +} + +func (cf ChapterFrame) WriteTo(w io.Writer) (n int64, err error) { + return useBufWriter(w, func(bw *bufWriter) { + bw.EncodeAndWriteText(cf.ElementID, EncodingISO) + bw.WriteByte(0) + binary.Write(bw, binary.BigEndian, int32(cf.StartTime/nanosInMillis)) + binary.Write(bw, binary.BigEndian, int32(cf.EndTime/nanosInMillis)) + + binary.Write(bw, binary.BigEndian, cf.StartOffset) + binary.Write(bw, binary.BigEndian, cf.EndOffset) + + if cf.Title != nil { + writeFrame(bw, "TIT2", *cf.Title, true) + } + + if cf.Description != nil { + writeFrame(bw, "TIT3", *cf.Description, true) + } + }) +} + +func parseChapterFrame(br *bufReader, version byte) (Framer, error) { + elementID := br.ReadText(EncodingISO) + synchSafe := version == 4 + var startTime uint32 + var startOffset uint32 + var endTime uint32 + var endOffset uint32 + + if err := binary.Read(br, binary.BigEndian, &startTime); err != nil { + return nil, err + } + if err := binary.Read(br, binary.BigEndian, &endTime); err != nil { + return nil, err + } + if err := binary.Read(br, binary.BigEndian, &startOffset); err != nil { + return nil, err + } + if err := binary.Read(br, binary.BigEndian, &endOffset); err != nil { + return nil, err + } + + var title TextFrame + 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 + } + id, bodySize := header.ID, header.BodySize + if id == "TIT2" || id == "TIT3" { + bodyRd := getLimitedReader(br, bodySize) + br := newBufReader(bodyRd) + frame, err := parseTextFrame(br) + if err != nil { + putLimitedReader(bodyRd) + return nil, err + } + if id == "TIT2" { + title = frame.(TextFrame) + } else if id == "TIT3" { + description = frame.(TextFrame) + } + + putLimitedReader(bodyRd) + } + } + + cf := ChapterFrame{ + ElementID: string(elementID), + // StartTime is given in milliseconds, so we should convert it to nanoseconds + // for time.Duration + StartTime: time.Duration(int64(startTime) * nanosInMillis), + EndTime: time.Duration(int64(endTime) * nanosInMillis), + StartOffset: startOffset, + EndOffset: endOffset, + Title: &title, + Description: &description, + } + return cf, nil +} diff --git a/chapter_frame_test.go b/chapter_frame_test.go new file mode 100644 index 0000000..8068b38 --- /dev/null +++ b/chapter_frame_test.go @@ -0,0 +1,169 @@ +package id3v2 + +import ( + "io" + "io/ioutil" + "log" + "os" + "testing" + "time" +) + +func prepareTestFile() (*os.File, error) { + src, err := os.Open("./testdata/test.mp3") + if err != nil { + return nil, err + } + defer src.Close() + + tmpFile, err := ioutil.TempFile("", "chapter_test") + if err != nil { + return nil, err + } + + _, err = io.Copy(tmpFile, src) + if err != nil { + return nil, err + } + return tmpFile, nil +} + +func TestAddChapterFrame(t *testing.T) { + type fields struct { + ElementID string + StartTime time.Duration + EndTime time.Duration + StartOffset uint32 + EndOffset uint32 + Title *TextFrame + Description *TextFrame + } + tests := []struct { + name string + fields fields + }{ + { + name: "element id only", + fields: fields{ + ElementID: "chap0", + StartTime: 0, + EndTime: time.Duration(1000 * nanosInMillis), + StartOffset: 0, + EndOffset: 0, + }, + }, + { + name: "with title", + fields: fields{ + ElementID: "chap0", + StartTime: 0, + EndTime: time.Duration(1000 * nanosInMillis), + StartOffset: 0, + EndOffset: 0, + Title: &TextFrame{ + Encoding: EncodingUTF8, + Text: "chapter 0", + }, + }, + }, + { + name: "with description", + fields: fields{ + ElementID: "chap0", + StartTime: 0, + EndTime: time.Duration(1000 * nanosInMillis), + StartOffset: 0, + EndOffset: 0, + Description: &TextFrame{ + Encoding: EncodingUTF8, + Text: "chapter 0", + }, + }, + }, + { + name: "with title and description", + fields: fields{ + ElementID: "chap0", + StartTime: 0, + EndTime: time.Duration(1000 * nanosInMillis), + StartOffset: 0, + EndOffset: 0, + Title: &TextFrame{ + Encoding: EncodingUTF8, + Text: "chapter 0 title", + }, + Description: &TextFrame{ + Encoding: EncodingUTF8, + Text: "chapter 0 description", + }, + }, + }, + { + name: "non-zero time and offset", + fields: fields{ + ElementID: "chap0", + StartTime: time.Duration(1000 * nanosInMillis), + EndTime: time.Duration(1000 * nanosInMillis), + StartOffset: 10, + EndOffset: 10, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpFile, err := prepareTestFile() + if err != nil { + t.Error(err) + } + defer os.Remove(tmpFile.Name()) + + tag, err := Open(tmpFile.Name(), Options{Parse: true}) + if tag == nil || err != nil { + log.Fatal("Error while opening mp3 file: ", err) + } + + cf := ChapterFrame{ + ElementID: tt.fields.ElementID, + StartTime: tt.fields.StartTime, + EndTime: tt.fields.EndTime, + StartOffset: tt.fields.StartOffset, + EndOffset: tt.fields.EndOffset, + Title: tt.fields.Title, + Description: tt.fields.Description, + } + tag.AddChapterFrame(cf) + + if err := tag.Save(); err != nil { + t.Error(err) + } + tag.Close() + + tag, err = Open(tmpFile.Name(), Options{Parse: true}) + if tag == nil || err != nil { + log.Fatal("Error while opening mp3 file: ", err) + } + frame := tag.GetLastFrame("CHAP").(ChapterFrame) + if frame.ElementID != tt.fields.ElementID { + t.Errorf("Expected element ID: %s, but got %s", tt.fields.ElementID, frame.ElementID) + } + if tt.fields.Title != nil && frame.Title.Text != tt.fields.Title.Text { + t.Errorf("Expected title: %s, but got %s", tt.fields.Title.Text, frame.Title) + } + if tt.fields.Description != nil && frame.Description.Text != tt.fields.Description.Text { + t.Errorf("Expected description: %s, but got %s", tt.fields.Description.Text, frame.Description.Text) + } + if frame.StartTime != tt.fields.StartTime { + t.Errorf("Expected start time: %s, but got %s", tt.fields.StartTime, frame.StartTime) + } + if frame.EndTime != tt.fields.EndTime { + t.Errorf("Expected end time: %s, but got %s", tt.fields.EndTime, frame.EndTime) + } + if frame.StartOffset != tt.fields.StartOffset { + t.Errorf("Expected start offset: %d, but got %d", tt.fields.StartOffset, frame.StartOffset) + } + if frame.EndOffset != tt.fields.EndOffset { + t.Errorf("Expected end offset: %d, but got %d", tt.fields.EndOffset, frame.EndOffset) + } + }) + } +} diff --git a/comment_frame.go b/comment_frame.go index f78c1c2..2e399ad 100644 --- a/comment_frame.go +++ b/comment_frame.go @@ -42,7 +42,7 @@ func (cf CommentFrame) WriteTo(w io.Writer) (n int64, err error) { }) } -func parseCommentFrame(br *bufReader) (Framer, error) { +func parseCommentFrame(br *bufReader, version byte) (Framer, error) { encoding := getEncoding(br.ReadByte()) language := br.Next(3) description := br.ReadText(encoding) diff --git a/common_ids.go b/common_ids.go index 33f6922..aeac14d 100644 --- a/common_ids.go +++ b/common_ids.go @@ -10,6 +10,7 @@ import "strings" var ( V23CommonIDs = map[string]string{ "Attached picture": "APIC", + "Chapters": "CHAP", "Comments": "COMM", "Album/Movie/Show title": "TALB", "BPM": "TBPM", @@ -62,6 +63,7 @@ var ( V24CommonIDs = map[string]string{ "Attached picture": "APIC", + "Chapters": "CHAP", "Comments": "COMM", "Album/Movie/Show title": "TALB", "BPM": "TBPM", @@ -135,8 +137,9 @@ var ( // if strings.HasPrefix(id, "T") { // ... // } -var parsers = map[string]func(*bufReader) (Framer, error){ +var parsers = map[string]func(*bufReader, byte) (Framer, error){ "APIC": parsePictureFrame, + "CHAP": parseChapterFrame, "COMM": parseCommentFrame, "POPM": parsePopularimeterFrame, "TXXX": parseUserDefinedTextFrame, diff --git a/encoding_test.go b/encoding_test.go index f6ab722..6954b8d 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -75,7 +75,7 @@ func TestUnsynchronisedLyricsFrameWithUTF16(t *testing.T) { t.Fatal(err) } - parsed, err := parseUnsynchronisedLyricsFrame(newBufReader(buf)) + parsed, err := parseUnsynchronisedLyricsFrame(newBufReader(buf), 4) if err != nil { t.Fatal(err) } diff --git a/parse.go b/parse.go index 45d240d..2344f24 100644 --- a/parse.go +++ b/parse.go @@ -98,7 +98,7 @@ func (tag *Tag) parseFrames(opts Options) error { } br.Reset(bodyRd) - frame, err := parseFrameBody(id, br) + frame, err := parseFrameBody(id, br, tag.version) if err != nil && err != io.EOF { return err } @@ -174,13 +174,13 @@ func skipReaderBuf(rd io.Reader, buf []byte) error { return nil } -func parseFrameBody(id string, br *bufReader) (Framer, error) { +func parseFrameBody(id string, br *bufReader, version byte) (Framer, error) { if id[0] == 'T' && id != "TXXX" { return parseTextFrame(br) } if parseFunc, exists := parsers[id]; exists { - return parseFunc(br) + return parseFunc(br, version) } return parseUnknownFrame(br) diff --git a/picture_frame.go b/picture_frame.go index 4c9059e..57b0a45 100644 --- a/picture_frame.go +++ b/picture_frame.go @@ -42,7 +42,7 @@ func (pf PictureFrame) WriteTo(w io.Writer) (n int64, err error) { }) } -func parsePictureFrame(br *bufReader) (Framer, error) { +func parsePictureFrame(br *bufReader, version byte) (Framer, error) { encoding := getEncoding(br.ReadByte()) mimeType := br.ReadText(EncodingISO) pictureType := br.ReadByte() diff --git a/popularimeter_frame.go b/popularimeter_frame.go index c61d1a7..ee67385 100644 --- a/popularimeter_frame.go +++ b/popularimeter_frame.go @@ -50,7 +50,7 @@ func (pf PopularimeterFrame) WriteTo(w io.Writer) (n int64, err error) { }) } -func parsePopularimeterFrame(br *bufReader) (Framer, error) { +func parsePopularimeterFrame(br *bufReader, version byte) (Framer, error) { email := br.ReadText(EncodingISO) rating := br.ReadByte() 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/ufid_frame.go b/ufid_frame.go index 9f83dc8..55530bb 100644 --- a/ufid_frame.go +++ b/ufid_frame.go @@ -24,7 +24,7 @@ func (ufid UFIDFrame) WriteTo(w io.Writer) (n int64, err error) { }) } -func parseUFIDFrame(br *bufReader) (Framer, error) { +func parseUFIDFrame(br *bufReader, version byte) (Framer, error) { owner := br.ReadText(EncodingISO) ident := br.ReadAll() diff --git a/unsynchronised_lyrics_frame.go b/unsynchronised_lyrics_frame.go index 09b86a0..ee48cb3 100644 --- a/unsynchronised_lyrics_frame.go +++ b/unsynchronised_lyrics_frame.go @@ -42,7 +42,7 @@ func (uslf UnsynchronisedLyricsFrame) WriteTo(w io.Writer) (n int64, err error) }) } -func parseUnsynchronisedLyricsFrame(br *bufReader) (Framer, error) { +func parseUnsynchronisedLyricsFrame(br *bufReader, version byte) (Framer, error) { encoding := getEncoding(br.ReadByte()) language := br.Next(3) contentDescriptor := br.ReadText(encoding) diff --git a/user_defined_text_frame.go b/user_defined_text_frame.go index f6cd917..a08ca6b 100644 --- a/user_defined_text_frame.go +++ b/user_defined_text_frame.go @@ -27,7 +27,7 @@ func (udtf UserDefinedTextFrame) WriteTo(w io.Writer) (n int64, err error) { }) } -func parseUserDefinedTextFrame(br *bufReader) (Framer, error) { +func parseUserDefinedTextFrame(br *bufReader, version byte) (Framer, error) { encoding := getEncoding(br.ReadByte()) description := br.ReadText(encoding)