Skip to content

Commit

Permalink
support CHAP frames (#62)
Browse files Browse the repository at this point in the history
* add ChapterFrame type for CHAP frames

* fix to parse CHAP frame

* update logic

* support old golang

* use `Description` called in spec

* refactor: put together identical code

* fix: should write title and description

* refine test

- test with TIT2 and TIT3 pattern
- improve error message

* if tag version is 4, syncSafe is enable

* need test that has non-zero time and offset

* use `else if`

* add name

* make more simple

* better to use lowercase letter

* remove comment

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* Update chapter_frame_test.go

More clarity message

Co-authored-by: Albert Nigmatzianov <[email protected]>

* use tt.fields.* as StartTime or EndTime

Co-authored-by: cnt0 <[email protected]>
Co-authored-by: Albert Nigmatzianov <[email protected]>
  • Loading branch information
3 people authored Nov 23, 2021
1 parent ca3c642 commit 033cd25
Show file tree
Hide file tree
Showing 12 changed files with 326 additions and 11 deletions.
138 changes: 138 additions & 0 deletions chapter_frame.go
Original file line number Diff line number Diff line change
@@ -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
}
169 changes: 169 additions & 0 deletions chapter_frame_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion comment_frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion common_ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -62,6 +63,7 @@ var (

V24CommonIDs = map[string]string{
"Attached picture": "APIC",
"Chapters": "CHAP",
"Comments": "COMM",
"Album/Movie/Show title": "TALB",
"BPM": "TBPM",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion picture_frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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()
Expand Down
2 changes: 1 addition & 1 deletion popularimeter_frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading

0 comments on commit 033cd25

Please sign in to comment.