From 9274796729a56290d788106e0ed5327643103e4a Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 17 Jun 2025 01:15:08 +0100 Subject: [PATCH 1/2] early implementation of ari melody LIVE tracker --- controller/config.go | 4 ++ controller/twitch.go | 97 ++++++++++++++++++++++++++++++++++++++++ main.go | 47 ++++--------------- model/appstate.go | 8 ++++ model/twitch.go | 43 ++++++++++++++++++ public/style/colours.css | 1 + public/style/index.css | 81 +++++++++++++++++++++++++++++++++ view/index.go | 45 +++++++++++++++++++ view/static.go | 31 +++++++++++++ views/index.html | 17 +++++++ 10 files changed, 335 insertions(+), 39 deletions(-) create mode 100644 controller/twitch.go create mode 100644 model/twitch.go create mode 100644 view/index.go create mode 100644 view/static.go 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!

From 581273370de2029c8258564ab2a180eea93f0a0d Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 17 Jun 2025 02:01:06 +0100 Subject: [PATCH 2/2] improvements to LIVE tracker --- controller/twitch.go | 3 --- main.go | 8 +++++--- model/appstate.go | 4 ++-- public/style/index.css | 41 +++++++++++++++++++++++++++++++++++------ public/style/main.css | 1 + view/index.go | 2 +- views/index.html | 2 +- 7 files changed, 45 insertions(+), 16 deletions(-) diff --git a/controller/twitch.go b/controller/twitch.go index f7dc509..98d3b9a 100644 --- a/controller/twitch.go +++ b/controller/twitch.go @@ -4,7 +4,6 @@ import ( "arimelody-web/model" "bytes" "encoding/json" - "fmt" "net/http" "net/url" "time" @@ -63,8 +62,6 @@ func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStre 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 }, diff --git a/main.go b/main.go index 8f37639..0234d79 100644 --- a/main.go +++ b/main.go @@ -460,9 +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) + if app.Config.Twitch != nil { + err = controller.TwitchSetup(&app) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err) + } } // initial invite code diff --git a/model/appstate.go b/model/appstate.go index 861a991..3a1c230 100644 --- a/model/appstate.go +++ b/model/appstate.go @@ -34,8 +34,8 @@ type ( DataDirectory string `toml:"data_dir"` TrustedProxies []string `toml:"trusted_proxies"` DB DBConfig `toml:"db"` - Discord DiscordConfig `toml:"discord"` - Twitch TwitchConfig `toml:"twitch"` + Discord *DiscordConfig `toml:"discord"` + Twitch *TwitchConfig `toml:"twitch"` } AppState struct { diff --git a/public/style/index.css b/public/style/index.css index f60fdb7..6efe241 100644 --- a/public/style/index.css +++ b/public/style/index.css @@ -231,11 +231,6 @@ div#web-buttons { box-shadow: 0 0 8px var(--primary); } -#live-banner h2 { - margin: 0 0 .4em 0; - color: var(--on-background); -} - #live-banner p { margin: 0; } @@ -252,7 +247,11 @@ div#web-buttons { } .live-preview div:first-of-type { - text-align: center; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: .3em; } .live-thumbnail { @@ -283,6 +282,36 @@ div#web-buttons { overflow-x: hidden; } +#live-banner h2 { + margin: 0; + color: var(--on-background); + font-family: 'Inter', sans-serif; + font-weight: 800; + font-style: italic; +} + +.live-pinger { + width: .5em; + height: .5em; + margin: .1em .2em; + display: inline-block; + border-radius: 100%; + background-color: var(--primary); + box-shadow: 0 0 4px var(--primary); + animation: live-pinger-pulse 1s infinite alternate ease-in-out; +} + +@keyframes live-pinger-pulse { + from { + opacity: .8; + transform: scale(1.0); + } + to { + opacity: 1; + transform: scale(1.1); + } +} + .live-game { overflow: hidden; text-wrap: nowrap; diff --git a/public/style/main.css b/public/style/main.css index 4e9e113..680ee2b 100644 --- a/public/style/main.css +++ b/public/style/main.css @@ -3,6 +3,7 @@ @import url("/style/footer.css"); @import url("/style/prideflag.css"); @import url("/style/cursor.css"); +@import url("/font/inter/inter.css"); @font-face { font-family: "Monaspace Argon"; diff --git a/view/index.go b/view/index.go index 995de44..b6e3891 100644 --- a/view/index.go +++ b/view/index.go @@ -22,7 +22,7 @@ func IndexHandler(app *model.AppState) http.Handler { var err error var twitchStatus *model.TwitchStreamInfo = nil - if len(app.Config.Twitch.Broadcaster) > 0 { + if app.Twitch != nil && 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) diff --git a/views/index.html b/views/index.html index 98a862c..6985f6c 100644 --- a/views/index.html +++ b/views/index.html @@ -24,13 +24,13 @@
{{if .TwitchStatus}}
-

ari is LIVE right now!

+

ari melody LIVE

streaming: {{.TwitchStatus.GameName}}

{{.TwitchStatus.Title}}

{{.TwitchStatus.ViewerCount}} viewers