feat: add twitch graphql streamer resolution

This commit is contained in:
2026-04-28 23:01:26 +02:00
parent 37ece958f3
commit 2f81173f1e
11 changed files with 928 additions and 31 deletions
+119 -20
View File
@@ -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)
}