From 04ca14163e373c405a52d49111aa3136236fcd67 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 24 Jul 2026 11:59:39 +0100 Subject: [PATCH] more code tidy-up --- .air.toml | 6 +- cmd/satellite/main.go | 5 + main.go => cmd/stream-tools/main.go | 0 learning/public/cards.js | 2 - music/public/music.js | 2 - twitch/api.go | 209 +++++++++++++++++++++------- twitch/public/labels.js | 2 - twitch/twitch.go | 63 ++++----- 8 files changed, 195 insertions(+), 94 deletions(-) create mode 100644 cmd/satellite/main.go rename main.go => cmd/stream-tools/main.go (100%) diff --git a/.air.toml b/.air.toml index 23141a3..ceab2de 100644 --- a/.air.toml +++ b/.air.toml @@ -7,10 +7,10 @@ tmp_dir = "tmp" [build] args_bin = [] - bin = "./tmp/main" - cmd = "go build -o ./tmp/main ." + bin = "./tmp/stream-tools" + cmd = "go build -o ./tmp/stream-tools ./cmd/stream-tools" delay = 1000 - entrypoint = ["./tmp/main"] + entrypoint = ["./tmp/stream-tools"] exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] diff --git a/cmd/satellite/main.go b/cmd/satellite/main.go new file mode 100644 index 0000000..df4ea4b --- /dev/null +++ b/cmd/satellite/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + // TODO: build satellite service for offline-fetching subscribers and cheers +} diff --git a/main.go b/cmd/stream-tools/main.go similarity index 100% rename from main.go rename to cmd/stream-tools/main.go diff --git a/learning/public/cards.js b/learning/public/cards.js index a25da1a..eee14ed 100644 --- a/learning/public/cards.js +++ b/learning/public/cards.js @@ -43,8 +43,6 @@ async function updateTitle() { console.error("Connection lost, or an error has occurred."); console.log("Attempting to reconnect..."); - // TODO: stop reconnecting after 10 failed attempts - setTimeout(() => { updateTitle(); }, 1000); diff --git a/music/public/music.js b/music/public/music.js index 115452b..dfd2e42 100644 --- a/music/public/music.js +++ b/music/public/music.js @@ -37,8 +37,6 @@ async function sync() { console.error("Connection lost, or an error has occurred."); console.log("Attempting to reconnect..."); - // TODO: stop reconnecting after 10 failed attempts - setTimeout(() => { sync(); }, 1000); diff --git a/twitch/api.go b/twitch/api.go index 8094509..f9d265b 100644 --- a/twitch/api.go +++ b/twitch/api.go @@ -8,25 +8,40 @@ import ( "io" "log" "net/http" + "net/url" "os" "path" - "strings" + "strconv" "codeberg.org/arimelody/ari-stream-tools/twitch/api" ) -func (srv *Service) userIDsFromNames(usernames []string) ([]string, error) { - if len(usernames) == 0 { return []string{}, nil } - - url := strings.Builder{} - url.WriteString(api.BASE_URL) - url.WriteString("/users?login=") - url.WriteString(usernames[0]) - for _, username := range usernames { - url.WriteString("&login=") - url.WriteString(username) +type ( + FullUser struct { + ID string `json:"id"` + Login string `json:"login"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + BroadcasterType string `json:"broadcaster_type"` + Description string `json:"description"` + ProfileImageURL string `json:"profile_image_url"` + OfflineImageURL string `json:"offline_image_url"` + ViewCount int `json:"view_count"` + Email string `json:"email"` + CreatedAt string `json:"created_at"` } + GetUsersResponse struct { + Data []FullUser `json:"data"` + } +) +func (srv *Service) getUsers(ids []string, logins []string) (*GetUsersResponse, error) { + if len(ids) == 0 && len(logins) == 0 { return nil, nil } + + url, err := url.Parse(api.BASE_URL + "/users?" + url.Values{ + "id": ids, "login": logins, + }.Encode()) + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, err } @@ -41,36 +56,12 @@ func (srv *Service) userIDsFromNames(usernames []string) ([]string, error) { return nil, fmt.Errorf("%s: %s", res.Status, string(body)) } - type ( - UserData struct { - ID string `json:"id"` - Login string `json:"login"` - DisplayName string `json:"display_name"` - Type string `json:"type"` - BroadcasterType string `json:"broadcaster_type"` - Description string `json:"description"` - ProfileImageURL string `json:"profile_image_url"` - OfflineImageURL string `json:"offline_image_url"` - ViewCount int `json:"view_count"` - Email string `json:"email"` - CreatedAt string `json:"created_at"` - } - - Response struct { - Users []UserData `json:"data"` - } - ) - data := Response{} - if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + data := &GetUsersResponse{} + if err := json.NewDecoder(res.Body).Decode(data); err != nil { return nil, err } - userIDs := []string{} - for _, user := range data.Users { - userIDs = append(userIDs, user.ID) - } - - return userIDs, nil + return data, nil } func (srv *Service) registerEventSubSession(session *api.EventSubSession) error { @@ -89,13 +80,13 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { var event api.FollowEvent err := json.Unmarshal(jsonData, &event) if err != nil { return fmt.Errorf("Failed to cast to api.FollowEvent: %v", err) } - log.Printf("New follow: %s", event.UserLogin) - srv.labels.LatestFollower.Text = event.UserLogin - srv.labels.LatestFollower.C <- event.UserLogin + log.Printf("New follow: %s", event.UserName) + srv.labels.LatestFollower.Text = event.UserName + srv.labels.LatestFollower.C <- event.UserName if err := os.WriteFile( path.Join(DATA_PATH, "state", LABEL_LATEST_FOLLOWER), - []byte(event.UserLogin), 0640, + []byte(event.UserName), 0640, ); err != nil { return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_FOLLOWER, err) } @@ -104,13 +95,13 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { var event api.SubscribeEvent err := json.Unmarshal(jsonData, &event) if err != nil { return fmt.Errorf("Failed to cast to api.SubscribeEvent: %v", err) } - log.Printf("New subscription: %s", event.UserLogin) - srv.labels.LatestSubscriber.Text = event.UserLogin - srv.labels.LatestSubscriber.C <- event.UserLogin + log.Printf("New subscription: %s", event.UserName) + srv.labels.LatestSubscriber.Text = event.UserName + srv.labels.LatestSubscriber.C <- event.UserName if err := os.WriteFile( path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER), - []byte(event.UserLogin), 0640, + []byte(event.UserName), 0640, ); err != nil { return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_SUBSCRIBER, err) } @@ -119,13 +110,13 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { var event api.CheerEvent err := json.Unmarshal(jsonData, &event) if err != nil { return fmt.Errorf("Failed to cast to api.CheerEvent: %v", err) } - log.Printf("%s cheered x%d bits: %s", event.UserLogin, event.Bits, event.Message) - srv.labels.LatestCheer.Text = event.UserLogin - srv.labels.LatestCheer.C <- event.UserLogin + log.Printf("%s cheered x%d bits: %s", event.UserName, event.Bits, event.Message) + srv.labels.LatestCheer.Text = event.UserName + srv.labels.LatestCheer.C <- event.UserName if err := os.WriteFile( path.Join(DATA_PATH, "state", LABEL_LATEST_CHEER), - []byte(event.UserLogin), 0640, + []byte(event.UserName), 0640, ); err != nil { return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_CHEER, err) } @@ -134,7 +125,7 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { var event api.RaidEvent err := json.Unmarshal(jsonData, &event) if err != nil { return fmt.Errorf("Failed to cast to api.RaidEvent: %v", err) } - log.Printf("%s is now raiding with %d viewers!", event.FromUserLogin, event.Viewers) + log.Printf("%s is now raiding with %d viewers!", event.FromUserName, event.Viewers) case "channel.channel_points_custom_reward_redemption.add": var event api.ChannelPointCustomRewardRedeemEvent @@ -142,7 +133,7 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { if err != nil { return fmt.Errorf("Failed to cast to api.ChannelPointCustomRewardRedeemEvent: %v", err) } log.Printf( "%s just redeemed %s for %d channel points.", - event.UserLogin, + event.UserName, event.Reward.Title, event.Reward.Cost, ) @@ -153,8 +144,8 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { if err != nil { return fmt.Errorf("Failed to cast to api.ShoutoutCreate: %v", err) } log.Printf( "%s gave a shoutout to %s.", - event.FromUserLogin, - event.ToUserLogin, + event.FromUserName, + event.ToUserName, ) case "channel.chat.message": @@ -341,3 +332,113 @@ func (srv *Service) subscribeToDefaultEvents(ctx context.Context) { log.Printf("Failed to subscribe to channel.chat.message_delete: %v", err) } } + +type ( + Follower struct { + FollowedAt string `json:"followed_at"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + } + + Pagination struct { + Cursor string `json:"cursor"` + } + + GetFollowersResponse struct { + Data []Follower `json:"data"` + Pagination Pagination `json:"pagination"` + Total int `json:"total"` + Points int `json:"points"` + } +) +func (srv *Service) getFollowers( + ctx context.Context, + broadcasterID string, + limit int, +) (*GetFollowersResponse, error) { + client := srv.oauthConfig.Client(ctx, srv.oauthToken) + u, err := url.Parse(api.BASE_URL + "/channels/followers?" + url.Values{ + "broadcaster_id": []string{ broadcasterID }, + "first": []string { strconv.Itoa(limit) }, + }.Encode()) + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { return nil, err } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Client-Id", srv.clientID) + req.Header.Set("Authorization", "Bearer " + srv.oauthToken.AccessToken) + + res, err := client.Do(req) + if err != nil { return nil, err } + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("%s: %s", res.Status, string(body)) + } + + resData := &GetFollowersResponse{} + if err := json.NewDecoder(res.Body).Decode(resData); err != nil { return nil, err } + + return resData, nil +} + +type ( + Subscriber struct { + BroadcasterID string `json:"broadcaster_id"` + BroadcasterLogin string `json:"broadcaster_login"` + BroadcasterName string `json:"broadcaster_name"` + + GifterID string `json:"gifter_id"` + GifterLogin string `json:"gifter_login"` + GifterName string `json:"gifter_name"` + + IsGift bool `json:"is_gift"` + Tier string `json:"tier"` + PlanName string `json:"plan_name"` + + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + } + + GetSubscribersResponse struct { + Data []Subscriber `json:"data"` + Pagination Pagination `json:"pagination"` + Total int `json:"total"` + Points int `json:"points"` + } +) +// Unfortunately, this function is very unreliable for pulling chronological +// subscription records. Twitch API does not currently provide a mechanism for +// fetching the latest subscriber; this will need to be tracked manually. +func (srv *Service) getSubscribers( + ctx context.Context, + broadcasterID string, + limit int, +) (*GetSubscribersResponse, error) { + client := srv.oauthConfig.Client(ctx, srv.oauthToken) + u, err := url.Parse(api.BASE_URL + "/subscriptions?" + url.Values{ + "broadcaster_id": []string{ broadcasterID }, + "first": []string { strconv.Itoa(limit) }, + }.Encode()) + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { return nil, err } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Client-Id", srv.clientID) + req.Header.Set("Authorization", "Bearer " + srv.oauthToken.AccessToken) + + res, err := client.Do(req) + if err != nil { return nil, err } + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("%s: %s", res.Status, string(body)) + } + + resData := &GetSubscribersResponse{} + if err := json.NewDecoder(res.Body).Decode(resData); err != nil { return nil, err } + + return resData, nil +} diff --git a/twitch/public/labels.js b/twitch/public/labels.js index 7577aca..07bb8e1 100644 --- a/twitch/public/labels.js +++ b/twitch/public/labels.js @@ -40,8 +40,6 @@ async function sync() { console.error("Connection lost, or an error has occurred."); console.log("Attempting to reconnect..."); - // TODO: stop reconnecting after 10 failed attempts - setTimeout(() => { sync(); }, 1000); diff --git a/twitch/twitch.go b/twitch/twitch.go index 27d3cc5..844a8c7 100644 --- a/twitch/twitch.go +++ b/twitch/twitch.go @@ -124,17 +124,14 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) { port: opts.Port, labels: &twitchLabels{ LatestFollower: &twitchLabel{ - Text: "some_follower", C: latestFollowerC, Broadcast: latestFollowerBroadcast, }, LatestSubscriber: &twitchLabel{ - Text: "some_subscriber", C: latestSubscriberC, Broadcast: latestSubscriberBroadcast, }, LatestCheer: &twitchLabel{ - Text: "some_cheer", C: latestCheerC, Broadcast: latestCheerBroadcast, }, @@ -176,19 +173,6 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) { } } - if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_FOLLOWER)); err == nil { - srv.labels.LatestFollower.Text = string(data) - log.Printf("Loaded latest follower: %s", string(data)) - } - if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER)); err == nil { - srv.labels.LatestSubscriber.Text = string(data) - log.Printf("Loaded latest subscriber: %s", string(data)) - } - if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_CHEER)); err == nil { - srv.labels.LatestCheer.Text = string(data) - log.Printf("Loaded latest cheer: %s", string(data)) - } - return srv, nil } @@ -282,27 +266,44 @@ func (srv *Service) Run(ctx context.Context) { if srv.oauthToken == nil || !srv.oauthToken.Valid() { log.Printf("Log in with Twitch: http://localhost:%d/twitch/login", srv.port) } else { - srv.start(ctx) + if err := srv.start(ctx); err != nil { + log.Printf("Failed to start Twitch service: %v", err) + } } } -func (srv *Service) start(ctx context.Context) { - userIDs, err := srv.userIDsFromNames([]string{ srv.channelName }) - if err != nil { - log.Printf( - "Failed to resolve Twitch username \"%s\": %v", - srv.channelName, - err, - ) - return +func (srv *Service) start(ctx context.Context) error { + if res, err := srv.getUsers(nil, []string{ srv.channelName }); err == nil { + if len(res.Data) == 0 { + return fmt.Errorf("resolve username \"%s\", it may not exist?", srv.channelName) + } + srv.channelID = res.Data[0].ID + } else { return fmt.Errorf("fetch Twitch users: %v", err) } + + if res, err := srv.getFollowers(ctx, srv.channelID, 1); err == nil { + if len(res.Data) > 0 { + srv.labels.LatestFollower.Text = res.Data[0].UserName + log.Printf("Loaded latest follower: %s", res.Data[0].UserName) + } + } else { + log.Printf("Failed to fetch latest follower: %v", err) } - if len(userIDs) == 0 { - fmt.Printf("Failed to resolve username \"%s\", it may not exist?", srv.channelName) - return + + // TODO: build satellite service for offline-fetching subscribers and cheers + + if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER)); err == nil { + srv.labels.LatestSubscriber.Text = string(data) + log.Printf("Loaded latest subscriber: %s", string(data)) + } + + if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_CHEER)); err == nil { + srv.labels.LatestCheer.Text = string(data) + log.Printf("Loaded latest cheer: %s", string(data)) } - srv.channelID = userIDs[0] srv.startWebsocketListener(ctx) + + return nil } func (srv *Service) startWebsocketListener(ctx context.Context) { @@ -341,7 +342,7 @@ func (srv *Service) startWebsocketListener(ctx context.Context) { srv.subscribeToDefaultEvents(ctx) - log.Printf("Connected to Twitch as %s.", srv.channelName) + log.Printf("Connected to Twitch for channel %s.", srv.channelName) } if message.Metadata.MessageType == string(api.NOTIFICATION) {