twitch.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package feed
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "slices"
  9. "sort"
  10. "strings"
  11. "time"
  12. )
  13. type TwitchCategory struct {
  14. Slug string `json:"slug"`
  15. Name string `json:"name"`
  16. AvatarUrl string `json:"avatarURL"`
  17. ViewersCount int `json:"viewersCount"`
  18. Tags []struct {
  19. Name string `json:"tagName"`
  20. } `json:"tags"`
  21. GameReleaseDate string `json:"originalReleaseDate"`
  22. IsNew bool `json:"-"`
  23. }
  24. type TwitchChannel struct {
  25. Login string
  26. Exists bool
  27. Name string
  28. AvatarUrl string
  29. IsLive bool
  30. LiveSince time.Time
  31. Category string
  32. CategorySlug string
  33. ViewersCount int
  34. }
  35. type TwitchChannels []TwitchChannel
  36. func (channels TwitchChannels) SortByViewers() {
  37. sort.Slice(channels, func(i, j int) bool {
  38. return channels[i].ViewersCount > channels[j].ViewersCount
  39. })
  40. }
  41. func (channels TwitchChannels) SortByLive() {
  42. sort.SliceStable(channels, func(i, j int) bool {
  43. return channels[i].IsLive && !channels[j].IsLive
  44. })
  45. }
  46. type twitchOperationResponse struct {
  47. Data json.RawMessage
  48. Extensions struct {
  49. OperationName string `json:"operationName"`
  50. }
  51. }
  52. type twitchChannelShellOperationResponse struct {
  53. UserOrError struct {
  54. Type string `json:"__typename"`
  55. DisplayName string `json:"displayName"`
  56. ProfileImageUrl string `json:"profileImageURL"`
  57. Stream *struct {
  58. ViewersCount int `json:"viewersCount"`
  59. }
  60. } `json:"userOrError"`
  61. }
  62. type twitchStreamMetadataOperationResponse struct {
  63. UserOrNull *struct {
  64. Stream *struct {
  65. StartedAt string `json:"createdAt"`
  66. Game *struct {
  67. Slug string `json:"slug"`
  68. Name string `json:"name"`
  69. } `json:"game"`
  70. } `json:"stream"`
  71. } `json:"user"`
  72. }
  73. type twitchDirectoriesOperationResponse struct {
  74. Data struct {
  75. DirectoriesWithTags struct {
  76. Edges []struct {
  77. Node TwitchCategory `json:"node"`
  78. } `json:"edges"`
  79. } `json:"directoriesWithTags"`
  80. } `json:"data"`
  81. }
  82. const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
  83. const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
  84. const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]`
  85. func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) {
  86. reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
  87. request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
  88. request.Header.Add("Client-ID", twitchGqlClientId)
  89. response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request)
  90. if err != nil {
  91. return nil, err
  92. }
  93. if len(response) == 0 {
  94. return nil, errors.New("no categories could be retrieved")
  95. }
  96. edges := (response)[0].Data.DirectoriesWithTags.Edges
  97. categories := make([]TwitchCategory, 0, len(edges))
  98. for i := range edges {
  99. if slices.Contains(exclude, edges[i].Node.Slug) {
  100. continue
  101. }
  102. category := &edges[i].Node
  103. category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
  104. if len(category.Tags) > 2 {
  105. category.Tags = category.Tags[:2]
  106. }
  107. gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
  108. if err == nil {
  109. if time.Since(gameReleasedDate) < 14*24*time.Hour {
  110. category.IsNew = true
  111. }
  112. }
  113. categories = append(categories, *category)
  114. }
  115. if len(categories) > limit {
  116. categories = categories[:limit]
  117. }
  118. return categories, nil
  119. }
  120. const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
  121. // TODO: rework
  122. // The operations for multiple channels can all be sent in a single request
  123. // rather than sending a separate request for each channel. Need to figure out
  124. // what the limit is for max operations per request and batch operations in
  125. // multiple requests if number of channels exceeds allowed limit.
  126. func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
  127. result := TwitchChannel{
  128. Login: strings.ToLower(channel),
  129. }
  130. reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel))
  131. request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
  132. request.Header.Add("Client-ID", twitchGqlClientId)
  133. response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request)
  134. if err != nil {
  135. return result, err
  136. }
  137. if len(response) != 2 {
  138. return result, fmt.Errorf("expected 2 operation responses, got %d", len(response))
  139. }
  140. var channelShell twitchChannelShellOperationResponse
  141. var streamMetadata twitchStreamMetadataOperationResponse
  142. for i := range response {
  143. switch response[i].Extensions.OperationName {
  144. case "ChannelShell":
  145. err = json.Unmarshal(response[i].Data, &channelShell)
  146. if err != nil {
  147. return result, fmt.Errorf("failed to unmarshal channel shell: %w", err)
  148. }
  149. case "StreamMetadata":
  150. err = json.Unmarshal(response[i].Data, &streamMetadata)
  151. if err != nil {
  152. return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err)
  153. }
  154. default:
  155. return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName)
  156. }
  157. }
  158. if channelShell.UserOrError.Type != "User" {
  159. result.Name = result.Login
  160. return result, nil
  161. }
  162. result.Exists = true
  163. result.Name = channelShell.UserOrError.DisplayName
  164. result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl
  165. if channelShell.UserOrError.Stream != nil {
  166. result.IsLive = true
  167. result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
  168. if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
  169. result.Category = streamMetadata.UserOrNull.Stream.Game.Name
  170. result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
  171. startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
  172. if err == nil {
  173. result.LiveSince = startedAt
  174. } else {
  175. slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
  176. }
  177. }
  178. }
  179. return result, nil
  180. }
  181. func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
  182. result := make(TwitchChannels, 0, len(channelLogins))
  183. job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
  184. channels, errs, err := workerPoolDo(job)
  185. if err != nil {
  186. return result, err
  187. }
  188. var failed int
  189. for i := range channels {
  190. if errs[i] != nil {
  191. failed++
  192. slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i])
  193. continue
  194. }
  195. result = append(result, channels[i])
  196. }
  197. if failed == len(channelLogins) {
  198. return result, ErrNoContent
  199. }
  200. if failed > 0 {
  201. return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed)
  202. }
  203. return result, nil
  204. }