twitch.go 7.4 KB

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