Ready for early adopter release
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details

Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
This commit is contained in:
Martyn 2020-07-16 12:34:29 +02:00
parent d13c00eff0
commit ee0b2328b2
7 changed files with 235 additions and 56 deletions

View File

@ -1,14 +1,14 @@
irc:
password: ENC[AES256_GCM,data:bmVyAcxlCKaL8MkttQWipktXkoU637pSRuEZHETvkFBdRX4p,iv:NSZ6TtlXcxy7cUtKZ1MrB4hAX4FwNV5LuPNViFd0jh0=,tag:4Ex7p59w2FFkFkp4yEhZ0w==,type:str]
password: ENC[AES256_GCM,data:oxd4te1D31VaTea8a+fw8DgiPQBuaIAyYjKTRUL9ywR+DPGY,iv:ZV9agOpxGzu+wK/X8ThXHQgNjkytKpqf8I7kcMtVBcQ=,tag:1ntk+4gVQfj9g1CNYpuh0g==,type:str]
twitchapp:
client_id: ENC[AES256_GCM,data:Iy1Wb5WevXQtDpDPTKKkpYez+WWOGOUDre89qf1Y,iv:z/ZbcgADO5vDQOjX/A6xU99lGiD2k3qxor31KnVFE4o=,tag:pgf67HdxOjUSWEktgPfolw==,type:str]
client_secret: ENC[AES256_GCM,data:5fY77ccyfqZMh30hMdhXy1h7fio4763NLgYu9tgP,iv:kLyEULWcgKHwpPxoBX69IcHAaeAob9AN8gD5/K5nIyg=,tag:nxtZMuCZdGyVhBBvgRz2SA==,type:str]
client_id: ENC[AES256_GCM,data:XlpiZQX6oY/frhlOg/sA85AuB1JmlLpHX2yBp84y,iv:SI/elJatih9J289rYXi+wzyYy38SAyt+DfHqWfQy56c=,tag:LIq3dYLAd/isLygBsh2gYw==,type:str]
client_secret: ENC[AES256_GCM,data:E5zgitQ0cq+MDQsc5gjAyShwgoRS4TwewzLRus7M,iv:9TKxA2sASAvwbUOVVB3UPgowyBjUmLJA/tJCEpt2XGc=,tag:CIZw2pf3mBxASQLO48zJtQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
lastmodified: '2020-07-14T18:03:02Z'
mac: ENC[AES256_GCM,data:quUrWcjviC08daW0k+Y0a6oB+ZXT8NkF2z1cni2DeFUuSXjrxDQcMY3POj45DbzHrojXMkJKOk6JLOcj9oV1h8uCqyZ/rGWhgfly1YNLJEP8LcJbCQt2jUAhctxaQkvPtqIak7e/ABKHhMuKmBbNWzhTLBbyl2/YR2+pPsLxKQA=,iv:PbRPKBaLtns4+M0ydrVTecVHgtR3q7TkhKWN01cEhVE=,tag:nCK7gSQ+F97lJJhCOol7zw==,type:str]
lastmodified: '2020-07-15T20:38:41Z'
mac: ENC[AES256_GCM,data:8zIsya5YjfW/R1xD8Q2B1yIoXEOXJsDCqAeicFyNL6zkxPSCZSv5hJfBDH0kmNuAfR9oG7+XCqQbhNM8bVrNWbhYILc8aJuexth67riuVibWvg0r2xdMnozJYvJ6u1rRJ9xwRtYvBzjqK6SMuq5mkD2uq7wcmJr0rNoWjnXhuVc=,iv:wMQ8HgfskVGlt+x5t+ah+Yo1b0gbV/6jE1APBrxhdS8=,tag:JZXTby+v8meFEwbSBOyWlg==,type:str]
pgp:
- created_at: '2020-07-14T18:02:04Z'
enc: |

View File

@ -35,7 +35,7 @@ securityContext: {}
secretFiles: {}
irc:
nick: twitchsingstools
nick: tstools
twitchapp: {}
@ -45,7 +45,7 @@ service:
type: ClusterIP
port: 80
externalHostname: twitchsingstools.ing.martyn.berlin
externalHostname: twitchsingstools.martyn.berlin
ingress:
enabled: true
@ -56,10 +56,16 @@ ingress:
- host: twitchsingstools.ing.martyn.berlin
paths:
- /
- host: twitchsingstools.martyn.berlin
paths:
- /
tls:
- secretName: tstools-tls
hosts:
- twitchsingstools.ing.martyn.berlin
- secretName: tstools-secondary-tls
hosts:
- twitchsingstools.martyn.berlin
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious

View File

@ -106,6 +106,7 @@ type ChannelData struct {
VideoCache []SingsVideoStruct `json:"videoCache"`
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
Bearer string `json:"bearer"`
TwitchUserID string `json:"twitchUserID"`
}
// Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it
@ -159,6 +160,13 @@ func (bb *KardBot) UpdateBearerToken(user string, token string) {
bb.ChannelData[user] = record
}
func (bb *KardBot) UpdateTwitchUserID(user string, userid string) {
record := bb.ChannelData[user]
record.TwitchUserID = userid
bb.Database.Write("channelData", user, record)
bb.ChannelData[user] = record
}
// Listens for and logs messages from chat. Responds to commands from the channel owner. The bot
// continues until it gets disconnected, told to shutdown, or forcefully shutdown.
func (bb *KardBot) HandleChat() error {

View File

@ -108,10 +108,6 @@ func TemplateHandler(response http.ResponseWriter, request *http.Request) {
ClientID string
BaseURI string
}
// tmpl, err := template.New("html"+request.URL.Path).Funcs(template.FuncMap{
// "ToUpper": strings.ToUpper,
// "ToLower": strings.ToLower,
// }).ParseFiles("html"+request.URL.Path)
_ = strings.ToLower("Hello")
if strings.Index(request.URL.Path, "/") < 0 {
http.Error(response, "No slashes wat - "+request.URL.Path, http.StatusInternalServerError)
@ -151,6 +147,73 @@ func humanTimeFromTimeString(s string) string {
// return humanize.Time(d)
}
type AugmentedSingsVideoStruct struct {
Date time.Time
NiceDate string
FullTitle string
Duet bool
OtherSinger string
SongTitle string
LastSungSong time.Time
NiceLastSungSong string
LastSungSinger time.Time
NiceLastSungSinger string
}
func AugmentSingsVideoStructForCSV(input irc.SingsVideoStruct) AugmentedSingsVideoStruct {
var ret AugmentedSingsVideoStruct
ret.Date = input.Date
ret.NiceDate = input.Date.Format("2006-01-02 15:04:05")
ret.FullTitle = input.FullTitle
ret.Duet = input.Duet
ret.OtherSinger = input.OtherSinger
ret.SongTitle = input.SongTitle
ret.LastSungSong = input.LastSungSong
ret.NiceLastSungSong = input.LastSungSong.Format("2006-01-02 15:04:05")
ret.LastSungSinger = input.LastSungSinger
if !ret.Duet {
ret.NiceLastSungSinger = "Solo performance"
} else {
ret.NiceLastSungSinger = input.LastSungSinger.Format("2006-01-02 15:04:05")
}
return ret
}
func AugmentSingsVideoStructSliceForCSV(input []irc.SingsVideoStruct) []AugmentedSingsVideoStruct {
ret := make([]AugmentedSingsVideoStruct, 0)
for _, record := range input {
ret = append(ret, AugmentSingsVideoStructForCSV(record))
}
return ret
}
func AugmentSingsVideoStruct(input irc.SingsVideoStruct) AugmentedSingsVideoStruct {
var ret AugmentedSingsVideoStruct
ret.Date = input.Date
ret.NiceDate = humanize.Time(input.Date)
ret.FullTitle = input.FullTitle
ret.Duet = input.Duet
ret.OtherSinger = input.OtherSinger
ret.SongTitle = input.SongTitle
ret.LastSungSong = input.LastSungSong
ret.NiceLastSungSong = humanize.Time(input.LastSungSong)
ret.LastSungSinger = input.LastSungSinger
if !ret.Duet {
ret.NiceLastSungSinger = "Solo performance"
} else {
ret.NiceLastSungSinger = humanize.Time(input.LastSungSinger)
}
return ret
}
func AugmentSingsVideoStructSlice(input []irc.SingsVideoStruct) []AugmentedSingsVideoStruct {
ret := make([]AugmentedSingsVideoStruct, 0)
for _, record := range input {
ret = append(ret, AugmentSingsVideoStruct(record))
}
return ret
}
func AdminHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
@ -165,12 +228,30 @@ func AdminHandler(response http.ResponseWriter, request *http.Request) {
SinceTimeUTC string
Leaving bool
HasLeft bool
SongData []irc.SingsVideoStruct
SongData []AugmentedSingsVideoStruct
TopNSongs []SongSings
TopNSingers []SingerSings
ChannelKey string
}
channelData := ircBot.ChannelData[vars["channel"]]
if time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours() > 1 {
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
if err != nil {
errCache := make([]irc.SingsVideoStruct, 0)
var ret irc.SingsVideoStruct
ret.FullTitle = "Error fetching videos: " + err.Error()
errCache = append(errCache, ret)
vids = errCache
}
updateCalculatedFields(vids)
ircBot.UpdateVideoCache(vars["channel"], vids)
} else {
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
}
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, channelData.VideoCache, topNSongs}
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers, vars["key"]}
if request.Method == "POST" {
request.ParseForm()
@ -196,7 +277,7 @@ func AdminHandler(response http.ResponseWriter, request *http.Request) {
}
ircBot.Database.Write("channelData", vars["channel"], record)
ircBot.ChannelData[vars["channel"]] = record
td = TemplateData{record.Name, record.Command, record.ExtraStrings, record.JoinTime, record.JoinTime.Format(irc.UTCFormat), false, record.HasLeft, channelData.VideoCache, topNSongs}
td = TemplateData{record.Name, record.Command, record.ExtraStrings, record.JoinTime, record.JoinTime.Format(irc.UTCFormat), false, record.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers, vars["key"]}
ircBot.JoinChannel(record.Name)
}
sourceData := ircBot.ChannelData[vars["channel"]]
@ -212,9 +293,7 @@ func AdminHandler(response http.ResponseWriter, request *http.Request) {
}
ircBot.Database.Write("channelData", vars["channel"], sourceData)
}
tmpl, err := template.New("admin.html").Funcs(template.FuncMap{
"niceDate": humanize.Time,
}).ParseFiles("web/admin.html")
tmpl, err := template.New("admin.html").ParseFiles("web/admin.html")
if err != nil {
panic(err.Error())
}
@ -303,8 +382,8 @@ type SongSings struct {
Sings int
}
type SingerSings struct {
SongTitle string
Sings int
SingerName string
Sings int
}
func calculateTopNSongs(songCache []irc.SingsVideoStruct, howMany int) []SongSings {
@ -329,6 +408,30 @@ func calculateTopNSongs(songCache []irc.SingsVideoStruct, howMany int) []SongSin
return ret
}
func calculateTopNSingers(songCache []irc.SingsVideoStruct, howMany int) []SingerSings {
songMap := map[string]int{}
for _, record := range songCache {
if record.Duet {
sings := songMap[record.OtherSinger]
sings += 1
songMap[record.OtherSinger] = sings
}
}
slice := make([]SingerSings, 0)
for key, value := range songMap {
ss := SingerSings{key, value}
slice = append(slice, ss)
}
sort.SliceStable(slice, func(i, j int) bool {
return slice[i].Sings > slice[j].Sings
})
var ret []SingerSings
for i := 1; i <= howMany; i++ {
ret = append(ret, slice[i])
}
return ret
}
func calculateLastSungSongDate(songCache []irc.SingsVideoStruct, SongTitle string) time.Time {
var t time.Time
for _, record := range songCache {
@ -480,21 +583,7 @@ func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
user := usersObject.Data[0]
magicCode := ircBot.ReadOrCreateChannelKey(user.Login)
ircBot.UpdateBearerToken(user.Login, oauthResponse.Access_token)
if time.Now().Sub(ircBot.ChannelData[user.Login].VideoCacheUpdated).Hours() > 1 {
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(ircBot.ChannelData[user.Login].VideoCache), time.Now().Sub(ircBot.ChannelData[user.Login].VideoCacheUpdated).Hours())
vids, err := fetchAllVoDs(user.Id, oauthResponse.Access_token)
if err != nil {
errCache := make([]irc.SingsVideoStruct, 0)
var ret irc.SingsVideoStruct
ret.FullTitle = "Error fetching videos: " + err.Error()
errCache = append(errCache, ret)
vids = errCache
}
updateCalculatedFields(vids)
ircBot.UpdateVideoCache(user.Login, vids)
} else {
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(ircBot.ChannelData[user.Login].VideoCache), time.Now().Sub(ircBot.ChannelData[user.Login].VideoCacheUpdated).Hours())
}
ircBot.UpdateTwitchUserID(user.Login, user.Id)
url := "https://" + ircBot.Config.ExternalUrl + "/admin/" + user.Login + "/" + magicCode
http.Redirect(response, request, url, http.StatusFound)
@ -554,6 +643,49 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
}
}
func CSVHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
UnauthorizedHandler(response, request)
return
}
type TemplateData struct {
Channel string
Command string
ExtraStrings string
SinceTime time.Time
SinceTimeUTC string
Leaving bool
HasLeft bool
SongData []AugmentedSingsVideoStruct
TopNSongs []SongSings
TopNSingers []SingerSings
}
channelData := ircBot.ChannelData[vars["channel"]]
if time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours() > 1 {
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
if err != nil {
errCache := make([]irc.SingsVideoStruct, 0)
var ret irc.SingsVideoStruct
ret.FullTitle = "Error fetching videos: " + err.Error()
errCache = append(errCache, ret)
vids = errCache
}
updateCalculatedFields(vids)
ircBot.UpdateVideoCache(vars["channel"], vids)
} else {
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
}
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
response.Header().Add("Content-Disposition", "attachment; filename=\"duets.csv\"")
response.Header().Add("Content-type", "text/csv")
tmpl := template.Must(template.ParseFiles("web/data.csv"))
tmpl.Execute(response, td)
}
func HandleHTTP(passedIrcBot *irc.KardBot) {
ircBot = passedIrcBot
r := mux.NewRouter()
@ -565,6 +697,7 @@ func HandleHTTP(passedIrcBot *irc.KardBot) {
r.PathPrefix("/static/").Handler(http.FileServer(http.Dir("./web/")))
r.HandleFunc("/cover.css", CSSHandler)
r.HandleFunc("/admin/{channel}/{key}", AdminHandler)
r.HandleFunc("/csv/{channel}/{key}", CSVHandler)
//r.HandleFunc("/twitchadmin", TwitchAdminHandler)
//r.HandleFunc("/twitchtobackend", TwitchBackendHandler)
r.Path("/twitchtobackend").Queries("access_token", "{access_token}", "scope", "{scope}", "token_type", "{token_type}").HandlerFunc(TwitchBackendHandler)

View File

@ -4,9 +4,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Karaokards bot for twitch chat">
<meta name="description" content="Twitch Sings tools for data analysis">
<meta name="author" content="Martyn Ranyard">
<title>Karaokards</title>
<title>Twitch Sings Tools</title>
<!-- Bootstrap core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
@ -22,7 +22,7 @@
-ms-user-select: none;
user-select: none;
}
#insightspanel > table:nth-child(1) > thead:nth-child(1) > tr:nth-child(1) > td:nth-child(1)
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
@ -76,7 +76,7 @@
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">Karaokards</h3>
<h3 class="masthead-brand">Twitch Sings Tools</h3>
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="/">Home</a>
</nav>
@ -108,8 +108,8 @@
document.getElementById("yuhateme").classList.add("visibleSave")
}
</script>
<h1 class="cover-heading">Karaokards admin panel for {{.Channel}}!!!</h1>
<ul class="nav nav-tabs" style="width: 80%;">
<h1 class="cover-heading">Twitch Sings Tools admin panel for {{.Channel}}!!!</h1>
<ul class="nav nav-tabs" style="width: 100%;">
<li class="nav-item">
<a id="insights" class="nav-link active panel" href="#" onclick="tabClick(this)">Insights</a>
</li>
@ -123,27 +123,35 @@
<a id="bot" class="nav-link panel" href="#" onclick="tabClick(this)">Bot</a>
</li>
</ul>
<div style="width: 80%;overflow-y: scroll; display: block;" id="insightspanel" class="controlpanel">
Top 10 Songs :
<div style="width: 100%; overflow-y: scroll; display: block;" id="insightspanel" class="controlpanel">
<table>
<thead><tr><td>What</td><td>Sings</td>
{{ range .TopNSongs }}
<tr><td>{{ .SongTitle }}</td><td>{{ .Sings }}</td></tr>
{{ end }}
<tr><th><h2>Top 10 Songs : </h2></th><th><h2>Top 10 Singers : </h2></th></tr>
<tr><td><table>
<thead><tr><th>Song</th><th>Sings</th></tr></thead>
{{ range .TopNSongs }}
<tr><td>{{ .SongTitle }}</td><td>{{ .Sings }}</td></tr>
{{ end }}
</table></td><td><table>
<thead><tr><th>Singer</th><th>Sings</th></tr></thead>
{{ range .TopNSingers }}
<tr><td>{{ .SingerName }}</td><td>{{ .Sings }}</td></tr>
{{ end }}
</table></td></tr>
</table>
</div>
<div style="width: 80%;overflow-y: scroll; display: none;" id="datapanel" class="controlpanel">
<div style="width: 100%; overflow-y: scroll; display: none;" id="datapanel" class="controlpanel">
<table>
<thead><tr><td>Published</td><td>Who</td><td>What</td><td>Last sang this song</td><td>Last dueted with performer</td></tr></thead>
<thead><tr><th>Published</th><th>Who</th><th>What</th><th>Last sang this song</th><th>Last dueted with performer</th></th></thead>
{{ range .SongData }}
<tr><td title="{{ .Date }}">{{ .Date | niceDate }}</td><td>{{ .SongTitle }}</td><td>{{ .OtherSinger }}</td><td title="{{ .LastSungSong }}">{{ .LastSungSong | niceDate }}</td><td title="{{ .LastSungSinger }}">{{ .LastSungSinger | niceDate }}</td></tr>
<tr><td title="{{ .Date }}">{{ .NiceDate }}</td><td>{{ .SongTitle }}</td><td>{{ .OtherSinger }}</td><td title="{{ .LastSungSong }}">{{ .NiceLastSungSong }}</td><td title="{{ .LastSungSinger }}">{{ .NiceLastSungSinger }}</td></tr>
{{ end }}
</table>
</div>
<div style="overflow-y: scroll; display: none;" id="csvpanel" class="controlpanel">
<h2>CSV support coming!</h2>
<div style="width: 100%; overflow-y: scroll; display: none;" id="csvpanel" class="controlpanel">
<h2>Click <a href="/csv/{{.Channel}}/{{.ChannelKey}}">here</a> to download the data as a CSV</h2>
</div>
<div style="overflow-y: scroll; display: none;" id="botpanel" class="controlpanel">
<div style="width: 100%; overflow-y: scroll; display: none;" id="botpanel" class="controlpanel">
<h2>The bot isn't really ready yet... it just has the old Karaokards facility at the moment. I woudn't bother inviting it yet.</h2>
{{ if .HasLeft }}
<h2>Not in your channel at the moment!</h2>
<p>The bot is not currently in your channel, chances are you've not ever asked it to join, you asked it to leave, or something went horribly wrong.</p>
@ -156,7 +164,7 @@
{{ else }}
<h2>Note you can give your moderators the url you are on right now to control this bot. They don't have to be logged into twitch to do so.</h2>
<table>
<thead><tr><td>Channel Data :</td><td><input type="button" value="Edit" onclick="javascript:editMode();"></td></tr></thead>
<thead><tr><th>Channel Data :</th><th><input type="button" value="Edit" onclick="javascript:editMode();"></th></tr></thead>
<tbody>
<tr><td>Member of channel since {{.SinceTimeUTC}}</td></tr>
<tr><td>Command for prompt:</td><td class="displaySetting"></tdclass>{{.Command}}</td><td class="editSetting"><input type="text" name="Command" value="{{.Command}}"></td></tr>

View File

@ -1,4 +1,4 @@
Published,Who,What,Last sang this song,Last dueted with performer
{{ range .SongData -}}
{{- .Date }},{{ .SongTitle }},{{ .OtherSinger }},{{ .LastSungSong }},{{ .LastSungSinger }}
{{- .NiceDate }},{{ .SongTitle }},{{ .OtherSinger }},{{ .NiceLastSungSong }},{{ .NiceLastSungSinger }}
{{ end }}

1 Published,Who,What,Last sang this song,Last dueted with performer
2 {{ range .SongData -}}
3 {{- .Date }},{{ .SongTitle }},{{ .OtherSinger }},{{ .LastSungSong }},{{ .LastSungSinger }} {{- .NiceDate }},{{ .SongTitle }},{{ .OtherSinger }},{{ .NiceLastSungSong }},{{ .NiceLastSungSinger }}
4 {{ end }}

View File

@ -63,9 +63,33 @@
</div>
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Karaokards!!!</h1>
<h1 class="cover-heading">Twitch Sings Tools?</h1>
<h2>Data about Twitch Sings <b>published</b> performances</h2>
<p>This set of tools uses the standard twitch APIs to create a list of songs you have sung and singers you have sung with. <i>Note</i>: If twitch sings ever changes how they name published performances, this may get harder to do.</p>
<h2>Some insights</h2>
<p>There's a "top 10 people you sing with" and a "top 10 songs you sing". There's actually not that much insight that can be drawn other than those without getting people involved :-)</p>
<h2>CSV Export!</h2>
<p>You can bring the data into Excel, Google Sheets, Libre/OpenOffice, Lotus 1-2-3 or whatever, and analyse/graph to your hearts content!</p>
<h2>Chatbot</h2>
<p>There's a chat bot and lots of features are planned :</p>
<ul>
<li>[NOT YET IMPLEMENTED] Suggest a singer to sing with I haven't sung with in a while</li>
<li>[NOT YET IMPLEMENTED] Suggest a song to sing that I haven't sung in a while</li>
<li>[NOT YET IMPLEMENTED] List recent duets with <i>singer</i> so I don't have to look it up via the twitch web interface</li>
<li>[NOT YET IMPLEMENTED] Check the <a href="https://songlist.sings.twitch.tv">songlist</a> for a song</li>
<li>Give a random prompt to give you an idea of a song to sing</li>
</ul>
<p>Random prompt for you : {{.Prompt}}</p>
<p>There are a total of {{.AvailCount}} prompts available. This bot is hanging out in {{.ChannelCount}} channels and has served {{.MessageCount}} prompts via twitch chat!</p>
<h2>FAQ</h2>
<ul><li>What about Open Duets</li>
<li>Unfortunately Twitch Sings publishes Open Duets in a way that they don't appear in the APIs. Whilst I could reverse-engineer the calls TS itself uses, I'm already skirting danger by calling this page "Twitch Sings Tools" (hint, they closed one account because of that!)</li></ul>
<ul><li>Can you Open-source this?</li>
<li>Can <a href="https://git.martyn.berlin/martyn/twitchsingstools">and have</a>! It's a bit of a mess architecturally because I started with a twitch chat bot and grew it out, badly. One day I <i>might</i> refactor the code.</li></ul>
<ul><li>What about unpublished duets?</li>
<li>Unfortunately, there's no way to get that data. I live by the rule of "publish everything", but if you're a perfectionist (I am with seeds), I'm sorry, that data isn't accessible.</li></ul>
<ul><li>Will you implement X</li>
<li>If it's not too hard, and the data is availabe via published APIs I'll consider it. Remember that the data is downloadable as a CSV so you can probably do a lot with that.</li></ul>
</main>
<footer class="mastfoot mt-auto">
<div class="inner">