Replace Google's hidden Translate TTS with official Google Cloud TTS and make it automatically generate audio after posting

This commit is contained in:
Jan-Lukas Else 2021-12-16 20:21:54 +01:00
parent 893caf8ec4
commit eb8c33704b
14 changed files with 236 additions and 288 deletions

View File

@ -27,6 +27,7 @@ type config struct {
EasterEgg *configEasterEgg `mapstructure:"easterEgg"` EasterEgg *configEasterEgg `mapstructure:"easterEgg"`
Debug bool `mapstructure:"debug"` Debug bool `mapstructure:"debug"`
MapTiles *configMapTiles `mapstructure:"mapTiles"` MapTiles *configMapTiles `mapstructure:"mapTiles"`
TTS *configTTS `mapstructure:"tts"`
initialized bool initialized bool
} }
@ -287,6 +288,11 @@ type configMapTiles struct {
MaxZoom int `mapstructure:"maxZoom"` MaxZoom int `mapstructure:"maxZoom"`
} }
type configTTS struct {
Enabled bool `mapstructure:"enabled"`
GoogleAPIKey string `mapstructure:"googleApiKey"`
}
func (a *goBlog) loadConfigFile(file string) error { func (a *goBlog) loadConfigFile(file string) error {
// Use viper to load the config file // Use viper to load the config file
v := viper.New() v := viper.New()
@ -372,6 +378,10 @@ func (a *goBlog) initConfig() error {
} }
// Check config for each blog // Check config for each blog
for _, blog := range a.cfg.Blogs { for _, blog := range a.cfg.Blogs {
// Check if language is set
if blog.Lang == "" {
blog.Lang = "en"
}
// Blogroll // Blogroll
if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" { if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" {
br.Enabled = false br.Enabled = false

View File

@ -6,4 +6,10 @@ This section of the documentation is a work in progress!
### Scheduling posts ### 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. 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.

View File

@ -136,6 +136,13 @@ mapTiles:
minZoom: 0 # (Optional) Minimum zoom level minZoom: 0 # (Optional) Minimum zoom level
maxZoom: 20 # (Optional) Maximum 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 # Blogs
defaultBlog: en # Default blog (needed because you can define multiple blogs) defaultBlog: en # Default blog (needed because you can define multiple blogs)
blogs: blogs:

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -42,12 +43,14 @@ func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r
}, },
} }
for _, p := range posts { for _, p := range posts {
var contentBuf bytes.Buffer
a.min.Write(&contentBuf, contenttype.HTML, []byte(a.feedHtml(p)))
feed.Add(&feeds.Item{ feed.Add(&feeds.Item{
Title: p.RenderedTitle, Title: p.RenderedTitle,
Link: &feeds.Link{Href: a.fullPostURL(p)}, Link: &feeds.Link{Href: a.fullPostURL(p)},
Description: a.postSummary(p), Description: a.postSummary(p),
Id: p.Path, Id: p.Path,
Content: string(a.postHtml(p, true)), Content: contentBuf.String(),
Created: timeNoErr(dateparse.ParseLocal(p.Published)), Created: timeNoErr(dateparse.ParseLocal(p.Published)),
Updated: timeNoErr(dateparse.ParseLocal(p.Updated)), Updated: timeNoErr(dateparse.ParseLocal(p.Updated)),
}) })

15
go.mod
View File

@ -14,7 +14,6 @@ require (
github.com/cretz/bine v0.2.0 github.com/cretz/bine v0.2.0
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f
github.com/dgraph-io/ristretto v0.1.0 github.com/dgraph-io/ristretto v0.1.0
github.com/dmulholl/mp3lib v1.0.0
github.com/elnormous/contenttype v1.0.0 github.com/elnormous/contenttype v1.0.0
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/emersion/go-smtp v0.15.0 github.com/emersion/go-smtp v0.15.0
@ -41,9 +40,9 @@ require (
github.com/schollz/sqlite3dump v1.3.1 github.com/schollz/sqlite3dump v1.3.1
github.com/snabb/sitemap v1.0.0 github.com/snabb/sitemap v1.0.0
github.com/spf13/cast v1.4.1 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/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/thoas/go-funk v0.9.1
github.com/tkrajina/gpxgo v1.1.2 github.com/tkrajina/gpxgo v1.1.2
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
@ -52,13 +51,13 @@ require (
// master // master
github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38 github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20211209124913-491a49abca63 golang.org/x/net v0.0.0-20211216030914-fe4d6282115f
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/text v0.3.7 golang.org/x/text v0.3.7
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
nhooyr.io/websocket v1.8.7 nhooyr.io/websocket v1.8.7
tailscale.com v1.18.1 tailscale.com v1.18.2
// main // main
willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971 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/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect
github.com/tcnksm/go-httpstat v0.2.0 // 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/u-root/uio v0.0.0-20210528114334-82958018845c // indirect
github.com/vishvananda/netlink v1.1.1-0.20211101163509-b10eb8fe5cf6 // indirect github.com/vishvananda/netlink v1.1.1-0.20211101163509-b10eb8fe5cf6 // indirect
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae // 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/mem v0.0.0-20201119185036-c04c5a6ff174 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37 // 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/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.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 // indirect
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
golang.zx2c4.com/wireguard v0.0.0-20211116201604-de7c702ace45 // indirect golang.zx2c4.com/wireguard v0.0.0-20211116201604-de7c702ace45 // indirect

30
go.sum
View File

@ -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/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 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 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 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 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= 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/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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.1 h1:nuJZuYpG7gTj/XqiUwg8bA0cp1+M2mC3J4g5luUYBKk=
github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= 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/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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/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 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= 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.24 h1:4wlbX+U5IgVa8fH//mlwzv+2g47MN7lu3s9HClAHc28=
github.com/tdewolff/minify/v2 v2.9.23/go.mod h1:4o1Mw4T3RLV0CHUny7OEnntezuwoj/FNst4QzrNxIts= github.com/tdewolff/minify/v2 v2.9.24/go.mod h1:L/bwPtsU/Xx30MxCndlClCMMiLbqROgkR4vZT+QIGXA=
github.com/tdewolff/parse/v2 v2.5.24 h1:sJPG5Viy2lq9NBbnK4KpWEA+17RNZz8EOXVqErHKHgs= github.com/tdewolff/parse/v2 v2.5.26 h1:a/q3lwDCi4GIQ+sSbs4UOHuObhqp8GHAhfqop/zDyQQ=
github.com/tdewolff/parse/v2 v2.5.24/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= 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 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4=
github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= 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-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-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-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-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 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-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-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-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-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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-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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/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-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-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-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-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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-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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= 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/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
software.sslmate.com/src/go-pkcs12 v0.0.0-20180114231543-2291e8f0f237 h1:iAEkCBPbRaflBgZ7o9gjVUuWuvWeV4sytFWg9o+Pj2k= 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.2 h1:97bYZWQ91knzR/JvP24Xz/uaq81zz/Xn47PqDdnjquc=
tailscale.com v1.18.1/go.mod h1:XzG4o2vtYFkVvmJWPaTGSaOzqlKSRx2WU+aJbrxaVE0= 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 h1:b4juh5znIpBA1KnzHMP0UB4Cs+3/0b0XfchkWE81FXw=
willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971/go.mod h1:kvVnWrkkEscVAIITCEoiTX66Hcyg59C7q0E49mb9TJ0= 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= willnorris.com/go/webmention v0.0.0-20211028201829-b0044f1a24d0 h1:3/ozQ2qGZat82ON3AYMTot3gCg/vU7tgn/LYSJbkVPM=

View File

@ -177,6 +177,7 @@ func (app *goBlog) initComponents(logging bool) {
app.initWebmention() app.initWebmention()
app.initTelegram() app.initTelegram()
app.initBlogStats() app.initBlogStats()
app.initTTS()
app.initSessions() app.initSessions()
app.initIndieAuth() app.initIndieAuth()
app.startPostsScheduler() app.startPostsScheduler()

View File

@ -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) type mediaStorageSaveFunc func(filename string, file io.Reader) (location string, err error)
func (a *goBlog) saveMediaFile(filename string, f io.Reader) (string, 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) { func (l *localMediaStorage) save(filename string, file io.Reader) (location string, err error) {
if err = os.MkdirAll(l.path, 0777); err != nil { if err = saveToFile(file, filepath.Join(l.path, filename)); 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 {
return "", err return "", err
} }
return l.location(filename), nil return l.location(filename), nil

View File

@ -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
}

View File

@ -53,12 +53,10 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML {
// Build HTML // Build HTML
var htmlBuilder strings.Builder var htmlBuilder strings.Builder
// Add audio to the top // Add audio to the top
if audio, ok := p.Parameters[a.cfg.Micropub.AudioParam]; ok && len(audio) > 0 { for _, a := range p.Parameters[a.cfg.Micropub.AudioParam] {
for _, a := range audio { htmlBuilder.WriteString(`<audio controls preload=none><source src="`)
htmlBuilder.WriteString(`<audio controls preload=none><source src="`) htmlBuilder.WriteString(a)
htmlBuilder.WriteString(a) htmlBuilder.WriteString(`"/></audio>`)
htmlBuilder.WriteString(`"/></audio>`)
}
} }
// Render markdown // Render markdown
htmlContent, err := a.renderMarkdown(p.Content, absolute) htmlContent, err := a.renderMarkdown(p.Content, absolute)
@ -68,14 +66,12 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML {
} }
htmlBuilder.Write(htmlContent) htmlBuilder.Write(htmlContent)
// Add bookmark links to the bottom // Add bookmark links to the bottom
if link, ok := p.Parameters[a.cfg.Micropub.BookmarkParam]; ok && len(link) > 0 { for _, l := range p.Parameters[a.cfg.Micropub.BookmarkParam] {
for _, l := range link { htmlBuilder.WriteString(`<p><a class=u-bookmark-of href="`)
htmlBuilder.WriteString(`<p><a class=u-bookmark-of href="`) htmlBuilder.WriteString(l)
htmlBuilder.WriteString(l) htmlBuilder.WriteString(`" target=_blank rel=noopener>`)
htmlBuilder.WriteString(`" target=_blank rel=noopener>`) htmlBuilder.WriteString(l)
htmlBuilder.WriteString(l) htmlBuilder.WriteString(`</a></p>`)
htmlBuilder.WriteString(`</a></p>`)
}
} }
// Cache // Cache
html := template.HTML(htmlBuilder.String()) html := template.HTML(htmlBuilder.String())
@ -86,6 +82,28 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML {
return 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(`<audio controls preload=none><source src="`)
htmlBuilder.WriteString(a)
htmlBuilder.WriteString(`"/></audio>`)
}
// 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(`<p><a href="`)
htmlBuilder.WriteString(a.getFullAddress(p.Path))
htmlBuilder.WriteString(`#interactions">`)
htmlBuilder.WriteString(a.ts.GetTemplateStringVariant(blogConfig.Lang, "interactions"))
htmlBuilder.WriteString(`</a></p>`)
}
return htmlBuilder.String()
}
const summaryDivider = "<!--more-->" const summaryDivider = "<!--more-->"
func (a *goBlog) postSummary(p *post) (summary string) { func (a *goBlog) postSummary(p *post) (summary string) {
@ -244,5 +262,5 @@ func (p *post) Old() bool {
} }
func (p *post) TTS() string { func (p *post) TTS() string {
return p.firstParameter("tts") return p.firstParameter(ttsParameter)
} }

View File

@ -81,6 +81,7 @@ func (a *goBlog) initRendering() error {
"mbytes": mBytesString, "mbytes": mBytesString,
"editortemplate": a.editorPostTemplate, "editortemplate": a.editorPostTemplate,
"editorpostdesc": a.editorPostDesc, "editorpostdesc": a.editorPostDesc,
"ttsenabled": a.ttsEnabled,
} }
baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt)) baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt))
if err != nil { if err != nil {

View File

@ -36,11 +36,13 @@
<input type="hidden" name="url" value="{{ .Canonical }}"> <input type="hidden" name="url" value="{{ .Canonical }}">
<input type="submit" value="{{ string .Blog.Lang "delete" }}" class="confirm" data-confirmmessage="{{ string .Blog.Lang "confirmdelete" }}"> <input type="submit" value="{{ string .Blog.Lang "delete" }}" class="confirm" data-confirmmessage="{{ string .Blog.Lang "confirmdelete" }}">
</form> </form>
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}"> {{ if ttsenabled }}
<input type="hidden" name="editoraction" value="tts"> <form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}">
<input type="hidden" name="url" value="{{ .Canonical }}"> <input type="hidden" name="editoraction" value="tts">
<input type="submit" value="{{ string .Blog.Lang "gentts" }}"> <input type="hidden" name="url" value="{{ .Canonical }}">
</form> <input type="submit" value="{{ string .Blog.Lang "gentts" }}">
</form>
{{ end }}
<script defer src="{{ asset "js/formconfirm.js" }}"></script> <script defer src="{{ asset "js/formconfirm.js" }}"></script>
</div> </div>
{{ end }} {{ end }}

219
tts.go
View File

@ -1,23 +1,57 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"sync"
"unicode"
"github.com/google/uuid" "go.goblog.app/app/pkgs/contenttype"
"go.goblog.app/app/pkgs/mp3merge"
) )
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 { func (a *goBlog) createPostTTSAudio(p *post) error {
// Get required values // Get required values
lang := a.cfg.Blogs[p.Blog].Lang lang := a.cfg.Blogs[p.Blog].Lang
@ -53,86 +87,63 @@ func (a *goBlog) createPostTTSAudio(p *post) error {
if err != nil { if err != nil {
return err return err
} }
if loc == "" {
// Set post parameter return errors.New("no media location for tts audio")
if loc != "" {
err = a.db.replacePostParam(p.Path, "tts", []string{loc})
if err != nil {
return err
}
} }
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 { // Set post parameter
// Create temporary directory err = a.db.replacePostParam(p.Path, ttsParameter, []string{loc})
tmpDir, err := os.MkdirTemp("", "")
if err != nil { if err != nil {
return err return err
} }
defer func() {
_ = os.RemoveAll(tmpDir)
}()
// Split text // Purge cache
textParts := []string{} a.cache.purge()
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
}
return nil 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 // Check parameters
if lang == "" { if lang == "" {
return errors.New("language not provided") 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") return errors.New("output file not provided")
} }
// Encode params // Create request body
ttsUrlVals := url.Values{} body := map[string]interface{}{
ttsUrlVals.Set("client", "tw-ob") "audioConfig": map[string]interface{}{
ttsUrlVals.Set("tl", lang) "audioEncoding": "MP3",
ttsUrlVals.Set("q", strings.TrimSpace(text)) },
"input": map[string]interface{}{
// Create request "text": text,
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://translate.google.com/translate_tts?"+ttsUrlVals.Encode(), nil) },
"voice": map[string]interface{}{
"languageCode": lang,
},
}
jb, err := json.Marshal(body)
if err != nil { if err != nil {
return err 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 // Do request
res, err := a.httpClient.Do(req) 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() defer res.Body.Close()
if res.StatusCode != http.StatusOK { 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 // Decode response
if err = os.MkdirAll(path.Dir(outputFile), os.ModePerm); err != nil { var content map[string]interface{}
if err = json.NewDecoder(res.Body).Decode(&content); err != nil {
return err return err
} }
out, err := os.Create(outputFile) if encoded, ok := content["audioContent"]; ok {
if err != nil { if encodedStr, ok := encoded.(string); ok {
return err if audio, err := base64.StdEncoding.DecodeString(encodedStr); err == nil {
os.WriteFile(outputFile, audio, os.ModePerm)
return nil
} else {
return err
}
}
} }
defer func() { return errors.New("no audio content")
_ = out.Close()
}()
if _, err = io.Copy(out, res.Body); err != nil {
return err
}
return nil
} }

View File

@ -9,7 +9,9 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path" "path"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -309,3 +311,19 @@ func doHandlerRequest(req *http.Request, handler http.Handler) (*http.Response,
} }
return client.Do(req) 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
}