feat: add twitch graphql streamer resolution
This commit is contained in:
+119
-20
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user