diff --git a/chatcommands.go b/chatcommands.go index d466c46..c18b6e3 100644 --- a/chatcommands.go +++ b/chatcommands.go @@ -439,7 +439,7 @@ var commands = &CommandControl{ HelpText: "Reload the emotes on the server.", Function: func(cl *Client, args []string) (string, error) { cl.SendServerMessage("Reloading emotes") - num, err := common.LoadEmotes() + err := loadEmotes() if err != nil { common.LogErrorf("Unbale to reload emotes: %s\n", err) return "", err @@ -447,6 +447,8 @@ var commands = &CommandControl{ cl.belongsTo.AddChatMsg(common.NewChatHiddenMessage(common.CdEmote, common.Emotes)) cl.belongsTo.AddModNotice(cl.name + " has reloaded emotes") + + num := len(Emotes) common.LogInfof("Loaded %d emotes\n", num) return fmt.Sprintf("Emotes loaded: %d", num), nil }, @@ -525,7 +527,7 @@ var commands = &CommandControl{ go func() { // Pretty sure this breaks on partial downloads (eg, one good channel and one non-existent) - _, err := GetEmotes(args) + err := getEmotes(args) if err != nil { cl.SendChatData(common.NewChatMessage("", "", err.Error(), @@ -533,8 +535,13 @@ var commands = &CommandControl{ return } + // If the emotes were able to be downloaded, add the channels to settings + settingsMtx.Lock() + settings.ApprovedEmotes = append(settings.ApprovedEmotes, args...) + settingsMtx.Unlock() + // reload emotes now that new ones were added - _, err = common.LoadEmotes() + err = loadEmotes() if err != nil { cl.SendChatData(common.NewChatMessage("", "", err.Error(), diff --git a/chatroom.go b/chatroom.go index 2d37572..1111189 100644 --- a/chatroom.go +++ b/chatroom.go @@ -36,11 +36,11 @@ func newChatRoom() (*ChatRoom, error) { clients: []*Client{}, } - num, err := common.LoadEmotes() + err := loadEmotes() if err != nil { return nil, fmt.Errorf("error loading emotes: %s", err) } - common.LogInfof("Loaded %d emotes\n", num) + common.LogInfof("Loaded %d emotes\n", len(Emotes)) //the "heartbeat" for broadcasting messages go cr.Broadcast() diff --git a/common/emotes.go b/common/emotes.go index 35f330d..3bff321 100644 --- a/common/emotes.go +++ b/common/emotes.go @@ -2,14 +2,23 @@ package common import ( "fmt" - "path/filepath" + "path" "strings" ) -var Emotes map[string]string +var Emotes map[string]EmotePath + +type EmotePath struct { + Dir string + File string +} + +func (e EmotePath) path() string { + return path.Join(e.Dir, e.File) +} func EmoteToHtml(file, title string) string { - return fmt.Sprintf(``, file, title) + return fmt.Sprintf(``, file, title) } func ParseEmotesArray(words []string) []string { @@ -21,7 +30,7 @@ func ParseEmotesArray(words []string) []string { found := false for key, val := range Emotes { if key == wordTrimmed { - newWords = append(newWords, EmoteToHtml(val, key)) + newWords = append(newWords, EmoteToHtml(val.File, key)) found = true } } @@ -36,31 +45,3 @@ func ParseEmotes(msg string) string { words := ParseEmotesArray(strings.Split(msg, " ")) return strings.Join(words, " ") } - -func LoadEmotes() (int, error) { - newEmotes := map[string]string{} - - emotePNGs, err := filepath.Glob("./static/emotes/*.png") - if err != nil { - return 0, fmt.Errorf("unable to glob emote directory: %s\n", err) - } - - emoteGIFs, err := filepath.Glob("./static/emotes/*.gif") - if err != nil { - return 0, fmt.Errorf("unable to glob emote directory: %s\n", err) - } - globbed_files := []string(emotePNGs) - globbed_files = append(globbed_files, emoteGIFs...) - - LogInfoln("Loading emotes...") - emInfo := []string{} - for _, file := range globbed_files { - file = filepath.Base(file) - key := file[0 : len(file)-4] - newEmotes[key] = file - emInfo = append(emInfo, key) - } - Emotes = newEmotes - LogInfoln(strings.Join(emInfo, " ")) - return len(Emotes), nil -} diff --git a/common/emotes_test.go b/common/emotes_test.go index f531b6f..376dadb 100644 --- a/common/emotes_test.go +++ b/common/emotes_test.go @@ -1,7 +1,6 @@ package common import ( - "os" "testing" ) @@ -26,12 +25,21 @@ var data_good = map[string]string{ } func TestMain(m *testing.M) { - Emotes = map[string]string{ - "one": "one.png", - "two": "two.png", - "three": "three.gif", + Emotes = map[string]EmotePath{ + "one": EmotePath{ + Dir: "", + File: "one.png", + }, + "two": EmotePath{ + Dir: "", + File: "two.png", + }, + "three": EmotePath{ + Dir: "", + File: "three.gif", + }, } - os.Exit(m.Run()) + // os.Exit(m.Run()) } func TestEmotes_ParseEmotes(t *testing.T) { diff --git a/emotes.go b/emotes.go index dcd6335..c9a0431 100644 --- a/emotes.go +++ b/emotes.go @@ -4,164 +4,224 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" + "path" "strings" - "github.com/zorchenhimer/MovieNight/common" + "github.com/pkg/errors" ) -type twitchChannel struct { - ChannelName string `json:"channel_name"` - DisplayName string `json:"display_name"` - ChannelId string `json:"channel_id"` - BroadcasterType string `json:"broadcaster_type"` - Plans map[string]string `json:"plans"` - Emotes []struct { - Code string `json:"code"` - Set int `json:"emoticon_set"` - Id int `json:"id"` - } `json:"emotes"` - BaseSetId string `json:"base_set_id"` - GeneratedAt string `json:"generated_at"` +const emoteDir = "./static/emotes/" + +var Emotes map[string]Emote + +type Emote struct { + Dir string + File string } -// Used in settings -type EmoteSet struct { - Channel string // channel name - Prefix string // emote prefix - Found bool `json:"-"` +func (e Emote) path() string { + return path.Join(e.Dir, e.File) } -const subscriberJson string = `subscribers.json` +type TwitchUser struct { + ID string + Login string +} -// Download a single channel's emote set -func (tc *twitchChannel) downloadEmotes() (*EmoteSet, error) { - es := &EmoteSet{Channel: strings.ToLower(tc.ChannelName)} - for _, emote := range tc.Emotes { - url := fmt.Sprintf(`https://static-cdn.jtvnw.net/emoticons/v1/%d/1.0`, emote.Id) - png := `static/emotes/` + emote.Code + `.png` +type EmoteInfo struct { + ID int + Code string +} - if len(es.Prefix) == 0 { - // For each letter - for i := 0; i < len(emote.Code); i++ { - // Find the first capital - b := emote.Code[i] - if b >= 'A' && b <= 'Z' { - es.Prefix = emote.Code[0 : i-1] - common.LogDebugf("Found prefix for channel %q: %q (%q)\n", es.Channel, es.Prefix, emote) - break +// func loadEmotes() error { +// newEmotes := map[string]string{} + +// emotePNGs, err := filepath.Glob("./static/emotes/*.png") +// if err != nil { +// return 0, fmt.Errorf("unable to glob emote directory: %s\n", err) +// } + +// emoteGIFs, err := filepath.Glob("./static/emotes/*.gif") +// if err != nil { +// return 0, fmt.Errorf("unable to glob emote directory: %s\n", err) +// } +// globbed_files := []string(emotePNGs) +// globbed_files = append(globbed_files, emoteGIFs...) + +// LogInfoln("Loading emotes...") +// emInfo := []string{} +// for _, file := range globbed_files { +// file = filepath.Base(file) +// key := file[0 : len(file)-4] +// newEmotes[key] = file +// emInfo = append(emInfo, key) +// } +// Emotes = newEmotes +// LogInfoln(strings.Join(emInfo, " ")) +// return len(Emotes), nil +// } + +func loadEmotes() error { + fmt.Println(processEmoteDir(emoteDir)) + return nil +} + +func processEmoteDir(path string) ([]Emote, error) { + dir, err := os.Open(path) + if err != nil { + return nil, errors.Wrap(err, "could not open emoteDir:") + } + + files, err := dir.Readdir(0) + if err != nil { + return nil, errors.Wrap(err, "could not get files:") + } + + var emotes []Emote + for _, file := range files { + emotes = append(emotes, Emote{Dir: path, File: file.Name()}) + } + + subdir, err := dir.Readdirnames(0) + if err != nil { + return nil, errors.Wrap(err, "could not get sub directories:") + } + + for _, d := range subdir { + subEmotes, err := processEmoteDir(d) + if err != nil { + return nil, errors.Wrapf(err, "could not process sub directory \"%s\":", d) + } + emotes = append(emotes, subEmotes...) + } + + return emotes, nil +} + +func getEmotes(names []string) error { + users := getUserIDs(names) + users = append(users, TwitchUser{ID: "0", Login: "global"}) + + for _, user := range users { + emotes, cheers, err := getChannelEmotes(user.ID) + if err != nil { + return errors.Wrapf(err, "could not get emote data for \"%s\"", user.ID) + } + + emoteUserDir := path.Join(emoteDir, "twitch", user.Login) + if _, err := os.Stat(emoteUserDir); os.IsNotExist(err) { + os.MkdirAll(emoteUserDir, os.ModePerm) + } + + for _, emote := range emotes { + if !strings.ContainsAny(emote.Code, `:;\[]|?&`) { + filePath := path.Join(emoteUserDir, emote.Code+".png") + file, err := os.Create(filePath) + if err != nil { + + return errors.Wrapf(err, "could not create emote file in path \"%s\":", filePath) + } + + err = downloadEmote(emote.ID, file) + if err != nil { + return errors.Wrapf(err, "could not download emote %s:", emote.Code) } } } - resp, err := http.Get(url) - if err != nil { - return nil, err - } + for amount, sizes := range cheers { + name := fmt.Sprintf("%sCheer%s.gif", user.Login, amount) + filePath := path.Join(emoteUserDir, name) + file, err := os.Create(filePath) + if err != nil { + return errors.Wrapf(err, "could not create emote file in path \"%s\":", filePath) + } - f, err := os.Create(png) - if err != nil { - return nil, err - } - - _, err = io.Copy(f, resp.Body) - if err != nil { - return nil, err - } - } - - return es, nil -} - -func GetEmotes(names []string) ([]*EmoteSet, error) { - // Do this up-front - for i := 0; i < len(names); i++ { - names[i] = strings.ToLower(names[i]) - } - - channels, err := findChannels(names) - if err != nil { - return nil, fmt.Errorf("Error reading %q: %v", subscriberJson, err) - } - - emoteSets := []*EmoteSet{} - for _, c := range channels { - es, err := c.downloadEmotes() - if err != nil { - return nil, fmt.Errorf("Error downloading emotes: %v", err) - } - emoteSets = append(emoteSets, es) - } - - for _, es := range emoteSets { - found := false - for _, name := range names { - if es.Channel == name { - found = true - break + err = downloadCheerEmote(sizes["4"], file) + if err != nil { + return errors.Wrapf(err, "could not download emote %s:", name) } } - if !found { - es.Found = false - } } - - return emoteSets, nil + return nil } -func findChannels(names []string) ([]twitchChannel, error) { - file, err := os.Open(subscriberJson) +func getUserIDs(names []string) []TwitchUser { + logins := strings.Join(names, "&login=") + request, err := http.NewRequest("GET", fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", logins), nil) if err != nil { - return nil, err + log.Fatalln("Error generating new request:", err) } - defer file.Close() + request.Header.Set("Client-ID", settings.TwitchClientID) - data := []twitchChannel{} - dec := json.NewDecoder(file) - - // Open bracket - _, err = dec.Token() + client := http.Client{} + resp, err := client.Do(request) if err != nil { - return nil, err + log.Fatalln("Error sending request:", err) } - done := false - for dec.More() && !done { - // opening bracket of channel - _, err = dec.Token() - if err != nil { - return nil, err - } + decoder := json.NewDecoder(resp.Body) + type userResponse struct { + Data []TwitchUser + } + var data userResponse - // Decode the channel stuff - var c twitchChannel - err = dec.Decode(&c) - if err != nil { - return nil, err - } - - // Is this a channel we are looking for? - found := false - for _, search := range names { - if strings.ToLower(c.ChannelName) == search { - found = true - break - } - } - - // Yes it is. Add it to the data - if found { - data = append(data, c) - } - - // Check for completion. Don't bother parsing the rest of - // the json file if we've already found everything that we're - // looking for. - if len(data) == len(names) { - done = true - } + err = decoder.Decode(&data) + if err != nil { + log.Fatalln("Error decoding data:", err) } - return data, nil + return data.Data +} + +func getChannelEmotes(ID string) ([]EmoteInfo, map[string]map[string]string, error) { + resp, err := http.Get("https://api.twitchemotes.com/api/v4/channels/" + ID) + if err != nil { + return nil, nil, errors.Wrap(err, "could not get emotes") + } + decoder := json.NewDecoder(resp.Body) + + type EmoteResponse struct { + Emotes []EmoteInfo + Cheermotes map[string]map[string]string + } + var data EmoteResponse + + err = decoder.Decode(&data) + if err != nil { + return nil, nil, errors.Wrap(err, "could not decode emotes") + } + + return data.Emotes, data.Cheermotes, nil +} + +func downloadEmote(ID int, file *os.File) error { + resp, err := http.Get(fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%d/3.0", ID)) + if err != nil { + return errors.Errorf("could not download emote file %s: %v", file.Name(), err) + } + defer resp.Body.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return errors.Errorf("could not save emote: %v", err) + } + return nil +} + +func downloadCheerEmote(url string, file *os.File) error { + resp, err := http.Get(url) + if err != nil { + return errors.Errorf("could not download cheer file %s: %v", file.Name(), err) + } + defer resp.Body.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return errors.Errorf("could not save cheer: %v", err) + } + return nil } diff --git a/go.mod b/go.mod index 94936ce..de0ebd0 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983 // indirect github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431 github.com/ory/dockertest v3.3.4+incompatible // indirect - github.com/pkg/errors v0.8.1 // indirect + github.com/pkg/errors v0.8.1 github.com/sirupsen/logrus v1.4.1 // indirect github.com/stretchr/objx v0.2.0 // indirect golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a // indirect diff --git a/go.sum b/go.sum index 8b632a2..6ddf141 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,7 @@ github.com/ory/dockertest v3.3.2+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnh github.com/ory/dockertest v3.3.4+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/handlers.go b/handlers.go index c75d306..a7da689 100644 --- a/handlers.go +++ b/handlers.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "path" "path/filepath" "strings" "sync" @@ -79,7 +80,7 @@ func wsImages(w http.ResponseWriter, r *http.Request) { func wsEmotes(w http.ResponseWriter, r *http.Request) { emotefile := filepath.Base(r.URL.Path) - http.ServeFile(w, r, "./static/emotes/"+emotefile) + http.ServeFile(w, r, path.Join(emoteDir, emotefile)) } // Handling the websocket diff --git a/main.go b/main.go index ce470ef..ab013ff 100644 --- a/main.go +++ b/main.go @@ -14,9 +14,10 @@ import ( ) var ( - addr string - sKey string - stats = newStreamStats() + pullEmotes bool + addr string + sKey string + stats = newStreamStats() ) func setupSettings() error { @@ -42,6 +43,7 @@ func setupSettings() error { func main() { flag.StringVar(&addr, "l", "", "host:port of the MovieNight") flag.StringVar(&sKey, "k", "", "Stream key, to protect your stream") + flag.BoolVar(&pullEmotes, "e", false, "Pull emotes") flag.Parse() format.RegisterAll() @@ -51,6 +53,16 @@ func main() { os.Exit(1) } + if pullEmotes { + common.LogInfoln("Pulling emotes") + err := getEmotes(settings.ApprovedEmotes) + if err != nil { + common.LogErrorf("Error downloading emotes: %+v\n", err) + common.LogErrorf("Error downloading emotes: %v\n", err) + os.Exit(1) + } + } + if err := common.InitTemplates(); err != nil { common.LogErrorln(err) os.Exit(1) @@ -61,7 +73,8 @@ func main() { // Load emotes before starting server. var err error - if chat, err = newChatRoom(); err != nil { + chat, err = newChatRoom() + if err != nil { common.LogErrorln(err) os.Exit(1) } diff --git a/settings.go b/settings.go index f8f1717..086dc87 100644 --- a/settings.go +++ b/settings.go @@ -30,8 +30,9 @@ type Settings struct { AdminPassword string StreamKey string ListenAddress string - ApprovedEmotes []EmoteSet // list of channels that have been approved for emote use. Global emotes are always "approved". - SessionKey string // key for session data + ApprovedEmotes []string // list of channels that have been approved for emote use. Global emotes are always "approved". + TwitchClientID string // client id from twitch developers portal + SessionKey string // key for session data Bans []BanInfo LogLevel common.LogLevel LogFile string