home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

early implementation of ari melody LIVE tracker

ari melody 92747967 f7b3faf8

+335 -39
+4
controller/config.go
··· 77 77 if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env } 78 78 if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env } 79 79 80 + if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env } 81 + if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env } 82 + if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env } 83 + 80 84 return nil 81 85 }
+97
controller/twitch.go
··· 1 + package controller 2 + 3 + import ( 4 + "arimelody-web/model" 5 + "bytes" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "net/url" 10 + "time" 11 + ) 12 + 13 + const TWITCH_API_BASE = "https://api.twitch.tv/helix/" 14 + 15 + func TwitchSetup(app *model.AppState) error { 16 + app.Twitch = &model.TwitchState{} 17 + err := RefreshTwitchToken(app) 18 + return err 19 + } 20 + 21 + func RefreshTwitchToken(app *model.AppState) error { 22 + if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) { 23 + return nil 24 + } 25 + 26 + requestUrl, _ := url.Parse("https://id.twitch.tv/oauth2/token") 27 + req, _ := http.NewRequest(http.MethodPost, requestUrl.String(), bytes.NewBuffer([]byte(url.Values{ 28 + "client_id": []string{ app.Config.Twitch.ClientID }, 29 + "client_secret": []string{ app.Config.Twitch.Secret }, 30 + "grant_type": []string{ "client_credentials" }, 31 + }.Encode()))) 32 + 33 + res, err := http.DefaultClient.Do(req) 34 + if err != nil { 35 + return err 36 + } 37 + 38 + type TwitchOAuthToken struct { 39 + AccessToken string `json:"access_token"` 40 + ExpiresIn int `json:"expires_in"` 41 + TokenType string `json:"token_type"` 42 + } 43 + oauthResponse := TwitchOAuthToken{} 44 + err = json.NewDecoder(res.Body).Decode(&oauthResponse) 45 + if err != nil { 46 + return err 47 + } 48 + 49 + app.Twitch.Token = &model.TwitchOAuthToken{ 50 + AccessToken: oauthResponse.AccessToken, 51 + ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(), 52 + TokenType: oauthResponse.TokenType, 53 + } 54 + 55 + return nil 56 + } 57 + 58 + var lastStreamState *model.TwitchStreamInfo 59 + var lastStreamStateAt time.Time 60 + 61 + func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) { 62 + if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) { 63 + return lastStreamState, nil 64 + } 65 + 66 + fmt.Print("MAKING COSTLY REQUEST TO TWITCH.TV API...\n") 67 + 68 + requestUrl, _ := url.Parse(TWITCH_API_BASE + "streams") 69 + requestUrl.RawQuery = url.Values{ 70 + "user_login": []string{ broadcaster }, 71 + }.Encode() 72 + req, _ := http.NewRequest(http.MethodGet, requestUrl.String(), nil) 73 + req.Header.Set("Client-Id", app.Config.Twitch.ClientID) 74 + req.Header.Set("Authorization", "Bearer " + app.Twitch.Token.AccessToken) 75 + 76 + res, err := http.DefaultClient.Do(req) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + type StreamsResponse struct { 82 + Data []model.TwitchStreamInfo `json:"data"` 83 + } 84 + streamInfo := StreamsResponse{} 85 + err = json.NewDecoder(res.Body).Decode(&streamInfo) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + if len(streamInfo.Data) == 0 { 91 + return nil, nil 92 + } 93 + 94 + lastStreamState = &streamInfo.Data[0] 95 + lastStreamStateAt = time.Now().UTC() 96 + return lastStreamState, nil 97 + }
+8 -39
main.go
··· 22 22 "arimelody-web/cursor" 23 23 "arimelody-web/log" 24 24 "arimelody-web/model" 25 - "arimelody-web/templates" 26 25 "arimelody-web/view" 27 26 28 27 "github.com/jmoiron/sqlx" ··· 40 39 41 40 app := model.AppState{ 42 41 Config: controller.GetConfig(), 42 + Twitch: nil, 43 43 } 44 44 45 45 // initialise database connection ··· 460 460 // handle DB migrations 461 461 controller.CheckDBVersionAndMigrate(app.DB) 462 462 463 + err = controller.TwitchSetup(&app) 464 + if err != nil { 465 + fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err) 466 + } 467 + 463 468 // initial invite code 464 469 accountsCount := 0 465 470 err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") ··· 511 516 mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) 512 517 mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) 513 518 mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) 514 - mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) 519 + mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) 515 520 mux.Handle("/cursor-ws", cursor.Handler(app)) 516 - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 517 - if r.Method == http.MethodHead { 518 - w.WriteHeader(http.StatusOK) 519 - return 520 - } 521 - 522 - if r.URL.Path == "/" || r.URL.Path == "/index.html" { 523 - err := templates.IndexTemplate.Execute(w, nil) 524 - if err != nil { 525 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 526 - } 527 - return 528 - } 529 - staticHandler("public").ServeHTTP(w, r) 530 - })) 521 + mux.Handle("/", view.IndexHandler(app)) 531 522 532 523 return mux 533 - } 534 - 535 - func staticHandler(directory string) http.Handler { 536 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 537 - info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) 538 - 539 - // does the file exist? 540 - if err != nil { 541 - if errors.Is(err, os.ErrNotExist) { 542 - http.NotFound(w, r) 543 - return 544 - } 545 - } 546 - 547 - // is thjs a directory? (forbidden) 548 - if info.IsDir() { 549 - http.NotFound(w, r) 550 - return 551 - } 552 - 553 - http.FileServer(http.Dir(directory)).ServeHTTP(w, r) 554 - }) 555 524 } 556 525 557 526 var PoweredByStrings = []string{
+8
model/appstate.go
··· 21 21 Secret string `toml:"secret"` 22 22 } 23 23 24 + TwitchConfig struct { 25 + Broadcaster string `toml:"broadcaster"` 26 + ClientID string `toml:"client_id"` 27 + Secret string `toml:"secret"` 28 + } 29 + 24 30 Config struct { 25 31 BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` 26 32 Host string `toml:"host"` ··· 29 35 TrustedProxies []string `toml:"trusted_proxies"` 30 36 DB DBConfig `toml:"db"` 31 37 Discord DiscordConfig `toml:"discord"` 38 + Twitch TwitchConfig `toml:"twitch"` 32 39 } 33 40 34 41 AppState struct { 35 42 DB *sqlx.DB 36 43 Config Config 37 44 Log log.Logger 45 + Twitch *TwitchState 38 46 } 39 47 )
+43
model/twitch.go
··· 1 + package model 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + ) 8 + 9 + type ( 10 + TwitchOAuthToken struct { 11 + AccessToken string 12 + ExpiresAt time.Time 13 + TokenType string 14 + } 15 + 16 + TwitchState struct { 17 + Token *TwitchOAuthToken 18 + } 19 + 20 + TwitchStreamInfo struct { 21 + ID string `json:"id"` 22 + UserID string `json:"user_id"` 23 + UserLogin string `json:"user_login"` 24 + UserName string `json:"user_name"` 25 + GameID string `json:"game_id"` 26 + GameName string `json:"game_name"` 27 + Type string `json:"type"` 28 + Title string `json:"title"` 29 + ViewerCount int `json:"viewer_count"` 30 + StartedAt string `json:"started_at"` 31 + Language string `json:"language"` 32 + ThumbnailURL string `json:"thumbnail_url"` 33 + TagIDs []string `json:"tag_ids"` 34 + Tags []string `json:"tags"` 35 + IsMature bool `json:"is_mature"` 36 + } 37 + ) 38 + 39 + func (info *TwitchStreamInfo) Thumbnail(width int, height int) string { 40 + res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1) 41 + res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1) 42 + return res 43 + }
+1
public/style/colours.css
··· 6 6 --secondary: #f8e05b; 7 7 --tertiary: #f788fe; 8 8 --links: #5eb2ff; 9 + --live: #fd3737; 9 10 } 10 11 11 12 @media (prefers-color-scheme: light) {
+81
public/style/index.css
··· 222 222 transform: translate(-2px, -2px); 223 223 box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee; 224 224 } 225 + 226 + #live-banner { 227 + margin: 1em 0 2em 0; 228 + padding: 1em; 229 + border-radius: 4px; 230 + border: 1px solid var(--primary); 231 + box-shadow: 0 0 8px var(--primary); 232 + } 233 + 234 + #live-banner h2 { 235 + margin: 0 0 .4em 0; 236 + color: var(--on-background); 237 + } 238 + 239 + #live-banner p { 240 + margin: 0; 241 + } 242 + 243 + .live-highlight { 244 + color: var(--primary); 245 + } 246 + 247 + .live-preview { 248 + display: flex; 249 + flex-direction: row; 250 + justify-content: center; 251 + gap: 1em; 252 + } 253 + 254 + .live-preview div:first-of-type { 255 + text-align: center; 256 + } 257 + 258 + .live-thumbnail { 259 + border-radius: 4px; 260 + } 261 + 262 + .live-button { 263 + margin: .2em; 264 + padding: .4em .5em; 265 + display: inline-block; 266 + color: var(--primary); 267 + border: 1px solid var(--primary); 268 + border-radius: 4px; 269 + transition: color .1s linear, background-color .1s linear, box-shadow .1s linear; 270 + } 271 + 272 + .live-button:hover { 273 + color: var(--background); 274 + background-color: var(--primary); 275 + box-shadow: 0 0 8px var(--primary); 276 + text-decoration: none; 277 + } 278 + 279 + .live-info { 280 + display: flex; 281 + flex-direction: column; 282 + gap: .3em; 283 + overflow-x: hidden; 284 + } 285 + 286 + .live-game { 287 + overflow: hidden; 288 + text-wrap: nowrap; 289 + text-overflow: ellipsis; 290 + } 291 + 292 + .live-game .live-game-prefix { 293 + opacity: .8; 294 + } 295 + 296 + .live-title { 297 + display: -webkit-box; 298 + -webkit-line-clamp: 2; 299 + -webkit-box-orient: vertical; 300 + overflow: hidden; 301 + } 302 + 303 + .live-viewers { 304 + opacity: .5; 305 + }
+45
view/index.go
··· 1 + package view 2 + 3 + import ( 4 + "arimelody-web/controller" 5 + "arimelody-web/model" 6 + "arimelody-web/templates" 7 + "fmt" 8 + "net/http" 9 + "os" 10 + ) 11 + 12 + func IndexHandler(app *model.AppState) http.Handler { 13 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 + if r.Method == http.MethodHead { 15 + w.WriteHeader(http.StatusOK) 16 + return 17 + } 18 + 19 + type IndexData struct { 20 + TwitchStatus *model.TwitchStreamInfo 21 + } 22 + 23 + var err error 24 + var twitchStatus *model.TwitchStreamInfo = nil 25 + if len(app.Config.Twitch.Broadcaster) > 0 { 26 + twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster) 27 + if err != nil { 28 + fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err) 29 + } 30 + } 31 + 32 + if r.URL.Path == "/" || r.URL.Path == "/index.html" { 33 + err := templates.IndexTemplate.Execute(w, IndexData{ 34 + TwitchStatus: twitchStatus, 35 + }) 36 + if err != nil { 37 + fmt.Fprintf(os.Stderr, "WARN: Failed to render index page: %v\n", err) 38 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 39 + } 40 + return 41 + } 42 + 43 + StaticHandler("public").ServeHTTP(w, r) 44 + }) 45 + }
+31
view/static.go
··· 1 + package view 2 + 3 + import ( 4 + "errors" 5 + "net/http" 6 + "os" 7 + "path/filepath" 8 + ) 9 + 10 + func StaticHandler(directory string) http.Handler { 11 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 + info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) 13 + 14 + // does the file exist? 15 + if err != nil { 16 + if errors.Is(err, os.ErrNotExist) { 17 + http.NotFound(w, r) 18 + return 19 + } 20 + } 21 + 22 + // is thjs a directory? (forbidden) 23 + if info.IsDir() { 24 + http.NotFound(w, r) 25 + return 26 + } 27 + 28 + http.FileServer(http.Dir(directory)).ServeHTTP(w, r) 29 + }) 30 + } 31 +
+17
views/index.html
··· 22 22 23 23 {{define "content"}} 24 24 <main> 25 + {{if .TwitchStatus}} 26 + <div id="live-banner"> 27 + <h2>ari is <span class="live-highlight">LIVE</span> right now!</h2> 28 + <div class="live-preview"> 29 + <div> 30 + <img src="{{.TwitchStatus.Thumbnail 144 81}}" alt="livestream thumbnail" class="live-thumbnail"> 31 + <a href="https://twitch.tv/{{.TwitchStatus.UserName}}" class="live-button">join in!</a> 32 + </div> 33 + <div class="live-info"> 34 + <p class="live-game"><span class="live-game-prefix">streaming:</span> {{.TwitchStatus.GameName}}</p> 35 + <p class="live-title">{{.TwitchStatus.Title}}</p> 36 + <p class="live-viewers">{{.TwitchStatus.ViewerCount}} viewers</p> 37 + </div> 38 + </div> 39 + </div> 40 + {{end}} 41 + 25 42 <h1 class="typeout"> 26 43 # hello, world! 27 44 </h1>