commit 7ab1723a5eaf9fd66eaed30f4edf9d5f5f32c76f Author: bel Date: Tue Sep 14 06:30:17 2021 -0600 archive diff --git a/MovieNight/.gitignore b/MovieNight/.gitignore new file mode 100755 index 0000000..1e38249 --- /dev/null +++ b/MovieNight/.gitignore @@ -0,0 +1,46 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.aseprite + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Log files +*.log + +# GoCode debug file +debug + +# Linux binary +MovieNight + +# Windows binary +MovieNight.exe + +# Darwin binary +MovieNightDarwin + +# Twitch channel info +static/subscriber.json + +# This file now holds the stream key. Don't include it. +settings.json + +# vscode +.vscode + +# Autobuilt wasm files +static/main.wasm + +# tags for vim +tags + +# channel and emote list from twitch +subscribers.json diff --git a/MovieNight/.travis.yml b/MovieNight/.travis.yml new file mode 100755 index 0000000..55a3d40 --- /dev/null +++ b/MovieNight/.travis.yml @@ -0,0 +1,10 @@ +language: go + +before_install: + - make get + +go: + - 1.12.x + +env: + - GO111MODULE=on diff --git a/MovieNight/Dockerfile b/MovieNight/Dockerfile new file mode 100755 index 0000000..e990927 --- /dev/null +++ b/MovieNight/Dockerfile @@ -0,0 +1,18 @@ +FROM frolvlad/alpine-glibc:alpine-3.9_glibc-2.29 + +RUN apk update \ + && apk add --no-cache \ + ca-certificates \ + ffmpeg \ + bash + +RUN mkdir -p /var/log +WORKDIR /main + +COPY . . + +ENV GOPATH="" +ENV MNT="/mnt/" +ENTRYPOINT ["/main/MovieNight"] +CMD [] + diff --git a/MovieNight/Makefile b/MovieNight/Makefile new file mode 100755 index 0000000..79ad13f --- /dev/null +++ b/MovieNight/Makefile @@ -0,0 +1,53 @@ +# If a different version of Go is installed (via `go get`) set the GO_VERSION +# environment variable to that version. For example, setting it to "1.13.7" +# will run `go1.13.7 build [...]` instead of `go build [...]`. +# +# For info on installing extra versions, see this page: +# https://golang.org/doc/install#extra_versions + +TAGS= + +# Windows needs the .exe extension. +ifeq ($(OS),Windows_NT) +EXT=.exe +endif + +.PHONY: fmt vet get clean dev setdev test ServerMovieNight + +all: fmt vet test MovieNight$(EXT) static/main.wasm settings.json + +# Build the server deployment +server: ServerMovieNight static/main.wasm + +# Bulid used for deploying to my server. +ServerMovieNight: *.go common/*.go + GOOS=linux GOARCH=386 go$(GO_VERSION) build -o MovieNight $(TAGS) + +setdev: + $(eval export TAGS=-tags "dev") + +dev: setdev all + +MovieNight$(EXT): *.go common/*.go + go$(GO_VERSION) build -o $@ $(TAGS) + +static/main.wasm: wasm/*.go common/*.go + GOOS=js GOARCH=wasm go$(GO_VERSION) build -o $@ $(TAGS) wasm/*.go + +clean: + -rm MovieNight$(EXT) ./static/main.wasm + +fmt: + gofmt -w . + +vet: + go$(GO_VERSION) vet $(TAGS) ./... + GOOS=js GOARCH=wasm go$(GO_VERSION) vet $(TAGS) ./... + +test: + go$(GO_VERSION) test $(TAGS) ./... + +# Do not put settings_example.json here as a prereq to avoid overwriting +# the settings if the example is updated. +settings.json: + cp settings_example.json settings.json diff --git a/MovieNight/chatclient.go b/MovieNight/chatclient.go new file mode 100755 index 0000000..decfe4d --- /dev/null +++ b/MovieNight/chatclient.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "html" + "regexp" + "strings" + "time" + "unicode" + + "github.com/zorchenhimer/MovieNight/common" +) + +var ( + regexSpoiler = regexp.MustCompile(`\|\|(.*?)\|\|`) + spoilerStart = `` + spoilerEnd = `` +) + +type Client struct { + name string // Display name + conn *chatConnection + belongsTo *ChatRoom + color string + CmdLevel common.CommandLevel + IsColorForced bool + IsNameForced bool + regexName *regexp.Regexp + + // Times since last event. use time.Duration.Since() + nextChat time.Time // rate limit chat messages + nextNick time.Time // rate limit nickname changes + nextColor time.Time // rate limit color changes + nextAuth time.Time // rate limit failed auth attempts. Sould prolly have a backoff policy. + authTries int // number of failed auth attempts + + nextDuplicate time.Time + lastMsg string +} + +func NewClient(connection *chatConnection, room *ChatRoom, name, color string) (*Client, error) { + c := &Client{ + conn: connection, + belongsTo: room, + color: color, + } + + if err := c.setName(name); err != nil { + return nil, fmt.Errorf("could not set client name to %#v: %v", name, err) + } + + // Set initial vaules to their rate limit duration in the past. + c.nextChat = time.Now() + c.nextNick = time.Now() + c.nextColor = time.Now() + c.nextAuth = time.Now() + + return c, nil +} + +//Client has a new message to broadcast +func (cl *Client) NewMsg(data common.ClientData) { + return +} + +func (cl *Client) SendChatData(data common.ChatData) error { + // Don't send chat or event data to clients that have not fully joined the + // chatroom (ie, they have not set a name). + if cl.name == "" && (data.Type == common.DTChat || data.Type == common.DTEvent) { + return nil + } + + // Colorize name on chat messages + if data.Type == common.DTChat { + var err error + data = cl.replaceColorizedName(data) + if err != nil { + return fmt.Errorf("could not colorize name: %v", err) + } + } + + cd, err := data.ToJSON() + if err != nil { + return fmt.Errorf("could not create ChatDataJSON of type %d: %v", data.Type, err) + } + return cl.Send(cd) +} + +func (cl *Client) Send(data common.ChatDataJSON) error { + err := cl.conn.WriteData(data) + if err != nil { + return fmt.Errorf("could not send message: %v", err) + } + return nil +} + +func (cl *Client) SendServerMessage(s string) error { + err := cl.SendChatData(common.NewChatMessage("", ColorServerMessage, s, common.CmdlUser, common.MsgServer)) + if err != nil { + return fmt.Errorf("could send server message to %s: message - %#v: %v", cl.name, s, err) + } + return nil +} + +// Make links clickable +func formatLinks(input string) string { + newMsg := []string{} + for _, word := range strings.Split(input, " ") { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + word = html.UnescapeString(word) + word = fmt.Sprintf(`%s`, word, word) + } + newMsg = append(newMsg, word) + } + return strings.Join(newMsg, " ") +} + +//Exiting out +func (cl *Client) Exit() { + cl.belongsTo.Leave(cl.name, cl.color) +} + +// Outgoing messages +func (cl *Client) Message(msg string) { + msg = common.ParseEmotes(msg) + cl.belongsTo.AddMsg(cl, false, false, msg) +} + +// Outgoing /me command +func (cl *Client) Me(msg string) { + msg = common.ParseEmotes(msg) + cl.belongsTo.AddMsg(cl, true, false, msg) +} + +func (cl *Client) Mod() { + if cl.CmdLevel < common.CmdlMod { + cl.CmdLevel = common.CmdlMod + } +} + +func (cl *Client) Unmod() { + cl.CmdLevel = common.CmdlUser +} + +func (cl *Client) Host() string { + return cl.conn.Host() +} + +func (cl *Client) setName(s string) error { + cl.name = s + if cl.conn != nil { + cl.conn.clientName = s + } + return nil +} + +func (cl *Client) setColor(s string) error { + cl.color = s + return cl.SendChatData(common.NewChatHiddenMessage(common.CdColor, cl.color)) +} + +func (cl *Client) replaceColorizedName(chatData common.ChatData) common.ChatData { + data := chatData.Data.(common.DataMessage) + words := strings.Split(data.Message, " ") + newWords := []string{} + + for _, word := range words { + if strings.ToLower(word) == strings.ToLower(cl.name) || strings.ToLower(word) == strings.ToLower("@"+cl.name) { + newWords = append(newWords, ``+word+``) + } else { + newWords = append(newWords, word) + } + } + + data.Message = strings.Join(newWords, " ") + chatData.Data = data + return chatData +} + +var dumbSpaces = []string{ + "\n", + "\t", + "\r", + "\u200b", +} + +func removeDumbSpaces(msg string) string { + for _, ds := range dumbSpaces { + msg = strings.ReplaceAll(msg, ds, " ") + } + + newMsg := "" + for _, r := range msg { + if unicode.IsSpace(r) { + newMsg += " " + } else { + newMsg += string(r) + } + } + return newMsg +} + +func addSpoilerTags(msg string) string { + return regexSpoiler.ReplaceAllString(msg, fmt.Sprintf(`%s$1%s`, spoilerStart, spoilerEnd)) +} diff --git a/MovieNight/chatclient_test.go b/MovieNight/chatclient_test.go new file mode 100755 index 0000000..d7dce99 --- /dev/null +++ b/MovieNight/chatclient_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "testing" + + "github.com/zorchenhimer/MovieNight/common" +) + +func TestClient_addSpoilerTag(t *testing.T) { + data := [][]string{ + {"||||", spoilerStart + spoilerEnd}, + {"|||||", spoilerStart + spoilerEnd + "|"}, + {"||||||", spoilerStart + spoilerEnd + "||"}, + {"|||||||", spoilerStart + spoilerEnd + "|||"}, + {"||||||||", spoilerStart + spoilerEnd + spoilerStart + spoilerEnd}, + {"||test||", spoilerStart + "test" + spoilerEnd}, + {"|| ||", spoilerStart + " " + spoilerEnd}, + {"|s|||", "|s|||"}, + } + + for i := range data { + s := addSpoilerTags(data[i][0]) + if s != data[i][1] { + t.Errorf("expected %#v, got %#v with %#v", data[i][1], s, data[i][0]) + } + } +} + +// Name highlighting should not interfere with emotes +func TestClient_emoteHighlight(t *testing.T) { + data := [][]string{ + {"zorchenhimer", `zorchenhimer`}, + {"@zorchenhimer", `@zorchenhimer`}, + {"Zorchenhimer", `Zorchenhimer`}, + {"@Zorchenhimer", `@Zorchenhimer`}, + {"hello zorchenhimer", `hello zorchenhimer`}, + {"hello zorchenhimer ass", `hello zorchenhimer ass`}, + {``, ``}, + {`zorchenhimer `, `zorchenhimer `}, + } + + client, err := NewClient(nil, nil, "Zorchenhimer", "#9547ff") + if err != nil { + t.Errorf("Client init error: %v", err) + } + + for _, d := range data { + chatData := client.replaceColorizedName(common.NewChatMessage(client.name, client.color, d[0], common.CmdlUser, common.MsgChat)) + if chatData.Data.(common.DataMessage).Message != d[1] { + t.Errorf("\nExpected:\n\t%s\nReceived\n\t%s", d[1], chatData.Data.(common.DataMessage).Message) + } else { + t.Logf("Passed %s", d[0]) + } + } +} diff --git a/MovieNight/chatcommands.go b/MovieNight/chatcommands.go new file mode 100755 index 0000000..7c499d8 --- /dev/null +++ b/MovieNight/chatcommands.go @@ -0,0 +1,645 @@ +package main + +import ( + "fmt" + "html" + "strings" + "time" + + "github.com/zorchenhimer/MovieNight/common" +) + +type CommandControl struct { + user map[string]Command + mod map[string]Command + admin map[string]Command +} + +type Command struct { + HelpText string + Function CommandFunction +} + +type CommandFunction func(client *Client, args []string) (string, error) + +var commands = &CommandControl{ + user: map[string]Command{ + common.CNMe.String(): Command{ + HelpText: "Display an action message.", + Function: func(client *Client, args []string) (string, error) { + if len(args) != 0 { + client.Me(strings.Join(args, " ")) + return "", nil + } + return "", fmt.Errorf("Missing a message") + }, + }, + + common.CNHelp.String(): Command{ + HelpText: "This help text.", + Function: cmdHelp, + }, + + common.CNEmotes.String(): Command{ + HelpText: "Display a list of available emotes.", + Function: func(client *Client, args []string) (string, error) { + client.SendChatData(common.NewChatCommand(common.CmdEmotes, []string{"/emotes"})) + return "Opening emote list in new window.", nil + }, + }, + + common.CNCount.String(): Command{ + HelpText: "Display number of users in chat.", + Function: func(client *Client, args []string) (string, error) { + return fmt.Sprintf("Users in chat: %d", client.belongsTo.UserCount()), nil + }, + }, + + common.CNColor.String(): Command{ + HelpText: "Change user color.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) > 2 { + return "", fmt.Errorf("Too many arguments!") + } + + // If the caller is privileged enough, they can change the color of another user + if len(args) == 2 { + if cl.CmdLevel == common.CmdlUser { + return "", fmt.Errorf("You cannot change someone else's color. PeepoSus") + } + + name, color := "", "" + + if strings.ToLower(args[0]) == strings.ToLower(args[1]) || + (common.IsValidColor(args[0]) && common.IsValidColor(args[1])) { + return "", fmt.Errorf("Name and color are ambiguous. Prefix the name with '@' or color with '#'") + } + + // Check for explicit name + if strings.HasPrefix(args[0], "@") { + name = strings.TrimLeft(args[0], "@") + color = args[1] + common.LogDebugln("[color:mod] Found explicit name: ", name) + } else if strings.HasPrefix(args[1], "@") { + name = strings.TrimLeft(args[1], "@") + color = args[0] + common.LogDebugln("[color:mod] Found explicit name: ", name) + + // Check for explicit color + } else if strings.HasPrefix(args[0], "#") { + name = strings.TrimPrefix(args[1], "@") // this shouldn't be needed, but just in case. + color = args[0] + common.LogDebugln("[color:mod] Found explicit color: ", color) + } else if strings.HasPrefix(args[1], "#") { + name = strings.TrimPrefix(args[0], "@") // this shouldn't be needed, but just in case. + color = args[1] + common.LogDebugln("[color:mod] Found explicit color: ", color) + + // Guess + } else if common.IsValidColor(args[0]) { + name = strings.TrimPrefix(args[1], "@") + color = args[0] + common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color) + } else if common.IsValidColor(args[1]) { + name = strings.TrimPrefix(args[0], "@") + color = args[1] + common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color) + } + + if name == "" { + return "", fmt.Errorf("Cannot determine name. Prefix name with @.") + } + if color == "" { + return "", fmt.Errorf("Cannot determine color. Prefix name with @.") + } + + if color == "" { + common.LogInfof("[color:mod] %s missing color\n", cl.name) + return "", fmt.Errorf("Missing color") + } + + if name == "" { + common.LogInfof("[color:mod] %s missing name\n", cl.name) + return "", fmt.Errorf("Missing name") + } + + if err := cl.belongsTo.ForceColorChange(name, color); err != nil { + return "", err + } + return fmt.Sprintf("Color changed for user %s to %s\n", name, color), nil + } + + // Don't allow an unprivileged user to change their color if + // it was changed by a mod + if cl.IsColorForced { + common.LogInfof("[color] %s tried to change a forced color\n", cl.name) + return "", fmt.Errorf("You are not allowed to change your color.") + } + + if time.Now().Before(cl.nextColor) && cl.CmdLevel == common.CmdlUser { + return "", fmt.Errorf("Slow down. You can change your color in %0.0f seconds.", time.Until(cl.nextColor).Seconds()) + } + + if len(args) == 0 { + cl.setColor(common.RandomColor()) + return "Random color chosen: " + cl.color, nil + } + + // Change the color of the user + if !common.IsValidColor(args[0]) { + return "", fmt.Errorf("To choose a specific color use the format /color #c029ce. Hex values expected.") + } + + cl.nextColor = time.Now().Add(time.Second * settings.RateLimitColor) + + err := cl.setColor(args[0]) + if err != nil { + common.LogErrorf("[color] could not send color update to client: %v\n", err) + } + + common.LogInfof("[color] %s new color: %s\n", cl.name, cl.color) + return "Color changed successfully.", nil + }, + }, + + common.CNWhoAmI.String(): Command{ + HelpText: "Shows debug user info", + Function: func(cl *Client, args []string) (string, error) { + return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t", + cl.name, + cl.CmdLevel >= common.CmdlMod, + cl.CmdLevel == common.CmdlAdmin), nil + }, + }, + + common.CNAuth.String(): Command{ + HelpText: "Authenticate to admin", + Function: func(cl *Client, args []string) (string, error) { + if cl.CmdLevel == common.CmdlAdmin { + return "", fmt.Errorf("You are already authenticated.") + } + + // TODO: handle back off policy + if time.Now().Before(cl.nextAuth) { + cl.nextAuth = time.Now().Add(time.Second * settings.RateLimitAuth) + return "", fmt.Errorf("Slow down.") + } + cl.authTries += 1 // this isn't used yet + cl.nextAuth = time.Now().Add(time.Second * settings.RateLimitAuth) + + pw := html.UnescapeString(strings.Join(args, " ")) + + if settings.AdminPassword == pw { + cl.CmdLevel = common.CmdlAdmin + cl.belongsTo.AddModNotice(cl.name + " used the admin password") + common.LogInfof("[auth] %s used the admin password\n", cl.name) + return "Admin rights granted.", nil + } + + if cl.belongsTo.redeemModPass(pw) { + cl.CmdLevel = common.CmdlMod + cl.belongsTo.AddModNotice(cl.name + " used a mod password") + common.LogInfof("[auth] %s used a mod password\n", cl.name) + return "Moderator privileges granted.", nil + } + + cl.belongsTo.AddModNotice(cl.name + " attempted to auth without success") + common.LogInfof("[auth] %s gave an invalid password\n", cl.name) + return "", fmt.Errorf("Invalid password.") + }, + }, + + common.CNUsers.String(): Command{ + HelpText: "Show a list of users in chat", + Function: func(cl *Client, args []string) (string, error) { + names := cl.belongsTo.GetNames() + return strings.Join(names, " "), nil + }, + }, + + common.CNNick.String(): Command{ + HelpText: "Change display name", + Function: func(cl *Client, args []string) (string, error) { + if time.Now().Before(cl.nextNick) && cl.CmdLevel == common.CmdlUser { + //cl.nextNick = time.Now().Add(time.Second * settings.RateLimitNick) + return "", fmt.Errorf("Slow down. You can change your nick in %0.0f seconds.", time.Until(cl.nextNick).Seconds()) + } + cl.nextNick = time.Now().Add(time.Second * settings.RateLimitNick) + + if len(args) == 0 { + return "", fmt.Errorf("Missing name to change to.") + } + + newName := strings.TrimLeft(args[0], "@") + oldName := cl.name + forced := false + + // Two arguments to force a name change on another user: `/nick OldName NewName` + if len(args) == 2 { + if cl.CmdLevel == common.CmdlUser { + return "", fmt.Errorf("Only admins and mods can do that PeepoSus") + } + + oldName = strings.TrimLeft(args[0], "@") + newName = strings.TrimLeft(args[1], "@") + forced = true + } + + if len(args) == 1 && cl.IsNameForced && cl.CmdLevel != common.CmdlAdmin { + return "", fmt.Errorf("You cannot change your name once it has been changed by an admin.") + } + + err := cl.belongsTo.changeName(oldName, newName, forced) + if err != nil { + return "", fmt.Errorf("Unable to change name: " + err.Error()) + } + + return "", nil + }, + }, + + common.CNStats.String(): Command{ + HelpText: "Show some stats for stream.", + Function: func(cl *Client, args []string) (string, error) { + cl.belongsTo.clientsMtx.Lock() + users := len(cl.belongsTo.clients) + cl.belongsTo.clientsMtx.Unlock() + + // Just print max users and time alive here + return fmt.Sprintf("Current users in chat: %d
Max users in chat: %d
Server uptime: %s
Stream uptime: %s", + users, + stats.getMaxUsers(), + time.Since(stats.start), + stats.getStreamLength(), + ), nil + }, + }, + + common.CNPin.String(): Command{ + HelpText: "Display the current room access type and pin/password (if applicable).", + Function: func(cl *Client, args []string) (string, error) { + switch settings.RoomAccess { + case AccessPin: + return "Room is secured via PIN. Current PIN: " + settings.RoomAccessPin, nil + case AccessRequest: + return "Room is secured via access requests. Users must request to be granted access.", nil + } + return "Room is open access. Anybody can join.", nil + }, + }, + }, + + mod: map[string]Command{ + common.CNSv.String(): Command{ + HelpText: "Send a server announcement message. It will show up red with a border in chat.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("Missing message") + } + svmsg := formatLinks(strings.Join(common.ParseEmotesArray(args), " ")) + cl.belongsTo.AddModNotice("Server message from " + cl.name) + cl.belongsTo.AddMsg(cl, false, true, svmsg) + return "", nil + }, + }, + + common.CNPlaying.String(): Command{ + HelpText: "Set the title text and info link.", + Function: func(cl *Client, args []string) (string, error) { + // Clear/hide title if sent with no arguments. + if len(args) == 0 { + cl.belongsTo.ClearPlaying() + return "Title cleared", nil + } + link := "" + title := "" + + // pick out the link (can be anywhere, as long as there are no spaces). + for _, word := range args { + word = html.UnescapeString(word) + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + link = word + } else { + title = title + " " + word + } + } + + title = strings.TrimSpace(title) + link = strings.TrimSpace(link) + + if len(title) > settings.TitleLength { + return "", fmt.Errorf("Title too long (%d/%d)", len(title), settings.TitleLength) + } + + // Send a notice to the mods and admins + if len(link) == 0 { + cl.belongsTo.AddModNotice(cl.name + " set the playing title to '" + title + "' with no link") + } else { + cl.belongsTo.AddModNotice(cl.name + " set the playing title to '" + title + "' with link '" + link + "'") + } + + cl.belongsTo.SetPlaying(title, link) + return "", nil + }, + }, + + common.CNUnmod.String(): Command{ + HelpText: "Revoke a user's moderator privilages. Moderators can only unmod themselves.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) > 0 && cl.CmdLevel != common.CmdlAdmin && cl.name != args[0] { + return "", fmt.Errorf("You can only unmod yourself, not others.") + } + + if len(args) == 0 || (len(args) == 1 && strings.TrimLeft(args[0], "@") == cl.name) { + cl.Unmod() + cl.belongsTo.AddModNotice(cl.name + " has unmodded themselves") + return "You have unmodded yourself.", nil + } + name := strings.TrimLeft(args[0], "@") + + if err := cl.belongsTo.Unmod(name); err != nil { + return "", err + } + + cl.belongsTo.AddModNotice(cl.name + " has unmodded " + name) + return "", nil + }, + }, + + common.CNKick.String(): Command{ + HelpText: "Kick a user from chat.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("Missing name to kick.") + } + return "", cl.belongsTo.Kick(strings.TrimLeft(args[0], "@")) + }, + }, + + common.CNBan.String(): Command{ + HelpText: "Ban a user from chat. They will not be able to re-join chat, but will still be able to view the stream.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("missing name to ban.") + } + + name := strings.TrimLeft(args[0], "@") + common.LogInfof("[ban] Attempting to ban %s\n", name) + return "", cl.belongsTo.Ban(name) + }, + }, + + common.CNUnban.String(): Command{ + HelpText: "Remove a ban on a user.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("missing name to unban.") + } + name := strings.TrimLeft(args[0], "@") + common.LogInfof("[ban] Attempting to unban %s\n", name) + + err := settings.RemoveBan(name) + if err != nil { + return "", err + } + cl.belongsTo.AddModNotice(cl.name + " has unbanned " + name) + return "", nil + }, + }, + + common.CNPurge.String(): Command{ + HelpText: "Purge the chat.", + Function: func(cl *Client, args []string) (string, error) { + common.LogInfoln("[purge] clearing chat") + cl.belongsTo.AddCmdMsg(common.CmdPurgeChat, nil) + return "", nil + }, + }, + }, + + admin: map[string]Command{ + common.CNMod.String(): Command{ + HelpText: "Grant moderator privilages to a user.", + Function: func(cl *Client, args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("Missing user to mod.") + } + + name := strings.TrimLeft(args[0], "@") + if err := cl.belongsTo.Mod(name); err != nil { + return "", err + } + cl.belongsTo.AddModNotice(cl.name + " has modded " + name) + return "", nil + }, + }, + + common.CNReloadPlayer.String(): Command{ + HelpText: "Reload the stream player for everybody in chat.", + Function: func(cl *Client, args []string) (string, error) { + cl.belongsTo.AddModNotice(cl.name + " has modded forced a player reload") + cl.belongsTo.AddCmdMsg(common.CmdRefreshPlayer, nil) + return "Reloading player for all chatters.", nil + }, + }, + + common.CNReloadEmotes.String(): Command{ + HelpText: "Reload the emotes on the server.", + Function: func(cl *Client, args []string) (string, error) { + go commandReloadEmotes(cl) + return "Reloading emotes...", nil + }, + }, + + common.CNModpass.String(): Command{ + HelpText: "Generate a single-use mod password.", + Function: func(cl *Client, args []string) (string, error) { + cl.belongsTo.AddModNotice(cl.name + " generated a mod password") + password := cl.belongsTo.generateModPass() + return "Single use password: " + password, nil + }, + }, + + common.CNRoomAccess.String(): Command{ + HelpText: "Change the room access type.", + Function: func(cl *Client, args []string) (string, error) { + // Print current access type if no arguments given + if len(args) == 0 { + return "Current room access type: " + string(settings.RoomAccess), nil + } + + switch AccessMode(strings.ToLower(args[0])) { + case AccessOpen: + settings.RoomAccess = AccessOpen + common.LogInfoln("[access] Room set to open") + return "Room access set to open", nil + + case AccessPin: + // A pin/password was provided, use it. + if len(args) == 2 { + // TODO: make this a bit more robust. Currently, only accepts a single word as a pin/password + settings.RoomAccessPin = args[1] + + // A pin/password was not provided, generate a new one. + } else { + _, err := settings.generateNewPin() + if err != nil { + common.LogErrorln("Error generating new access pin: ", err.Error()) + return "", fmt.Errorf("Unable to generate a new pin, access unchanged: " + err.Error()) + } + } + settings.RoomAccess = AccessPin + common.LogInfoln("[access] Room set to pin: " + settings.RoomAccessPin) + return "Room access set to Pin: " + settings.RoomAccessPin, nil + + case AccessRequest: + settings.RoomAccess = AccessRequest + common.LogInfoln("[access] Room set to request") + return "Room access set to request. WARNING: this isn't implemented yet.", nil + + default: + return "", fmt.Errorf("Invalid access mode") + } + }, + }, + + common.CNIP.String(): Command{ + HelpText: "List users and IP in the server console. Requires logging level to be set to info or above.", + Function: func(cl *Client, args []string) (string, error) { + cl.belongsTo.clientsMtx.Lock() + common.LogInfoln("Clients:") + for id, client := range cl.belongsTo.clients { + common.LogInfof(" [%d] %s %s\n", id, client.name, client.conn.Host()) + } + cl.belongsTo.clientsMtx.Unlock() + return "see console for output", nil + }, + }, + + common.CNAddEmotes.String(): Command{ + HelpText: "Add emotes from a given twitch channel.", + Function: func(cl *Client, args []string) (string, error) { + // Fire this off in it's own goroutine so the client doesn't + // block waiting for the emote download to finish. + go func() { + + // Pretty sure this breaks on partial downloads (eg, one good channel and one non-existent) + err := getEmotes(args) + if err != nil { + cl.SendChatData(common.NewChatMessage("", "", + err.Error(), + common.CmdlUser, common.MsgCommandResponse)) + return + } + + // If the emotes were able to be downloaded, add the channels to settings + settings.AddApprovedEmotes(args) + + // reload emotes now that new ones were added + err = loadEmotes() + if err != nil { + cl.SendChatData(common.NewChatMessage("", "", + err.Error(), + common.CmdlUser, common.MsgCommandResponse)) + return + } + + cl.belongsTo.AddModNotice(cl.name + " has added emotes from the following channels: " + strings.Join(args, ", ")) + + commandReloadEmotes(cl) + }() + return "Emote download initiated for the following channels: " + strings.Join(args, ", "), nil + }, + }, + }, +} + +func (cc *CommandControl) RunCommand(command string, args []string, sender *Client) (string, error) { + // get correct command from combined commands + cmd := common.GetFullChatCommand(command) + + // Look for user command + if userCmd, ok := cc.user[cmd]; ok { + common.LogInfof("[user] %s /%s %s\n", sender.name, command, strings.Join(args, " ")) + return userCmd.Function(sender, args) + } + + // Look for mod command + if modCmd, ok := cc.mod[cmd]; ok { + if sender.CmdLevel >= common.CmdlMod { + common.LogInfof("[mod] %s /%s %s\n", sender.name, command, strings.Join(args, " ")) + return modCmd.Function(sender, args) + } + + common.LogInfof("[mod REJECTED] %s /%s %s\n", sender.name, command, strings.Join(args, " ")) + return "", fmt.Errorf("You are not a mod Jebaited") + } + + // Look for admin command + if adminCmd, ok := cc.admin[cmd]; ok { + if sender.CmdLevel == common.CmdlAdmin { + common.LogInfof("[admin] %s /%s %s\n", sender.name, command, strings.Join(args, " ")) + return adminCmd.Function(sender, args) + } + common.LogInfof("[admin REJECTED] %s /%s %s\n", sender.name, command, strings.Join(args, " ")) + return "", fmt.Errorf("You are not the admin Jebaited") + } + + // Command not found + common.LogInfof("[cmd|error] %s /%s %s\n", sender.name, command, strings.Join(args, " ")) + return "", fmt.Errorf("Invalid command.") +} + +func cmdHelp(cl *Client, args []string) (string, error) { + url := "/help" + + if cl.CmdLevel >= common.CmdlMod { + url += "?mod=1" + } + + if cl.CmdLevel == common.CmdlAdmin { + url += "&admin=1" + } + + cl.SendChatData(common.NewChatCommand(common.CmdHelp, []string{url})) + return `Opening help in new window.`, nil +} + +func getHelp(lvl common.CommandLevel) map[string]string { + var cmdList map[string]Command + switch lvl { + case common.CmdlUser: + cmdList = commands.user + case common.CmdlMod: + cmdList = commands.mod + case common.CmdlAdmin: + cmdList = commands.admin + } + + helptext := map[string]string{} + for name, cmd := range cmdList { + helptext[name] = cmd.HelpText + } + return helptext +} + +func commandReloadEmotes(cl *Client) { + cl.SendServerMessage("Reloading emotes") + err := loadEmotes() + if err != nil { + common.LogErrorf("Unbale to reload emotes: %s\n", err) + //return "", err + + cl.SendChatData(common.NewChatMessage("", "", + err.Error(), + common.CmdlUser, common.MsgCommandResponse)) + return + } + + cl.belongsTo.AddChatMsg(common.NewChatHiddenMessage(common.CdEmote, common.Emotes)) + cl.belongsTo.AddModNotice(cl.name + " has reloaded emotes") + + num := len(common.Emotes) + common.LogInfof("Loaded %d emotes\n", num) + cl.belongsTo.AddModNotice(fmt.Sprintf("%s reloaded %d emotes.", cl.name, num)) +} diff --git a/MovieNight/chatroom.go b/MovieNight/chatroom.go new file mode 100755 index 0000000..5632bb8 --- /dev/null +++ b/MovieNight/chatroom.go @@ -0,0 +1,475 @@ +package main + +import ( + "fmt" + + "strings" + "sync" + "time" + + "github.com/zorchenhimer/MovieNight/common" +) + +const ( + ColorServerMessage string = "#ea6260" +) + +type ChatRoom struct { + clients []*Client // this needs to be a pointer. key is suid. + clientsMtx sync.Mutex + + queue chan common.ChatData + modqueue chan common.ChatData // mod and admin broadcast messages + + playing string + playingLink string + + modPasswords []string // single-use mod passwords + modPasswordsMtx sync.Mutex +} + +//initializing the chatroom +func newChatRoom() (*ChatRoom, error) { + cr := &ChatRoom{ + queue: make(chan common.ChatData, 1000), + modqueue: make(chan common.ChatData, 1000), + clients: []*Client{}, + } + + err := loadEmotes() + if err != nil { + return nil, fmt.Errorf("error loading emotes: %s", err) + } + common.LogInfof("Loaded %d emotes\n", len(common.Emotes)) + + //the "heartbeat" for broadcasting messages + go cr.Broadcast() + return cr, nil +} + +// A new client joined +func (cr *ChatRoom) Join(conn *chatConnection, data common.JoinData) (*Client, error) { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() + + sendHiddenMessage := func(cd common.ClientDataType, i interface{}) { + // If the message cant be converted, then just don't send + if d, err := common.NewChatHiddenMessage(cd, i).ToJSON(); err == nil { + conn.WriteJSON(d) + } + } + + if settings.RoomAccess == AccessPin && data.Name == settings.RoomAccessPin { + sendHiddenMessage(common.CdNotify, "That's the access pin! Please enter a name.") + return nil, UserFormatError{Name: data.Name} + } + + if !common.IsValidName(data.Name) { + sendHiddenMessage(common.CdNotify, common.InvalidNameError) + return nil, UserFormatError{Name: data.Name} + } + + nameLower := strings.ToLower(data.Name) + for _, client := range cr.clients { + if strings.ToLower(client.name) == nameLower { + sendHiddenMessage(common.CdNotify, "Name already taken") + return nil, UserTakenError{Name: data.Name} + } + } + + // If color is invalid, then set it to a random color + if !common.IsValidColor(data.Color) { + data.Color = common.RandomColor() + } + + client, err := NewClient(conn, cr, data.Name, data.Color) + if err != nil { + sendHiddenMessage(common.CdNotify, "Could not join client") + return nil, fmt.Errorf("Unable to join client: %v", err) + } + + // Overwrite to use client instead + sendHiddenMessage = func(cd common.ClientDataType, i interface{}) { + client.SendChatData(common.NewChatHiddenMessage(cd, i)) + } + + host := client.Host() + + if banned, names := settings.IsBanned(host); banned { + sendHiddenMessage(common.CdNotify, "You are banned") + return nil, newBannedUserError(host, data.Name, names) + } + + cr.clients = append(cr.clients, client) + + common.LogChatf("[join] %s %s\n", host, data.Color) + playingCommand, err := common.NewChatCommand(common.CmdPlaying, []string{cr.playing, cr.playingLink}).ToJSON() + if err != nil { + common.LogErrorf("Unable to encode playing command on join: %s\n", err) + } else { + client.Send(playingCommand) + } + cr.AddEventMsg(common.EvJoin, data.Name, data.Color) + + sendHiddenMessage(common.CdJoin, nil) + sendHiddenMessage(common.CdEmote, common.Emotes) + + stats.updateMaxUsers(len(cr.clients)) + + return client, nil +} + +// TODO: fix this up a bit. kick and leave are the same, incorrect, error: "That +// name was already used!" leaving the chatroom +func (cr *ChatRoom) Leave(name, color string) { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() //preventing simultaneous access to the `clients` map + + client, id, err := cr.getClient(name) + if err != nil { + common.LogErrorf("[leave] Unable to get client suid %v\n", err) + return + } + host := client.Host() + name = client.name // grab the name from here for proper capitalization + client.conn.Close() + cr.delClient(id) + + cr.AddEventMsg(common.EvLeave, name, color) + common.LogChatf("[leave] %s %s\n", host, name) +} + +// kicked from the chatroom +func (cr *ChatRoom) Kick(name string) error { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() //preventing simultaneous access to the `clients` map + + client, id, err := cr.getClient(name) + if err != nil { + return fmt.Errorf("Unable to get client for name " + name) + } + + if client.CmdLevel == common.CmdlMod { + return fmt.Errorf("You cannot kick another mod.") + } + + if client.CmdLevel == common.CmdlAdmin { + return fmt.Errorf("Jebaited No.") + } + + color := client.color + host := client.Host() + client.conn.Close() + cr.delClient(id) + + cr.AddEventMsg(common.EvKick, name, color) + common.LogInfof("[kick] %s %s has been kicked\n", host, name) + return nil +} + +func (cr *ChatRoom) Ban(name string) error { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() + + client, id, err := cr.getClient(name) + if err != nil { + common.LogErrorf("[ban] Unable to get client for name %q\n", name) + return fmt.Errorf("Cannot find that name") + } + + if client.CmdLevel == common.CmdlAdmin { + return fmt.Errorf("You cannot ban an admin Jebaited") + } + + names := []string{} + host := client.Host() + color := client.color + + // Remove the named client + client.conn.Close() + cr.delClient(id) + + // Remove additional clients on that IP address + for id, c := range cr.clients { + if c.Host() == host { + names = append(names, client.name) + client.conn.Close() + cr.delClient(id) + } + } + + err = settings.AddBan(host, names) + if err != nil { + common.LogErrorf("[BAN] Error banning %q: %s\n", name, err) + cr.AddEventMsg(common.EvKick, name, color) + } else { + cr.AddEventMsg(common.EvBan, name, color) + } + return nil +} + +// Add a chat message from a viewer +func (cr *ChatRoom) AddMsg(from *Client, isAction, isServer bool, msg string) { + t := common.MsgChat + + if isAction { + t = common.MsgAction + } + + if isServer { + t = common.MsgServer + } + + cr.AddChatMsg(common.NewChatMessage(from.name, from.color, msg, from.CmdLevel, t)) +} + +// Add a chat message object to the queue +func (cr *ChatRoom) AddChatMsg(data common.ChatData) { + select { + case cr.queue <- data: + default: + common.LogErrorln("Unable to queue chat message. Channel full.") + } +} + +func (cr *ChatRoom) AddCmdMsg(command common.CommandType, args []string) { + select { + case cr.queue <- common.NewChatCommand(command, args): + default: + common.LogErrorln("Unable to queue command message. Channel full.") + } +} + +func (cr *ChatRoom) AddModNotice(message string) { + select { + case cr.modqueue <- common.NewChatMessage("", "", message, common.CmdlUser, common.MsgNotice): + default: + common.LogErrorln("Unable to queue notice. Channel full.") + } +} + +func (cr *ChatRoom) AddEventMsg(event common.EventType, name, color string) { + select { + case cr.queue <- common.NewChatEvent(event, name, color): + default: + common.LogErrorln("Unable to queue event message. Channel full.") + } +} + +func (cr *ChatRoom) Unmod(name string) error { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() + + client, _, err := cr.getClient(name) + if err != nil { + return err + } + + client.Unmod() + client.SendServerMessage(`You have been unmodded.`) + return nil +} + +func (cr *ChatRoom) Mod(name string) error { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() + + client, _, err := cr.getClient(name) + if err != nil { + return err + } + + if client.CmdLevel < common.CmdlMod { + client.CmdLevel = common.CmdlMod + client.SendServerMessage(`You have been modded.`) + } + return nil +} + +func (cr *ChatRoom) ForceColorChange(name, color string) error { + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() + + client, _, err := cr.getClient(name) + if err != nil { + return err + } + + client.IsColorForced = true + client.color = color + return nil +} + +func (cr *ChatRoom) UserCount() int { + return len(cr.clients) +} + +//broadcasting all the messages in the queue in one block +func (cr *ChatRoom) Broadcast() { + send := func(data common.ChatData, client *Client) { + err := client.SendChatData(data) + if err != nil { + common.LogErrorf("Error sending data to client: %v\n", err) + } + } + + for { + select { + case msg := <-cr.queue: + cr.clientsMtx.Lock() + for _, client := range cr.clients { + go send(msg, client) + } + cr.clientsMtx.Unlock() + case msg := <-cr.modqueue: + cr.clientsMtx.Lock() + for _, client := range cr.clients { + if client.CmdLevel >= common.CmdlMod { + send(msg, client) + } + } + cr.clientsMtx.Unlock() + default: + time.Sleep(50 * time.Millisecond) + // No messages to send + // This default block is required so the above case + // does not block. + } + } +} + +func (cr *ChatRoom) ClearPlaying() { + cr.playing = "" + cr.playingLink = "" + cr.AddCmdMsg(common.CmdPlaying, []string{"", ""}) +} + +func (cr *ChatRoom) SetPlaying(title, link string) { + cr.playing = title + cr.playingLink = link + cr.AddCmdMsg(common.CmdPlaying, []string{title, link}) +} + +func (cr *ChatRoom) GetNames() []string { + names := []string{} + defer cr.clientsMtx.Unlock() + cr.clientsMtx.Lock() + + for _, val := range cr.clients { + names = append(names, val.name) + } + + return names +} + +func (cr *ChatRoom) delClient(sliceId int) { + cr.clients = append(cr.clients[:sliceId], cr.clients[sliceId+1:]...) +} + +func (cr *ChatRoom) getClient(name string) (*Client, int, error) { + for id, client := range cr.clients { + if client.name == name { + return client, id, nil + } + } + + return nil, -1, fmt.Errorf("client with that name not found") +} + +func (cr *ChatRoom) generateModPass() string { + defer cr.modPasswordsMtx.Unlock() + cr.modPasswordsMtx.Lock() + + pass, err := generatePass(time.Now().Unix()) + if err != nil { + return fmt.Sprintf("Error generating moderator password: %s", err) + } + + // Make sure the password is unique + for existsInSlice(cr.modPasswords, pass) { + pass, err = generatePass(time.Now().Unix()) + if err != nil { + return fmt.Sprintf("Error generating moderator password: %s", err) + } + } + + cr.modPasswords = append(cr.modPasswords, pass) + return pass +} + +func (cr *ChatRoom) redeemModPass(pass string) bool { + if pass == "" { + return false + } + + defer cr.modPasswordsMtx.Unlock() + cr.modPasswordsMtx.Lock() + + if existsInSlice(cr.modPasswords, pass) { + cr.modPasswords = removeFromSlice(cr.modPasswords, pass) + return true + } + return false +} + +func removeFromSlice(slice []string, needle string) []string { + slc := []string{} + for _, item := range slice { + if item != needle { + slc = append(slc, item) + } + } + return slc +} + +func existsInSlice(slice []string, needle string) bool { + for _, item := range slice { + if item == needle { + return true + } + } + return false +} + +func (cr *ChatRoom) changeName(oldName, newName string, forced bool) error { + cr.clientsMtx.Lock() + defer cr.clientsMtx.Unlock() + + if !common.IsValidName(newName) { + return fmt.Errorf("%q nick is not a valid name", newName) + } + + newLower := strings.ToLower(newName) + oldLower := strings.ToLower(oldName) + + var currentClient *Client + for _, client := range cr.clients { + if strings.ToLower(client.name) == newLower { + if strings.ToLower(client.name) != oldLower { + return fmt.Errorf("%q is already taken.", newName) + } + } + + if strings.ToLower(client.name) == oldLower { + currentClient = client + } + } + + if currentClient != nil { + err := currentClient.setName(newName) + if err != nil { + return fmt.Errorf("could not set client name to %#v: %v", newName, err) + } + common.LogDebugf("%q -> %q\n", oldName, newName) + + if forced { + cr.AddEventMsg(common.EvNameChangeForced, oldName+":"+newName, currentClient.color) + currentClient.IsNameForced = true + } else { + cr.AddEventMsg(common.EvNameChange, oldName+":"+newName, currentClient.color) + } + return nil + } + + return fmt.Errorf("Client not found with name %q", oldName) +} diff --git a/MovieNight/common/chatcommands.go b/MovieNight/common/chatcommands.go new file mode 100755 index 0000000..8e9abc5 --- /dev/null +++ b/MovieNight/common/chatcommands.go @@ -0,0 +1,87 @@ +package common + +import "strings" + +const CommandNameSeparator = "," + +type ChatCommandNames []string + +func (c ChatCommandNames) String() string { + return strings.Join(c, CommandNameSeparator) +} + +// Names for commands +var ( + // User Commands + CNMe ChatCommandNames = []string{"me"} + CNHelp ChatCommandNames = []string{"help"} + CNCount ChatCommandNames = []string{"count"} + CNColor ChatCommandNames = []string{"color", "colour"} + CNWhoAmI ChatCommandNames = []string{"w", "whoami"} + CNAuth ChatCommandNames = []string{"auth"} + CNUsers ChatCommandNames = []string{"users"} + CNNick ChatCommandNames = []string{"nick", "name"} + CNStats ChatCommandNames = []string{"stats"} + CNPin ChatCommandNames = []string{"pin", "password"} + CNEmotes ChatCommandNames = []string{"emotes"} + // Mod Commands + CNSv ChatCommandNames = []string{"sv"} + CNPlaying ChatCommandNames = []string{"playing"} + CNUnmod ChatCommandNames = []string{"unmod"} + CNKick ChatCommandNames = []string{"kick"} + CNBan ChatCommandNames = []string{"ban"} + CNUnban ChatCommandNames = []string{"unban"} + CNPurge ChatCommandNames = []string{"purge"} + // Admin Commands + CNMod ChatCommandNames = []string{"mod"} + CNReloadPlayer ChatCommandNames = []string{"reloadplayer"} + CNReloadEmotes ChatCommandNames = []string{"reloademotes"} + CNModpass ChatCommandNames = []string{"modpass"} + CNIP ChatCommandNames = []string{"iplist"} + CNAddEmotes ChatCommandNames = []string{"addemotes"} + CNRoomAccess ChatCommandNames = []string{"changeaccess", "hodor"} +) + +var ChatCommands = []ChatCommandNames{ + // User + CNMe, + CNHelp, + CNCount, + CNColor, + CNWhoAmI, + CNAuth, + CNUsers, + CNNick, + CNStats, + CNPin, + CNEmotes, + + // Mod + CNSv, + CNPlaying, + CNUnmod, + CNKick, + CNBan, + CNUnban, + CNPurge, + + // Admin + CNMod, + CNReloadPlayer, + CNReloadEmotes, + CNModpass, + CNIP, + CNAddEmotes, + CNRoomAccess, +} + +func GetFullChatCommand(c string) string { + for _, names := range ChatCommands { + for _, n := range names { + if c == n { + return names.String() + } + } + } + return "" +} diff --git a/MovieNight/common/chatdata.go b/MovieNight/common/chatdata.go new file mode 100755 index 0000000..1fd77c7 --- /dev/null +++ b/MovieNight/common/chatdata.go @@ -0,0 +1,249 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +type DataInterface interface { + HTML() string +} + +type ChatData struct { + Type DataType + Data DataInterface +} + +func (c ChatData) ToJSON() (ChatDataJSON, error) { + rawData, err := json.Marshal(c.Data) + return ChatDataJSON{ + Type: c.Type, + Data: rawData, + }, err +} + +type ChatDataJSON struct { + Type DataType + Data json.RawMessage +} + +func (c ChatDataJSON) ToData() (ChatData, error) { + data, err := c.GetData() + return ChatData{ + Type: c.Type, + Data: data, + }, err +} + +func (c ChatDataJSON) GetData() (DataInterface, error) { + var data DataInterface + var err error + + switch c.Type { + case DTInvalid: + return nil, errors.New("data type is invalid") + case DTChat: + d := DataMessage{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTCommand: + d := DataCommand{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTEvent: + d := DataEvent{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTClient: + d := ClientData{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTHidden: + d := HiddenMessage{} + err = json.Unmarshal(c.Data, &d) + data = d + default: + err = fmt.Errorf("unhandled data type: %d", c.Type) + } + + return data, err +} + +type ClientData struct { + Type ClientDataType + Message string +} + +func (c ClientData) HTML() string { + // Client data is for client to server communication only, so clients should not see this + return `The developer messed up. You should not be seeing this.` +} + +type DataMessage struct { + From string + Color string + Message string + Level CommandLevel + Type MessageType +} + +// TODO: Read this HTML from a template somewhere +func (dc DataMessage) HTML() string { + switch dc.Type { + case MsgAction: + return `` + dc.From + + ` ` + dc.Message + `` + + case MsgServer: + return `` + dc.Message + `` + + case MsgError: + return `` + dc.Message + `` + + case MsgNotice: + return `` + dc.Message + `` + + case MsgCommandResponse: + return `` + dc.Message + `` + + case MsgCommandError: + return `` + dc.Message + `` + + default: + badge := "" + switch dc.Level { + case CmdlMod: + badge = `` + case CmdlAdmin: + badge = `` + } + return `` + badge + `` + dc.From + + `: ` + dc.Message + `` + } +} + +func NewChatMessage(name, color, msg string, lvl CommandLevel, msgtype MessageType) ChatData { + return ChatData{ + Type: DTChat, + Data: DataMessage{ + From: name, + Color: color, + Message: msg, + Type: msgtype, + Level: lvl, + }, + } +} + +type DataCommand struct { + Command CommandType + Arguments []string +} + +func (de DataCommand) HTML() string { + switch de.Command { + case CmdPurgeChat: + return `Chat has been purged by a moderator.` + default: + return "" + } +} + +func NewChatCommand(command CommandType, args []string) ChatData { + return ChatData{ + Type: DTCommand, + Data: DataCommand{ + Command: command, + Arguments: args, + }, + } +} + +type DataEvent struct { + Event EventType + User string + Color string +} + +func (de DataEvent) HTML() string { + switch de.Event { + case EvKick: + return `` + + de.User + ` has been kicked.` + case EvLeave: + return `` + + de.User + ` has left the chat.` + case EvBan: + return `` + + de.User + ` has been banned.` + case EvJoin: + return `` + + de.User + ` has joined the chat.` + case EvNameChange: + names := strings.Split(de.User, ":") + if len(names) != 2 { + return `Somebody changed their name, but IDK who ` + + ParseEmotes("Jebaited") + `.` + } + + return `` + + names[0] + ` has changed their name to ` + names[1] + `.` + case EvNameChangeForced: + names := strings.Split(de.User, ":") + if len(names) != 2 { + return `An admin changed somebody's name, but IDK who ` + + ParseEmotes("Jebaited") + `.` + } + + return `` + + names[0] + ` has had their name changed to ` + names[1] + ` by an admin.` + } + return "" +} + +func NewChatEvent(event EventType, name, color string) ChatData { + return ChatData{ + Type: DTEvent, + Data: DataEvent{ + Event: event, + User: name, + Color: color, + }, + } +} + +// DataHidden is for the server to send instructions and data +// to the client without the purpose of outputting it on the chat +type HiddenMessage struct { + Type ClientDataType + Data interface{} +} + +func (h HiddenMessage) HTML() string { + return "" +} + +func NewChatHiddenMessage(clientType ClientDataType, data interface{}) ChatData { + return ChatData{ + Type: DTHidden, + Data: HiddenMessage{ + Type: clientType, + Data: data, + }, + } +} + +func DecodeData(rawjson string) (ChatDataJSON, error) { + var data ChatDataJSON + err := json.Unmarshal([]byte(rawjson), &data) + return data, err +} + +type JoinData struct { + Name string + Color string +} diff --git a/MovieNight/common/colors.go b/MovieNight/common/colors.go new file mode 100755 index 0000000..5b2312e --- /dev/null +++ b/MovieNight/common/colors.go @@ -0,0 +1,135 @@ +package common + +import ( + "errors" + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "time" +) + +func init() { + rand.Seed(int64(time.Now().Nanosecond())) +} + +// Colors holds all the valid html color names for MovieNight +// the values in colors must be lowercase so it matches with the color input +// this saves from having to call strings.ToLower(color) every time to check +var Colors = []string{ + "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", + "beige", "bisque", "blanchedalmond", "blueviolet", "brown", + "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", + "cornflowerblue", "cornsilk", "crimson", "cyan", "darkcyan", + "darkgoldenrod", "darkgray", "darkkhaki", "darkmagenta", "darkolivegreen", + "darkorange", "darkorchid", "darksalmon", "darkseagreen", "darkslateblue", + "darkslategray", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", + "dimgray", "dodgerblue", "firebrick", "floralwhite", "forestgreen", + "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", + "gray", "greenyellow", "honeydew", "hotpink", "indigo", + "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", + "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", + "lightgrey", "lightgreen", "lightpink", "lightsalmon", "lightseagreen", + "lightskyblue", "lightslategray", "lightsteelblue", "lightyellow", "lime", + "limegreen", "linen", "magenta", "mediumaquamarine", "mediumorchid", + "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", + "mediumvioletred", "mintcream", "mistyrose", "moccasin", "navajowhite", + "oldlace", "olive", "olivedrab", "orange", "orangered", + "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", + "papayawhip", "peachpuff", "peru", "pink", "plum", + "powderblue", "purple", "rebeccapurple", "red", "rosybrown", + "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", + "seashell", "sienna", "silver", "skyblue", "slateblue", + "slategray", "snow", "springgreen", "steelblue", "tan", + "teal", "thistle", "tomato", "turquoise", "violet", + "wheat", "white", "whitesmoke", "yellow", "yellowgreen", +} + +var ( + regexColor = regexp.MustCompile(`^([0-9A-Fa-f]{3}){1,2}$`) +) + +// IsValidColor takes a string s and compares it against a list of css color names. +// It also accepts hex codes in the form of #RGB and #RRGGBB +func IsValidColor(s string) bool { + s = strings.TrimLeft(strings.ToLower(s), "#") + for _, c := range Colors { + if s == c { + return true + } + } + + if regexColor.MatchString(s) { + r, g, b, err := hex(s) + if err != nil { + return false + } + total := float32(r + g + b) + return total > 0.7 && float32(b)/total < 0.7 + } + return false +} + +// RandomColor returns a hex color code +func RandomColor() string { + var color string + for !IsValidColor(color) { + color = "" + for i := 0; i < 3; i++ { + s := strconv.FormatInt(rand.Int63n(255), 16) + if len(s) == 1 { + s = "0" + s + } + color += s + } + } + return "#" + color +} + +// hex returns R, G, B as values +func hex(s string) (int, int, int, error) { + // Make the string just the base16 numbers + s = strings.TrimLeft(s, "#") + + if len(s) == 3 { + var err error + s, err = hexThreeToSix(s) + if err != nil { + return 0, 0, 0, err + } + } + + if len(s) == 6 { + R64, err := strconv.ParseInt(s[0:2], 16, 32) + if err != nil { + return 0, 0, 0, err + } + + G64, err := strconv.ParseInt(s[2:4], 16, 32) + if err != nil { + return 0, 0, 0, err + } + + B64, err := strconv.ParseInt(s[4:6], 16, 32) + if err != nil { + return 0, 0, 0, err + } + + return int(R64), int(G64), int(B64), nil + } + return 0, 0, 0, errors.New("incorrect format") +} + +func hexThreeToSix(s string) (string, error) { + if len(s) != 3 { + return "", fmt.Errorf("%d is the incorrect length of string for convertsion", len(s)) + } + + h := "" + for i := 0; i < 3; i++ { + h += string(s[i]) + h += string(s[i]) + } + return h, nil +} diff --git a/MovieNight/common/colors_test.go b/MovieNight/common/colors_test.go new file mode 100755 index 0000000..855df26 --- /dev/null +++ b/MovieNight/common/colors_test.go @@ -0,0 +1,42 @@ +package common + +import ( + "testing" +) + +func TestColorHexThreeToSix(t *testing.T) { + expected := "RRGGBB" + result, _ := hexThreeToSix("RGB") + if result != expected { + t.Errorf("expected %#v, got %#v", expected, result) + } +} + +func TestHex(t *testing.T) { + // The testing data layout is inputer, Expected Red, Exp Green, Exp Blue, expect error + data := [][]interface{}{ + []interface{}{"010203", 1, 2, 3, false}, + []interface{}{"100", 17, 0, 0, false}, + []interface{}{"100", 1, 0, 0, true}, + []interface{}{"1000", 0, 0, 0, true}, + []interface{}{"010203", 1, 2, 4, true}, + []interface{}{"0102GG", 1, 2, 4, true}, + } + + for i := range data { + input := data[i][0].(string) + r, g, b, err := hex(input) + if err != nil { + if !data[i][4].(bool) { + t.Errorf("with input %#v: %v", input, err) + } + continue + } + + rr, rg, rb := data[i][1].(int), data[i][2].(int), data[i][3].(int) + + if !data[i][4].(bool) && (r != rr || g != rg || b != rb) { + t.Errorf("expected %d, %d, %d - got %d, %d, %d", r, g, b, rr, rg, rb) + } + } +} diff --git a/MovieNight/common/constants.go b/MovieNight/common/constants.go new file mode 100755 index 0000000..56aebc5 --- /dev/null +++ b/MovieNight/common/constants.go @@ -0,0 +1,73 @@ +package common + +type ClientDataType int + +// Data types for communicating with the client +const ( + CdMessage ClientDataType = iota // a normal message from the client meant to be broadcast + CdUsers // get a list of users + CdPing // ping the server to keep the connection alive + CdAuth // get the auth levels of the user + CdColor // get the users color + CdEmote // get a list of emotes + CdJoin // a message saying the client wants to join + CdNotify // a notify message for the client to show +) + +type DataType int + +// Data types for command messages +const ( + DTInvalid DataType = iota + DTChat // chat message + DTCommand // non-chat function + DTEvent // join/leave/kick/ban events + DTClient // a message coming from the client + DTHidden // a message that is purely instruction and data, not shown to user +) + +type CommandType int + +// Command Types +const ( + CmdPlaying CommandType = iota + CmdRefreshPlayer + CmdPurgeChat + CmdHelp + CmdEmotes +) + +type CommandLevel int + +// Command access levels +const ( + CmdlUser CommandLevel = iota + CmdlMod + CmdlAdmin +) + +type EventType int + +// Event Types +const ( + EvJoin EventType = iota + EvLeave + EvKick + EvBan + EvServerMessage + EvNameChange + EvNameChangeForced +) + +type MessageType int + +// Message Types +const ( + MsgChat MessageType = iota // standard chat + MsgAction // /me command + MsgServer // server message + MsgError // something went wrong + MsgNotice // Like MsgServer, but for mods and admins only. + MsgCommandResponse // The response from command + MsgCommandError // The error response from command +) diff --git a/MovieNight/common/emotes.go b/MovieNight/common/emotes.go new file mode 100755 index 0000000..5f1d3ec --- /dev/null +++ b/MovieNight/common/emotes.go @@ -0,0 +1,74 @@ +package common + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +type EmotesMap map[string]string + +var Emotes EmotesMap + +var reStripStatic = regexp.MustCompile(`^(\\|/)?static`) + +func init() { + Emotes = NewEmotesMap() +} + +func NewEmotesMap() EmotesMap { + return map[string]string{} +} + +func (em EmotesMap) Add(fullpath string) EmotesMap { + fullpath = reStripStatic.ReplaceAllLiteralString(fullpath, "") + + base := filepath.Base(fullpath) + code := base[0 : len(base)-len(filepath.Ext(base))] + + _, exists := em[code] + + num := 0 + for exists { + num += 1 + _, exists = em[fmt.Sprintf("%s-%d", code, num)] + } + + if num > 0 { + code = fmt.Sprintf("%s-%d", code, num) + } + + em[code] = fullpath + //fmt.Printf("Added emote %s at path %q\n", code, fullpath) + return em +} + +func EmoteToHtml(file, title string) string { + return fmt.Sprintf(``, file, title) +} + +func ParseEmotesArray(words []string) []string { + newWords := []string{} + for _, word := range words { + // make :emote: and [emote] valid for replacement. + wordTrimmed := strings.Trim(word, ":[]") + + found := false + for key, val := range Emotes { + if key == wordTrimmed { + newWords = append(newWords, EmoteToHtml(val, key)) + found = true + } + } + if !found { + newWords = append(newWords, word) + } + } + return newWords +} + +func ParseEmotes(msg string) string { + words := ParseEmotesArray(strings.Split(msg, " ")) + return strings.Join(words, " ") +} diff --git a/MovieNight/common/emotes_test.go b/MovieNight/common/emotes_test.go new file mode 100755 index 0000000..bb31646 --- /dev/null +++ b/MovieNight/common/emotes_test.go @@ -0,0 +1,44 @@ +package common + +import ( + "os" + "testing" +) + +var data_good = map[string]string{ + "one": ``, + "two": ``, + "three": ``, + + ":one:": ``, + ":two:": ``, + ":three:": ``, + + "[one]": ``, + "[two]": ``, + "[three]": ``, + + ":one: two [three]": ` `, + + "nope one what": `nope what`, + "nope :two: what": `nope what`, + "nope [three] what": `nope what`, +} + +func TestMain(m *testing.M) { + Emotes = map[string]string{ + "one": "/emotes/one.png", + "two": "/emotes/two.png", + "three": "/emotes/three.gif", + } + os.Exit(m.Run()) +} + +func TestEmotes_ParseEmotes(t *testing.T) { + for input, expected := range data_good { + got := ParseEmotes(input) + if got != expected { + t.Errorf("%s failed to parse into %q. Received: %q", input, expected, got) + } + } +} diff --git a/MovieNight/common/logging.go b/MovieNight/common/logging.go new file mode 100755 index 0000000..01bf824 --- /dev/null +++ b/MovieNight/common/logging.go @@ -0,0 +1,200 @@ +package common + +import ( + "fmt" + "io" + "log" + "os" +) + +var loglevel LogLevel + +type LogLevel string + +const ( + LLError LogLevel = "error" // only log errors + LLChat LogLevel = "chat" // log chat and commands + LLInfo LogLevel = "info" // log info messages (not quite debug, but not chat) + LLDebug LogLevel = "debug" // log everything +) + +const ( + logPrefixError string = "[ERROR] " + logPrefixChat string = "[CHAT] " + logPrefixInfo string = "[INFO] " + logPrefixDebug string = "[DEBUG] " +) + +var ( + logError *log.Logger + logChat *log.Logger + logInfo *log.Logger + logDebug *log.Logger +) + +func SetupLogging(level LogLevel, file string) error { + switch level { + case LLDebug: + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + logChat = log.New(os.Stdout, logPrefixChat, log.LstdFlags) + logDebug = log.New(os.Stdout, logPrefixDebug, log.LstdFlags) + logInfo = log.New(os.Stdout, logPrefixInfo, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + logChat = log.New(io.MultiWriter(os.Stdout, f), logPrefixChat, log.LstdFlags) + logInfo = log.New(io.MultiWriter(os.Stdout, f), logPrefixInfo, log.LstdFlags) + logDebug = log.New(io.MultiWriter(os.Stdout, f), logPrefixDebug, log.LstdFlags) + } + case LLChat: + logDebug = nil + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + logChat = log.New(os.Stdout, logPrefixChat, log.LstdFlags) + logInfo = log.New(os.Stdout, logPrefixInfo, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + logChat = log.New(io.MultiWriter(os.Stdout, f), logPrefixChat, log.LstdFlags) + logInfo = log.New(io.MultiWriter(os.Stdout, f), logPrefixInfo, log.LstdFlags) + } + + case LLInfo: + logDebug = nil + logChat = nil + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + logInfo = log.New(os.Stdout, logPrefixInfo, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + logInfo = log.New(io.MultiWriter(os.Stdout, f), logPrefixInfo, log.LstdFlags) + } + + // Default to error + default: + logChat = nil + logDebug = nil + logInfo = nil + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + } + } + return nil +} + +func LogErrorf(format string, v ...interface{}) { + if logError == nil { + panic("Logging not setup!") + } + + logError.Printf(format, v...) +} + +func LogErrorln(v ...interface{}) { + if logError == nil { + panic("Logging not setup!") + } + + logError.Println(v...) +} + +func LogChatf(format string, v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging chat and commands is turned off. + if logChat == nil { + return + } + + logChat.Printf(format, v...) +} + +func LogChatln(v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging chat and commands is turned off. + if logChat == nil { + return + } + + logChat.Println(v...) +} + +func LogInfof(format string, v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging info is turned off. + if logInfo == nil { + return + } + + logInfo.Printf(format, v...) +} + +func LogInfoln(v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging info is turned off. + if logInfo == nil { + return + } + + logInfo.Println(v...) +} + +func LogDebugf(format string, v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging debug is turned off. + if logDebug == nil { + return + } + + logDebug.Printf(format, v...) +} + +func LogDebugln(v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging debug is turned off. + if logDebug == nil { + return + } + + logDebug.Println(v...) +} diff --git a/MovieNight/common/logging_dev.go b/MovieNight/common/logging_dev.go new file mode 100755 index 0000000..9dccfc1 --- /dev/null +++ b/MovieNight/common/logging_dev.go @@ -0,0 +1,18 @@ +// +build dev + +package common + +import ( + "log" + "os" +) + +var logDev *log.Logger = log.New(os.Stdout, "[DEV]", log.LstdFlags) + +func LogDevf(format string, v ...interface{}) { + logDev.Printf(format, v...) +} + +func LogDevln(v ...interface{}) { + logDev.Println(v...) +} diff --git a/MovieNight/common/templates.go b/MovieNight/common/templates.go new file mode 100755 index 0000000..e8b3abb --- /dev/null +++ b/MovieNight/common/templates.go @@ -0,0 +1,90 @@ +package common + +import ( + "fmt" + html "html/template" + "net/http" + "strings" + text "text/template" +) + +// Holds the server's templates +var serverTemplates map[string]*html.Template + +// Holds the client's chat templates +var chatTemplates map[string]*text.Template + +var isServer bool = false + +// keys and files to load for that template +var serverTemplateDefs map[string][]string = map[string][]string{ + "pin": []string{"./static/base.html", "./static/thedoor.html"}, + "main": []string{"./static/base.html", "./static/main.html"}, + "help": []string{"./static/base.html", "./static/help.html"}, + "emotes": []string{"./static/base.html", "./static/emotes.html"}, +} + +var chatTemplateDefs map[string]string = map[string]string{ + fmt.Sprint(DTInvalid, 0): "wot", + + fmt.Sprint(DTChat, MsgChat): `{{.Badge}} {{.From}}` + + `: {{.Message}}`, + fmt.Sprint(DTChat, MsgAction): `{{.From}}` + + ` {{.Message}}`, +} + +// Called from the server +func InitTemplates() error { + isServer = true + serverTemplates = make(map[string]*html.Template) + chatTemplates = make(map[string]*text.Template) + + // Parse server templates + for key, files := range serverTemplateDefs { + t, err := html.ParseFiles(files...) + if err != nil { + return fmt.Errorf("Unable to parse templates for %s: %v", key, err) + } + + serverTemplates[key] = t + } + + // Parse client templates + //for key, def := range chatTemplateDefs { + // t := text.New(key) + // err, _ := t.Parse(def) + // if err != nil { + // return fmt.Errorf("Unabel to parse chat template %q: %v", key, err) + // } + + // chatTemplates[key] = t + //} + + return nil +} + +// TODO +func LoadChatTemplates() error { + return nil +} + +func ExecuteChatTemplate(typeA, typeB int, data interface{}) (string, error) { + key := fmt.Sprint(typeA, typeB) + t := chatTemplates[key] + builder := &strings.Builder{} + + if err := t.Execute(builder, data); err != nil { + return "", err + } + + return builder.String(), nil +} + +func ExecuteServerTemplate(w http.ResponseWriter, key string, data interface{}) error { + t, ok := serverTemplates[key] + if !ok { + return fmt.Errorf("Template with the key %q does not exist", key) + } + + return t.Execute(w, data) +} diff --git a/MovieNight/common/utils.go b/MovieNight/common/utils.go new file mode 100755 index 0000000..4f1dda2 --- /dev/null +++ b/MovieNight/common/utils.go @@ -0,0 +1,18 @@ +package common + +// Misc utils + +import ( + "regexp" +) + +var usernameRegex *regexp.Regexp = regexp.MustCompile(`^[0-9a-zA-Z_-]*[a-zA-Z0-9]+[0-9a-zA-Z_-]*$`) + +const InvalidNameError string = `Invalid name.
Name must be between 3 and 36 characters in length; contain only numbers, letters, underscores or dashes; and contain at least one number or letter.
Names cannot contain spaces.` + +// IsValidName checks that name is within the correct ranges, follows the regex defined +// and is not a valid color name +func IsValidName(name string) bool { + return 3 <= len(name) && len(name) <= 36 && + usernameRegex.MatchString(name) +} diff --git a/MovieNight/connection.go b/MovieNight/connection.go new file mode 100755 index 0000000..f2f8cc7 --- /dev/null +++ b/MovieNight/connection.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "net" + "sync" + + "github.com/gorilla/websocket" + "github.com/zorchenhimer/MovieNight/common" +) + +type chatConnection struct { + *websocket.Conn + mutex sync.RWMutex + forwardedFor string + clientName string +} + +func (cc *chatConnection) ReadData(data interface{}) error { + cc.mutex.RLock() + defer cc.mutex.RUnlock() + + stats.msgInInc() + return cc.ReadJSON(data) +} + +func (cc *chatConnection) WriteData(data interface{}) error { + cc.mutex.Lock() + defer cc.mutex.Unlock() + + stats.msgOutInc() + err := cc.WriteJSON(data) + if err != nil { + if operr, ok := err.(*net.OpError); ok { + common.LogDebugln("OpError: " + operr.Err.Error()) + } + return fmt.Errorf("Error writing data to %s %s: %v", cc.clientName, cc.Host(), err) + } + return nil +} + +func (cc *chatConnection) Host() string { + if len(cc.forwardedFor) > 0 { + return cc.forwardedFor + } + + host, _, err := net.SplitHostPort(cc.RemoteAddr().String()) + if err != nil { + return cc.RemoteAddr().String() + } + return host +} diff --git a/MovieNight/emotes.go b/MovieNight/emotes.go new file mode 100755 index 0000000..d8fd00b --- /dev/null +++ b/MovieNight/emotes.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/zorchenhimer/MovieNight/common" +) + +const emoteDir = "./static/emotes/" + +type TwitchUser struct { + ID string + Login string +} + +type EmoteInfo struct { + ID int + Code string +} + +func loadEmotes() error { + //fmt.Println(processEmoteDir(emoteDir)) + newEmotes, err := processEmoteDir(emoteDir) + if err != nil { + return err + } + + common.Emotes = newEmotes + + return nil +} + +func processEmoteDir(path string) (common.EmotesMap, error) { + dirInfo, err := ioutil.ReadDir(path) + if err != nil { + return nil, errors.Wrap(err, "could not open emoteDir:") + } + + subDirs := []string{} + + for _, item := range dirInfo { + // Get first level subdirs (eg, "twitch", "discord", etc) + if item.IsDir() { + subDirs = append(subDirs, item.Name()) + continue + } + } + + em := common.NewEmotesMap() + // Find top level emotes + em, err = findEmotes(path, em) + if err != nil { + return nil, errors.Wrap(err, "could not findEmotes() in top level directory:") + } + + // Get second level subdirs (eg, "twitch", "zorchenhimer", etc) + for _, dir := range subDirs { + subd, err := ioutil.ReadDir(filepath.Join(path, dir)) + if err != nil { + fmt.Printf("Error reading dir %q: %v\n", subd, err) + continue + } + for _, d := range subd { + if d.IsDir() { + //emotes = append(emotes, findEmotes(filepath.Join(path, dir, d.Name()))...) + p := filepath.Join(path, dir, d.Name()) + em, err = findEmotes(p, em) + if err != nil { + fmt.Printf("Error finding emotes in %q: %v\n", p, err) + } + } + } + } + + fmt.Printf("processEmoteDir: %d\n", len(em)) + return em, nil +} + +func findEmotes(dir string, em common.EmotesMap) (common.EmotesMap, error) { + //em := NewEmotesMap() + + fmt.Printf("finding emotes in %q\n", dir) + emotePNGs, err := filepath.Glob(filepath.Join(dir, "*.png")) + if err != nil { + return em, fmt.Errorf("unable to glob emote directory: %s\n", err) + } + fmt.Printf("%d emotePNGs\n", len(emotePNGs)) + + emoteGIFs, err := filepath.Glob(filepath.Join(dir, "*.gif")) + if err != nil { + return em, errors.Wrap(err, "unable to glob emote directory:") + } + fmt.Printf("%d emoteGIFs\n", len(emoteGIFs)) + + for _, file := range emotePNGs { + em = em.Add(file) + //emotes = append(emotes, common.Emote{FullPath: dir, Code: file}) + } + + for _, file := range emoteGIFs { + em = em.Add(file) + } + + return em, nil +} + +func getEmotes(names []string) error { + users := getUserIDs(names) + users = append(users, TwitchUser{ID: "0", Login: "twitch"}) + + 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 := filepath.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 := filepath.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) + } + } + } + + for amount, sizes := range cheers { + name := fmt.Sprintf("%sCheer%s.gif", user.Login, amount) + filePath := filepath.Join(emoteUserDir, name) + file, err := os.Create(filePath) + if err != nil { + return errors.Wrapf(err, "could not create emote file in path \"%s\":", filePath) + } + + err = downloadCheerEmote(sizes["4"], file) + if err != nil { + return errors.Wrapf(err, "could not download emote %s:", name) + } + } + } + return nil +} + +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 { + log.Fatalln("Error generating new request:", err) + } + request.Header.Set("Client-ID", settings.TwitchClientID) + + client := http.Client{} + resp, err := client.Do(request) + if err != nil { + log.Fatalln("Error sending request:", err) + } + + decoder := json.NewDecoder(resp.Body) + type userResponse struct { + Data []TwitchUser + } + var data userResponse + + err = decoder.Decode(&data) + if err != nil { + log.Fatalln("Error decoding data:", err) + } + + 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/MovieNight/entrypoint.sh b/MovieNight/entrypoint.sh new file mode 100755 index 0000000..2ce2f13 --- /dev/null +++ b/MovieNight/entrypoint.sh @@ -0,0 +1,37 @@ +#! /bin/bash + +function main() { + listen & + while true; do + sleep 10 + publish + done +} + +function listen() { + if [ ! -d /main ]; then + go build && ./MovieNight + elif [ ! -e /main/exec-MovieNight ]; then + /main/MovieNight + else + /main/exec-MovieNight + fi + kill -9 1 +} + +function publish() { + ffmpeg \ + -i rtsp://${RTSP_IP:-192.168.0.83}:${RTSP_PORT:-8554}/unicast \ + -preset ultrafast \ + -filter:v scale=-1:${RES:-720} \ + -vcodec libx264 \ + -acodec copy \ + -f flv \ + -b:v ${KBPS:-500}k \ + -b:a 0k \ + rtmp://localhost:1935/live/ALongStreamKey +} + +if [ "$0" == "$BASH_SOURCE" ]; then + main "$@" +fi diff --git a/MovieNight/errors.go b/MovieNight/errors.go new file mode 100755 index 0000000..3f4f8d5 --- /dev/null +++ b/MovieNight/errors.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "reflect" + "strings" +) + +func errorName(err error) string { + return reflect.ValueOf(err).Type().Name() +} + +// UserNameError is a base error for errors that deal with user names +type UserNameError struct { + Name string +} + +// UserFormatError is an error for when the name format does not match what is required +type UserFormatError UserNameError + +func (e UserFormatError) Error() string { + return fmt.Sprintf("\"%s\", is in an invalid format", e.Name) +} + +// UserTakenError is an error for when a user tries to join with a name that is already taken +type UserTakenError UserNameError + +func (e UserTakenError) Error() string { + return fmt.Sprintf("\"%s\", is already taken", e.Name) +} + +// BannedUserError is an error for when a user tries to join with a banned ip address +type BannedUserError struct { + Host, Name string + Names []string +} + +func (e BannedUserError) Error() string { + return fmt.Sprintf("banned user tried to connect with IP %s: %s (banned with name(s) %s)", e.Host, e.Name, strings.Join(e.Names, ", ")) +} + +func newBannedUserError(host, name string, names []string) BannedUserError { + return BannedUserError{ + Host: host, + Name: name, + Names: names, + } +} diff --git a/MovieNight/favicon.png b/MovieNight/favicon.png new file mode 100755 index 0000000..723c1e7 Binary files /dev/null and b/MovieNight/favicon.png differ diff --git a/MovieNight/go.mod b/MovieNight/go.mod new file mode 100755 index 0000000..451ae17 --- /dev/null +++ b/MovieNight/go.mod @@ -0,0 +1,23 @@ +module github.com/zorchenhimer/MovieNight + +go 1.12 + +require ( + github.com/Microsoft/go-winio v0.4.12 // indirect + github.com/cenkalti/backoff v2.1.1+incompatible // indirect + github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a // indirect + github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect + github.com/gorilla/sessions v1.1.3 + github.com/gorilla/websocket v1.4.0 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + 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 + 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 + golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f // indirect + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect + golang.org/x/sys v0.0.0-20190412213103-97732733099d +) diff --git a/MovieNight/go.sum b/MovieNight/go.sum new file mode 100755 index 0000000..013c0dc --- /dev/null +++ b/MovieNight/go.sum @@ -0,0 +1,102 @@ +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Microsoft/go-winio v0.4.11 h1:zoIOcVf0xPN1tnMVbTtEdI+P8OofVk3NObnwOQ6nK2Q= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= +github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/chromedp/cdproto v0.0.0-20190217000753-2d8e8962ceb2 h1:4Ck8YOuS0G3+0xMb80cDSff7QpUolhSc0PGyfagbcdA= +github.com/chromedp/cdproto v0.0.0-20190217000753-2d8e8962ceb2/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= +github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= +github.com/chromedp/chromedp v0.1.3 h1:Nkqt42/7tvzg57mexc4LbM8nZbx7vSZ+eiUpeczGGL8= +github.com/chromedp/chromedp v0.1.3/go.mod h1:ZahQlJx8YBfDtuFN80zn6P7fskSotBkdhgKDoLWFANk= +github.com/containerd/continuity v0.0.0-20181027224239-bea7585dbfac h1:PThQaO4yCvJzJBUW1XoFQxLotWRhvX2fgljJX8yrhFI= +github.com/containerd/continuity v0.0.0-20181027224239-bea7585dbfac/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/dom v0.3.0 h1:u89+QvT1OPRSSTFf54o9RuK7C0Uoq2jFo4VCa4rnjtI= +github.com/dennwc/dom v0.3.0/go.mod h1:/z5w9Stx19m8RUwolsmsqTs9rDxKgJO5T9UEumilgk4= +github.com/dennwc/testproxy v1.0.1 h1:mQhNVWHPolTYjJrDZYKcugIplWRSlFAis6k/Zf1s0c0= +github.com/dennwc/testproxy v1.0.1/go.mod h1:EHGV9tzWhMPLmEoVJ2KGyC149XqwKZwBDViCjhKD5d8= +github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= +github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307 h1:vl4eIlySbjertFaNwiMjXsGrFVK25aOWLq7n+3gh2ls= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190221075403-6243d8e04c3f h1:B6PQkurxGG1rqEX96oE14gbj8bqvYC5dtks9r5uGmlE= +github.com/mailru/easyjson v0.0.0-20190221075403-6243d8e04c3f/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431 h1:nWhrOsCKdV6bivw03k7MROF2tYzCFGfYBYFrTEHyucs= +github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431/go.mod h1:aFJ1ZwLjvHN4yEzE5Bkz8rD8/d8Vlj3UIuvz2yfET7I= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/ory/dockertest v3.3.2+incompatible h1:uO+NcwH6GuFof/Uz8yzjNi1g0sGT5SLAJbdBvD8bUYc= +github.com/ory/dockertest v3.3.2+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= +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= +github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 h1:YoY1wS6JYVRpIfFngRf2HHo9R9dAne3xbkGOQ5rJXjU= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/MovieNight/handlers.go b/MovieNight/handlers.go new file mode 100755 index 0000000..8ef585d --- /dev/null +++ b/MovieNight/handlers.go @@ -0,0 +1,378 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "path" + "path/filepath" + "strings" + + "github.com/zorchenhimer/MovieNight/common" + + "github.com/gorilla/websocket" + "github.com/nareix/joy4/av/avutil" + "github.com/nareix/joy4/av/pubsub" + "github.com/nareix/joy4/format/flv" + "github.com/nareix/joy4/format/rtmp" +) + +var ( + // Read/Write mutex for rtmp stream + l = NewSuperLock() + + // Map of active streams + channels = map[string]*Channel{} +) + +type Channel struct { + que *pubsub.Queue +} + +type writeFlusher struct { + httpflusher http.Flusher + io.Writer +} + +func (self writeFlusher) Flush() error { + self.httpflusher.Flush() + return nil +} + +// Serving static files +func wsStaticFiles(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/favicon.ico": + http.ServeFile(w, r, "./favicon.png") + return + case "/justvideo": + http.ServeFile(w, r, "./static/justvideo.html") + return + } + + goodPath := r.URL.Path[8:len(r.URL.Path)] + common.LogDebugf("[static] serving %q from folder ./static/\n", goodPath) + + http.ServeFile(w, r, "./static/"+goodPath) +} + +func wsWasmFile(w http.ResponseWriter, r *http.Request) { + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + common.LogDebugln("[static] serving wasm file") + http.ServeFile(w, r, "./static/main.wasm") +} + +func wsImages(w http.ResponseWriter, r *http.Request) { + base := filepath.Base(r.URL.Path) + common.LogDebugln("[img] ", base) + http.ServeFile(w, r, "./static/img/"+base) +} + +func wsEmotes(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, path.Join("./static/", r.URL.Path)) +} + +// Handling the websocket +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, //not checking origin +} + +//this is also the handler for joining to the chat +func wsHandler(w http.ResponseWriter, r *http.Request) { + log.Println("ws handler") + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + common.LogErrorln("Error upgrading to websocket:", err) + return + } + + common.LogDebugln("Connection has been upgraded to websocket") + + go func() { + // Handle incomming messages + for { + var data common.ClientData + err := conn.ReadJSON(&data) + if err != nil { //if error then assuming that the connection is closed + return + } + } + + }() +} + +// returns if it's OK to proceed +func checkRoomAccess(w http.ResponseWriter, r *http.Request) bool { + session, err := sstore.Get(r, "moviesession") + if err != nil { + // Don't return as server error here, just make a new session. + common.LogErrorf("Unable to get session for client %s: %v\n", r.RemoteAddr, err) + } + + if settings.RoomAccess == AccessPin { + pin := session.Values["pin"] + // No pin found in session + if pin == nil || len(pin.(string)) == 0 { + if r.Method == "POST" { + // Check for correct pin + err = r.ParseForm() + if err != nil { + common.LogErrorf("Error parsing form") + http.Error(w, "Unable to get session data", http.StatusInternalServerError) + } + + postPin := strings.TrimSpace(r.Form.Get("txtInput")) + common.LogDebugf("Received pin: %s\n", postPin) + if postPin == settings.RoomAccessPin { + // Pin is correct. Save it to session and return true. + session.Values["pin"] = settings.RoomAccessPin + session.Save(r, w) + return true + } + // Pin is incorrect. + handlePinTemplate(w, r, "Incorrect PIN") + return false + } + // nope. display pin entry and return + handlePinTemplate(w, r, "") + return false + } + + // Pin found in session, but it has changed since last time. + if pin.(string) != settings.RoomAccessPin { + // Clear out the old pin. + session.Values["pin"] = nil + session.Save(r, w) + + // Prompt for new one. + handlePinTemplate(w, r, "Pin has changed. Enter new PIN.") + return false + } + + // Correct pin found in session + return true + } + + // TODO: this. + if settings.RoomAccess == AccessRequest { + http.Error(w, "Requesting access not implemented yet", http.StatusNotImplemented) + return false + } + + // Room is open. + return true +} + +func handlePinTemplate(w http.ResponseWriter, r *http.Request, errorMessage string) { + log.Println("handle pin temp") + type Data struct { + Title string + SubmitText string + Notice string + } + + if errorMessage == "" { + errorMessage = "Please enter the PIN" + } + + data := Data{ + Title: "Enter Pin", + SubmitText: "Submit Pin", + Notice: errorMessage, + } + + err := common.ExecuteServerTemplate(w, "pin", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handleHelpTemplate(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) +} + +func handleEmoteTemplate(w http.ResponseWriter, r *http.Request) { + log.Println("handle emote temp") + type Data struct { + Title string + Emotes map[string]string + } + + data := Data{ + Title: "Available Emotes", + Emotes: common.Emotes, + } + + err := common.ExecuteServerTemplate(w, "emotes", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handlePin(w http.ResponseWriter, r *http.Request) { + log.Println("handle pin") + session, err := sstore.Get(r, "moviesession") + if err != nil { + common.LogDebugf("Unable to get session: %v\n", err) + } + + val := session.Values["pin"] + if val == nil { + session.Values["pin"] = "1234" + err := session.Save(r, w) + if err != nil { + fmt.Fprintf(w, "unable to save session: %v", err) + } + fmt.Fprint(w, "Pin was not set") + common.LogDebugln("pin was not set") + } else { + fmt.Fprintf(w, "pin set: %v", val) + common.LogDebugf("pin is set: %v\n", val) + } +} + +func handleIndexTemplate(w http.ResponseWriter, r *http.Request) { + log.Println("handle ind temp") + if settings.RoomAccess != AccessOpen { + if !checkRoomAccess(w, r) { + common.LogDebugln("Denied access") + return + } + common.LogDebugln("Granted access") + } + + type Data struct { + Video, Chat bool + MessageHistoryCount int + Title string + } + + data := Data{ + Video: true, + Chat: true, + MessageHistoryCount: settings.MaxMessageCount, + Title: "Movie Night!", + } + + path := strings.Split(strings.TrimLeft(r.URL.Path, "/"), "/") + if path[0] == "video" { + data.Chat = false + data.Title += " - video" + } + + // Force browser to replace cache since file was not changed + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + + err := common.ExecuteServerTemplate(w, "main", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handlePublish(conn *rtmp.Conn) { + log.Println("handle publish") + streams, _ := conn.Streams() + + l.Lock() + common.LogDebugln("request string->", conn.URL.RequestURI()) + urlParts := strings.Split(strings.Trim(conn.URL.RequestURI(), "/"), "/") + common.LogDebugln("urlParts->", urlParts) + + if len(urlParts) > 2 { + common.LogErrorln("Extra garbage after stream key") + return + } + + /* + if len(urlParts) != 2 { + common.LogErrorln("Missing stream key") + return + } + + if urlParts[1] != settings.GetStreamKey() { + common.LogErrorln("Stream key is incorrect. Denying stream.") + return //If key not match, deny stream + } + */ + + streamPath := urlParts[0] + ch := channels[streamPath] + if ch == nil { + ch = &Channel{} + ch.que = pubsub.NewQueue() + ch.que.WriteHeader(streams) + channels[streamPath] = ch + } else { + ch = nil + } + l.Unlock() + if ch == nil { + common.LogErrorln("Unable to start stream, channel is nil.") + return + } + + stats.startStream() + + common.LogInfoln("Stream started") + avutil.CopyPackets(ch.que, conn) + common.LogInfoln("Stream finished") + + stats.endStream() + + l.Lock() + delete(channels, streamPath) + l.Unlock() + ch.que.Close() +} + +func handlePlay(conn *rtmp.Conn) { + log.Println("handle play") + l.RLock() + ch := channels[conn.URL.Path] + l.RUnlock() + + if ch != nil { + cursor := ch.que.Latest() + avutil.CopyFile(conn, cursor) + } +} + +func handleDefault(w http.ResponseWriter, r *http.Request) { + log.Println("handle def") + l.RLock() + ch := channels[strings.Trim(r.URL.Path, "/")] + l.RUnlock() + + if ch != nil { + l.StartStream() + defer l.StopStream() + + w.Header().Set("Content-Type", "video/x-flv") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(200) + flusher := w.(http.Flusher) + flusher.Flush() + + muxer := flv.NewMuxerWriteFlusher(writeFlusher{httpflusher: flusher, Writer: w}) + cursor := ch.que.Latest() + + avutil.CopyFile(muxer, cursor) + } else { + if r.URL.Path != "/" { + // not really an error for the server, but for the client. + common.LogInfoln("[http 404] ", r.URL.Path) + http.NotFound(w, r) + } else { + handleIndexTemplate(w, r) + } + } +} diff --git a/MovieNight/main.go b/MovieNight/main.go new file mode 100755 index 0000000..da61f60 --- /dev/null +++ b/MovieNight/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "time" + + "github.com/gorilla/sessions" + "github.com/nareix/joy4/format" + "github.com/nareix/joy4/format/rtmp" + "github.com/zorchenhimer/MovieNight/common" +) + +var ( + pullEmotes bool + addr string + sKey string + stats = newStreamStats() +) + +func setupSettings() error { + var err error + settings, err = LoadSettings("settings.json") + if err != nil { + return fmt.Errorf("Unable to load settings: %s", err) + } + if len(settings.StreamKey) == 0 { + return fmt.Errorf("Missing stream key is settings.json") + } + + sstore = sessions.NewCookieStore([]byte(settings.SessionKey)) + sstore.Options = &sessions.Options{ + Path: "/", + MaxAge: 60 * 60 * 24, // one day + SameSite: http.SameSiteStrictMode, + } + + return nil +} + +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() + + if err := setupSettings(); err != nil { + fmt.Printf("Error loading settings: %v\n", err) + 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) + } + + exit := make(chan bool) + go handleInterrupt(exit) + + if addr == "" { + addr = settings.ListenAddress + } + + if addr[0] != ':' { + addr = ":" + addr + } + + // A stream key was passed on the command line. Use it, but don't save + // it over the stream key in the settings.json file. + if sKey != "" { + settings.SetTempKey(sKey) + } + + common.LogInfoln("Stream key: ", settings.GetStreamKey()) + common.LogInfoln("Admin password: ", settings.AdminPassword) + common.LogInfoln("Listen and serve ", addr) + common.LogInfoln("RoomAccess: ", settings.RoomAccess) + common.LogInfoln("RoomAccessPin: ", settings.RoomAccessPin) + + go startServer() + go startRmtpServer() + + <-exit +} + +func startRmtpServer() { + server := &rtmp.Server{ + HandlePlay: handlePlay, + HandlePublish: handlePublish, + } + err := server.ListenAndServe() + if err != nil { + // If the server cannot start, don't pretend we can continue. + panic("Error trying to start rtmp server: " + err.Error()) + } +} + +func startServer() { + // Chat websocket + http.HandleFunc("/ws", wsHandler) + http.HandleFunc("/static/js/", wsStaticFiles) + http.HandleFunc("/static/css/", wsStaticFiles) + http.HandleFunc("/static/img/", wsImages) + http.HandleFunc("/static/main.wasm", wsWasmFile) + http.HandleFunc("/emotes/", wsEmotes) + http.HandleFunc("/favicon.ico", wsStaticFiles) + http.HandleFunc("/video", handleIndexTemplate) + http.HandleFunc("/help", handleHelpTemplate) + http.HandleFunc("/pin", handlePin) + http.HandleFunc("/emotes", handleEmoteTemplate) + + http.HandleFunc("/", handleDefault) + + http.HandleFunc("/pls/restart", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/pls/restart/soft", http.StatusSeeOther) + }) + + http.HandleFunc("/pls/restart/soft", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `I'm on the case. Give me 30 seconds. Love you <3`) + go func() { + killStream() + l = NewSuperLock() + }() + }) + http.HandleFunc("/pls/restart/hard", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `I'm on the case. Give me 2 minutes. Love you <3`) + go func() { + rebootCam() + time.Sleep(time.Second * 60) + killStream() + l = NewSuperLock() + }() + }) + + go rtsp() + + err := http.ListenAndServe(addr, nil) + if err != nil { + // If the server cannot start, don't pretend we can continue. + panic("Error trying to start chat/http server: " + err.Error()) + } +} + +func handleInterrupt(exit chan bool) { + ch := make(chan os.Signal) + signal.Notify(ch, os.Interrupt) + <-ch + common.LogInfoln("Closing server") + if settings.StreamStats { + stats.Print() + } + permaKillStream() // todo + exit <- true +} diff --git a/MovieNight/notes.txt b/MovieNight/notes.txt new file mode 100755 index 0000000..e1dafbc --- /dev/null +++ b/MovieNight/notes.txt @@ -0,0 +1,63 @@ +== TODO + +- break long words across lines + +- mod commands + - auth command to gain mod status + - kick/mute/timeout + - list users + - purge chat + - mods cannot kick/ban other mods or admin + - only admin can kick/ban mods + - admin revoke command with password + - broadcast mod/unmod command results to mods and admins + - fix /color for mods and admins + +- "login" options + - IP admin/mod? + - save ip/name combo for reconnects? + +- Move kick/ban core functionality into command instead of room? + or to (server-side) client? + +- add a Chatroom.FindUser(name) function + +- rewrite Javascript to accept json data. + - separate data into commands and chat + - commands will just execute more JS (eg, changing title) + - chat will append chat message + - moves all styling to client + +- rewrite javascript client in go webasm? + +== Commands +/color + change user color +/me + italic chat message without leading colon. message is the same color as name. +/count + display the number of users in chat +/w +/whoami + debugging command. prints name, mod, and admin status +/auth + authenticate to admin + += Mod commands +/playing [title] [link] + update title and link. clears title if no arguments +/sv + server announcement message. it's red, with a red border, centered in chat. +/kick + kick user from chat +/unmod + unmod self only + += Admin commands +/reloademotes + reload emotes map +/reloadplayer + reloads the video player of everybody in chat +/unmod + unmod a user +/mod mod a user diff --git a/MovieNight/readme.md b/MovieNight/readme.md new file mode 100755 index 0000000..020426d --- /dev/null +++ b/MovieNight/readme.md @@ -0,0 +1,62 @@ +# MovieNight stream server + +[![Build status](https://api.travis-ci.org/zorchenhimer/MovieNight.svg?branch=master)](https://travis-ci.org/zorchenhimer/MovieNight) + +This is a single-instance streaming server with chat. Originally written to +replace Rabbit as the platform for watching movies with a group of people +online. + +## Build requirements + +- Go 1.12 or newer +- GNU Make + +## Install + +To just download and run: + +```bash +$ git clone https://github.com/zorchenhimer/MovieNight +$ cd MovieNight +$ make +$ ./MovieNight +``` + +## Usage + +Now you can use OBS to push a stream to the server. Set the stream URL to + +```text +rtmp://your.domain.host/live +``` + +and enter the stream key. + +Now you can view the stream at + +```text +http://your.domain.host:8089/ +``` + +There is a video only version at + +```text +http://your.domain.host:8089/video +``` + +and a chat only version at + +```text +http://your.domain.host:8089/chat +``` + +The default listen port is `:8089`. It can be changed by providing a new port +at startup: + +```text +Usage of .\MovieNight.exe: + -k string + Stream key, to protect your stream + -l string + host:port of the MovieNight (default ":8089") +``` diff --git a/MovieNight/rtsp.go b/MovieNight/rtsp.go new file mode 100755 index 0000000..ed1aac9 --- /dev/null +++ b/MovieNight/rtsp.go @@ -0,0 +1,131 @@ +package main + +import ( + "crypto/tls" + "io" + "log" + "net/http" + "os" + "os/exec" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +var rtspCmd *exec.Cmd +var done bool + +func rtsp() { + install := exec.Command("bash", "-c", ` + if ! which ffmpeg; then + apk add --no-cache ffmpeg \ + || sudo apk add --no-cache ffmpeg \ + || (apt update; apt -y install ffmpeg) \ + || (apt -y update; apt -y install ffmpeg) \ + || (sudo apt -y update; sudo apt -y install ffmpeg) \ + || (apt-get update; apt-get -y install ffmpeg) \ + || (apt-get -y update; apt-get -y install ffmpeg) \ + || (sudo apt-get -y update; sudo apt-get -y install ffmpeg) \ + || true + fi + if ! which ffmpeg; then + exit 499 + fi + `) + if err := install.Run(); err != nil { + panic(err) + } + for !done { + rtspCmd = exec.Command("bash", "-c", ` + exec ffmpeg \ + -hide_banner \ + -loglevel quiet \ + -i rtsp://${RTSP_IP:-192.168.0.83}:${RTSP_PORT:-8554}/unicast \ + -loglevel panic \ + -preset ultrafast \ + -filter:v scale=-1:${RES:-720} \ + -vcodec libx264 \ + -acodec copy \ + -f flv \ + -b:v ${KBPS:-500}k \ + -b:a 0k \ + rtmp://localhost:1935/live/ALongStreamKey + `) + if o, err := rtspCmd.StdoutPipe(); err != nil { + panic(err) + } else { + go io.Copy(os.Stdout, o) + } + if o, err := rtspCmd.StderrPipe(); err != nil { + panic(err) + } else { + go io.Copy(os.Stderr, o) + } + log.Println("starting rtsp cmd", rtspCmd) + if err := rtspCmd.Start(); err != nil { + panic(err) + } + time.Sleep(time.Second * 15) + log.Println("starting stream initially") + startStream() + log.Println("stopping stream initially") + stopStream() + log.Println("waiting rtsp cmd") + log.Println(rtspCmd.Wait()) + } +} + +func startStream() { + signalStream(syscall.Signal(unix.SIGCONT)) +} + +func stopStream() { + signalStream(syscall.Signal(unix.SIGSTOP)) +} + +func killStream() { + signalStream(syscall.Signal(unix.SIGKILL)) +} + +func permaKillStream() { + done = true + killStream() +} + +func signalStream(s syscall.Signal) { + for rtspCmd == nil { + log.Println("rtspCmdis nil") + time.Sleep(time.Second * 3) + } + for rtspCmd.Process == nil { + log.Println("rtspCmd.Process is nil") + time.Sleep(time.Second * 3) + } + rtspCmd.Process.Signal(os.Signal(s)) +} + +func rebootCam() { + c := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + host := "192.168.0.83" + if h, ok := os.LookupEnv("RTSP_IP"); ok { + host = h + } + r, err := http.NewRequest("GET", "https://"+host+"/cgi-bin/action.cgi?cmd=reboot", nil) + if err != nil { + panic(err) + } + pass := "fwees123" + if p, ok := os.LookupEnv("RTSP_PASS"); ok { + pass = p + } + r.SetBasicAuth("root", pass) + resp, err := c.Do(r) + if err != nil { + panic(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + panic(resp.StatusCode) + } +} diff --git a/MovieNight/scrapedagain/.gitignore b/MovieNight/scrapedagain/.gitignore new file mode 100755 index 0000000..1e38249 --- /dev/null +++ b/MovieNight/scrapedagain/.gitignore @@ -0,0 +1,46 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.aseprite + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Log files +*.log + +# GoCode debug file +debug + +# Linux binary +MovieNight + +# Windows binary +MovieNight.exe + +# Darwin binary +MovieNightDarwin + +# Twitch channel info +static/subscriber.json + +# This file now holds the stream key. Don't include it. +settings.json + +# vscode +.vscode + +# Autobuilt wasm files +static/main.wasm + +# tags for vim +tags + +# channel and emote list from twitch +subscribers.json diff --git a/MovieNight/scrapedagain/.travis.yml b/MovieNight/scrapedagain/.travis.yml new file mode 100755 index 0000000..55a3d40 --- /dev/null +++ b/MovieNight/scrapedagain/.travis.yml @@ -0,0 +1,10 @@ +language: go + +before_install: + - make get + +go: + - 1.12.x + +env: + - GO111MODULE=on diff --git a/MovieNight/scrapedagain/Dockerfile b/MovieNight/scrapedagain/Dockerfile new file mode 100755 index 0000000..e990927 --- /dev/null +++ b/MovieNight/scrapedagain/Dockerfile @@ -0,0 +1,18 @@ +FROM frolvlad/alpine-glibc:alpine-3.9_glibc-2.29 + +RUN apk update \ + && apk add --no-cache \ + ca-certificates \ + ffmpeg \ + bash + +RUN mkdir -p /var/log +WORKDIR /main + +COPY . . + +ENV GOPATH="" +ENV MNT="/mnt/" +ENTRYPOINT ["/main/MovieNight"] +CMD [] + diff --git a/MovieNight/scrapedagain/Makefile b/MovieNight/scrapedagain/Makefile new file mode 100755 index 0000000..79ad13f --- /dev/null +++ b/MovieNight/scrapedagain/Makefile @@ -0,0 +1,53 @@ +# If a different version of Go is installed (via `go get`) set the GO_VERSION +# environment variable to that version. For example, setting it to "1.13.7" +# will run `go1.13.7 build [...]` instead of `go build [...]`. +# +# For info on installing extra versions, see this page: +# https://golang.org/doc/install#extra_versions + +TAGS= + +# Windows needs the .exe extension. +ifeq ($(OS),Windows_NT) +EXT=.exe +endif + +.PHONY: fmt vet get clean dev setdev test ServerMovieNight + +all: fmt vet test MovieNight$(EXT) static/main.wasm settings.json + +# Build the server deployment +server: ServerMovieNight static/main.wasm + +# Bulid used for deploying to my server. +ServerMovieNight: *.go common/*.go + GOOS=linux GOARCH=386 go$(GO_VERSION) build -o MovieNight $(TAGS) + +setdev: + $(eval export TAGS=-tags "dev") + +dev: setdev all + +MovieNight$(EXT): *.go common/*.go + go$(GO_VERSION) build -o $@ $(TAGS) + +static/main.wasm: wasm/*.go common/*.go + GOOS=js GOARCH=wasm go$(GO_VERSION) build -o $@ $(TAGS) wasm/*.go + +clean: + -rm MovieNight$(EXT) ./static/main.wasm + +fmt: + gofmt -w . + +vet: + go$(GO_VERSION) vet $(TAGS) ./... + GOOS=js GOARCH=wasm go$(GO_VERSION) vet $(TAGS) ./... + +test: + go$(GO_VERSION) test $(TAGS) ./... + +# Do not put settings_example.json here as a prereq to avoid overwriting +# the settings if the example is updated. +settings.json: + cp settings_example.json settings.json diff --git a/MovieNight/scrapedagain/common/chatcommands.go b/MovieNight/scrapedagain/common/chatcommands.go new file mode 100755 index 0000000..8e9abc5 --- /dev/null +++ b/MovieNight/scrapedagain/common/chatcommands.go @@ -0,0 +1,87 @@ +package common + +import "strings" + +const CommandNameSeparator = "," + +type ChatCommandNames []string + +func (c ChatCommandNames) String() string { + return strings.Join(c, CommandNameSeparator) +} + +// Names for commands +var ( + // User Commands + CNMe ChatCommandNames = []string{"me"} + CNHelp ChatCommandNames = []string{"help"} + CNCount ChatCommandNames = []string{"count"} + CNColor ChatCommandNames = []string{"color", "colour"} + CNWhoAmI ChatCommandNames = []string{"w", "whoami"} + CNAuth ChatCommandNames = []string{"auth"} + CNUsers ChatCommandNames = []string{"users"} + CNNick ChatCommandNames = []string{"nick", "name"} + CNStats ChatCommandNames = []string{"stats"} + CNPin ChatCommandNames = []string{"pin", "password"} + CNEmotes ChatCommandNames = []string{"emotes"} + // Mod Commands + CNSv ChatCommandNames = []string{"sv"} + CNPlaying ChatCommandNames = []string{"playing"} + CNUnmod ChatCommandNames = []string{"unmod"} + CNKick ChatCommandNames = []string{"kick"} + CNBan ChatCommandNames = []string{"ban"} + CNUnban ChatCommandNames = []string{"unban"} + CNPurge ChatCommandNames = []string{"purge"} + // Admin Commands + CNMod ChatCommandNames = []string{"mod"} + CNReloadPlayer ChatCommandNames = []string{"reloadplayer"} + CNReloadEmotes ChatCommandNames = []string{"reloademotes"} + CNModpass ChatCommandNames = []string{"modpass"} + CNIP ChatCommandNames = []string{"iplist"} + CNAddEmotes ChatCommandNames = []string{"addemotes"} + CNRoomAccess ChatCommandNames = []string{"changeaccess", "hodor"} +) + +var ChatCommands = []ChatCommandNames{ + // User + CNMe, + CNHelp, + CNCount, + CNColor, + CNWhoAmI, + CNAuth, + CNUsers, + CNNick, + CNStats, + CNPin, + CNEmotes, + + // Mod + CNSv, + CNPlaying, + CNUnmod, + CNKick, + CNBan, + CNUnban, + CNPurge, + + // Admin + CNMod, + CNReloadPlayer, + CNReloadEmotes, + CNModpass, + CNIP, + CNAddEmotes, + CNRoomAccess, +} + +func GetFullChatCommand(c string) string { + for _, names := range ChatCommands { + for _, n := range names { + if c == n { + return names.String() + } + } + } + return "" +} diff --git a/MovieNight/scrapedagain/common/chatdata.go b/MovieNight/scrapedagain/common/chatdata.go new file mode 100755 index 0000000..1fd77c7 --- /dev/null +++ b/MovieNight/scrapedagain/common/chatdata.go @@ -0,0 +1,249 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +type DataInterface interface { + HTML() string +} + +type ChatData struct { + Type DataType + Data DataInterface +} + +func (c ChatData) ToJSON() (ChatDataJSON, error) { + rawData, err := json.Marshal(c.Data) + return ChatDataJSON{ + Type: c.Type, + Data: rawData, + }, err +} + +type ChatDataJSON struct { + Type DataType + Data json.RawMessage +} + +func (c ChatDataJSON) ToData() (ChatData, error) { + data, err := c.GetData() + return ChatData{ + Type: c.Type, + Data: data, + }, err +} + +func (c ChatDataJSON) GetData() (DataInterface, error) { + var data DataInterface + var err error + + switch c.Type { + case DTInvalid: + return nil, errors.New("data type is invalid") + case DTChat: + d := DataMessage{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTCommand: + d := DataCommand{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTEvent: + d := DataEvent{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTClient: + d := ClientData{} + err = json.Unmarshal(c.Data, &d) + data = d + case DTHidden: + d := HiddenMessage{} + err = json.Unmarshal(c.Data, &d) + data = d + default: + err = fmt.Errorf("unhandled data type: %d", c.Type) + } + + return data, err +} + +type ClientData struct { + Type ClientDataType + Message string +} + +func (c ClientData) HTML() string { + // Client data is for client to server communication only, so clients should not see this + return `The developer messed up. You should not be seeing this.` +} + +type DataMessage struct { + From string + Color string + Message string + Level CommandLevel + Type MessageType +} + +// TODO: Read this HTML from a template somewhere +func (dc DataMessage) HTML() string { + switch dc.Type { + case MsgAction: + return `` + dc.From + + ` ` + dc.Message + `` + + case MsgServer: + return `` + dc.Message + `` + + case MsgError: + return `` + dc.Message + `` + + case MsgNotice: + return `` + dc.Message + `` + + case MsgCommandResponse: + return `` + dc.Message + `` + + case MsgCommandError: + return `` + dc.Message + `` + + default: + badge := "" + switch dc.Level { + case CmdlMod: + badge = `` + case CmdlAdmin: + badge = `` + } + return `` + badge + `` + dc.From + + `: ` + dc.Message + `` + } +} + +func NewChatMessage(name, color, msg string, lvl CommandLevel, msgtype MessageType) ChatData { + return ChatData{ + Type: DTChat, + Data: DataMessage{ + From: name, + Color: color, + Message: msg, + Type: msgtype, + Level: lvl, + }, + } +} + +type DataCommand struct { + Command CommandType + Arguments []string +} + +func (de DataCommand) HTML() string { + switch de.Command { + case CmdPurgeChat: + return `Chat has been purged by a moderator.` + default: + return "" + } +} + +func NewChatCommand(command CommandType, args []string) ChatData { + return ChatData{ + Type: DTCommand, + Data: DataCommand{ + Command: command, + Arguments: args, + }, + } +} + +type DataEvent struct { + Event EventType + User string + Color string +} + +func (de DataEvent) HTML() string { + switch de.Event { + case EvKick: + return `` + + de.User + ` has been kicked.` + case EvLeave: + return `` + + de.User + ` has left the chat.` + case EvBan: + return `` + + de.User + ` has been banned.` + case EvJoin: + return `` + + de.User + ` has joined the chat.` + case EvNameChange: + names := strings.Split(de.User, ":") + if len(names) != 2 { + return `Somebody changed their name, but IDK who ` + + ParseEmotes("Jebaited") + `.` + } + + return `` + + names[0] + ` has changed their name to ` + names[1] + `.` + case EvNameChangeForced: + names := strings.Split(de.User, ":") + if len(names) != 2 { + return `An admin changed somebody's name, but IDK who ` + + ParseEmotes("Jebaited") + `.` + } + + return `` + + names[0] + ` has had their name changed to ` + names[1] + ` by an admin.` + } + return "" +} + +func NewChatEvent(event EventType, name, color string) ChatData { + return ChatData{ + Type: DTEvent, + Data: DataEvent{ + Event: event, + User: name, + Color: color, + }, + } +} + +// DataHidden is for the server to send instructions and data +// to the client without the purpose of outputting it on the chat +type HiddenMessage struct { + Type ClientDataType + Data interface{} +} + +func (h HiddenMessage) HTML() string { + return "" +} + +func NewChatHiddenMessage(clientType ClientDataType, data interface{}) ChatData { + return ChatData{ + Type: DTHidden, + Data: HiddenMessage{ + Type: clientType, + Data: data, + }, + } +} + +func DecodeData(rawjson string) (ChatDataJSON, error) { + var data ChatDataJSON + err := json.Unmarshal([]byte(rawjson), &data) + return data, err +} + +type JoinData struct { + Name string + Color string +} diff --git a/MovieNight/scrapedagain/common/colors.go b/MovieNight/scrapedagain/common/colors.go new file mode 100755 index 0000000..5b2312e --- /dev/null +++ b/MovieNight/scrapedagain/common/colors.go @@ -0,0 +1,135 @@ +package common + +import ( + "errors" + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "time" +) + +func init() { + rand.Seed(int64(time.Now().Nanosecond())) +} + +// Colors holds all the valid html color names for MovieNight +// the values in colors must be lowercase so it matches with the color input +// this saves from having to call strings.ToLower(color) every time to check +var Colors = []string{ + "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", + "beige", "bisque", "blanchedalmond", "blueviolet", "brown", + "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", + "cornflowerblue", "cornsilk", "crimson", "cyan", "darkcyan", + "darkgoldenrod", "darkgray", "darkkhaki", "darkmagenta", "darkolivegreen", + "darkorange", "darkorchid", "darksalmon", "darkseagreen", "darkslateblue", + "darkslategray", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", + "dimgray", "dodgerblue", "firebrick", "floralwhite", "forestgreen", + "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", + "gray", "greenyellow", "honeydew", "hotpink", "indigo", + "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", + "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", + "lightgrey", "lightgreen", "lightpink", "lightsalmon", "lightseagreen", + "lightskyblue", "lightslategray", "lightsteelblue", "lightyellow", "lime", + "limegreen", "linen", "magenta", "mediumaquamarine", "mediumorchid", + "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", + "mediumvioletred", "mintcream", "mistyrose", "moccasin", "navajowhite", + "oldlace", "olive", "olivedrab", "orange", "orangered", + "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", + "papayawhip", "peachpuff", "peru", "pink", "plum", + "powderblue", "purple", "rebeccapurple", "red", "rosybrown", + "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", + "seashell", "sienna", "silver", "skyblue", "slateblue", + "slategray", "snow", "springgreen", "steelblue", "tan", + "teal", "thistle", "tomato", "turquoise", "violet", + "wheat", "white", "whitesmoke", "yellow", "yellowgreen", +} + +var ( + regexColor = regexp.MustCompile(`^([0-9A-Fa-f]{3}){1,2}$`) +) + +// IsValidColor takes a string s and compares it against a list of css color names. +// It also accepts hex codes in the form of #RGB and #RRGGBB +func IsValidColor(s string) bool { + s = strings.TrimLeft(strings.ToLower(s), "#") + for _, c := range Colors { + if s == c { + return true + } + } + + if regexColor.MatchString(s) { + r, g, b, err := hex(s) + if err != nil { + return false + } + total := float32(r + g + b) + return total > 0.7 && float32(b)/total < 0.7 + } + return false +} + +// RandomColor returns a hex color code +func RandomColor() string { + var color string + for !IsValidColor(color) { + color = "" + for i := 0; i < 3; i++ { + s := strconv.FormatInt(rand.Int63n(255), 16) + if len(s) == 1 { + s = "0" + s + } + color += s + } + } + return "#" + color +} + +// hex returns R, G, B as values +func hex(s string) (int, int, int, error) { + // Make the string just the base16 numbers + s = strings.TrimLeft(s, "#") + + if len(s) == 3 { + var err error + s, err = hexThreeToSix(s) + if err != nil { + return 0, 0, 0, err + } + } + + if len(s) == 6 { + R64, err := strconv.ParseInt(s[0:2], 16, 32) + if err != nil { + return 0, 0, 0, err + } + + G64, err := strconv.ParseInt(s[2:4], 16, 32) + if err != nil { + return 0, 0, 0, err + } + + B64, err := strconv.ParseInt(s[4:6], 16, 32) + if err != nil { + return 0, 0, 0, err + } + + return int(R64), int(G64), int(B64), nil + } + return 0, 0, 0, errors.New("incorrect format") +} + +func hexThreeToSix(s string) (string, error) { + if len(s) != 3 { + return "", fmt.Errorf("%d is the incorrect length of string for convertsion", len(s)) + } + + h := "" + for i := 0; i < 3; i++ { + h += string(s[i]) + h += string(s[i]) + } + return h, nil +} diff --git a/MovieNight/scrapedagain/common/colors_test.go b/MovieNight/scrapedagain/common/colors_test.go new file mode 100755 index 0000000..855df26 --- /dev/null +++ b/MovieNight/scrapedagain/common/colors_test.go @@ -0,0 +1,42 @@ +package common + +import ( + "testing" +) + +func TestColorHexThreeToSix(t *testing.T) { + expected := "RRGGBB" + result, _ := hexThreeToSix("RGB") + if result != expected { + t.Errorf("expected %#v, got %#v", expected, result) + } +} + +func TestHex(t *testing.T) { + // The testing data layout is inputer, Expected Red, Exp Green, Exp Blue, expect error + data := [][]interface{}{ + []interface{}{"010203", 1, 2, 3, false}, + []interface{}{"100", 17, 0, 0, false}, + []interface{}{"100", 1, 0, 0, true}, + []interface{}{"1000", 0, 0, 0, true}, + []interface{}{"010203", 1, 2, 4, true}, + []interface{}{"0102GG", 1, 2, 4, true}, + } + + for i := range data { + input := data[i][0].(string) + r, g, b, err := hex(input) + if err != nil { + if !data[i][4].(bool) { + t.Errorf("with input %#v: %v", input, err) + } + continue + } + + rr, rg, rb := data[i][1].(int), data[i][2].(int), data[i][3].(int) + + if !data[i][4].(bool) && (r != rr || g != rg || b != rb) { + t.Errorf("expected %d, %d, %d - got %d, %d, %d", r, g, b, rr, rg, rb) + } + } +} diff --git a/MovieNight/scrapedagain/common/constants.go b/MovieNight/scrapedagain/common/constants.go new file mode 100755 index 0000000..56aebc5 --- /dev/null +++ b/MovieNight/scrapedagain/common/constants.go @@ -0,0 +1,73 @@ +package common + +type ClientDataType int + +// Data types for communicating with the client +const ( + CdMessage ClientDataType = iota // a normal message from the client meant to be broadcast + CdUsers // get a list of users + CdPing // ping the server to keep the connection alive + CdAuth // get the auth levels of the user + CdColor // get the users color + CdEmote // get a list of emotes + CdJoin // a message saying the client wants to join + CdNotify // a notify message for the client to show +) + +type DataType int + +// Data types for command messages +const ( + DTInvalid DataType = iota + DTChat // chat message + DTCommand // non-chat function + DTEvent // join/leave/kick/ban events + DTClient // a message coming from the client + DTHidden // a message that is purely instruction and data, not shown to user +) + +type CommandType int + +// Command Types +const ( + CmdPlaying CommandType = iota + CmdRefreshPlayer + CmdPurgeChat + CmdHelp + CmdEmotes +) + +type CommandLevel int + +// Command access levels +const ( + CmdlUser CommandLevel = iota + CmdlMod + CmdlAdmin +) + +type EventType int + +// Event Types +const ( + EvJoin EventType = iota + EvLeave + EvKick + EvBan + EvServerMessage + EvNameChange + EvNameChangeForced +) + +type MessageType int + +// Message Types +const ( + MsgChat MessageType = iota // standard chat + MsgAction // /me command + MsgServer // server message + MsgError // something went wrong + MsgNotice // Like MsgServer, but for mods and admins only. + MsgCommandResponse // The response from command + MsgCommandError // The error response from command +) diff --git a/MovieNight/scrapedagain/common/emotes.go b/MovieNight/scrapedagain/common/emotes.go new file mode 100755 index 0000000..5f1d3ec --- /dev/null +++ b/MovieNight/scrapedagain/common/emotes.go @@ -0,0 +1,74 @@ +package common + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +type EmotesMap map[string]string + +var Emotes EmotesMap + +var reStripStatic = regexp.MustCompile(`^(\\|/)?static`) + +func init() { + Emotes = NewEmotesMap() +} + +func NewEmotesMap() EmotesMap { + return map[string]string{} +} + +func (em EmotesMap) Add(fullpath string) EmotesMap { + fullpath = reStripStatic.ReplaceAllLiteralString(fullpath, "") + + base := filepath.Base(fullpath) + code := base[0 : len(base)-len(filepath.Ext(base))] + + _, exists := em[code] + + num := 0 + for exists { + num += 1 + _, exists = em[fmt.Sprintf("%s-%d", code, num)] + } + + if num > 0 { + code = fmt.Sprintf("%s-%d", code, num) + } + + em[code] = fullpath + //fmt.Printf("Added emote %s at path %q\n", code, fullpath) + return em +} + +func EmoteToHtml(file, title string) string { + return fmt.Sprintf(``, file, title) +} + +func ParseEmotesArray(words []string) []string { + newWords := []string{} + for _, word := range words { + // make :emote: and [emote] valid for replacement. + wordTrimmed := strings.Trim(word, ":[]") + + found := false + for key, val := range Emotes { + if key == wordTrimmed { + newWords = append(newWords, EmoteToHtml(val, key)) + found = true + } + } + if !found { + newWords = append(newWords, word) + } + } + return newWords +} + +func ParseEmotes(msg string) string { + words := ParseEmotesArray(strings.Split(msg, " ")) + return strings.Join(words, " ") +} diff --git a/MovieNight/scrapedagain/common/emotes_test.go b/MovieNight/scrapedagain/common/emotes_test.go new file mode 100755 index 0000000..bb31646 --- /dev/null +++ b/MovieNight/scrapedagain/common/emotes_test.go @@ -0,0 +1,44 @@ +package common + +import ( + "os" + "testing" +) + +var data_good = map[string]string{ + "one": ``, + "two": ``, + "three": ``, + + ":one:": ``, + ":two:": ``, + ":three:": ``, + + "[one]": ``, + "[two]": ``, + "[three]": ``, + + ":one: two [three]": ` `, + + "nope one what": `nope what`, + "nope :two: what": `nope what`, + "nope [three] what": `nope what`, +} + +func TestMain(m *testing.M) { + Emotes = map[string]string{ + "one": "/emotes/one.png", + "two": "/emotes/two.png", + "three": "/emotes/three.gif", + } + os.Exit(m.Run()) +} + +func TestEmotes_ParseEmotes(t *testing.T) { + for input, expected := range data_good { + got := ParseEmotes(input) + if got != expected { + t.Errorf("%s failed to parse into %q. Received: %q", input, expected, got) + } + } +} diff --git a/MovieNight/scrapedagain/common/logging.go b/MovieNight/scrapedagain/common/logging.go new file mode 100755 index 0000000..01bf824 --- /dev/null +++ b/MovieNight/scrapedagain/common/logging.go @@ -0,0 +1,200 @@ +package common + +import ( + "fmt" + "io" + "log" + "os" +) + +var loglevel LogLevel + +type LogLevel string + +const ( + LLError LogLevel = "error" // only log errors + LLChat LogLevel = "chat" // log chat and commands + LLInfo LogLevel = "info" // log info messages (not quite debug, but not chat) + LLDebug LogLevel = "debug" // log everything +) + +const ( + logPrefixError string = "[ERROR] " + logPrefixChat string = "[CHAT] " + logPrefixInfo string = "[INFO] " + logPrefixDebug string = "[DEBUG] " +) + +var ( + logError *log.Logger + logChat *log.Logger + logInfo *log.Logger + logDebug *log.Logger +) + +func SetupLogging(level LogLevel, file string) error { + switch level { + case LLDebug: + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + logChat = log.New(os.Stdout, logPrefixChat, log.LstdFlags) + logDebug = log.New(os.Stdout, logPrefixDebug, log.LstdFlags) + logInfo = log.New(os.Stdout, logPrefixInfo, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + logChat = log.New(io.MultiWriter(os.Stdout, f), logPrefixChat, log.LstdFlags) + logInfo = log.New(io.MultiWriter(os.Stdout, f), logPrefixInfo, log.LstdFlags) + logDebug = log.New(io.MultiWriter(os.Stdout, f), logPrefixDebug, log.LstdFlags) + } + case LLChat: + logDebug = nil + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + logChat = log.New(os.Stdout, logPrefixChat, log.LstdFlags) + logInfo = log.New(os.Stdout, logPrefixInfo, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + logChat = log.New(io.MultiWriter(os.Stdout, f), logPrefixChat, log.LstdFlags) + logInfo = log.New(io.MultiWriter(os.Stdout, f), logPrefixInfo, log.LstdFlags) + } + + case LLInfo: + logDebug = nil + logChat = nil + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + logInfo = log.New(os.Stdout, logPrefixInfo, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + logInfo = log.New(io.MultiWriter(os.Stdout, f), logPrefixInfo, log.LstdFlags) + } + + // Default to error + default: + logChat = nil + logDebug = nil + logInfo = nil + if file == "" { + logError = log.New(os.Stderr, logPrefixError, log.LstdFlags) + } else { + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Unable to open log file for writing: %s", err) + } + logError = log.New(io.MultiWriter(os.Stderr, f), logPrefixError, log.LstdFlags) + } + } + return nil +} + +func LogErrorf(format string, v ...interface{}) { + if logError == nil { + panic("Logging not setup!") + } + + logError.Printf(format, v...) +} + +func LogErrorln(v ...interface{}) { + if logError == nil { + panic("Logging not setup!") + } + + logError.Println(v...) +} + +func LogChatf(format string, v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging chat and commands is turned off. + if logChat == nil { + return + } + + logChat.Printf(format, v...) +} + +func LogChatln(v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging chat and commands is turned off. + if logChat == nil { + return + } + + logChat.Println(v...) +} + +func LogInfof(format string, v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging info is turned off. + if logInfo == nil { + return + } + + logInfo.Printf(format, v...) +} + +func LogInfoln(v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging info is turned off. + if logInfo == nil { + return + } + + logInfo.Println(v...) +} + +func LogDebugf(format string, v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging debug is turned off. + if logDebug == nil { + return + } + + logDebug.Printf(format, v...) +} + +func LogDebugln(v ...interface{}) { + // if logError isn't set to something, logging wasn't setup. + if logError == nil { + panic("Logging not setup!") + } + + // logging debug is turned off. + if logDebug == nil { + return + } + + logDebug.Println(v...) +} diff --git a/MovieNight/scrapedagain/common/logging_dev.go b/MovieNight/scrapedagain/common/logging_dev.go new file mode 100755 index 0000000..9dccfc1 --- /dev/null +++ b/MovieNight/scrapedagain/common/logging_dev.go @@ -0,0 +1,18 @@ +// +build dev + +package common + +import ( + "log" + "os" +) + +var logDev *log.Logger = log.New(os.Stdout, "[DEV]", log.LstdFlags) + +func LogDevf(format string, v ...interface{}) { + logDev.Printf(format, v...) +} + +func LogDevln(v ...interface{}) { + logDev.Println(v...) +} diff --git a/MovieNight/scrapedagain/common/templates.go b/MovieNight/scrapedagain/common/templates.go new file mode 100755 index 0000000..e8b3abb --- /dev/null +++ b/MovieNight/scrapedagain/common/templates.go @@ -0,0 +1,90 @@ +package common + +import ( + "fmt" + html "html/template" + "net/http" + "strings" + text "text/template" +) + +// Holds the server's templates +var serverTemplates map[string]*html.Template + +// Holds the client's chat templates +var chatTemplates map[string]*text.Template + +var isServer bool = false + +// keys and files to load for that template +var serverTemplateDefs map[string][]string = map[string][]string{ + "pin": []string{"./static/base.html", "./static/thedoor.html"}, + "main": []string{"./static/base.html", "./static/main.html"}, + "help": []string{"./static/base.html", "./static/help.html"}, + "emotes": []string{"./static/base.html", "./static/emotes.html"}, +} + +var chatTemplateDefs map[string]string = map[string]string{ + fmt.Sprint(DTInvalid, 0): "wot", + + fmt.Sprint(DTChat, MsgChat): `{{.Badge}} {{.From}}` + + `: {{.Message}}`, + fmt.Sprint(DTChat, MsgAction): `{{.From}}` + + ` {{.Message}}`, +} + +// Called from the server +func InitTemplates() error { + isServer = true + serverTemplates = make(map[string]*html.Template) + chatTemplates = make(map[string]*text.Template) + + // Parse server templates + for key, files := range serverTemplateDefs { + t, err := html.ParseFiles(files...) + if err != nil { + return fmt.Errorf("Unable to parse templates for %s: %v", key, err) + } + + serverTemplates[key] = t + } + + // Parse client templates + //for key, def := range chatTemplateDefs { + // t := text.New(key) + // err, _ := t.Parse(def) + // if err != nil { + // return fmt.Errorf("Unabel to parse chat template %q: %v", key, err) + // } + + // chatTemplates[key] = t + //} + + return nil +} + +// TODO +func LoadChatTemplates() error { + return nil +} + +func ExecuteChatTemplate(typeA, typeB int, data interface{}) (string, error) { + key := fmt.Sprint(typeA, typeB) + t := chatTemplates[key] + builder := &strings.Builder{} + + if err := t.Execute(builder, data); err != nil { + return "", err + } + + return builder.String(), nil +} + +func ExecuteServerTemplate(w http.ResponseWriter, key string, data interface{}) error { + t, ok := serverTemplates[key] + if !ok { + return fmt.Errorf("Template with the key %q does not exist", key) + } + + return t.Execute(w, data) +} diff --git a/MovieNight/scrapedagain/common/utils.go b/MovieNight/scrapedagain/common/utils.go new file mode 100755 index 0000000..4f1dda2 --- /dev/null +++ b/MovieNight/scrapedagain/common/utils.go @@ -0,0 +1,18 @@ +package common + +// Misc utils + +import ( + "regexp" +) + +var usernameRegex *regexp.Regexp = regexp.MustCompile(`^[0-9a-zA-Z_-]*[a-zA-Z0-9]+[0-9a-zA-Z_-]*$`) + +const InvalidNameError string = `Invalid name.
Name must be between 3 and 36 characters in length; contain only numbers, letters, underscores or dashes; and contain at least one number or letter.
Names cannot contain spaces.` + +// IsValidName checks that name is within the correct ranges, follows the regex defined +// and is not a valid color name +func IsValidName(name string) bool { + return 3 <= len(name) && len(name) <= 36 && + usernameRegex.MatchString(name) +} diff --git a/MovieNight/scrapedagain/connection.go b/MovieNight/scrapedagain/connection.go new file mode 100755 index 0000000..f2f8cc7 --- /dev/null +++ b/MovieNight/scrapedagain/connection.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "net" + "sync" + + "github.com/gorilla/websocket" + "github.com/zorchenhimer/MovieNight/common" +) + +type chatConnection struct { + *websocket.Conn + mutex sync.RWMutex + forwardedFor string + clientName string +} + +func (cc *chatConnection) ReadData(data interface{}) error { + cc.mutex.RLock() + defer cc.mutex.RUnlock() + + stats.msgInInc() + return cc.ReadJSON(data) +} + +func (cc *chatConnection) WriteData(data interface{}) error { + cc.mutex.Lock() + defer cc.mutex.Unlock() + + stats.msgOutInc() + err := cc.WriteJSON(data) + if err != nil { + if operr, ok := err.(*net.OpError); ok { + common.LogDebugln("OpError: " + operr.Err.Error()) + } + return fmt.Errorf("Error writing data to %s %s: %v", cc.clientName, cc.Host(), err) + } + return nil +} + +func (cc *chatConnection) Host() string { + if len(cc.forwardedFor) > 0 { + return cc.forwardedFor + } + + host, _, err := net.SplitHostPort(cc.RemoteAddr().String()) + if err != nil { + return cc.RemoteAddr().String() + } + return host +} diff --git a/MovieNight/scrapedagain/emotes.go b/MovieNight/scrapedagain/emotes.go new file mode 100755 index 0000000..d8fd00b --- /dev/null +++ b/MovieNight/scrapedagain/emotes.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/zorchenhimer/MovieNight/common" +) + +const emoteDir = "./static/emotes/" + +type TwitchUser struct { + ID string + Login string +} + +type EmoteInfo struct { + ID int + Code string +} + +func loadEmotes() error { + //fmt.Println(processEmoteDir(emoteDir)) + newEmotes, err := processEmoteDir(emoteDir) + if err != nil { + return err + } + + common.Emotes = newEmotes + + return nil +} + +func processEmoteDir(path string) (common.EmotesMap, error) { + dirInfo, err := ioutil.ReadDir(path) + if err != nil { + return nil, errors.Wrap(err, "could not open emoteDir:") + } + + subDirs := []string{} + + for _, item := range dirInfo { + // Get first level subdirs (eg, "twitch", "discord", etc) + if item.IsDir() { + subDirs = append(subDirs, item.Name()) + continue + } + } + + em := common.NewEmotesMap() + // Find top level emotes + em, err = findEmotes(path, em) + if err != nil { + return nil, errors.Wrap(err, "could not findEmotes() in top level directory:") + } + + // Get second level subdirs (eg, "twitch", "zorchenhimer", etc) + for _, dir := range subDirs { + subd, err := ioutil.ReadDir(filepath.Join(path, dir)) + if err != nil { + fmt.Printf("Error reading dir %q: %v\n", subd, err) + continue + } + for _, d := range subd { + if d.IsDir() { + //emotes = append(emotes, findEmotes(filepath.Join(path, dir, d.Name()))...) + p := filepath.Join(path, dir, d.Name()) + em, err = findEmotes(p, em) + if err != nil { + fmt.Printf("Error finding emotes in %q: %v\n", p, err) + } + } + } + } + + fmt.Printf("processEmoteDir: %d\n", len(em)) + return em, nil +} + +func findEmotes(dir string, em common.EmotesMap) (common.EmotesMap, error) { + //em := NewEmotesMap() + + fmt.Printf("finding emotes in %q\n", dir) + emotePNGs, err := filepath.Glob(filepath.Join(dir, "*.png")) + if err != nil { + return em, fmt.Errorf("unable to glob emote directory: %s\n", err) + } + fmt.Printf("%d emotePNGs\n", len(emotePNGs)) + + emoteGIFs, err := filepath.Glob(filepath.Join(dir, "*.gif")) + if err != nil { + return em, errors.Wrap(err, "unable to glob emote directory:") + } + fmt.Printf("%d emoteGIFs\n", len(emoteGIFs)) + + for _, file := range emotePNGs { + em = em.Add(file) + //emotes = append(emotes, common.Emote{FullPath: dir, Code: file}) + } + + for _, file := range emoteGIFs { + em = em.Add(file) + } + + return em, nil +} + +func getEmotes(names []string) error { + users := getUserIDs(names) + users = append(users, TwitchUser{ID: "0", Login: "twitch"}) + + 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 := filepath.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 := filepath.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) + } + } + } + + for amount, sizes := range cheers { + name := fmt.Sprintf("%sCheer%s.gif", user.Login, amount) + filePath := filepath.Join(emoteUserDir, name) + file, err := os.Create(filePath) + if err != nil { + return errors.Wrapf(err, "could not create emote file in path \"%s\":", filePath) + } + + err = downloadCheerEmote(sizes["4"], file) + if err != nil { + return errors.Wrapf(err, "could not download emote %s:", name) + } + } + } + return nil +} + +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 { + log.Fatalln("Error generating new request:", err) + } + request.Header.Set("Client-ID", settings.TwitchClientID) + + client := http.Client{} + resp, err := client.Do(request) + if err != nil { + log.Fatalln("Error sending request:", err) + } + + decoder := json.NewDecoder(resp.Body) + type userResponse struct { + Data []TwitchUser + } + var data userResponse + + err = decoder.Decode(&data) + if err != nil { + log.Fatalln("Error decoding data:", err) + } + + 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/MovieNight/scrapedagain/errors.go b/MovieNight/scrapedagain/errors.go new file mode 100755 index 0000000..3f4f8d5 --- /dev/null +++ b/MovieNight/scrapedagain/errors.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "reflect" + "strings" +) + +func errorName(err error) string { + return reflect.ValueOf(err).Type().Name() +} + +// UserNameError is a base error for errors that deal with user names +type UserNameError struct { + Name string +} + +// UserFormatError is an error for when the name format does not match what is required +type UserFormatError UserNameError + +func (e UserFormatError) Error() string { + return fmt.Sprintf("\"%s\", is in an invalid format", e.Name) +} + +// UserTakenError is an error for when a user tries to join with a name that is already taken +type UserTakenError UserNameError + +func (e UserTakenError) Error() string { + return fmt.Sprintf("\"%s\", is already taken", e.Name) +} + +// BannedUserError is an error for when a user tries to join with a banned ip address +type BannedUserError struct { + Host, Name string + Names []string +} + +func (e BannedUserError) Error() string { + return fmt.Sprintf("banned user tried to connect with IP %s: %s (banned with name(s) %s)", e.Host, e.Name, strings.Join(e.Names, ", ")) +} + +func newBannedUserError(host, name string, names []string) BannedUserError { + return BannedUserError{ + Host: host, + Name: name, + Names: names, + } +} diff --git a/MovieNight/scrapedagain/favicon.png b/MovieNight/scrapedagain/favicon.png new file mode 100755 index 0000000..723c1e7 Binary files /dev/null and b/MovieNight/scrapedagain/favicon.png differ diff --git a/MovieNight/scrapedagain/go.mod b/MovieNight/scrapedagain/go.mod new file mode 100755 index 0000000..451ae17 --- /dev/null +++ b/MovieNight/scrapedagain/go.mod @@ -0,0 +1,23 @@ +module github.com/zorchenhimer/MovieNight + +go 1.12 + +require ( + github.com/Microsoft/go-winio v0.4.12 // indirect + github.com/cenkalti/backoff v2.1.1+incompatible // indirect + github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a // indirect + github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect + github.com/gorilla/sessions v1.1.3 + github.com/gorilla/websocket v1.4.0 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + 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 + 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 + golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f // indirect + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect + golang.org/x/sys v0.0.0-20190412213103-97732733099d +) diff --git a/MovieNight/scrapedagain/go.sum b/MovieNight/scrapedagain/go.sum new file mode 100755 index 0000000..013c0dc --- /dev/null +++ b/MovieNight/scrapedagain/go.sum @@ -0,0 +1,102 @@ +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Microsoft/go-winio v0.4.11 h1:zoIOcVf0xPN1tnMVbTtEdI+P8OofVk3NObnwOQ6nK2Q= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= +github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/chromedp/cdproto v0.0.0-20190217000753-2d8e8962ceb2 h1:4Ck8YOuS0G3+0xMb80cDSff7QpUolhSc0PGyfagbcdA= +github.com/chromedp/cdproto v0.0.0-20190217000753-2d8e8962ceb2/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= +github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= +github.com/chromedp/chromedp v0.1.3 h1:Nkqt42/7tvzg57mexc4LbM8nZbx7vSZ+eiUpeczGGL8= +github.com/chromedp/chromedp v0.1.3/go.mod h1:ZahQlJx8YBfDtuFN80zn6P7fskSotBkdhgKDoLWFANk= +github.com/containerd/continuity v0.0.0-20181027224239-bea7585dbfac h1:PThQaO4yCvJzJBUW1XoFQxLotWRhvX2fgljJX8yrhFI= +github.com/containerd/continuity v0.0.0-20181027224239-bea7585dbfac/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/dom v0.3.0 h1:u89+QvT1OPRSSTFf54o9RuK7C0Uoq2jFo4VCa4rnjtI= +github.com/dennwc/dom v0.3.0/go.mod h1:/z5w9Stx19m8RUwolsmsqTs9rDxKgJO5T9UEumilgk4= +github.com/dennwc/testproxy v1.0.1 h1:mQhNVWHPolTYjJrDZYKcugIplWRSlFAis6k/Zf1s0c0= +github.com/dennwc/testproxy v1.0.1/go.mod h1:EHGV9tzWhMPLmEoVJ2KGyC149XqwKZwBDViCjhKD5d8= +github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= +github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307 h1:vl4eIlySbjertFaNwiMjXsGrFVK25aOWLq7n+3gh2ls= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190221075403-6243d8e04c3f h1:B6PQkurxGG1rqEX96oE14gbj8bqvYC5dtks9r5uGmlE= +github.com/mailru/easyjson v0.0.0-20190221075403-6243d8e04c3f/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431 h1:nWhrOsCKdV6bivw03k7MROF2tYzCFGfYBYFrTEHyucs= +github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431/go.mod h1:aFJ1ZwLjvHN4yEzE5Bkz8rD8/d8Vlj3UIuvz2yfET7I= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/ory/dockertest v3.3.2+incompatible h1:uO+NcwH6GuFof/Uz8yzjNi1g0sGT5SLAJbdBvD8bUYc= +github.com/ory/dockertest v3.3.2+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= +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= +github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 h1:YoY1wS6JYVRpIfFngRf2HHo9R9dAne3xbkGOQ5rJXjU= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/MovieNight/scrapedagain/handlers.go b/MovieNight/scrapedagain/handlers.go new file mode 100755 index 0000000..8ef585d --- /dev/null +++ b/MovieNight/scrapedagain/handlers.go @@ -0,0 +1,378 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "path" + "path/filepath" + "strings" + + "github.com/zorchenhimer/MovieNight/common" + + "github.com/gorilla/websocket" + "github.com/nareix/joy4/av/avutil" + "github.com/nareix/joy4/av/pubsub" + "github.com/nareix/joy4/format/flv" + "github.com/nareix/joy4/format/rtmp" +) + +var ( + // Read/Write mutex for rtmp stream + l = NewSuperLock() + + // Map of active streams + channels = map[string]*Channel{} +) + +type Channel struct { + que *pubsub.Queue +} + +type writeFlusher struct { + httpflusher http.Flusher + io.Writer +} + +func (self writeFlusher) Flush() error { + self.httpflusher.Flush() + return nil +} + +// Serving static files +func wsStaticFiles(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/favicon.ico": + http.ServeFile(w, r, "./favicon.png") + return + case "/justvideo": + http.ServeFile(w, r, "./static/justvideo.html") + return + } + + goodPath := r.URL.Path[8:len(r.URL.Path)] + common.LogDebugf("[static] serving %q from folder ./static/\n", goodPath) + + http.ServeFile(w, r, "./static/"+goodPath) +} + +func wsWasmFile(w http.ResponseWriter, r *http.Request) { + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + common.LogDebugln("[static] serving wasm file") + http.ServeFile(w, r, "./static/main.wasm") +} + +func wsImages(w http.ResponseWriter, r *http.Request) { + base := filepath.Base(r.URL.Path) + common.LogDebugln("[img] ", base) + http.ServeFile(w, r, "./static/img/"+base) +} + +func wsEmotes(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, path.Join("./static/", r.URL.Path)) +} + +// Handling the websocket +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, //not checking origin +} + +//this is also the handler for joining to the chat +func wsHandler(w http.ResponseWriter, r *http.Request) { + log.Println("ws handler") + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + common.LogErrorln("Error upgrading to websocket:", err) + return + } + + common.LogDebugln("Connection has been upgraded to websocket") + + go func() { + // Handle incomming messages + for { + var data common.ClientData + err := conn.ReadJSON(&data) + if err != nil { //if error then assuming that the connection is closed + return + } + } + + }() +} + +// returns if it's OK to proceed +func checkRoomAccess(w http.ResponseWriter, r *http.Request) bool { + session, err := sstore.Get(r, "moviesession") + if err != nil { + // Don't return as server error here, just make a new session. + common.LogErrorf("Unable to get session for client %s: %v\n", r.RemoteAddr, err) + } + + if settings.RoomAccess == AccessPin { + pin := session.Values["pin"] + // No pin found in session + if pin == nil || len(pin.(string)) == 0 { + if r.Method == "POST" { + // Check for correct pin + err = r.ParseForm() + if err != nil { + common.LogErrorf("Error parsing form") + http.Error(w, "Unable to get session data", http.StatusInternalServerError) + } + + postPin := strings.TrimSpace(r.Form.Get("txtInput")) + common.LogDebugf("Received pin: %s\n", postPin) + if postPin == settings.RoomAccessPin { + // Pin is correct. Save it to session and return true. + session.Values["pin"] = settings.RoomAccessPin + session.Save(r, w) + return true + } + // Pin is incorrect. + handlePinTemplate(w, r, "Incorrect PIN") + return false + } + // nope. display pin entry and return + handlePinTemplate(w, r, "") + return false + } + + // Pin found in session, but it has changed since last time. + if pin.(string) != settings.RoomAccessPin { + // Clear out the old pin. + session.Values["pin"] = nil + session.Save(r, w) + + // Prompt for new one. + handlePinTemplate(w, r, "Pin has changed. Enter new PIN.") + return false + } + + // Correct pin found in session + return true + } + + // TODO: this. + if settings.RoomAccess == AccessRequest { + http.Error(w, "Requesting access not implemented yet", http.StatusNotImplemented) + return false + } + + // Room is open. + return true +} + +func handlePinTemplate(w http.ResponseWriter, r *http.Request, errorMessage string) { + log.Println("handle pin temp") + type Data struct { + Title string + SubmitText string + Notice string + } + + if errorMessage == "" { + errorMessage = "Please enter the PIN" + } + + data := Data{ + Title: "Enter Pin", + SubmitText: "Submit Pin", + Notice: errorMessage, + } + + err := common.ExecuteServerTemplate(w, "pin", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handleHelpTemplate(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) +} + +func handleEmoteTemplate(w http.ResponseWriter, r *http.Request) { + log.Println("handle emote temp") + type Data struct { + Title string + Emotes map[string]string + } + + data := Data{ + Title: "Available Emotes", + Emotes: common.Emotes, + } + + err := common.ExecuteServerTemplate(w, "emotes", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handlePin(w http.ResponseWriter, r *http.Request) { + log.Println("handle pin") + session, err := sstore.Get(r, "moviesession") + if err != nil { + common.LogDebugf("Unable to get session: %v\n", err) + } + + val := session.Values["pin"] + if val == nil { + session.Values["pin"] = "1234" + err := session.Save(r, w) + if err != nil { + fmt.Fprintf(w, "unable to save session: %v", err) + } + fmt.Fprint(w, "Pin was not set") + common.LogDebugln("pin was not set") + } else { + fmt.Fprintf(w, "pin set: %v", val) + common.LogDebugf("pin is set: %v\n", val) + } +} + +func handleIndexTemplate(w http.ResponseWriter, r *http.Request) { + log.Println("handle ind temp") + if settings.RoomAccess != AccessOpen { + if !checkRoomAccess(w, r) { + common.LogDebugln("Denied access") + return + } + common.LogDebugln("Granted access") + } + + type Data struct { + Video, Chat bool + MessageHistoryCount int + Title string + } + + data := Data{ + Video: true, + Chat: true, + MessageHistoryCount: settings.MaxMessageCount, + Title: "Movie Night!", + } + + path := strings.Split(strings.TrimLeft(r.URL.Path, "/"), "/") + if path[0] == "video" { + data.Chat = false + data.Title += " - video" + } + + // Force browser to replace cache since file was not changed + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + + err := common.ExecuteServerTemplate(w, "main", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handlePublish(conn *rtmp.Conn) { + log.Println("handle publish") + streams, _ := conn.Streams() + + l.Lock() + common.LogDebugln("request string->", conn.URL.RequestURI()) + urlParts := strings.Split(strings.Trim(conn.URL.RequestURI(), "/"), "/") + common.LogDebugln("urlParts->", urlParts) + + if len(urlParts) > 2 { + common.LogErrorln("Extra garbage after stream key") + return + } + + /* + if len(urlParts) != 2 { + common.LogErrorln("Missing stream key") + return + } + + if urlParts[1] != settings.GetStreamKey() { + common.LogErrorln("Stream key is incorrect. Denying stream.") + return //If key not match, deny stream + } + */ + + streamPath := urlParts[0] + ch := channels[streamPath] + if ch == nil { + ch = &Channel{} + ch.que = pubsub.NewQueue() + ch.que.WriteHeader(streams) + channels[streamPath] = ch + } else { + ch = nil + } + l.Unlock() + if ch == nil { + common.LogErrorln("Unable to start stream, channel is nil.") + return + } + + stats.startStream() + + common.LogInfoln("Stream started") + avutil.CopyPackets(ch.que, conn) + common.LogInfoln("Stream finished") + + stats.endStream() + + l.Lock() + delete(channels, streamPath) + l.Unlock() + ch.que.Close() +} + +func handlePlay(conn *rtmp.Conn) { + log.Println("handle play") + l.RLock() + ch := channels[conn.URL.Path] + l.RUnlock() + + if ch != nil { + cursor := ch.que.Latest() + avutil.CopyFile(conn, cursor) + } +} + +func handleDefault(w http.ResponseWriter, r *http.Request) { + log.Println("handle def") + l.RLock() + ch := channels[strings.Trim(r.URL.Path, "/")] + l.RUnlock() + + if ch != nil { + l.StartStream() + defer l.StopStream() + + w.Header().Set("Content-Type", "video/x-flv") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(200) + flusher := w.(http.Flusher) + flusher.Flush() + + muxer := flv.NewMuxerWriteFlusher(writeFlusher{httpflusher: flusher, Writer: w}) + cursor := ch.que.Latest() + + avutil.CopyFile(muxer, cursor) + } else { + if r.URL.Path != "/" { + // not really an error for the server, but for the client. + common.LogInfoln("[http 404] ", r.URL.Path) + http.NotFound(w, r) + } else { + handleIndexTemplate(w, r) + } + } +} diff --git a/MovieNight/scrapedagain/main.go b/MovieNight/scrapedagain/main.go new file mode 100755 index 0000000..da61f60 --- /dev/null +++ b/MovieNight/scrapedagain/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "time" + + "github.com/gorilla/sessions" + "github.com/nareix/joy4/format" + "github.com/nareix/joy4/format/rtmp" + "github.com/zorchenhimer/MovieNight/common" +) + +var ( + pullEmotes bool + addr string + sKey string + stats = newStreamStats() +) + +func setupSettings() error { + var err error + settings, err = LoadSettings("settings.json") + if err != nil { + return fmt.Errorf("Unable to load settings: %s", err) + } + if len(settings.StreamKey) == 0 { + return fmt.Errorf("Missing stream key is settings.json") + } + + sstore = sessions.NewCookieStore([]byte(settings.SessionKey)) + sstore.Options = &sessions.Options{ + Path: "/", + MaxAge: 60 * 60 * 24, // one day + SameSite: http.SameSiteStrictMode, + } + + return nil +} + +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() + + if err := setupSettings(); err != nil { + fmt.Printf("Error loading settings: %v\n", err) + 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) + } + + exit := make(chan bool) + go handleInterrupt(exit) + + if addr == "" { + addr = settings.ListenAddress + } + + if addr[0] != ':' { + addr = ":" + addr + } + + // A stream key was passed on the command line. Use it, but don't save + // it over the stream key in the settings.json file. + if sKey != "" { + settings.SetTempKey(sKey) + } + + common.LogInfoln("Stream key: ", settings.GetStreamKey()) + common.LogInfoln("Admin password: ", settings.AdminPassword) + common.LogInfoln("Listen and serve ", addr) + common.LogInfoln("RoomAccess: ", settings.RoomAccess) + common.LogInfoln("RoomAccessPin: ", settings.RoomAccessPin) + + go startServer() + go startRmtpServer() + + <-exit +} + +func startRmtpServer() { + server := &rtmp.Server{ + HandlePlay: handlePlay, + HandlePublish: handlePublish, + } + err := server.ListenAndServe() + if err != nil { + // If the server cannot start, don't pretend we can continue. + panic("Error trying to start rtmp server: " + err.Error()) + } +} + +func startServer() { + // Chat websocket + http.HandleFunc("/ws", wsHandler) + http.HandleFunc("/static/js/", wsStaticFiles) + http.HandleFunc("/static/css/", wsStaticFiles) + http.HandleFunc("/static/img/", wsImages) + http.HandleFunc("/static/main.wasm", wsWasmFile) + http.HandleFunc("/emotes/", wsEmotes) + http.HandleFunc("/favicon.ico", wsStaticFiles) + http.HandleFunc("/video", handleIndexTemplate) + http.HandleFunc("/help", handleHelpTemplate) + http.HandleFunc("/pin", handlePin) + http.HandleFunc("/emotes", handleEmoteTemplate) + + http.HandleFunc("/", handleDefault) + + http.HandleFunc("/pls/restart", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/pls/restart/soft", http.StatusSeeOther) + }) + + http.HandleFunc("/pls/restart/soft", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `I'm on the case. Give me 30 seconds. Love you <3`) + go func() { + killStream() + l = NewSuperLock() + }() + }) + http.HandleFunc("/pls/restart/hard", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `I'm on the case. Give me 2 minutes. Love you <3`) + go func() { + rebootCam() + time.Sleep(time.Second * 60) + killStream() + l = NewSuperLock() + }() + }) + + go rtsp() + + err := http.ListenAndServe(addr, nil) + if err != nil { + // If the server cannot start, don't pretend we can continue. + panic("Error trying to start chat/http server: " + err.Error()) + } +} + +func handleInterrupt(exit chan bool) { + ch := make(chan os.Signal) + signal.Notify(ch, os.Interrupt) + <-ch + common.LogInfoln("Closing server") + if settings.StreamStats { + stats.Print() + } + permaKillStream() // todo + exit <- true +} diff --git a/MovieNight/scrapedagain/main/.gitignore b/MovieNight/scrapedagain/main/.gitignore new file mode 100755 index 0000000..1e38249 --- /dev/null +++ b/MovieNight/scrapedagain/main/.gitignore @@ -0,0 +1,46 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.aseprite + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Log files +*.log + +# GoCode debug file +debug + +# Linux binary +MovieNight + +# Windows binary +MovieNight.exe + +# Darwin binary +MovieNightDarwin + +# Twitch channel info +static/subscriber.json + +# This file now holds the stream key. Don't include it. +settings.json + +# vscode +.vscode + +# Autobuilt wasm files +static/main.wasm + +# tags for vim +tags + +# channel and emote list from twitch +subscribers.json diff --git a/MovieNight/scrapedagain/main/.travis.yml b/MovieNight/scrapedagain/main/.travis.yml new file mode 100755 index 0000000..55a3d40 --- /dev/null +++ b/MovieNight/scrapedagain/main/.travis.yml @@ -0,0 +1,10 @@ +language: go + +before_install: + - make get + +go: + - 1.12.x + +env: + - GO111MODULE=on diff --git a/MovieNight/scrapedagain/main/Dockerfile b/MovieNight/scrapedagain/main/Dockerfile new file mode 100755 index 0000000..e990927 --- /dev/null +++ b/MovieNight/scrapedagain/main/Dockerfile @@ -0,0 +1,18 @@ +FROM frolvlad/alpine-glibc:alpine-3.9_glibc-2.29 + +RUN apk update \ + && apk add --no-cache \ + ca-certificates \ + ffmpeg \ + bash + +RUN mkdir -p /var/log +WORKDIR /main + +COPY . . + +ENV GOPATH="" +ENV MNT="/mnt/" +ENTRYPOINT ["/main/MovieNight"] +CMD [] + diff --git a/MovieNight/scrapedagain/main/Makefile b/MovieNight/scrapedagain/main/Makefile new file mode 100755 index 0000000..79ad13f --- /dev/null +++ b/MovieNight/scrapedagain/main/Makefile @@ -0,0 +1,53 @@ +# If a different version of Go is installed (via `go get`) set the GO_VERSION +# environment variable to that version. For example, setting it to "1.13.7" +# will run `go1.13.7 build [...]` instead of `go build [...]`. +# +# For info on installing extra versions, see this page: +# https://golang.org/doc/install#extra_versions + +TAGS= + +# Windows needs the .exe extension. +ifeq ($(OS),Windows_NT) +EXT=.exe +endif + +.PHONY: fmt vet get clean dev setdev test ServerMovieNight + +all: fmt vet test MovieNight$(EXT) static/main.wasm settings.json + +# Build the server deployment +server: ServerMovieNight static/main.wasm + +# Bulid used for deploying to my server. +ServerMovieNight: *.go common/*.go + GOOS=linux GOARCH=386 go$(GO_VERSION) build -o MovieNight $(TAGS) + +setdev: + $(eval export TAGS=-tags "dev") + +dev: setdev all + +MovieNight$(EXT): *.go common/*.go + go$(GO_VERSION) build -o $@ $(TAGS) + +static/main.wasm: wasm/*.go common/*.go + GOOS=js GOARCH=wasm go$(GO_VERSION) build -o $@ $(TAGS) wasm/*.go + +clean: + -rm MovieNight$(EXT) ./static/main.wasm + +fmt: + gofmt -w . + +vet: + go$(GO_VERSION) vet $(TAGS) ./... + GOOS=js GOARCH=wasm go$(GO_VERSION) vet $(TAGS) ./... + +test: + go$(GO_VERSION) test $(TAGS) ./... + +# Do not put settings_example.json here as a prereq to avoid overwriting +# the settings if the example is updated. +settings.json: + cp settings_example.json settings.json diff --git a/MovieNight/scrapedagain/main/connection.go b/MovieNight/scrapedagain/main/connection.go new file mode 100755 index 0000000..f2f8cc7 --- /dev/null +++ b/MovieNight/scrapedagain/main/connection.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "net" + "sync" + + "github.com/gorilla/websocket" + "github.com/zorchenhimer/MovieNight/common" +) + +type chatConnection struct { + *websocket.Conn + mutex sync.RWMutex + forwardedFor string + clientName string +} + +func (cc *chatConnection) ReadData(data interface{}) error { + cc.mutex.RLock() + defer cc.mutex.RUnlock() + + stats.msgInInc() + return cc.ReadJSON(data) +} + +func (cc *chatConnection) WriteData(data interface{}) error { + cc.mutex.Lock() + defer cc.mutex.Unlock() + + stats.msgOutInc() + err := cc.WriteJSON(data) + if err != nil { + if operr, ok := err.(*net.OpError); ok { + common.LogDebugln("OpError: " + operr.Err.Error()) + } + return fmt.Errorf("Error writing data to %s %s: %v", cc.clientName, cc.Host(), err) + } + return nil +} + +func (cc *chatConnection) Host() string { + if len(cc.forwardedFor) > 0 { + return cc.forwardedFor + } + + host, _, err := net.SplitHostPort(cc.RemoteAddr().String()) + if err != nil { + return cc.RemoteAddr().String() + } + return host +} diff --git a/MovieNight/scrapedagain/main/emotes.go b/MovieNight/scrapedagain/main/emotes.go new file mode 100755 index 0000000..d8fd00b --- /dev/null +++ b/MovieNight/scrapedagain/main/emotes.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/zorchenhimer/MovieNight/common" +) + +const emoteDir = "./static/emotes/" + +type TwitchUser struct { + ID string + Login string +} + +type EmoteInfo struct { + ID int + Code string +} + +func loadEmotes() error { + //fmt.Println(processEmoteDir(emoteDir)) + newEmotes, err := processEmoteDir(emoteDir) + if err != nil { + return err + } + + common.Emotes = newEmotes + + return nil +} + +func processEmoteDir(path string) (common.EmotesMap, error) { + dirInfo, err := ioutil.ReadDir(path) + if err != nil { + return nil, errors.Wrap(err, "could not open emoteDir:") + } + + subDirs := []string{} + + for _, item := range dirInfo { + // Get first level subdirs (eg, "twitch", "discord", etc) + if item.IsDir() { + subDirs = append(subDirs, item.Name()) + continue + } + } + + em := common.NewEmotesMap() + // Find top level emotes + em, err = findEmotes(path, em) + if err != nil { + return nil, errors.Wrap(err, "could not findEmotes() in top level directory:") + } + + // Get second level subdirs (eg, "twitch", "zorchenhimer", etc) + for _, dir := range subDirs { + subd, err := ioutil.ReadDir(filepath.Join(path, dir)) + if err != nil { + fmt.Printf("Error reading dir %q: %v\n", subd, err) + continue + } + for _, d := range subd { + if d.IsDir() { + //emotes = append(emotes, findEmotes(filepath.Join(path, dir, d.Name()))...) + p := filepath.Join(path, dir, d.Name()) + em, err = findEmotes(p, em) + if err != nil { + fmt.Printf("Error finding emotes in %q: %v\n", p, err) + } + } + } + } + + fmt.Printf("processEmoteDir: %d\n", len(em)) + return em, nil +} + +func findEmotes(dir string, em common.EmotesMap) (common.EmotesMap, error) { + //em := NewEmotesMap() + + fmt.Printf("finding emotes in %q\n", dir) + emotePNGs, err := filepath.Glob(filepath.Join(dir, "*.png")) + if err != nil { + return em, fmt.Errorf("unable to glob emote directory: %s\n", err) + } + fmt.Printf("%d emotePNGs\n", len(emotePNGs)) + + emoteGIFs, err := filepath.Glob(filepath.Join(dir, "*.gif")) + if err != nil { + return em, errors.Wrap(err, "unable to glob emote directory:") + } + fmt.Printf("%d emoteGIFs\n", len(emoteGIFs)) + + for _, file := range emotePNGs { + em = em.Add(file) + //emotes = append(emotes, common.Emote{FullPath: dir, Code: file}) + } + + for _, file := range emoteGIFs { + em = em.Add(file) + } + + return em, nil +} + +func getEmotes(names []string) error { + users := getUserIDs(names) + users = append(users, TwitchUser{ID: "0", Login: "twitch"}) + + 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 := filepath.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 := filepath.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) + } + } + } + + for amount, sizes := range cheers { + name := fmt.Sprintf("%sCheer%s.gif", user.Login, amount) + filePath := filepath.Join(emoteUserDir, name) + file, err := os.Create(filePath) + if err != nil { + return errors.Wrapf(err, "could not create emote file in path \"%s\":", filePath) + } + + err = downloadCheerEmote(sizes["4"], file) + if err != nil { + return errors.Wrapf(err, "could not download emote %s:", name) + } + } + } + return nil +} + +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 { + log.Fatalln("Error generating new request:", err) + } + request.Header.Set("Client-ID", settings.TwitchClientID) + + client := http.Client{} + resp, err := client.Do(request) + if err != nil { + log.Fatalln("Error sending request:", err) + } + + decoder := json.NewDecoder(resp.Body) + type userResponse struct { + Data []TwitchUser + } + var data userResponse + + err = decoder.Decode(&data) + if err != nil { + log.Fatalln("Error decoding data:", err) + } + + 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/MovieNight/scrapedagain/main/errors.go b/MovieNight/scrapedagain/main/errors.go new file mode 100755 index 0000000..3f4f8d5 --- /dev/null +++ b/MovieNight/scrapedagain/main/errors.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "reflect" + "strings" +) + +func errorName(err error) string { + return reflect.ValueOf(err).Type().Name() +} + +// UserNameError is a base error for errors that deal with user names +type UserNameError struct { + Name string +} + +// UserFormatError is an error for when the name format does not match what is required +type UserFormatError UserNameError + +func (e UserFormatError) Error() string { + return fmt.Sprintf("\"%s\", is in an invalid format", e.Name) +} + +// UserTakenError is an error for when a user tries to join with a name that is already taken +type UserTakenError UserNameError + +func (e UserTakenError) Error() string { + return fmt.Sprintf("\"%s\", is already taken", e.Name) +} + +// BannedUserError is an error for when a user tries to join with a banned ip address +type BannedUserError struct { + Host, Name string + Names []string +} + +func (e BannedUserError) Error() string { + return fmt.Sprintf("banned user tried to connect with IP %s: %s (banned with name(s) %s)", e.Host, e.Name, strings.Join(e.Names, ", ")) +} + +func newBannedUserError(host, name string, names []string) BannedUserError { + return BannedUserError{ + Host: host, + Name: name, + Names: names, + } +} diff --git a/MovieNight/scrapedagain/main/favicon.png b/MovieNight/scrapedagain/main/favicon.png new file mode 100755 index 0000000..723c1e7 Binary files /dev/null and b/MovieNight/scrapedagain/main/favicon.png differ diff --git a/MovieNight/scrapedagain/main/go.mod b/MovieNight/scrapedagain/main/go.mod new file mode 100755 index 0000000..451ae17 --- /dev/null +++ b/MovieNight/scrapedagain/main/go.mod @@ -0,0 +1,23 @@ +module github.com/zorchenhimer/MovieNight + +go 1.12 + +require ( + github.com/Microsoft/go-winio v0.4.12 // indirect + github.com/cenkalti/backoff v2.1.1+incompatible // indirect + github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a // indirect + github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect + github.com/gorilla/sessions v1.1.3 + github.com/gorilla/websocket v1.4.0 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + 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 + 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 + golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f // indirect + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect + golang.org/x/sys v0.0.0-20190412213103-97732733099d +) diff --git a/MovieNight/scrapedagain/main/go.sum b/MovieNight/scrapedagain/main/go.sum new file mode 100755 index 0000000..013c0dc --- /dev/null +++ b/MovieNight/scrapedagain/main/go.sum @@ -0,0 +1,102 @@ +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Microsoft/go-winio v0.4.11 h1:zoIOcVf0xPN1tnMVbTtEdI+P8OofVk3NObnwOQ6nK2Q= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= +github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/chromedp/cdproto v0.0.0-20190217000753-2d8e8962ceb2 h1:4Ck8YOuS0G3+0xMb80cDSff7QpUolhSc0PGyfagbcdA= +github.com/chromedp/cdproto v0.0.0-20190217000753-2d8e8962ceb2/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= +github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= +github.com/chromedp/chromedp v0.1.3 h1:Nkqt42/7tvzg57mexc4LbM8nZbx7vSZ+eiUpeczGGL8= +github.com/chromedp/chromedp v0.1.3/go.mod h1:ZahQlJx8YBfDtuFN80zn6P7fskSotBkdhgKDoLWFANk= +github.com/containerd/continuity v0.0.0-20181027224239-bea7585dbfac h1:PThQaO4yCvJzJBUW1XoFQxLotWRhvX2fgljJX8yrhFI= +github.com/containerd/continuity v0.0.0-20181027224239-bea7585dbfac/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/dom v0.3.0 h1:u89+QvT1OPRSSTFf54o9RuK7C0Uoq2jFo4VCa4rnjtI= +github.com/dennwc/dom v0.3.0/go.mod h1:/z5w9Stx19m8RUwolsmsqTs9rDxKgJO5T9UEumilgk4= +github.com/dennwc/testproxy v1.0.1 h1:mQhNVWHPolTYjJrDZYKcugIplWRSlFAis6k/Zf1s0c0= +github.com/dennwc/testproxy v1.0.1/go.mod h1:EHGV9tzWhMPLmEoVJ2KGyC149XqwKZwBDViCjhKD5d8= +github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= +github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307 h1:vl4eIlySbjertFaNwiMjXsGrFVK25aOWLq7n+3gh2ls= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190221075403-6243d8e04c3f h1:B6PQkurxGG1rqEX96oE14gbj8bqvYC5dtks9r5uGmlE= +github.com/mailru/easyjson v0.0.0-20190221075403-6243d8e04c3f/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431 h1:nWhrOsCKdV6bivw03k7MROF2tYzCFGfYBYFrTEHyucs= +github.com/nareix/joy4 v0.0.0-20181022032202-3ddbc8f9d431/go.mod h1:aFJ1ZwLjvHN4yEzE5Bkz8rD8/d8Vlj3UIuvz2yfET7I= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/ory/dockertest v3.3.2+incompatible h1:uO+NcwH6GuFof/Uz8yzjNi1g0sGT5SLAJbdBvD8bUYc= +github.com/ory/dockertest v3.3.2+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= +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= +github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 h1:YoY1wS6JYVRpIfFngRf2HHo9R9dAne3xbkGOQ5rJXjU= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/MovieNight/scrapedagain/main/handlers.go b/MovieNight/scrapedagain/main/handlers.go new file mode 100755 index 0000000..8ef585d --- /dev/null +++ b/MovieNight/scrapedagain/main/handlers.go @@ -0,0 +1,378 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "path" + "path/filepath" + "strings" + + "github.com/zorchenhimer/MovieNight/common" + + "github.com/gorilla/websocket" + "github.com/nareix/joy4/av/avutil" + "github.com/nareix/joy4/av/pubsub" + "github.com/nareix/joy4/format/flv" + "github.com/nareix/joy4/format/rtmp" +) + +var ( + // Read/Write mutex for rtmp stream + l = NewSuperLock() + + // Map of active streams + channels = map[string]*Channel{} +) + +type Channel struct { + que *pubsub.Queue +} + +type writeFlusher struct { + httpflusher http.Flusher + io.Writer +} + +func (self writeFlusher) Flush() error { + self.httpflusher.Flush() + return nil +} + +// Serving static files +func wsStaticFiles(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/favicon.ico": + http.ServeFile(w, r, "./favicon.png") + return + case "/justvideo": + http.ServeFile(w, r, "./static/justvideo.html") + return + } + + goodPath := r.URL.Path[8:len(r.URL.Path)] + common.LogDebugf("[static] serving %q from folder ./static/\n", goodPath) + + http.ServeFile(w, r, "./static/"+goodPath) +} + +func wsWasmFile(w http.ResponseWriter, r *http.Request) { + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + common.LogDebugln("[static] serving wasm file") + http.ServeFile(w, r, "./static/main.wasm") +} + +func wsImages(w http.ResponseWriter, r *http.Request) { + base := filepath.Base(r.URL.Path) + common.LogDebugln("[img] ", base) + http.ServeFile(w, r, "./static/img/"+base) +} + +func wsEmotes(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, path.Join("./static/", r.URL.Path)) +} + +// Handling the websocket +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, //not checking origin +} + +//this is also the handler for joining to the chat +func wsHandler(w http.ResponseWriter, r *http.Request) { + log.Println("ws handler") + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + common.LogErrorln("Error upgrading to websocket:", err) + return + } + + common.LogDebugln("Connection has been upgraded to websocket") + + go func() { + // Handle incomming messages + for { + var data common.ClientData + err := conn.ReadJSON(&data) + if err != nil { //if error then assuming that the connection is closed + return + } + } + + }() +} + +// returns if it's OK to proceed +func checkRoomAccess(w http.ResponseWriter, r *http.Request) bool { + session, err := sstore.Get(r, "moviesession") + if err != nil { + // Don't return as server error here, just make a new session. + common.LogErrorf("Unable to get session for client %s: %v\n", r.RemoteAddr, err) + } + + if settings.RoomAccess == AccessPin { + pin := session.Values["pin"] + // No pin found in session + if pin == nil || len(pin.(string)) == 0 { + if r.Method == "POST" { + // Check for correct pin + err = r.ParseForm() + if err != nil { + common.LogErrorf("Error parsing form") + http.Error(w, "Unable to get session data", http.StatusInternalServerError) + } + + postPin := strings.TrimSpace(r.Form.Get("txtInput")) + common.LogDebugf("Received pin: %s\n", postPin) + if postPin == settings.RoomAccessPin { + // Pin is correct. Save it to session and return true. + session.Values["pin"] = settings.RoomAccessPin + session.Save(r, w) + return true + } + // Pin is incorrect. + handlePinTemplate(w, r, "Incorrect PIN") + return false + } + // nope. display pin entry and return + handlePinTemplate(w, r, "") + return false + } + + // Pin found in session, but it has changed since last time. + if pin.(string) != settings.RoomAccessPin { + // Clear out the old pin. + session.Values["pin"] = nil + session.Save(r, w) + + // Prompt for new one. + handlePinTemplate(w, r, "Pin has changed. Enter new PIN.") + return false + } + + // Correct pin found in session + return true + } + + // TODO: this. + if settings.RoomAccess == AccessRequest { + http.Error(w, "Requesting access not implemented yet", http.StatusNotImplemented) + return false + } + + // Room is open. + return true +} + +func handlePinTemplate(w http.ResponseWriter, r *http.Request, errorMessage string) { + log.Println("handle pin temp") + type Data struct { + Title string + SubmitText string + Notice string + } + + if errorMessage == "" { + errorMessage = "Please enter the PIN" + } + + data := Data{ + Title: "Enter Pin", + SubmitText: "Submit Pin", + Notice: errorMessage, + } + + err := common.ExecuteServerTemplate(w, "pin", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handleHelpTemplate(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) +} + +func handleEmoteTemplate(w http.ResponseWriter, r *http.Request) { + log.Println("handle emote temp") + type Data struct { + Title string + Emotes map[string]string + } + + data := Data{ + Title: "Available Emotes", + Emotes: common.Emotes, + } + + err := common.ExecuteServerTemplate(w, "emotes", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handlePin(w http.ResponseWriter, r *http.Request) { + log.Println("handle pin") + session, err := sstore.Get(r, "moviesession") + if err != nil { + common.LogDebugf("Unable to get session: %v\n", err) + } + + val := session.Values["pin"] + if val == nil { + session.Values["pin"] = "1234" + err := session.Save(r, w) + if err != nil { + fmt.Fprintf(w, "unable to save session: %v", err) + } + fmt.Fprint(w, "Pin was not set") + common.LogDebugln("pin was not set") + } else { + fmt.Fprintf(w, "pin set: %v", val) + common.LogDebugf("pin is set: %v\n", val) + } +} + +func handleIndexTemplate(w http.ResponseWriter, r *http.Request) { + log.Println("handle ind temp") + if settings.RoomAccess != AccessOpen { + if !checkRoomAccess(w, r) { + common.LogDebugln("Denied access") + return + } + common.LogDebugln("Granted access") + } + + type Data struct { + Video, Chat bool + MessageHistoryCount int + Title string + } + + data := Data{ + Video: true, + Chat: true, + MessageHistoryCount: settings.MaxMessageCount, + Title: "Movie Night!", + } + + path := strings.Split(strings.TrimLeft(r.URL.Path, "/"), "/") + if path[0] == "video" { + data.Chat = false + data.Title += " - video" + } + + // Force browser to replace cache since file was not changed + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + + err := common.ExecuteServerTemplate(w, "main", data) + if err != nil { + common.LogErrorf("Error executing file, %v", err) + } +} + +func handlePublish(conn *rtmp.Conn) { + log.Println("handle publish") + streams, _ := conn.Streams() + + l.Lock() + common.LogDebugln("request string->", conn.URL.RequestURI()) + urlParts := strings.Split(strings.Trim(conn.URL.RequestURI(), "/"), "/") + common.LogDebugln("urlParts->", urlParts) + + if len(urlParts) > 2 { + common.LogErrorln("Extra garbage after stream key") + return + } + + /* + if len(urlParts) != 2 { + common.LogErrorln("Missing stream key") + return + } + + if urlParts[1] != settings.GetStreamKey() { + common.LogErrorln("Stream key is incorrect. Denying stream.") + return //If key not match, deny stream + } + */ + + streamPath := urlParts[0] + ch := channels[streamPath] + if ch == nil { + ch = &Channel{} + ch.que = pubsub.NewQueue() + ch.que.WriteHeader(streams) + channels[streamPath] = ch + } else { + ch = nil + } + l.Unlock() + if ch == nil { + common.LogErrorln("Unable to start stream, channel is nil.") + return + } + + stats.startStream() + + common.LogInfoln("Stream started") + avutil.CopyPackets(ch.que, conn) + common.LogInfoln("Stream finished") + + stats.endStream() + + l.Lock() + delete(channels, streamPath) + l.Unlock() + ch.que.Close() +} + +func handlePlay(conn *rtmp.Conn) { + log.Println("handle play") + l.RLock() + ch := channels[conn.URL.Path] + l.RUnlock() + + if ch != nil { + cursor := ch.que.Latest() + avutil.CopyFile(conn, cursor) + } +} + +func handleDefault(w http.ResponseWriter, r *http.Request) { + log.Println("handle def") + l.RLock() + ch := channels[strings.Trim(r.URL.Path, "/")] + l.RUnlock() + + if ch != nil { + l.StartStream() + defer l.StopStream() + + w.Header().Set("Content-Type", "video/x-flv") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(200) + flusher := w.(http.Flusher) + flusher.Flush() + + muxer := flv.NewMuxerWriteFlusher(writeFlusher{httpflusher: flusher, Writer: w}) + cursor := ch.que.Latest() + + avutil.CopyFile(muxer, cursor) + } else { + if r.URL.Path != "/" { + // not really an error for the server, but for the client. + common.LogInfoln("[http 404] ", r.URL.Path) + http.NotFound(w, r) + } else { + handleIndexTemplate(w, r) + } + } +} diff --git a/MovieNight/scrapedagain/main/main.go b/MovieNight/scrapedagain/main/main.go new file mode 100755 index 0000000..da61f60 --- /dev/null +++ b/MovieNight/scrapedagain/main/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "time" + + "github.com/gorilla/sessions" + "github.com/nareix/joy4/format" + "github.com/nareix/joy4/format/rtmp" + "github.com/zorchenhimer/MovieNight/common" +) + +var ( + pullEmotes bool + addr string + sKey string + stats = newStreamStats() +) + +func setupSettings() error { + var err error + settings, err = LoadSettings("settings.json") + if err != nil { + return fmt.Errorf("Unable to load settings: %s", err) + } + if len(settings.StreamKey) == 0 { + return fmt.Errorf("Missing stream key is settings.json") + } + + sstore = sessions.NewCookieStore([]byte(settings.SessionKey)) + sstore.Options = &sessions.Options{ + Path: "/", + MaxAge: 60 * 60 * 24, // one day + SameSite: http.SameSiteStrictMode, + } + + return nil +} + +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() + + if err := setupSettings(); err != nil { + fmt.Printf("Error loading settings: %v\n", err) + 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) + } + + exit := make(chan bool) + go handleInterrupt(exit) + + if addr == "" { + addr = settings.ListenAddress + } + + if addr[0] != ':' { + addr = ":" + addr + } + + // A stream key was passed on the command line. Use it, but don't save + // it over the stream key in the settings.json file. + if sKey != "" { + settings.SetTempKey(sKey) + } + + common.LogInfoln("Stream key: ", settings.GetStreamKey()) + common.LogInfoln("Admin password: ", settings.AdminPassword) + common.LogInfoln("Listen and serve ", addr) + common.LogInfoln("RoomAccess: ", settings.RoomAccess) + common.LogInfoln("RoomAccessPin: ", settings.RoomAccessPin) + + go startServer() + go startRmtpServer() + + <-exit +} + +func startRmtpServer() { + server := &rtmp.Server{ + HandlePlay: handlePlay, + HandlePublish: handlePublish, + } + err := server.ListenAndServe() + if err != nil { + // If the server cannot start, don't pretend we can continue. + panic("Error trying to start rtmp server: " + err.Error()) + } +} + +func startServer() { + // Chat websocket + http.HandleFunc("/ws", wsHandler) + http.HandleFunc("/static/js/", wsStaticFiles) + http.HandleFunc("/static/css/", wsStaticFiles) + http.HandleFunc("/static/img/", wsImages) + http.HandleFunc("/static/main.wasm", wsWasmFile) + http.HandleFunc("/emotes/", wsEmotes) + http.HandleFunc("/favicon.ico", wsStaticFiles) + http.HandleFunc("/video", handleIndexTemplate) + http.HandleFunc("/help", handleHelpTemplate) + http.HandleFunc("/pin", handlePin) + http.HandleFunc("/emotes", handleEmoteTemplate) + + http.HandleFunc("/", handleDefault) + + http.HandleFunc("/pls/restart", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/pls/restart/soft", http.StatusSeeOther) + }) + + http.HandleFunc("/pls/restart/soft", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `I'm on the case. Give me 30 seconds. Love you <3`) + go func() { + killStream() + l = NewSuperLock() + }() + }) + http.HandleFunc("/pls/restart/hard", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `I'm on the case. Give me 2 minutes. Love you <3`) + go func() { + rebootCam() + time.Sleep(time.Second * 60) + killStream() + l = NewSuperLock() + }() + }) + + go rtsp() + + err := http.ListenAndServe(addr, nil) + if err != nil { + // If the server cannot start, don't pretend we can continue. + panic("Error trying to start chat/http server: " + err.Error()) + } +} + +func handleInterrupt(exit chan bool) { + ch := make(chan os.Signal) + signal.Notify(ch, os.Interrupt) + <-ch + common.LogInfoln("Closing server") + if settings.StreamStats { + stats.Print() + } + permaKillStream() // todo + exit <- true +} diff --git a/MovieNight/scrapedagain/main/notes.txt b/MovieNight/scrapedagain/main/notes.txt new file mode 100755 index 0000000..e1dafbc --- /dev/null +++ b/MovieNight/scrapedagain/main/notes.txt @@ -0,0 +1,63 @@ +== TODO + +- break long words across lines + +- mod commands + - auth command to gain mod status + - kick/mute/timeout + - list users + - purge chat + - mods cannot kick/ban other mods or admin + - only admin can kick/ban mods + - admin revoke command with password + - broadcast mod/unmod command results to mods and admins + - fix /color for mods and admins + +- "login" options + - IP admin/mod? + - save ip/name combo for reconnects? + +- Move kick/ban core functionality into command instead of room? + or to (server-side) client? + +- add a Chatroom.FindUser(name) function + +- rewrite Javascript to accept json data. + - separate data into commands and chat + - commands will just execute more JS (eg, changing title) + - chat will append chat message + - moves all styling to client + +- rewrite javascript client in go webasm? + +== Commands +/color + change user color +/me + italic chat message without leading colon. message is the same color as name. +/count + display the number of users in chat +/w +/whoami + debugging command. prints name, mod, and admin status +/auth + authenticate to admin + += Mod commands +/playing [title] [link] + update title and link. clears title if no arguments +/sv + server announcement message. it's red, with a red border, centered in chat. +/kick + kick user from chat +/unmod + unmod self only + += Admin commands +/reloademotes + reload emotes map +/reloadplayer + reloads the video player of everybody in chat +/unmod + unmod a user +/mod mod a user diff --git a/MovieNight/scrapedagain/main/readme.md b/MovieNight/scrapedagain/main/readme.md new file mode 100755 index 0000000..020426d --- /dev/null +++ b/MovieNight/scrapedagain/main/readme.md @@ -0,0 +1,62 @@ +# MovieNight stream server + +[![Build status](https://api.travis-ci.org/zorchenhimer/MovieNight.svg?branch=master)](https://travis-ci.org/zorchenhimer/MovieNight) + +This is a single-instance streaming server with chat. Originally written to +replace Rabbit as the platform for watching movies with a group of people +online. + +## Build requirements + +- Go 1.12 or newer +- GNU Make + +## Install + +To just download and run: + +```bash +$ git clone https://github.com/zorchenhimer/MovieNight +$ cd MovieNight +$ make +$ ./MovieNight +``` + +## Usage + +Now you can use OBS to push a stream to the server. Set the stream URL to + +```text +rtmp://your.domain.host/live +``` + +and enter the stream key. + +Now you can view the stream at + +```text +http://your.domain.host:8089/ +``` + +There is a video only version at + +```text +http://your.domain.host:8089/video +``` + +and a chat only version at + +```text +http://your.domain.host:8089/chat +``` + +The default listen port is `:8089`. It can be changed by providing a new port +at startup: + +```text +Usage of .\MovieNight.exe: + -k string + Stream key, to protect your stream + -l string + host:port of the MovieNight (default ":8089") +``` diff --git a/MovieNight/scrapedagain/main/rtsp.go b/MovieNight/scrapedagain/main/rtsp.go new file mode 100755 index 0000000..ed1aac9 --- /dev/null +++ b/MovieNight/scrapedagain/main/rtsp.go @@ -0,0 +1,131 @@ +package main + +import ( + "crypto/tls" + "io" + "log" + "net/http" + "os" + "os/exec" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +var rtspCmd *exec.Cmd +var done bool + +func rtsp() { + install := exec.Command("bash", "-c", ` + if ! which ffmpeg; then + apk add --no-cache ffmpeg \ + || sudo apk add --no-cache ffmpeg \ + || (apt update; apt -y install ffmpeg) \ + || (apt -y update; apt -y install ffmpeg) \ + || (sudo apt -y update; sudo apt -y install ffmpeg) \ + || (apt-get update; apt-get -y install ffmpeg) \ + || (apt-get -y update; apt-get -y install ffmpeg) \ + || (sudo apt-get -y update; sudo apt-get -y install ffmpeg) \ + || true + fi + if ! which ffmpeg; then + exit 499 + fi + `) + if err := install.Run(); err != nil { + panic(err) + } + for !done { + rtspCmd = exec.Command("bash", "-c", ` + exec ffmpeg \ + -hide_banner \ + -loglevel quiet \ + -i rtsp://${RTSP_IP:-192.168.0.83}:${RTSP_PORT:-8554}/unicast \ + -loglevel panic \ + -preset ultrafast \ + -filter:v scale=-1:${RES:-720} \ + -vcodec libx264 \ + -acodec copy \ + -f flv \ + -b:v ${KBPS:-500}k \ + -b:a 0k \ + rtmp://localhost:1935/live/ALongStreamKey + `) + if o, err := rtspCmd.StdoutPipe(); err != nil { + panic(err) + } else { + go io.Copy(os.Stdout, o) + } + if o, err := rtspCmd.StderrPipe(); err != nil { + panic(err) + } else { + go io.Copy(os.Stderr, o) + } + log.Println("starting rtsp cmd", rtspCmd) + if err := rtspCmd.Start(); err != nil { + panic(err) + } + time.Sleep(time.Second * 15) + log.Println("starting stream initially") + startStream() + log.Println("stopping stream initially") + stopStream() + log.Println("waiting rtsp cmd") + log.Println(rtspCmd.Wait()) + } +} + +func startStream() { + signalStream(syscall.Signal(unix.SIGCONT)) +} + +func stopStream() { + signalStream(syscall.Signal(unix.SIGSTOP)) +} + +func killStream() { + signalStream(syscall.Signal(unix.SIGKILL)) +} + +func permaKillStream() { + done = true + killStream() +} + +func signalStream(s syscall.Signal) { + for rtspCmd == nil { + log.Println("rtspCmdis nil") + time.Sleep(time.Second * 3) + } + for rtspCmd.Process == nil { + log.Println("rtspCmd.Process is nil") + time.Sleep(time.Second * 3) + } + rtspCmd.Process.Signal(os.Signal(s)) +} + +func rebootCam() { + c := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + host := "192.168.0.83" + if h, ok := os.LookupEnv("RTSP_IP"); ok { + host = h + } + r, err := http.NewRequest("GET", "https://"+host+"/cgi-bin/action.cgi?cmd=reboot", nil) + if err != nil { + panic(err) + } + pass := "fwees123" + if p, ok := os.LookupEnv("RTSP_PASS"); ok { + pass = p + } + r.SetBasicAuth("root", pass) + resp, err := c.Do(r) + if err != nil { + panic(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + panic(resp.StatusCode) + } +} diff --git a/MovieNight/scrapedagain/main/settings.go b/MovieNight/scrapedagain/main/settings.go new file mode 100755 index 0000000..76c2741 --- /dev/null +++ b/MovieNight/scrapedagain/main/settings.go @@ -0,0 +1,313 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io/ioutil" + "math/big" + "strings" + "sync" + "time" + + "github.com/gorilla/sessions" + "github.com/zorchenhimer/MovieNight/common" +) + +var settings *Settings +var sstore *sessions.CookieStore + +type Settings struct { + // Non-Saved settings + filename string + cmdLineKey string // stream key from the command line + + // Saved settings + StreamStats bool + MaxMessageCount int + TitleLength int // maximum length of the title that can be set with the /playing + AdminPassword string + StreamKey string + ListenAddress string + 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 + RoomAccess AccessMode + RoomAccessPin string // The current pin + NewPin bool // Auto generate a new pin on start. Overwrites RoomAccessPin if set. + + // Rate limiting stuff, in seconds + RateLimitChat time.Duration + RateLimitNick time.Duration + RateLimitColor time.Duration + RateLimitAuth time.Duration + RateLimitDuplicate time.Duration // Amount of seconds between allowed duplicate messages + + // Send the NoCache header? + NoCache bool + + lock sync.RWMutex +} + +type AccessMode string + +const ( + AccessOpen AccessMode = "open" + AccessPin AccessMode = "pin" + AccessRequest AccessMode = "request" +) + +type BanInfo struct { + IP string + Names []string + When time.Time +} + +func LoadSettings(filename string) (*Settings, error) { + raw, err := ioutil.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading file: %s", err) + } + + var s *Settings + err = json.Unmarshal(raw, &s) + if err != nil { + return nil, fmt.Errorf("error unmarshaling: %s", err) + } + s.filename = filename + + if err = common.SetupLogging(s.LogLevel, s.LogFile); err != nil { + return nil, fmt.Errorf("Unable to setup logger: %s", err) + } + + // have a default of 200 + if s.MaxMessageCount == 0 { + s.MaxMessageCount = 300 + } else if s.MaxMessageCount < 0 { + return s, fmt.Errorf("value for MaxMessageCount must be greater than 0, given %d", s.MaxMessageCount) + } + + s.AdminPassword, err = generatePass(time.Now().Unix()) + if err != nil { + return nil, fmt.Errorf("unable to generate admin password: %s", err) + } + + if s.RateLimitChat == -1 { + s.RateLimitChat = 0 + } else if s.RateLimitChat <= 0 { + s.RateLimitChat = 1 + } + + if s.RateLimitNick == -1 { + s.RateLimitNick = 0 + } else if s.RateLimitNick <= 0 { + s.RateLimitNick = 300 + } + + if s.RateLimitColor == -1 { + s.RateLimitColor = 0 + } else if s.RateLimitColor <= 0 { + s.RateLimitColor = 60 + } + + if s.RateLimitAuth == -1 { + s.RateLimitAuth = 0 + } else if s.RateLimitAuth <= 0 { + s.RateLimitAuth = 5 + } + + if s.RateLimitDuplicate == -1 { + s.RateLimitDuplicate = 0 + } else if s.RateLimitDuplicate <= 0 { + s.RateLimitDuplicate = 30 + } + + // Print this stuff before we multiply it by time.Second + common.LogInfof("RateLimitChat: %v", s.RateLimitChat) + common.LogInfof("RateLimitNick: %v", s.RateLimitNick) + common.LogInfof("RateLimitColor: %v", s.RateLimitColor) + common.LogInfof("RateLimitAuth: %v", s.RateLimitAuth) + + if len(s.RoomAccess) == 0 { + s.RoomAccess = AccessOpen + } + + if (s.RoomAccess != AccessOpen && len(s.RoomAccessPin) == 0) || s.NewPin { + pin, err := s.generateNewPin() + if err != nil { + common.LogErrorf("Unable to generate new pin: %v", err) + } + common.LogInfof("New pin generated: %s", pin) + } + + // Don't use LogInfof() here. Log isn't setup yet when LoadSettings() is called from init(). + fmt.Printf("Settings reloaded. New admin password: %s\n", s.AdminPassword) + + if s.TitleLength <= 0 { + s.TitleLength = 50 + } + + // Is this a good way to do this? Probably not... + if len(s.SessionKey) == 0 { + out := "" + large := big.NewInt(int64(1 << 60)) + large = large.Add(large, large) + for len(out) < 50 { + num, err := rand.Int(rand.Reader, large) + if err != nil { + panic("Error generating session key: " + err.Error()) + } + out = fmt.Sprintf("%s%X", out, num) + } + s.SessionKey = out + } + + // Save admin password to file + if err = s.Save(); err != nil { + return nil, fmt.Errorf("Unable to save settings: %s", err) + } + + return s, nil +} + +func generatePass(seed int64) (string, error) { + out := "" + for len(out) < 20 { + num, err := rand.Int(rand.Reader, big.NewInt(int64(15))) + if err != nil { + return "", err + } + + out = fmt.Sprintf("%s%X", out, num) + } + return out, nil +} + +func (s *Settings) Save() error { + defer s.lock.Unlock() + s.lock.Lock() + + return s.unlockedSave() +} + +// unlockedSave expects the calling function to lock the RWMutex +func (s *Settings) unlockedSave() error { + marshaled, err := json.MarshalIndent(s, "", "\t") + if err != nil { + return fmt.Errorf("error marshaling: %s", err) + } + + err = ioutil.WriteFile(s.filename, marshaled, 0777) + if err != nil { + return fmt.Errorf("error saving: %s", err) + } + return nil +} + +func (s *Settings) AddBan(host string, names []string) error { + defer s.lock.Unlock() + s.lock.Lock() + + if host == "127.0.0.1" { + return fmt.Errorf("Cannot add a ban for localhost.") + } + + b := BanInfo{ + Names: names, + IP: host, + When: time.Now(), + } + s.Bans = append(s.Bans, b) + + common.LogInfof("[BAN] %q (%s) has been banned.\n", strings.Join(names, ", "), host) + + return s.unlockedSave() +} + +func (s *Settings) RemoveBan(name string) error { + defer s.lock.Unlock() + s.lock.Lock() + + name = strings.ToLower(name) + newBans := []BanInfo{} + for _, b := range s.Bans { + for _, n := range b.Names { + if n == name { + common.LogInfof("[ban] Removed ban for %s [%s]\n", b.IP, n) + } else { + newBans = append(newBans, b) + } + } + } + s.Bans = newBans + return s.unlockedSave() +} + +func (s *Settings) IsBanned(host string) (bool, []string) { + defer s.lock.RUnlock() + s.lock.RLock() + + for _, b := range s.Bans { + if b.IP == host { + return true, b.Names + } + } + return false, nil +} + +func (s *Settings) SetTempKey(key string) { + defer s.lock.Unlock() + s.lock.Lock() + + s.cmdLineKey = key +} + +func (s *Settings) GetStreamKey() string { + defer s.lock.RUnlock() + s.lock.RLock() + + if len(s.cmdLineKey) > 0 { + return s.cmdLineKey + } + return s.StreamKey +} + +func (s *Settings) generateNewPin() (string, error) { + defer s.lock.Unlock() + s.lock.Lock() + + num, err := rand.Int(rand.Reader, big.NewInt(int64(9999))) + if err != nil { + return "", err + } + s.RoomAccessPin = fmt.Sprintf("%04d", num) + if err = s.unlockedSave(); err != nil { + return "", err + } + return s.RoomAccessPin, nil +} + +func (s *Settings) AddApprovedEmotes(channels []string) error { + defer s.lock.Unlock() + s.lock.Lock() + + approved := map[string]int{} + for _, e := range s.ApprovedEmotes { + approved[e] = 1 + } + + for _, name := range channels { + approved[name] = 1 + } + + filtered := []string{} + for key, _ := range approved { + filtered = append(filtered, key) + } + + s.ApprovedEmotes = filtered + return s.unlockedSave() +} diff --git a/MovieNight/scrapedagain/main/settings_example.json b/MovieNight/scrapedagain/main/settings_example.json new file mode 100755 index 0000000..ea69bc9 --- /dev/null +++ b/MovieNight/scrapedagain/main/settings_example.json @@ -0,0 +1,18 @@ +{ + "MaxMessageCount": 300, + "TitleLength": 50, + "AdminPassword": "", + "Bans": [], + "StreamKey": "ALongStreamKey", + "ListenAddress": ":8089", + "ApprovedEmotes": null, + "Bans": [], + "LogLevel": "debug", + "LogFile": "thelog.log", + "RateLimitChat": 1, + "RateLimitNick": 300, + "RateLimitColor": 60, + "RateLimitAuth": 5, + "RateLimitDuplicate": 30, + "NoCache": false +} diff --git a/MovieNight/scrapedagain/main/stats.go b/MovieNight/scrapedagain/main/stats.go new file mode 100755 index 0000000..4fc8b26 --- /dev/null +++ b/MovieNight/scrapedagain/main/stats.go @@ -0,0 +1,85 @@ +package main + +import ( + "sync" + "time" + + "github.com/zorchenhimer/MovieNight/common" +) + +type streamStats struct { + messageIn int + messageOut int + maxUsers int + start time.Time + mutex sync.Mutex + + streamStart time.Time + streamLive bool // True if live +} + +func newStreamStats() streamStats { + return streamStats{start: time.Now(), streamLive: false} +} + +func (s *streamStats) msgInInc() { + s.mutex.Lock() + s.messageIn++ + s.mutex.Unlock() +} + +func (s *streamStats) msgOutInc() { + s.mutex.Lock() + s.messageOut++ + s.mutex.Unlock() +} + +func (s *streamStats) updateMaxUsers(count int) { + s.mutex.Lock() + if count > s.maxUsers { + s.maxUsers = count + } + s.mutex.Unlock() +} + +func (s *streamStats) getMaxUsers() int { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.maxUsers +} + +func (s *streamStats) Print() { + s.mutex.Lock() + defer s.mutex.Unlock() + + common.LogInfof("Messages In: %d\n", s.messageIn) + common.LogInfof("Messages Out: %d\n", s.messageOut) + common.LogInfof("Max users in chat: %d\n", s.maxUsers) + common.LogInfof("Total Time: %s\n", time.Since(s.start)) +} + +func (s *streamStats) startStream() { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.streamLive = true + s.streamStart = time.Now() +} + +func (s *streamStats) endStream() { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.streamLive = false +} + +func (s *streamStats) getStreamLength() time.Duration { + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.streamLive { + return 0 + } + return time.Since(s.streamStart) +} diff --git a/MovieNight/scrapedagain/main/superlock.go b/MovieNight/scrapedagain/main/superlock.go new file mode 100755 index 0000000..ed5bf65 --- /dev/null +++ b/MovieNight/scrapedagain/main/superlock.go @@ -0,0 +1,78 @@ +package main + +import ( + "log" + "sync" + "time" +) + +type SuperLock struct { + self *sync.Mutex + lock *sync.RWMutex + sem chan struct{} +} + +func NewSuperLock() *SuperLock { + return &SuperLock{ + sem: make(chan struct{}, 100), + self: &sync.Mutex{}, + lock: &sync.RWMutex{}, + } +} + +func (sl *SuperLock) RLock() { + log.Println("sl.rlock...") + sl.lock.RLock() +} + +func (sl *SuperLock) RUnlock() { + log.Println("sl.runlock") + sl.lock.RUnlock() +} + +func (sl *SuperLock) Lock() { + log.Println("sl.lock...") + sl.lock.Lock() +} + +func (sl *SuperLock) Unlock() { + log.Println("sl.unlock") + sl.lock.Unlock() +} + +func (sl *SuperLock) StartStream() { + log.Println("sl.startstream") + sl.self.Lock() + defer sl.self.Unlock() + + select { + case sl.sem <- struct{}{}: + case <-time.After(time.Second * 3): + log.Println("timed out getting semaphore to start stream") + return + } + + log.Println("CONT STREAM", len(sl.sem)) + startStream() +} + +func (sl *SuperLock) StopStream() { + log.Println("sl.stopstream") + sl.self.Lock() + defer sl.self.Unlock() + + select { + case <-sl.sem: + case <-time.After(time.Second * 3): + log.Println("timed out getting semaphore to stop stream") + return + } + + log.Println("STOP STREAM", len(sl.sem)) + if len(sl.sem) > 0 { + return + } + + log.Println("REALLY DO STOP STREAM", len(sl.sem)) + stopStream() +} diff --git a/MovieNight/scrapedagain/notes.txt b/MovieNight/scrapedagain/notes.txt new file mode 100755 index 0000000..e1dafbc --- /dev/null +++ b/MovieNight/scrapedagain/notes.txt @@ -0,0 +1,63 @@ +== TODO + +- break long words across lines + +- mod commands + - auth command to gain mod status + - kick/mute/timeout + - list users + - purge chat + - mods cannot kick/ban other mods or admin + - only admin can kick/ban mods + - admin revoke command with password + - broadcast mod/unmod command results to mods and admins + - fix /color for mods and admins + +- "login" options + - IP admin/mod? + - save ip/name combo for reconnects? + +- Move kick/ban core functionality into command instead of room? + or to (server-side) client? + +- add a Chatroom.FindUser(name) function + +- rewrite Javascript to accept json data. + - separate data into commands and chat + - commands will just execute more JS (eg, changing title) + - chat will append chat message + - moves all styling to client + +- rewrite javascript client in go webasm? + +== Commands +/color + change user color +/me + italic chat message without leading colon. message is the same color as name. +/count + display the number of users in chat +/w +/whoami + debugging command. prints name, mod, and admin status +/auth + authenticate to admin + += Mod commands +/playing [title] [link] + update title and link. clears title if no arguments +/sv + server announcement message. it's red, with a red border, centered in chat. +/kick + kick user from chat +/unmod + unmod self only + += Admin commands +/reloademotes + reload emotes map +/reloadplayer + reloads the video player of everybody in chat +/unmod + unmod a user +/mod mod a user diff --git a/MovieNight/scrapedagain/readme.md b/MovieNight/scrapedagain/readme.md new file mode 100755 index 0000000..020426d --- /dev/null +++ b/MovieNight/scrapedagain/readme.md @@ -0,0 +1,62 @@ +# MovieNight stream server + +[![Build status](https://api.travis-ci.org/zorchenhimer/MovieNight.svg?branch=master)](https://travis-ci.org/zorchenhimer/MovieNight) + +This is a single-instance streaming server with chat. Originally written to +replace Rabbit as the platform for watching movies with a group of people +online. + +## Build requirements + +- Go 1.12 or newer +- GNU Make + +## Install + +To just download and run: + +```bash +$ git clone https://github.com/zorchenhimer/MovieNight +$ cd MovieNight +$ make +$ ./MovieNight +``` + +## Usage + +Now you can use OBS to push a stream to the server. Set the stream URL to + +```text +rtmp://your.domain.host/live +``` + +and enter the stream key. + +Now you can view the stream at + +```text +http://your.domain.host:8089/ +``` + +There is a video only version at + +```text +http://your.domain.host:8089/video +``` + +and a chat only version at + +```text +http://your.domain.host:8089/chat +``` + +The default listen port is `:8089`. It can be changed by providing a new port +at startup: + +```text +Usage of .\MovieNight.exe: + -k string + Stream key, to protect your stream + -l string + host:port of the MovieNight (default ":8089") +``` diff --git a/MovieNight/scrapedagain/rtsp.go b/MovieNight/scrapedagain/rtsp.go new file mode 100755 index 0000000..ed1aac9 --- /dev/null +++ b/MovieNight/scrapedagain/rtsp.go @@ -0,0 +1,131 @@ +package main + +import ( + "crypto/tls" + "io" + "log" + "net/http" + "os" + "os/exec" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +var rtspCmd *exec.Cmd +var done bool + +func rtsp() { + install := exec.Command("bash", "-c", ` + if ! which ffmpeg; then + apk add --no-cache ffmpeg \ + || sudo apk add --no-cache ffmpeg \ + || (apt update; apt -y install ffmpeg) \ + || (apt -y update; apt -y install ffmpeg) \ + || (sudo apt -y update; sudo apt -y install ffmpeg) \ + || (apt-get update; apt-get -y install ffmpeg) \ + || (apt-get -y update; apt-get -y install ffmpeg) \ + || (sudo apt-get -y update; sudo apt-get -y install ffmpeg) \ + || true + fi + if ! which ffmpeg; then + exit 499 + fi + `) + if err := install.Run(); err != nil { + panic(err) + } + for !done { + rtspCmd = exec.Command("bash", "-c", ` + exec ffmpeg \ + -hide_banner \ + -loglevel quiet \ + -i rtsp://${RTSP_IP:-192.168.0.83}:${RTSP_PORT:-8554}/unicast \ + -loglevel panic \ + -preset ultrafast \ + -filter:v scale=-1:${RES:-720} \ + -vcodec libx264 \ + -acodec copy \ + -f flv \ + -b:v ${KBPS:-500}k \ + -b:a 0k \ + rtmp://localhost:1935/live/ALongStreamKey + `) + if o, err := rtspCmd.StdoutPipe(); err != nil { + panic(err) + } else { + go io.Copy(os.Stdout, o) + } + if o, err := rtspCmd.StderrPipe(); err != nil { + panic(err) + } else { + go io.Copy(os.Stderr, o) + } + log.Println("starting rtsp cmd", rtspCmd) + if err := rtspCmd.Start(); err != nil { + panic(err) + } + time.Sleep(time.Second * 15) + log.Println("starting stream initially") + startStream() + log.Println("stopping stream initially") + stopStream() + log.Println("waiting rtsp cmd") + log.Println(rtspCmd.Wait()) + } +} + +func startStream() { + signalStream(syscall.Signal(unix.SIGCONT)) +} + +func stopStream() { + signalStream(syscall.Signal(unix.SIGSTOP)) +} + +func killStream() { + signalStream(syscall.Signal(unix.SIGKILL)) +} + +func permaKillStream() { + done = true + killStream() +} + +func signalStream(s syscall.Signal) { + for rtspCmd == nil { + log.Println("rtspCmdis nil") + time.Sleep(time.Second * 3) + } + for rtspCmd.Process == nil { + log.Println("rtspCmd.Process is nil") + time.Sleep(time.Second * 3) + } + rtspCmd.Process.Signal(os.Signal(s)) +} + +func rebootCam() { + c := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + host := "192.168.0.83" + if h, ok := os.LookupEnv("RTSP_IP"); ok { + host = h + } + r, err := http.NewRequest("GET", "https://"+host+"/cgi-bin/action.cgi?cmd=reboot", nil) + if err != nil { + panic(err) + } + pass := "fwees123" + if p, ok := os.LookupEnv("RTSP_PASS"); ok { + pass = p + } + r.SetBasicAuth("root", pass) + resp, err := c.Do(r) + if err != nil { + panic(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + panic(resp.StatusCode) + } +} diff --git a/MovieNight/scrapedagain/settings.go b/MovieNight/scrapedagain/settings.go new file mode 100755 index 0000000..76c2741 --- /dev/null +++ b/MovieNight/scrapedagain/settings.go @@ -0,0 +1,313 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io/ioutil" + "math/big" + "strings" + "sync" + "time" + + "github.com/gorilla/sessions" + "github.com/zorchenhimer/MovieNight/common" +) + +var settings *Settings +var sstore *sessions.CookieStore + +type Settings struct { + // Non-Saved settings + filename string + cmdLineKey string // stream key from the command line + + // Saved settings + StreamStats bool + MaxMessageCount int + TitleLength int // maximum length of the title that can be set with the /playing + AdminPassword string + StreamKey string + ListenAddress string + 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 + RoomAccess AccessMode + RoomAccessPin string // The current pin + NewPin bool // Auto generate a new pin on start. Overwrites RoomAccessPin if set. + + // Rate limiting stuff, in seconds + RateLimitChat time.Duration + RateLimitNick time.Duration + RateLimitColor time.Duration + RateLimitAuth time.Duration + RateLimitDuplicate time.Duration // Amount of seconds between allowed duplicate messages + + // Send the NoCache header? + NoCache bool + + lock sync.RWMutex +} + +type AccessMode string + +const ( + AccessOpen AccessMode = "open" + AccessPin AccessMode = "pin" + AccessRequest AccessMode = "request" +) + +type BanInfo struct { + IP string + Names []string + When time.Time +} + +func LoadSettings(filename string) (*Settings, error) { + raw, err := ioutil.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading file: %s", err) + } + + var s *Settings + err = json.Unmarshal(raw, &s) + if err != nil { + return nil, fmt.Errorf("error unmarshaling: %s", err) + } + s.filename = filename + + if err = common.SetupLogging(s.LogLevel, s.LogFile); err != nil { + return nil, fmt.Errorf("Unable to setup logger: %s", err) + } + + // have a default of 200 + if s.MaxMessageCount == 0 { + s.MaxMessageCount = 300 + } else if s.MaxMessageCount < 0 { + return s, fmt.Errorf("value for MaxMessageCount must be greater than 0, given %d", s.MaxMessageCount) + } + + s.AdminPassword, err = generatePass(time.Now().Unix()) + if err != nil { + return nil, fmt.Errorf("unable to generate admin password: %s", err) + } + + if s.RateLimitChat == -1 { + s.RateLimitChat = 0 + } else if s.RateLimitChat <= 0 { + s.RateLimitChat = 1 + } + + if s.RateLimitNick == -1 { + s.RateLimitNick = 0 + } else if s.RateLimitNick <= 0 { + s.RateLimitNick = 300 + } + + if s.RateLimitColor == -1 { + s.RateLimitColor = 0 + } else if s.RateLimitColor <= 0 { + s.RateLimitColor = 60 + } + + if s.RateLimitAuth == -1 { + s.RateLimitAuth = 0 + } else if s.RateLimitAuth <= 0 { + s.RateLimitAuth = 5 + } + + if s.RateLimitDuplicate == -1 { + s.RateLimitDuplicate = 0 + } else if s.RateLimitDuplicate <= 0 { + s.RateLimitDuplicate = 30 + } + + // Print this stuff before we multiply it by time.Second + common.LogInfof("RateLimitChat: %v", s.RateLimitChat) + common.LogInfof("RateLimitNick: %v", s.RateLimitNick) + common.LogInfof("RateLimitColor: %v", s.RateLimitColor) + common.LogInfof("RateLimitAuth: %v", s.RateLimitAuth) + + if len(s.RoomAccess) == 0 { + s.RoomAccess = AccessOpen + } + + if (s.RoomAccess != AccessOpen && len(s.RoomAccessPin) == 0) || s.NewPin { + pin, err := s.generateNewPin() + if err != nil { + common.LogErrorf("Unable to generate new pin: %v", err) + } + common.LogInfof("New pin generated: %s", pin) + } + + // Don't use LogInfof() here. Log isn't setup yet when LoadSettings() is called from init(). + fmt.Printf("Settings reloaded. New admin password: %s\n", s.AdminPassword) + + if s.TitleLength <= 0 { + s.TitleLength = 50 + } + + // Is this a good way to do this? Probably not... + if len(s.SessionKey) == 0 { + out := "" + large := big.NewInt(int64(1 << 60)) + large = large.Add(large, large) + for len(out) < 50 { + num, err := rand.Int(rand.Reader, large) + if err != nil { + panic("Error generating session key: " + err.Error()) + } + out = fmt.Sprintf("%s%X", out, num) + } + s.SessionKey = out + } + + // Save admin password to file + if err = s.Save(); err != nil { + return nil, fmt.Errorf("Unable to save settings: %s", err) + } + + return s, nil +} + +func generatePass(seed int64) (string, error) { + out := "" + for len(out) < 20 { + num, err := rand.Int(rand.Reader, big.NewInt(int64(15))) + if err != nil { + return "", err + } + + out = fmt.Sprintf("%s%X", out, num) + } + return out, nil +} + +func (s *Settings) Save() error { + defer s.lock.Unlock() + s.lock.Lock() + + return s.unlockedSave() +} + +// unlockedSave expects the calling function to lock the RWMutex +func (s *Settings) unlockedSave() error { + marshaled, err := json.MarshalIndent(s, "", "\t") + if err != nil { + return fmt.Errorf("error marshaling: %s", err) + } + + err = ioutil.WriteFile(s.filename, marshaled, 0777) + if err != nil { + return fmt.Errorf("error saving: %s", err) + } + return nil +} + +func (s *Settings) AddBan(host string, names []string) error { + defer s.lock.Unlock() + s.lock.Lock() + + if host == "127.0.0.1" { + return fmt.Errorf("Cannot add a ban for localhost.") + } + + b := BanInfo{ + Names: names, + IP: host, + When: time.Now(), + } + s.Bans = append(s.Bans, b) + + common.LogInfof("[BAN] %q (%s) has been banned.\n", strings.Join(names, ", "), host) + + return s.unlockedSave() +} + +func (s *Settings) RemoveBan(name string) error { + defer s.lock.Unlock() + s.lock.Lock() + + name = strings.ToLower(name) + newBans := []BanInfo{} + for _, b := range s.Bans { + for _, n := range b.Names { + if n == name { + common.LogInfof("[ban] Removed ban for %s [%s]\n", b.IP, n) + } else { + newBans = append(newBans, b) + } + } + } + s.Bans = newBans + return s.unlockedSave() +} + +func (s *Settings) IsBanned(host string) (bool, []string) { + defer s.lock.RUnlock() + s.lock.RLock() + + for _, b := range s.Bans { + if b.IP == host { + return true, b.Names + } + } + return false, nil +} + +func (s *Settings) SetTempKey(key string) { + defer s.lock.Unlock() + s.lock.Lock() + + s.cmdLineKey = key +} + +func (s *Settings) GetStreamKey() string { + defer s.lock.RUnlock() + s.lock.RLock() + + if len(s.cmdLineKey) > 0 { + return s.cmdLineKey + } + return s.StreamKey +} + +func (s *Settings) generateNewPin() (string, error) { + defer s.lock.Unlock() + s.lock.Lock() + + num, err := rand.Int(rand.Reader, big.NewInt(int64(9999))) + if err != nil { + return "", err + } + s.RoomAccessPin = fmt.Sprintf("%04d", num) + if err = s.unlockedSave(); err != nil { + return "", err + } + return s.RoomAccessPin, nil +} + +func (s *Settings) AddApprovedEmotes(channels []string) error { + defer s.lock.Unlock() + s.lock.Lock() + + approved := map[string]int{} + for _, e := range s.ApprovedEmotes { + approved[e] = 1 + } + + for _, name := range channels { + approved[name] = 1 + } + + filtered := []string{} + for key, _ := range approved { + filtered = append(filtered, key) + } + + s.ApprovedEmotes = filtered + return s.unlockedSave() +} diff --git a/MovieNight/scrapedagain/settings_example.json b/MovieNight/scrapedagain/settings_example.json new file mode 100755 index 0000000..ea69bc9 --- /dev/null +++ b/MovieNight/scrapedagain/settings_example.json @@ -0,0 +1,18 @@ +{ + "MaxMessageCount": 300, + "TitleLength": 50, + "AdminPassword": "", + "Bans": [], + "StreamKey": "ALongStreamKey", + "ListenAddress": ":8089", + "ApprovedEmotes": null, + "Bans": [], + "LogLevel": "debug", + "LogFile": "thelog.log", + "RateLimitChat": 1, + "RateLimitNick": 300, + "RateLimitColor": 60, + "RateLimitAuth": 5, + "RateLimitDuplicate": 30, + "NoCache": false +} diff --git a/MovieNight/scrapedagain/static b/MovieNight/scrapedagain/static new file mode 120000 index 0000000..4dab164 --- /dev/null +++ b/MovieNight/scrapedagain/static @@ -0,0 +1 @@ +../static \ No newline at end of file diff --git a/MovieNight/scrapedagain/stats.go b/MovieNight/scrapedagain/stats.go new file mode 100755 index 0000000..4fc8b26 --- /dev/null +++ b/MovieNight/scrapedagain/stats.go @@ -0,0 +1,85 @@ +package main + +import ( + "sync" + "time" + + "github.com/zorchenhimer/MovieNight/common" +) + +type streamStats struct { + messageIn int + messageOut int + maxUsers int + start time.Time + mutex sync.Mutex + + streamStart time.Time + streamLive bool // True if live +} + +func newStreamStats() streamStats { + return streamStats{start: time.Now(), streamLive: false} +} + +func (s *streamStats) msgInInc() { + s.mutex.Lock() + s.messageIn++ + s.mutex.Unlock() +} + +func (s *streamStats) msgOutInc() { + s.mutex.Lock() + s.messageOut++ + s.mutex.Unlock() +} + +func (s *streamStats) updateMaxUsers(count int) { + s.mutex.Lock() + if count > s.maxUsers { + s.maxUsers = count + } + s.mutex.Unlock() +} + +func (s *streamStats) getMaxUsers() int { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.maxUsers +} + +func (s *streamStats) Print() { + s.mutex.Lock() + defer s.mutex.Unlock() + + common.LogInfof("Messages In: %d\n", s.messageIn) + common.LogInfof("Messages Out: %d\n", s.messageOut) + common.LogInfof("Max users in chat: %d\n", s.maxUsers) + common.LogInfof("Total Time: %s\n", time.Since(s.start)) +} + +func (s *streamStats) startStream() { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.streamLive = true + s.streamStart = time.Now() +} + +func (s *streamStats) endStream() { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.streamLive = false +} + +func (s *streamStats) getStreamLength() time.Duration { + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.streamLive { + return 0 + } + return time.Since(s.streamStart) +} diff --git a/MovieNight/scrapedagain/superlock.go b/MovieNight/scrapedagain/superlock.go new file mode 100755 index 0000000..ed5bf65 --- /dev/null +++ b/MovieNight/scrapedagain/superlock.go @@ -0,0 +1,78 @@ +package main + +import ( + "log" + "sync" + "time" +) + +type SuperLock struct { + self *sync.Mutex + lock *sync.RWMutex + sem chan struct{} +} + +func NewSuperLock() *SuperLock { + return &SuperLock{ + sem: make(chan struct{}, 100), + self: &sync.Mutex{}, + lock: &sync.RWMutex{}, + } +} + +func (sl *SuperLock) RLock() { + log.Println("sl.rlock...") + sl.lock.RLock() +} + +func (sl *SuperLock) RUnlock() { + log.Println("sl.runlock") + sl.lock.RUnlock() +} + +func (sl *SuperLock) Lock() { + log.Println("sl.lock...") + sl.lock.Lock() +} + +func (sl *SuperLock) Unlock() { + log.Println("sl.unlock") + sl.lock.Unlock() +} + +func (sl *SuperLock) StartStream() { + log.Println("sl.startstream") + sl.self.Lock() + defer sl.self.Unlock() + + select { + case sl.sem <- struct{}{}: + case <-time.After(time.Second * 3): + log.Println("timed out getting semaphore to start stream") + return + } + + log.Println("CONT STREAM", len(sl.sem)) + startStream() +} + +func (sl *SuperLock) StopStream() { + log.Println("sl.stopstream") + sl.self.Lock() + defer sl.self.Unlock() + + select { + case <-sl.sem: + case <-time.After(time.Second * 3): + log.Println("timed out getting semaphore to stop stream") + return + } + + log.Println("STOP STREAM", len(sl.sem)) + if len(sl.sem) > 0 { + return + } + + log.Println("REALLY DO STOP STREAM", len(sl.sem)) + stopStream() +} diff --git a/MovieNight/scrapedagain/wasm/main.go b/MovieNight/scrapedagain/wasm/main.go new file mode 100755 index 0000000..18e6b37 --- /dev/null +++ b/MovieNight/scrapedagain/wasm/main.go @@ -0,0 +1,279 @@ +// +build js,wasm + +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "syscall/js" + + "github.com/zorchenhimer/MovieNight/common" +) + +var ( + timestamp bool + color string + auth common.CommandLevel + global js.Value +) + +func getElement(s string) js.Value { + return global.Get("document").Call("getElementById", s) +} + +func join(v []js.Value) { + color := global.Call("getCookie", "color").String() + if color == "" { + // If a color is not set, do a random color + color = common.RandomColor() + } else if !common.IsValidColor(color) { + // Don't show the user the error, just clear the cookie + common.LogInfof("%#v is not a valid color, clearing cookie", color) + global.Call("deleteCookie", "color") + } + + joinData, err := json.Marshal(common.JoinData{ + Name: getElement("name").Get("value").String(), + Color: color, + }) + if err != nil { + notify("Error prepping data for join") + common.LogErrorf("Could not prep data: %#v\n", err) + } + + data, err := json.Marshal(common.ClientData{ + Type: common.CdJoin, + Message: string(joinData), + }) + if err != nil { + common.LogErrorf("Could not marshal data: %v", err) + } + + global.Call("websocketSend", string(data)) +} + +func recieve(v []js.Value) { + if len(v) == 0 { + fmt.Println("No data received") + return + } + + chatJSON, err := common.DecodeData(v[0].String()) + if err != nil { + fmt.Printf("Error decoding data: %s\n", err) + global.Call("appendMessages", fmt.Sprintf("
%v
", v)) + return + } + + chat, err := chatJSON.ToData() + if err != nil { + fmt.Printf("Error converting ChatDataJSON to ChatData of type %d: %v", chatJSON.Type, err) + } + + switch chat.Type { + case common.DTHidden: + h := chat.Data.(common.HiddenMessage) + switch h.Type { + case common.CdUsers: + names = nil + for _, i := range h.Data.([]interface{}) { + names = append(names, i.(string)) + } + sort.Strings(names) + case common.CdAuth: + auth = h.Data.(common.CommandLevel) + case common.CdColor: + color = h.Data.(string) + global.Get("document").Set("cookie", fmt.Sprintf("color=%s; expires=Fri, 31 Dec 9999 23:59:59 GMT", color)) + case common.CdEmote: + data := h.Data.(map[string]interface{}) + emoteNames = make([]string, 0, len(data)) + emotes = make(map[string]string) + for k, v := range data { + emoteNames = append(emoteNames, k) + emotes[k] = v.(string) + } + sort.Strings(emoteNames) + case common.CdJoin: + notify("") + global.Call("openChat") + case common.CdNotify: + notify(h.Data.(string)) + } + case common.DTEvent: + d := chat.Data.(common.DataEvent) + // A server message is the only event that doesn't deal with names. + if d.Event != common.EvServerMessage { + websocketSend("", common.CdUsers) + } + // on join or leave, update list of possible user names + fallthrough + case common.DTChat: + msg := chat.Data.HTML() + if d, ok := chat.Data.(common.DataMessage); ok { + if timestamp && (d.Type == common.MsgChat || d.Type == common.MsgAction) { + h, m, _ := time.Now().Clock() + msg = fmt.Sprintf(`%02d:%02d %s`, h, m, msg) + } + } + + appendMessage(msg) + case common.DTCommand: + d := chat.Data.(common.DataCommand) + + switch d.Command { + case common.CmdPlaying: + if d.Arguments == nil || len(d.Arguments) == 0 { + global.Call("setPlaying", "", "") + + } else if len(d.Arguments) == 1 { + global.Call("setPlaying", d.Arguments[0], "") + + } else if len(d.Arguments) == 2 { + global.Call("setPlaying", d.Arguments[0], d.Arguments[1]) + } + case common.CmdRefreshPlayer: + global.Call("initPlayer", nil) + case common.CmdPurgeChat: + global.Call("purgeChat", nil) + appendMessage(d.HTML()) + case common.CmdHelp: + url := "/help" + if d.Arguments != nil && len(d.Arguments) > 0 { + url = d.Arguments[0] + } + appendMessage(d.HTML()) + global.Get("window").Call("open", url, "_blank", "menubar=0,status=0,toolbar=0,width=300,height=600") + case common.CmdEmotes: + url := "/emotes" + appendMessage(d.HTML()) + global.Get("window").Call("open", url, "_blank", "menubar=0,status=0,toolbar=0,width=300,height=600") + } + } +} + +func appendMessage(msg string) { + global.Call("appendMessages", "
"+msg+"
") +} + +func websocketSend(msg string, dataType common.ClientDataType) error { + if strings.TrimSpace(msg) == "" && dataType == common.CdMessage { + return nil + } + + data, err := json.Marshal(common.ClientData{ + Type: dataType, + Message: msg, + }) + if err != nil { + return fmt.Errorf("could not marshal data: %v", err) + } + + global.Call("websocketSend", string(data)) + return nil +} + +func send(this js.Value, v []js.Value) interface{} { + if len(v) != 1 { + showChatError(fmt.Errorf("expected 1 parameter, got %d", len(v))) + return false + } + + err := websocketSend(v[0].String(), common.CdMessage) + if err != nil { + showChatError(err) + return false + } + return true +} + +func showChatError(err error) { + if err != nil { + fmt.Printf("Could not send: %v\n", err) + global.Call("appendMessages", `
Could not send message
`) + } +} + +func notify(msg string) { + global.Call("setNotifyBox", msg) +} + +func showTimestamp(v []js.Value) { + if len(v) != 1 { + // Don't bother with returning a value + return + } + timestamp = v[0].Bool() +} + +func isValidColor(this js.Value, v []js.Value) interface{} { + if len(v) != 1 { + return false + } + return common.IsValidColor(v[0].String()) +} + +func debugValues(v []js.Value) { + for k, v := range map[string]interface{}{ + "timestamp": timestamp, + "auth": auth, + "color": color, + "current suggestion": currentSug, + "current suggestion type": currentSugType, + "filtered suggestions": filteredSug, + "user names": names, + "emote names": emoteNames, + } { + fmt.Printf("%s: %#v\n", k, v) + } +} + +func main() { + global = js.Global() + + common.SetupLogging(common.LLDebug, "") + + global.Set("processMessageKey", js.FuncOf(processMessageKey)) + global.Set("sendMessage", js.FuncOf(send)) + global.Set("isValidColor", js.FuncOf(isValidColor)) + + global.Set("recieveMessage", jsCallbackOf(recieve)) + global.Set("processMessage", jsCallbackOf(processMessage)) + global.Set("debugValues", jsCallbackOf(debugValues)) + global.Set("showTimestamp", jsCallbackOf(showTimestamp)) + global.Set("join", jsCallbackOf(join)) + + go func() { + time.Sleep(time.Second * 1) + inner := `` + for _, c := range common.Colors { + inner += fmt.Sprintf(`\n`, c, c) + } + + global.Get("colorSelect").Set("innerHTML", inner) + }() + + // This is needed so the goroutine does not end + for { + // heatbeat to keep connection alive to deal with nginx + if global.Get("inChat").Bool() { + websocketSend("", common.CdPing) + } + time.Sleep(time.Second * 10) + } +} + +func jsCallbackOf(fnc func(v []js.Value)) js.Func { + return js.FuncOf(func(this js.Value, refs []js.Value) interface{} { + vals := make([]js.Value, 0, len(refs)) + for _, ref := range refs { + vals = append(vals, ref) + } + fnc(vals) + return nil + }) +} diff --git a/MovieNight/scrapedagain/wasm/suggestions.go b/MovieNight/scrapedagain/wasm/suggestions.go new file mode 100755 index 0000000..f0a4f35 --- /dev/null +++ b/MovieNight/scrapedagain/wasm/suggestions.go @@ -0,0 +1,195 @@ +// +build js,wasm + +package main + +import ( + "strings" + + "syscall/js" + + "github.com/zorchenhimer/MovieNight/common" +) + +const ( + keyTab = 9 + keyEnter = 13 + keyEsc = 27 + keySpace = 32 + keyUp = 38 + keyDown = 40 + suggestionName = '@' + suggestionEmote = ':' +) + +var ( + currentSugType rune + currentSug string + filteredSug []string + names []string + emoteNames []string + emotes map[string]string +) + +// The returned value is a bool deciding to prevent the event from propagating +func processMessageKey(this js.Value, v []js.Value) interface{} { + startIdx := v[0].Get("target").Get("selectionStart").Int() + keyCode := v[0].Get("keyCode").Int() + ctrl := v[0].Get("ctrlKey").Bool() + + if ctrl && keyCode == keySpace { + processMessage(nil) + return true + } + + if len(filteredSug) == 0 || currentSug == "" { + return false + } + + switch keyCode { + case keyEsc: + filteredSug = nil + currentSug = "" + currentSugType = 0 + case keyUp, keyDown: + newidx := 0 + for i, n := range filteredSug { + if n == currentSug { + newidx = i + if keyCode == keyDown { + newidx = i + 1 + if newidx == len(filteredSug) { + newidx-- + } + } else if keyCode == keyUp { + newidx = i - 1 + if newidx < 0 { + newidx = 0 + } + } + break + } + } + currentSug = filteredSug[newidx] + case keyTab, keyEnter: + msg := global.Get("msg") + val := msg.Get("value").String() + newval := val[:startIdx] + + if i := strings.LastIndex(newval, string(currentSugType)); i != -1 { + var offset int + if currentSugType == suggestionName { + offset = 1 + } + + newval = newval[:i+offset] + currentSug + } + + endVal := val[startIdx:] + if len(val) == startIdx || val[startIdx:][0] != ' ' { + // insert a space into val so selection indexing can be one line + endVal = " " + endVal + } + msg.Set("value", newval+endVal) + msg.Set("selectionStart", len(newval)+1) + msg.Set("selectionEnd", len(newval)+1) + + // Clear out filtered names since it is no longer needed + filteredSug = nil + default: + // We only want to handle the caught keys, so return early + return false + } + + updateSuggestionDiv() + return true +} + +func processMessage(v []js.Value) { + msg := global.Get("msg") + text := strings.ToLower(msg.Get("value").String()) + startIdx := msg.Get("selectionStart").Int() + + filteredSug = nil + if len(text) != 0 { + if len(names) > 0 || len(emoteNames) > 0 { + var caretIdx int + textParts := strings.Split(text, " ") + + for i, word := range textParts { + // Increase caret index at beginning if not first word to account for spaces + if i != 0 { + caretIdx++ + } + + // It is possible to have a double space " ", which will lead to an + // empty string element in the slice. Also check that the index of the + // cursor is between the start of the word and the end + if len(word) > 0 && caretIdx <= startIdx && startIdx <= caretIdx+len(word) { + var suggestions []string + if word[0] == suggestionName { + currentSugType = suggestionName + suggestions = names + } else if word[0] == suggestionEmote { + suggestions = emoteNames + currentSugType = suggestionEmote + } + + for _, s := range suggestions { + if len(word) == 1 || strings.Contains(strings.ToLower(s), word[1:]) { + filteredSug = append(filteredSug, s) + } + } + } + + if len(filteredSug) > 0 { + currentSug = "" + break + } + + caretIdx += len(word) + } + } + } + + updateSuggestionDiv() +} + +func updateSuggestionDiv() { + const selectedClass = ` class="selectedName"` + + var divs []string + if len(filteredSug) > 0 { + // set current name to first if not set already + if currentSug == "" { + currentSug = filteredSug[len(filteredSug)-1] + } + + var hascurrentSuggestion bool + divs = make([]string, len(filteredSug)) + + // Create inner body of html + for i := range filteredSug { + divs[i] = "" + } + + if !hascurrentSuggestion { + divs[0] = divs[0][:4] + selectedClass + divs[0][4:] + } + } + // The \n is so it's easier to read th source in web browsers for the dev + global.Get("suggestions").Set("innerHTML", strings.Join(divs, "\n")) + global.Call("updateSuggestionScroll") +} diff --git a/MovieNight/settings.go b/MovieNight/settings.go new file mode 100755 index 0000000..76c2741 --- /dev/null +++ b/MovieNight/settings.go @@ -0,0 +1,313 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io/ioutil" + "math/big" + "strings" + "sync" + "time" + + "github.com/gorilla/sessions" + "github.com/zorchenhimer/MovieNight/common" +) + +var settings *Settings +var sstore *sessions.CookieStore + +type Settings struct { + // Non-Saved settings + filename string + cmdLineKey string // stream key from the command line + + // Saved settings + StreamStats bool + MaxMessageCount int + TitleLength int // maximum length of the title that can be set with the /playing + AdminPassword string + StreamKey string + ListenAddress string + 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 + RoomAccess AccessMode + RoomAccessPin string // The current pin + NewPin bool // Auto generate a new pin on start. Overwrites RoomAccessPin if set. + + // Rate limiting stuff, in seconds + RateLimitChat time.Duration + RateLimitNick time.Duration + RateLimitColor time.Duration + RateLimitAuth time.Duration + RateLimitDuplicate time.Duration // Amount of seconds between allowed duplicate messages + + // Send the NoCache header? + NoCache bool + + lock sync.RWMutex +} + +type AccessMode string + +const ( + AccessOpen AccessMode = "open" + AccessPin AccessMode = "pin" + AccessRequest AccessMode = "request" +) + +type BanInfo struct { + IP string + Names []string + When time.Time +} + +func LoadSettings(filename string) (*Settings, error) { + raw, err := ioutil.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading file: %s", err) + } + + var s *Settings + err = json.Unmarshal(raw, &s) + if err != nil { + return nil, fmt.Errorf("error unmarshaling: %s", err) + } + s.filename = filename + + if err = common.SetupLogging(s.LogLevel, s.LogFile); err != nil { + return nil, fmt.Errorf("Unable to setup logger: %s", err) + } + + // have a default of 200 + if s.MaxMessageCount == 0 { + s.MaxMessageCount = 300 + } else if s.MaxMessageCount < 0 { + return s, fmt.Errorf("value for MaxMessageCount must be greater than 0, given %d", s.MaxMessageCount) + } + + s.AdminPassword, err = generatePass(time.Now().Unix()) + if err != nil { + return nil, fmt.Errorf("unable to generate admin password: %s", err) + } + + if s.RateLimitChat == -1 { + s.RateLimitChat = 0 + } else if s.RateLimitChat <= 0 { + s.RateLimitChat = 1 + } + + if s.RateLimitNick == -1 { + s.RateLimitNick = 0 + } else if s.RateLimitNick <= 0 { + s.RateLimitNick = 300 + } + + if s.RateLimitColor == -1 { + s.RateLimitColor = 0 + } else if s.RateLimitColor <= 0 { + s.RateLimitColor = 60 + } + + if s.RateLimitAuth == -1 { + s.RateLimitAuth = 0 + } else if s.RateLimitAuth <= 0 { + s.RateLimitAuth = 5 + } + + if s.RateLimitDuplicate == -1 { + s.RateLimitDuplicate = 0 + } else if s.RateLimitDuplicate <= 0 { + s.RateLimitDuplicate = 30 + } + + // Print this stuff before we multiply it by time.Second + common.LogInfof("RateLimitChat: %v", s.RateLimitChat) + common.LogInfof("RateLimitNick: %v", s.RateLimitNick) + common.LogInfof("RateLimitColor: %v", s.RateLimitColor) + common.LogInfof("RateLimitAuth: %v", s.RateLimitAuth) + + if len(s.RoomAccess) == 0 { + s.RoomAccess = AccessOpen + } + + if (s.RoomAccess != AccessOpen && len(s.RoomAccessPin) == 0) || s.NewPin { + pin, err := s.generateNewPin() + if err != nil { + common.LogErrorf("Unable to generate new pin: %v", err) + } + common.LogInfof("New pin generated: %s", pin) + } + + // Don't use LogInfof() here. Log isn't setup yet when LoadSettings() is called from init(). + fmt.Printf("Settings reloaded. New admin password: %s\n", s.AdminPassword) + + if s.TitleLength <= 0 { + s.TitleLength = 50 + } + + // Is this a good way to do this? Probably not... + if len(s.SessionKey) == 0 { + out := "" + large := big.NewInt(int64(1 << 60)) + large = large.Add(large, large) + for len(out) < 50 { + num, err := rand.Int(rand.Reader, large) + if err != nil { + panic("Error generating session key: " + err.Error()) + } + out = fmt.Sprintf("%s%X", out, num) + } + s.SessionKey = out + } + + // Save admin password to file + if err = s.Save(); err != nil { + return nil, fmt.Errorf("Unable to save settings: %s", err) + } + + return s, nil +} + +func generatePass(seed int64) (string, error) { + out := "" + for len(out) < 20 { + num, err := rand.Int(rand.Reader, big.NewInt(int64(15))) + if err != nil { + return "", err + } + + out = fmt.Sprintf("%s%X", out, num) + } + return out, nil +} + +func (s *Settings) Save() error { + defer s.lock.Unlock() + s.lock.Lock() + + return s.unlockedSave() +} + +// unlockedSave expects the calling function to lock the RWMutex +func (s *Settings) unlockedSave() error { + marshaled, err := json.MarshalIndent(s, "", "\t") + if err != nil { + return fmt.Errorf("error marshaling: %s", err) + } + + err = ioutil.WriteFile(s.filename, marshaled, 0777) + if err != nil { + return fmt.Errorf("error saving: %s", err) + } + return nil +} + +func (s *Settings) AddBan(host string, names []string) error { + defer s.lock.Unlock() + s.lock.Lock() + + if host == "127.0.0.1" { + return fmt.Errorf("Cannot add a ban for localhost.") + } + + b := BanInfo{ + Names: names, + IP: host, + When: time.Now(), + } + s.Bans = append(s.Bans, b) + + common.LogInfof("[BAN] %q (%s) has been banned.\n", strings.Join(names, ", "), host) + + return s.unlockedSave() +} + +func (s *Settings) RemoveBan(name string) error { + defer s.lock.Unlock() + s.lock.Lock() + + name = strings.ToLower(name) + newBans := []BanInfo{} + for _, b := range s.Bans { + for _, n := range b.Names { + if n == name { + common.LogInfof("[ban] Removed ban for %s [%s]\n", b.IP, n) + } else { + newBans = append(newBans, b) + } + } + } + s.Bans = newBans + return s.unlockedSave() +} + +func (s *Settings) IsBanned(host string) (bool, []string) { + defer s.lock.RUnlock() + s.lock.RLock() + + for _, b := range s.Bans { + if b.IP == host { + return true, b.Names + } + } + return false, nil +} + +func (s *Settings) SetTempKey(key string) { + defer s.lock.Unlock() + s.lock.Lock() + + s.cmdLineKey = key +} + +func (s *Settings) GetStreamKey() string { + defer s.lock.RUnlock() + s.lock.RLock() + + if len(s.cmdLineKey) > 0 { + return s.cmdLineKey + } + return s.StreamKey +} + +func (s *Settings) generateNewPin() (string, error) { + defer s.lock.Unlock() + s.lock.Lock() + + num, err := rand.Int(rand.Reader, big.NewInt(int64(9999))) + if err != nil { + return "", err + } + s.RoomAccessPin = fmt.Sprintf("%04d", num) + if err = s.unlockedSave(); err != nil { + return "", err + } + return s.RoomAccessPin, nil +} + +func (s *Settings) AddApprovedEmotes(channels []string) error { + defer s.lock.Unlock() + s.lock.Lock() + + approved := map[string]int{} + for _, e := range s.ApprovedEmotes { + approved[e] = 1 + } + + for _, name := range channels { + approved[name] = 1 + } + + filtered := []string{} + for key, _ := range approved { + filtered = append(filtered, key) + } + + s.ApprovedEmotes = filtered + return s.unlockedSave() +} diff --git a/MovieNight/settings_example.json b/MovieNight/settings_example.json new file mode 100755 index 0000000..ea69bc9 --- /dev/null +++ b/MovieNight/settings_example.json @@ -0,0 +1,18 @@ +{ + "MaxMessageCount": 300, + "TitleLength": 50, + "AdminPassword": "", + "Bans": [], + "StreamKey": "ALongStreamKey", + "ListenAddress": ":8089", + "ApprovedEmotes": null, + "Bans": [], + "LogLevel": "debug", + "LogFile": "thelog.log", + "RateLimitChat": 1, + "RateLimitNick": 300, + "RateLimitColor": 60, + "RateLimitAuth": 5, + "RateLimitDuplicate": 30, + "NoCache": false +} diff --git a/MovieNight/static/base.html b/MovieNight/static/base.html new file mode 100755 index 0000000..262ff5a --- /dev/null +++ b/MovieNight/static/base.html @@ -0,0 +1,22 @@ + + + + + + {{ .Title }} + + + + + {{template "header" .}} + + + + +
+
+ {{template "body" .}} +
+ + + \ No newline at end of file diff --git a/MovieNight/static/css/hack/fonts/hack-bold-subset.woff b/MovieNight/static/css/hack/fonts/hack-bold-subset.woff new file mode 100755 index 0000000..a47c8aa Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bold-subset.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-bold-subset.woff2 b/MovieNight/static/css/hack/fonts/hack-bold-subset.woff2 new file mode 100755 index 0000000..93d425e Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bold-subset.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-bold.woff b/MovieNight/static/css/hack/fonts/hack-bold.woff new file mode 100755 index 0000000..368b913 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bold.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-bold.woff2 b/MovieNight/static/css/hack/fonts/hack-bold.woff2 new file mode 100755 index 0000000..1155477 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bold.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-bolditalic-subset.woff b/MovieNight/static/css/hack/fonts/hack-bolditalic-subset.woff new file mode 100755 index 0000000..0da4750 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bolditalic-subset.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-bolditalic-subset.woff2 b/MovieNight/static/css/hack/fonts/hack-bolditalic-subset.woff2 new file mode 100755 index 0000000..236b7de Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bolditalic-subset.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-bolditalic.woff b/MovieNight/static/css/hack/fonts/hack-bolditalic.woff new file mode 100755 index 0000000..ce87fe2 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bolditalic.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-bolditalic.woff2 b/MovieNight/static/css/hack/fonts/hack-bolditalic.woff2 new file mode 100755 index 0000000..46ff1c4 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-bolditalic.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-italic-subset.woff b/MovieNight/static/css/hack/fonts/hack-italic-subset.woff new file mode 100755 index 0000000..1d1f511 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-italic-subset.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-italic-subset.woff2 b/MovieNight/static/css/hack/fonts/hack-italic-subset.woff2 new file mode 100755 index 0000000..b6f5fc9 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-italic-subset.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-italic.woff b/MovieNight/static/css/hack/fonts/hack-italic.woff new file mode 100755 index 0000000..bd545e4 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-italic.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-italic.woff2 b/MovieNight/static/css/hack/fonts/hack-italic.woff2 new file mode 100755 index 0000000..1e7630c Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-italic.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-regular-subset.woff b/MovieNight/static/css/hack/fonts/hack-regular-subset.woff new file mode 100755 index 0000000..85583a5 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-regular-subset.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-regular-subset.woff2 b/MovieNight/static/css/hack/fonts/hack-regular-subset.woff2 new file mode 100755 index 0000000..1e3abb9 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-regular-subset.woff2 differ diff --git a/MovieNight/static/css/hack/fonts/hack-regular.woff b/MovieNight/static/css/hack/fonts/hack-regular.woff new file mode 100755 index 0000000..e835381 Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-regular.woff differ diff --git a/MovieNight/static/css/hack/fonts/hack-regular.woff2 b/MovieNight/static/css/hack/fonts/hack-regular.woff2 new file mode 100755 index 0000000..524465c Binary files /dev/null and b/MovieNight/static/css/hack/fonts/hack-regular.woff2 differ diff --git a/MovieNight/static/css/hack/hack-subset.css b/MovieNight/static/css/hack/hack-subset.css new file mode 100755 index 0000000..20ba245 --- /dev/null +++ b/MovieNight/static/css/hack/hack-subset.css @@ -0,0 +1,34 @@ +/*! + * Hack typeface https://github.com/source-foundry/Hack + * License: https://github.com/source-foundry/Hack/blob/master/LICENSE.md + */ +/* FONT PATHS + * -------------------------- */ +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-regular-subset.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-regular-subset.woff?sha=3114f1256') format('woff'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bold-subset.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-bold-subset.woff?sha=3114f1256') format('woff'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-italic-subset.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-italic-webfont.woff?sha=3114f1256') format('woff'); + font-weight: 400; + font-style: italic; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bolditalic-subset.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-bolditalic-subset.woff?sha=3114f1256') format('woff'); + font-weight: 700; + font-style: italic; +} + diff --git a/MovieNight/static/css/hack/hack-subset.css.in b/MovieNight/static/css/hack/hack-subset.css.in new file mode 100755 index 0000000..b8f3885 --- /dev/null +++ b/MovieNight/static/css/hack/hack-subset.css.in @@ -0,0 +1,34 @@ +/*! + * Hack typeface https://github.com/source-foundry/Hack + * License: https://github.com/source-foundry/Hack/blob/master/LICENSE.md + */ +/* FONT PATHS + * -------------------------- */ +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-regular-subset.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-regular-subset.woff?sha={{ ink }}') format('woff'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bold-subset.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-bold-subset.woff?sha={{ ink }}') format('woff'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-italic-subset.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-italic-webfont.woff?sha={{ ink }}') format('woff'); + font-weight: 400; + font-style: italic; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bolditalic-subset.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-bolditalic-subset.woff?sha={{ ink }}') format('woff'); + font-weight: 700; + font-style: italic; +} + diff --git a/MovieNight/static/css/hack/hack.css b/MovieNight/static/css/hack/hack.css new file mode 100755 index 0000000..df17027 --- /dev/null +++ b/MovieNight/static/css/hack/hack.css @@ -0,0 +1,34 @@ +/*! + * Hack typeface https://github.com/source-foundry/Hack + * License: https://github.com/source-foundry/Hack/blob/master/LICENSE.md + */ +/* FONT PATHS + * -------------------------- */ +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-regular.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-regular.woff?sha=3114f1256') format('woff'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bold.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-bold.woff?sha=3114f1256') format('woff'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-italic.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-italic.woff?sha=3114f1256') format('woff'); + font-weight: 400; + font-style: italic; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bolditalic.woff2?sha=3114f1256') format('woff2'), url('fonts/hack-bolditalic.woff?sha=3114f1256') format('woff'); + font-weight: 700; + font-style: italic; +} + diff --git a/MovieNight/static/css/hack/hack.css.in b/MovieNight/static/css/hack/hack.css.in new file mode 100755 index 0000000..8b9f2dd --- /dev/null +++ b/MovieNight/static/css/hack/hack.css.in @@ -0,0 +1,34 @@ +/*! + * Hack typeface https://github.com/source-foundry/Hack + * License: https://github.com/source-foundry/Hack/blob/master/LICENSE.md + */ +/* FONT PATHS + * -------------------------- */ +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-regular.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-regular.woff?sha={{ ink }}') format('woff'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bold.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-bold.woff?sha={{ ink }}') format('woff'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-italic.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-italic.woff?sha={{ ink }}') format('woff'); + font-weight: 400; + font-style: italic; +} + +@font-face { + font-family: 'Hack'; + src: url('fonts/hack-bolditalic.woff2?sha={{ ink }}') format('woff2'), url('fonts/hack-bolditalic.woff?sha={{ ink }}') format('woff'); + font-weight: 700; + font-style: italic; +} + diff --git a/MovieNight/static/css/site.css b/MovieNight/static/css/site.css new file mode 100755 index 0000000..67517b4 --- /dev/null +++ b/MovieNight/static/css/site.css @@ -0,0 +1,385 @@ +:root { + --var-border: 1px solid #606060; + --var-border-radius: 5px; + --var-message-color: #f4f4f4; + --var-link-color: #a9c8c3; + --var-contrast-color: #1bf7ec; + --var-background-color: #0F0F11; + --var-popout-color: #393940; + --var-max-height: 98vh; + --var-max-width: 98vw; +} + +html { + background: var(--var-background-color); + font-family: "Hack"; + font-size: 14px; +} + +dt { + font-weight: bold; +} + +body { + height: var(--var-max-height); + margin: 0px; +} + +span.name { + font-weight: bold; +} + +span.cmdme { + font-style: italic; +} + +span.msg { + font-style: normal; + color: #cfccd1; +} + +.msg a, .doornotice { + color: #cfccd1; +} + +span.svmsg { + font-style: italic; + color: #ea6260; +} + +input[type=text] { + background: transparent; + border: var(--var-border); + border-radius: var(--var-border-radius); + color: var(--var-message-color); + padding: 5px; + font-weight: bold; +} + +.root { + max-width: var(--var-max-width); + max-height: var(--var-max-height); + height: var(--var-max-height); + width: var(--var-max-width); + margin: 0px 1vw; +} + +.pretty-button { + padding: 5px 10px; +} + +.button { + background: #232328; + color: var(--var-message-color); + font-size: 16px; + font-weight: bold; + border: none; + border-radius: var(--var-border-radius); +} + +.button:hover { + background: #46464f; +} + +.scrollbar { + /* Firefox compatable scrollbar settings */ + scrollbar-color: #45009e transparent; + scrollbar-width: thin; +} + +.scrollbar::-webkit-scrollbar { + width: 6px; +} + +.scrollbar::-webkit-scrollbar-thumb { + background-color: #45009e; +} + +.announcement { + font-weight: bold; + color: #ea6260; + text-align: center; + margin-top: 10px; + margin-bottom: 10px; + border-top: 3px solid red; + border-bottom: 3px solid red; +} + +.helptext { + color: white; +} + +#emotesbody { + color: var(--var-message-color); +} + +.emotedef { + display: flex; + flex-direction: row; +} + +.emotedef div { + padding: 5px; +} + +.notice { + color: #595959; + font-size: 75%; +} + +.command { + color: #B1B1B1; +} + +.commanderror { + color: #e82222; +} + +.notice, +.command, +.announcement { + display: block; + text-align: center; +} + +.mention { + background: #1cf67ed9; + color: var(--var-background-color); + padding: 1px 2px; + border-radius: 4px; +} + +.contrast { + color: var(--var-contrast-color); +} + +.spoiler { + border-radius: 3px; + padding: 0px 3px; +} + +.spoiler *, +.spoiler { + background: var(--var-popout-color); + color: var(--var-popout-color); +} + +.spoiler-active { + background: var(--var-background-color); + color: aqua; +} + +.range-div { + margin-bottom: 5px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 5px; + padding: 5px; +} + +.range-div>input[type=button] { + flex: 2; +} + +.hiddendiv { + display: none; + color: var(--var-message-color); + background: var(--var-popout-color); + padding: 2em; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +} + +#videoElement { + position: relative; + top: 50%; + transform: translateY(-50%); + width: 100%; +} + +#loadingFiles { + display: flex; + align-items: center; + justify-content: center; +} + +#doorentry { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100%; +} + +#chatwindow { + display: none; + grid-template-rows: auto 1fr; + position: relative; + padding-top: 1vh; + max-height: calc(var(--var-max-height) - 1vh); + min-width: 300px; +} + +#hidden { + display: none; + position: absolute; + top: 0px; + left: 0px; + right: 0px; + margin: 0px 5px; + padding: 3px; + background: var(--var-popout-color); +} + +#hidden:hover, +#optionBox:hover~#hidden { + display: unset; +} + +#optionBox, +#notifyBox { + color: var(--var-contrast-color); + font-weight: bold; + font-size: 12px; + text-align: center; +} + +#accessRequest div { + margin-bottom: 5px; +} + +#colorInputDiv { + font-size: 14px; +} + +#colorInput { + border: 2px solid var(--var-message-color); +} + + +#playing { + color: #288a85; + font-size: x-Large; +} + +#chatButtons { + margin: 5px; +} + +#chatButtons button { + margin: 5px 0px; +} + +#joinbox { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 10px; +} + +#joinbox div { + margin: 5px auto; +} + +#joinbox a { + text-decoration: none; + color: var(--var-link-color); +} + +#joinbox a:hover { + text-decoration: underline; +} + +#chat { + display: grid; + grid-template-rows: 1.5em min-content 1fr 6em 2.5em 1em; + grid-gap: 10px; + margin: 0px 5px; + overflow: auto; + font-size: 12px; +} + +#messages { + min-height: 15em; + color: var(--var-message-color); + overflow-y: scroll; + border: var(--var-border); +} + +#messages div { + padding: .1em .5em .15em .5em; + word-wrap: break-word; +} + +#msgbox { + position: relative; + display: grid; +} + +#msg { + background: transparent; + border: var(--var-border); + border-radius: var(--var-border-radius); + border-bottom-right-radius: 0px; + padding: 5px; + color: var(--var-message-color); + resize: none; +} + +#suggestions { + background: #3b3b43; + position: absolute; + min-width: 10em; + max-height: 35em; + overflow-y: scroll; + border-radius: 5px 5px 0px 5px; + color: var(--var-message-color); +} + +#suggestions>div { + display: flex; + align-items: center; + padding: 5px; +} + +#suggestions>div>img { + margin-right: 1em; +} + +#suggestions div.selectedName { + color: var(--var-contrast-color); +} + +#helpbody { + color: #b1b1b1; +} + +#colorName { + font-weight: bold; + background: var(--var-background-color); + padding: -10px; +} + +#colorSubmit:disabled { + display: none; +} + +#remote { + display: none; + position: absolute; + left: 0; + right: 0; + top: 0; + margin: 1em auto; + width: 50px; + z-index: 99; +} + +#devKeys { + display: none; + position: absolute; + z-index: 99; + color: var(--var-contrast-color); +} diff --git a/MovieNight/static/emotes.html b/MovieNight/static/emotes.html new file mode 100755 index 0000000..d4d2f93 --- /dev/null +++ b/MovieNight/static/emotes.html @@ -0,0 +1,14 @@ +{{define "header"}} +{{end}} + +{{define "body"}} +
+

Available Emotes

+ {{range $k, $v := .Emotes}} +
+
+
{{$k}}
+
+ {{end}} +
+{{end}} diff --git a/MovieNight/static/emotes/.gitignore b/MovieNight/static/emotes/.gitignore new file mode 100755 index 0000000..127e8c5 --- /dev/null +++ b/MovieNight/static/emotes/.gitignore @@ -0,0 +1,3 @@ +# No images allowed, but keep the folder +*.gif +*.png diff --git a/MovieNight/static/help.html b/MovieNight/static/help.html new file mode 100755 index 0000000..83ecb99 --- /dev/null +++ b/MovieNight/static/help.html @@ -0,0 +1,32 @@ +{{define "header"}} +{{end}} + +{{define "body"}} +
+

Commands

+
+ {{range $k, $v := .Commands}} +
{{$k}}
+
{{$v}}
+ {{end}} +
+ {{if .ModCommands}} +

Moderator

+
+ {{range $k, $v := .ModCommands}} +
{{$k}}
+
{{$v}}
+ {{end}} +
+ {{end}} + {{if .AdminCommands}} +

Administrator

+
+ {{range $k, $v := .AdminCommands}} +
{{$k}}
+
{{$v}}
+ {{end}} +
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/MovieNight/static/img/admin.png b/MovieNight/static/img/admin.png new file mode 100755 index 0000000..6c642c2 Binary files /dev/null and b/MovieNight/static/img/admin.png differ diff --git a/MovieNight/static/img/mod.png b/MovieNight/static/img/mod.png new file mode 100755 index 0000000..bbd325a Binary files /dev/null and b/MovieNight/static/img/mod.png differ diff --git a/MovieNight/static/img/remote.png b/MovieNight/static/img/remote.png new file mode 100755 index 0000000..e82a53e Binary files /dev/null and b/MovieNight/static/img/remote.png differ diff --git a/MovieNight/static/img/remote_active.png b/MovieNight/static/img/remote_active.png new file mode 100755 index 0000000..6b47b10 Binary files /dev/null and b/MovieNight/static/img/remote_active.png differ diff --git a/MovieNight/static/js/both.js b/MovieNight/static/js/both.js new file mode 100755 index 0000000..66dd52b --- /dev/null +++ b/MovieNight/static/js/both.js @@ -0,0 +1,66 @@ +/// + +let konamiCode = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"] +let lastKeys = [] +let devKeys = false; + +// Make this on all pages so video page also doesn't do this +$(document).on("keydown", function (e) { + lastKeys.push(e); + if (lastKeys.length > 10) { + lastKeys.shift(); + } + + if (devKeys) { + let modifiedLastKeys = [] + lastKeys.forEach((e) => { + switch (e.key) { + case " ": + modifiedLastKeys.push(`Space - ${e.keyCode}`); + break; + default: + modifiedLastKeys.push(`${e.key} - ${e.keyCode}`); + break; + } + }) + $("#devKeys").html(`'${modifiedLastKeys.join("', '")}'`); + } + + if (e.which === 8 && !$(e.target).is("input, textarea")) { + e.preventDefault(); + } + + checkKonami(e); +}); + + +function checkKonami(e) { + if (lastKeys.length === konamiCode.length) { + for (let i = 0; i < lastKeys.length; i++) { + if (lastKeys[i].key != konamiCode[i]) { + return; + } + } + $("#remote").css("display", "block"); + } +} + +function flipRemote() { + $("#remote").attr("src", "/static/img/remote_active.png"); + setTimeout(() => { + $("#remote").attr("src", "/static/img/remote.png"); + }, Math.round(Math.random() * 10000) + 1000); +} + +function enableDebug() { + devKeys = true; + $("#devKeys").css("display", "block"); +} + +/* +// Just add a / above to uncomment the block +setTimeout(() => { + enableDebug(); + alert("Comment this out. It shows the keys."); +}, 150); +//*/ diff --git a/MovieNight/static/js/chat.js b/MovieNight/static/js/chat.js new file mode 100755 index 0000000..a038cb7 --- /dev/null +++ b/MovieNight/static/js/chat.js @@ -0,0 +1,288 @@ +/// + +function getCookie(cname) { + var name = cname + "="; + var decodedCookie = decodeURIComponent(document.cookie); + var ca = decodedCookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; +} + +function deleteCookie(cname) { + document.cookie = `${cname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT` +} + +function setPlaying(title, link) { + if (title !== "") { + $('#playing').text(title); + document.title = "Movie Night | " + title; + } else { + $('#playing').text(""); + document.title = "Movie Night"; + } + + $('#playing').removeAttr('href'); + if (link !== "") { + $('#playing').attr('href', link); + } +} + +function startGo() { + if (!WebAssembly.instantiateStreaming) { // polyfill + WebAssembly.instantiateStreaming = async (resp, importObject) => { + const source = await (await resp).arrayBuffer(); + return await WebAssembly.instantiate(source, importObject); + }; + } + + const go = new Go(); + WebAssembly.instantiateStreaming(fetch("/static/main.wasm"), go.importObject).then((result) => { + go.run(result.instance); + }).then(() => { + $("#chatwindow").css("display", "grid"); + $("#loadingFiles").css("display", "none"); + }).catch((err) => { + console.error(err); + }); +} + +function getWsUri() { + port = window.location.port; + if (port == "") { + if (window.location.protocol == "http:") + port = "80" + else + port = "443" + } + port = ":" + port; + proto = "ws://" + if (location.protocol == "https:") { + proto = "wss://" + } + return proto + window.location.hostname + port + "/ws"; +} + +let maxMessageCount = 0 +function appendMessages(msg) { + let msgs = $("#messages").find('div'); + + // let's just say that if the max count is less than 1, then the count is infinite + // the server side should take care of chaking max count ranges + if (msgs.length > maxMessageCount) { + msgs.first().remove(); + } + + $("#messages").append(msg); + $("#messages").children().last()[0].scrollIntoView({ block: "end" }); +} + +function purgeChat() { + $('#messages').empty() +} + +inChat = false +function openChat() { + console.log("chat opening"); + $("#joinbox").css("display", "none"); + $("#chat").css("display", "grid"); + $("#hidden").css("display", "") + $("#msg").val(""); + $("#msg").focus(); + inChat = true; +} + +function closeChat() { + console.log("chat closing"); + $("#joinbox").css("display", ""); + $("#chat").css("display", "none"); + $("#hidden").css("display", "none") + setNotifyBox("That name was already used!"); + inChat = false; +} + +function websocketSend(data) { + if (ws.readyState == ws.OPEN) { + ws.send(data); + } else { + console.log("did not send data because websocket is not open", data); + } +} + +function sendChat() { + sendMessage($("#msg").val()); + $("#msg").val(""); +} + +function updateSuggestionCss(m) { + if ($("#suggestions").children().length > 0) { + $("#suggestions").css("bottom", $("#msg").outerHeight(true) - 1 + "px"); + $("#suggestions").css("display", ""); + } else { + $("#suggestions").css("display", "none"); + } +} + +function updateSuggestionScroll() { + let item = $("#suggestions .selectedName"); + if (item.length !== 0) { + item[0].scrollIntoView({ block: "center" }); + } +} + +function setNotifyBox(msg = "") { + $("#notifyBox").html(msg); +} + +// Button Wrapper Functions +function auth() { + let pass = prompt("Enter pass"); + if (pass != "" && pass !== null) { + sendMessage("/auth " + pass); + } +} + +function nick() { + let nick = prompt("Enter new name"); + if (nick != "" && nick !== null) { + sendMessage("/nick " + nick); + } +} + +function help() { + sendMessage("/help"); +} + +function showColors(show) { + if (show === undefined) { + show = $("#hiddencolor").css("display") === "none"; + } + + $("#hiddencolor").css("display", show ? "block" : ""); +} + +function colorAsHex() { + let r = parseInt($("#colorRed").val()).toString(16).padStart(2, "0"); + let g = parseInt($("#colorGreen").val()).toString(16).padStart(2, "0"); + let b = parseInt($("#colorBlue").val()).toString(16).padStart(2, "0"); + return `#${r}${g}${b}` +} + +function updateColor() { + let r = $("#colorRed").val(); + let g = $("#colorGreen").val(); + let b = $("#colorBlue").val(); + + $("#colorRedLabel").text(r.padStart(3, "0")); + $("#colorGreenLabel").text(g.padStart(3, "0")); + $("#colorBlueLabel").text(b.padStart(3, "0")); + + $("#colorName").css("color", `rgb(${r}, ${g}, ${b})`); + + if (isValidColor(colorAsHex())) { + $("#colorWarning").text(""); + } else { + $("#colorWarning").text("Unreadable Color"); + } +} + +function changeColor() { + if (isValidColor(colorAsHex())) { + sendColor(colorAsHex()); + } +} + +function colorSelectChange() { + let val = $("#colorSelect").val() + if (val !== "") { + sendColor(val); + } +} + +function sendColor(color) { + sendMessage("/color " + color); + showColors(false); +} + +function setTimestamp(v) { + showTimestamp(v) + document.cookie = "timestamp=" + v + "; expires=Fri, 31 Dec 9999 23:59:59 GMT"; +} + +// Get the websocket setup in a function so it can be recalled +function setupWebSocket() { + ws = new WebSocket(getWsUri()); + ws.onmessage = (m) => recieveMessage(m.data); + ws.onopen = () => console.log("Websocket Open"); + ws.onclose = () => { + closeChat(); + setNotifyBox("The connection to the server has closed. Please refresh page to connect again."); + $("#joinbox").css("display", "none"); + } + ws.onerror = (e) => { + console.log("Websocket Error:", e); + e.target.close(); + } +} + +function setupEvents() { + $("#name").on({ + keypress: (e) => { + if (e.originalEvent.keyCode == 13) { + $("#join").trigger("click"); + } + } + }); + + $("#msg").on({ + keypress: (e) => { + if (e.originalEvent.keyCode == 13 && !e.originalEvent.shiftKey) { + $("#send").trigger("click"); + e.preventDefault(); + } + }, + keydown: (e) => { + if (processMessageKey(e)) { + e.preventDefault(); + } + }, + input: () => processMessage(), + }); + + $("#send").on({ + click: () => $("#msg").focus(), + }); + + var suggestionObserver = new MutationObserver( + (mutations) => mutations.forEach(updateSuggestionCss) + ).observe($("#suggestions")[0], { childList: true }); +} + +function defaultValues() { + setTimeout(() => { + let timestamp = getCookie("timestamp") + if (timestamp !== "") { + showTimestamp(timestamp === "true") + } + }, 500); +} + +window.addEventListener("onresize", updateSuggestionCss); + +window.addEventListener("load", () => { + setNotifyBox(); + setupWebSocket(); + startGo(); + setupEvents(); + defaultValues(); + + // Make sure name is focused on start + $("#name").focus(); +}); diff --git a/MovieNight/static/js/flv.min.js b/MovieNight/static/js/flv.min.js new file mode 100755 index 0000000..f52f966 --- /dev/null +++ b/MovieNight/static/js/flv.min.js @@ -0,0 +1,6 @@ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var t;t="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,t.flvjs=e()}}(function(){var e;return function e(t,n,i){function r(a,o){if(!n[a]){if(!t[a]){var u="function"==typeof require&&require;if(!o&&u)return u(a,!0);if(s)return s(a,!0);var l=new Error("Cannot find module '"+a+"'");throw l.code="MODULE_NOT_FOUND",l}var d=n[a]={exports:{}};t[a][0].call(d.exports,function(e){var n=t[a][1][e];return r(n||e)},d,d.exports,e,t,n,i)}return n[a].exports}for(var s="function"==typeof require&&require,a=0;a0&&this._events[e].length>n&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),"function"==typeof console.trace&&console.trace()),this},i.prototype.on=i.prototype.addListener,i.prototype.once=function(e,t){function n(){this.removeListener(e,n),i||(i=!0,t.apply(this,arguments))}if(!r(t))throw TypeError("listener must be a function");var i=!1;return n.listener=t,this.on(e,n),this},i.prototype.removeListener=function(e,t){var n,i,s,o;if(!r(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;if(n=this._events[e],s=n.length,i=-1,n===t||r(n.listener)&&n.listener===t)delete this._events[e],this._events.removeListener&&this.emit("removeListener",e,t);else if(a(n)){for(o=s;o-- >0;)if(n[o]===t||n[o].listener&&n[o].listener===t){i=o;break}if(i<0)return this;1===n.length?(n.length=0,delete this._events[e]):n.splice(i,1),this._events.removeListener&&this.emit("removeListener",e,t)}return this},i.prototype.removeAllListeners=function(e){var t,n;if(!this._events)return this;if(!this._events.removeListener)return 0===arguments.length?this._events={}:this._events[e]&&delete this._events[e],this;if(0===arguments.length){for(t in this._events)"removeListener"!==t&&this.removeAllListeners(t);return this.removeAllListeners("removeListener"),this._events={},this}if(n=this._events[e],r(n))this.removeListener(e,n);else if(n)for(;n.length;)this.removeListener(e,n[n.length-1]);return delete this._events[e],this},i.prototype.listeners=function(e){return this._events&&this._events[e]?r(this._events[e])?[this._events[e]]:this._events[e].slice():[]},i.prototype.listenerCount=function(e){if(this._events){var t=this._events[e];if(r(t))return 1;if(t)return t.length}return 0},i.listenerCount=function(e,t){return e.listenerCount(t)}},{}],3:[function(e,t,n){function i(){throw new Error("setTimeout has not been defined")}function r(){throw new Error("clearTimeout has not been defined")}function s(e){if(h===setTimeout)return setTimeout(e,0);if((h===i||!h)&&setTimeout)return h=setTimeout,setTimeout(e,0);try{return h(e,0)}catch(t){try{return h.call(null,e,0)}catch(t){return h.call(this,e,0)}}}function a(e){if(f===clearTimeout)return clearTimeout(e);if((f===r||!f)&&clearTimeout)return f=clearTimeout,clearTimeout(e);try{return f(e)}catch(t){try{return f.call(null,e)}catch(t){return f.call(this,e)}}}function o(){p&&_&&(p=!1,_.length?m=_.concat(m):v=-1,m.length&&u())}function u(){if(!p){var e=s(o);p=!0;for(var t=m.length;t;){for(_=m,m=[];++v1)for(var n=1;n=e[r]&&t0&&e[0].originalDts=t[r].dts&&et[i].lastSample.originalDts&&e=t[i].lastSample.originalDts&&(i===t.length-1||i0&&(r=this._searchNearestSegmentBefore(n.originalBeginDts)+1),this._lastAppendLocation=r,this._list.splice(r,0,n)}},{key:"getLastSegmentBefore",value:function(e){var t=this._searchNearestSegmentBefore(e);return t>=0?this._list[t]:null}},{key:"getLastSampleBefore",value:function(e){var t=this.getLastSegmentBefore(e);return null!=t?t.lastSample:null}},{key:"getLastSyncPointBefore",value:function(e){for(var t=this._searchNearestSegmentBefore(e),n=this._list[t].syncPoints;0===n.length&&t>0;)t--,n=this._list[t].syncPoints;return n.length>0?n[n.length-1]:null}},{key:"type",get:function(){return this._type}},{key:"length",get:function(){return this._list.length}}]),e}()},{}],9:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var s=function(){function e(e,t){for(var n=0;n0&&(i+=";codecs="+n.codec);var r=!1;if(l.default.v(this.TAG,"Received Initialization Segment, mimeType: "+i),this._lastInitSegments[n.type]=n,i!==this._mimeTypes[n.type]){if(this._mimeTypes[n.type])l.default.v(this.TAG,"Notice: "+n.type+" mimeType changed, origin: "+this._mimeTypes[n.type]+", target: "+i);else{r=!0;try{var s=this._sourceBuffers[n.type]=this._mediaSource.addSourceBuffer(i);s.addEventListener("error",this.e.onSourceBufferError),s.addEventListener("updateend",this.e.onSourceBufferUpdateEnd)}catch(e){return l.default.e(this.TAG,e.message),void this._emitter.emit(c.default.ERROR,{code:e.code,msg:e.message})}}this._mimeTypes[n.type]=i}t||this._pendingSegments[n.type].push(n),r||this._sourceBuffers[n.type]&&!this._sourceBuffers[n.type].updating&&this._doAppendSegments(),h.default.safari&&"audio/mpeg"===n.container&&n.mediaDuration>0&&(this._requireSetMediaDuration=!0,this._pendingMediaDuration=n.mediaDuration/1e3,this._updateMediaSourceDuration())}},{key:"appendMediaSegment",value:function(e){var t=e;this._pendingSegments[t.type].push(t),this._config.autoCleanupSourceBuffer&&this._needCleanupSourceBuffer()&&this._doCleanupSourceBuffer();var n=this._sourceBuffers[t.type];!n||n.updating||this._hasPendingRemoveRanges()||this._doAppendSegments()}},{key:"seek",value:function(e){for(var t in this._sourceBuffers)if(this._sourceBuffers[t]){var n=this._sourceBuffers[t];if("open"===this._mediaSource.readyState)try{n.abort()}catch(e){l.default.e(this.TAG,e.message)}this._idrList.clear();var i=this._pendingSegments[t];if(i.splice(0,i.length),"closed"!==this._mediaSource.readyState){for(var r=0;r=1&&e-i.start(0)>=this._config.autoCleanupMaxBackwardDuration)return!0}}return!1}},{key:"_doCleanupSourceBuffer",value:function(){var e=this._mediaElement.currentTime;for(var t in this._sourceBuffers){var n=this._sourceBuffers[t];if(n){for(var i=n.buffered,r=!1,s=0;s=this._config.autoCleanupMaxBackwardDuration){r=!0;var u=e-this._config.autoCleanupMinBackwardDuration;this._pendingRemoveRanges[t].push({start:a,end:u})}}else o0&&(isNaN(t)||n>t)&&(l.default.v(this.TAG,"Update MediaSource duration from "+t+" to "+n),this._mediaSource.duration=n),this._requireSetMediaDuration=!1,this._pendingMediaDuration=0}}},{key:"_doRemoveRanges",value:function(){for(var e in this._pendingRemoveRanges)if(this._sourceBuffers[e]&&!this._sourceBuffers[e].updating)for(var t=this._sourceBuffers[e],n=this._pendingRemoveRanges[e];n.length&&!t.updating;){var i=n.shift();t.remove(i.start,i.end)}}},{key:"_doAppendSegments",value:function(){var e=this._pendingSegments;for(var t in e)if(this._sourceBuffers[t]&&!this._sourceBuffers[t].updating&&e[t].length>0){var n=e[t].shift();if(n.timestampOffset){var i=this._sourceBuffers[t].timestampOffset,r=n.timestampOffset/1e3,s=Math.abs(i-r);s>.1&&(l.default.v(this.TAG,"Update MPEG audio timestampOffset from "+i+" to "+r),this._sourceBuffers[t].timestampOffset=r),delete n.timestampOffset}if(!n.data||0===n.data.byteLength)continue;try{this._sourceBuffers[t].appendBuffer(n.data),this._isBufferFull=!1,"video"===t&&n.hasOwnProperty("info")&&this._idrList.appendArray(n.info.syncPoints)}catch(e){this._pendingSegments[t].unshift(n),22===e.code?(this._isBufferFull||this._emitter.emit(c.default.BUFFER_FULL),this._isBufferFull=!0):(l.default.e(this.TAG,e.message),this._emitter.emit(c.default.ERROR,{code:e.code,msg:e.message}))}}}},{key:"_onSourceOpen",value:function(){if(l.default.v(this.TAG,"MediaSource onSourceOpen"),this._mediaSource.removeEventListener("sourceopen",this.e.onSourceOpen),this._pendingSourceBufferInit.length>0)for(var e=this._pendingSourceBufferInit;e.length;){var t=e.shift();this.appendInitSegment(t,!0)}this._hasPendingSegments()&&this._doAppendSegments(),this._emitter.emit(c.default.SOURCE_OPEN)}},{key:"_onSourceEnded",value:function(){l.default.v(this.TAG,"MediaSource onSourceEnded")}},{key:"_onSourceClose",value:function(){l.default.v(this.TAG,"MediaSource onSourceClose"),this._mediaSource&&null!=this.e&&(this._mediaSource.removeEventListener("sourceopen",this.e.onSourceOpen),this._mediaSource.removeEventListener("sourceended",this.e.onSourceEnded),this._mediaSource.removeEventListener("sourceclose",this.e.onSourceClose))}},{key:"_hasPendingSegments",value:function(){var e=this._pendingSegments;return e.video.length>0||e.audio.length>0}},{key:"_hasPendingRemoveRanges",value:function(){var e=this._pendingRemoveRanges;return e.video.length>0||e.audio.length>0}},{key:"_onSourceBufferUpdateEnd",value:function(){this._requireSetMediaDuration?this._updateMediaSourceDuration():this._hasPendingRemoveRanges()?this._doRemoveRanges():this._hasPendingSegments()?this._doAppendSegments():this._hasPendingEos&&this.endOfStream(),this._emitter.emit(c.default.UPDATE_END)}},{key:"_onSourceBufferError",value:function(e){l.default.e(this.TAG,"SourceBuffer Error: "+e)}}]),e}();n.default=p},{"../utils/browser.js":39,"../utils/exception.js":40,"../utils/logger.js":41,"./media-segment-info.js":8,"./mse-events.js":10,events:2}],10:[function(e,t,n){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var i={ERROR:"error",SOURCE_OPEN:"source_open",UPDATE_END:"update_end",BUFFER_FULL:"buffer_full"};n.default=i},{}],11:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var s=function(){function e(e,t){for(var n=0;n0)this._demuxer.bindDataSource(this._ioctl),this._demuxer.timestampBase=this._mediaDataSource.segments[this._currentSegmentIndex].timestampBase,r=this._demuxer.parseChunks(e,t);else if((i=m.default.probe(e)).match){this._demuxer=new m.default(i,this._config),this._remuxer||(this._remuxer=new v.default(this._config));var s=this._mediaDataSource;void 0==s.duration||isNaN(s.duration)||(this._demuxer.overridedDuration=s.duration),"boolean"==typeof s.hasAudio&&(this._demuxer.overridedHasAudio=s.hasAudio),"boolean"==typeof s.hasVideo&&(this._demuxer.overridedHasVideo=s.hasVideo),this._demuxer.timestampBase=s.segments[this._currentSegmentIndex].timestampBase,this._demuxer.onError=this._onDemuxException.bind(this),this._demuxer.onMediaInfo=this._onMediaInfo.bind(this),this._remuxer.bindDataSource(this._demuxer.bindDataSource(this._ioctl)),this._remuxer.onInitSegment=this._onRemuxerInitSegmentArrival.bind(this),this._remuxer.onMediaSegment=this._onRemuxerMediaSegmentArrival.bind(this),r=this._demuxer.parseChunks(e,t)}else i=null,l.default.e(this.TAG,"Non-FLV, Unsupported media type!"),Promise.resolve().then(function(){n._internalAbort()}),this._emitter.emit(k.default.DEMUX_ERROR,y.default.FORMAT_UNSUPPORTED,"Non-FLV, Unsupported media type"),r=0;return r}},{key:"_onMediaInfo",value:function(e){var t=this;null==this._mediaInfo&&(this._mediaInfo=Object.assign({},e),this._mediaInfo.keyframesIndex=null,this._mediaInfo.segments=[],this._mediaInfo.segmentCount=this._mediaDataSource.segments.length,Object.setPrototypeOf(this._mediaInfo,c.default.prototype));var n=Object.assign({},e);Object.setPrototypeOf(n,c.default.prototype),this._mediaInfo.segments[this._currentSegmentIndex]=n,this._reportSegmentMediaInfo(this._currentSegmentIndex),null!=this._pendingSeekTime&&Promise.resolve().then(function(){var e=t._pendingSeekTime;t._pendingSeekTime=null,t.seek(e)})}},{key:"_onIOSeeked",value:function(){this._remuxer.insertDiscontinuity()}},{key:"_onIOComplete",value:function(e){var t=e,n=t+1;n0&&n[0].originalDts===i&&(i=n[0].pts),this._emitter.emit(k.default.RECOMMEND_SEEKPOINT,i)}}},{key:"_enableStatisticsReporter",value:function(){null==this._statisticsReporter&&(this._statisticsReporter=self.setInterval(this._reportStatisticsInfo.bind(this),this._config.statisticsInfoReportInterval))}},{key:"_disableStatisticsReporter",value:function(){this._statisticsReporter&&(self.clearInterval(this._statisticsReporter),this._statisticsReporter=null)}},{key:"_reportSegmentMediaInfo",value:function(e){var t=this._mediaInfo.segments[e],n=Object.assign({},t);n.duration=this._mediaInfo.duration,n.segmentCount=this._mediaInfo.segmentCount,delete n.segments,delete n.keyframesIndex,this._emitter.emit(k.default.MEDIA_INFO,n)}},{key:"_reportStatisticsInfo",value:function(){var e={};e.url=this._ioctl.currentURL,e.hasRedirect=this._ioctl.hasRedirect,e.hasRedirect&&(e.redirectedURL=this._ioctl.currentRedirectedURL),e.speed=this._ioctl.currentSpeed,e.loaderType=this._ioctl.loaderType,e.currentSegmentIndex=this._currentSegmentIndex,e.totalSegmentCount=this._mediaDataSource.segments.length,this._emitter.emit(k.default.STATISTICS_INFO,e)}}]),e}());n.default=L},{"../demux/demux-errors.js":16,"../demux/flv-demuxer.js":18,"../io/io-controller.js":23,"../io/loader.js":24,"../remux/mp4-remuxer.js":38,"../utils/browser.js":39,"../utils/logger.js":41,"./media-info.js":7,"./transmuxing-events.js":13,events:2}],13:[function(e,t,n){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var i={IO_ERROR:"io_error",DEMUX_ERROR:"demux_error",INIT_SEGMENT:"init_segment",MEDIA_SEGMENT:"media_segment",LOADING_COMPLETE:"loading_complete",RECOVERED_EARLY_EOF:"recovered_early_eof",MEDIA_INFO:"media_info",STATISTICS_INFO:"statistics_info",RECOMMEND_SEEKPOINT:"recommend_seekpoint"};n.default=i},{}],14:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(n,"__esModule",{value:!0});var r=e("../utils/logger.js"),s=(i(r),e("../utils/logging-control.js")),a=i(s),o=e("../utils/polyfill.js"),u=i(o),l=e("./transmuxing-controller.js"),d=i(l),h=e("./transmuxing-events.js"),f=i(h),c=function(e){function t(t,n){var i={msg:f.default.INIT_SEGMENT,data:{type:t,data:n}};e.postMessage(i,[n.data])}function n(t,n){var i={msg:f.default.MEDIA_SEGMENT,data:{type:t,data:n}};e.postMessage(i,[n.data])}function i(){var t={msg:f.default.LOADING_COMPLETE};e.postMessage(t)}function r(){var t={msg:f.default.RECOVERED_EARLY_EOF};e.postMessage(t)}function s(t){var n={msg:f.default.MEDIA_INFO,data:t};e.postMessage(n)}function o(t){var n={msg:f.default.STATISTICS_INFO,data:t};e.postMessage(n)}function l(t,n){e.postMessage({msg:f.default.IO_ERROR,data:{type:t,info:n}})}function h(t,n){e.postMessage({msg:f.default.DEMUX_ERROR,data:{type:t,info:n}})}function c(t){e.postMessage({msg:f.default.RECOMMEND_SEEKPOINT,data:t})}function _(t,n){e.postMessage({msg:"logcat_callback",data:{type:t,logcat:n}})}var m=null,p=_.bind(this);u.default.install(),e.addEventListener("message",function(u){switch(u.data.cmd){case"init":m=new d.default(u.data.param[0],u.data.param[1]),m.on(f.default.IO_ERROR,l.bind(this)),m.on(f.default.DEMUX_ERROR,h.bind(this)),m.on(f.default.INIT_SEGMENT,t.bind(this)),m.on(f.default.MEDIA_SEGMENT,n.bind(this)),m.on(f.default.LOADING_COMPLETE,i.bind(this)),m.on(f.default.RECOVERED_EARLY_EOF,r.bind(this)),m.on(f.default.MEDIA_INFO,s.bind(this)),m.on(f.default.STATISTICS_INFO,o.bind(this)),m.on(f.default.RECOMMEND_SEEKPOINT,c.bind(this));break;case"destroy":m&&(m.destroy(),m=null),e.postMessage({msg:"destroyed"});break;case"start":m.start();break;case"stop":m.stop();break;case"seek":m.seek(u.data.param);break;case"pause":m.pause();break;case"resume":m.resume();break;case"logging_config":var _=u.data.param;a.default.applyConfig(_),!0===_.enableCallback?a.default.addLogListener(p):a.default.removeLogListener(p)}})};n.default=c},{"../utils/logger.js":41,"../utils/logging-control.js":42,"../utils/polyfill.js":43,"./transmuxing-controller.js":12,"./transmuxing-events.js":13}],15:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var s=function(){function e(e,t){for(var n=0;n0?(0,l.default)(new Uint8Array(e,t+2,r)):"",{data:s,size:2+r}}},{key:"parseLongString",value:function(e,t,n){if(n<4)throw new d.IllegalStateException("Data not enough when parse LongString");var i=new DataView(e,t,n),r=i.getUint32(0,!h),s=void 0;return s=r>0?(0,l.default)(new Uint8Array(e,t+4,r)):"",{data:s,size:4+r}}},{key:"parseDate",value:function(e,t,n){if(n<10)throw new d.IllegalStateException("Data size invalid when parse Date");var i=new DataView(e,t,n),r=i.getFloat64(0,!h);return r+=60*i.getInt16(8,!h)*1e3,{data:new Date(r),size:10}}},{key:"parseValue",value:function(t,n,i){if(i<1)throw new d.IllegalStateException("Data not enough when parse Value");var r=new DataView(t,n,i),s=1,a=r.getUint8(0),u=void 0,l=!1;try{switch(a){case 0:u=r.getFloat64(1,!h),s+=8;break;case 1:u=!!r.getUint8(1),s+=1;break;case 2:var f=e.parseString(t,n+1,i-1);u=f.data,s+=f.size;break;case 3:u={};var c=0;for(9==(16777215&r.getUint32(i-4,!h))&&(c=3);s32)throw new s.InvalidArgumentException("ExpGolomb: readBits() bits exceeded max 32bits!");if(e<=this._current_word_bits_left){var t=this._current_word>>>32-e;return this._current_word<<=e,this._current_word_bits_left-=e,t}var n=this._current_word_bits_left?this._current_word:0;n>>>=32-this._current_word_bits_left;var i=e-this._current_word_bits_left;this._fillCurrentWord();var r=Math.min(i,this._current_word_bits_left),a=this._current_word>>>32-r;return this._current_word<<=r,this._current_word_bits_left-=r,n=n<>>e))return this._current_word<<=e,this._current_word_bits_left-=e,e;return this._fillCurrentWord(),e+this._skipLeadingZero()}},{key:"readUEG",value:function(){var e=this._skipLeadingZero();return this.readBits(e+1)-1}},{key:"readSEG",value:function(){var e=this.readUEG();return 1&e?e+1>>>1:-1*(e>>>1)}}]),e}();n.default=a},{"../utils/exception.js":40}],18:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function s(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]}Object.defineProperty(n,"__esModule",{value:!0});var a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},o=function(){function e(e,t){for(var n=0;n13))return 0;i=e.probe(t).dataOffset}if(this._firstParse){this._firstParse=!1,n+i!==this._dataOffset&&l.default.w(this.TAG,"First time parsing but chunk byteStart invalid!");0!==new DataView(t,i).getUint32(0,!r)&&l.default.w(this.TAG,"PrevTagSize0 !== 0 !!!"),i+=4}for(;it.byteLength)break;var a=s.getUint8(0),o=16777215&s.getUint32(0,!r);if(i+11+o+4>t.byteLength)break;if(8===a||9===a||18===a){var u=s.getUint8(4),d=s.getUint8(5),h=s.getUint8(6),f=s.getUint8(7),c=h|d<<8|u<<16|f<<24;0!==(16777215&s.getUint32(7,!r))&&l.default.w(this.TAG,"Meet tag which has StreamID != 0!");var _=i+11;switch(a){case 8:this._parseAudioData(t,_,o,c);break;case 9:this._parseVideoData(t,_,o,c,n+i);break;case 18:this._parseScriptData(t,_,o)}var m=s.getUint32(11+o,!r);m!==11+o&&l.default.w(this.TAG,"Invalid PrevTagSize "+m),i+=11+o+4}else l.default.w(this.TAG,"Unsupported tag type "+a+", skipped"),i+=11+o+4}return this._isInitialMetadataDispatched()&&this._dispatch&&(this._audioTrack.length||this._videoTrack.length)&&this._onDataAvailable(this._audioTrack,this._videoTrack),i}},{key:"_parseScriptData",value:function(e,t,n){var i=h.default.parseScriptData(e,t,n);if(i.hasOwnProperty("onMetaData")){if(null==i.onMetaData||"object"!==a(i.onMetaData))return void l.default.w(this.TAG,"Invalid onMetaData structure!");this._metadata&&l.default.w(this.TAG,"Found another onMetaData tag!"),this._metadata=i;var r=this._metadata.onMetaData;if("boolean"==typeof r.hasAudio&&!1===this._hasAudioFlagOverrided&&(this._hasAudio=r.hasAudio,this._mediaInfo.hasAudio=this._hasAudio),"boolean"==typeof r.hasVideo&&!1===this._hasVideoFlagOverrided&&(this._hasVideo=r.hasVideo,this._mediaInfo.hasVideo=this._hasVideo),"number"==typeof r.audiodatarate&&(this._mediaInfo.audioDataRate=r.audiodatarate),"number"==typeof r.videodatarate&&(this._mediaInfo.videoDataRate=r.videodatarate),"number"==typeof r.width&&(this._mediaInfo.width=r.width),"number"==typeof r.height&&(this._mediaInfo.height=r.height),"number"==typeof r.duration){if(!this._durationOverrided){var s=Math.floor(r.duration*this._timescale);this._duration=s,this._mediaInfo.duration=s}}else this._mediaInfo.duration=0;if("number"==typeof r.framerate){var o=Math.floor(1e3*r.framerate);if(o>0){var u=o/1e3;this._referenceFrameRate.fixed=!0,this._referenceFrameRate.fps=u,this._referenceFrameRate.fps_num=o,this._referenceFrameRate.fps_den=1e3,this._mediaInfo.fps=u}}if("object"===a(r.keyframes)){this._mediaInfo.hasKeyframesIndex=!0;var d=r.keyframes;this._mediaInfo.keyframesIndex=this._parseKeyframesIndex(d),r.keyframes=null}else this._mediaInfo.hasKeyframesIndex=!1;this._dispatch=!1,this._mediaInfo.metadata=r,l.default.v(this.TAG,"Parsed onMetaData"),this._mediaInfo.isComplete()&&this._onMediaInfo(this._mediaInfo)}}},{key:"_parseKeyframesIndex",value:function(e){for(var t=[],n=[],i=1;i>>4;if(2!==a&&10!==a)return void this._onError(m.default.CODEC_UNSUPPORTED,"Flv: Unsupported audio codec idx: "+a);var o=0,u=(12&s)>>>2;if(!(u>=0&&u<=4))return void this._onError(m.default.FORMAT_ERROR,"Flv: Invalid audio sample rate idx: "+u);o=this._flvSoundRateTable[u];var d=1&s,h=this._audioMetadata,f=this._audioTrack;if(h||(!1===this._hasAudio&&!1===this._hasAudioFlagOverrided&&(this._hasAudio=!0,this._mediaInfo.hasAudio=!0),h=this._audioMetadata={},h.type="audio",h.id=f.id,h.timescale=this._timescale,h.duration=this._duration,h.audioSampleRate=o,h.channelCount=0===d?1:2),10===a){var c=this._parseAACAudioData(e,t+1,n-1);if(void 0==c)return;if(0===c.packetType){h.config&&l.default.w(this.TAG,"Found another AudioSpecificConfig!");var _=c.data;h.audioSampleRate=_.samplingRate,h.channelCount=_.channelCount,h.codec=_.codec,h.originalCodec=_.originalCodec,h.config=_.config,h.refSampleDuration=1024/h.audioSampleRate*h.timescale,l.default.v(this.TAG,"Parsed AudioSpecificConfig"),this._isInitialMetadataDispatched()?this._dispatch&&(this._audioTrack.length||this._videoTrack.length)&&this._onDataAvailable(this._audioTrack,this._videoTrack):this._audioInitialMetadataDispatched=!0,this._dispatch=!1,this._onTrackMetadata("audio",h);var p=this._mediaInfo;p.audioCodec=h.originalCodec,p.audioSampleRate=h.audioSampleRate,p.audioChannelCount=h.channelCount,p.hasVideo?null!=p.videoCodec&&(p.mimeType='video/x-flv; codecs="'+p.videoCodec+","+p.audioCodec+'"'):p.mimeType='video/x-flv; codecs="'+p.audioCodec+'"',p.isComplete()&&this._onMediaInfo(p)}else if(1===c.packetType){var v=this._timestampBase+i,g={unit:c.data,dts:v,pts:v};f.samples.push(g),f.length+=c.data.length}else l.default.e(this.TAG,"Flv: Unsupported AAC data type "+c.packetType)}else if(2===a){if(!h.codec){var y=this._parseMP3AudioData(e,t+1,n-1,!0);if(void 0==y)return;h.audioSampleRate=y.samplingRate,h.channelCount=y.channelCount,h.codec=y.codec,h.originalCodec=y.originalCodec,h.refSampleDuration=1152/h.audioSampleRate*h.timescale,l.default.v(this.TAG,"Parsed MPEG Audio Frame Header"),this._audioInitialMetadataDispatched=!0,this._onTrackMetadata("audio",h);var E=this._mediaInfo;E.audioCodec=h.codec,E.audioSampleRate=h.audioSampleRate,E.audioChannelCount=h.channelCount,E.audioDataRate=y.bitRate,E.hasVideo?null!=E.videoCodec&&(E.mimeType='video/x-flv; codecs="'+E.videoCodec+","+E.audioCodec+'"'):E.mimeType='video/x-flv; codecs="'+E.audioCodec+'"',E.isComplete()&&this._onMediaInfo(E)}var b=this._parseMP3AudioData(e,t+1,n-1,!1);if(void 0==b)return;var S=this._timestampBase+i,k={unit:b,dts:S,pts:S};f.samples.push(k),f.length+=b.length}}}},{ +key:"_parseAACAudioData",value:function(e,t,n){if(n<=1)return void l.default.w(this.TAG,"Flv: Invalid AAC packet, missing AACPacketType or/and Data!");var i={},r=new Uint8Array(e,t,n);return i.packetType=r[0],0===r[0]?i.data=this._parseAACAudioSpecificConfig(e,t+1,n-1):i.data=r.subarray(1),i}},{key:"_parseAACAudioSpecificConfig",value:function(e,t,n){var i=new Uint8Array(e,t,n),r=null,s=0,a=0,o=0,u=null;if(s=a=i[0]>>>3,(o=(7&i[0])<<1|i[1]>>>7)<0||o>=this._mpegSamplingRates.length)return void this._onError(m.default.FORMAT_ERROR,"Flv: AAC invalid sampling frequency index!");var l=this._mpegSamplingRates[o],d=(120&i[1])>>>3;if(d<0||d>=8)return void this._onError(m.default.FORMAT_ERROR,"Flv: AAC invalid channel configuration");5===s&&(u=(7&i[1])<<1|i[2]>>>7,i[2]);var h=self.navigator.userAgent.toLowerCase();return-1!==h.indexOf("firefox")?o>=6?(s=5,r=new Array(4),u=o-3):(s=2,r=new Array(2),u=o):-1!==h.indexOf("android")?(s=2,r=new Array(2),u=o):(s=5,u=o,r=new Array(4),o>=6?u=o-3:1===d&&(s=2,r=new Array(2),u=o)),r[0]=s<<3,r[0]|=(15&o)>>>1,r[1]=(15&o)<<7,r[1]|=(15&d)<<3,5===s&&(r[1]|=(15&u)>>>1,r[2]=(1&u)<<7,r[2]|=8,r[3]=0),{config:r,samplingRate:l,channelCount:d,codec:"mp4a.40."+s,originalCodec:"mp4a.40."+a}}},{key:"_parseMP3AudioData",value:function(e,t,n,i){if(n<4)return void l.default.w(this.TAG,"Flv: Invalid MP3 packet, header missing!");var r=(this._littleEndian,new Uint8Array(e,t,n)),s=null;if(i){if(255!==r[0])return;var a=r[1]>>>3&3,o=(6&r[1])>>1,u=(240&r[2])>>>4,d=(12&r[2])>>>2,h=r[3]>>>6&3,f=3!==h?2:1,c=0,_=0;switch(a){case 0:c=this._mpegAudioV25SampleRateTable[d];break;case 2:c=this._mpegAudioV20SampleRateTable[d];break;case 3:c=this._mpegAudioV10SampleRateTable[d]}switch(o){case 1:34,u>>4,o=15&s;if(7!==o)return void this._onError(m.default.CODEC_UNSUPPORTED,"Flv: Unsupported codec in video frame: "+o);this._parseAVCVideoPacket(e,t+1,n-1,i,r,a)}}},{key:"_parseAVCVideoPacket",value:function(e,t,n,i,r,s){if(n<4)return void l.default.w(this.TAG,"Flv: Invalid AVC packet, missing AVCPacketType or/and CompositionTime");var a=this._littleEndian,o=new DataView(e,t,n),u=o.getUint8(0),d=16777215&o.getUint32(0,!a);if(0===u)this._parseAVCDecoderConfigurationRecord(e,t+4,n-4);else if(1===u)this._parseAVCVideoData(e,t+4,n-4,i,r,s,d);else if(2!==u)return void this._onError(m.default.FORMAT_ERROR,"Flv: Invalid video packet type "+u)}},{key:"_parseAVCDecoderConfigurationRecord",value:function(e,t,n){if(n<7)return void l.default.w(this.TAG,"Flv: Invalid AVCDecoderConfigurationRecord, lack of data!");var i=this._videoMetadata,r=this._videoTrack,s=this._littleEndian,a=new DataView(e,t,n);i?void 0!==i.avcc&&l.default.w(this.TAG,"Found another AVCDecoderConfigurationRecord!"):(!1===this._hasVideo&&!1===this._hasVideoFlagOverrided&&(this._hasVideo=!0,this._mediaInfo.hasVideo=!0),i=this._videoMetadata={},i.type="video",i.id=r.id,i.timescale=this._timescale,i.duration=this._duration);var o=a.getUint8(0),u=a.getUint8(1);a.getUint8(2),a.getUint8(3);if(1!==o||0===u)return void this._onError(m.default.FORMAT_ERROR,"Flv: Invalid AVCDecoderConfigurationRecord");if(this._naluLengthSize=1+(3&a.getUint8(4)),3!==this._naluLengthSize&&4!==this._naluLengthSize)return void this._onError(m.default.FORMAT_ERROR,"Flv: Strange NaluLengthSizeMinusOne: "+(this._naluLengthSize-1));var d=31&a.getUint8(5);if(0===d)return void this._onError(m.default.FORMAT_ERROR,"Flv: Invalid AVCDecoderConfigurationRecord: No SPS");d>1&&l.default.w(this.TAG,"Flv: Strange AVCDecoderConfigurationRecord: SPS Count = "+d);for(var h=6,f=0;f1&&l.default.w(this.TAG,"Flv: Strange AVCDecoderConfigurationRecord: PPS Count = "+w),h++;for(var R=0;R=n){l.default.w(this.TAG,"Malformed Nalu near timestamp "+_+", offset = "+f+", dataSize = "+n);break}var p=u.getUint32(f,!o);if(3===c&&(p>>>=8),p>n-c)return void l.default.w(this.TAG,"Malformed Nalus near timestamp "+_+", NaluSize > DataSize!");var v=31&u.getUint8(f+c);5===v&&(m=!0);var g=new Uint8Array(e,t+f,c+p),y={type:v,data:g};d.push(y),h+=g.byteLength,f+=c+p}if(d.length){var E=this._videoTrack,b={units:d,length:h,isKeyframe:m,dts:_,cts:a,pts:_+a};m&&(b.fileposition=r),E.samples.push(b),E.length+=h}}},{key:"onTrackMetadata",get:function(){return this._onTrackMetadata},set:function(e){this._onTrackMetadata=e}},{key:"onMediaInfo",get:function(){return this._onMediaInfo},set:function(e){this._onMediaInfo=e}},{key:"onError",get:function(){return this._onError},set:function(e){this._onError=e}},{key:"onDataAvailable",get:function(){return this._onDataAvailable},set:function(e){this._onDataAvailable=e}},{key:"timestampBase",get:function(){return this._timestampBase},set:function(e){this._timestampBase=e}},{key:"overridedDuration",get:function(){return this._duration},set:function(e){this._durationOverrided=!0,this._duration=e,this._mediaInfo.duration=e}},{key:"overridedHasAudio",set:function(e){this._hasAudioFlagOverrided=!0,this._hasAudio=e,this._mediaInfo.hasAudio=e}},{key:"overridedHasVideo",set:function(e){this._hasVideoFlagOverrided=!0,this._hasVideo=e,this._mediaInfo.hasVideo=e}}],[{key:"probe",value:function(e){var t=new Uint8Array(e),n={match:!1};if(70!==t[0]||76!==t[1]||86!==t[2]||1!==t[3])return n;var i=(4&t[4])>>>2!=0,r=0!=(1&t[4]),a=s(t,5);return a<9?n:{match:!0,consumed:a,dataOffset:a,hasAudioTrack:i,hasVideoTrack:r}}}]),e}();n.default=y},{"../core/media-info.js":7,"../utils/exception.js":40,"../utils/logger.js":41,"./amf-parser.js":15,"./demux-errors.js":16,"./sps-parser.js":19}],19:[function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var r=function(){function e(e,t){for(var n=0;n=2&&3===t[s]&&0===t[s-1]&&0===t[s-2]||(i[r]=t[s],r++);return new Uint8Array(i.buffer,0,r)}},{key:"parseSPS",value:function(t){var n=e._ebsp2rbsp(t),i=new a.default(n);i.readByte();var r=i.readByte();i.readByte();var s=i.readByte();i.readUEG();var o=e.getProfileString(r),u=e.getLevelString(s),l=1,d=420,h=[0,420,422,444],f=8;if((100===r||110===r||122===r||244===r||44===r||83===r||86===r||118===r||128===r||138===r||144===r)&&(l=i.readUEG(),3===l&&i.readBits(1),l<=3&&(d=h[l]),f=i.readUEG()+8,i.readUEG(),i.readBits(1),i.readBool()))for(var c=3!==l?8:12,_=0;_0&&I<16?(w=x[I-1],R=M[I-1]):255===I&&(w=i.readByte()<<8|i.readByte(),R=i.readByte()<<8|i.readByte())}if(i.readBool()&&i.readBool(),i.readBool()&&(i.readBits(4),i.readBool()&&i.readBits(24)),i.readBool()&&(i.readUEG(),i.readUEG()),i.readBool()){var D=i.readBits(32),B=i.readBits(32);O=i.readBool(),T=B,C=2*D,A=T/C}}var j=1;1===w&&1===R||(j=w/R);var P=0,U=0;if(0===l)P=1,U=2-E;else{var N=3===l?1:2,F=1===l?2:1;P=N,U=F*(2-E)}var G=16*(g+1),V=16*(y+1)*(2-E);G-=(b+S)*P,V-=(k+L)*U;var z=Math.ceil(G*j);return i.destroy(),i=null,{profile_string:o,level_string:u,bit_depth:f,chroma_format:d,chroma_format_string:e.getChromaFormatString(d),frame_rate:{fixed:O,fps:A,fps_den:C,fps_num:T},sar_ratio:{width:w,height:R},codec_size:{width:G,height:V},present_size:{width:z,height:V}}}},{key:"_skipScalingList",value:function(e,t){for(var n=8,i=8,r=0,s=0;s=15048,t=!f.default.msedge||e;return self.fetch&&self.ReadableStream&&t}catch(e){return!1}}}]),l(t,[{key:"destroy",value:function(){this.isWorking()&&this.abort(),u(t.prototype.__proto__||Object.getPrototypeOf(t.prototype),"destroy",this).call(this)}},{key:"open",value:function(e,t){var n=this;this._dataSource=e,this._range=t;var i=e.url;this._config.reuseRedirectedURL&&void 0!=e.redirectedURL&&(i=e.redirectedURL);var r=this._seekHandler.getConfig(i,t),s=new self.Headers;if("object"===o(r.headers)){var a=r.headers;for(var u in a)a.hasOwnProperty(u)&&s.append(u,a[u])}var l={method:"GET",headers:s,mode:"cors",cache:"default",referrerPolicy:"no-referrer-when-downgrade"};!1===e.cors&&(l.mode="same-origin"),e.withCredentials&&(l.credentials="include"),e.referrerPolicy&&(l.referrerPolicy=e.referrerPolicy),this._status=c.LoaderStatus.kConnecting,self.fetch(r.url,l).then(function(e){if(n._requestAbort)return n._requestAbort=!1,void(n._status=c.LoaderStatus.kIdle);if(e.ok&&e.status>=200&&e.status<=299){if(e.url!==r.url&&n._onURLRedirect){var t=n._seekHandler.removeURLParameters(e.url);n._onURLRedirect(t)}var i=e.headers.get("Content-Length");return null!=i&&(n._contentLength=parseInt(i),0!==n._contentLength&&n._onContentLengthKnown&&n._onContentLengthKnown(n._contentLength)),n._pump.call(n,e.body.getReader())}if(n._status=c.LoaderStatus.kError,!n._onError)throw new _.RuntimeException("FetchStreamLoader: Http code invalid, "+e.status+" "+e.statusText);n._onError(c.LoaderErrors.HTTP_STATUS_CODE_INVALID,{code:e.status,msg:e.statusText})}).catch(function(e){if(n._status=c.LoaderStatus.kError,!n._onError)throw e;n._onError(c.LoaderErrors.EXCEPTION,{code:-1,msg:e.message})})}},{key:"abort",value:function(){this._requestAbort=!0}},{key:"_pump",value:function(e){var t=this;return e.read().then(function(n){if(!n.done){if(!0===t._requestAbort)return t._requestAbort=!1,t._status=c.LoaderStatus.kComplete,e.cancel();t._status=c.LoaderStatus.kBuffering;var i=n.value.buffer,r=t._range.from+t._receivedLength;return t._receivedLength+=i.byteLength,t._onDataArrival&&t._onDataArrival(i,r,t._receivedLength),t._pump(e)}t._status=c.LoaderStatus.kComplete,t._onComplete&&t._onComplete(t._range.from,t._range.from+t._receivedLength-1)}).catch(function(e){if(11!==e.code||!f.default.msedge){t._status=c.LoaderStatus.kError;var n=0,i=null;if(19!==e.code&&"network error"!==e.message||!(null===t._contentLength||null!==t._contentLength&&t._receivedLength0&&(this._stashInitialSize=n.stashInitialSize),this._stashUsed=0,this._stashSize=this._stashInitialSize,this._bufferSize=3145728,this._stashBuffer=new ArrayBuffer(this._bufferSize),this._stashByteStart=0,this._enableStash=!0,!1===n.enableStashBuffer&&(this._enableStash=!1),this._loader=null,this._loaderClass=null,this._seekHandler=null,this._dataSource=t,this._isWebSocketURL=/wss?:\/\/(.+?)/.test(t.url),this._refTotalLength=t.filesize?t.filesize:null,this._totalLength=this._refTotalLength,this._fullRequestFlag=!1,this._currentRange=null,this._redirectedURL=null,this._speedNormalized=0,this._speedSampler=new l.default,this._speedNormalizeList=[64,128,256,384,512,768,1024,1536,2048,3072,4096],this._isEarlyEofReconnecting=!1,this._paused=!1,this._resumeFrom=0,this._onDataArrival=null,this._onSeeked=null,this._onError=null,this._onComplete=null,this._onRedirect=null,this._onRecoveredEarlyEof=null,this._selectSeekHandler(),this._selectLoader(),this._createLoader()}return s(e,[{key:"destroy",value:function(){this._loader.isWorking()&&this._loader.abort(),this._loader.destroy(),this._loader=null,this._loaderClass=null,this._dataSource=null,this._stashBuffer=null,this._stashUsed=this._stashSize=this._bufferSize=this._stashByteStart=0,this._currentRange=null,this._speedSampler=null,this._isEarlyEofReconnecting=!1,this._onDataArrival=null,this._onSeeked=null,this._onError=null,this._onComplete=null,this._onRedirect=null,this._onRecoveredEarlyEof=null,this._extraData=null}},{key:"isWorking",value:function(){return this._loader&&this._loader.isWorking()&&!this._paused}},{key:"isPaused",value:function(){return this._paused}},{key:"_selectSeekHandler",value:function(){var e=this._config;if("range"===e.seekType)this._seekHandler=new b.default(this._config.rangeLoadZeroStart);else if("param"===e.seekType){var t=e.seekParamStart||"bstart",n=e.seekParamEnd||"bend";this._seekHandler=new k.default(t,n)}else{if("custom"!==e.seekType)throw new L.InvalidArgumentException("Invalid seekType in config: "+e.seekType);if("function"!=typeof e.customSeekHandler)throw new L.InvalidArgumentException("Custom seekType specified in config but invalid customSeekHandler!");this._seekHandler=new e.customSeekHandler}}},{key:"_selectLoader",value:function(){if(this._isWebSocketURL)this._loaderClass=y.default;else if(f.default.isSupported())this._loaderClass=f.default;else if(_.default.isSupported())this._loaderClass=_.default;else{if(!v.default.isSupported())throw new L.RuntimeException("Your browser doesn't support xhr with arraybuffer responseType!");this._loaderClass=v.default}}},{key:"_createLoader",value:function(){this._loader=new this._loaderClass(this._seekHandler,this._config),!1===this._loader.needStashBuffer&&(this._enableStash=!1),this._loader.onContentLengthKnown=this._onContentLengthKnown.bind(this),this._loader.onURLRedirect=this._onURLRedirect.bind(this),this._loader.onDataArrival=this._onLoaderChunkArrival.bind(this),this._loader.onComplete=this._onLoaderComplete.bind(this),this._loader.onError=this._onLoaderError.bind(this)}},{key:"open",value:function(e){this._currentRange={from:0,to:-1},e&&(this._currentRange.from=e),this._speedSampler.reset(),e||(this._fullRequestFlag=!0),this._loader.open(this._dataSource,Object.assign({},this._currentRange))}},{key:"abort",value:function(){this._loader.abort(),this._paused&&(this._paused=!1,this._resumeFrom=0)}},{key:"pause",value:function(){this.isWorking()&&(this._loader.abort(),0!==this._stashUsed?(this._resumeFrom=this._stashByteStart,this._currentRange.to=this._stashByteStart-1):this._resumeFrom=this._currentRange.to+1,this._stashUsed=0,this._stashByteStart=0,this._paused=!0)}},{key:"resume",value:function(){if(this._paused){this._paused=!1;var e=this._resumeFrom;this._resumeFrom=0,this._internalSeek(e,!0)}}},{key:"seek",value:function(e){this._paused=!1,this._stashUsed=0,this._stashByteStart=0,this._internalSeek(e,!0)}},{key:"_internalSeek",value:function(e,t){this._loader.isWorking()&&this._loader.abort(),this._flushStashBuffer(t),this._loader.destroy(),this._loader=null;var n={from:e,to:-1};this._currentRange={from:n.from,to:-1},this._speedSampler.reset(),this._stashSize=this._stashInitialSize,this._createLoader(),this._loader.open(this._dataSource,n),this._onSeeked&&this._onSeeked()}},{key:"updateUrl",value:function(e){if(!e||"string"!=typeof e||0===e.length)throw new L.InvalidArgumentException("Url must be a non-empty string!");this._dataSource.url=e}},{key:"_expandBuffer",value:function(e){for(var t=this._stashSize;t+10485760){var i=new Uint8Array(this._stashBuffer,0,this._stashUsed);new Uint8Array(n,0,t).set(i,0)}this._stashBuffer=n,this._bufferSize=t}}},{key:"_normalizeSpeed",value:function(e){var t=this._speedNormalizeList,n=t.length-1,i=0,r=0,s=n;if(e=t[i]&&e=512&&e<=1024?Math.floor(1.5*e):2*e)>8192&&(t=8192);var n=1024*t+1048576;this._bufferSize0){var o=this._stashBuffer.slice(0,this._stashUsed),u=this._dispatchChunks(o,this._stashByteStart);if(u0){var l=new Uint8Array(o,u);a.set(l,0),this._stashUsed=l.byteLength,this._stashByteStart+=u}}else this._stashUsed=0,this._stashByteStart+=u;this._stashUsed+e.byteLength>this._bufferSize&&(this._expandBuffer(this._stashUsed+e.byteLength),a=new Uint8Array(this._stashBuffer,0,this._bufferSize)),a.set(new Uint8Array(e),this._stashUsed),this._stashUsed+=e.byteLength}else{var d=this._dispatchChunks(e,t);if(dthis._bufferSize&&(this._expandBuffer(h),a=new Uint8Array(this._stashBuffer,0,this._bufferSize)),a.set(new Uint8Array(e,d),0),this._stashUsed+=h,this._stashByteStart=t+d}}}else if(0===this._stashUsed){var f=this._dispatchChunks(e,t);if(fthis._bufferSize&&this._expandBuffer(c);var _=new Uint8Array(this._stashBuffer,0,this._bufferSize);_.set(new Uint8Array(e,f),0),this._stashUsed+=c,this._stashByteStart=t+f}}else{this._stashUsed+e.byteLength>this._bufferSize&&this._expandBuffer(this._stashUsed+e.byteLength);var m=new Uint8Array(this._stashBuffer,0,this._bufferSize);m.set(new Uint8Array(e),this._stashUsed),this._stashUsed+=e.byteLength;var p=this._dispatchChunks(this._stashBuffer.slice(0,this._stashUsed),this._stashByteStart);if(p0){var v=new Uint8Array(this._stashBuffer,p);m.set(v,0)}this._stashUsed-=p,this._stashByteStart+=p}}}},{key:"_flushStashBuffer",value:function(e){if(this._stashUsed>0){var t=this._stashBuffer.slice(0,this._stashUsed),n=this._dispatchChunks(t,this._stashByteStart),i=t.byteLength-n;if(n0){var r=new Uint8Array(this._stashBuffer,0,this._bufferSize),s=new Uint8Array(t,n);r.set(s,0),this._stashUsed=s.byteLength,this._stashByteStart+=n}return 0}o.default.w(this.TAG,i+" bytes unconsumed data remain when flush buffer, dropped")}return this._stashUsed=0,this._stashByteStart=0,i}return 0}},{key:"_onLoaderComplete",value:function(e,t){this._flushStashBuffer(!0),this._onComplete&&this._onComplete(this._extraData)}},{key:"_onLoaderError",value:function(e,t){switch(o.default.e(this.TAG,"Loader error, code = "+t.code+", msg = "+t.msg),this._flushStashBuffer(!1),this._isEarlyEofReconnecting&&(this._isEarlyEofReconnecting=!1,e=d.LoaderErrors.UNRECOVERABLE_EARLY_EOF),e){case d.LoaderErrors.EARLY_EOF:if(!this._config.isLive&&this._totalLength){var n=this._currentRange.to+1;return void(n0)for(var s=n.split("&"),a=0;a0;o[0]!==this._startName&&o[0]!==this._endName&&(u&&(r+="&"),r+=s[a])}return 0===r.length?t:t+"?"+r}}]),e}();n.default=s},{}],26:[function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var r=function(){function e(e,t){for(var n=0;n=500?this.currentKBps:0}},{key:"averageKBps",get:function(){var e=(this._now()-this._firstCheckpoint)/1e3;return this._totalBytes/e/1024}}]),e}();n.default=s},{}],28:[function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function r(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function s(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}Object.defineProperty(n,"__esModule",{value:!0});var a=function e(t,n,i){null===t&&(t=Function.prototype);var r=Object.getOwnPropertyDescriptor(t,n);if(void 0===r){var s=Object.getPrototypeOf(t);return null===s?void 0:e(s,n,i)}if("value"in r)return r.value;var a=r.get;if(void 0!==a)return a.call(i)},o=function(){function e(e,t){for(var n=0;n299)){if(this._status=h.LoaderStatus.kError,!this._onError)throw new f.RuntimeException("MozChunkedLoader: Http code invalid, "+t.status+" "+t.statusText);this._onError(h.LoaderErrors.HTTP_STATUS_CODE_INVALID,{code:t.status,msg:t.statusText})}else this._status=h.LoaderStatus.kBuffering}}},{key:"_onProgress",value:function(e){null===this._contentLength&&null!==e.total&&0!==e.total&&(this._contentLength=e.total,this._onContentLengthKnown&&this._onContentLengthKnown(this._contentLength));var t=e.target.response,n=this._range.from+this._receivedLength;this._receivedLength+=t.byteLength,this._onDataArrival&&this._onDataArrival(t,n,this._receivedLength)}},{key:"_onLoadEnd",value:function(e){if(!0===this._requestAbort)return void(this._requestAbort=!1);this._status!==h.LoaderStatus.kError&&(this._status=h.LoaderStatus.kComplete,this._onComplete&&this._onComplete(this._range.from,this._range.from+this._receivedLength-1))}},{key:"_onXhrError",value:function(e){this._status=h.LoaderStatus.kError;var t=0,n=null;if(this._contentLength&&e.loaded=200&&t.status<=299){if(this._status=h.LoaderStatus.kBuffering,void 0!=t.responseURL){var n=this._seekHandler.removeURLParameters(t.responseURL);t.responseURL!==this._currentRequestURL&&n!==this._currentRedirectedURL&&(this._currentRedirectedURL=n,this._onURLRedirect&&this._onURLRedirect(n))}var i=t.getResponseHeader("Content-Length");if(null!=i&&null==this._contentLength){var r=parseInt(i);r>0&&(this._contentLength=r,this._onContentLengthKnown&&this._onContentLengthKnown(this._contentLength))}}else{if(this._status=h.LoaderStatus.kError,!this._onError)throw new f.RuntimeException("MSStreamLoader: Http code invalid, "+t.status+" "+t.statusText);this._onError(h.LoaderErrors.HTTP_STATUS_CODE_INVALID,{code:t.status,msg:t.statusText})}else if(3===t.readyState&&t.status>=200&&t.status<=299){this._status=h.LoaderStatus.kBuffering;var s=t.response;this._reader.readAsArrayBuffer(s)}}},{key:"_xhrOnError",value:function(e){this._status=h.LoaderStatus.kError;var t=h.LoaderErrors.EXCEPTION,n={code:-1,msg:e.constructor.name+" "+e.type};if(!this._onError)throw new f.RuntimeException(n.msg);this._onError(t,n)}},{key:"_msrOnProgress",value:function(e){var t=e.target,n=t.result;if(null==n)return void this._doReconnectIfNeeded();var i=n.slice(this._lastTimeBufferSize);this._lastTimeBufferSize=n.byteLength;var r=this._totalRange.from+this._receivedLength;this._receivedLength+=i.byteLength,this._onDataArrival&&this._onDataArrival(i,r,this._receivedLength),n.byteLength>=this._bufferLimit&&(d.default.v(this.TAG,"MSStream buffer exceeded max size near "+(r+i.byteLength)+", reconnecting..."),this._doReconnectIfNeeded())}},{key:"_doReconnectIfNeeded",value:function(){if(null==this._contentLength||this._receivedLength=this._contentLength&&(n=this._range.from+this._contentLength-1),this._currentRequestRange={from:t,to:n},this._internalOpen(this._dataSource,this._currentRequestRange)}},{key:"_internalOpen",value:function(e,t){this._lastTimeLoaded=0;var n=e.url;this._config.reuseRedirectedURL&&(void 0!=this._currentRedirectedURL?n=this._currentRedirectedURL:void 0!=e.redirectedURL&&(n=e.redirectedURL));var i=this._seekHandler.getConfig(n,t);this._currentRequestURL=i.url;var r=this._xhr=new XMLHttpRequest;if(r.open("GET",i.url,!0),r.responseType="arraybuffer",r.onreadystatechange=this._onReadyStateChange.bind(this),r.onprogress=this._onProgress.bind(this),r.onload=this._onLoad.bind(this),r.onerror=this._onXhrError.bind(this),e.withCredentials&&r.withCredentials&&(r.withCredentials=!0),"object"===o(i.headers)){var s=i.headers;for(var a in s)s.hasOwnProperty(a)&&r.setRequestHeader(a,s[a])}r.send()}},{key:"abort",value:function(){this._requestAbort=!0,this._internalAbort(),this._status=_.LoaderStatus.kComplete}},{key:"_internalAbort",value:function(){this._xhr&&(this._xhr.onreadystatechange=null,this._xhr.onprogress=null,this._xhr.onload=null,this._xhr.onerror=null,this._xhr.abort(),this._xhr=null)}},{key:"_onReadyStateChange",value:function(e){var t=e.target;if(2===t.readyState){if(void 0!=t.responseURL){var n=this._seekHandler.removeURLParameters(t.responseURL);t.responseURL!==this._currentRequestURL&&n!==this._currentRedirectedURL&&(this._currentRedirectedURL=n,this._onURLRedirect&&this._onURLRedirect(n))}if(t.status>=200&&t.status<=299){if(this._waitForTotalLength)return;this._status=_.LoaderStatus.kBuffering}else{if(this._status=_.LoaderStatus.kError,!this._onError)throw new m.RuntimeException("RangeLoader: Http code invalid, "+t.status+" "+t.statusText);this._onError(_.LoaderErrors.HTTP_STATUS_CODE_INVALID,{code:t.status,msg:t.statusText})}}}},{key:"_onProgress",value:function(e){if(null===this._contentLength){var t=!1;if(this._waitForTotalLength){this._waitForTotalLength=!1,this._totalLengthReceived=!0,t=!0;var n=e.total;this._internalAbort(),null!=n&0!==n&&(this._totalLength=n)}if(-1===this._range.to?this._contentLength=this._totalLength-this._range.from:this._contentLength=this._range.to-this._range.from+1,t)return void this._openSubRange();this._onContentLengthKnown&&this._onContentLengthKnown(this._contentLength)}var i=e.loaded-this._lastTimeLoaded;this._lastTimeLoaded=e.loaded,this._speedSampler.addBytes(i)}},{key:"_normalizeSpeed",value:function(e){var t=this._chunkSizeKBList,n=t.length-1,i=0,r=0,s=n;if(e=t[i]&&e=3&&(t=this._speedSampler.currentKBps),0!==t){var n=this._normalizeSpeed(t);this._currentSpeedNormalized!==n&&(this._currentSpeedNormalized=n,this._currentChunkSizeKB=n)}var i=e.target.response,r=this._range.from+this._receivedLength;this._receivedLength+=i.byteLength;var s=!1;null!=this._contentLength&&this._receivedLength0&&this._receivedLength0&&(this._requestSetTime=!0,this._mediaElement.currentTime=0),this._transmuxer=new p.default(this._mediaDataSource,this._config),this._transmuxer.on(g.default.INIT_SEGMENT,function(t,n){e._msectl.appendInitSegment(n)}),this._transmuxer.on(g.default.MEDIA_SEGMENT,function(t,n){if(e._msectl.appendMediaSegment(n),e._config.lazyLoad&&!e._config.isLive){var i=e._mediaElement.currentTime;n.info.endDts>=1e3*(i+e._config.lazyLoadMaxDuration)&&null==e._progressChecker&&(d.default.v(e.TAG,"Maximum buffering duration exceeded, suspend transmuxing task"),e._suspendTransmuxer())}}),this._transmuxer.on(g.default.LOADING_COMPLETE,function(){e._msectl.endOfStream(),e._emitter.emit(_.default.LOADING_COMPLETE)}),this._transmuxer.on(g.default.RECOVERED_EARLY_EOF,function(){e._emitter.emit(_.default.RECOVERED_EARLY_EOF)}),this._transmuxer.on(g.default.IO_ERROR,function(t,n){e._emitter.emit(_.default.ERROR,k.ErrorTypes.NETWORK_ERROR,t,n)}),this._transmuxer.on(g.default.DEMUX_ERROR,function(t,n){e._emitter.emit(_.default.ERROR,k.ErrorTypes.MEDIA_ERROR,t,{code:-1,msg:n})}),this._transmuxer.on(g.default.MEDIA_INFO,function(t){e._mediaInfo=t,e._emitter.emit(_.default.MEDIA_INFO,Object.assign({},t))}),this._transmuxer.on(g.default.STATISTICS_INFO,function(t){e._statisticsInfo=e._fillStatisticsInfo(t), +e._emitter.emit(_.default.STATISTICS_INFO,Object.assign({},e._statisticsInfo))}),this._transmuxer.on(g.default.RECOMMEND_SEEKPOINT,function(t){e._mediaElement&&!e._config.accurateSeek&&(e._requestSetTime=!0,e._mediaElement.currentTime=t/1e3)}),this._transmuxer.open()}}},{key:"unload",value:function(){this._mediaElement&&this._mediaElement.pause(),this._msectl&&this._msectl.seek(0),this._transmuxer&&(this._transmuxer.close(),this._transmuxer.destroy(),this._transmuxer=null)}},{key:"play",value:function(){return this._mediaElement.play()}},{key:"pause",value:function(){this._mediaElement.pause()}},{key:"_fillStatisticsInfo",value:function(e){if(e.playerType=this._type,!(this._mediaElement instanceof HTMLVideoElement))return e;var t=!0,n=0,i=0;if(this._mediaElement.getVideoPlaybackQuality){var r=this._mediaElement.getVideoPlaybackQuality();n=r.totalVideoFrames,i=r.droppedVideoFrames}else void 0!=this._mediaElement.webkitDecodedFrameCount?(n=this._mediaElement.webkitDecodedFrameCount,i=this._mediaElement.webkitDroppedFrameCount):t=!1;return t&&(e.decodedFrames=n,e.droppedFrames=i),e}},{key:"_onmseUpdateEnd",value:function(){if(this._config.lazyLoad&&!this._config.isLive){for(var e=this._mediaElement.buffered,t=this._mediaElement.currentTime,n=0,i=0;i=t+this._config.lazyLoadMaxDuration&&null==this._progressChecker&&(d.default.v(this.TAG,"Maximum buffering duration exceeded, suspend transmuxing task"),this._suspendTransmuxer())}}},{key:"_onmseBufferFull",value:function(){d.default.v(this.TAG,"MSE SourceBuffer is full, suspend transmuxing task"),null==this._progressChecker&&this._suspendTransmuxer()}},{key:"_suspendTransmuxer",value:function(){this._transmuxer&&(this._transmuxer.pause(),null==this._progressChecker&&(this._progressChecker=window.setInterval(this._checkProgressAndResume.bind(this),1e3)))}},{key:"_checkProgressAndResume",value:function(){for(var e=this._mediaElement.currentTime,t=this._mediaElement.buffered,n=!1,i=0;i=r&&e=s-this._config.lazyLoadRecoverDuration&&(n=!0);break}}n&&(window.clearInterval(this._progressChecker),this._progressChecker=null,n&&(d.default.v(this.TAG,"Continue loading from paused position"),this._transmuxer.resume()))}},{key:"_isTimepointBuffered",value:function(e){for(var t=this._mediaElement.buffered,n=0;n=i&&e0){var r=this._mediaElement.buffered.start(0);(r<1&&e0&&t.currentTime0){var i=n.start(0);if(i<1&&t0&&(this._mediaElement.currentTime=0),this._mediaElement.preload="auto",this._mediaElement.load(),this._statisticsReporter=window.setInterval(this._reportStatisticsInfo.bind(this),this._config.statisticsInfoReportInterval)}},{key:"unload",value:function(){this._mediaElement&&(this._mediaElement.src="",this._mediaElement.removeAttribute("src")),null!=this._statisticsReporter&&(window.clearInterval(this._statisticsReporter),this._statisticsReporter=null)}},{key:"play",value:function(){return this._mediaElement.play()}},{key:"pause",value:function(){this._mediaElement.pause()}},{key:"_onvLoadedMetadata",value:function(e){null!=this._pendingSeekTime&&(this._mediaElement.currentTime=this._pendingSeekTime,this._pendingSeekTime=null),this._emitter.emit(d.default.MEDIA_INFO,this.mediaInfo)}},{key:"_reportStatisticsInfo",value:function(){this._emitter.emit(d.default.STATISTICS_INFO,this.statisticsInfo)}},{key:"type",get:function(){return this._type}},{key:"buffered",get:function(){return this._mediaElement.buffered}},{key:"duration",get:function(){return this._mediaElement.duration}},{key:"volume",get:function(){return this._mediaElement.volume},set:function(e){this._mediaElement.volume=e}},{key:"muted",get:function(){return this._mediaElement.muted},set:function(e){this._mediaElement.muted=e}},{key:"currentTime",get:function(){return this._mediaElement?this._mediaElement.currentTime:0},set:function(e){this._mediaElement?this._mediaElement.currentTime=e:this._pendingSeekTime=e}},{key:"mediaInfo",get:function(){var e=this._mediaElement instanceof HTMLAudioElement?"audio/":"video/",t={mimeType:e+this._mediaDataSource.type};return this._mediaElement&&(t.duration=Math.floor(1e3*this._mediaElement.duration),this._mediaElement instanceof HTMLVideoElement&&(t.width=this._mediaElement.videoWidth,t.height=this._mediaElement.videoHeight)),t}},{key:"statisticsInfo",get:function(){var e={playerType:this._type,url:this._mediaDataSource.url};if(!(this._mediaElement instanceof HTMLVideoElement))return e;var t=!0,n=0,i=0;if(this._mediaElement.getVideoPlaybackQuality){var r=this._mediaElement.getVideoPlaybackQuality();n=r.totalVideoFrames,i=r.droppedVideoFrames}else void 0!=this._mediaElement.webkitDecodedFrameCount?(n=this._mediaElement.webkitDecodedFrameCount,i=this._mediaElement.webkitDroppedFrameCount):t=!1;return t&&(e.decodedFrames=n,e.droppedFrames=i),e}}]),e}();n.default=c},{"../config.js":5,"../utils/exception.js":40,"./player-events.js":35,events:2}],34:[function(e,t,n){"use strict";Object.defineProperty(n,"__esModule",{value:!0}),n.ErrorDetails=n.ErrorTypes=void 0;var i=e("../io/loader.js"),r=e("../demux/demux-errors.js"),s=function(e){return e&&e.__esModule?e:{default:e}}(r);n.ErrorTypes={NETWORK_ERROR:"NetworkError",MEDIA_ERROR:"MediaError",OTHER_ERROR:"OtherError"},n.ErrorDetails={NETWORK_EXCEPTION:i.LoaderErrors.EXCEPTION,NETWORK_STATUS_CODE_INVALID:i.LoaderErrors.HTTP_STATUS_CODE_INVALID,NETWORK_TIMEOUT:i.LoaderErrors.CONNECTING_TIMEOUT,NETWORK_UNRECOVERABLE_EARLY_EOF:i.LoaderErrors.UNRECOVERABLE_EARLY_EOF,MEDIA_MSE_ERROR:"MediaMSEError",MEDIA_FORMAT_ERROR:s.default.FORMAT_ERROR,MEDIA_FORMAT_UNSUPPORTED:s.default.FORMAT_UNSUPPORTED,MEDIA_CODEC_UNSUPPORTED:s.default.CODEC_UNSUPPORTED}},{"../demux/demux-errors.js":16,"../io/loader.js":24}],35:[function(e,t,n){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var i={ERROR:"error",LOADING_COMPLETE:"loading_complete",RECOVERED_EARLY_EOF:"recovered_early_eof",MEDIA_INFO:"media_info",STATISTICS_INFO:"statistics_info"};n.default=i},{}],36:[function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var r=function(){function e(e,t){for(var n=0;n>>24&255,n[1]=t>>>16&255,n[2]=t>>>8&255,n[3]=255&t,n.set(e,4);for(var a=8,o=0;o>>24&255,t>>>16&255,t>>>8&255,255&t,n>>>24&255,n>>>16&255,n>>>8&255,255&n,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255]))}},{key:"trak",value:function(t){return e.box(e.types.trak,e.tkhd(t),e.mdia(t))}},{key:"tkhd",value:function(t){var n=t.id,i=t.duration,r=t.presentWidth,s=t.presentHeight;return e.box(e.types.tkhd,new Uint8Array([0,0,0,7,0,0,0,0,0,0,0,0,n>>>24&255,n>>>16&255,n>>>8&255,255&n,0,0,0,0,i>>>24&255,i>>>16&255,i>>>8&255,255&i,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,r>>>8&255,255&r,0,0,s>>>8&255,255&s,0,0]))}},{key:"mdia",value:function(t){return e.box(e.types.mdia,e.mdhd(t),e.hdlr(t),e.minf(t))}},{key:"mdhd",value:function(t){var n=t.timescale,i=t.duration;return e.box(e.types.mdhd,new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,n>>>24&255,n>>>16&255,n>>>8&255,255&n,i>>>24&255,i>>>16&255,i>>>8&255,255&i,85,196,0,0]))}},{key:"hdlr",value:function(t){var n=null;return n="audio"===t.type?e.constants.HDLR_AUDIO:e.constants.HDLR_VIDEO,e.box(e.types.hdlr,n)}},{key:"minf",value:function(t){var n=null;return n="audio"===t.type?e.box(e.types.smhd,e.constants.SMHD):e.box(e.types.vmhd,e.constants.VMHD),e.box(e.types.minf,n,e.dinf(),e.stbl(t))}},{key:"dinf",value:function(){return e.box(e.types.dinf,e.box(e.types.dref,e.constants.DREF))}},{key:"stbl",value:function(t){return e.box(e.types.stbl,e.stsd(t),e.box(e.types.stts,e.constants.STTS),e.box(e.types.stsc,e.constants.STSC),e.box(e.types.stsz,e.constants.STSZ),e.box(e.types.stco,e.constants.STCO))}},{key:"stsd",value:function(t){return"audio"===t.type?"mp3"===t.codec?e.box(e.types.stsd,e.constants.STSD_PREFIX,e.mp3(t)):e.box(e.types.stsd,e.constants.STSD_PREFIX,e.mp4a(t)):e.box(e.types.stsd,e.constants.STSD_PREFIX,e.avc1(t))}},{key:"mp3",value:function(t){var n=t.channelCount,i=t.audioSampleRate,r=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,n,0,16,0,0,0,0,i>>>8&255,255&i,0,0]);return e.box(e.types[".mp3"],r)}},{key:"mp4a",value:function(t){var n=t.channelCount,i=t.audioSampleRate,r=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,n,0,16,0,0,0,0,i>>>8&255,255&i,0,0]);return e.box(e.types.mp4a,r,e.esds(t))}},{key:"esds",value:function(t){var n=t.config||[],i=n.length,r=new Uint8Array([0,0,0,0,3,23+i,0,1,0,4,15+i,64,21,0,0,0,0,0,0,0,0,0,0,0,5].concat([i]).concat(n).concat([6,1,2]));return e.box(e.types.esds,r)}},{key:"avc1",value:function(t){var n=t.avcc,i=t.codecWidth,r=t.codecHeight,s=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,i>>>8&255,255&i,r>>>8&255,255&r,0,72,0,0,0,72,0,0,0,0,0,0,0,1,10,120,113,113,47,102,108,118,46,106,115,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,255,255]);return e.box(e.types.avc1,s,e.box(e.types.avcC,n))}},{key:"mvex",value:function(t){return e.box(e.types.mvex,e.trex(t))}},{key:"trex",value:function(t){var n=t.id,i=new Uint8Array([0,0,0,0,n>>>24&255,n>>>16&255,n>>>8&255,255&n,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,1]);return e.box(e.types.trex,i)}},{key:"moof",value:function(t,n){return e.box(e.types.moof,e.mfhd(t.sequenceNumber),e.traf(t,n))}},{key:"mfhd",value:function(t){var n=new Uint8Array([0,0,0,0,t>>>24&255,t>>>16&255,t>>>8&255,255&t]);return e.box(e.types.mfhd,n)}},{key:"traf",value:function(t,n){var i=t.id,r=e.box(e.types.tfhd,new Uint8Array([0,0,0,0,i>>>24&255,i>>>16&255,i>>>8&255,255&i])),s=e.box(e.types.tfdt,new Uint8Array([0,0,0,0,n>>>24&255,n>>>16&255,n>>>8&255,255&n])),a=e.sdtp(t),o=e.trun(t,a.byteLength+16+16+8+16+8+8);return e.box(e.types.traf,r,s,o,a)}},{key:"sdtp",value:function(t){for(var n=t.samples||[],i=n.length,r=new Uint8Array(4+i),s=0;s>>24&255,r>>>16&255,r>>>8&255,255&r,n>>>24&255,n>>>16&255,n>>>8&255,255&n],0);for(var o=0;o>>24&255,u>>>16&255,u>>>8&255,255&u,l>>>24&255,l>>>16&255,l>>>8&255,255&l,d.isLeading<<2|d.dependsOn,d.isDependedOn<<6|d.hasRedundancy<<4|d.isNonSync,0,0,h>>>24&255,h>>>16&255,h>>>8&255,255&h],12+16*o)}return e.box(e.types.trun,a)}},{key:"mdat",value:function(t){return e.box(e.types.mdat,t)}}]),e}();s.init(),n.default=s},{}],38:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var s=function(){function e(e,t){for(var n=0;n=1?A[A.length-1].duration:Math.floor(a);var D=!1,B=null;if(M>1.5*a&&"mp3"!==this._audioMeta.codec&&this._fillAudioTimestampGap&&!c.default.safari){D=!0;var j=Math.abs(M-a),P=Math.ceil(j/a),U=x+a;o.default.w(this.TAG,"Large audio timestamp gap detected, may cause AV sync to drift. Silent frames will be generated to avoid unsync.\ndts: "+(x+M)+" ms, expected: "+(x+Math.round(a))+" ms, delta: "+Math.round(j)+" ms, generate: "+P+" frames");var N=h.default.getSilentFrame(this._audioMeta.originalCodec,this._audioMeta.channelCount);null==N&&(o.default.w(this.TAG,"Unable to generate silent frame for "+this._audioMeta.originalCodec+" with "+this._audioMeta.channelCount+" channels, repeat last frame"),N=C),B=[];for(var F=0;F0){var V=B[B.length-1];V.duration=G-V.dts}var z={dts:G,pts:G,cts:0,unit:N,size:N.byteLength,duration:0,originalDts:I,flags:{isLeading:0,dependsOn:1,isDependedOn:0,hasRedundancy:0}};B.push(z),v+=C.byteLength,U+=a}var H=B[B.length-1];H.duration=x+M-H.dts,M=Math.round(a)}A.push({dts:x,pts:x,cts:0,unit:T.unit,size:T.unit.byteLength,duration:M,originalDts:I,flags:{isLeading:0,dependsOn:1,isDependedOn:0,hasRedundancy:0}}),D&&A.push.apply(A,B)}u?p=new Uint8Array(v):(p=new Uint8Array(v),p[0]=v>>>24&255,p[1]=v>>>16&255,p[2]=v>>>8&255,p[3]=255&v,p.set(l.default.types.mdat,4));for(var K=0;K>>24&255,h[1]=d>>>16&255,h[2]=d>>>8&255,h[3]=255&d,h.set(l.default.types.mdat,4);var f=n[0].dts-this._dtsBase;if(this._videoNextDts)i=f-this._videoNextDts;else if(this._videoSegmentInfoList.isEmpty())i=0;else{var c=this._videoSegmentInfoList.getLastSampleBefore(f);if(null!=c){var m=f-(c.originalDts+c.duration);m<=3&&(m=0);var p=c.dts+c.duration+m;i=f-p}else i=0}for(var v=new _.MediaSegmentInfo,g=[],y=0;y=1?g[g.length-1].duration:Math.floor(this._videoMeta.refSampleDuration);if(S){var A=new _.SampleInfo(k,w,R,E.dts,!0);A.fileposition=E.fileposition,v.appendSyncPoint(A)}g.push({dts:k,pts:w,cts:L,units:E.units,size:E.length,isKeyframe:S,duration:R,originalDts:b,flags:{isLeading:0,dependsOn:S?2:1,isDependedOn:S?1:0,hasRedundancy:0,isNonSync:S?0:1}})}for(var O=0;O=0&&/(rv)(?::| )([\w.]+)/.exec(e)||e.indexOf("compatible")<0&&/(firefox)[ \/]([\w.]+)/.exec(e)||[],n=/(ipad)/.exec(e)||/(ipod)/.exec(e)||/(windows phone)/.exec(e)||/(iphone)/.exec(e)||/(kindle)/.exec(e)||/(android)/.exec(e)||/(windows)/.exec(e)||/(mac)/.exec(e)||/(linux)/.exec(e)||/(cros)/.exec(e)||[],r={browser:t[5]||t[3]||t[1]||"",version:t[2]||t[4]||"0",majorVersion:t[4]||t[2]||"0",platform:n[0]||""},s={};if(r.browser){s[r.browser]=!0;var a=r.majorVersion.split(".");s.version={major:parseInt(r.majorVersion,10),string:r.version},a.length>1&&(s.version.minor=parseInt(a[1],10)),a.length>2&&(s.version.build=parseInt(a[2],10))}r.platform&&(s[r.platform]=!0),(s.chrome||s.opr||s.safari)&&(s.webkit=!0),(s.rv||s.iemobile)&&(s.rv&&delete s.rv,r.browser="msie",s.msie=!0),s.edge&&(delete s.edge,r.browser="msedge",s.msedge=!0),s.opr&&(r.browser="opera",s.opera=!0),s.safari&&s.android&&(r.browser="android", +s.android=!0),s.name=r.browser,s.platform=r.platform;for(var o in i)i.hasOwnProperty(o)&&delete i[o];Object.assign(i,s)}(),n.default=i},{}],40:[function(e,t,n){"use strict";function i(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function r(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var a=function(){function e(e,t){for(var n=0;n "+n;e.ENABLE_CALLBACK&&e.emitter.emit("log","error",i),e.ENABLE_ERROR&&(console.error?console.error(i):console.warn?console.warn(i):console.log(i))}},{key:"i",value:function(t,n){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var i="["+t+"] > "+n;e.ENABLE_CALLBACK&&e.emitter.emit("log","info",i),e.ENABLE_INFO&&(console.info?console.info(i):console.log(i))}},{key:"w",value:function(t,n){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var i="["+t+"] > "+n;e.ENABLE_CALLBACK&&e.emitter.emit("log","warn",i),e.ENABLE_WARN&&(console.warn?console.warn(i):console.log(i))}},{key:"d",value:function(t,n){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var i="["+t+"] > "+n;e.ENABLE_CALLBACK&&e.emitter.emit("log","debug",i),e.ENABLE_DEBUG&&(console.debug?console.debug(i):console.log(i))}},{key:"v",value:function(t,n){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var i="["+t+"] > "+n;e.ENABLE_CALLBACK&&e.emitter.emit("log","verbose",i),e.ENABLE_VERBOSE&&console.log(i)}}]),e}();o.GLOBAL_TAG="flv.js",o.FORCE_GLOBAL_TAG=!1,o.ENABLE_ERROR=!0,o.ENABLE_INFO=!0,o.ENABLE_WARN=!0,o.ENABLE_DEBUG=!0,o.ENABLE_VERBOSE=!0,o.ENABLE_CALLBACK=!1,o.emitter=new a.default,n.default=o},{events:2}],42:[function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var s=function(){function e(e,t){for(var n=0;n0){var n=e.getConfig();t.emit("change",n)}}},{key:"registerListener",value:function(t){e.emitter.addListener("change",t)}},{key:"removeListener",value:function(t){e.emitter.removeListener("change",t)}},{key:"addLogListener",value:function(t){l.default.emitter.addListener("log",t),l.default.emitter.listenerCount("log")>0&&(l.default.ENABLE_CALLBACK=!0,e._notifyChange())}},{key:"removeLogListener",value:function(t){l.default.emitter.removeListener("log",t),0===l.default.emitter.listenerCount("log")&&(l.default.ENABLE_CALLBACK=!1,e._notifyChange())}},{key:"forceGlobalTag",get:function(){return l.default.FORCE_GLOBAL_TAG},set:function(t){l.default.FORCE_GLOBAL_TAG=t,e._notifyChange()}},{key:"globalTag",get:function(){return l.default.GLOBAL_TAG},set:function(t){l.default.GLOBAL_TAG=t,e._notifyChange()}},{key:"enableAll",get:function(){return l.default.ENABLE_VERBOSE&&l.default.ENABLE_DEBUG&&l.default.ENABLE_INFO&&l.default.ENABLE_WARN&&l.default.ENABLE_ERROR},set:function(t){l.default.ENABLE_VERBOSE=t,l.default.ENABLE_DEBUG=t,l.default.ENABLE_INFO=t,l.default.ENABLE_WARN=t,l.default.ENABLE_ERROR=t,e._notifyChange()}},{key:"enableDebug",get:function(){return l.default.ENABLE_DEBUG},set:function(t){l.default.ENABLE_DEBUG=t,e._notifyChange()}},{key:"enableVerbose",get:function(){return l.default.ENABLE_VERBOSE},set:function(t){l.default.ENABLE_VERBOSE=t,e._notifyChange()}},{key:"enableInfo",get:function(){return l.default.ENABLE_INFO},set:function(t){l.default.ENABLE_INFO=t,e._notifyChange()}},{key:"enableWarn",get:function(){return l.default.ENABLE_WARN},set:function(t){l.default.ENABLE_WARN=t,e._notifyChange()}},{key:"enableError",get:function(){return l.default.ENABLE_ERROR},set:function(t){l.default.ENABLE_ERROR=t,e._notifyChange()}}]),e}();d.emitter=new o.default,n.default=d},{"./logger.js":41,events:2}],43:[function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(n,"__esModule",{value:!0});var r=function(){function e(e,t){for(var n=0;n=128){t.push(String.fromCharCode(65535&a)),r+=2;continue}}}else if(n[r]<240){if(i(n,r,2)){var o=(15&n[r])<<12|(63&n[r+1])<<6|63&n[r+2];if(o>=2048&&55296!=(63488&o)){t.push(String.fromCharCode(65535&o)),r+=3;continue}}}else if(n[r]<248&&i(n,r,3)){var u=(7&n[r])<<18|(63&n[r+1])<<12|(63&n[r+2])<<6|63&n[r+3];if(u>65536&&u<1114112){u-=65536,t.push(String.fromCharCode(u>>>10|55296)),t.push(String.fromCharCode(1023&u|56320)),r+=4;continue}}t.push(String.fromCharCode(65533)),++r}return t.join("")}Object.defineProperty(n,"__esModule",{value:!0}),n.default=r},{}]},{},[21])(21)}); diff --git a/MovieNight/static/js/jquery.js b/MovieNight/static/js/jquery.js new file mode 100755 index 0000000..25714ed --- /dev/null +++ b/MovieNight/static/js/jquery.js @@ -0,0 +1,4 @@ +/*! jQuery v2.1.3 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ +!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l=a.document,m="2.1.3",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return n.each(this,a,b)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(a=arguments[h]))for(b in a)c=g[b],d=a[b],g!==d&&(j&&d&&(n.isPlainObject(d)||(e=n.isArray(d)))?(e?(e=!1,f=c&&n.isArray(c)?c:[]):f=c&&n.isPlainObject(c)?c:{},g[b]=n.extend(j,f,d)):void 0!==d&&(g[b]=d));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray,isWindow:function(a){return null!=a&&a===a.window},isNumeric:function(a){return!n.isArray(a)&&a-parseFloat(a)+1>=0},isPlainObject:function(a){return"object"!==n.type(a)||a.nodeType||n.isWindow(a)?!1:a.constructor&&!j.call(a.constructor.prototype,"isPrototypeOf")?!1:!0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(a){var b,c=eval;a=n.trim(a),a&&(1===a.indexOf("use strict")?(b=l.createElement("script"),b.text=a,l.head.appendChild(b).parentNode.removeChild(b)):c(a))},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=s(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){return null==b?-1:g.call(b,a,c)},merge:function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;d++)a[e++]=b[d];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=s(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(c=a[b],b=a,a=c),n.isFunction(a)?(e=d.call(arguments,2),f=function(){return a.apply(b||this,e.concat(d.call(arguments)))},f.guid=a.guid=a.guid||n.guid++,f):void 0},now:Date.now,support:k}),n.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function s(a){var b=a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=hb(),z=hb(),A=hb(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N=M.replace("w","w#"),O="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+N+"))|)"+L+"*\\]",P=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+O+")*)|.*)\\)|)",Q=new RegExp(L+"+","g"),R=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),S=new RegExp("^"+L+"*,"+L+"*"),T=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),U=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),V=new RegExp(P),W=new RegExp("^"+N+"$"),X={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+P),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},eb=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(fb){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function gb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],k=b.nodeType,"string"!=typeof a||!a||1!==k&&9!==k&&11!==k)return d;if(!e&&p){if(11!==k&&(f=_.exec(a)))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return H.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName)return H.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=1!==k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+rb(o[l]);w=ab.test(a)&&pb(b.parentNode)||b,x=o.join(",")}if(x)try{return H.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function hb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ib(a){return a[u]=!0,a}function jb(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function kb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function lb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function nb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function ob(a){return ib(function(b){return b=+b,ib(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function pb(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=gb.support={},f=gb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=gb.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=g.documentElement,e=g.defaultView,e&&e!==e.top&&(e.addEventListener?e.addEventListener("unload",eb,!1):e.attachEvent&&e.attachEvent("onunload",eb)),p=!f(g),c.attributes=jb(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=jb(function(a){return a.appendChild(g.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(g.getElementsByClassName),c.getById=jb(function(a){return o.appendChild(a).id=u,!g.getElementsByName||!g.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(g.querySelectorAll))&&(jb(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),jb(function(a){var b=g.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&jb(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",P)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===g||a.ownerDocument===v&&t(v,a)?-1:b===g||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,h=[a],i=[b];if(!e||!f)return a===g?-1:b===g?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return lb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?lb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},g):n},gb.matches=function(a,b){return gb(a,null,null,b)},gb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return gb(b,n,null,[a]).length>0},gb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},gb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},gb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},gb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=gb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=gb.selectors={cacheLength:50,createPseudo:ib,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||gb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&gb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=gb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(Q," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||gb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ib(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ib(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?ib(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ib(function(a){return function(b){return gb(a,b).length>0}}),contains:ib(function(a){return a=a.replace(cb,db),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ib(function(a){return W.test(a||"")||gb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:ob(function(){return[0]}),last:ob(function(a,b){return[b-1]}),eq:ob(function(a,b,c){return[0>c?c+b:c]}),even:ob(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:ob(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:ob(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:ob(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function sb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function tb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ub(a,b,c){for(var d=0,e=b.length;e>d;d++)gb(a,b[d],c);return c}function vb(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function wb(a,b,c,d,e,f){return d&&!d[u]&&(d=wb(d)),e&&!e[u]&&(e=wb(e,f)),ib(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ub(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:vb(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=vb(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=vb(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function xb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=sb(function(a){return a===b},h,!0),l=sb(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[sb(tb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return wb(i>1&&tb(m),i>1&&rb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&xb(a.slice(i,e)),f>e&&xb(a=a.slice(e)),f>e&&rb(a))}m.push(c)}return tb(m)}function yb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=F.call(i));s=vb(s)}H.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&gb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?ib(f):f}return h=gb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=xb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,yb(e,d)),f.selector=a}return f},i=gb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&pb(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&rb(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&pb(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=jb(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),jb(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||kb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&jb(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||kb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),jb(function(a){return null==a.getAttribute("disabled")})||kb(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),gb}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=n.expr.match.needsContext,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^.[^:#\[\.,]*$/;function x(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(w.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return g.call(b,a)>=0!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=this.length,d=[],e=this;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;c>b;b++)if(n.contains(e[b],this))return!0}));for(b=0;c>b;b++)n.find(a,e[b],d);return d=this.pushStack(c>1?n.unique(d):d),d.selector=this.selector?this.selector+" "+a:a,d},filter:function(a){return this.pushStack(x(this,a||[],!1))},not:function(a){return this.pushStack(x(this,a||[],!0))},is:function(a){return!!x(this,"string"==typeof a&&u.test(a)?n(a):a||[],!1).length}});var y,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=n.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||y).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:l,!0)),v.test(c[1])&&n.isPlainObject(b))for(c in b)n.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}return d=l.getElementById(c[2]),d&&d.parentNode&&(this.length=1,this[0]=d),this.context=l,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof y.ready?y.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};A.prototype=n.fn,y=n(l);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};n.extend({dir:function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),n.fn.extend({has:function(a){var b=n(a,this),c=b.length;return this.filter(function(){for(var a=0;c>a;a++)if(n.contains(this,b[a]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=u.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.unique(f):f)},index:function(a){return a?"string"==typeof a?g.call(n(a),this[0]):g.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.unique(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){while((a=a[b])&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return n.dir(a,"parentNode")},parentsUntil:function(a,b,c){return n.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return n.dir(a,"nextSibling")},prevAll:function(a){return n.dir(a,"previousSibling")},nextUntil:function(a,b,c){return n.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return n.dir(a,"previousSibling",c)},siblings:function(a){return n.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return n.sibling(a.firstChild)},contents:function(a){return a.contentDocument||n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(C[a]||n.unique(e),B.test(a)&&e.reverse()),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return n.each(a.match(E)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):n.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(b=a.memory&&l,c=!0,g=e||0,e=0,f=h.length,d=!0;h&&f>g;g++)if(h[g].apply(l[0],l[1])===!1&&a.stopOnFalse){b=!1;break}d=!1,h&&(i?i.length&&j(i.shift()):b?h=[]:k.disable())},k={add:function(){if(h){var c=h.length;!function g(b){n.each(b,function(b,c){var d=n.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&g(c)})}(arguments),d?f=h.length:b&&(e=c,j(b))}return this},remove:function(){return h&&n.each(arguments,function(a,b){var c;while((c=n.inArray(b,h,c))>-1)h.splice(c,1),d&&(f>=c&&f--,g>=c&&g--)}),this},has:function(a){return a?n.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],f=0,this},disable:function(){return h=i=b=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,b||k.disable(),this},locked:function(){return!i},fireWith:function(a,b){return!h||c&&!i||(b=b||[],b=[a,b.slice?b.slice():b],d?i.push(b):j(b)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!c}};return k},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&n.isFunction(a.promise)?e:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(H.resolveWith(l,[n]),n.fn.triggerHandler&&(n(l).triggerHandler("ready"),n(l).off("ready"))))}});function I(){l.removeEventListener("DOMContentLoaded",I,!1),a.removeEventListener("load",I,!1),n.ready()}n.ready.promise=function(b){return H||(H=n.Deferred(),"complete"===l.readyState?setTimeout(n.ready):(l.addEventListener("DOMContentLoaded",I,!1),a.addEventListener("load",I,!1))),H.promise(b)},n.ready.promise();var J=n.access=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===n.type(c)){e=!0;for(h in c)n.access(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,n.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(n(a),c)})),b))for(;i>h;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f};n.acceptData=function(a){return 1===a.nodeType||9===a.nodeType||!+a.nodeType};function K(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=n.expando+K.uid++}K.uid=1,K.accepts=n.acceptData,K.prototype={key:function(a){if(!K.accepts(a))return 0;var b={},c=a[this.expando];if(!c){c=K.uid++;try{b[this.expando]={value:c},Object.defineProperties(a,b)}catch(d){b[this.expando]=c,n.extend(a,b)}}return this.cache[c]||(this.cache[c]={}),c},set:function(a,b,c){var d,e=this.key(a),f=this.cache[e];if("string"==typeof b)f[b]=c;else if(n.isEmptyObject(f))n.extend(this.cache[e],b);else for(d in b)f[d]=b[d];return f},get:function(a,b){var c=this.cache[this.key(a)];return void 0===b?c:c[b]},access:function(a,b,c){var d;return void 0===b||b&&"string"==typeof b&&void 0===c?(d=this.get(a,b),void 0!==d?d:this.get(a,n.camelCase(b))):(this.set(a,b,c),void 0!==c?c:b)},remove:function(a,b){var c,d,e,f=this.key(a),g=this.cache[f];if(void 0===b)this.cache[f]={};else{n.isArray(b)?d=b.concat(b.map(n.camelCase)):(e=n.camelCase(b),b in g?d=[b,e]:(d=e,d=d in g?[d]:d.match(E)||[])),c=d.length;while(c--)delete g[d[c]]}},hasData:function(a){return!n.isEmptyObject(this.cache[a[this.expando]]||{})},discard:function(a){a[this.expando]&&delete this.cache[a[this.expando]]}};var L=new K,M=new K,N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){var d;if(void 0===c&&1===a.nodeType)if(d="data-"+b.replace(O,"-$1").toLowerCase(),c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}M.set(a,b,c)}else c=void 0;return c}n.extend({hasData:function(a){return M.hasData(a)||L.hasData(a)},data:function(a,b,c){return M.access(a,b,c) +},removeData:function(a,b){M.remove(a,b)},_data:function(a,b,c){return L.access(a,b,c)},_removeData:function(a,b){L.remove(a,b)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=M.get(f),1===f.nodeType&&!L.get(f,"hasDataAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));L.set(f,"hasDataAttrs",!0)}return e}return"object"==typeof a?this.each(function(){M.set(this,a)}):J(this,function(b){var c,d=n.camelCase(a);if(f&&void 0===b){if(c=M.get(f,a),void 0!==c)return c;if(c=M.get(f,d),void 0!==c)return c;if(c=P(f,d,void 0),void 0!==c)return c}else this.each(function(){var c=M.get(this,d);M.set(this,d,b),-1!==a.indexOf("-")&&void 0!==c&&M.set(this,a,b)})},null,b,arguments.length>1,null,!0)},removeData:function(a){return this.each(function(){M.remove(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=L.get(a,b),c&&(!d||n.isArray(c)?d=L.access(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return L.get(a,c)||L.access(a,c,{empty:n.Callbacks("once memory").add(function(){L.remove(a,[b+"queue",c])})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthx",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var U="undefined";k.focusinBubbles="onfocusin"in a;var V=/^key/,W=/^(?:mouse|pointer|contextmenu)|click/,X=/^(?:focusinfocus|focusoutblur)$/,Y=/^([^.]*)(?:\.(.+)|)$/;function Z(){return!0}function $(){return!1}function _(){try{return l.activeElement}catch(a){}}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.get(a);if(r){c.handler&&(f=c,c=f.handler,e=f.selector),c.guid||(c.guid=n.guid++),(i=r.events)||(i=r.events={}),(g=r.handle)||(g=r.handle=function(b){return typeof n!==U&&n.event.triggered!==b.type?n.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(E)||[""],j=b.length;while(j--)h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o&&(l=n.event.special[o]||{},o=(e?l.delegateType:l.bindType)||o,l=n.event.special[o]||{},k=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},f),(m=i[o])||(m=i[o]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,p,g)!==!1||a.addEventListener&&a.addEventListener(o,g,!1)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),n.event.global[o]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.hasData(a)&&L.get(a);if(r&&(i=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=i[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&q!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete i[o])}else for(o in i)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(i)&&(delete r.handle,L.remove(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,m,o,p=[d||l],q=j.call(b,"type")?b.type:b,r=j.call(b,"namespace")?b.namespace.split("."):[];if(g=h=d=d||l,3!==d.nodeType&&8!==d.nodeType&&!X.test(q+n.event.triggered)&&(q.indexOf(".")>=0&&(r=q.split("."),q=r.shift(),r.sort()),k=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=r.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:n.makeArray(c,[b]),o=n.event.special[q]||{},e||!o.trigger||o.trigger.apply(d,c)!==!1)){if(!e&&!o.noBubble&&!n.isWindow(d)){for(i=o.delegateType||q,X.test(i+q)||(g=g.parentNode);g;g=g.parentNode)p.push(g),h=g;h===(d.ownerDocument||l)&&p.push(h.defaultView||h.parentWindow||a)}f=0;while((g=p[f++])&&!b.isPropagationStopped())b.type=f>1?i:o.bindType||q,m=(L.get(g,"events")||{})[b.type]&&L.get(g,"handle"),m&&m.apply(g,c),m=k&&g[k],m&&m.apply&&n.acceptData(g)&&(b.result=m.apply(g,c),b.result===!1&&b.preventDefault());return b.type=q,e||b.isDefaultPrevented()||o._default&&o._default.apply(p.pop(),c)!==!1||!n.acceptData(d)||k&&n.isFunction(d[q])&&!n.isWindow(d)&&(h=d[k],h&&(d[k]=null),n.event.triggered=q,d[q](),n.event.triggered=void 0,h&&(d[k]=h)),b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(L.get(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(g.namespace))&&(a.handleObj=g,a.data=g.data,e=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==e&&(a.result=e)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!==this;i=i.parentNode||this)if(i.disabled!==!0||"click"!==a.type){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>=0:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]*)\/>/gi,bb=/<([\w:]+)/,cb=/<|&#?\w+;/,db=/<(?:script|style|link)/i,eb=/checked\s*(?:[^=]|=\s*.checked.)/i,fb=/^$|\/(?:java|ecma)script/i,gb=/^true\/(.*)/,hb=/^\s*\s*$/g,ib={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ib.optgroup=ib.option,ib.tbody=ib.tfoot=ib.colgroup=ib.caption=ib.thead,ib.th=ib.td;function jb(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function kb(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function lb(a){var b=gb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function mb(a,b){for(var c=0,d=a.length;d>c;c++)L.set(a[c],"globalEval",!b||L.get(b[c],"globalEval"))}function nb(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(L.hasData(a)&&(f=L.access(a),g=L.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;d>c;c++)n.event.add(b,e,j[e][c])}M.hasData(a)&&(h=M.access(a),i=n.extend({},h),M.set(b,i))}}function ob(a,b){var c=a.getElementsByTagName?a.getElementsByTagName(b||"*"):a.querySelectorAll?a.querySelectorAll(b||"*"):[];return void 0===b||b&&n.nodeName(a,b)?n.merge([a],c):c}function pb(a,b){var c=b.nodeName.toLowerCase();"input"===c&&T.test(a.type)?b.checked=a.checked:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}n.extend({clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=n.contains(a.ownerDocument,a);if(!(k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(g=ob(h),f=ob(a),d=0,e=f.length;e>d;d++)pb(f[d],g[d]);if(b)if(c)for(f=f||ob(a),g=g||ob(h),d=0,e=f.length;e>d;d++)nb(f[d],g[d]);else nb(a,h);return g=ob(h,"script"),g.length>0&&mb(g,!i&&ob(a,"script")),h},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,k=b.createDocumentFragment(),l=[],m=0,o=a.length;o>m;m++)if(e=a[m],e||0===e)if("object"===n.type(e))n.merge(l,e.nodeType?[e]:e);else if(cb.test(e)){f=f||k.appendChild(b.createElement("div")),g=(bb.exec(e)||["",""])[1].toLowerCase(),h=ib[g]||ib._default,f.innerHTML=h[1]+e.replace(ab,"<$1>")+h[2],j=h[0];while(j--)f=f.lastChild;n.merge(l,f.childNodes),f=k.firstChild,f.textContent=""}else l.push(b.createTextNode(e));k.textContent="",m=0;while(e=l[m++])if((!d||-1===n.inArray(e,d))&&(i=n.contains(e.ownerDocument,e),f=ob(k.appendChild(e),"script"),i&&mb(f),c)){j=0;while(e=f[j++])fb.test(e.type||"")&&c.push(e)}return k},cleanData:function(a){for(var b,c,d,e,f=n.event.special,g=0;void 0!==(c=a[g]);g++){if(n.acceptData(c)&&(e=c[L.expando],e&&(b=L.cache[e]))){if(b.events)for(d in b.events)f[d]?n.event.remove(c,d):n.removeEvent(c,d,b.handle);L.cache[e]&&delete L.cache[e]}delete M.cache[c[M.expando]]}}}),n.fn.extend({text:function(a){return J(this,function(a){return void 0===a?n.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=a)})},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=jb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=jb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?n.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||n.cleanData(ob(c)),c.parentNode&&(b&&n.contains(c.ownerDocument,c)&&mb(ob(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(n.cleanData(ob(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return J(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!db.test(a)&&!ib[(bb.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(ab,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(ob(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,n.cleanData(ob(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,m=this,o=l-1,p=a[0],q=n.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&eb.test(p))return this.each(function(c){var d=m.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(c=n.buildFragment(a,this[0].ownerDocument,!1,this),d=c.firstChild,1===c.childNodes.length&&(c=d),d)){for(f=n.map(ob(c,"script"),kb),g=f.length;l>j;j++)h=c,j!==o&&(h=n.clone(h,!0,!0),g&&n.merge(f,ob(h,"script"))),b.call(this[j],h,j);if(g)for(i=f[f.length-1].ownerDocument,n.map(f,lb),j=0;g>j;j++)h=f[j],fb.test(h.type||"")&&!L.access(h,"globalEval")&&n.contains(i,h)&&(h.src?n._evalUrl&&n._evalUrl(h.src):n.globalEval(h.textContent.replace(hb,"")))}return this}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=[],e=n(a),g=e.length-1,h=0;g>=h;h++)c=h===g?this:this.clone(!0),n(e[h])[b](c),f.apply(d,c.get());return this.pushStack(d)}});var qb,rb={};function sb(b,c){var d,e=n(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:n.css(e[0],"display");return e.detach(),f}function tb(a){var b=l,c=rb[a];return c||(c=sb(a,b),"none"!==c&&c||(qb=(qb||n("