Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support CHAP frames #62

Merged
merged 24 commits into from Nov 23, 2021
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
143 changes: 143 additions & 0 deletions chapter_frame.go
@@ -0,0 +1,143 @@
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)
// nanoseconds => milliseconds
n10v marked this conversation as resolved.
Show resolved Hide resolved
binary.Write(bw, binary.BigEndian, int32(cf.StartTime/nanosInMillis))
n10v marked this conversation as resolved.
Show resolved Hide resolved
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)
n10v marked this conversation as resolved.
Show resolved Hide resolved
var synchSafe bool
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)
if version == 4 {
synchSafe = true
} else {
synchSafe = false
}
n10v marked this conversation as resolved.
Show resolved Hide resolved
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)
}
n10v marked this conversation as resolved.
Show resolved Hide resolved
}

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
}
187 changes: 187 additions & 0 deletions chapter_frame_test.go
@@ -0,0 +1,187 @@
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
wantElementId string
n10v marked this conversation as resolved.
Show resolved Hide resolved
wantTitle string
wantDescription string
}{
{
name: "element id only",
fields: fields{
ElementID: "chap0",
StartTime: 0,
EndTime: time.Duration(1000 * nanosInMillis),
StartOffset: 0,
EndOffset: 0,
},
wantElementId: "chap0",
wantTitle: "",
wantDescription: "",
},
{
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",
},
},
wantElementId: "chap0",
wantTitle: "chapter 0",
wantDescription: "",
},
{
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",
},
},
wantElementId: "chap0",
wantTitle: "",
wantDescription: "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",
},
},
wantElementId: "chap0",
wantTitle: "chapter 0 title",
wantDescription: "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,
},
wantElementId: "chap0",
wantTitle: "",
wantDescription: "",
},
}
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.wantElementId {
t.Errorf("expected: %s, but got %s", tt.wantElementId, frame.ElementID)
n10v marked this conversation as resolved.
Show resolved Hide resolved
}
if frame.Title.Text != tt.wantTitle {
n10v marked this conversation as resolved.
Show resolved Hide resolved
t.Errorf("expected: %s, but got %s", tt.wantTitle, frame.Title)
takaishi marked this conversation as resolved.
Show resolved Hide resolved
}
if frame.Description.Text != tt.wantDescription {
n10v marked this conversation as resolved.
Show resolved Hide resolved
t.Errorf("expected: %s, but got %s", tt.wantDescription, frame.Description.Text)
takaishi marked this conversation as resolved.
Show resolved Hide resolved
}
if frame.StartTime != tt.fields.StartTime {
t.Errorf("expected: %s, but got %s", tt.fields.StartTime, frame.StartTime)
takaishi marked this conversation as resolved.
Show resolved Hide resolved
}
if frame.EndTime != tt.fields.EndTime {
t.Errorf("expected: %s, but got %s", tt.fields.EndTime, frame.EndTime)
takaishi marked this conversation as resolved.
Show resolved Hide resolved
}
if frame.StartOffset != tt.fields.StartOffset {
t.Errorf("expected: %d, but got %d", tt.fields.StartOffset, frame.StartOffset)
takaishi marked this conversation as resolved.
Show resolved Hide resolved
}
if frame.EndOffset != tt.fields.EndOffset {
t.Errorf("expected: %d, but got %d", tt.fields.EndOffset, frame.EndOffset)
takaishi marked this conversation as resolved.
Show resolved Hide resolved
}
})
}
}
n10v marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 1 addition & 1 deletion comment_frame.go
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
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
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