diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..e3cce99 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,14 @@ +module main + +go 1.17 + +require ( + github.com/abema/go-mp4 v0.7.2 + github.com/alexflint/go-arg v1.4.3 + github.com/sunfish-shogi/bufseekio v0.1.0 +) + +require ( + github.com/alexflint/go-scalar v1.1.0 // indirect + github.com/google/uuid v1.1.2 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..17d4142 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,39 @@ +github.com/abema/go-mp4 v0.7.2 h1:ugTC8gfEmjyaDKpXs3vi2QzgJbDu9B8m6UMMIpbYbGg= +github.com/abema/go-mp4 v0.7.2/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= +github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= +github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM= +github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= +github.com/sunfish-shogi/bufseekio v0.1.0 h1:zu38kFbv0KuuiwZQeuYeS02U9AM14j0pVA9xkHOCJ2A= +github.com/sunfish-shogi/bufseekio v0.1.0/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..6dbe575 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,536 @@ +package main + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/abema/go-mp4" + "github.com/alexflint/go-arg" + "github.com/sunfish-shogi/bufseekio" +) + +var atomsMap = map[string]mp4.BoxType{ + "Album": {'\251', 'a', 'l', 'b'}, + "AlbumArtist": {'a', 'A', 'R', 'T'}, + "Artist": {'\251', 'A', 'R', 'T'}, + "Comment": {'\251', 'c', 'm', 't'}, + "Composer": {'\251', 'w', 'r', 't'}, + "Copyright": {'c', 'p', 'r', 't'}, + "Cover": {'c', 'o', 'v', 'r'}, + "Disk": {'d', 'i', 's', 'k'}, + "Genre": {'\251', 'g', 'e', 'n'}, + "Label": {'\251', 'l', 'a', 'b'}, + "Title": {'\251', 'n', 'a', 'm'}, + "Track": {'t', 'r', 'k', 'n'}, + "Year": {'\251', 'd', 'a', 'y'}, +} + +func parseArgs() *Args { + var args Args + arg.MustParse(&args) + return &args +} + +func parseTags(args *Args) (*Tags, error) { + _tags := &Tags{ + Album: args.Album, + AlbumArtist: args.AlbumArtist, + Artist: args.Artist, + Comment: args.Comment, + Composer: args.Composer, + Custom: args.Custom, + Delete: args.Delete, + DiskNumber: args.DiskNumber, + DiskTotal: args.DiskTotal, + Genre: args.Genre, + Label: args.Label, + Title: args.Title, + TrackNumber: args.TrackNumber, + TrackTotal: args.TrackTotal, + Year: args.Year, + } + if args.Cover != "" { + coverBytes, err := ioutil.ReadFile(args.Cover) + if err != nil { + return nil, err + } + _tags.Cover = coverBytes + } + return _tags, nil +} + +// Icky, but don't think there's any other better way. +func tagsEmpty(_tags *Tags) bool { + emptyOne := &Tags{DiskNumber: 0, DiskTotal: 0, TrackNumber: 0, TrackTotal: 0} + emptyTwo := &Tags{DiskNumber: 0, DiskTotal: 0, TrackNumber: 0, TrackTotal: 0, Custom: map[string]string{}} + return reflect.DeepEqual(_tags, emptyOne) || reflect.DeepEqual(_tags, emptyTwo) +} + +func copy(w *mp4.Writer, h *mp4.ReadHandle) error { + _, err := w.StartBox(&h.BoxInfo) + if err != nil { + return err + } + box, _, err := h.ReadPayload() + if err != nil { + return err + } + _, err = mp4.Marshal(w, box, h.BoxInfo.Context) + if err != nil { + return err + } + _, err = h.Expand() + if err != nil { + return err + } + _, err = w.EndBox() + return err +} + +// See which atoms don't already exist and will need creating. +func populateAtoms(f *os.File, _tags *Tags) (map[string]bool, error) { + ilst, err := mp4.ExtractBox( + f, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeUdta(), mp4.BoxTypeMeta(), mp4.BoxTypeIlst()}) + if err != nil { + return nil, err + } + if len(ilst) == 0 { + return nil, errors.New("Ilst atom is missing. Implement me.") + } + atoms := map[string]bool{} + fields := reflect.VisibleFields(reflect.TypeOf(*_tags)) + for _, field := range fields { + fieldName := field.Name + if fieldName == "Custom" || fieldName == "TrackTotal" || fieldName == "DiskTotal" { + continue + } + if fieldName == "TrackNumber" { + fieldName = "Track" + } else if fieldName == "DiskNumber" { + fieldName = "Disk" + } + boxType, ok := atomsMap[fieldName] + if !ok { + continue + } + boxes, err := mp4.ExtractBox( + f, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeUdta(), mp4.BoxTypeMeta(), mp4.BoxTypeIlst(), boxType}) + if err != nil { + return nil, err + } + atoms[fieldName] = len(boxes) == 0 + } + return atoms, nil +} + +func marshalData(w *mp4.Writer, ctx mp4.Context, val interface{}) error { + _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeData()}) + if err != nil { + return err + } + var boxData mp4.Data + switch v := val.(type) { + case string: + boxData.DataType = mp4.DataTypeStringUTF8 + boxData.Data = []byte(v) + case []byte: + boxData.DataType = mp4.DataTypeBinary + boxData.Data = v + } + _, err = mp4.Marshal(w, &boxData, ctx) + if err != nil { + return err + } + _, err = w.EndBox() + return err +} + +func writeMeta(w *mp4.Writer, tag mp4.BoxType, ctx mp4.Context, val interface{}) error { + _, err := w.StartBox(&mp4.BoxInfo{Type: tag}) + if err != nil { + return err + } + err = marshalData(w, ctx, val) + if err != nil { + return err + } + _, err = w.EndBox() + return err +} + +func writeCustomMeta(w *mp4.Writer, ctx mp4.Context, field string, val interface{}) error { + _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxType{'-', '-', '-', '-'}, Context: ctx}) + if err != nil { + return err + } + _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxType{'m', 'e', 'a', 'n'}, Context: ctx}) + if err != nil { + return err + } + _, err = w.Write([]byte{'\x00', '\x00', '\x00', '\x00'}) + if err != nil { + return err + } + _, err = io.WriteString(w, "com.apple.iTunes") + if err != nil { + return err + } + _, err = w.EndBox() + if err != nil { + return err + } + _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxType{'n', 'a', 'm', 'e'}, Context: ctx}) + if err != nil { + return err + } + _, err = w.Write([]byte{'\x00', '\x00', '\x00', '\x00'}) + if err != nil { + return err + } + _, err = io.WriteString(w, field) + if err != nil { + return err + } + _, err = w.EndBox() + if err != nil { + return err + } + err = marshalData(w, ctx, val) + if err != nil { + return err + } + _, err = w.EndBox() + return err +} + +// func writeCover(h *mp4.ReadHandle, w *mp4.Writer, ctx mp4.Context, coverData []byte) error { +// _, err := w.StartBox(&h.BoxInfo) +// if err != nil { +// return err +// } +// box, _, err := h.ReadPayload() +// if err != nil { +// return err +// } +// _, err = mp4.Marshal(w, box, h.BoxInfo.Context) +// if err != nil { +// return err +// } +// err = writeMeta(w, h.BoxInfo.Type, ctx, coverData) +// if err != nil { +// return err +// } +// _, err = w.EndBox() +// if err != nil { +// return err +// } +// return nil +// } + +// Make new atoms and write to. +func createAndWrite(h *mp4.ReadHandle, w *mp4.Writer, ctx mp4.Context, _tags *Tags, atoms map[string]bool) error { + _, err := w.StartBox(&h.BoxInfo) + if err != nil { + return err + } + box, _, err := h.ReadPayload() + if err != nil { + return err + } + _, err = mp4.Marshal(w, box, h.BoxInfo.Context) + if err != nil { + return err + } + if _tags.Cover != nil { + err := writeMeta(w, atomsMap["Cover"], ctx, _tags.Cover) + if err != nil { + return err + } + } + if _tags.TrackNumber > 0 { + trkn := make([]byte, 8) + binary.BigEndian.PutUint32(trkn, uint32(_tags.TrackNumber)) + if _tags.TrackTotal > 0 { + binary.BigEndian.PutUint16(trkn[4:], uint16(_tags.TrackTotal)) + } + err = writeMeta(w, atomsMap["Track"], ctx, trkn) + if err != nil { + return err + } + } + if _tags.DiskNumber > 0 { + disk := make([]byte, 8) + binary.BigEndian.PutUint32(disk, uint32(_tags.DiskNumber)) + if _tags.DiskTotal > 0 { + binary.BigEndian.PutUint16(disk[4:], uint16(_tags.DiskTotal)) + } + err = writeMeta(w, atomsMap["Disk"], ctx, disk) + if err != nil { + return err + } + } + for tagName, needCreate := range atoms { + if tagName == "Cover" || tagName == "Track" || tagName == "Disk" { + continue + } + val := reflect.ValueOf(*_tags).FieldByName(tagName).String() + if !needCreate || val == "" { + continue + } + boxType := atomsMap[tagName] + err = writeMeta(w, boxType, ctx, val) + if err != nil { + return err + } + } + for field, value := range _tags.Custom { + if value == "" { + continue + } + err = writeCustomMeta(w, ctx, field, strings.ToUpper(value)) + if err != nil { + return err + } + } + _, err = h.Expand() + if err != nil { + return err + } + _, err = w.EndBox() + return err +} + +func writeExisting(h *mp4.ReadHandle, w *mp4.Writer, _tags *Tags, currentKey string, ctx mp4.Context) (bool, error) { + if currentKey == "Cover" && _tags.Cover == nil { + return true, nil + } + if currentKey == "Cover" && _tags.Cover != nil { + // err := writeCover(h, w, ctx, _tags.Cover) + // if err != nil { + // return false, nil + // } + //err := writeMeta(w, atomsMap["Cover"], ctx, _tags.Cover) + _, err := w.StartBox(&h.BoxInfo) + if err != nil { + return false, err + } + box, _, err := h.ReadPayload() + if err != nil { + return false, err + } + data := box.(*mp4.Data) + data.DataType = mp4.DataTypeBinary + data.Data = []byte(_tags.Cover) + _, err = mp4.Marshal(w, data, h.BoxInfo.Context) + if err != nil { + return false, err + } + _, err = w.EndBox() + if err != nil { + return false, err + } + // err := writeMeta(w, h.BoxInfo.Type, ctx, _tags.Cover) + // if err != nil { + // return false, err + // } + } else if currentKey == "Disk" { + if _tags.DiskNumber < 1 { + return true, nil + } + // disk := make([]byte, 8) + // binary.BigEndian.PutUint32(disk, uint32(_tags.DiskNumber)) + // if _tags.DiskTotal > 0 { + // binary.BigEndian.PutUint16(disk[4:], uint16(_tags.DiskTotal)) + // } + // err := writeMeta(w, h.BoxInfo.Type, ctx, disk) + // if err != nil { + // return false, err + // } + } else if currentKey == "Track" { + if _tags.TrackNumber < 1 { + return true, nil + } + // trkn := make([]byte, 8) + // binary.BigEndian.PutUint32(trkn, uint32(_tags.TrackNumber)) + // if _tags.TrackTotal > 0 { + // binary.BigEndian.PutUint16(trkn[4:], uint16(_tags.TrackTotal)) + // } + // // err := writeMeta(w, h.BoxInfo.Type, ctx, trkn) + } else { + toWrite := reflect.ValueOf(*_tags).FieldByName(currentKey).String() + if toWrite == "" { + return true, nil + } + // Not working here. + // err := writeMeta(w, h.BoxInfo.Type, ctx, toWrite) + // if err != nil { + // return false, err + // } + _, err := w.StartBox(&h.BoxInfo) + if err != nil { + return false, err + } + box, _, err := h.ReadPayload() + if err != nil { + return false, err + } + data := box.(*mp4.Data) + data.DataType = mp4.DataTypeStringUTF8 + data.Data = []byte(toWrite) + _, err = mp4.Marshal(w, data, h.BoxInfo.Context) + if err != nil { + return false, err + } + _, err = w.EndBox() + return false, err + + } + return false, nil +} + +func containsAtom(boxType mp4.BoxType, boxes []mp4.BoxType) mp4.BoxType { + for _, _boxType := range boxes { + if boxType == _boxType { + return boxType + } + } + return mp4.BoxType{} +} + +func containsTag(delete []string, currentTag string) bool { + for _, tag := range delete { + if strings.EqualFold(tag, currentTag) { + return true + } + } + return false +} + +func getTag(boxType mp4.BoxType) string { + for k, v := range atomsMap { + if v == boxType { + return k + } + } + return "" +} + +func getAtomsList() []mp4.BoxType { + var atomsList []mp4.BoxType + for _, atom := range atomsMap { + atomsList = append(atomsList, atom) + } + return atomsList +} + +func copyTrack(srcPath, destPath string) error { + srcFile, err := os.OpenFile(srcPath, os.O_RDONLY, 0755) + if err != nil { + return err + } + defer srcFile.Close() + outFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0755) + if err != nil { + return err + } + defer outFile.Close() + _, err = io.Copy(outFile, srcFile) + return err +} + +func write(trackPath string, _tags *Tags) error { + var currentKey string + ctx := mp4.Context{UnderIlstMeta: true} + tempPath, err := os.MkdirTemp(os.TempDir(), "mp4tag") + if err != nil { + return errors.New( + "Failed to make temp directory.\n" + err.Error()) + } + defer os.RemoveAll(tempPath) + tempPath = filepath.Join(tempPath, "tmp.m4a") + atomsList := getAtomsList() + outFile, err := os.OpenFile(trackPath, os.O_RDONLY, 0755) + if err != nil { + return err + } + tempFile, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0755) + if err != nil { + outFile.Close() + return err + } + r := bufseekio.NewReadSeeker(outFile, 128*1024, 4) + atoms, err := populateAtoms(outFile, _tags) + if err != nil { + outFile.Close() + tempFile.Close() + return err + } + w := mp4.NewWriter(tempFile) + _, err = mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) { + switch h.BoxInfo.Type { + case mp4.BoxTypeMoov(), mp4.BoxTypeUdta(), mp4.BoxTypeMeta(): + err := copy(w, h) + return nil, err + case mp4.BoxTypeIlst(): + err := createAndWrite(h, w, ctx, _tags, atoms) + return nil, err + case containsAtom(h.BoxInfo.Type, atomsList): + if h.BoxInfo.Type == atomsMap["Cover"] && _tags.Cover != nil { + return nil, nil + } + currentKey = getTag(h.BoxInfo.Type) + if containsTag(_tags.Delete, currentKey) { + return nil, nil + } + err = copy(w, h) + return nil, err + case mp4.BoxTypeData(): + if currentKey == "" { + return nil, w.CopyBox(r, &h.BoxInfo) + } + needCreate := atoms[currentKey] + if !needCreate { + valEmpty, err := writeExisting(h, w, _tags, currentKey, ctx) + currentKey = "" + if err != nil { + return nil, err + } else if valEmpty { + return nil, w.CopyBox(r, &h.BoxInfo) + } + } + return nil, nil + default: + return nil, w.CopyBox(r, &h.BoxInfo) + } + }) + outFile.Close() + tempFile.Close() + if err != nil { + return err + } + err = copyTrack(tempPath, trackPath) + return err +} + +func main() { + args := parseArgs() + _tags, err := parseTags(args) + if err != nil { + panic("Failed to parse tags.\n" + err.Error()) + } + if tagsEmpty(_tags) { + fmt.Println("Nothing to write. Exiting...") + os.Exit(0) + } + err = write(args.FilePath, _tags) + if err != nil { + panic("Failed to write tags.\n" + err.Error()) + } +} diff --git a/cli/structs.go b/cli/structs.go new file mode 100644 index 0000000..f238d7a --- /dev/null +++ b/cli/structs.go @@ -0,0 +1,40 @@ +package main + +type Args struct { + Album string `arg:"--album" help:"Write album tag."` + AlbumArtist string `arg:"--albumArtist" help:"Write album artist tag."` + Artist string `arg:"--artist" help:"Write artist tag."` + Comment string `arg:"--comment" help:"Write comment tag."` + Composer string `arg:"--composer" help:"Write composer tag."` + Cover string `arg:"--cover" help:"Path of cover to write. JPEG is recommended."` + Custom map[string]string `arg:"--custom" help:"Write custom tags. Multiple tags with the same field name can be written.\n\t\t\t Example: \"--custom MYCUSTOMFIELD1=value1 MYCUSTOMFIELD2=value2\""` + Delete []string `arg:"-d, --delete" help:"Tags to delete.\n\t\t\t Options: album, albumartist, artist, comment, composer, cover, disk, genre, label, title, track, year.\n\t\t\t Example: \"-d album albumartist\""` + DiskNumber int `arg:"--diskNumber" help:"Write disk number tag."` + DiskTotal int `arg:"--diskTotal" help:"Write disk total tag. Can't be written without disk number tag."` + FilePath string `arg:"positional, required" help:"Path of file to write to."` + Genre string `arg:"--genre" help:"Write genre tag."` + Label string `arg:"--label" help:"Write label tag."` + Title string `arg:"--title" help:"Write title tag."` + TrackNumber int `arg:"--trackNumber" help:"Write track number tag."` + TrackTotal int `arg:"--trackTotal" help:"Write track total tag. Can't be written without track number tag."` + Year string `arg:"--year" help:"Write year tag."` +} + +type Tags struct { + Album string + AlbumArtist string + Artist string + Comment string + Composer string + Cover []byte + Custom map[string]string + Delete []string + DiskNumber int + DiskTotal int + Genre string + Label string + Title string + TrackNumber int + TrackTotal int + Year string +}