diff --git a/config.go b/config.go index ae4c767..a6c5564 100644 --- a/config.go +++ b/config.go @@ -27,6 +27,7 @@ type config struct { EasterEgg *configEasterEgg `mapstructure:"easterEgg"` Debug bool `mapstructure:"debug"` MapTiles *configMapTiles `mapstructure:"mapTiles"` + TTS *configTTS `mapstructure:"tts"` initialized bool } @@ -287,6 +288,11 @@ type configMapTiles struct { MaxZoom int `mapstructure:"maxZoom"` } +type configTTS struct { + Enabled bool `mapstructure:"enabled"` + GoogleAPIKey string `mapstructure:"googleApiKey"` +} + func (a *goBlog) loadConfigFile(file string) error { // Use viper to load the config file v := viper.New() @@ -372,6 +378,10 @@ func (a *goBlog) initConfig() error { } // Check config for each blog for _, blog := range a.cfg.Blogs { + // Check if language is set + if blog.Lang == "" { + blog.Lang = "en" + } // Blogroll if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" { br.Enabled = false diff --git a/docs/usage.md b/docs/usage.md index 2464074..a496030 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,4 +6,10 @@ This section of the documentation is a work in progress! ### Scheduling posts -To schedule a post, create a post with `status: scheduled` and set the `published` field to the desired date. A scheduler runs in the background and checks every 10 seconds if a scheduled post should be published. If there's a post to publish, the post status is changed to `published`. That will also trigger configured hooks. Scheduled posts are only visible when logged in. \ No newline at end of file +To schedule a post, create a post with `status: scheduled` and set the `published` field to the desired date. A scheduler runs in the background and checks every 10 seconds if a scheduled post should be published. If there's a post to publish, the post status is changed to `published`. That will also trigger configured hooks. Scheduled posts are only visible when logged in. + +## Text-to-Speech + +GoBlog features a button on each post that allows you to read the post's content aloud. By default, that uses an API from the browser to generate the speech. But it's not available on all browsers and on some operating systems it sounds horrible. + +There's also the possibility to configure GoBlog to use Google Cloud's Text-to-Speech API. For that take a look at the `example-config.yml` file. If configured and enabled, after publishing a post, GoBlog will automatically generate an audio file, save it to the configured media storage (local file storage by default) and safe the audio file URL to the post's `tts` parameter. After updating a post, you can manually regenerate the audio file by using the button on the post. When deleting a post or regenerating the audio, GoBlog tries to delete the old audio file as well. \ No newline at end of file diff --git a/example-config.yml b/example-config.yml index 395c1c9..b601db8 100644 --- a/example-config.yml +++ b/example-config.yml @@ -136,6 +136,13 @@ mapTiles: minZoom: 0 # (Optional) Minimum zoom level maxZoom: 20 # (Optional) Maximum zoom level +# Text-to-Speech (not just using the browser API, but Google Cloud's TTS-API) +# If enabled, it will automatically generate a TTS audio file after publishing a public post that has a section as well +# It's possible to regenerate the audio at any time. That will also try and delete previously generated TTS audio files +tts: + enabled: true + googleApiKey: "xxxxxxxx" + # Blogs defaultBlog: en # Default blog (needed because you can define multiple blogs) blogs: diff --git a/feeds.go b/feeds.go index fb52df9..71dda79 100644 --- a/feeds.go +++ b/feeds.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "net/http" "strings" "time" @@ -42,12 +43,14 @@ func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r }, } for _, p := range posts { + var contentBuf bytes.Buffer + a.min.Write(&contentBuf, contenttype.HTML, []byte(a.feedHtml(p))) feed.Add(&feeds.Item{ Title: p.RenderedTitle, Link: &feeds.Link{Href: a.fullPostURL(p)}, Description: a.postSummary(p), Id: p.Path, - Content: string(a.postHtml(p, true)), + Content: contentBuf.String(), Created: timeNoErr(dateparse.ParseLocal(p.Published)), Updated: timeNoErr(dateparse.ParseLocal(p.Updated)), }) diff --git a/go.mod b/go.mod index 27bff41..110020f 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/cretz/bine v0.2.0 github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f github.com/dgraph-io/ristretto v0.1.0 - github.com/dmulholl/mp3lib v1.0.0 github.com/elnormous/contenttype v1.0.0 github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac github.com/emersion/go-smtp v0.15.0 @@ -41,9 +40,9 @@ require ( github.com/schollz/sqlite3dump v1.3.1 github.com/snabb/sitemap v1.0.0 github.com/spf13/cast v1.4.1 - github.com/spf13/viper v1.10.0 + github.com/spf13/viper v1.10.1 github.com/stretchr/testify v1.7.0 - github.com/tdewolff/minify/v2 v2.9.23 + github.com/tdewolff/minify/v2 v2.9.24 github.com/thoas/go-funk v0.9.1 github.com/tkrajina/gpxgo v1.1.2 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 @@ -52,13 +51,13 @@ require ( // master github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38 github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 - golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b - golang.org/x/net v0.0.0-20211209124913-491a49abca63 + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 + golang.org/x/net v0.0.0-20211216030914-fe4d6282115f golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/text v0.3.7 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b nhooyr.io/websocket v1.8.7 - tailscale.com v1.18.1 + tailscale.com v1.18.2 // main willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971 ) @@ -112,7 +111,7 @@ require ( github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect - github.com/tdewolff/parse/v2 v2.5.24 // indirect + github.com/tdewolff/parse/v2 v2.5.26 // indirect github.com/u-root/uio v0.0.0-20210528114334-82958018845c // indirect github.com/vishvananda/netlink v1.1.1-0.20211101163509-b10eb8fe5cf6 // indirect github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae // indirect @@ -120,7 +119,7 @@ require ( go4.org/mem v0.0.0-20201119185036-c04c5a6ff174 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37 // indirect golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect - golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect + golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 // indirect golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect golang.zx2c4.com/wireguard v0.0.0-20211116201604-de7c702ace45 // indirect diff --git a/go.sum b/go.sum index 3fc561f..7c93879 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,6 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dmulholl/mp3lib v1.0.0 h1:PZq24kJBIk5zIxi/t6Qp8/EOAbAqThyrUCpkUKLBeWQ= -github.com/dmulholl/mp3lib v1.0.0/go.mod h1:4RoA+iht/khfwxmH1ieoxZTzYVbb0am/zdvFkyGRr6I= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= @@ -368,8 +366,8 @@ github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmq github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.10.0 h1:mXH0UwHS4D2HwWZa75im4xIQynLfblmWV7qcWpfv0yk= -github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= +github.com/spf13/viper v1.10.1 h1:nuJZuYpG7gTj/XqiUwg8bA0cp1+M2mC3J4g5luUYBKk= +github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -387,10 +385,10 @@ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= -github.com/tdewolff/minify/v2 v2.9.23 h1:UrLltJpnJPm7/fYFP3Ue/GD5tHufx2z7ERQihACLkmg= -github.com/tdewolff/minify/v2 v2.9.23/go.mod h1:4o1Mw4T3RLV0CHUny7OEnntezuwoj/FNst4QzrNxIts= -github.com/tdewolff/parse/v2 v2.5.24 h1:sJPG5Viy2lq9NBbnK4KpWEA+17RNZz8EOXVqErHKHgs= -github.com/tdewolff/parse/v2 v2.5.24/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= +github.com/tdewolff/minify/v2 v2.9.24 h1:4wlbX+U5IgVa8fH//mlwzv+2g47MN7lu3s9HClAHc28= +github.com/tdewolff/minify/v2 v2.9.24/go.mod h1:L/bwPtsU/Xx30MxCndlClCMMiLbqROgkR4vZT+QIGXA= +github.com/tdewolff/parse/v2 v2.5.26 h1:a/q3lwDCi4GIQ+sSbs4UOHuObhqp8GHAhfqop/zDyQQ= +github.com/tdewolff/parse/v2 v2.5.26/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4= github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= @@ -441,8 +439,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= -golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -517,8 +515,8 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211209124913-491a49abca63 h1:iocB37TsdFuN6IBRZ+ry36wrkoV51/tl5vOWqkcPGvY= -golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM= +golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -592,8 +590,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E= -golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= @@ -780,8 +778,8 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= software.sslmate.com/src/go-pkcs12 v0.0.0-20180114231543-2291e8f0f237 h1:iAEkCBPbRaflBgZ7o9gjVUuWuvWeV4sytFWg9o+Pj2k= -tailscale.com v1.18.1 h1:3hkMsdpREdz2w0O3YcmOgJkl95ChTT4Dje7wq8prD/E= -tailscale.com v1.18.1/go.mod h1:XzG4o2vtYFkVvmJWPaTGSaOzqlKSRx2WU+aJbrxaVE0= +tailscale.com v1.18.2 h1:97bYZWQ91knzR/JvP24Xz/uaq81zz/Xn47PqDdnjquc= +tailscale.com v1.18.2/go.mod h1:XzG4o2vtYFkVvmJWPaTGSaOzqlKSRx2WU+aJbrxaVE0= willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971 h1:b4juh5znIpBA1KnzHMP0UB4Cs+3/0b0XfchkWE81FXw= willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971/go.mod h1:kvVnWrkkEscVAIITCEoiTX66Hcyg59C7q0E49mb9TJ0= willnorris.com/go/webmention v0.0.0-20211028201829-b0044f1a24d0 h1:3/ozQ2qGZat82ON3AYMTot3gCg/vU7tgn/LYSJbkVPM= diff --git a/main.go b/main.go index b1e42b2..e8988cd 100644 --- a/main.go +++ b/main.go @@ -177,6 +177,7 @@ func (app *goBlog) initComponents(logging bool) { app.initWebmention() app.initTelegram() app.initBlogStats() + app.initTTS() app.initSessions() app.initIndieAuth() app.startPostsScheduler() diff --git a/mediaStorage.go b/mediaStorage.go index 00f65ba..f4abc37 100644 --- a/mediaStorage.go +++ b/mediaStorage.go @@ -24,6 +24,11 @@ func (a *goBlog) initMediaStorage() { }) } +func (a *goBlog) mediaStorageEnabled() bool { + a.initMediaStorage() + return a.mediaStorage != nil +} + type mediaStorageSaveFunc func(filename string, file io.Reader) (location string, err error) func (a *goBlog) saveMediaFile(filename string, f io.Reader) (string, error) { @@ -92,14 +97,7 @@ func (a *goBlog) initLocalMediaStorage() mediaStorage { } func (l *localMediaStorage) save(filename string, file io.Reader) (location string, err error) { - if err = os.MkdirAll(l.path, 0777); err != nil { - return "", err - } - newFile, err := os.Create(filepath.Join(l.path, filename)) - if err != nil { - return "", err - } - if _, err = io.Copy(newFile, file); err != nil { + if err = saveToFile(file, filepath.Join(l.path, filename)); err != nil { return "", err } return l.location(filename), nil diff --git a/pkgs/mp3merge/mp3merge.go b/pkgs/mp3merge/mp3merge.go deleted file mode 100644 index 8ded502..0000000 --- a/pkgs/mp3merge/mp3merge.go +++ /dev/null @@ -1,136 +0,0 @@ -package mp3merge - -import ( - "errors" - "io" - "os" - "path/filepath" - - "github.com/dmulholl/mp3lib" - "github.com/thoas/go-funk" -) - -// Inspired by https://github.com/dmulholl/mp3cat/blob/2ec1e4fe4d995ebd41bf1887b3cab8e2a569b3d4/mp3cat.go - -// Merge multiple mp3 files into one file -func MergeMP3(out string, in []string) error { - - var totalFrames, totalBytes uint32 - var firstBitRate int - var isVBR bool - - // Check if output file is included in input files - if funk.ContainsString(in, out) { - return errors.New("the list of input files includes the output file") - } - - // Create the output file. - if err := os.MkdirAll(filepath.Dir(out), os.ModePerm); err != nil { - return err - } - outfile, err := os.Create(out) - if err != nil { - return err - } - - // Loop over the input files and append their MP3 frames to the output file. - for _, inpath := range in { - infile, err := os.Open(inpath) - if err != nil { - return err - } - - isFirstFrame := true - - for { - // Read the next frame from the input file. - frame := mp3lib.NextFrame(infile) - if frame == nil { - break - } - - // Skip the first frame if it's a VBR header. - if isFirstFrame { - isFirstFrame = false - if mp3lib.IsXingHeader(frame) || mp3lib.IsVbriHeader(frame) { - continue - } - } - - // If we detect more than one bitrate we'll need to add a VBR - // header to the output file. - if firstBitRate == 0 { - firstBitRate = frame.BitRate - } else if frame.BitRate != firstBitRate { - isVBR = true - } - - // Write the frame to the output file. - _, err := outfile.Write(frame.RawBytes) - if err != nil { - return err - } - - totalFrames += 1 - totalBytes += uint32(len(frame.RawBytes)) - } - - _ = infile.Close() - } - - _ = outfile.Close() - - // If we detected multiple bitrates, prepend a VBR header to the file. - if isVBR { - err = addXingHeader(out, totalFrames, totalBytes) - if err != nil { - return err - } - } - - return nil - -} - -// Prepend an Xing VBR header to the specified MP3 file. -func addXingHeader(filepath string, totalFrames, totalBytes uint32) error { - tmpSuffix := ".mp3merge.tmp" - - outputFile, err := os.Create(filepath + tmpSuffix) - if err != nil { - return err - } - - inputFile, err := os.Open(filepath) - if err != nil { - return err - } - - xingHeader := mp3lib.NewXingHeader(totalFrames, totalBytes) - - _, err = outputFile.Write(xingHeader.RawBytes) - if err != nil { - return err - } - - _, err = io.Copy(outputFile, inputFile) - if err != nil { - return err - } - - _ = outputFile.Close() - _ = inputFile.Close() - - err = os.Remove(filepath) - if err != nil { - return err - } - - err = os.Rename(filepath+tmpSuffix, filepath) - if err != nil { - return err - } - - return nil - -} diff --git a/postsFuncs.go b/postsFuncs.go index 37b15fc..a10926d 100644 --- a/postsFuncs.go +++ b/postsFuncs.go @@ -53,12 +53,10 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML { // Build HTML var htmlBuilder strings.Builder // Add audio to the top - if audio, ok := p.Parameters[a.cfg.Micropub.AudioParam]; ok && len(audio) > 0 { - for _, a := range audio { - htmlBuilder.WriteString(``) - } + for _, a := range p.Parameters[a.cfg.Micropub.AudioParam] { + htmlBuilder.WriteString(``) } // Render markdown htmlContent, err := a.renderMarkdown(p.Content, absolute) @@ -68,14 +66,12 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML { } htmlBuilder.Write(htmlContent) // Add bookmark links to the bottom - if link, ok := p.Parameters[a.cfg.Micropub.BookmarkParam]; ok && len(link) > 0 { - for _, l := range link { - htmlBuilder.WriteString(`

`) - htmlBuilder.WriteString(l) - htmlBuilder.WriteString(`

`) - } + for _, l := range p.Parameters[a.cfg.Micropub.BookmarkParam] { + htmlBuilder.WriteString(`

`) + htmlBuilder.WriteString(l) + htmlBuilder.WriteString(`

`) } // Cache html := template.HTML(htmlBuilder.String()) @@ -86,6 +82,28 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML { return html } +func (a *goBlog) feedHtml(p *post) string { + var htmlBuilder strings.Builder + // Add TTS audio to the top + for _, a := range p.Parameters[ttsParameter] { + htmlBuilder.WriteString(``) + } + // Add post HTML + htmlBuilder.WriteString(string(a.postHtml(p, true))) + // Add link to interactions and comments + blogConfig := a.cfg.Blogs[defaultIfEmpty(p.Blog, a.cfg.DefaultBlog)] + if cc := blogConfig.Comments; cc != nil && cc.Enabled { + htmlBuilder.WriteString(`

`) + htmlBuilder.WriteString(a.ts.GetTemplateStringVariant(blogConfig.Lang, "interactions")) + htmlBuilder.WriteString(`

`) + } + return htmlBuilder.String() +} + const summaryDivider = "" func (a *goBlog) postSummary(p *post) (summary string) { @@ -244,5 +262,5 @@ func (p *post) Old() bool { } func (p *post) TTS() string { - return p.firstParameter("tts") + return p.firstParameter(ttsParameter) } diff --git a/render.go b/render.go index d4f60e2..30efe1f 100644 --- a/render.go +++ b/render.go @@ -81,6 +81,7 @@ func (a *goBlog) initRendering() error { "mbytes": mBytesString, "editortemplate": a.editorPostTemplate, "editorpostdesc": a.editorPostDesc, + "ttsenabled": a.ttsEnabled, } baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt)) if err != nil { diff --git a/templates/post.gohtml b/templates/post.gohtml index 775ef38..7283cb3 100644 --- a/templates/post.gohtml +++ b/templates/post.gohtml @@ -36,11 +36,13 @@ -
- - - -
+ {{ if ttsenabled }} +
+ + + +
+ {{ end }} {{ end }} diff --git a/tts.go b/tts.go index 44a7b60..1c1619d 100644 --- a/tts.go +++ b/tts.go @@ -1,23 +1,57 @@ package main import ( + "bytes" "context" + "encoding/base64" + "encoding/json" "errors" "fmt" - "io" + "log" "net/http" "net/url" "os" "path" "path/filepath" - "strings" - "sync" - "unicode" - "github.com/google/uuid" - "go.goblog.app/app/pkgs/mp3merge" + "go.goblog.app/app/pkgs/contenttype" ) +const ttsParameter = "tts" + +func (a *goBlog) initTTS() { + if !a.ttsEnabled() { + return + } + createOrUpdate := func(p *post) { + // Automatically create audio for published section posts only + if !p.isPublishedSectionPost() { + return + } + // Check if there is already a tts audio file + if p.firstParameter(ttsParameter) != "" { + return + } + // Create TTS audio + err := a.createPostTTSAudio(p) + if err != nil { + log.Printf("create post audio for %s failed: %v", p.Path, err) + } + } + a.pPostHooks = append(a.pPostHooks, createOrUpdate) + a.pUpdateHooks = append(a.pUpdateHooks, createOrUpdate) + a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) { + // Try to delete the audio file + _ = a.deletePostTTSAudio(p) + }) +} + +func (a *goBlog) ttsEnabled() bool { + tts := a.cfg.TTS + // Requires media storage as well + return tts != nil && tts.Enabled && tts.GoogleAPIKey != "" && a.mediaStorageEnabled() +} + func (a *goBlog) createPostTTSAudio(p *post) error { // Get required values lang := a.cfg.Blogs[p.Blog].Lang @@ -53,86 +87,63 @@ func (a *goBlog) createPostTTSAudio(p *post) error { if err != nil { return err } - - // Set post parameter - if loc != "" { - err = a.db.replacePostParam(p.Path, "tts", []string{loc}) - if err != nil { - return err - } + if loc == "" { + return errors.New("no media location for tts audio") } - return nil -} + if old := p.firstParameter(ttsParameter); old != "" && old != loc { + // Already has tts audio, but with different location + // Try to delete the old audio file + _ = a.deletePostTTSAudio(p) + } -func (a *goBlog) createTTSAudio(lang, text, outputFile string) error { - // Create temporary directory - tmpDir, err := os.MkdirTemp("", "") + // Set post parameter + err = a.db.replacePostParam(p.Path, ttsParameter, []string{loc}) if err != nil { return err } - defer func() { - _ = os.RemoveAll(tmpDir) - }() - // Split text - textParts := []string{} - var textPartBuilder strings.Builder - textRunes := []rune(text) - for i, r := range textRunes { - textPartBuilder.WriteRune(r) - newText := false - if strings.ContainsRune(",.:!?)", r) && i+1 < len(textRunes) && unicode.IsSpace(textRunes[i+1]) { - newText = true - } else if r == '\n' { - newText = true - } else if textPartBuilder.Len() > 500 && unicode.IsSpace(r) { - newText = true - } - if newText { - textParts = append(textParts, textPartBuilder.String()) - textPartBuilder.Reset() - } - } - textParts = append(textParts, textPartBuilder.String()) - - // Start request for every text part - allFiles := []string{} - var wg sync.WaitGroup - var ttsErr error - ctx, cancel := context.WithCancel(context.Background()) - for _, s := range textParts { - s := strings.TrimSpace(s) - if s == "" { - continue - } - fileName := filepath.Join(tmpDir, uuid.NewString()+".mp3") - allFiles = append(allFiles, fileName) - wg.Add(1) - go func() { - defer wg.Done() - err := a.downloadTTSAudio(ctx, lang, s, fileName) - if err != nil && ttsErr == nil { - ttsErr = err - cancel() - } - }() - } - wg.Wait() - cancel() - if ttsErr != nil { - return ttsErr - } - - // Merge MP3s - if err = mp3merge.MergeMP3(outputFile, allFiles); err != nil { - return err - } + // Purge cache + a.cache.purge() return nil } -func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile string) error { +// Tries to delete the tts audio file, but doesn't remove the post parameter +func (a *goBlog) deletePostTTSAudio(p *post) bool { + // Check if post has tts audio + audio := p.firstParameter(ttsParameter) + if audio == "" { + return false + } + // Get filename and check if file is from the configured media storage + fileUrl, err := url.Parse(audio) + if err != nil { + // Failed to parse audio url + log.Println("failed to parse audio url:", err) + return false + } + fileName := path.Base(fileUrl.Path) + if a.getFullAddress(a.mediaFileLocation(fileName)) != audio { + // File is not from the configured media storage + return false + } + // Try to delete the audio file + err = a.deleteMediaFile(fileName) + if err != nil { + log.Println("failed to delete audio file:", err) + return false + } + return true +} + +func (a *goBlog) createTTSAudio(lang, text, outputFile string) error { + // Check if Google Cloud TTS is enabled + gctts := a.cfg.TTS + if !gctts.Enabled || gctts.GoogleAPIKey == "" { + return errors.New("missing config for Google Cloud TTS") + } + // Check parameters if lang == "" { return errors.New("language not provided") @@ -144,18 +155,30 @@ func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile st return errors.New("output file not provided") } - // Encode params - ttsUrlVals := url.Values{} - ttsUrlVals.Set("client", "tw-ob") - ttsUrlVals.Set("tl", lang) - ttsUrlVals.Set("q", strings.TrimSpace(text)) - - // Create request - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://translate.google.com/translate_tts?"+ttsUrlVals.Encode(), nil) + // Create request body + body := map[string]interface{}{ + "audioConfig": map[string]interface{}{ + "audioEncoding": "MP3", + }, + "input": map[string]interface{}{ + "text": text, + }, + "voice": map[string]interface{}{ + "languageCode": lang, + }, + } + jb, err := json.Marshal(body) if err != nil { return err } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0") + + // Create request + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://texttospeech.googleapis.com/v1beta1/text:synthesize?key="+gctts.GoogleAPIKey, bytes.NewReader(jb)) + if err != nil { + return err + } + req.Header.Set(contentType, contenttype.JSON) + req.Header.Set(userAgent, appUserAgent) // Do request res, err := a.httpClient.Do(req) @@ -164,23 +187,23 @@ func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile st } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return fmt.Errorf("TTS: got status: %s, text: %s", res.Status, text) + return fmt.Errorf("got status: %s, text: %s", res.Status, text) } - // Save response - if err = os.MkdirAll(path.Dir(outputFile), os.ModePerm); err != nil { + // Decode response + var content map[string]interface{} + if err = json.NewDecoder(res.Body).Decode(&content); err != nil { return err } - out, err := os.Create(outputFile) - if err != nil { - return err + if encoded, ok := content["audioContent"]; ok { + if encodedStr, ok := encoded.(string); ok { + if audio, err := base64.StdEncoding.DecodeString(encodedStr); err == nil { + os.WriteFile(outputFile, audio, os.ModePerm) + return nil + } else { + return err + } + } } - defer func() { - _ = out.Close() - }() - if _, err = io.Copy(out, res.Body); err != nil { - return err - } - - return nil + return errors.New("no audio content") } diff --git a/utils.go b/utils.go index b78588c..c51cf3f 100644 --- a/utils.go +++ b/utils.go @@ -9,7 +9,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "path" + "path/filepath" "sort" "strings" "time" @@ -309,3 +311,19 @@ func doHandlerRequest(req *http.Request, handler http.Handler) (*http.Response, } return client.Do(req) } + +func saveToFile(reader io.Reader, fileName string) error { + // Create folder path if not exists + if err := os.MkdirAll(filepath.Dir(fileName), os.ModePerm); err != nil { + return err + } + // Create file + out, err := os.Create(fileName) + if err != nil { + return err + } + // Copy response to file + defer out.Close() + _, err = io.Copy(out, reader) + return err +}