glance/internal/feed/twitch.go
fawn 0681a04607
Add custom sorting to the twitch channels widget
this allows the channels to be sorted as defined in the config while
still keeping the live channels on the top. the wording could probably
be improved, "custom" is too broad but i'm not sure what else to call
it.
2024-05-18 03:07:34 +03:00

255 lines
7.2 KiB
Go

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
})
}
func (channels TwitchChannels) SortByLive() {
sort.SliceStable(channels, func(i, j int) bool {
return channels[i].IsLive && !channels[j].IsLive
})
}
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
}