918 lines
30 KiB
Go
Executable File
918 lines
30 KiB
Go
Executable File
package webserver
|
|
|
|
import (
|
|
"container/heap"
|
|
"errors"
|
|
"math/rand"
|
|
"regexp"
|
|
"sort"
|
|
"sync"
|
|
"unicode"
|
|
|
|
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
|
|
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/gorilla/mux"
|
|
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//var store = sessions.NewCookieStore(os.Getenv("SESSION_KEY"))
|
|
|
|
type twitchauthresponse struct {
|
|
Access_token string `json: "access_token"`
|
|
Expires_in int `json: "expires_in"`
|
|
Refresh_token string `json: "refresh_token"`
|
|
Scope []string `json: "scope"`
|
|
Token_type string `json: "token_type"`
|
|
}
|
|
|
|
type twitchUser struct {
|
|
Id string `json: "id"`
|
|
Login string `json: "login"`
|
|
Display_name string `json: "display_name"`
|
|
Type string `json: "type"`
|
|
Broadcaster_type string `json: "affiliate"`
|
|
Description string `json: "description"`
|
|
Profile_image_url string `json: "profile_image_url"`
|
|
Offline_image_url string `json: "offline_image_url"`
|
|
View_count int `json: "view_count"`
|
|
}
|
|
|
|
type twitchUsersBigResponse struct {
|
|
Data []twitchUser `json:"data"`
|
|
}
|
|
|
|
type twitchCursor struct {
|
|
Cursor string `json:"cursor"`
|
|
}
|
|
type videoStruct struct {
|
|
CreatedAt string `json:"created_at"` // Date when the video was created.
|
|
Description string `json:"description"` // Description of the video.
|
|
Duration string `json:"duration"` // Length of the video.
|
|
ID string `json:"id"` // ID of the video.
|
|
Language string `json:"language"` // Language of the video.
|
|
Pagination string `json:"pagination"` // A cursor value, to be used in a subsequent request to specify the starting point of the next set of results.
|
|
PublishedAt string `json:"published_at"` // Date when the video was published.
|
|
ThumbnailURL string `json:"thumbnail_url"` // Template URL for the thumbnail of the video.
|
|
Title string `json:"title"` // Title of the video.
|
|
Type string `json:"type"` // Type of video. Valid values: "upload", "archive", "highlight".
|
|
URL string `json:"url"` // URL of the video.
|
|
UserID string `json:"user_id"` // ID of the user who owns the video.
|
|
UserName string `json:"user_name"` // Display name corresponding to user_id.
|
|
ViewCount int `json:"view_count"` // Number of times the video has been viewed.
|
|
Viewable string `json:"viewable"` // Indicates whether the video is publicly viewable. Valid values: "public", "private".
|
|
}
|
|
type videosResponse struct {
|
|
Data []videoStruct `json:"data"`
|
|
Pagination twitchCursor `json:"pagination"`
|
|
}
|
|
|
|
var ircBot *irc.KardBot
|
|
var globalData *data.GlobalData
|
|
|
|
func HealthHandler(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "I'm okay jack!")
|
|
}
|
|
|
|
func NotFoundHandler(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Add("X-Template-File", "html"+request.URL.Path)
|
|
response.WriteHeader(404)
|
|
tmpl := template.Must(template.ParseFiles("web/404.html"))
|
|
tmpl.Execute(response, nil)
|
|
}
|
|
|
|
func CSSHandler(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Add("Content-type", "text/css")
|
|
tmpl := template.Must(template.ParseFiles("web/cover.css"))
|
|
tmpl.Execute(response, nil)
|
|
}
|
|
|
|
func RootHandler(response http.ResponseWriter, request *http.Request) {
|
|
request.URL.Path = "/index.html"
|
|
TemplateHandler(response, request)
|
|
}
|
|
|
|
func TemplateHandler(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Add("X-Template-File", "web"+request.URL.Path)
|
|
type TemplateData struct {
|
|
Prompt string
|
|
AvailCount int
|
|
ChannelCount int
|
|
MessageCount int
|
|
ClientID string
|
|
BaseURI string
|
|
}
|
|
_ = strings.ToLower("Hello")
|
|
if strings.Index(request.URL.Path, "/") < 0 {
|
|
http.Error(response, "No slashes wat - "+request.URL.Path, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
basenameSlice := strings.Split(request.URL.Path, "/")
|
|
basename := basenameSlice[len(basenameSlice)-1]
|
|
//fmt.Fprintf(response, "%q", basenameSlice)
|
|
tmpl, err := template.New(basename).Funcs(template.FuncMap{
|
|
"ToUpper": strings.ToUpper,
|
|
"ToLower": strings.ToLower,
|
|
}).ParseFiles("web" + request.URL.Path)
|
|
if err != nil {
|
|
http.Error(response, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
// NotFoundHandler(response, request)
|
|
// return
|
|
}
|
|
var td = TemplateData{ircBot.Prompts[rand.Intn(len(ircBot.Prompts))], len(ircBot.Prompts), ircBot.ActiveChannels(), 0, ircBot.AppCredentials.ClientID, "https://" + globalData.Config.ExternalURL}
|
|
err = tmpl.Execute(response, td)
|
|
if err != nil {
|
|
http.Error(response, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func LeaveHandler(response http.ResponseWriter, request *http.Request) {
|
|
request.URL.Path = "/bye.html"
|
|
TemplateHandler(response, request)
|
|
}
|
|
|
|
func humanTimeFromTimeString(s string) string {
|
|
return s
|
|
// format := "2006-01-02 15:04:05 +0000 UTC"
|
|
// d, _ := time.Parse(format, s)
|
|
// return humanize.Time(d)
|
|
}
|
|
|
|
type AugmentedSingsVideoStruct struct {
|
|
Date time.Time
|
|
NiceDate string
|
|
ShortDate string
|
|
FullTitle string
|
|
Duet bool
|
|
OtherSinger string
|
|
SongTitle string
|
|
LastSungSong time.Time
|
|
NiceLastSungSong string
|
|
LastSungSinger time.Time
|
|
NiceLastSungSinger string
|
|
VideoURL string
|
|
VideoNumber string //yes, I don't care any more.
|
|
}
|
|
|
|
func AugmentSingsVideoStructForCSV(input data.SingsVideoStruct) AugmentedSingsVideoStruct {
|
|
var ret AugmentedSingsVideoStruct
|
|
ret.Date = input.Date
|
|
ret.NiceDate = input.Date.Format("2006-01-02 15:04:05")
|
|
ret.ShortDate = input.Date.Format("2006-01-02")
|
|
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
|
|
ret.VideoURL = input.VideoURL
|
|
urlParts := strings.Split(input.VideoURL, "/")
|
|
ret.VideoNumber = urlParts[len(urlParts)-1]
|
|
if !ret.Duet {
|
|
ret.NiceLastSungSinger = "Solo performance"
|
|
} else {
|
|
ret.NiceLastSungSinger = input.LastSungSinger.Format("2006-01-02 15:04:05")
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func AugmentSingsVideoStructSliceForCSV(input []data.SingsVideoStruct) []AugmentedSingsVideoStruct {
|
|
ret := make([]AugmentedSingsVideoStruct, 0)
|
|
for _, record := range input {
|
|
ret = append(ret, AugmentSingsVideoStructForCSV(record))
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func AugmentSingsVideoStruct(input data.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 []data.SingsVideoStruct) []AugmentedSingsVideoStruct {
|
|
ret := make([]AugmentedSingsVideoStruct, 0)
|
|
for _, record := range input {
|
|
ret = append(ret, AugmentSingsVideoStruct(record))
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func UnauthorizedHandler(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Add("X-Template-File", "html"+request.URL.Path)
|
|
response.WriteHeader(401)
|
|
tmpl := template.Must(template.ParseFiles("web/401.html"))
|
|
tmpl.Execute(response, nil)
|
|
}
|
|
|
|
func twitchHTTPClient(call string, bearer string) (string, error) {
|
|
url := "https://api.twitch.tv/helix/" + call
|
|
var bearerHeader = "Bearer " + bearer
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
req.Header.Add("Client-ID", ircBot.AppCredentials.ClientID)
|
|
req.Header.Add("Authorization", bearerHeader)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := ioutil.ReadAll(resp.Body)
|
|
if resp.StatusCode != 200 {
|
|
return string(http.StatusText(resp.StatusCode)), errors.New("HTTP ERROR: " + http.StatusText(resp.StatusCode))
|
|
} else {
|
|
return string([]byte(body)), nil
|
|
}
|
|
}
|
|
|
|
func ValidateTwitchBearerToken(bearer string) (bool, error) {
|
|
url := "https://id.twitch.tv/oauth2/validate"
|
|
var bearerHeader = "Bearer " + bearer
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
req.Header.Add("Client-ID", ircBot.AppCredentials.ClientID)
|
|
req.Header.Add("Authorization", bearerHeader)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
_, _ = ioutil.ReadAll(resp.Body)
|
|
return resp.StatusCode == 200, nil
|
|
}
|
|
|
|
func twitchVidToSingsVid(twitchFormat videoStruct) (data.SingsVideoStruct, error) {
|
|
var ret data.SingsVideoStruct
|
|
layout := "2006-01-02T15:04:05Z"
|
|
var d time.Time
|
|
d, err := time.Parse(layout, twitchFormat.CreatedAt)
|
|
if err != nil {
|
|
return ret, err
|
|
}
|
|
ret.Date = d
|
|
ret.VideoURL = twitchFormat.URL
|
|
|
|
var DuetRegex = regexp.MustCompile(`^Duet with ([^ ]*): (.*)$`)
|
|
matches := DuetRegex.FindAllStringSubmatch(twitchFormat.Title, -1)
|
|
if matches == nil {
|
|
var SoloRegex = regexp.MustCompile(`^Solo performance: (.*)$`)
|
|
matches := SoloRegex.FindAllStringSubmatch(twitchFormat.Title, -1)
|
|
if matches == nil {
|
|
ret.SongTitle = "Does not match either solo or duet performance regex "
|
|
ret.FullTitle = twitchFormat.Title
|
|
return ret, errors.New("Does not match either solo or duet performance regex : " + twitchFormat.Title)
|
|
}
|
|
ret.Duet = false
|
|
ret.SongTitle = matches[0][1]
|
|
ret.FullTitle = twitchFormat.Title
|
|
return ret, nil
|
|
}
|
|
ret.Duet = true
|
|
ret.OtherSinger = matches[0][1]
|
|
ret.SongTitle = matches[0][2]
|
|
ret.FullTitle = twitchFormat.Title
|
|
return ret, nil
|
|
}
|
|
|
|
type SongSings struct {
|
|
SongTitle string
|
|
Sings int
|
|
}
|
|
type SingerSings struct {
|
|
SingerName string
|
|
Sings int
|
|
}
|
|
|
|
func calculateTopNSongs(songCache []data.SingsVideoStruct, howMany int) []SongSings {
|
|
songMap := map[string]int{}
|
|
for _, record := range songCache {
|
|
sings := songMap[record.SongTitle]
|
|
sings += 1
|
|
songMap[record.SongTitle] = sings
|
|
}
|
|
slice := make([]SongSings, 0)
|
|
for key, value := range songMap {
|
|
ss := SongSings{key, value}
|
|
slice = append(slice, ss)
|
|
}
|
|
sort.SliceStable(slice, func(i, j int) bool {
|
|
return slice[i].Sings > slice[j].Sings
|
|
})
|
|
var ret []SongSings
|
|
for i := 1; i <= howMany; i++ {
|
|
ret = append(ret, slice[i])
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func IsLower(s string) bool {
|
|
for _, r := range s {
|
|
if !unicode.IsLower(r) && unicode.IsLetter(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func unmangleSingerName(MangledCaseName string, songCache []data.SingsVideoStruct) string {
|
|
options := make(map[string]string, 0)
|
|
for _, record := range songCache {
|
|
if strings.ToUpper(MangledCaseName) == strings.ToUpper(record.OtherSinger) {
|
|
options[record.OtherSinger] = "WHATEVER"
|
|
}
|
|
}
|
|
// One answer means we don't care upper, lower, mixed.
|
|
if len(options) == 1 {
|
|
for key := range options {
|
|
return key
|
|
}
|
|
}
|
|
// More than one is probably closed-beta where the name was lowercased.
|
|
for key := range options {
|
|
if !IsLower(key) {
|
|
return key
|
|
}
|
|
}
|
|
// Eep, we shouldn't get here, let's just return something.
|
|
for key := range options {
|
|
return key
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func prependSong(x []SingerSings, y SingerSings) []SingerSings {
|
|
x = append(x, SingerSings{})
|
|
copy(x[1:], x)
|
|
x[0] = y
|
|
return x
|
|
}
|
|
|
|
type kv struct {
|
|
Key string
|
|
Value int
|
|
}
|
|
|
|
func getHeap(m map[string]int) *KVHeap {
|
|
h := &KVHeap{}
|
|
heap.Init(h)
|
|
for k, v := range m {
|
|
heap.Push(h, kv{k, v})
|
|
}
|
|
return h
|
|
}
|
|
|
|
type KVHeap []kv
|
|
|
|
func (h KVHeap) Len() int { return len(h) }
|
|
func (h KVHeap) Less(i, j int) bool { return h[i].Value > h[j].Value }
|
|
func (h KVHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
|
|
|
func (h *KVHeap) Push(x interface{}) {
|
|
*h = append(*h, x.(kv))
|
|
}
|
|
|
|
func (h *KVHeap) Pop() interface{} {
|
|
old := *h
|
|
n := len(old)
|
|
x := old[n-1]
|
|
*h = old[0 : n-1]
|
|
return x
|
|
}
|
|
|
|
var cacheLock sync.Mutex
|
|
|
|
type CacheDetails struct {
|
|
Age time.Duration `json: "cache_age"`
|
|
AgeStr string `json: "cache_age_nice"`
|
|
SongCount int `json: "expires_in"`
|
|
}
|
|
|
|
func getCacheDetails(channel string) CacheDetails {
|
|
var ret CacheDetails
|
|
channelData := globalData.ChannelData[channel]
|
|
ret.Age = time.Now().Sub(channelData.VideoCacheUpdated)
|
|
ret.AgeStr = humanize.Time(channelData.VideoCacheUpdated)
|
|
ret.SongCount = len(channelData.VideoCache)
|
|
return ret
|
|
}
|
|
|
|
func forceUpdateCache(channel string) {
|
|
fmt.Printf("Forcing cache update!")
|
|
channelData := globalData.ChannelData[channel]
|
|
tenHours := time.Hour * -10
|
|
videoCacheUpdated := time.Now().Add(tenHours) // Subtract 10 hours from now, cache is 10 hours old.
|
|
channelData.VideoCacheUpdated = videoCacheUpdated
|
|
globalData.ChannelData[channel] = channelData
|
|
updateCacheIfNecessary(channel)
|
|
}
|
|
|
|
func updateCacheIfNecessary(channel string) {
|
|
cacheLock.Lock()
|
|
channelData := globalData.ChannelData[channel]
|
|
if time.Now().Sub(channelData.VideoCacheUpdated).Hours() > 1 {
|
|
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(channelData.VideoCache), time.Now().Sub(globalData.ChannelData[channel].VideoCacheUpdated).Hours())
|
|
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
|
|
if err != nil {
|
|
errCache := make([]data.SingsVideoStruct, 0)
|
|
var ret data.SingsVideoStruct
|
|
ret.FullTitle = "Error fetching videos: " + err.Error()
|
|
errCache = append(errCache, ret)
|
|
vids = errCache
|
|
}
|
|
updateCalculatedFields(vids)
|
|
globalData.UpdateVideoCache(channel, vids)
|
|
} else {
|
|
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(channelData.VideoCache), time.Now().Sub(globalData.ChannelData[channel].VideoCacheUpdated).Hours())
|
|
}
|
|
cacheLock.Unlock()
|
|
}
|
|
|
|
func calculateTopNSingers(songCache []data.SingsVideoStruct, howMany int) []SingerSings {
|
|
songMap := map[string]int{}
|
|
songCount := 0
|
|
for _, record := range songCache {
|
|
if record.Duet {
|
|
sings := songMap[strings.ToUpper(record.OtherSinger)]
|
|
sings++
|
|
songCount++
|
|
songMap[strings.ToUpper(record.OtherSinger)] = sings
|
|
}
|
|
}
|
|
slice := make([]SingerSings, 0)
|
|
h := getHeap(songMap)
|
|
for i := 0; i < howMany; i++ {
|
|
deets := heap.Pop(h)
|
|
position := i + 1
|
|
fmt.Printf("%d) %#v\n", position, deets)
|
|
ss := SingerSings{unmangleSingerName(deets.(kv).Key, songCache), deets.(kv).Value}
|
|
slice = append(slice, ss)
|
|
}
|
|
fmt.Printf("Considered %d songs, Shan has %d\n", songCount, songMap["SHANXOX_"])
|
|
return slice
|
|
}
|
|
|
|
func calculateLastSungSongDate(songCache []data.SingsVideoStruct, SongTitle string) time.Time {
|
|
var t time.Time
|
|
for _, record := range songCache {
|
|
if record.SongTitle == SongTitle {
|
|
if record.Date.After(t) {
|
|
t = record.Date
|
|
}
|
|
}
|
|
}
|
|
return t
|
|
}
|
|
|
|
func calculateLastSungSingerDate(songCache []data.SingsVideoStruct, Singer string) time.Time {
|
|
var t time.Time
|
|
for _, record := range songCache {
|
|
if strings.ToUpper(record.OtherSinger) == strings.ToUpper(Singer) {
|
|
if record.Date.After(t) {
|
|
t = record.Date
|
|
if strings.ToUpper(Singer) == "SHANXOX_" {
|
|
fmt.Printf("Last sang with %s (%s) %s", Singer, record.OtherSinger, t)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return t
|
|
}
|
|
|
|
func updateCalculatedFields(songCache []data.SingsVideoStruct) {
|
|
for i, record := range songCache {
|
|
if record.Duet {
|
|
songCache[i].LastSungSinger = calculateLastSungSingerDate(songCache, record.OtherSinger)
|
|
}
|
|
songCache[i].LastSungSong = calculateLastSungSongDate(songCache, record.SongTitle)
|
|
}
|
|
}
|
|
|
|
func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]data.SingsVideoStruct, error) {
|
|
url := ""
|
|
if from == "" {
|
|
url = "videos?user_id=" + userID + "&first=100&type=upload"
|
|
} else {
|
|
url = "videos?user_id=" + userID + "&first=100&type=upload&after=" + from
|
|
}
|
|
|
|
vidResponse, err := twitchHTTPClient(url, bearer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var fullResponse videosResponse
|
|
err = json.Unmarshal([]byte(vidResponse), &fullResponse)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
titles := make([]data.SingsVideoStruct, 0)
|
|
for _, videoData := range fullResponse.Data {
|
|
ret, err := twitchVidToSingsVid(videoData)
|
|
if err != nil {
|
|
titles = append(titles, ret)
|
|
} else {
|
|
titles = append(titles, ret)
|
|
}
|
|
}
|
|
if fullResponse.Pagination.Cursor != "" {
|
|
fmt.Println("NOTICE: Recursion needed, cursor is " + fullResponse.Pagination.Cursor)
|
|
recurse, err := fetchVoDsPagesRecursive(userID, bearer, fullResponse.Pagination.Cursor)
|
|
if err != nil {
|
|
fmt.Println("ERROR: Bailing out during recursion because of error : " + err.Error())
|
|
}
|
|
titles = append(titles, recurse...)
|
|
}
|
|
return titles, nil
|
|
}
|
|
|
|
func fetchAllVoDs(userID string, bearer string) ([]data.SingsVideoStruct, error) {
|
|
tokenValid, err := ValidateTwitchBearerToken(bearer)
|
|
if err != nil {
|
|
fmt.Println("Error validating token : " + err.Error())
|
|
return nil, err
|
|
}
|
|
if !tokenValid {
|
|
fmt.Println("Error validating token (revoked?)")
|
|
return nil, errors.New("Failed to validate token with twitch (authorization revoked?!)")
|
|
}
|
|
titles, err := fetchVoDsPagesRecursive(userID, bearer, "")
|
|
if err != nil {
|
|
return make([]data.SingsVideoStruct, 0), err
|
|
}
|
|
return titles, nil
|
|
}
|
|
|
|
func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
|
|
|
|
vars := mux.Vars(request)
|
|
if vars["code"] != "" {
|
|
response.Header().Add("Content-type", "text/plain")
|
|
resp, err := http.PostForm(
|
|
"https://id.twitch.tv/oauth2/token",
|
|
url.Values{
|
|
"client_id": {ircBot.AppCredentials.ClientID},
|
|
"client_secret": {ircBot.AppCredentials.ClientSecret},
|
|
"code": {vars["code"]},
|
|
"grant_type": {"authorization_code"},
|
|
"redirect_uri": {"https://" + globalData.Config.ExternalURL + "/twitchadmin"}})
|
|
if err != nil {
|
|
response.WriteHeader(500)
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
return
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
response.WriteHeader(500)
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
return
|
|
}
|
|
|
|
var oauthResponse twitchauthresponse
|
|
err = json.Unmarshal(body, &oauthResponse)
|
|
if err != nil {
|
|
response.WriteHeader(500)
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
return
|
|
}
|
|
|
|
usersResponse, err := twitchHTTPClient("users", oauthResponse.Access_token)
|
|
if err != nil {
|
|
response.WriteHeader(500)
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
return
|
|
}
|
|
|
|
var usersObject twitchUsersBigResponse
|
|
err = json.Unmarshal([]byte(usersResponse), &usersObject)
|
|
if err != nil {
|
|
response.WriteHeader(500)
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if len(usersObject.Data) != 1 {
|
|
response.WriteHeader(500)
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: Twitch returned not 1 user for the request!\n---\n")
|
|
return
|
|
}
|
|
|
|
user := usersObject.Data[0]
|
|
magicCode := globalData.ReadOrCreateChannelKey(user.Login)
|
|
globalData.UpdateBearerToken(user.Login, oauthResponse.Access_token)
|
|
globalData.UpdateTwitchUserID(user.Login, user.Id)
|
|
url := "https://" + globalData.Config.ExternalURL + "/admin/" + user.Login + "/" + magicCode
|
|
http.Redirect(response, request, url, http.StatusFound)
|
|
|
|
} else {
|
|
|
|
fmt.Fprintf(response, "I'm not okay jack! %v \n", vars)
|
|
for key, val := range vars {
|
|
fmt.Fprintf(response, "%s = %s\n", key, val)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Add("Content-type", "text/plain")
|
|
vars := mux.Vars(request)
|
|
// fmt.Fprintf(response, "I'm okay jack! %v \n", vars)
|
|
// for key, val := range(vars) {
|
|
// fmt.Fprint(response, "%s = %s\n", key, val)
|
|
// }
|
|
|
|
if vars["code"] != "" {
|
|
// https://id.twitch.tv/oauth2/token
|
|
// ?client_id=<your client ID>
|
|
// &client_secret=<your client secret>
|
|
// &code=<authorization code received above>
|
|
// &grant_type=authorization_code
|
|
// &redirect_uri=<your registered redirect URI>
|
|
|
|
// ircBot.AppCredentials.ClientID
|
|
// ircBot.AppCredentials.Password
|
|
// vars["oauthtoken"]
|
|
// authorization_code
|
|
// "https://"+globalData.Config.ExternalURL+/twitchadmin
|
|
fmt.Println("Asking twitch for more...")
|
|
resp, err := http.PostForm(
|
|
"https://id.twitch.tv/oauth2/token",
|
|
url.Values{
|
|
"client_id": {ircBot.AppCredentials.ClientID},
|
|
"client_secret": {ircBot.AppCredentials.ClientSecret},
|
|
"code": {vars["code"]},
|
|
"grant_type": {"authorization_code"},
|
|
"redirect_uri": {"https://" + globalData.Config.ExternalURL + "/twitchadmin"}})
|
|
if err != nil {
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, "ERROR: "+err.Error())
|
|
}
|
|
response.Header().Add("Content-type", "text/plain")
|
|
fmt.Fprint(response, string(body))
|
|
} else {
|
|
UnauthorizedHandler(response, request)
|
|
}
|
|
}
|
|
|
|
func CSVHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
type TemplateData struct {
|
|
Channel string
|
|
Commands []data.CommandStruct
|
|
ExtraStrings string
|
|
SinceTime time.Time
|
|
SinceTimeUTC string
|
|
Leaving bool
|
|
HasLeft bool
|
|
SongData []AugmentedSingsVideoStruct
|
|
TopNSongs []SongSings
|
|
TopNSingers []SingerSings
|
|
}
|
|
updateCacheIfNecessary(vars["channel"])
|
|
channelData := globalData.ChannelData[vars["channel"]]
|
|
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
|
|
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
|
|
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
|
|
if request.URL.Path[0:4] == "/csv" {
|
|
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)
|
|
} else {
|
|
response.Header().Add("Content-Disposition", "attachment; filename=\"duets.tsv\"")
|
|
response.Header().Add("Content-type", "text/tab-separated-values")
|
|
tmpl := template.Must(template.ParseFiles("web/data.tsv"))
|
|
tmpl.Execute(response, td)
|
|
}
|
|
}
|
|
|
|
func JSONHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
fmt.Printf("%s != %s\n", vars["key"], globalData.ChannelData[vars["channel"]].AdminKey)
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
type TemplateData struct {
|
|
Channel string
|
|
Commands []data.CommandStruct
|
|
ExtraStrings string
|
|
SinceTime time.Time
|
|
SinceTimeUTC string
|
|
Leaving bool
|
|
HasLeft bool
|
|
SongData []AugmentedSingsVideoStruct
|
|
TopNSongs []SongSings
|
|
TopNSingers []SingerSings
|
|
}
|
|
updateCacheIfNecessary(vars["channel"])
|
|
channelData := globalData.ChannelData[vars["channel"]]
|
|
|
|
var topNSongs []SongSings
|
|
var topNSingers []SingerSings
|
|
|
|
if request.URL.Path[0:4] != "/deb" {
|
|
topNSongs = calculateTopNSongs(channelData.VideoCache, 10)
|
|
topNSingers = calculateTopNSingers(channelData.VideoCache, 10)
|
|
}
|
|
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers}
|
|
response.Header().Add("Content-type", "application/json")
|
|
if request.URL.Path[0:5] == "/json" {
|
|
tmpl := template.Must(template.ParseFiles("web/data.json"))
|
|
tmpl.Execute(response, td)
|
|
} else if request.URL.Path[0:9] == "/topsongs" {
|
|
tmpl := template.Must(template.ParseFiles("web/topsongs.json"))
|
|
tmpl.Execute(response, td)
|
|
} else { // top 10 singers!
|
|
tmpl := template.Must(template.ParseFiles("web/topsingers.json"))
|
|
tmpl.Execute(response, td)
|
|
}
|
|
}
|
|
|
|
func ScriptHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
type TemplateData struct {
|
|
Channel string
|
|
Commands []data.CommandStruct
|
|
ExtraStrings string
|
|
SinceTime time.Time
|
|
SinceTimeUTC string
|
|
Leaving bool
|
|
HasLeft bool
|
|
SongData []AugmentedSingsVideoStruct
|
|
TopNSongs []SongSings
|
|
TopNSingers []SingerSings
|
|
}
|
|
updateCacheIfNecessary(vars["channel"])
|
|
channelData := globalData.ChannelData[vars["channel"]]
|
|
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
|
|
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
|
|
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
|
|
if request.URL.Path[0:11] == "/script.bat" {
|
|
response.Header().Add("Content-Disposition", "attachment; filename=\"script.bat\"")
|
|
response.Header().Add("Content-type", "application/x-bat")
|
|
tmpl := template.Must(template.ParseFiles("web/script.bat"))
|
|
tmpl.Execute(response, td)
|
|
} else {
|
|
response.Header().Add("Content-Disposition", "attachment; filename=\"script.sh\"")
|
|
response.Header().Add("Content-type", "text/x-shellscript")
|
|
tmpl := template.Must(template.ParseFiles("web/script.sh"))
|
|
tmpl.Execute(response, td)
|
|
}
|
|
}
|
|
|
|
func CacheDetailsHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
deets := getCacheDetails(vars["channel"])
|
|
response.Header().Add("Content-type", "application/json")
|
|
enc := json.NewEncoder(response)
|
|
enc.Encode(deets)
|
|
}
|
|
|
|
func BotDetailsHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
type ChannelDataSmaller struct {
|
|
Commands []data.CommandStruct `json:"commands,omitempty"`
|
|
ExtraStrings string `json:"extrastrings,omitempty"`
|
|
JoinTime time.Time `json:"jointime"`
|
|
HasLeft bool `json:"hasleft"`
|
|
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
|
|
}
|
|
var deets ChannelDataSmaller
|
|
deets.Commands = globalData.ChannelData[vars["channel"]].Commands
|
|
deets.ExtraStrings = globalData.ChannelData[vars["channel"]].ExtraStrings
|
|
deets.JoinTime = globalData.ChannelData[vars["channel"]].JoinTime
|
|
deets.HasLeft = globalData.ChannelData[vars["channel"]].HasLeft
|
|
deets.VideoCacheUpdated = globalData.ChannelData[vars["channel"]].VideoCacheUpdated
|
|
response.Header().Add("Content-type", "application/json")
|
|
enc := json.NewEncoder(response)
|
|
enc.Encode(deets)
|
|
}
|
|
|
|
func ReactIndexHandler(entrypoint string) func(w http.ResponseWriter, r *http.Request) {
|
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, entrypoint)
|
|
}
|
|
|
|
return http.HandlerFunc(fn)
|
|
}
|
|
|
|
func ForceRefreshHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
forceUpdateCache(vars["channel"])
|
|
response.Header().Add("Content-type", "application/json")
|
|
enc := json.NewEncoder(response)
|
|
enc.Encode(true)
|
|
}
|
|
|
|
func JoinHandler(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
|
UnauthorizedHandler(response, request)
|
|
return
|
|
}
|
|
globalData.UpdateJoined(vars["channel"], false)
|
|
ircBot.JoinChannel(vars["channel"])
|
|
}
|
|
|
|
func HandleHTTP(passedIrcBot *irc.KardBot, passedGlobalData *data.GlobalData) {
|
|
ircBot = passedIrcBot
|
|
globalData = passedGlobalData
|
|
r := mux.NewRouter()
|
|
loggedRouter := handlers.LoggingHandler(os.Stdout, r)
|
|
r.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
|
|
r.HandleFunc("/healthz", HealthHandler)
|
|
r.HandleFunc("/web/{.*}", TemplateHandler)
|
|
r.HandleFunc("/cachedeets/{channel}/{key}", CacheDetailsHandler)
|
|
r.HandleFunc("/botdeets/{channel}/{key}", BotDetailsHandler)
|
|
r.HandleFunc("/join/{channel}/{key}", JoinHandler)
|
|
r.HandleFunc("/force/{channel}/{key}", ForceRefreshHandler)
|
|
r.HandleFunc("/csv/{channel}/{key}", CSVHandler)
|
|
r.HandleFunc("/tsv/{channel}/{key}", CSVHandler)
|
|
r.HandleFunc("/json/{channel}/{key}", JSONHandler)
|
|
r.HandleFunc("/debug/{channel}/{key}", JSONHandler)
|
|
r.HandleFunc("/topsongs/{channel}/{key}", JSONHandler)
|
|
r.HandleFunc("/topsingers/{channel}/{key}", JSONHandler)
|
|
r.HandleFunc("/script.bat/{channel}/{key}", ScriptHandler)
|
|
r.Path("/twitchtobackend").Queries("access_token", "{access_token}", "scope", "{scope}", "token_type", "{token_type}").HandlerFunc(TwitchBackendHandler)
|
|
r.Path("/twitchadmin").Queries("code", "{code}", "scope", "{scope}").HandlerFunc(TwitchAdminHandler)
|
|
r.PathPrefix("/static").Handler(http.StripPrefix("/static", http.FileServer(http.Dir("./web/react-frontend/static"))))
|
|
r.PathPrefix("/").HandlerFunc(ReactIndexHandler("./web/react-frontend/index.html"))
|
|
http.Handle("/", r)
|
|
srv := &http.Server{
|
|
Handler: loggedRouter,
|
|
Addr: "0.0.0.0:5353",
|
|
WriteTimeout: 15 * time.Second,
|
|
ReadTimeout: 15 * time.Second,
|
|
}
|
|
fmt.Println("Listening on 0.0.0.0:5353")
|
|
srv.ListenAndServe()
|
|
}
|