refactor: app orchestration into tui

This commit is contained in:
2026-04-29 20:35:20 +02:00
parent 3d012e0c2e
commit 1147f4d25b
11 changed files with 1149 additions and 1086 deletions
+13 -9
View File
@@ -1,29 +1,33 @@
module parasocial
go 1.24.0
go 1.24.2
require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+24 -17
View File
@@ -2,44 +2,51 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+3 -236
View File
@@ -1,251 +1,18 @@
// 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.
// app.go owns process-level application bootstrap before handing control to the TUI.
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/irc"
"parasocial/internal/tui"
"parasocial/internal/twitch"
)
const streamerRefreshInterval = 5 * time.Minute
// Run loads the application configuration and starts the terminal UI.
// Run loads 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)
ircManager := newIRCManager(ch)
defer ircManager.Close()
service, err := newTwitchService(httpClient, state)
if err != nil {
ch <- tui.StreamerUpdate{Err: err, Done: true}
return
}
if err := resolveStreamerEntries(ctx, service, state, cfg.Streamers, ircManager, 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)
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
}
type ircSyncer interface {
Sync(context.Context, string, string, []irc.Target)
}
// 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
}
func newIRCManager(ch chan<- tui.StreamerUpdate) *irc.Manager {
return &irc.Manager{
Addr: irc.DefaultAddr,
Events: func(event irc.Event) {
ch <- tui.StreamerUpdate{
IRC: &tui.IRCUpdate{
Login: event.Streamer,
State: tui.IRCState(event.State),
Line: event.Line,
},
}
},
}
}
// resolveStreamerEntries streams viewer and streamer resolution results into the TUI.
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate)) error {
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext)
}
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
viewer, err := service.CurrentUser(ctx)
if err != nil {
return fmt.Errorf("resolve current user: %w", err)
}
send(tui.StreamerUpdate{Viewer: viewer})
entries := twitch.LoadingStreamerEntries(logins)
syncWatchedChannels(ctx, syncer, state, entries)
for {
for index, login := range logins {
entry, err := resolveStreamerEntry(ctx, service, login)
if errors.Is(err, context.Canceled) {
return err
}
entries[index] = *entry
send(tui.StreamerUpdate{
Index: index,
Entry: entry,
})
syncWatchedChannels(ctx, syncer, state, entries)
}
if err := sleep(ctx, interval); err != nil {
return err
}
}
}
func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) {
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
case errors.Is(err, context.Canceled):
return nil, err
default:
entry.Status = twitch.StreamerError
entry.Error = err.Error()
return entry, nil
}
stream, err := service.StreamInfo(ctx, channel.ID)
switch {
case err == nil:
entry.Status = twitch.StreamerReady
entry.Live = stream.Online
case errors.Is(err, context.Canceled):
return nil, err
default:
entry.Status = twitch.StreamerError
entry.Error = err.Error()
return entry, nil
}
if !entry.Live {
return entry, nil
}
if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) {
return nil, err
}
return entry, nil
}
func sleepContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) {
if syncer == nil || state == nil {
return
}
syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries))
}
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
targets := make([]irc.Target, 0, 2)
for _, entry := range entries {
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
continue
}
targets = append(targets, irc.Target{Login: entry.Login})
if len(targets) == 2 {
break
}
}
return targets
return tui.Run(ctx, tui.Options{Streamers: cfg.Streamers})
}
-288
View File
@@ -1,288 +0,0 @@
// 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"
"reflect"
"testing"
"time"
"parasocial/internal/auth"
"parasocial/internal/irc"
"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
resolveErrs map[string]error
streams map[string][]streamResult
streamCalls map[string]int
playbackErr map[string]error
}
type streamResult struct {
info *twitch.StreamInfo
err 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.resolveErrs[login]; ok {
return nil, err
}
return f.channels[login], nil
}
func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) {
if f.streamCalls == nil {
f.streamCalls = map[string]int{}
}
call := f.streamCalls[channelID]
f.streamCalls[channelID] = call + 1
results := f.streams[channelID]
if len(results) == 0 {
return &twitch.StreamInfo{Online: false}, nil
}
if call >= len(results) {
call = len(results) - 1
}
result := results[call]
if result.err != nil {
return nil, result.err
}
return result.info, nil
}
func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) {
if err, ok := f.playbackErr[login]; ok {
return nil, err
}
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil
}
type fakeIRCSyncer struct {
calls [][]string
}
func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) {
logins := make([]string, 0, len(targets))
for _, target := range targets {
logins = append(logins, target.Login)
}
f.calls = append(f.calls, logins)
}
// 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"},
},
resolveErrs: map[string]error{
"beta": errors.New("lookup failed"),
},
streams: map[string][]streamResult{
"1": {{info: &twitch.StreamInfo{Online: true}}},
},
}
var updates []tui.StreamerUpdate
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, nil, func(update tui.StreamerUpdate) {
updates = append(updates, update)
}, 0, func(context.Context, time.Duration) error {
return context.Canceled
})
if !errors.Is(err, context.Canceled) {
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" || !updates[1].Entry.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])
}
}
func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(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"},
},
streams: map[string][]streamResult{
"1": {{info: &twitch.StreamInfo{Online: true}}},
},
playbackErr: map[string]error{
"alpha_live": errors.New("token lookup failed"),
},
}
var updates []tui.StreamerUpdate
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) {
updates = append(updates, update)
}, 0, func(context.Context, time.Duration) error {
return context.Canceled
})
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if len(updates) != 2 {
t.Fatalf("len(updates) = %d", len(updates))
}
entry := updates[1].Entry
if entry == nil || entry.Status != twitch.StreamerReady || !entry.Live {
t.Fatalf("entry = %#v", entry)
}
}
func TestResolveStreamerEntriesRefreshesLiveState(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"},
},
streams: map[string][]streamResult{
"1": {
{info: &twitch.StreamInfo{Online: false}},
{info: &twitch.StreamInfo{Online: true}},
},
},
}
var updates []tui.StreamerUpdate
sleepCalls := 0
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) {
updates = append(updates, update)
}, 0, func(context.Context, time.Duration) error {
sleepCalls++
if sleepCalls == 1 {
return nil
}
return context.Canceled
})
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if len(updates) != 3 {
t.Fatalf("len(updates) = %d", len(updates))
}
if updates[1].Entry == nil || updates[1].Entry.Live {
t.Fatalf("first pass = %#v", updates[1])
}
if updates[2].Entry == nil || !updates[2].Entry.Live {
t.Fatalf("second pass = %#v", updates[2])
}
}
func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(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"},
"beta": {ID: "2", Login: "beta_live"},
"gamma": {ID: "3", Login: "gamma_live"},
},
streams: map[string][]streamResult{
"1": {{info: &twitch.StreamInfo{Online: true}}},
"2": {{info: &twitch.StreamInfo{Online: true}}},
"3": {{info: &twitch.StreamInfo{Online: true}}},
},
}
syncer := &fakeIRCSyncer{}
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta", "gamma"}, syncer, func(tui.StreamerUpdate) {
}, 0, func(context.Context, time.Duration) error {
return context.Canceled
})
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
want := [][]string{
{},
{"alpha_live"},
{"alpha_live", "beta_live"},
{"alpha_live", "beta_live"},
}
if !reflect.DeepEqual(syncer.calls, want) {
t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want)
}
}
func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(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"},
"beta": {ID: "2", Login: "beta_live"},
},
streams: map[string][]streamResult{
"1": {
{info: &twitch.StreamInfo{Online: true}},
{info: &twitch.StreamInfo{Online: false}},
},
"2": {
{info: &twitch.StreamInfo{Online: false}},
{info: &twitch.StreamInfo{Online: true}},
},
},
}
syncer := &fakeIRCSyncer{}
sleepCalls := 0
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, syncer, func(tui.StreamerUpdate) {
}, 0, func(context.Context, time.Duration) error {
sleepCalls++
if sleepCalls == 1 {
return nil
}
return context.Canceled
})
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
want := [][]string{
{},
{"alpha_live"},
{"alpha_live"},
{},
{"beta_live"},
}
if !reflect.DeepEqual(syncer.calls, want) {
t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want)
}
}
+48
View File
@@ -0,0 +1,48 @@
package tui
import (
"parasocial/internal/auth"
"parasocial/internal/twitch"
)
// IRCState describes the current IRC join lifecycle for one streamer row.
type IRCState string
const (
IRCPending IRCState = "pending"
IRCJoined IRCState = "joined"
IRCDisconnected IRCState = "disconnected"
)
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
type AuthUpdate struct {
Line string
State *auth.State
Err error
Done bool
}
// StreamerUpdate carries one streamer resolution update into the TUI.
type StreamerUpdate struct {
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *IRCUpdate
Index int
Err error
Done bool
}
// IRCUpdate carries one IRC connection state or log line into the TUI.
type IRCUpdate struct {
Login string
State IRCState
Line string
}
type authStartedMsg struct {
Updates <-chan AuthUpdate
}
type streamerStartedMsg struct {
Updates <-chan StreamerUpdate
}
+113 -365
View File
@@ -1,13 +1,10 @@
// model.go contains the Bubble Tea state machine for the current terminal UI.
// It switches between the Twitch login log view and the streamer list view,
// and applies auth progress messages emitted by the app layer.
package tui
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
@@ -15,7 +12,6 @@ import (
)
const defaultViewWidth = 80
const greenDot = "\x1b[32m●\x1b[0m"
type viewMode int
@@ -24,70 +20,16 @@ const (
streamerView
)
// IRCState describes the current IRC join lifecycle for one streamer row.
type IRCState string
const (
IRCPending IRCState = "pending"
IRCJoined IRCState = "joined"
IRCDisconnected IRCState = "disconnected"
)
// 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
State *auth.State
Err error
Done bool
}
// StreamerUpdate carries one streamer resolution update into the TUI.
type StreamerUpdate struct {
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *IRCUpdate
Index int
Err error
Done bool
}
// IRCUpdate carries one IRC connection state or log line into the TUI.
type IRCUpdate struct {
Login string
State IRCState
Line string
}
// Options configures the initial streamer list, auth state, and worker starter hooks.
type Options struct {
Streamers []twitch.StreamerEntry
AuthState *auth.State
StartAuth StartAuthFunc
StartResolve StartResolveFunc
}
// authStartedMsg hands the model the channel that will stream auth updates.
type authStartedMsg struct {
Updates <-chan AuthUpdate
}
// streamerStartedMsg hands the model the channel that will stream streamer updates.
type streamerStartedMsg struct {
Updates <-chan StreamerUpdate
type modelRuntime interface {
startAuth(chan<- AuthUpdate)
startResolve(*auth.State, chan<- StreamerUpdate)
}
// Model is the terminal UI for auth and streamer display.
type Model struct {
streamers []twitch.StreamerEntry
authState *auth.State
startAuth StartAuthFunc
startResolve StartResolveFunc
runtime modelRuntime
authUpdates <-chan AuthUpdate
streamerUpdates <-chan StreamerUpdate
authLogs []string
@@ -99,44 +41,49 @@ type Model struct {
height int
selectedConfig string
ircDetails map[string]ircDetail
authViewport viewport.Model
}
type ircDetail struct {
joined bool
}
type streamerRow struct {
index int
entry twitch.StreamerEntry
line string
}
// New returns a Bubble Tea model for auth and streamer display.
func New(options Options) Model {
streamers := twitch.LoadingStreamerEntries(options.Streamers)
if len(options.initialStreamers) > 0 {
streamers = append([]twitch.StreamerEntry(nil), options.initialStreamers...)
}
model := Model{
streamers: append([]twitch.StreamerEntry(nil), options.Streamers...),
streamers: streamers,
authState: options.AuthState,
startAuth: options.StartAuth,
startResolve: options.StartResolve,
runtime: options.runtime,
authLogs: []string{},
mode: authView,
ircDetails: make(map[string]ircDetail),
width: defaultViewWidth,
height: 24,
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
}
if options.AuthState != nil {
model.mode = streamerView
}
model.ensureSelection()
model.syncAuthViewport()
return model
}
// Init kicks off authentication only when the UI starts in the login state.
func (m Model) Init() tea.Cmd {
if m.mode == authView {
return startAuthSession(m.startAuth)
return startAuthSession(m.runtime)
}
return startStreamerResolution(m.startResolve, m.authState)
return startStreamerResolution(m.runtime, m.authState)
}
// Update applies auth progress events and handles the global quit keys.
// Update applies runtime progress events and handles global keys.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case authStartedMsg:
@@ -146,12 +93,49 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamerUpdates = msg.Updates
return m, waitForStreamerUpdate(msg.Updates)
case AuthUpdate:
return m.updateAuth(msg)
case StreamerUpdate:
return m.updateStreamer(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeComponents()
m.ensureSelection()
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.mode == streamerView {
m.moveSelection(-1)
}
return m, nil
case "down", "j":
if m.mode == streamerView {
m.moveSelection(1)
}
return m, nil
case "ctrl+c", "esc", "q":
return m, tea.Quit
}
}
if m.mode == authView {
var cmd tea.Cmd
m.authViewport, cmd = m.authViewport.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
if msg.Line != "" {
m.authLogs = append(m.authLogs, msg.Line)
m.syncAuthViewport()
}
if msg.Done {
if msg.Err != nil {
m.authErr = msg.Err
m.syncAuthViewport()
return m, nil
}
if msg.State != nil {
@@ -164,14 +148,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ircDetails = make(map[string]ircDetail)
m.selectedConfig = ""
m.ensureSelection()
return m, startStreamerResolution(m.startResolve, m.authState)
m.resizeComponents()
return m, startStreamerResolution(m.runtime, m.authState)
}
return m, nil
}
if m.authUpdates != nil {
return m, waitForAuthUpdate(m.authUpdates)
}
case StreamerUpdate:
return m, nil
}
func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
if msg.Viewer != nil {
m.viewer = msg.Viewer
m.resolveErr = nil
@@ -186,120 +174,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resolveErr = msg.Err
}
m.ensureSelection()
m.resizeComponents()
if msg.Done {
return m, nil
}
if m.streamerUpdates != nil {
return m, waitForStreamerUpdate(m.streamerUpdates)
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.ensureSelection()
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.mode == streamerView {
m.moveSelection(-1)
}
case "down", "j":
if m.mode == streamerView {
m.moveSelection(1)
}
case "ctrl+c", "esc", "q":
return m, tea.Quit
}
}
return m, nil
}
// View renders either the login log screen or the authenticated streamer list.
func (m Model) View() string {
if m.mode == authView {
var builder strings.Builder
builder.WriteString("Twitch Login\n")
if len(m.authLogs) == 0 {
builder.WriteString("Starting authentication...\n")
} else {
for _, line := range m.authLogs {
builder.WriteString(line)
builder.WriteByte('\n')
}
}
if m.authErr != nil {
builder.WriteString("\nLogin did not complete. Press q to quit.\n")
}
builder.WriteString("\n")
return builder.String()
}
return m.renderStreamerView()
}
func (m Model) renderStreamerView() string {
var builder strings.Builder
switch {
case m.viewer != nil:
fmt.Fprintf(&builder, "Logged in as %s (%s)\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", m.resolveErr)
} else {
builder.WriteString("Resolving viewer identity...\n")
}
}
fmt.Fprintf(&builder, "Watching: %s\n", watchingSummary(m.streamers))
rows := m.orderedRows()
selectedIndex := m.selectedRowIndex(rows)
leftPane := m.leftPaneLines(rows)
rightPane := m.rightPaneLines(rows, selectedIndex)
if m.height > 0 {
available := m.height - 3
if available > 0 {
leftPane = clipListLines(leftPane, available, selectedIndex+1)
rightPane = clipTailLines(rightPane, available)
}
}
bodyWidth := m.width
if bodyWidth <= 0 {
bodyWidth = defaultViewWidth
}
builder.WriteString("\n")
for _, line := range renderColumns(leftPane, rightPane, bodyWidth) {
builder.WriteString(line)
builder.WriteByte('\n')
}
builder.WriteByte('\n')
return builder.String()
}
func watchingSummary(streamers []twitch.StreamerEntry) string {
live := make([]string, 0, 2)
for _, streamer := range streamers {
if streamer.Status != twitch.StreamerReady || !streamer.Live {
continue
}
name := streamer.Login
if name == "" {
name = streamer.ConfigLogin
}
live = append(live, name)
if len(live) == 2 {
break
}
}
if len(live) == 0 {
return "no live streamers"
}
return strings.Join(live, ", ")
}
func (m *Model) applyIRCUpdate(update IRCUpdate) {
login := normalizeKey(update.Login)
if login == "" {
@@ -314,114 +198,89 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
detail.joined = false
}
if update.Line != "" {
detail.line = update.Line
}
m.ircDetails[login] = detail
}
func (m *Model) ensureSelection() {
rows := m.orderedRows()
if len(rows) == 0 {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
return
}
if m.selectedConfig == "" {
m.selectedConfig = rows[0].entry.ConfigLogin
m.selectedConfig = entries[0].ConfigLogin
return
}
for _, row := range rows {
if row.entry.ConfigLogin == m.selectedConfig {
for _, entry := range entries {
if entry.ConfigLogin == m.selectedConfig {
return
}
}
m.selectedConfig = rows[0].entry.ConfigLogin
m.selectedConfig = entries[0].ConfigLogin
}
func (m *Model) moveSelection(delta int) {
rows := m.orderedRows()
if len(rows) == 0 {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
return
}
m.ensureSelection()
selected := m.selectedRowIndex(rows)
selected := m.selectedRowIndex(entries) + delta
if selected < 0 {
selected = 0
}
selected += delta
if selected < 0 {
selected = 0
if selected >= len(entries) {
selected = len(entries) - 1
}
if selected >= len(rows) {
selected = len(rows) - 1
}
m.selectedConfig = rows[selected].entry.ConfigLogin
m.selectedConfig = entries[selected].ConfigLogin
}
func (m Model) orderedRows() []streamerRow {
rows := make([]streamerRow, 0, len(m.streamers))
for index, entry := range m.streamers {
func (m Model) orderedStreamers() []twitch.StreamerEntry {
entries := make([]twitch.StreamerEntry, 0, len(m.streamers))
for _, entry := range m.streamers {
if isActive(entry) {
rows = append(rows, streamerRow{index: index, entry: entry})
entries = append(entries, entry)
}
}
for index, entry := range m.streamers {
for _, entry := range m.streamers {
if isActive(entry) {
continue
}
rows = append(rows, streamerRow{index: index, entry: entry})
entries = append(entries, entry)
}
return rows
return entries
}
func (m Model) selectedRowIndex(rows []streamerRow) int {
for index, row := range rows {
if row.entry.ConfigLogin == m.selectedConfig {
func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int {
for index, entry := range entries {
if entry.ConfigLogin == m.selectedConfig {
return index
}
}
if len(rows) == 0 {
if len(entries) == 0 {
return -1
}
return 0
}
func (m Model) leftPaneLines(rows []streamerRow) []string {
lines := []string{"Streamers"}
for _, row := range rows {
prefix := " "
if row.entry.ConfigLogin == m.selectedConfig {
prefix = "> "
func (m *Model) resizeComponents() {
m.authViewport.Width = contentWidth(m.width)
m.authViewport.Height = authViewportHeight(m.height)
}
status := "inactive"
if isActive(row.entry) {
status = "active"
}
lines = append(lines, prefix+streamerName(row.entry)+" ["+status+"]")
}
if len(rows) == 0 {
lines = append(lines, " none")
}
return lines
func (m *Model) syncAuthViewport() {
m.authViewport.SetContent(m.authLogContent())
m.authViewport.GotoBottom()
}
func (m Model) rightPaneLines(rows []streamerRow, selected int) []string {
lines := []string{"IRC Chat"}
if selected < 0 || selected >= len(rows) {
return append(lines, "")
func (m Model) authLogContent() string {
if len(m.authLogs) == 0 {
return "Starting authentication..."
}
row := rows[selected].entry
if !isActive(row) {
return append(lines, "inactive")
}
detail := m.ircDetails[normalizeKey(row.Login)]
if !detail.joined {
return append(lines, "not joined")
}
return append(lines, greenDot)
return strings.Join(m.authLogs, "\n")
}
func isActive(entry twitch.StreamerEntry) bool {
@@ -439,140 +298,39 @@ func normalizeKey(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func renderColumns(left, right []string, totalWidth int) []string {
if totalWidth <= 0 {
totalWidth = defaultViewWidth
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)
}
leftWidth, rightWidth := splitWidths(totalWidth)
height := len(left)
if len(right) > height {
height = len(right)
}
lines := make([]string, 0, height)
for index := 0; index < height; index++ {
leftLine := ""
if index < len(left) {
leftLine = left[index]
}
rightLine := ""
if index < len(right) {
rightLine = right[index]
}
lines = append(lines, fitWidth(leftLine, leftWidth)+" │ "+fitWidth(rightLine, rightWidth))
}
return lines
}
func splitWidths(totalWidth int) (int, int) {
if totalWidth < 8 {
return max(1, totalWidth-4), 1
}
leftWidth := totalWidth / 3
if leftWidth < 20 {
leftWidth = 20
}
if leftWidth > 32 {
leftWidth = 32
}
rightWidth := totalWidth - leftWidth - 3
if rightWidth < 8 {
rightWidth = 8
leftWidth = totalWidth - rightWidth - 3
if leftWidth < 1 {
leftWidth = 1
rightWidth = max(1, totalWidth-leftWidth-3)
}
}
return leftWidth, rightWidth
}
func fitWidth(value string, width int) string {
if width <= 0 {
return ""
}
runes := []rune(value)
if len(runes) > width {
return string(runes[:width])
}
return value + strings.Repeat(" ", width-len(runes))
}
func clipListLines(lines []string, height, selectedLine int) []string {
if height <= 0 || len(lines) <= height {
return lines
}
if height == 1 {
return lines[:1]
}
rows := lines[1:]
visibleRows := height - 1
selectedRow := selectedLine - 1
if selectedRow < 0 {
selectedRow = 0
}
start := 0
if selectedRow >= visibleRows {
start = selectedRow - visibleRows + 1
}
if start+visibleRows > len(rows) {
start = len(rows) - visibleRows
}
if start < 0 {
start = 0
}
return append([]string{lines[0]}, rows[start:start+visibleRows]...)
}
func clipTailLines(lines []string, height int) []string {
if height <= 0 || len(lines) <= height {
return lines
}
if height == 1 {
return lines[:1]
}
body := lines[1:]
visibleRows := height - 1
if len(body) > visibleRows {
body = body[len(body)-visibleRows:]
}
return append([]string{lines[0]}, body...)
}
// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea.
func startAuthSession(start StartAuthFunc) tea.Cmd {
func startAuthSession(runtime modelRuntime) tea.Cmd {
return func() tea.Msg {
if start == nil {
return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true}
if runtime == nil {
return AuthUpdate{Err: errors.New("auth runtime is nil"), Done: true}
}
updates := make(chan AuthUpdate, 32)
start(updates)
runtime.startAuth(updates)
return authStartedMsg{Updates: updates}
}
}
// startStreamerResolution starts the background streamer resolver and returns its update channel.
func startStreamerResolution(start StartResolveFunc, state *auth.State) tea.Cmd {
func startStreamerResolution(runtime modelRuntime, 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 runtime == nil {
return StreamerUpdate{Err: errors.New("streamer runtime 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)
runtime.startResolve(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 {
update, ok := <-updates
@@ -583,7 +341,6 @@ func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
}
}
// 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
@@ -593,12 +350,3 @@ func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
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)
}
+101 -151
View File
@@ -5,118 +5,94 @@ import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/twitch"
)
func TestViewDisplaysSplitPaneWithSelectedStreamerDetails(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", ChannelID: "2", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
},
})
model.mode = streamerView
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
model.width = 80
type fakeModelRuntime struct {
authStarted bool
resolveStarted *auth.State
}
got := model.View()
if !strings.Contains(got, "Watching: alpha_live, beta_live\n\n") {
t.Fatalf("View() missing watching summary:\n%s", got)
func (f *fakeModelRuntime) startAuth(ch chan<- AuthUpdate) {
f.authStarted = true
close(ch)
}
if !strings.Contains(got, "> alpha_live [active]") {
t.Fatalf("View() missing selected active row:\n%s", got)
}
if !strings.Contains(got, "gamma [inactive]") {
t.Fatalf("View() missing inactive row:\n%s", got)
}
if !strings.Contains(got, "IRC Chat") || !strings.Contains(got, "not joined") {
t.Fatalf("View() missing detail pane:\n%s", got)
func (f *fakeModelRuntime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
f.resolveStarted = state
close(ch)
}
func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
)
assertContainsAll(t, model.View(),
"Watching: alpha_live, beta_live",
"alpha_live",
"live | irc idle",
"gamma",
"loading",
"IRC Chat",
"not joined",
)
}
func TestAuthUpdateAppendsLogLine(t *testing.T) {
model := New(Options{Streamers: twitch.LoadingStreamerEntries([]string{"alpha"})})
updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
updated, cmd := New(Options{Streamers: []string{"alpha"}}).Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
if cmd != nil {
t.Fatal("expected nil cmd after auth update without channel")
}
next := updated.(Model)
got := next.View()
want := "Twitch Login\nOpen page: https://www.twitch.tv/activate\n\n"
if got != want {
t.Fatalf("View() = %q, want %q", got, want)
}
assertContainsAll(t, updated.(Model).View(), "Twitch Login", "Open page: https://www.twitch.tv/activate")
}
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)
},
})
runtime := &fakeModelRuntime{}
state := &auth.State{Login: "viewer", UserID: "7"}
updated, cmd := model.Update(AuthUpdate{
State: state,
Done: true,
})
updated, cmd := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).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)
if runtime.resolveStarted != state {
t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state)
}
next := updated.(Model)
got := next.View()
if !strings.Contains(got, "Watching: no live streamers") {
t.Fatalf("View() = %q", got)
}
assertContainsAll(t, next.View(), "Watching: no live streamers")
if next.selectedConfig != "alpha" {
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
}
}
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)
},
})
func TestInitStartsAuthOrResolution(t *testing.T) {
runtime := &fakeModelRuntime{}
if _, ok := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Init()().(authStartedMsg); !ok {
t.Fatal("unauthenticated Init() did not start auth")
}
if !runtime.authStarted {
t.Fatal("expected auth runtime to start")
}
cmd := model.Init()
if cmd == nil {
t.Fatal("expected init command")
state := &auth.State{Login: "viewer", UserID: "7"}
runtime = &fakeModelRuntime{}
if _, ok := New(Options{Streamers: []string{"alpha"}, AuthState: state, runtime: runtime}).Init()().(streamerStartedMsg); !ok {
t.Fatal("authenticated Init() did not start streamer resolution")
}
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)
if runtime.resolveStarted != state {
t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state)
}
}
func TestStreamerUpdateAppliesEntry(t *testing.T) {
model := New(Options{
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
AuthState: &auth.State{Login: "viewer"},
})
model := New(Options{Streamers: []string{"alpha"}, AuthState: &auth.State{Login: "viewer"}})
model.mode = streamerView
updated, cmd := model.Update(StreamerUpdate{
@@ -135,39 +111,29 @@ func TestStreamerUpdateAppliesEntry(t *testing.T) {
}
next := updated.(Model)
assertContainsAll(t, next.View(), "alpha_live", "live")
if next.selectedConfig != "alpha" {
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
}
}
func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady},
},
})
model.mode = streamerView
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
model.width = 80
got := model.View()
if !strings.Contains(got, "inactive") {
t.Fatalf("View() = %q", got)
}
model := dashboardModel(twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady})
assertContainsAll(t, model.View(), "offline", "inactive")
}
func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
model := New(Options{initialStreamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "delta", Status: twitch.StreamerLoading},
},
})
}})
rows := model.orderedRows()
got := []string{rows[0].entry.ConfigLogin, rows[1].entry.ConfigLogin, rows[2].entry.ConfigLogin, rows[3].entry.ConfigLogin}
got := []string{}
for _, entry := range model.orderedStreamers() {
got = append(got, entry.ConfigLogin)
}
want := []string{"beta", "gamma", "alpha", "delta"}
for index := range want {
if got[index] != want[index] {
@@ -178,7 +144,7 @@ func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
initialStreamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
},
@@ -189,33 +155,19 @@ func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
updated, _ := model.Update(StreamerUpdate{
Index: 1,
Entry: &twitch.StreamerEntry{
ConfigLogin: "beta",
Login: "beta_live",
Live: true,
Status: twitch.StreamerReady,
},
Entry: &twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
})
next := updated.(Model)
if next.selectedConfig != "beta" {
t.Fatalf("selectedConfig = %q, want beta", next.selectedConfig)
}
if next.selectedRowIndex(next.orderedRows()) != 0 {
t.Fatalf("selected row index = %d, want 0", next.selectedRowIndex(next.orderedRows()))
if next.selectedConfig != "beta" || next.selectedRowIndex(next.orderedStreamers()) != 0 {
t.Fatalf("selection moved after reorder: %q at %d", next.selectedConfig, next.selectedRowIndex(next.orderedStreamers()))
}
}
func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
},
AuthState: &auth.State{Login: "viewer"},
})
model.mode = streamerView
model.selectedConfig = "alpha"
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
)
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
next := updated.(Model)
@@ -231,49 +183,47 @@ func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
}
func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
},
AuthState: &auth.State{Login: "viewer"},
})
model.mode = streamerView
model.width = 80
updated, _ := model.Update(StreamerUpdate{
IRC: &IRCUpdate{
updated, _ := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
Login: "alpha_live",
State: IRCJoined,
},
})
Live: true,
Status: twitch.StreamerReady,
}).Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}})
next := updated.(Model)
detail := next.ircDetails["alpha_live"]
if !detail.joined {
if !next.ircDetails["alpha_live"].joined {
t.Fatal("expected joined detail")
}
if !strings.Contains(next.View(), greenDot) {
t.Fatalf("View() missing green status dot:\n%s", next.View())
}
assertContainsAll(t, next.View(), "joined")
}
func TestWindowSizeClipsListAroundSelection(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Status: twitch.StreamerReady},
{ConfigLogin: "delta", Status: twitch.StreamerReady},
},
AuthState: &auth.State{Login: "viewer"},
})
model.mode = streamerView
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "delta", Status: twitch.StreamerReady},
)
model.selectedConfig = "delta"
updated, _ := model.Update(tea.WindowSizeMsg{Width: 60, Height: 6})
next := updated.(Model)
got := next.View()
if !strings.Contains(got, "> delta [inactive]") {
t.Fatalf("View() missing selected row after clipping:\n%s", got)
updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
assertContainsAll(t, updated.(Model).View(), "delta")
}
func dashboardModel(entries ...twitch.StreamerEntry) Model {
model := New(Options{initialStreamers: entries, AuthState: &auth.State{Login: "viewer"}})
model.mode = streamerView
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
model.width = 100
model.height = 28
return model
}
func assertContainsAll(t *testing.T, got string, wants ...string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in:\n%s", want, got)
}
}
}
+298
View File
@@ -0,0 +1,298 @@
package tui
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/gql"
"parasocial/internal/irc"
"parasocial/internal/twitch"
)
const streamerRefreshInterval = 5 * time.Minute
// Options configures the TUI runtime.
type Options struct {
Streamers []string
AuthState *auth.State
AuthPath string
Input io.Reader
Output io.Writer
runtime modelRuntime
initialStreamers []twitch.StreamerEntry
httpClient *http.Client
}
type runtime struct {
ctx context.Context
logins []string
httpClient *http.Client
authClient *auth.Client
authPath string
refreshEvery time.Duration
sleep func(context.Context, time.Duration) error
}
// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package.
func Run(ctx context.Context, options Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rt := newRuntime(ctx, options)
state := options.AuthState
if state == nil {
var err error
state, err = rt.reuseAuth()
if err != nil {
return err
}
}
options.AuthState = state
options.runtime = rt
input := options.Input
if input == nil {
input = os.Stdin
}
output := options.Output
if output == nil {
output = os.Stdout
}
program := tea.NewProgram(
New(options),
tea.WithContext(ctx),
tea.WithInput(input),
tea.WithOutput(output),
)
_, err := program.Run()
return err
}
func newRuntime(ctx context.Context, options Options) *runtime {
httpClient := options.httpClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
authPath := options.AuthPath
if authPath == "" {
authPath = auth.DefaultPath()
}
return &runtime{
ctx: ctx,
logins: append([]string(nil), options.Streamers...),
httpClient: httpClient,
authClient: auth.NewClient(httpClient),
authPath: authPath,
refreshEvery: streamerRefreshInterval,
sleep: sleepContext,
}
}
func (r *runtime) reuseAuth() (*auth.State, error) {
return r.authClient.ReuseAuth(r.ctx, r.authPath)
}
func (r *runtime) startAuth(ch chan<- AuthUpdate) {
go func() {
defer close(ch)
state, err := r.authClient.EnsureAuth(r.ctx, r.authPath, func(line string) {
ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")}
})
if err != nil {
ch <- AuthUpdate{
Line: fmt.Sprintf("Authentication failed: %v", err),
Err: err,
Done: true,
}
return
}
ch <- AuthUpdate{State: state, Done: true}
}()
}
func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
go func() {
defer close(ch)
ircManager := newIRCManager(ch)
defer ircManager.Close()
service, err := newTwitchService(r.httpClient, state)
if err != nil {
ch <- StreamerUpdate{Err: err, Done: true}
return
}
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, func(update StreamerUpdate) {
ch <- update
}, r.refreshEvery, r.sleep); err != nil {
if errors.Is(err, context.Canceled) {
return
}
ch <- StreamerUpdate{Err: err, Done: true}
return
}
ch <- StreamerUpdate{Done: true}
}()
}
type streamerService interface {
CurrentUser(context.Context) (*twitch.Viewer, error)
ResolveStreamer(context.Context, string) (*twitch.Channel, error)
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
}
type ircSyncer interface {
Sync(context.Context, string, string, []irc.Target)
}
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
}
func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager {
return &irc.Manager{
Addr: irc.DefaultAddr,
Events: func(event irc.Event) {
ch <- StreamerUpdate{
IRC: &IRCUpdate{
Login: event.Streamer,
State: IRCState(event.State),
Line: event.Line,
},
}
},
}
}
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate)) error {
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext)
}
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
viewer, err := service.CurrentUser(ctx)
if err != nil {
return fmt.Errorf("resolve current user: %w", err)
}
send(StreamerUpdate{Viewer: viewer})
entries := twitch.LoadingStreamerEntries(logins)
syncWatchedChannels(ctx, syncer, state, entries)
for {
for index, login := range logins {
entry, err := resolveStreamerEntry(ctx, service, login)
if errors.Is(err, context.Canceled) {
return err
}
entries[index] = *entry
send(StreamerUpdate{
Index: index,
Entry: entry,
})
syncWatchedChannels(ctx, syncer, state, entries)
}
if err := sleep(ctx, interval); err != nil {
return err
}
}
}
func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) {
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
case errors.Is(err, context.Canceled):
return nil, err
default:
entry.Status = twitch.StreamerError
entry.Error = err.Error()
return entry, nil
}
stream, err := service.StreamInfo(ctx, channel.ID)
switch {
case err == nil:
entry.Status = twitch.StreamerReady
entry.Live = stream.Online
case errors.Is(err, context.Canceled):
return nil, err
default:
entry.Status = twitch.StreamerError
entry.Error = err.Error()
return entry, nil
}
if !entry.Live {
return entry, nil
}
if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) {
return nil, err
}
return entry, nil
}
func sleepContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) {
if syncer == nil || state == nil {
return
}
syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries))
}
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
targets := make([]irc.Target, 0, 2)
for _, entry := range entries {
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
continue
}
targets = append(targets, irc.Target{Login: entry.Login})
if len(targets) == 2 {
break
}
}
return targets
}
+236
View File
@@ -0,0 +1,236 @@
package tui
import (
"context"
"errors"
"reflect"
"testing"
"time"
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/twitch"
)
type fakeStreamerService struct {
channels map[string]*twitch.Channel
resolveErrs map[string]error
streams map[string][]streamResult
streamCalls map[string]int
playbackErr map[string]error
}
type streamResult struct {
info *twitch.StreamInfo
err error
}
func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) {
return &twitch.Viewer{ID: "7", Login: "viewer"}, nil
}
func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) {
if err, ok := f.resolveErrs[login]; ok {
return nil, err
}
return f.channels[login], nil
}
func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) {
call := f.streamCalls[channelID]
f.streamCalls[channelID] = call + 1
results := f.streams[channelID]
if len(results) == 0 {
return &twitch.StreamInfo{Online: false}, nil
}
if call >= len(results) {
call = len(results) - 1
}
if results[call].err != nil {
return nil, results[call].err
}
return results[call].info, nil
}
func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) {
if err, ok := f.playbackErr[login]; ok {
return nil, err
}
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil
}
type fakeIRCSyncer struct {
calls [][]string
}
func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) {
logins := make([]string, 0, len(targets))
for _, target := range targets {
logins = append(logins, target.Login)
}
f.calls = append(f.calls, logins)
}
func TestResolveStreamerEntries(t *testing.T) {
service := streamService(
channel("alpha", "1", "alpha_live"),
resolveErr("beta", errors.New("lookup failed")),
live("1", true),
)
updates := collectUpdates(t, service, []string{"alpha", "beta"}, nil, cancelAfter(0))
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 entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || entry.Login != "alpha_live" || !entry.Live {
t.Fatalf("alpha update = %#v", updates[1])
}
if entry := updates[2].Entry; entry == nil || entry.Status != twitch.StreamerError {
t.Fatalf("beta update = %#v", updates[2])
}
}
func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) {
updates := collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
live("1", true),
playbackErr("alpha_live", errors.New("token lookup failed")),
), []string{"alpha"}, nil, cancelAfter(0))
if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || !entry.Live {
t.Fatalf("entry = %#v", entry)
}
}
func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) {
updates := collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
streams("1", false, true),
), []string{"alpha"}, nil, cancelAfter(1))
if len(updates) != 3 {
t.Fatalf("len(updates) = %d", len(updates))
}
if updates[1].Entry == nil || updates[1].Entry.Live {
t.Fatalf("first pass = %#v", updates[1])
}
if updates[2].Entry == nil || !updates[2].Entry.Live {
t.Fatalf("second pass = %#v", updates[2])
}
}
func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) {
syncer := &fakeIRCSyncer{}
collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
channel("beta", "2", "beta_live"),
channel("gamma", "3", "gamma_live"),
live("1", true),
live("2", true),
live("3", true),
), []string{"alpha", "beta", "gamma"}, syncer, cancelAfter(0))
assertSyncCalls(t, syncer.calls, [][]string{
{},
{"alpha_live"},
{"alpha_live", "beta_live"},
{"alpha_live", "beta_live"},
})
}
func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) {
syncer := &fakeIRCSyncer{}
collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
channel("beta", "2", "beta_live"),
streams("1", true, false),
streams("2", false, true),
), []string{"alpha", "beta"}, syncer, cancelAfter(1))
assertSyncCalls(t, syncer.calls, [][]string{
{},
{"alpha_live"},
{"alpha_live"},
{},
{"beta_live"},
})
}
type serviceOption func(*fakeStreamerService)
func streamService(options ...serviceOption) *fakeStreamerService {
service := &fakeStreamerService{
channels: map[string]*twitch.Channel{},
resolveErrs: map[string]error{},
streams: map[string][]streamResult{},
streamCalls: map[string]int{},
playbackErr: map[string]error{},
}
for _, option := range options {
option(service)
}
return service
}
func channel(login, id, resolved string) serviceOption {
return func(service *fakeStreamerService) {
service.channels[login] = &twitch.Channel{ID: id, Login: resolved}
}
}
func resolveErr(login string, err error) serviceOption {
return func(service *fakeStreamerService) {
service.resolveErrs[login] = err
}
}
func live(channelID string, online bool) serviceOption {
return streams(channelID, online)
}
func streams(channelID string, online ...bool) serviceOption {
return func(service *fakeStreamerService) {
for _, value := range online {
service.streams[channelID] = append(service.streams[channelID], streamResult{info: &twitch.StreamInfo{Online: value}})
}
}
}
func playbackErr(login string, err error) serviceOption {
return func(service *fakeStreamerService) {
service.playbackErr[login] = err
}
}
func collectUpdates(t *testing.T, service *fakeStreamerService, logins []string, syncer ircSyncer, sleep func(context.Context, time.Duration) error) []StreamerUpdate {
t.Helper()
var updates []StreamerUpdate
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, logins, syncer, func(update StreamerUpdate) {
updates = append(updates, update)
}, 0, sleep)
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
return updates
}
func cancelAfter(allowedSleeps int) func(context.Context, time.Duration) error {
calls := 0
return func(context.Context, time.Duration) error {
calls++
if calls <= allowedSleeps {
return nil
}
return context.Canceled
}
}
func assertSyncCalls(t *testing.T, got, want [][]string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("sync calls = %#v, want %#v", got, want)
}
}
+54
View File
@@ -0,0 +1,54 @@
package tui
import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
var (
pageStyle = lipgloss.NewStyle().
Padding(1, 2)
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#E6EDF3"))
mutedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6E7681"))
accentStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7CE38B"))
warnStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F0883E"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF7B72"))
panelStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#30363D")).
Padding(1, 2)
labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E")).
Bold(true)
statusLiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7CE38B"))
statusIdleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E"))
statusErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF7B72"))
rowNameStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#C9D1D9")).
PaddingLeft(1)
rowDescStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E")).
PaddingLeft(1)
selectedRowNameStyle = rowNameStyle.
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#7CE38B")).
Foreground(lipgloss.Color("#E6EDF3"))
selectedRowDescStyle = rowDescStyle.
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#7CE38B"))
)
func newAuthViewport(width, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = lipgloss.NewStyle()
return vp
}
+239
View File
@@ -0,0 +1,239 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"parasocial/internal/twitch"
)
// View renders either the login log screen or the authenticated streamer dashboard.
func (m Model) View() string {
if m.mode == authView {
return m.renderAuthView()
}
return m.renderStreamerView()
}
func (m Model) renderAuthView() string {
body := []string{
titleStyle.Render("Twitch Login"),
mutedStyle.Render("Complete the device authorization flow to continue."),
"",
panelStyle.Width(contentWidth(m.width)).Render(m.authViewport.View()),
}
if m.authErr != nil {
body = append(body, "", errorStyle.Render("Login did not complete. Press q to quit."))
}
return pageStyle.Render(strings.Join(body, "\n")) + "\n"
}
func (m Model) renderStreamerView() string {
header := m.renderDashboardHeader()
left := panelStyle.
Width(streamerListWidth(m.width)).
Height(panelHeight(m.height)).
Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows())
right := panelStyle.
Width(detailWidth(m.width)).
Height(panelHeight(m.height)).
Render(m.renderDetailPanel())
body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
return pageStyle.Render(header+"\n\n"+body) + "\n"
}
func (m Model) renderDashboardHeader() string {
var identity string
switch {
case m.viewer != nil:
identity = fmt.Sprintf("Logged in as %s (%s)", m.viewer.Login, m.viewer.ID)
case m.authState != nil && m.authState.Login != "":
identity = fmt.Sprintf("Logged in as %s", m.authState.Login)
default:
identity = "Logged in"
}
summary := "Watching: " + watchingSummary(m.streamers)
if m.resolveErr != nil {
summary += " " + warnStyle.Render(fmt.Sprintf("Viewer lookup failed: %v", m.resolveErr))
} else if m.viewer == nil {
summary += " " + mutedStyle.Render("Resolving viewer identity...")
}
return titleStyle.Render(identity) + "\n" + mutedStyle.Render(summary)
}
func (m Model) renderDetailPanel() string {
entries := m.orderedStreamers()
selected := m.selectedRowIndex(entries)
if selected < 0 || selected >= len(entries) {
return labelStyle.Render("IRC Chat") + "\n\n" + mutedStyle.Render("No streamers configured")
}
entry := entries[selected]
lines := []string{
labelStyle.Render("IRC Chat"),
"",
titleStyle.Render(streamerName(entry)),
"Status: " + statusText(entry),
}
if entry.Error != "" {
lines = append(lines, "Error: "+errorStyle.Render(entry.Error))
}
if !isActive(entry) {
lines = append(lines, "IRC: "+mutedStyle.Render("inactive"))
return strings.Join(lines, "\n")
}
detail := m.ircDetails[normalizeKey(entry.Login)]
if detail.joined {
lines = append(lines, "IRC: "+accentStyle.Render("joined"))
} else {
lines = append(lines, "IRC: "+mutedStyle.Render("not joined"))
}
if detail.line != "" {
lines = append(lines, "", mutedStyle.Render(detail.line))
}
return strings.Join(lines, "\n")
}
func (m Model) renderStreamerRows() string {
entries := m.visibleStreamers()
if len(entries) == 0 {
return mutedStyle.Render("none")
}
lines := make([]string, 0, len(entries)*2)
for _, entry := range entries {
nameStyle, descStyle := rowNameStyle, rowDescStyle
if entry.ConfigLogin == m.selectedConfig {
nameStyle, descStyle = selectedRowNameStyle, selectedRowDescStyle
}
detail := m.ircDetails[normalizeKey(entry.Login)]
lines = append(lines,
nameStyle.Render(streamerName(entry)),
descStyle.Render(rawStatus(entry)+" | "+ircSummary(detail)),
)
}
return strings.Join(lines, "\n")
}
func (m Model) visibleStreamers() []twitch.StreamerEntry {
entries := m.orderedStreamers()
visible := max(1, streamerListHeight(m.height)/2)
if len(entries) <= visible {
return entries
}
selected := max(0, m.selectedRowIndex(entries))
start := selected - visible + 1
if start < 0 {
start = 0
}
if start+visible > len(entries) {
start = len(entries) - visible
}
return entries[start : start+visible]
}
func watchingSummary(streamers []twitch.StreamerEntry) string {
live := make([]string, 0, 2)
for _, streamer := range streamers {
if streamer.Status != twitch.StreamerReady || !streamer.Live {
continue
}
name := streamer.Login
if name == "" {
name = streamer.ConfigLogin
}
live = append(live, name)
if len(live) == 2 {
break
}
}
if len(live) == 0 {
return "no live streamers"
}
return strings.Join(live, ", ")
}
func statusText(entry twitch.StreamerEntry) string {
switch {
case entry.Status == twitch.StreamerError:
return statusErrorStyle.Render("error")
case entry.Status == twitch.StreamerLoading:
return mutedStyle.Render("loading")
case entry.Live:
return statusLiveStyle.Render("live")
default:
return statusIdleStyle.Render("offline")
}
}
func rawStatus(entry twitch.StreamerEntry) string {
switch {
case entry.Status == twitch.StreamerError:
return "error"
case entry.Status == twitch.StreamerLoading:
return "loading"
case entry.Live:
return "live"
default:
return "offline"
}
}
func ircSummary(detail ircDetail) string {
if detail.joined {
return "irc joined"
}
return "irc idle"
}
func contentWidth(width int) int {
if width <= 0 {
return defaultViewWidth
}
return max(24, width-4)
}
func authViewportHeight(height int) int {
if height <= 0 {
return 8
}
return max(4, height-8)
}
func streamerListWidth(width int) int {
if width <= 0 {
return 34
}
if width < 72 {
return max(24, width/2-5)
}
return min(40, max(30, width/3))
}
func detailWidth(width int) int {
if width <= 0 {
return defaultViewWidth - streamerListWidth(width) - 7
}
return max(24, width-streamerListWidth(width)-11)
}
func streamerListHeight(height int) int {
if height <= 0 {
return 14
}
return max(4, height-10)
}
func panelHeight(height int) int {
if height <= 0 {
return 16
}
return max(6, height-8)
}