~ft/cue

48a5e773bbae58c774e5394a67bf1b65980b5e21 — Sigrid Haflínudóttir 2 years ago
copy
10 files changed, 1159 insertions(+), 0 deletions(-)

A LICENSE
A README
A cue.go
A cue_test.go
A parser.go
A parser_test.go
A sheet.go
A test.cue
A utils.go
A utils_test.go
A  => LICENSE +1 -0
@@ 1,1 @@
GPLv3, http://www.gnu.org/licenses/

A  => README +3 -0
@@ 1,3 @@
This is part of chub/cue with a few changes.

The original is at https://github.com/vchimishuk/chub

A  => cue.go +500 -0
@@ 1,500 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

// Package cue implement CUE-SHEET files parser.
// For CUE documentation see: http://digitalx.org/cue-sheet/syntax/
//
// TODO: Create parser specific Error (with line number and others).
package cue

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"log"
	"os"
	"regexp"
	"strconv"
	"strings"
)

// commandParser is the function for parsing one command.
type commandParser func(params []string, sheet *Sheet) error

// commandParserDesctiptor describes command parser.
type commandParserDescriptor struct {
	// -1 -- zero or more parameters.
	paramsCount int
	parser      commandParser
}

// parsersMap used for commands and parser functions correspondence.
var parsersMap = map[string]commandParserDescriptor{
	"CATALOG":    {1, parseCatalog},
	"CDTEXTFILE": {1, parseCdTextFile},
	"FILE":       {2, parseFile},
	"FLAGS":      {-1, parseFlags},
	"INDEX":      {2, parseIndex},
	"ISRC":       {1, parseIsrc},
	"PERFORMER":  {1, parsePerformer},
	"POSTGAP":    {1, parsePostgap},
	"PREGAP":     {1, parsePregap},
	"REM":        {-1, parseRem},
	"SONGWRITER": {1, parseSongWriter},
	"TITLE":      {1, parseTitle},
	"TRACK":      {2, parseTrack},
}

const (
	// FlagTruncateStrings enables 80-char string truncation to conform the specification.
	FlagTruncateStrings = 1 << iota
)

// ParseFile parses cue-sheet tile.
func ParseFile(filename string, flags int) (sheet *Sheet, err error) {
	file, err := os.Open(filename)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	return Parse(file, flags)
}

// Parse parses cue-sheet data from reader and returns filled Sheet struct.
func Parse(reader io.Reader, flags int) (sheet *Sheet, err error) {
	sheet = &Sheet{flags: flags}

	rd := bufio.NewReader(reader)
	lineNumber := 1

	for buf, _, err := rd.ReadLine(); err != io.EOF; buf, _, err = rd.ReadLine() {
		if err != nil {
			return nil, err
		}

		line := strings.TrimSpace(string(buf))

		// Skip empty lines.
		if len(line) == 0 {
			continue
		}

		cmd, params, err := parseCommand(line)
		if err != nil {
			return nil, fmt.Errorf("Line %d. %s", lineNumber, err)
		}

		parserDescriptor, ok := parsersMap[cmd]
		if !ok {
			return nil, fmt.Errorf("Line %d. Unknown command '%s'.", lineNumber, cmd)
		}

		paramsExpected := parserDescriptor.paramsCount
		paramsRecieved := len(params)
		if paramsExpected != -1 && paramsExpected != paramsRecieved {
			return nil, fmt.Errorf("Line %d. Command %s expected %d parameters but %d received.",
				lineNumber, cmd, paramsExpected, paramsRecieved)
		}

		err = parserDescriptor.parser(params, sheet)
		if err != nil {
			return nil, fmt.Errorf("Line %d. %s", lineNumber, err)
		}

		lineNumber++
	}

	return sheet, nil
}

// parseCatalog parsers CATALOG command.
func parseCatalog(params []string, sheet *Sheet) error {
	num := params[0]

	// TODO: Optimize regexp.
	matched, _ := regexp.MatchString("^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$", num)
	if !matched {
		return fmt.Errorf("%s is not valid catalog number.", params)
	}

	sheet.Catalog = num

	return nil
}

// parseCdTextFile parsers CDTEXTFILE command.
func parseCdTextFile(params []string, sheet *Sheet) error {
	sheet.CdTextFile = params[0]

	return nil
}

func parseFileType(t string) (FileType, error) {
	t = strings.ToTitle(t)
	switch t {
	case "BINARY":
		return FileTypeBinary, nil
	case "MOTOROLA":
		return FileTypeMotorola, nil
	case "AIFF":
		return FileTypeAiff, nil
	case "WAVE":
		return FileTypeWave, nil
	case "MP3":
		return FileTypeMp3, nil
	default:
		return 0, fmt.Errorf("Unsupported file type %s.", t)
	}
}

// parseFile parsers FILE command.
// params[0] -- fileName
// params[1] -- fileType
func parseFile(params []string, sheet *Sheet) error {
	fileType, err := parseFileType(params[1])
	if err != nil {
		return err
	}

	sheet.Files = append(sheet.Files, &File{
		Name: params[0],
		Type: fileType,
	})

	return nil
}

// parseFlags parsers FLAGS command.
func parseFlags(params []string, sheet *Sheet) error {
	flagParser := func(flag string) (trackFlag TrackFlag, err error) {
		var flags = map[string]TrackFlag{
			"DCP":  TrackFlagDcp,
			"4CH":  TrackFlag4ch,
			"PRE":  TrackFlagPre,
			"SCMS": TrackFlagScms,
		}

		trackFlag, ok := flags[flag]
		if !ok {
			err = fmt.Errorf("Unsupported track flag %s.", flag)
		}

		return
	}

	track := getCurrentTrack(sheet)
	if track == nil {
		return errors.New("TRACK command should appears before FLAGS command.")
	}

	for _, flagStr := range params {
		flag, err := flagParser(flagStr)
		if err != nil {
			return err
		}
		track.Flags = append(track.Flags, flag)
	}

	return nil
}

// parseIndex parsers INDEX command.
func parseIndex(params []string, sheet *Sheet) error {
	min, sec, frames, err := parseTime(params[1])
	if err != nil {
		return err
	}

	number, err := strconv.Atoi(params[0])
	if err != nil {
		return err
	}

	// All index numbers must be between 0 and 99 inclusive.
	if number < 0 || number > 99 {
		return errors.New("Invalid index number value.")
	}

	track := getCurrentTrack(sheet)
	if track == nil {
		return fmt.Errorf("TRACK command expected.")
	}

	// The first index of a file must start at 00:00:00.
	// Ignore this error, some CUE sheets don't have it
	if getFileLastIndex(getCurrentFile(sheet)) == nil {
		if min+sec+frames != 0 {
			log.Printf("INDEX 00 00:00:00 missing")
		}
	}

	// This is the first track index?
	if len(track.Indexes) == 0 {
		// The first index must be 0 or 1.
		if number >= 2 {
			return errors.New("0 or 1 index number expected.")
		}
	} else {
		// All other indexes being sequential to the first one.
		numberExpected := track.Indexes[len(track.Indexes)-1].Number + 1
		if numberExpected != number {
			return fmt.Errorf("%d index number expected.", numberExpected)
		}
	}

	track.Indexes = append(track.Indexes, &Index{
		Number: number,
		Time: &Time{
			Min:    min,
			Sec:    sec,
			Frames: frames,
		},
	})

	return nil
}

// parseIsrc parsers ISRC command.
func parseIsrc(params []string, sheet *Sheet) error {
	isrc := params[0]

	track := getCurrentTrack(sheet)
	if track == nil {
		return errors.New("TRACK command expected.")
	}

	if len(track.Indexes) != 0 {
		return errors.New("ISRC command expected.")
	}

	// TODO: Shame on you for this regexp.
	re := "^[0-9a-zA-z][0-9a-zA-z][0-9a-zA-z][0-9a-zA-z][0-9a-zA-z]" +
		"[0-9][0-9][0-9][0-9][0-9][0-9][0-9]$"
	matched, _ := regexp.MatchString(re, isrc)
	if !matched {
		return fmt.Errorf("%s is not valid ISRC number.", isrc)
	}

	track.Isrc = isrc

	return nil
}

// parsePerformer parsers PERFORMER command.
func parsePerformer(params []string, sheet *Sheet) error {
	performer := sheet.stringTruncateMaybe(params[0], 80)
	track := getCurrentTrack(sheet)

	if track == nil {
		// Performer command for the CD disk.
		sheet.Performer = performer
	} else {
		// Performer command for track.
		track.Performer = performer
	}

	return nil
}

// parsePostgap parsers POSTGAP command.
func parsePostgap(params []string, sheet *Sheet) error {
	track := getCurrentTrack(sheet)
	if track == nil {
		return errors.New("TRACK command expected.")
	}

	min, sec, frames, err := parseTime(params[0])
	if err != nil {
		return err
	}

	track.Postgap = &Time{Min: min, Sec: sec, Frames: frames}

	return nil
}

// parsePregap parsers PREGAP command.
func parsePregap(params []string, sheet *Sheet) error {
	track := getCurrentTrack(sheet)
	if track == nil {
		return errors.New("TRACK command expected.")
	}

	if len(track.Indexes) != 0 {
		return errors.New("Unexpected PREGAP command.")
	}

	min, sec, frames, err := parseTime(params[0])
	if err != nil {
		return err
	}

	track.Pregap = &Time{Min: min, Sec: sec, Frames: frames}

	return nil
}

// parseRem parsers REM command.
func parseRem(params []string, sheet *Sheet) error {
	comment := strings.Join(params, " ")
	track := getCurrentTrack(sheet)

	if track == nil {
		// Comment for the CD disk.
		sheet.Comments = append(sheet.Comments, comment)
	} else {
		// Comment for the track.
		track.Comments = append(track.Comments, comment)
	}

	return nil
}

// parseSongWriter parsers SONGWRITER command.
func parseSongWriter(params []string, sheet *Sheet) error {
	songwriter := sheet.stringTruncateMaybe(params[0], 80)
	track := getCurrentTrack(sheet)

	if track == nil {
		sheet.Songwriter = songwriter
	} else {
		track.Songwriter = songwriter
	}

	return nil
}

// parseTitle parsers TITLE command.
func parseTitle(params []string, sheet *Sheet) error {
	title := sheet.stringTruncateMaybe(params[0], 80)
	track := getCurrentTrack(sheet)

	if track == nil {
		// Title for the CD disk.
		sheet.Title = title
	} else {
		// Title command for track.
		track.Title = title
	}

	return nil
}

// parseTrack parses TRACK command.
func parseTrack(params []string, sheet *Sheet) error {
	// TRACK command should be after FILE command.
	if len(sheet.Files) == 0 {
		return fmt.Errorf("Unexpected TRACK command.")
	}

	numberStr := params[0]
	dataTypeStr := params[1]

	// Type parser function.
	parseDataType := func(t string) (dataType TrackDataType, err error) {
		var types = map[string]TrackDataType{
			"AUDIO":      DataTypeAudio,
			"CDG":        DataTypeCdg,
			"MODE1/2048": DataTypeMode1_2048,
			"MODE1/2352": DataTypeMode1_2352,
			"MODE2/2336": DataTypeMode2_2336,
			"MODE2/2352": DataTypeMode2_2352,
			"CDI/2336":   DataTypeCdi_2336,
			"CDI/2352":   DataTypeCdi_2352,
		}

		dataType, ok := types[t]
		if !ok {
			err = fmt.Errorf("Unsupported track datatype %s.", t)
		}

		return
	}

	number, err := strconv.Atoi(numberStr)
	if err != nil {
		return err
	}
	if number < 1 {
		return fmt.Errorf("Bad track number value.")
	}

	dataType, err := parseDataType(dataTypeStr)
	if err != nil {
		return err
	}

	track := &Track{
		Number:   number,
		DataType: dataType,
	}

	file := sheet.Files[len(sheet.Files)-1]

	// But all track numbers after the first must be sequential.
	if len(file.Tracks) > 0 {
		if file.Tracks[len(file.Tracks)-1].Number != number-1 {
			return fmt.Errorf("Expected track number %d, but %d received.",
				number-1, number)
		}
	}

	file.Tracks = append(file.Tracks, track)

	return nil
}

// getCurrentFile returns file object started with the last FILE command.
// Returns nil if there is no any File objects.
func getCurrentFile(sheet *Sheet) *File {
	if len(sheet.Files) == 0 {
		return nil
	}

	return sheet.Files[len(sheet.Files)-1]
}

// getCurrentTrack returns current track object, which was started with last TRACK command.
// Returns nil if there is no any Track object avaliable.
func getCurrentTrack(sheet *Sheet) *Track {
	file := getCurrentFile(sheet)
	if file == nil {
		return nil
	}

	if len(file.Tracks) == 0 {
		return nil
	}

	return file.Tracks[len(file.Tracks)-1]
}

// getFileLastIndex returns last index for the given file.
// Returns nil if file has no any indexes.
func getFileLastIndex(file *File) *Index {
	for i := len(file.Tracks) - 1; i >= 0; i-- {
		track := file.Tracks[i]

		for j := len(track.Indexes) - 1; j >= 0; j-- {
			return track.Indexes[j]
		}
	}

	return nil
}

A  => cue_test.go +101 -0
@@ 1,101 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

package cue

import (
	"testing"
)

const (
	expectedTitle        = "Doro"
	expectedPerformer    = "Doro"
	expectedFilesNumber  = 1
	expectedFileName     = "Doro - Doro.ape"
	expectedTracksNumber = 10
	// Expected first track values.
	expectedTrackNumber        = 1
	expectedTrackTitle         = "Unholy Love"
	expectedTrackPerformer     = "Doro"
	expectedTrackIndexesNumber = 1
	expectedIndexNumber        = 1
	expectedTrackIndexNumber   = 1
)

func TestPackage(t *testing.T) {
	filename := "test.cue"

	sheet, err := ParseFile(filename, 0)
	if err != nil {
		t.Fatalf("Failed to parse file. %s", err)
	}

	if sheet.Title != expectedTitle {
		t.Fatalf("Expected title %s but %s got.",
			expectedTitle, sheet.Title)
	}
	if sheet.Performer != expectedPerformer {
		t.Fatalf("Expected performer %s but %s got.",
			expectedPerformer, sheet.Performer)
	}

	if len(sheet.Files) != expectedFilesNumber {
		t.Fatalf("Expected files number %d but %d got.",
			expectedFilesNumber, len(sheet.Files))
	}

	file := sheet.Files[0]

	if file.Name != expectedFileName {
		t.Fatalf("Expected file name %s but %s got.",
			expectedFileName, file.Name)
	}
	if len(file.Tracks) != expectedTracksNumber {
		t.Fatalf("Expected tracks number %d but %d got.",
			expectedTracksNumber, len(file.Tracks))
	}

	// Assert first track only.
	track := file.Tracks[0]
	if track.Number != expectedTrackNumber {
		t.Fatalf("Expected track number %d but %d got.",
			expectedTrackNumber, track.Number)
	}
	if track.Title != expectedTrackTitle {
		t.Fatalf("Expected track title %s but %s got.",
			expectedTrackTitle, track.Title)
	}
	if track.Performer != expectedTrackPerformer {
		t.Fatalf("Expected track performer %s but %s got.",
			expectedTrackPerformer, track.Performer)
	}
	if len(track.Indexes) != expectedTrackIndexesNumber {
		t.Fatalf("Expected track indexes number %d but %d got.",
			expectedTrackIndexesNumber, len(track.Indexes))
	}

	index := track.Indexes[0]
	if index.Number != expectedIndexNumber {
		t.Fatalf("Expected track indexes number %d but %d got.",
			expectedTrackIndexNumber, index.Number)
	}
	time := index.Time
	if time.Min != 0 || time.Sec != 0 || time.Frames != 0 {
		t.Fatalf("Expected track index time 0:0:0 but %d:%d:%d got.",
			time.Min, time.Sec, time.Frames)
	}
}

A  => parser.go +184 -0
@@ 1,184 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

package cue

import (
	"bytes"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"unicode"
)

var illegalTimeFormatError = errors.New("Illegal time format.")

// parseCommand retrive string line and parses it with the following algorythm:
// * first word in the line is command name (cmd return value)
// * all rest words are command's parameters
// * if parameter includes more than one word it should be wrapped with ' or "
func parseCommand(line string) (cmd string, params []string, err error) {
	line = strings.TrimSpace(line)
	params = make([]string, 0)

	// Find cmd.
	i := strings.IndexFunc(line, unicode.IsSpace)
	if i < 0 { // We have only command without any parameters.
		cmd = line
		return
	}
	cmd = line[:i]
	line = strings.TrimSpace(line[i:])

	// Split parameters.
	l := len(line)
	var quotedChar byte = 0
	param := bytes.NewBufferString("")
	for i = 0; i < l; i++ {
		c := line[i]

		if quotedChar == 0 { // We are not in quote mode now, so we can enter into.
			if isQuoteChar(c) {
				// Quote can be started only at the beginnig of the parameter,
				// but not in the middle.
				if param.Len() != 0 {
					err = errors.New("Unexpected quortation character.")
					return
				}
				quotedChar = c
			} else if unicode.IsSpace(rune(c)) {
				// In not quote mode space starts new parameter.
				// But don't save empty parameters.
				if param.Len() != 0 {
					params = append(params, param.String())
					param = bytes.NewBufferString("")
				}
			} else {
				if c == '\\' { // Escape sequence in the text.
					if i+1 >= l {
						err = fmt.Errorf("Unfinished escape sequence.")
						return
					}

					s, e := parseEscapeSequence(line[i : i+2])
					if e != nil {
						err = e
						return
					}
					param.WriteByte(s)
					i++
				} else {
					param.WriteByte(c)
				}
			}
		} else {
			if c == quotedChar { // Close quote.
				quotedChar = 0
			} else {
				if c == '\\' { // Escape sequence in the text.
					if i+1 >= l {
						err = fmt.Errorf("Unfinished escape sequence.")
						return
					}

					s, e := parseEscapeSequence(line[i : i+2])
					if e != nil {
						err = e
						return
					}
					param.WriteByte(s)
					i++
				} else {
					param.WriteByte(c)
				}
			}
		}
	}

	params = append(params, param.String())

	return
}

// parseEscapeSequence returns escape character by it's string "source code" equivalent.
func parseEscapeSequence(seq string) (char byte, err error) {
	var m = map[string]byte{
		"\\\"": '"',
		"\\'":  '\'',
		"\\\\": '\\',
		"\\n":  '\n',
		"\\t":  '\t',
	}

	char, ok := m[seq]
	if !ok {
		err = fmt.Errorf("Usupported escape sequence '%s'.", seq)
	}

	return
}

// isQuoteChar returns true if given char is string quoted char:
// " or '.
func isQuoteChar(char byte) bool {
	return char == '\'' || char == '"'
}

// parserTime parses time string and returns separate values.
// Input string format: mm:ss:ff
func parseTime(length string) (min int, sec int, frames int, err error) {
	parts := strings.Split(length, ":")
	if len(parts) != 3 {
		err = illegalTimeFormatError
		return
	}

	min, err = strconv.Atoi(parts[0])
	if err != nil {
		err = illegalTimeFormatError
		return
	}

	sec, err = strconv.Atoi(parts[1])
	if err != nil {
		err = illegalTimeFormatError
		return
	}
	if sec > 59 {
		err = illegalTimeFormatError
		return
	}

	frames, err = strconv.Atoi(parts[2])
	if err != nil {
		err = illegalTimeFormatError
		return
	}
	if frames > 74 {
		if frames > 99 {
			err = illegalTimeFormatError
			return
		}
		// Some Tools generate sloppy frame counts
		// Use remainder for frames but increment seconds
		frames = frames % 74
		sec++
	}

	return
}

A  => parser_test.go +104 -0
@@ 1,104 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

package cue

import (
	"testing"
)

type expected struct {
	Cmd    string
	Params []string
}

type test struct {
	Input  string
	Etalon expected
}

func TestParseCommand(t *testing.T) {
	var tests = []test{
		{"COMMAND",
			expected{"COMMAND",
				[]string{}}},
		{"COMMAND \t PARAM1   PARAM2\tPARAM3",
			expected{"COMMAND",
				[]string{"PARAM1", "PARAM2", "PARAM3"}}},
		{"COMMAND 'PARAM1' \"PARAM2\" 'PAR\"AM3' 'P AR  AM 4'",
			expected{"COMMAND",
				[]string{"PARAM1", "PARAM2", "PAR\"AM3", "P AR  AM 4"}}},
		{"COMMAND 'P A R A M 1' \"PA RA M2\" PA\\\"RAM\\'3",
			expected{"COMMAND",
				[]string{"P A R A M 1", "PA RA M2", "PA\"RAM'3"}}},
	}

	for _, tt := range tests {
		cmd, params, err := parseCommand(tt.Input)
		if err != nil {
			t.Fatalf(err.Error())
		}

		if cmd != tt.Etalon.Cmd {
			t.Fatalf("Parsed command '%s' but '%s' expected", cmd, tt.Etalon.Cmd)
		}

		if len(params) != len(tt.Etalon.Params) {
			t.Fatalf("Parsed %d params but %d expected", len(params), len(tt.Etalon.Params))
		}

		for i := 0; i < len(params); i++ {
			if params[i] != tt.Etalon.Params[i] {
				t.Fatalf("Parsed '%s' parameter but '%s' expected", params[i], tt.Etalon.Params[i])
			}
		}
	}
}

type timeExpected struct {
	min    int
	sec    int
	frames int
}

func TestParseTime(t *testing.T) {
	var tests = map[string]timeExpected{
		"01:02:03": {1, 2, 3},
		"11:22:33": {11, 22, 33},
		"14:00:00": {14, 0, 0},
		"01:24:75": {1, 25, 1},
		"01:24:80": {1, 25, 6},
		"01:24:99": {1, 25, 25},
	}

	for input, expected := range tests {
		min, sec, frames, err := parseTime(input)
		if err != nil {
			t.Fatalf("Time parsing failed. Input string: '%s'. %s", input, err)
		}

		if min != expected.min {
			t.Fatalf("Expected %d minutes, but %d recieved.", expected.min, min)
		}
		if sec != expected.sec {
			t.Fatalf("Expected %d seconds, but %d recieved.", expected.sec, sec)
		}
		if frames != expected.frames {
			t.Fatalf("Expected %d frames, but %d recieved.", expected.frames, frames)
		}
	}
}

A  => sheet.go +148 -0
@@ 1,148 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

package cue

// Cue sheet file representation.
type Sheet struct {
	// Disc's media catalog number.
	Catalog string
	// Name of a perfomer for a CD-TEXT enhanced disc
	Performer string
	// Specify a title for a CD-TEXT enhanced disc.
	Title string
	// Specify songwriter for disc.
	Songwriter string
	// Comments in the CUE SHEET file.
	Comments []string
	// Name of the file that contains the encoded CD-TEXT information for the disc.
	CdTextFile string
	// Data/audio files descibed byt the cue-file.
	Files []*File

	flags int
}

// Type of the audio file.
type FileType int

const (
	// Intel binary file (least significant byte first)
	FileTypeBinary FileType = iota
	// Motorola binary file (most significant byte first)
	FileTypeMotorola
	// Audio AIFF file
	FileTypeAiff
	// Audio WAVE file
	FileTypeWave
	// Audio MP3 file
	FileTypeMp3
)

// Track datatype.
type TrackDataType int

const (
	// AUDIO – Audio/Music (2352)
	DataTypeAudio = iota
	// CDG – Karaoke CD+G (2448)
	DataTypeCdg
	// MODE1/2048 – CDROM Mode1 Data (cooked)
	DataTypeMode1_2048
	// MODE1/2352 – CDROM Mode1 Data (raw)
	DataTypeMode1_2352
	// MODE2/2336 – CDROM-XA Mode2 Data
	DataTypeMode2_2336
	// MODE2/2352 – CDROM-XA Mode2 Data
	DataTypeMode2_2352
	// CDI/2336 – CDI Mode2 Data
	DataTypeCdi_2336
	// CDI/2352 – CDI Mode2 Data
	DataTypeCdi_2352
)

// Time point description type.
type Time struct {
	// Minutes.
	Min int
	// Minutes.
	Sec int
	// Frames.
	Frames int
}

// Seconds returns length in seconds.
func (time *Time) Seconds() int {
	return time.Min*60 + time.Sec
}

// Track index type
type Index struct {
	// Index number.
	Number int
	// Index starting time.
	Time *Time
}

// Additional decode information about track.
type TrackFlag int

const (
	// Digital copy permitted.
	TrackFlagDcp = iota
	// Four channel audio.
	TrackFlag4ch
	// Pre-emphasis enabled (audio tracks only).
	TrackFlagPre
	// Serial copy management system (not supported by all recorders).
	TrackFlagScms
)

type Track struct {
	// Track number (1-99).
	Number int
	// Track datatype.
	DataType TrackDataType
	// Track title.
	Title string
	// Track preformer.
	Performer string
	// Songwriter.
	Songwriter string
	// Track decode flags.
	Flags []TrackFlag
	// Internetional Standaard Recording Code.
	Isrc string
	// Track indexes.
	Indexes []*Index
	// Length of the track pregap.
	Pregap *Time
	// Length of the track postgap.
	Postgap *Time
	// Comments.
	Comments []string
}

// Audio file representation structure.
type File struct {
	// Name (path) of the file.
	Name string
	// Type of the audio file.
	Type FileType
	// List of present tracks in the file.
	Tracks []*Track
}

A  => test.cue +47 -0
@@ 1,47 @@
REM GENRE "Hard Rock"
REM DATE 1990
REM DISCID 840A130A
REM COMMENT "ExactAudioCopy v0.95b4"
PERFORMER "Doro"
TITLE "Doro"
FILE "Doro - Doro.ape" WAVE
  TRACK 01 AUDIO
    TITLE "Unholy Love"
    PERFORMER "Doro"
    INDEX 01 00:00:00
  TRACK 02 AUDIO
    TITLE "I Had Too Much to Dream"
    PERFORMER "Doro"
    INDEX 01 04:31:07
  TRACK 03 AUDIO
    TITLE "Rock On"
    PERFORMER "Doro"
    INDEX 01 08:38:39
  TRACK 04 AUDIO
    TITLE "Only You"
    PERFORMER "Doro"
    INDEX 01 11:48:03
  TRACK 05 AUDIO
    TITLE "I'll Be Holding On"
    PERFORMER "Doro"
    INDEX 01 16:05:70
  TRACK 06 AUDIO
    TITLE "Something Wicked This Way Comes"
    PERFORMER "Doro"
    INDEX 01 21:24:11
  TRACK 07 AUDIO
    TITLE "Rare Diamond"
    PERFORMER "Doro"
    INDEX 01 26:35:27
  TRACK 08 AUDIO
    TITLE "Broken"
    PERFORMER "Doro"
    INDEX 01 30:06:34
  TRACK 09 AUDIO
    TITLE "Alive"
    PERFORMER "Doro"
    INDEX 01 34:48:09
  TRACK 10 AUDIO
    TITLE "Mirage"
    PERFORMER "Doro"
    INDEX 01 39:03:14

A  => utils.go +29 -0
@@ 1,29 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

package cue

// stringTruncateMaybe truncates string up to newLen characters if string truncation
// is enabled.
// If given string is shorter than newLen if will be returned without any changes.
func (sheet *Sheet) stringTruncateMaybe(str string, newLen int) string {
	if len(str) > newLen && (sheet.flags&FlagTruncateStrings) != 0 {
		return str[:newLen]
	}

	return str
}

A  => utils_test.go +42 -0
@@ 1,42 @@
// Copyright 2016 Viacheslav Chimishuk <vchimishuk@yandex.ru>
//
// This file is part of Chub.
//
// Chub is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Chub is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Chub. If not, see <http://www.gnu.org/licenses/>.

package cue

import (
	"testing"
)

func TestUtils(t *testing.T) {
	const input string = "1234567890"
	var inputLen = len(input)
	sheet := &Sheet{flags: FlagTruncateStrings}

	for i := 0; i < inputLen+2; i++ {
		out := sheet.stringTruncateMaybe(input, i)
		l := len(out)
		if i < inputLen {
			if l != i {
				t.Fatalf("Assertion failed: len(stringTruncate(\"%s\", %d)) == %d", input, i, l)
			}
		} else {
			if l != inputLen {
				t.Fatalf("Assertion failed: len(stringTruncate(\"%s\", %d)) == %d", input, i, inputLen)
			}
		}
	}
}