mirror of https://github.com/jlelse/GoBlog
Replace Google's hidden Translate TTS with official Google Cloud TTS and make it automatically generate audio after posting
This commit is contained in:
parent
893caf8ec4
commit
eb8c33704b
10
config.go
10
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
|
||||
|
|
|
@ -7,3 +7,9 @@ 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.
|
||||
|
||||
## 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.
|
|
@ -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:
|
||||
|
|
5
feeds.go
5
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)),
|
||||
})
|
||||
|
|
15
go.mod
15
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
|
||||
|
|
30
go.sum
30
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=
|
||||
|
|
1
main.go
1
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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
|
@ -53,13 +53,11 @@ 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 {
|
||||
for _, a := range p.Parameters[a.cfg.Micropub.AudioParam] {
|
||||
htmlBuilder.WriteString(`<audio controls preload=none><source src="`)
|
||||
htmlBuilder.WriteString(a)
|
||||
htmlBuilder.WriteString(`"/></audio>`)
|
||||
}
|
||||
}
|
||||
// Render markdown
|
||||
htmlContent, err := a.renderMarkdown(p.Content, absolute)
|
||||
if err != nil {
|
||||
|
@ -68,15 +66,13 @@ 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 {
|
||||
for _, l := range p.Parameters[a.cfg.Micropub.BookmarkParam] {
|
||||
htmlBuilder.WriteString(`<p><a class=u-bookmark-of href="`)
|
||||
htmlBuilder.WriteString(l)
|
||||
htmlBuilder.WriteString(`" target=_blank rel=noopener>`)
|
||||
htmlBuilder.WriteString(l)
|
||||
htmlBuilder.WriteString(`</a></p>`)
|
||||
}
|
||||
}
|
||||
// Cache
|
||||
html := template.HTML(htmlBuilder.String())
|
||||
if p.renderCache == nil {
|
||||
|
@ -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(`<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-->"
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -36,11 +36,13 @@
|
|||
<input type="hidden" name="url" value="{{ .Canonical }}">
|
||||
<input type="submit" value="{{ string .Blog.Lang "delete" }}" class="confirm" data-confirmmessage="{{ string .Blog.Lang "confirmdelete" }}">
|
||||
</form>
|
||||
{{ if ttsenabled }}
|
||||
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}">
|
||||
<input type="hidden" name="editoraction" value="tts">
|
||||
<input type="hidden" name="url" value="{{ .Canonical }}">
|
||||
<input type="submit" value="{{ string .Blog.Lang "gentts" }}">
|
||||
</form>
|
||||
{{ end }}
|
||||
<script defer src="{{ asset "js/formconfirm.js" }}"></script>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
217
tts.go
217
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
|
||||
}
|
||||
if loc == "" {
|
||||
return errors.New("no media location for tts audio")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Set post parameter
|
||||
if loc != "" {
|
||||
err = a.db.replacePostParam(p.Path, "tts", []string{loc})
|
||||
err = a.db.replacePostParam(p.Path, ttsParameter, []string{loc})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Purge cache
|
||||
a.cache.purge()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Create temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "")
|
||||
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
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Merge MP3s
|
||||
if err = mp3merge.MergeMP3(outputFile, allFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile string) error {
|
||||
// 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
|
||||
}
|
||||
defer func() {
|
||||
_ = out.Close()
|
||||
}()
|
||||
if _, err = io.Copy(out, res.Body); 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
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.New("no audio content")
|
||||
}
|
||||
|
|
18
utils.go
18
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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue