feat: add twitch live detection to streamer list

This commit is contained in:
2026-04-28 23:22:51 +02:00
parent 2f81173f1e
commit 01b044e305
7 changed files with 354 additions and 32 deletions
+53
View File
@@ -33,6 +33,17 @@ type Channel struct {
Login string
}
// StreamInfo describes whether a channel is live.
type StreamInfo struct {
Online bool
}
// PlaybackToken carries the Twitch playback token/signature pair for live HLS access.
type PlaybackToken struct {
Signature string
Value string
}
// StreamerStatus describes one row's resolution state in the UI.
type StreamerStatus string
@@ -47,6 +58,7 @@ type StreamerEntry struct {
ConfigLogin string
Login string
ChannelID string
Live bool
Status StreamerStatus
Error string
}
@@ -106,3 +118,44 @@ func (s *Service) ResolveStreamer(ctx context.Context, login string) (*Channel,
}
return &Channel{ID: data.User.ID, Login: resolvedLogin}, nil
}
// StreamInfo resolves whether the given channel ID is currently live.
func (s *Service) StreamInfo(ctx context.Context, channelID string) (*StreamInfo, error) {
var data struct {
User *struct {
Stream *struct {
ID string `json:"id"`
} `json:"stream"`
} `json:"user"`
}
if err := s.GQL.Do(ctx, gql.WithIsStreamLiveQuery(channelID), &data); err != nil {
return nil, err
}
if data.User == nil {
return nil, fmt.Errorf("stream info missing user for channel %s", channelID)
}
if data.User.Stream == nil {
return &StreamInfo{Online: false}, nil
}
return &StreamInfo{Online: true}, nil
}
// PlaybackAccessToken fetches the Twitch playback token needed to access a live stream.
func (s *Service) PlaybackAccessToken(ctx context.Context, login string) (*PlaybackToken, error) {
var data struct {
StreamPlaybackAccessToken *struct {
Signature string `json:"signature"`
Value string `json:"value"`
} `json:"streamPlaybackAccessToken"`
}
if err := s.GQL.Do(ctx, gql.PlaybackAccessToken(login), &data); err != nil {
return nil, err
}
if data.StreamPlaybackAccessToken == nil || data.StreamPlaybackAccessToken.Signature == "" || data.StreamPlaybackAccessToken.Value == "" {
return nil, fmt.Errorf("playback access token missing signature or value for %s", login)
}
return &PlaybackToken{
Signature: data.StreamPlaybackAccessToken.Signature,
Value: data.StreamPlaybackAccessToken.Value,
}, nil
}
+61
View File
@@ -63,3 +63,64 @@ func TestResolveStreamerNotFound(t *testing.T) {
t.Fatalf("error = %v", err)
}
}
func TestStreamInfoOffline(t *testing.T) {
t.Parallel()
client := &fakeGQL{data: map[string]string{
"WithIsStreamLiveQuery": `{"user":{"stream":null}}`,
}}
service := &Service{GQL: client}
info, err := service.StreamInfo(context.Background(), "123")
if err != nil {
t.Fatal(err)
}
if info.Online {
t.Fatalf("info = %#v, want offline", info)
}
}
func TestStreamInfoOnline(t *testing.T) {
t.Parallel()
client := &fakeGQL{data: map[string]string{
"WithIsStreamLiveQuery": `{"user":{"stream":{"id":"broadcast"}}}`,
}}
service := &Service{GQL: client}
info, err := service.StreamInfo(context.Background(), "123")
if err != nil {
t.Fatal(err)
}
if !info.Online {
t.Fatalf("info = %#v, want online", info)
}
}
func TestPlaybackAccessToken(t *testing.T) {
t.Parallel()
client := &fakeGQL{data: map[string]string{
"PlaybackAccessToken": `{"streamPlaybackAccessToken":{"signature":"sig","value":"token"}}`,
}}
service := &Service{GQL: client}
token, err := service.PlaybackAccessToken(context.Background(), "streamer")
if err != nil {
t.Fatal(err)
}
if token.Signature != "sig" || token.Value != "token" {
t.Fatalf("token = %#v", token)
}
}
func TestPlaybackAccessTokenMissingFields(t *testing.T) {
t.Parallel()
client := &fakeGQL{data: map[string]string{
"PlaybackAccessToken": `{"streamPlaybackAccessToken":{"signature":"sig"}}`,
}}
service := &Service{GQL: client}
_, err := service.PlaybackAccessToken(context.Background(), "streamer")
if err == nil {
t.Fatal("expected error")
}
}