mirror of
https://github.com/glanceapp/glance.git
synced 2024-12-02 05:14:59 +01:00
0681a04607
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.
255 lines
7.2 KiB
Go
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
|
|
}
|