diff --git a/controller/config.go b/controller/config.go index 1d3cbbb..fdfa756 100644 --- a/controller/config.go +++ b/controller/config.go @@ -77,5 +77,9 @@ func handleConfigOverrides(config *model.Config) error { if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env } if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env } + if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env } + if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env } + if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env } + return nil } diff --git a/controller/twitch.go b/controller/twitch.go new file mode 100644 index 0000000..f7dc509 --- /dev/null +++ b/controller/twitch.go @@ -0,0 +1,97 @@ +package controller + +import ( + "arimelody-web/model" + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +const TWITCH_API_BASE = "https://api.twitch.tv/helix/" + +func TwitchSetup(app *model.AppState) error { + app.Twitch = &model.TwitchState{} + err := RefreshTwitchToken(app) + return err +} + +func RefreshTwitchToken(app *model.AppState) error { + if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) { + return nil + } + + requestUrl, _ := url.Parse("https://id.twitch.tv/oauth2/token") + req, _ := http.NewRequest(http.MethodPost, requestUrl.String(), bytes.NewBuffer([]byte(url.Values{ + "client_id": []string{ app.Config.Twitch.ClientID }, + "client_secret": []string{ app.Config.Twitch.Secret }, + "grant_type": []string{ "client_credentials" }, + }.Encode()))) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + type TwitchOAuthToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + oauthResponse := TwitchOAuthToken{} + err = json.NewDecoder(res.Body).Decode(&oauthResponse) + if err != nil { + return err + } + + app.Twitch.Token = &model.TwitchOAuthToken{ + AccessToken: oauthResponse.AccessToken, + ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(), + TokenType: oauthResponse.TokenType, + } + + return nil +} + +var lastStreamState *model.TwitchStreamInfo +var lastStreamStateAt time.Time + +func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) { + if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) { + return lastStreamState, nil + } + + fmt.Print("MAKING COSTLY REQUEST TO TWITCH.TV API...\n") + + requestUrl, _ := url.Parse(TWITCH_API_BASE + "streams") + requestUrl.RawQuery = url.Values{ + "user_login": []string{ broadcaster }, + }.Encode() + req, _ := http.NewRequest(http.MethodGet, requestUrl.String(), nil) + req.Header.Set("Client-Id", app.Config.Twitch.ClientID) + req.Header.Set("Authorization", "Bearer " + app.Twitch.Token.AccessToken) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + type StreamsResponse struct { + Data []model.TwitchStreamInfo `json:"data"` + } + streamInfo := StreamsResponse{} + err = json.NewDecoder(res.Body).Decode(&streamInfo) + if err != nil { + return nil, err + } + + if len(streamInfo.Data) == 0 { + return nil, nil + } + + lastStreamState = &streamInfo.Data[0] + lastStreamStateAt = time.Now().UTC() + return lastStreamState, nil +} diff --git a/main.go b/main.go index 29539ac..8f37639 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,6 @@ import ( "arimelody-web/cursor" "arimelody-web/log" "arimelody-web/model" - "arimelody-web/templates" "arimelody-web/view" "github.com/jmoiron/sqlx" @@ -40,6 +39,7 @@ func main() { app := model.AppState{ Config: controller.GetConfig(), + Twitch: nil, } // initialise database connection @@ -460,6 +460,11 @@ func main() { // handle DB migrations controller.CheckDBVersionAndMigrate(app.DB) + err = controller.TwitchSetup(&app) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err) + } + // initial invite code accountsCount := 0 err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") @@ -511,49 +516,13 @@ func createServeMux(app *model.AppState) *http.ServeMux { mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) - mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) + mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) mux.Handle("/cursor-ws", cursor.Handler(app)) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead { - w.WriteHeader(http.StatusOK) - return - } - - if r.URL.Path == "/" || r.URL.Path == "/index.html" { - err := templates.IndexTemplate.Execute(w, nil) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - return - } - staticHandler("public").ServeHTTP(w, r) - })) + mux.Handle("/", view.IndexHandler(app)) return mux } -func staticHandler(directory string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) - - // does the file exist? - if err != nil { - if errors.Is(err, os.ErrNotExist) { - http.NotFound(w, r) - return - } - } - - // is thjs a directory? (forbidden) - if info.IsDir() { - http.NotFound(w, r) - return - } - - http.FileServer(http.Dir(directory)).ServeHTTP(w, r) - }) -} - var PoweredByStrings = []string{ "nerd rage", "estrogen", diff --git a/model/appstate.go b/model/appstate.go index a910a29..861a991 100644 --- a/model/appstate.go +++ b/model/appstate.go @@ -21,6 +21,12 @@ type ( Secret string `toml:"secret"` } + TwitchConfig struct { + Broadcaster string `toml:"broadcaster"` + ClientID string `toml:"client_id"` + Secret string `toml:"secret"` + } + Config struct { BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` Host string `toml:"host"` @@ -29,11 +35,13 @@ type ( TrustedProxies []string `toml:"trusted_proxies"` DB DBConfig `toml:"db"` Discord DiscordConfig `toml:"discord"` + Twitch TwitchConfig `toml:"twitch"` } AppState struct { DB *sqlx.DB Config Config Log log.Logger + Twitch *TwitchState } ) diff --git a/model/twitch.go b/model/twitch.go new file mode 100644 index 0000000..6bca17d --- /dev/null +++ b/model/twitch.go @@ -0,0 +1,43 @@ +package model + +import ( + "fmt" + "strings" + "time" +) + +type ( + TwitchOAuthToken struct { + AccessToken string + ExpiresAt time.Time + TokenType string + } + + TwitchState struct { + Token *TwitchOAuthToken + } + + TwitchStreamInfo struct { + ID string `json:"id"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + GameID string `json:"game_id"` + GameName string `json:"game_name"` + Type string `json:"type"` + Title string `json:"title"` + ViewerCount int `json:"viewer_count"` + StartedAt string `json:"started_at"` + Language string `json:"language"` + ThumbnailURL string `json:"thumbnail_url"` + TagIDs []string `json:"tag_ids"` + Tags []string `json:"tags"` + IsMature bool `json:"is_mature"` + } +) + +func (info *TwitchStreamInfo) Thumbnail(width int, height int) string { + res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1) + res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1) + return res +} diff --git a/public/style/colours.css b/public/style/colours.css index 2bf607d..de63198 100644 --- a/public/style/colours.css +++ b/public/style/colours.css @@ -6,6 +6,7 @@ --secondary: #f8e05b; --tertiary: #f788fe; --links: #5eb2ff; + --live: #fd3737; } @media (prefers-color-scheme: light) { diff --git a/public/style/index.css b/public/style/index.css index 6edb362..f60fdb7 100644 --- a/public/style/index.css +++ b/public/style/index.css @@ -222,3 +222,84 @@ div#web-buttons { transform: translate(-2px, -2px); box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee; } + +#live-banner { + margin: 1em 0 2em 0; + padding: 1em; + border-radius: 4px; + border: 1px solid var(--primary); + box-shadow: 0 0 8px var(--primary); +} + +#live-banner h2 { + margin: 0 0 .4em 0; + color: var(--on-background); +} + +#live-banner p { + margin: 0; +} + +.live-highlight { + color: var(--primary); +} + +.live-preview { + display: flex; + flex-direction: row; + justify-content: center; + gap: 1em; +} + +.live-preview div:first-of-type { + text-align: center; +} + +.live-thumbnail { + border-radius: 4px; +} + +.live-button { + margin: .2em; + padding: .4em .5em; + display: inline-block; + color: var(--primary); + border: 1px solid var(--primary); + border-radius: 4px; + transition: color .1s linear, background-color .1s linear, box-shadow .1s linear; +} + +.live-button:hover { + color: var(--background); + background-color: var(--primary); + box-shadow: 0 0 8px var(--primary); + text-decoration: none; +} + +.live-info { + display: flex; + flex-direction: column; + gap: .3em; + overflow-x: hidden; +} + +.live-game { + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; +} + +.live-game .live-game-prefix { + opacity: .8; +} + +.live-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.live-viewers { + opacity: .5; +} diff --git a/view/index.go b/view/index.go new file mode 100644 index 0000000..995de44 --- /dev/null +++ b/view/index.go @@ -0,0 +1,45 @@ +package view + +import ( + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/templates" + "fmt" + "net/http" + "os" +) + +func IndexHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + + type IndexData struct { + TwitchStatus *model.TwitchStreamInfo + } + + var err error + var twitchStatus *model.TwitchStreamInfo = nil + if len(app.Config.Twitch.Broadcaster) > 0 { + twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err) + } + } + + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + err := templates.IndexTemplate.Execute(w, IndexData{ + TwitchStatus: twitchStatus, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render index page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + StaticHandler("public").ServeHTTP(w, r) + }) +} diff --git a/view/static.go b/view/static.go new file mode 100644 index 0000000..52263a2 --- /dev/null +++ b/view/static.go @@ -0,0 +1,31 @@ +package view + +import ( + "errors" + "net/http" + "os" + "path/filepath" +) + +func StaticHandler(directory string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) + + // does the file exist? + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.NotFound(w, r) + return + } + } + + // is thjs a directory? (forbidden) + if info.IsDir() { + http.NotFound(w, r) + return + } + + http.FileServer(http.Dir(directory)).ServeHTTP(w, r) + }) +} + diff --git a/views/index.html b/views/index.html index f639c55..98a862c 100644 --- a/views/index.html +++ b/views/index.html @@ -22,6 +22,23 @@ {{define "content"}}
+ {{if .TwitchStatus}} +
+

ari is LIVE right now!

+
+
+ livestream thumbnail + join in! +
+
+

streaming: {{.TwitchStatus.GameName}}

+

{{.TwitchStatus.Title}}

+

{{.TwitchStatus.ViewerCount}} viewers

+
+
+
+ {{end}} +

# hello, world!