package feed import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "slices" "sort" "strings" "time" ) type TwitchCategory struct { Slug string `json:"slug"` Name string `json:"name"` AvatarUrl string `json:"avatarURL"` ViewersCount int `json:"viewersCount"` Tags []struct { Name string `json:"tagName"` } `json:"tags"` GameReleaseDate string `json:"originalReleaseDate"` IsNew bool `json:"-"` } type TwitchChannel struct { Login string Exists bool Name string AvatarUrl string IsLive bool LiveSince time.Time Category string CategorySlug string ViewersCount int } type TwitchChannels []TwitchChannel func (channels TwitchChannels) SortByViewers() { sort.Slice(channels, func(i, j int) bool { return channels[i].ViewersCount > channels[j].ViewersCount }) } type twitchOperationResponse struct { Data json.RawMessage Extensions struct { OperationName string `json:"operationName"` } } type twitchChannelShellOperationResponse struct { UserOrError struct { Type string `json:"__typename"` DisplayName string `json:"displayName"` ProfileImageUrl string `json:"profileImageURL"` Stream *struct { ViewersCount int `json:"viewersCount"` } } `json:"userOrError"` } type twitchStreamMetadataOperationResponse struct { UserOrNull *struct { Stream *struct { StartedAt string `json:"createdAt"` Game *struct { Slug string `json:"slug"` Name string `json:"name"` } `json:"game"` } `json:"stream"` } `json:"user"` } type twitchDirectoriesOperationResponse struct { Data struct { DirectoriesWithTags struct { Edges []struct { Node TwitchCategory `json:"node"` } `json:"edges"` } `json:"directoriesWithTags"` } `json:"data"` } const twitchGqlEndpoint = "https://gql.twitch.tv/gql" const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]` func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) { reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit)) request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) request.Header.Add("Client-ID", twitchGqlClientId) response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request) if err != nil { return nil, err } if len(response) == 0 { return nil, errors.New("no categories could be retrieved") } edges := (response)[0].Data.DirectoriesWithTags.Edges categories := make([]TwitchCategory, 0, len(edges)) for i := range edges { if slices.Contains(exclude, edges[i].Node.Slug) { continue } category := &edges[i].Node category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1) if len(category.Tags) > 2 { category.Tags = category.Tags[:2] } gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate) if err == nil { if time.Since(gameReleasedDate) < 14*24*time.Hour { category.IsNew = true } } categories = append(categories, *category) } if len(categories) > limit { categories = categories[:limit] } return categories, nil } const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]` // TODO: rework // The operations for multiple channels can all be sent in a single request // rather than sending a separate request for each channel. Need to figure out // what the limit is for max operations per request and batch operations in // multiple requests if number of channels exceeds allowed limit. func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { result := TwitchChannel{ Login: strings.ToLower(channel), } reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel)) request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) request.Header.Add("Client-ID", twitchGqlClientId) response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request) if err != nil { return result, err } if len(response) != 2 { return result, fmt.Errorf("expected 2 operation responses, got %d", len(response)) } var channelShell twitchChannelShellOperationResponse var streamMetadata twitchStreamMetadataOperationResponse for i := range response { switch response[i].Extensions.OperationName { case "ChannelShell": err = json.Unmarshal(response[i].Data, &channelShell) if err != nil { return result, fmt.Errorf("failed to unmarshal channel shell: %w", err) } case "StreamMetadata": err = json.Unmarshal(response[i].Data, &streamMetadata) if err != nil { return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err) } default: return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName) } } if channelShell.UserOrError.Type != "User" { result.Name = result.Login return result, nil } result.Exists = true result.Name = channelShell.UserOrError.DisplayName result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl if channelShell.UserOrError.Stream != nil { result.IsLive = true result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil { result.Category = streamMetadata.UserOrNull.Stream.Game.Name result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt) if err == nil { result.LiveSince = startedAt } else { slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) } } } return result, nil } func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { result := make(TwitchChannels, 0, len(channelLogins)) job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10) channels, errs, err := workerPoolDo(job) if err != nil { return result, err } var failed int for i := range channels { if errs[i] != nil { failed++ slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i]) continue } result = append(result, channels[i]) } if failed == len(channelLogins) { return result, ErrNoContent } if failed > 0 { return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed) } return result, nil }