From 2f81173f1e253b4234898a0fed1392cbff56a5d0 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Tue, 28 Apr 2026 23:01:26 +0200 Subject: [PATCH] feat: add twitch graphql streamer resolution --- internal/app/app.go | 85 ++++++++++++++++- internal/app/app_test.go | 72 +++++++++++++++ internal/auth/auth_test.go | 27 ++++++ internal/auth/store.go | 2 - internal/gql/client.go | 156 ++++++++++++++++++++++++++++++++ internal/gql/client_test.go | 144 +++++++++++++++++++++++++++++ internal/gql/operations.go | 69 ++++++++++++++ internal/tui/model.go | 139 ++++++++++++++++++++++++---- internal/tui/model_test.go | 92 +++++++++++++++++-- internal/twitch/service.go | 108 ++++++++++++++++++++++ internal/twitch/service_test.go | 65 +++++++++++++ 11 files changed, 928 insertions(+), 31 deletions(-) create mode 100644 internal/app/app_test.go create mode 100644 internal/gql/client.go create mode 100644 internal/gql/client_test.go create mode 100644 internal/gql/operations.go create mode 100644 internal/twitch/service.go create mode 100644 internal/twitch/service_test.go diff --git a/internal/app/app.go b/internal/app/app.go index a822397..f4c558d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ package app import ( "context" + "errors" "fmt" "net/http" "os" @@ -15,7 +16,9 @@ import ( "parasocial/internal/auth" "parasocial/internal/config" + "parasocial/internal/gql" "parasocial/internal/tui" + "parasocial/internal/twitch" ) // Run loads the application configuration and starts the terminal UI. @@ -39,7 +42,7 @@ func Run(ctx context.Context) error { program := tea.NewProgram( tui.New(tui.Options{ - Streamers: cfg.Streamers, + Streamers: twitch.LoadingStreamerEntries(cfg.Streamers), AuthState: state, StartAuth: func(ch chan<- tui.AuthUpdate) { go func() { @@ -60,6 +63,27 @@ func Run(ctx context.Context) error { ch <- tui.AuthUpdate{State: state, Done: true} }() }, + StartResolve: func(state *auth.State, ch chan<- tui.StreamerUpdate) { + go func() { + defer close(ch) + + service, err := newTwitchService(httpClient, state) + if err != nil { + ch <- tui.StreamerUpdate{Err: err, Done: true} + return + } + if err := resolveStreamerEntries(ctx, service, cfg.Streamers, func(update tui.StreamerUpdate) { + ch <- update + }); err != nil { + if errors.Is(err, context.Canceled) { + return + } + ch <- tui.StreamerUpdate{Err: err, Done: true} + return + } + ch <- tui.StreamerUpdate{Done: true} + }() + }, }), tea.WithContext(ctx), tea.WithInput(os.Stdin), @@ -68,3 +92,62 @@ func Run(ctx context.Context) error { _, err = program.Run() return err } + +// streamerService captures the Twitch lookups the app needs during UI resolution. +type streamerService interface { + CurrentUser(context.Context) (*twitch.Viewer, error) + ResolveStreamer(context.Context, string) (*twitch.Channel, error) +} + +// newTwitchService builds a Twitch service from the authenticated session state. +func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) { + client := &gql.Client{ + HTTPClient: httpClient, + Session: gql.Session{ + AccessToken: state.AccessToken, + ClientID: state.ClientID, + DeviceID: state.DeviceID, + UserAgent: auth.TVUserAgent(), + }, + } + if err := client.Validate(); err != nil { + return nil, fmt.Errorf("configure graphql session: %w", err) + } + return &twitch.Service{GQL: client}, nil +} + +// resolveStreamerEntries streams viewer and streamer resolution results into the TUI. +func resolveStreamerEntries(ctx context.Context, service streamerService, logins []string, send func(tui.StreamerUpdate)) error { + viewer, err := service.CurrentUser(ctx) + if err != nil { + return fmt.Errorf("resolve current user: %w", err) + } + send(tui.StreamerUpdate{Viewer: viewer}) + + for index, login := range logins { + entry := twitch.StreamerEntry{ + ConfigLogin: login, + Status: twitch.StreamerLoading, + } + + channel, err := service.ResolveStreamer(ctx, login) + switch { + case err == nil: + entry.Login = channel.Login + entry.ChannelID = channel.ID + entry.Status = twitch.StreamerReady + case errors.Is(err, context.Canceled): + return err + default: + entry.Status = twitch.StreamerError + entry.Error = err.Error() + } + + send(tui.StreamerUpdate{ + Index: index, + Entry: &entry, + }) + } + + return nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..d2af7ac --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,72 @@ +// app_test.go covers the app-layer orchestration around Twitch viewer and streamer resolution. +// It exercises the background resolution loop independently of Bubble Tea startup +// so the app wiring can be validated without requiring a full interactive session. +package app + +import ( + "context" + "errors" + "testing" + + "parasocial/internal/tui" + "parasocial/internal/twitch" +) + +// fakeStreamerService is a test double for the app's Twitch resolution dependency. +type fakeStreamerService struct { + viewer *twitch.Viewer + viewerErr error + channels map[string]*twitch.Channel + errs map[string]error +} + +// CurrentUser returns the configured fake viewer or viewer error. +func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) { + if f.viewerErr != nil { + return nil, f.viewerErr + } + return f.viewer, nil +} + +// ResolveStreamer returns the configured fake channel or resolution error. +func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) { + if err, ok := f.errs[login]; ok { + return nil, err + } + return f.channels[login], nil +} + +// TestResolveStreamerEntries verifies that app resolution emits viewer, success, and error updates. +func TestResolveStreamerEntries(t *testing.T) { + t.Parallel() + + service := &fakeStreamerService{ + viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, + channels: map[string]*twitch.Channel{ + "alpha": {ID: "1", Login: "alpha_live"}, + }, + errs: map[string]error{ + "beta": errors.New("lookup failed"), + }, + } + + var updates []tui.StreamerUpdate + err := resolveStreamerEntries(context.Background(), service, []string{"alpha", "beta"}, func(update tui.StreamerUpdate) { + updates = append(updates, update) + }) + if err != nil { + t.Fatal(err) + } + if len(updates) != 3 { + t.Fatalf("len(updates) = %d", len(updates)) + } + if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" { + t.Fatalf("viewer update = %#v", updates[0]) + } + if updates[1].Entry == nil || updates[1].Entry.Status != twitch.StreamerReady || updates[1].Entry.Login != "alpha_live" { + t.Fatalf("alpha update = %#v", updates[1]) + } + if updates[2].Entry == nil || updates[2].Entry.Status != twitch.StreamerError { + t.Fatalf("beta update = %#v", updates[2]) + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 4591439..1f1acc1 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -51,6 +51,33 @@ func TestSaveStatePersistsCookieEntries(t *testing.T) { } } +func TestSaveStateAllowsZeroExpiresIn(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cookies.json") + state := &State{ + AccessToken: "token", + TokenType: "bearer", + Scopes: RequiredScopes, + Login: "viewer", + UserID: "123", + ClientID: ClientID, + ExpiresIn: 0, + DeviceID: "device", + } + if err := SaveState(path, state); err != nil { + t.Fatalf("SaveState() error = %v", err) + } + + loaded, err := LoadState(path) + if err != nil { + t.Fatal(err) + } + if loaded.ExpiresIn != 0 { + t.Fatalf("ExpiresIn = %d, want 0", loaded.ExpiresIn) + } +} + func TestLoadStateMissingFileReturnsNil(t *testing.T) { t.Parallel() diff --git a/internal/auth/store.go b/internal/auth/store.go index 2862f1e..3a9c915 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -107,8 +107,6 @@ func (s *State) validate() error { return fmt.Errorf("missing client_id") case len(s.Scopes) == 0: return fmt.Errorf("missing scopes") - case s.ExpiresIn <= 0: - return fmt.Errorf("expires_in must be greater than zero") case s.DeviceID == "": return fmt.Errorf("missing device_id") default: diff --git a/internal/gql/client.go b/internal/gql/client.go new file mode 100644 index 0000000..94b32d3 --- /dev/null +++ b/internal/gql/client.go @@ -0,0 +1,156 @@ +// client.go defines the authenticated Twitch GraphQL transport layer. +// It owns request encoding, session header wiring, and response decoding +// so higher-level Twitch services can issue typed operations without repeating HTTP logic. +package gql + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const DefaultEndpoint = "https://gql.twitch.tv/gql" + +// Session carries the authenticated Twitch headers needed for GraphQL requests. +type Session struct { + AccessToken string + ClientID string + DeviceID string + UserAgent string +} + +// Client posts authenticated GraphQL requests to Twitch. +type Client struct { + HTTPClient *http.Client + Endpoint string + Session Session +} + +// Error is one entry in a GraphQL errors list. +type Error struct { + Message string `json:"message"` +} + +// StatusError reports a non-200 HTTP response together with its response body. +type StatusError struct { + StatusCode int + Body string +} + +// Error formats the HTTP status failure in a way that preserves the response body. +func (e *StatusError) Error() string { + body := strings.TrimSpace(e.Body) + if body == "" { + return fmt.Sprintf("graphql status %d", e.StatusCode) + } + return fmt.Sprintf("graphql status %d: %s", e.StatusCode, body) +} + +// Do executes one Twitch GraphQL request and decodes its data payload into out. +func (c *Client) Do(ctx context.Context, request Request, out any) error { + if err := c.Validate(); err != nil { + return err + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(request); err != nil { + return fmt.Errorf("encode graphql request %s: %w", request.operationLabel(), err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), &body) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "OAuth "+c.Session.AccessToken) + req.Header.Set("Client-Id", c.Session.ClientID) + req.Header.Set("User-Agent", c.Session.UserAgent) + req.Header.Set("X-Device-Id", c.Session.DeviceID) + + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("post graphql %s: %w", request.operationLabel(), err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read graphql %s response: %w", request.operationLabel(), err) + } + if resp.StatusCode != http.StatusOK { + return &StatusError{StatusCode: resp.StatusCode, Body: string(respBody)} + } + + var envelope struct { + Data json.RawMessage `json:"data"` + Errors []Error `json:"errors,omitempty"` + } + if err := json.Unmarshal(respBody, &envelope); err != nil { + return fmt.Errorf("parse graphql %s response: %w", request.operationLabel(), err) + } + if len(envelope.Errors) > 0 { + return fmt.Errorf("graphql %s returned errors: %s", request.operationLabel(), formatErrors(envelope.Errors)) + } + if len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) { + return fmt.Errorf("graphql %s response missing data", request.operationLabel()) + } + if out == nil { + return nil + } + if err := json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("decode graphql %s data: %w", request.operationLabel(), err) + } + return nil +} + +// formatErrors joins GraphQL error messages into one readable string. +func formatErrors(errs []Error) string { + parts := make([]string, 0, len(errs)) + for _, err := range errs { + if err.Message != "" { + parts = append(parts, err.Message) + } + } + if len(parts) == 0 { + return "unknown error" + } + return strings.Join(parts, "; ") +} + +// Validate rejects incomplete session configuration before any request is sent. +func (c *Client) Validate() error { + switch { + case c.Session.AccessToken == "": + return errors.New("missing access token") + case c.Session.ClientID == "": + return errors.New("missing client id") + case c.Session.DeviceID == "": + return errors.New("missing device id") + case c.Session.UserAgent == "": + return errors.New("missing user agent") + default: + return nil + } +} + +// httpClient returns the configured HTTP client or the default client. +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +// endpoint returns the configured GraphQL endpoint or the default Twitch endpoint. +func (c *Client) endpoint() string { + if c.Endpoint != "" { + return c.Endpoint + } + return DefaultEndpoint +} diff --git a/internal/gql/client_test.go b/internal/gql/client_test.go new file mode 100644 index 0000000..566cbdb --- /dev/null +++ b/internal/gql/client_test.go @@ -0,0 +1,144 @@ +package gql + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDoSendsHeadersAndDecodesData(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "OAuth token" { + t.Fatalf("Authorization = %q", r.Header.Get("Authorization")) + } + if r.Header.Get("Client-Id") != "client" { + t.Fatalf("Client-Id = %q", r.Header.Get("Client-Id")) + } + if r.Header.Get("X-Device-Id") != "device" { + t.Fatalf("X-Device-Id = %q", r.Header.Get("X-Device-Id")) + } + if r.Header.Get("User-Agent") != "agent" { + t.Fatalf("User-Agent = %q", r.Header.Get("User-Agent")) + } + var req Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.OperationName != "GetIDFromLogin" { + t.Fatalf("operation = %q", req.OperationName) + } + if req.Variables["login"] != "streamer" { + t.Fatalf("login variable = %#v", req.Variables["login"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + })) + defer server.Close() + + client := &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + Session: Session{ + AccessToken: "token", + ClientID: "client", + DeviceID: "device", + UserAgent: "agent", + }, + } + var out struct { + OK bool `json:"ok"` + } + if err := client.Do(context.Background(), GetIDFromLogin("streamer"), &out); err != nil { + t.Fatal(err) + } + if !out.OK { + t.Fatal("expected decoded data") + } +} + +func TestDoSupportsInlineQueries(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.OperationName != "CurrentUser" { + t.Fatalf("operation = %q", req.OperationName) + } + if !strings.Contains(req.Query, "currentUser") { + t.Fatalf("query = %q", req.Query) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"currentUser":{"id":"1","login":"viewer"}}}`)) + })) + defer server.Close() + + client := &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + Session: Session{ + AccessToken: "token", + ClientID: "client", + DeviceID: "device", + UserAgent: "agent", + }, + } + var out struct { + CurrentUser struct { + ID string `json:"id"` + Login string `json:"login"` + } `json:"currentUser"` + } + if err := client.Do(context.Background(), CurrentUser(), &out); err != nil { + t.Fatal(err) + } + if out.CurrentUser.Login != "viewer" { + t.Fatalf("login = %q", out.CurrentUser.Login) + } +} + +func TestDoSurfacesGraphQLErrors(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errors":[{"message":"bad auth"}]}`)) + })) + defer server.Close() + + client := &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + Session: Session{ + AccessToken: "token", + ClientID: "client", + DeviceID: "device", + UserAgent: "agent", + }, + } + if err := client.Do(context.Background(), CurrentUser(), &struct{}{}); err == nil { + t.Fatal("expected error") + } +} + +func TestPersistedOperationHashes(t *testing.T) { + t.Parallel() + + req := GetIDFromLogin("streamer") + if req.OperationName != "GetIDFromLogin" { + t.Fatalf("operation = %q", req.OperationName) + } + if req.Extensions.PersistedQuery == nil { + t.Fatal("expected persisted query metadata") + } + if got := req.Extensions.PersistedQuery.SHA256Hash; got != "94e82a7b1e3c21e186daa73ee2afc4b8f23bade1fbbff6fe8ac133f50a2f58ca" { + t.Fatalf("hash = %q", got) + } +} diff --git a/internal/gql/operations.go b/internal/gql/operations.go new file mode 100644 index 0000000..facc136 --- /dev/null +++ b/internal/gql/operations.go @@ -0,0 +1,69 @@ +// operations.go defines the minimal Twitch GraphQL operations used by the app. +// It builds either persisted-query or inline query requests for viewer identity +// and streamer login resolution without exposing raw payload assembly to callers. +package gql + +// persistedQuery stores the persisted-query metadata Twitch expects. +type persistedQuery struct { + Version int `json:"version"` + SHA256Hash string `json:"sha256Hash"` +} + +// extensions holds the GraphQL extensions block for one request. +type extensions struct { + PersistedQuery *persistedQuery `json:"persistedQuery,omitempty"` +} + +// Request is one Twitch GraphQL operation. +type Request struct { + OperationName string `json:"operationName,omitempty"` + Query string `json:"query,omitempty"` + Variables map[string]any `json:"variables,omitempty"` + Extensions extensions `json:"extensions,omitempty"` +} + +// operation builds a persisted-query request with the supplied variables. +func operation(name, hash string, variables map[string]any) Request { + return Request{ + OperationName: name, + Variables: variables, + Extensions: extensions{ + PersistedQuery: &persistedQuery{ + Version: 1, + SHA256Hash: hash, + }, + }, + } +} + +// queryOperation builds an inline-query request with the supplied variables. +func queryOperation(name, query string, variables map[string]any) Request { + return Request{ + OperationName: name, + Query: query, + Variables: variables, + } +} + +// operationLabel returns a readable operation name for logs and errors. +func (r Request) operationLabel() string { + if r.OperationName != "" { + return r.OperationName + } + if r.Query != "" { + return "anonymous" + } + return "unknown" +} + +// CurrentUser fetches the canonical identity for the authenticated viewer. +func CurrentUser() Request { + return queryOperation("CurrentUser", "query CurrentUser { currentUser { id login } }", nil) +} + +// GetIDFromLogin resolves one login into a Twitch user record using Twitch's persisted-query hash. +func GetIDFromLogin(login string) Request { + return operation("GetIDFromLogin", "94e82a7b1e3c21e186daa73ee2afc4b8f23bade1fbbff6fe8ac133f50a2f58ca", map[string]any{ + "login": login, + }) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 195a38b..c5098ac 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "parasocial/internal/auth" + "parasocial/internal/twitch" ) type viewMode int @@ -20,8 +21,12 @@ const ( streamerView ) +// StartAuthFunc begins the asynchronous Twitch login flow for the model. type StartAuthFunc func(chan<- AuthUpdate) +// StartResolveFunc begins the asynchronous viewer and streamer resolution flow for the model. +type StartResolveFunc func(*auth.State, chan<- StreamerUpdate) + // AuthUpdate carries one incremental auth log line or completion result into the TUI. type AuthUpdate struct { Line string @@ -30,11 +35,21 @@ type AuthUpdate struct { Done bool } -// Options configures the initial streamer list, auth state, and auth starter hook. +// StreamerUpdate carries one streamer resolution update into the TUI. +type StreamerUpdate struct { + Viewer *twitch.Viewer + Entry *twitch.StreamerEntry + Index int + Err error + Done bool +} + +// Options configures the initial streamer list, auth state, and worker starter hooks. type Options struct { - Streamers []string - AuthState *auth.State - StartAuth StartAuthFunc + Streamers []twitch.StreamerEntry + AuthState *auth.State + StartAuth StartAuthFunc + StartResolve StartResolveFunc } // authStartedMsg hands the model the channel that will stream auth updates. @@ -42,25 +57,35 @@ type authStartedMsg struct { Updates <-chan AuthUpdate } +// streamerStartedMsg hands the model the channel that will stream streamer updates. +type streamerStartedMsg struct { + Updates <-chan StreamerUpdate +} + // Model is the terminal UI for auth and streamer display. type Model struct { - streamers []string - authState *auth.State - startAuth StartAuthFunc - authUpdates <-chan AuthUpdate - authLogs []string - authErr error - mode viewMode + streamers []twitch.StreamerEntry + authState *auth.State + startAuth StartAuthFunc + startResolve StartResolveFunc + authUpdates <-chan AuthUpdate + streamerUpdates <-chan StreamerUpdate + authLogs []string + authErr error + resolveErr error + viewer *twitch.Viewer + mode viewMode } // New returns a Bubble Tea model for auth and streamer display. func New(options Options) Model { model := Model{ - streamers: append([]string(nil), options.Streamers...), - authState: options.AuthState, - startAuth: options.StartAuth, - authLogs: []string{}, - mode: authView, + streamers: append([]twitch.StreamerEntry(nil), options.Streamers...), + authState: options.AuthState, + startAuth: options.StartAuth, + startResolve: options.StartResolve, + authLogs: []string{}, + mode: authView, } if options.AuthState != nil { model.mode = streamerView @@ -73,7 +98,7 @@ func (m Model) Init() tea.Cmd { if m.mode == authView { return startAuthSession(m.startAuth) } - return nil + return startStreamerResolution(m.startResolve, m.authState) } // Update applies auth progress events and handles the global quit keys. @@ -82,6 +107,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case authStartedMsg: m.authUpdates = msg.Updates return m, waitForAuthUpdate(msg.Updates) + case streamerStartedMsg: + m.streamerUpdates = msg.Updates + return m, waitForStreamerUpdate(msg.Updates) case AuthUpdate: if msg.Line != "" { m.authLogs = append(m.authLogs, msg.Line) @@ -95,12 +123,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.authState = msg.State m.authErr = nil m.mode = streamerView + m.viewer = nil + m.resolveErr = nil + m.streamers = loadingEntries(m.streamers) + return m, startStreamerResolution(m.startResolve, m.authState) } return m, nil } if m.authUpdates != nil { return m, waitForAuthUpdate(m.authUpdates) } + case StreamerUpdate: + if msg.Viewer != nil { + m.viewer = msg.Viewer + m.resolveErr = nil + } + if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) { + m.streamers[msg.Index] = *msg.Entry + } + if msg.Err != nil { + m.resolveErr = msg.Err + } + if msg.Done { + return m, nil + } + if m.streamerUpdates != nil { + return m, waitForStreamerUpdate(m.streamerUpdates) + } case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc", "q": @@ -131,12 +180,27 @@ func (m Model) View() string { return builder.String() } - if m.authState != nil && m.authState.Login != "" { - fmt.Fprintf(&builder, "Logged in as %s\n\n", m.authState.Login) + switch { + case m.viewer != nil: + fmt.Fprintf(&builder, "Logged in as %s (%s)\n\n", m.viewer.Login, m.viewer.ID) + case m.authState != nil && m.authState.Login != "": + fmt.Fprintf(&builder, "Logged in as %s\n", m.authState.Login) + if m.resolveErr != nil { + fmt.Fprintf(&builder, "Viewer lookup failed: %v\n\n", m.resolveErr) + } else { + builder.WriteString("Resolving viewer identity...\n\n") + } } builder.WriteString("Streamers\n") for i, streamer := range m.streamers { - fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer) + switch streamer.Status { + case twitch.StreamerReady: + fmt.Fprintf(&builder, "%d. %s (%s)\n", i+1, streamer.Login, streamer.ChannelID) + case twitch.StreamerError: + fmt.Fprintf(&builder, "%d. %s [error: %s]\n", i+1, streamer.ConfigLogin, streamer.Error) + default: + fmt.Fprintf(&builder, "%d. %s [loading]\n", i+1, streamer.ConfigLogin) + } } builder.WriteString("\n") return builder.String() @@ -154,6 +218,21 @@ func startAuthSession(start StartAuthFunc) tea.Cmd { } } +// startStreamerResolution starts the background streamer resolver and returns its update channel. +func startStreamerResolution(start StartResolveFunc, state *auth.State) tea.Cmd { + return func() tea.Msg { + if start == nil { + return StreamerUpdate{Err: errors.New("streamer resolution start function is nil"), Done: true} + } + if state == nil { + return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true} + } + updates := make(chan StreamerUpdate, 32) + start(state, updates) + return streamerStartedMsg{Updates: updates} + } +} + // waitForAuthUpdate blocks until the next auth update is available from the worker. func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd { return func() tea.Msg { @@ -164,3 +243,23 @@ func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd { return update } } + +// waitForStreamerUpdate blocks until the next streamer update is available from the worker. +func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd { + return func() tea.Msg { + update, ok := <-updates + if !ok { + return StreamerUpdate{Done: true} + } + return update + } +} + +// loadingEntries resets the UI rows back to their initial loading state. +func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry { + logins := make([]string, 0, len(entries)) + for _, entry := range entries { + logins = append(logins, entry.ConfigLogin) + } + return twitch.LoadingStreamerEntries(logins) +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 19b80c7..c2f663e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -4,23 +4,28 @@ import ( "testing" "parasocial/internal/auth" + "parasocial/internal/twitch" ) func TestViewDisplaysStreamers(t *testing.T) { model := New(Options{ - Streamers: []string{"alpha", "beta"}, + Streamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady}, + {ConfigLogin: "beta", Status: twitch.StreamerLoading}, + }, }) model.mode = streamerView + model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} got := model.View() - want := "Streamers\n1. alpha\n2. beta\n\n" + want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live (1)\n2. beta [loading]\n\n" if got != want { t.Fatalf("View() = %q, want %q", got, want) } } func TestAuthUpdateAppendsLogLine(t *testing.T) { - model := New(Options{Streamers: []string{"alpha"}}) + model := New(Options{Streamers: twitch.LoadingStreamerEntries([]string{"alpha"})}) updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"}) if cmd != nil { @@ -35,17 +40,88 @@ func TestAuthUpdateAppendsLogLine(t *testing.T) { } } -func TestAuthSuccessSwitchesToStreamerView(t *testing.T) { - model := New(Options{Streamers: []string{"alpha"}}) +func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) { + var started *auth.State + model := New(Options{ + Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}), + StartResolve: func(state *auth.State, ch chan<- StreamerUpdate) { + started = state + close(ch) + }, + }) - updated, _ := model.Update(AuthUpdate{ - State: &auth.State{Login: "viewer"}, + state := &auth.State{Login: "viewer", UserID: "7"} + + updated, cmd := model.Update(AuthUpdate{ + State: state, Done: true, }) + if cmd == nil { + t.Fatal("expected streamer resolution command") + } + if _, ok := cmd().(streamerStartedMsg); !ok { + t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd()) + } + if started != state { + t.Fatalf("started state = %#v, want %#v", started, state) + } next := updated.(Model) got := next.View() - want := "Logged in as viewer\n\nStreamers\n1. alpha\n\n" + want := "Logged in as viewer\nResolving viewer identity...\n\nStreamers\n1. alpha [loading]\n\n" + if got != want { + t.Fatalf("View() = %q, want %q", got, want) + } +} + +func TestInitStartsResolutionWhenAlreadyAuthenticated(t *testing.T) { + var started *auth.State + state := &auth.State{Login: "viewer", UserID: "7"} + model := New(Options{ + Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}), + AuthState: state, + StartResolve: func(got *auth.State, ch chan<- StreamerUpdate) { + started = got + close(ch) + }, + }) + + cmd := model.Init() + if cmd == nil { + t.Fatal("expected init command") + } + if _, ok := cmd().(streamerStartedMsg); !ok { + t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd()) + } + if started != state { + t.Fatalf("started state = %#v, want %#v", started, state) + } +} + +func TestStreamerUpdateAppliesEntry(t *testing.T) { + model := New(Options{ + Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}), + AuthState: &auth.State{Login: "viewer"}, + }) + model.mode = streamerView + + updated, cmd := model.Update(StreamerUpdate{ + Viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, + Index: 0, + Entry: &twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + ChannelID: "1", + Status: twitch.StreamerReady, + }, + }) + if cmd != nil { + t.Fatal("expected nil cmd when no update channel is attached") + } + + next := updated.(Model) + got := next.View() + want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live (1)\n\n" if got != want { t.Fatalf("View() = %q, want %q", got, want) } diff --git a/internal/twitch/service.go b/internal/twitch/service.go new file mode 100644 index 0000000..dcdf3b8 --- /dev/null +++ b/internal/twitch/service.go @@ -0,0 +1,108 @@ +// service.go defines the Twitch domain lookups the rewritten app currently needs. +// It wraps the lower-level GraphQL client with small typed methods for the viewer +// identity and configured streamer resolution used to populate the terminal UI. +package twitch + +import ( + "context" + "errors" + "fmt" + + "parasocial/internal/gql" +) + +// GQLClient is the minimal GraphQL interface the Twitch service depends on. +type GQLClient interface { + Do(context.Context, gql.Request, any) error +} + +// Service exposes the Twitch lookups the current app needs. +type Service struct { + GQL GQLClient +} + +// Viewer is the authenticated Twitch account. +type Viewer struct { + ID string + Login string +} + +// Channel is the resolved Twitch channel identity for one streamer login. +type Channel struct { + ID string + Login string +} + +// StreamerStatus describes one row's resolution state in the UI. +type StreamerStatus string + +const ( + StreamerLoading StreamerStatus = "loading" + StreamerReady StreamerStatus = "ready" + StreamerError StreamerStatus = "error" +) + +// StreamerEntry is the UI-facing state for one configured streamer. +type StreamerEntry struct { + ConfigLogin string + Login string + ChannelID string + Status StreamerStatus + Error string +} + +// ErrStreamerNotFound is returned when Twitch has no channel for the requested login. +var ErrStreamerNotFound = errors.New("streamer does not exist") + +// LoadingStreamerEntries seeds UI state from the normalized config logins. +func LoadingStreamerEntries(logins []string) []StreamerEntry { + entries := make([]StreamerEntry, 0, len(logins)) + for _, login := range logins { + entries = append(entries, StreamerEntry{ + ConfigLogin: login, + Status: StreamerLoading, + }) + } + return entries +} + +// CurrentUser resolves the authenticated viewer through Twitch GraphQL. +func (s *Service) CurrentUser(ctx context.Context) (*Viewer, error) { + var data struct { + CurrentUser *struct { + ID string `json:"id"` + Login string `json:"login"` + } `json:"currentUser"` + } + if err := s.GQL.Do(ctx, gql.CurrentUser(), &data); err != nil { + return nil, err + } + if data.CurrentUser == nil || data.CurrentUser.ID == "" || data.CurrentUser.Login == "" { + return nil, fmt.Errorf("current user response missing id or login") + } + return &Viewer{ + ID: data.CurrentUser.ID, + Login: data.CurrentUser.Login, + }, nil +} + +// ResolveStreamer resolves a configured streamer login into canonical Twitch identity. +func (s *Service) ResolveStreamer(ctx context.Context, login string) (*Channel, error) { + var data struct { + User *struct { + ID string `json:"id"` + Login string `json:"login"` + } `json:"user"` + } + if err := s.GQL.Do(ctx, gql.GetIDFromLogin(login), &data); err != nil { + return nil, err + } + if data.User == nil || data.User.ID == "" { + return nil, fmt.Errorf("%w: %s", ErrStreamerNotFound, login) + } + resolvedLogin := data.User.Login + if resolvedLogin == "" { + resolvedLogin = login + } + return &Channel{ID: data.User.ID, Login: resolvedLogin}, nil +} diff --git a/internal/twitch/service_test.go b/internal/twitch/service_test.go new file mode 100644 index 0000000..9dd2d21 --- /dev/null +++ b/internal/twitch/service_test.go @@ -0,0 +1,65 @@ +package twitch + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "parasocial/internal/gql" +) + +type fakeGQL struct { + requests []gql.Request + data map[string]string +} + +func (f *fakeGQL) Do(_ context.Context, request gql.Request, out any) error { + f.requests = append(f.requests, request) + return json.Unmarshal([]byte(f.data[request.OperationName]), out) +} + +func TestCurrentUser(t *testing.T) { + t.Parallel() + + client := &fakeGQL{data: map[string]string{ + "CurrentUser": `{"currentUser":{"id":"7","login":"viewer"}}`, + }} + service := &Service{GQL: client} + viewer, err := service.CurrentUser(context.Background()) + if err != nil { + t.Fatal(err) + } + if viewer.ID != "7" || viewer.Login != "viewer" { + t.Fatalf("viewer = %#v", viewer) + } +} + +func TestResolveStreamer(t *testing.T) { + t.Parallel() + + client := &fakeGQL{data: map[string]string{ + "GetIDFromLogin": `{"user":{"id":"123","login":"streamer"}}`, + }} + service := &Service{GQL: client} + channel, err := service.ResolveStreamer(context.Background(), "streamer") + if err != nil { + t.Fatal(err) + } + if channel.ID != "123" || channel.Login != "streamer" { + t.Fatalf("channel = %#v", channel) + } +} + +func TestResolveStreamerNotFound(t *testing.T) { + t.Parallel() + + client := &fakeGQL{data: map[string]string{ + "GetIDFromLogin": `{"user":null}`, + }} + service := &Service{GQL: client} + _, err := service.ResolveStreamer(context.Background(), "missing") + if !errors.Is(err, ErrStreamerNotFound) { + t.Fatalf("error = %v", err) + } +}