diff --git a/go.mod b/go.mod index c7e8f27..c9d6713 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/gdamore/encoding v1.0.0 // indirect + github.com/google/uuid v1.1.2 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect @@ -25,9 +26,11 @@ require ( ) require ( + github.com/abema/go-mp4 v1.1.1 github.com/hashicorp/go-version v1.6.0 github.com/rivo/tview v0.0.0-20230406072732-e22ce9588bb4 github.com/stretchr/testify v1.8.3 + github.com/sunfish-shogi/bufseekio v0.1.0 golang.org/x/net v0.17.0 // indirect jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 ) diff --git a/go.sum b/go.sum index 005a203..68625f7 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/abema/go-mp4 v1.1.1 h1:OfzkdMO6SWTBR1ltNSVwlTHatrAK9I3iYLQfkdEMMuc= +github.com/abema/go-mp4 v1.1.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= @@ -7,10 +11,16 @@ github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCyS github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= +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/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -20,6 +30,7 @@ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWV github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -32,8 +43,13 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -47,6 +63,7 @@ golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -74,6 +91,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g= diff --git a/internal/controller/build_controller.go b/internal/controller/build_controller.go index 2c5fb4d..132a2c5 100644 --- a/internal/controller/build_controller.go +++ b/internal/controller/build_controller.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "io" + "io/ioutil" "net" "net/http" "os" @@ -13,6 +14,7 @@ import ( "abb_ia/internal/dto" "abb_ia/internal/ffmpeg" + "abb_ia/internal/mp4" "abb_ia/internal/utils" "abb_ia/internal/logger" @@ -144,15 +146,6 @@ func (c *BuildController) createMetadata(ab *dto.Audiobook) { f.WriteString("major_brand=isom\n") f.WriteString("minor_version=512\n") f.WriteString("compatible_brands=isomiso2mp41\n") - f.WriteString("title=" + ab.Title + "\n") - f.WriteString("artist=" + ab.Author + "\n") - f.WriteString("album=" + ab.Title + "\n") - f.WriteString("genre=" + ab.Genre + "\n") - f.WriteString("description=" + strings.ReplaceAll(ab.Description, "\n", "\\\n") + "\n") - f.WriteString("copyright=" + ab.LicenseUrl + "\n") - f.WriteString("comment=This audiobook was created using the 'Audiobook Builder' tool: https://github.com/"+ab.Config.GetRepoOwner()+"/"+ab.Config.GetRepoName()+"\\\n" + - "The audio files used for this book were obtained from the Internet Archive site: " + ab.IaURL + "\n") - for _, chapter := range part.Chapters { f.WriteString("[CHAPTER]\n") f.WriteString("TIMEBASE=1/1000\n") @@ -218,22 +211,50 @@ func (c *BuildController) buildAudiobookPart(ab *dto.Audiobook, partId int) { Run() if err != nil { logger.Error("FFMPEG Error: " + string(err.Error())) - } else { - // add Metadata, cover image and convert to .m4b - ffmpeg := ffmpeg.NewFFmpeg(). - Input(part.AACFile, ""). - Input(part.MetadataFile, ""). - Input(ab.CoverURL, ""). - Output(part.M4BFile, "-map_metadata 1 -y -acodec copy -y -vf pad='width=ceil(iw/2)*2:height=ceil(ih/2)*2'"). - Overwrite(true). - Params("-hide_banner -nostdin -nostats"). - SendProgressTo("http://127.0.0.1:" + strconv.Itoa(port)) - - go c.killSwitch(ffmpeg) - _, err := ffmpeg.Run() - if err != nil && !c.stopFlag { - logger.Error("FFMPEG Error: " + string(err.Error())) - } + return + } + + // add chapters and convert to .m4b + ffmpeg := ffmpeg.NewFFmpeg(). + Input(part.AACFile, ""). + Input(part.MetadataFile, ""). + Output(part.M4BFile, "-map_metadata 1 -y -vn -y -acodec copy"). + Overwrite(true). + Params("-hide_banner -nostdin -nostats"). + SendProgressTo("http://127.0.0.1:" + strconv.Itoa(port)) + + go c.killSwitch(ffmpeg) + _, err = ffmpeg.Run() + if err != nil && !c.stopFlag { + logger.Error("FFMPEG Error: " + string(err.Error())) + return + } + + // clean up + os.Remove(part.AACFile) + + // add tags and cover image + m4b, er := mp4.NewMp4(part.M4BFile) + if er != nil && !c.stopFlag { + logger.Error("Can't open m4b file for write: " + err.Error()) + } + m4b.SetTag("\xa9nam", ab.Title) + m4b.SetTag("\xa9alb", ab.Title) + m4b.SetTag("\xa9ART", ab.Author) + m4b.SetTag("desc", ab.Description) + m4b.SetTag("cprt", ab.LicenseUrl) + m4b.SetTag("purl", ab.IaURL) + m4b.SetTag("\xa9cmt", "This audiobook was created using the 'Audiobook Builder' tool: https://github.com/"+ab.Config.GetRepoOwner()+"/"+ab.Config.GetRepoName()+"\n"+ + "The audio files used for this book were obtained from the Internet Archive site: "+ab.IaURL) + + imageData, er := ioutil.ReadFile(ab.CoverFile) + if er == nil { + m4b.SetImage(imageData, mp4.DataTypeJPEG) + } + + er = m4b.Save() + if er != nil { + logger.Error("Can't save m4b file: " + err.Error()) } } diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go new file mode 100644 index 0000000..a778059 --- /dev/null +++ b/internal/mp4/mp4.go @@ -0,0 +1,205 @@ +package mp4 + +import ( + "fmt" + "os" + + "github.com/abema/go-mp4" + "github.com/sunfish-shogi/bufseekio" +) + +const ( + DataTypeBinary = 0 + DataTypeStringUTF8 = 1 + DataTypeJPEG = 14 + DataTypePNG = 13 +) + +type Mp4 struct { + fileName string + mp4Tags Mp4Tags +} + +type Mp4Tags map[string]Mp4Tag +type Mp4Tag struct { + Name string + Path string + DataType uint32 + Data []byte + Exists bool +} + +func NewMp4(fileName string) (*Mp4, error) { + m4b := &Mp4{fileName: fileName} + tags, err := m4b.GetMp4Tags() + if err != nil { + return nil, err + } + m4b.mp4Tags = tags + return m4b, nil +} + +func (m4b *Mp4) GetMp4Tags() (Mp4Tags, error) { + if m4b.mp4Tags != nil { + return m4b.mp4Tags, nil + } + inputFile, _ := os.Open(m4b.fileName) + defer inputFile.Close() + tags := make(Mp4Tags) + r := bufseekio.NewReadSeeker(inputFile, 128*1024, 4) + mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) { + if h.BoxInfo.Context.UnderIlst && h.BoxInfo.Type != mp4.BoxTypeData() { + tags[h.BoxInfo.Type.String()] = Mp4Tag{ + Name: h.BoxInfo.Type.String(), + Path: getPath(h.Path), + Exists: true, + } + } else if h.BoxInfo.Context.UnderIlstMeta && h.BoxInfo.Type == mp4.BoxTypeData() { + tagName := h.Path[len(h.Path)-2].String() + tag := tags[tagName] + box, _, err := h.ReadPayload() + if err != nil && box == nil { + return nil, err + } + boxData := box.(*mp4.Data) + tag.DataType = boxData.DataType + tag.Data = boxData.Data + tags[tagName] = tag + } + if h.BoxInfo.IsSupportedType() { + h.Expand() + } + return nil, nil + }) + m4b.mp4Tags = tags + return m4b.mp4Tags, nil +} + +func (m4b *Mp4) SetMp4Tag(tag *Mp4Tag) error { + t := m4b.mp4Tags[tag.Name] + if !t.Exists { + t.Name = tag.Name + t.DataType = tag.DataType + t.Exists = false + t.Data = tag.Data + } else { + t.Data = tag.Data + } + m4b.mp4Tags[tag.Name] = t + return nil +} + +func (m4b *Mp4) SetTag(name string, tag string) error { + if len(name) != 4 { + return fmt.Errorf("tag name must be 4 characters exactly") + } + t := m4b.mp4Tags[name] + if !t.Exists { + t.Name = name + t.DataType = mp4.DataTypeStringUTF8 + t.Exists = false + t.Data = []byte(tag) + } else { + t.Data = []byte(tag) + } + m4b.mp4Tags[name] = t + return nil +} + +func (m4b *Mp4) SetImage(imageData []byte, imageType uint32) error { + t := m4b.mp4Tags["covr"] + if !t.Exists { + t.Name = "covr" + t.DataType = imageType + t.Exists = false + t.Data = imageData + } else { + t.Data = imageData + } + m4b.mp4Tags["covr"] = t + return nil +} + +func (m4b *Mp4) Save() error { + inputFileName := m4b.fileName + outputFileName := inputFileName + ".tmp" + + inputFile, err := os.Open(inputFileName) + if err != nil { + return fmt.Errorf("can't open %s: %v", inputFileName, err) + } + outputFile, err := os.OpenFile(outputFileName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("can't create temporary file %s: %v", outputFileName, err) + } + defer inputFile.Close() + defer outputFile.Close() + + r := bufseekio.NewReadSeeker(inputFile, 128*1024, 4) + w := mp4.NewWriter(outputFile) + + mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) { + if !h.BoxInfo.IsSupportedType() { + // copy all data for unsupported box types + return nil, w.CopyBox(r, &h.BoxInfo) + } + + // write moov box header + _, err := w.StartBox(&h.BoxInfo) + if err != nil { + return nil, err + } + + // read payload + box, _, err := h.ReadPayload() + if err != nil && box == nil { + return nil, err + } + for _, tag := range m4b.mp4Tags { + if h.BoxInfo.Type == mp4.BoxTypeIlst() && !tag.Exists { + // create new tag + w.StartBox(&mp4.BoxInfo{Type: mp4.StrToBoxType(tag.Name)}) // meta container + w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeData()}) // data container + dataContainer := &mp4.Data{ + DataType: tag.DataType, + DataLang: 0x00, + Data: []byte(tag.Data), + } + mp4.Marshal(w, dataContainer, mp4.Context{UnderIlst: true, UnderIlstMeta: true}) + w.EndBox() // data container + w.EndBox() // meta container + } else if getPath(h.Path) == tag.Path+"data/" && h.BoxInfo.Type == mp4.BoxTypeData() { + // update existing tag + boxData := box.(*mp4.Data) + boxData.Data = []byte(tag.Data) + } + } + + // write box playload + if _, err := mp4.Marshal(w, box, h.BoxInfo.Context); err != nil { + return nil, err + } + // expand all of offsprings + if _, err := h.Expand(); err != nil { + return nil, err + } + // rewrite box size + _, err = w.EndBox() + return nil, err + }) + + inputFile.Close() + outputFile.Close() + // rename temporary file to final one + os.Remove(inputFileName) + os.Rename(outputFileName, inputFileName) + return nil +} + +func getPath(hPath mp4.BoxPath) string { + path := "" + for _, p := range hPath { + path += p.String() + "/" + } + return path +}