Files
parasocial/internal/app/app.go

154 lines
3.9 KiB
Go

// app.go wires together config loading, auth bootstrap, and the Bubble Tea program.
// It decides whether cached Twitch auth can be reused and, when needed,
// connects the interactive login flow to the TUI through auth update messages.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"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.
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cfg, err := config.LoadDefault()
if err != nil {
return err
}
httpClient := &http.Client{Timeout: 30 * time.Second}
authClient := auth.NewClient(httpClient)
authPath := auth.DefaultPath()
state, err := authClient.ReuseAuth(ctx, authPath)
if err != nil {
return err
}
program := tea.NewProgram(
tui.New(tui.Options{
Streamers: twitch.LoadingStreamerEntries(cfg.Streamers),
AuthState: state,
StartAuth: func(ch chan<- tui.AuthUpdate) {
go func() {
defer close(ch)
state, err := authClient.EnsureAuth(ctx, authPath, func(line string) {
ch <- tui.AuthUpdate{Line: strings.TrimRight(line, "\n")}
})
if err != nil {
ch <- tui.AuthUpdate{
Line: fmt.Sprintf("Authentication failed: %v", err),
Err: err,
Done: true,
}
return
}
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),
tea.WithOutput(os.Stdout),
)
_, 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
}