feat: add irc-backed streamer split view
This commit is contained in:
+53
-5
@@ -17,6 +17,7 @@ import (
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/config"
|
||||
"parasocial/internal/gql"
|
||||
"parasocial/internal/irc"
|
||||
"parasocial/internal/tui"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
@@ -68,13 +69,15 @@ func Run(ctx context.Context) error {
|
||||
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, cfg.Streamers, func(update tui.StreamerUpdate) {
|
||||
if err := resolveStreamerEntries(ctx, service, state, cfg.Streamers, ircManager, func(update tui.StreamerUpdate) {
|
||||
ch <- update
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
@@ -103,6 +106,10 @@ type streamerService interface {
|
||||
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{
|
||||
@@ -120,28 +127,48 @@ func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Servi
|
||||
return &twitch.Service{GQL: client}, nil
|
||||
}
|
||||
|
||||
// resolveStreamerEntries streams viewer and streamer resolution results into the TUI.
|
||||
func resolveStreamerEntries(ctx context.Context, service streamerService, logins []string, send func(tui.StreamerUpdate)) error {
|
||||
return resolveStreamerEntriesWithSleep(ctx, service, logins, send, streamerRefreshInterval, sleepContext)
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, logins []string, send func(tui.StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
|
||||
// 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
|
||||
@@ -201,3 +228,24 @@ func sleepContext(ctx context.Context, duration time.Duration) error {
|
||||
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
|
||||
}
|
||||
|
||||
+102
-3
@@ -6,9 +6,12 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/irc"
|
||||
"parasocial/internal/tui"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
@@ -73,6 +76,18 @@ func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login strin
|
||||
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()
|
||||
@@ -91,7 +106,7 @@ func TestResolveStreamerEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
var updates []tui.StreamerUpdate
|
||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, []string{"alpha", "beta"}, func(update 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
|
||||
@@ -130,7 +145,7 @@ func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) {
|
||||
}
|
||||
|
||||
var updates []tui.StreamerUpdate
|
||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, []string{"alpha"}, func(update 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
|
||||
@@ -165,7 +180,7 @@ func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) {
|
||||
|
||||
var updates []tui.StreamerUpdate
|
||||
sleepCalls := 0
|
||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, []string{"alpha"}, func(update 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 {
|
||||
sleepCalls++
|
||||
@@ -187,3 +202,87 @@ func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// Package irc manages Twitch IRC chat connections for watched streamers.
|
||||
package irc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultAddr = "irc.chat.twitch.tv:6667"
|
||||
|
||||
// ConnectionState describes the lifecycle state of one IRC connection.
|
||||
type ConnectionState string
|
||||
|
||||
const (
|
||||
StatePending ConnectionState = "pending"
|
||||
StateJoined ConnectionState = "joined"
|
||||
StateDisconnected ConnectionState = "disconnected"
|
||||
)
|
||||
|
||||
// Event carries one IRC lifecycle change or log line for a streamer connection.
|
||||
type Event struct {
|
||||
Streamer string
|
||||
State ConnectionState
|
||||
Line string
|
||||
}
|
||||
|
||||
// EventSink consumes IRC events emitted by a client or manager.
|
||||
type EventSink func(Event)
|
||||
|
||||
// Client maintains one Twitch IRC connection for a single streamer channel.
|
||||
type Client struct {
|
||||
Addr string
|
||||
Login string
|
||||
Token string
|
||||
Streamer string
|
||||
Once bool
|
||||
Debug bool
|
||||
Out io.Writer
|
||||
Events EventSink
|
||||
DialContext func(context.Context, string, string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Run connects, authenticates, joins the configured streamer channel, and stays connected.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
if c.Login == "" {
|
||||
return errors.New("missing Twitch login")
|
||||
}
|
||||
if c.Token == "" {
|
||||
return errors.New("missing OAuth access token")
|
||||
}
|
||||
if c.Streamer == "" {
|
||||
return errors.New("missing streamer")
|
||||
}
|
||||
|
||||
addr := c.Addr
|
||||
if addr == "" {
|
||||
addr = DefaultAddr
|
||||
}
|
||||
|
||||
c.status("Connecting to %s\n", addr)
|
||||
conn, err := c.dial(ctx, addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to Twitch IRC: %w", err)
|
||||
}
|
||||
|
||||
session := &session{
|
||||
conn: conn,
|
||||
out: c.Out,
|
||||
debug: c.Debug,
|
||||
}
|
||||
defer session.close()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = session.send("QUIT :parasocial shutting down", false)
|
||||
session.close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
if err := session.send("PASS oauth:"+c.Token, true); err != nil {
|
||||
return err
|
||||
}
|
||||
c.status("Sent PASS\n")
|
||||
if err := session.send("NICK "+c.Login, false); err != nil {
|
||||
return err
|
||||
}
|
||||
c.status("Sent NICK %s\n", c.Login)
|
||||
if err := session.send("JOIN #"+c.Streamer, false); err != nil {
|
||||
return err
|
||||
}
|
||||
c.status("Sent JOIN #%s\n", c.Streamer)
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
joined := false
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if joined {
|
||||
return fmt.Errorf("network interruption after join: %w", err)
|
||||
}
|
||||
return fmt.Errorf("IRC connection closed before join confirmation: %w", err)
|
||||
}
|
||||
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
session.debugIn(line)
|
||||
|
||||
if payload, ok := pingPayload(line); ok {
|
||||
if err := session.send("PONG "+payload, false); err != nil {
|
||||
return err
|
||||
}
|
||||
c.status("Responded to PING\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if isAuthFailure(line) {
|
||||
return fmt.Errorf("IRC authentication failure: %s", line)
|
||||
}
|
||||
if isJoinDenied(line, c.Streamer) {
|
||||
return fmt.Errorf("IRC join denied: %s", line)
|
||||
}
|
||||
if isWelcome(line) {
|
||||
c.status("IRC authentication accepted\n")
|
||||
continue
|
||||
}
|
||||
if isJoinConfirmation(line, c.Login, c.Streamer) {
|
||||
joinedLine := fmt.Sprintf("Joined #%s as %s", c.Streamer, c.Login)
|
||||
c.status("%s\n", joinedLine)
|
||||
c.emit(StateJoined, joinedLine)
|
||||
joined = true
|
||||
if c.Once {
|
||||
if err := session.send("QUIT :parasocial --once complete", false); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if joined {
|
||||
c.status("%s\n", line)
|
||||
c.emit("", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type session struct {
|
||||
conn net.Conn
|
||||
out io.Writer
|
||||
debug bool
|
||||
mu sync.Mutex
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *session) send(line string, redact bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
debugLine := line
|
||||
if redact {
|
||||
debugLine = "PASS oauth:<redacted>"
|
||||
}
|
||||
if s.debug && s.out != nil {
|
||||
fmt.Fprintf(s.out, "> %s\n", debugLine)
|
||||
}
|
||||
_, err := fmt.Fprintf(s.conn, "%s\r\n", line)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *session) debugIn(line string) {
|
||||
if s.debug && s.out != nil {
|
||||
fmt.Fprintf(s.out, "< %s\n", line)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) close() {
|
||||
s.once.Do(func() {
|
||||
_ = s.conn.SetDeadline(time.Now())
|
||||
_ = s.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) dial(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if c.DialContext != nil {
|
||||
return c.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
|
||||
func (c *Client) status(format string, args ...any) {
|
||||
if c.Out == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(c.Out, format, args...)
|
||||
}
|
||||
|
||||
func (c *Client) emit(state ConnectionState, line string) {
|
||||
if c.Events == nil {
|
||||
return
|
||||
}
|
||||
c.Events(Event{
|
||||
Streamer: c.Streamer,
|
||||
State: state,
|
||||
Line: line,
|
||||
})
|
||||
}
|
||||
|
||||
func pingPayload(line string) (string, bool) {
|
||||
if strings.HasPrefix(line, "PING ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "PING ")), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isWelcome(line string) bool {
|
||||
return strings.Contains(line, " 001 ")
|
||||
}
|
||||
|
||||
func isAuthFailure(line string) bool {
|
||||
lower := strings.ToLower(line)
|
||||
return strings.Contains(lower, "login authentication failed") ||
|
||||
strings.Contains(lower, "improperly formatted auth") ||
|
||||
strings.Contains(lower, "error logging in")
|
||||
}
|
||||
|
||||
func isJoinDenied(line, streamer string) bool {
|
||||
lower := strings.ToLower(line)
|
||||
channel := "#" + strings.ToLower(streamer)
|
||||
return (strings.Contains(lower, " 403 ") && strings.Contains(lower, channel)) ||
|
||||
(strings.Contains(lower, "notice "+channel) && strings.Contains(lower, "banned")) ||
|
||||
(strings.Contains(lower, "notice "+channel) && strings.Contains(lower, "not permitted"))
|
||||
}
|
||||
|
||||
func isJoinConfirmation(line, login, streamer string) bool {
|
||||
lower := strings.ToLower(line)
|
||||
channel := "#" + strings.ToLower(streamer)
|
||||
return strings.Contains(lower, " join "+channel) &&
|
||||
(strings.HasPrefix(lower, ":"+strings.ToLower(login)+"!") || strings.Contains(lower, "!"))
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package irc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunOnceSendsAuthJoinAndRespondsToPing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
lines := make(chan string, 10)
|
||||
serverErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
serverErr <- nil
|
||||
return
|
||||
}
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
lines <- line
|
||||
switch line {
|
||||
case "NICK viewer":
|
||||
_, _ = conn.Write([]byte(":tmi.twitch.tv 001 viewer :Welcome\r\n"))
|
||||
case "JOIN #streamer":
|
||||
_, _ = conn.Write([]byte("PING :tmi.twitch.tv\r\n"))
|
||||
case "PONG :tmi.twitch.tv":
|
||||
_, _ = conn.Write([]byte(":viewer!viewer@viewer.tmi.twitch.tv JOIN #streamer\r\n"))
|
||||
case "QUIT :parasocial --once complete":
|
||||
serverErr <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
client := &Client{
|
||||
Addr: listener.Addr().String(),
|
||||
Login: "viewer",
|
||||
Token: "token",
|
||||
Streamer: "streamer",
|
||||
Once: true,
|
||||
Out: io.Discard,
|
||||
}
|
||||
if err := client.Run(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"PASS oauth:token",
|
||||
"NICK viewer",
|
||||
"JOIN #streamer",
|
||||
"PONG :tmi.twitch.tv",
|
||||
"QUIT :parasocial --once complete",
|
||||
}
|
||||
for _, expected := range want {
|
||||
select {
|
||||
case got := <-lines:
|
||||
if got != expected {
|
||||
t.Fatalf("line = %q, want %q", got, expected)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
events := make(chan Event, 4)
|
||||
serverErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch line {
|
||||
case "NICK viewer":
|
||||
_, _ = conn.Write([]byte(":tmi.twitch.tv 001 viewer :Welcome\r\n"))
|
||||
case "JOIN #streamer":
|
||||
_, _ = conn.Write([]byte(":viewer!viewer@viewer.tmi.twitch.tv JOIN #streamer\r\n"))
|
||||
_, _ = conn.Write([]byte(":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello\r\n"))
|
||||
_, _ = conn.Write([]byte("PING :tmi.twitch.tv\r\n"))
|
||||
case "PONG :tmi.twitch.tv":
|
||||
serverErr <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
client := &Client{
|
||||
Addr: listener.Addr().String(),
|
||||
Login: "viewer",
|
||||
Token: "token",
|
||||
Streamer: "streamer",
|
||||
Events: func(event Event) {
|
||||
events <- event
|
||||
},
|
||||
Out: io.Discard,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- client.Run(ctx)
|
||||
}()
|
||||
|
||||
var got []Event
|
||||
for len(got) < 2 {
|
||||
select {
|
||||
case event := <-events:
|
||||
got = append(got, event)
|
||||
if event.Line == ":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello" {
|
||||
cancel()
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for IRC events")
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-serverErr; err != nil && err != io.EOF {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []Event{
|
||||
{Streamer: "streamer", State: StateJoined, Line: "Joined #streamer as viewer"},
|
||||
{Streamer: "streamer", Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello"},
|
||||
}
|
||||
if !reflect.DeepEqual(got[:2], want) {
|
||||
t.Fatalf("events = %#v, want %#v", got[:2], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFailureReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = conn.Write([]byte(":tmi.twitch.tv NOTICE * :Login authentication failed\r\n"))
|
||||
}()
|
||||
|
||||
client := &Client{
|
||||
Addr: listener.Addr().String(),
|
||||
Login: "viewer",
|
||||
Token: "bad-token",
|
||||
Streamer: "streamer",
|
||||
Out: io.Discard,
|
||||
}
|
||||
err = client.Run(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected auth failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authentication failure") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Target identifies one streamer channel that should have an active IRC connection.
|
||||
type Target struct {
|
||||
Login string
|
||||
}
|
||||
|
||||
// Manager reconciles the active IRC connections against the desired watched set.
|
||||
type Manager struct {
|
||||
Addr string
|
||||
Debug bool
|
||||
Out io.Writer
|
||||
Events EventSink
|
||||
DialContext func(context.Context, string, string) (net.Conn, error)
|
||||
RunClient func(context.Context, *Client) error
|
||||
|
||||
mu sync.Mutex
|
||||
active map[string]*managedClient
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type managedClient struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Sync applies the desired IRC target set using the provided viewer credentials.
|
||||
func (m *Manager) Sync(ctx context.Context, viewerLogin, accessToken string, targets []Target) {
|
||||
orderedTargets, desired := normalizeTargets(targets)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.active == nil {
|
||||
m.active = make(map[string]*managedClient)
|
||||
}
|
||||
|
||||
for login, client := range m.active {
|
||||
if _, ok := desired[login]; ok {
|
||||
continue
|
||||
}
|
||||
client.cancel()
|
||||
delete(m.active, login)
|
||||
}
|
||||
|
||||
if viewerLogin == "" || accessToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, login := range orderedTargets {
|
||||
if _, ok := m.active[login]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
client := &managedClient{cancel: cancel}
|
||||
m.active[login] = client
|
||||
|
||||
m.wg.Add(1)
|
||||
go func(streamer string, current *managedClient) {
|
||||
defer m.wg.Done()
|
||||
m.runClient(runCtx, viewerLogin, accessToken, streamer, current)
|
||||
}(login, client)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) runClient(ctx context.Context, viewerLogin, accessToken, streamer string, client *managedClient) {
|
||||
run := m.RunClient
|
||||
if run == nil {
|
||||
run = func(ctx context.Context, conn *Client) error {
|
||||
return conn.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
m.emit(Event{Streamer: streamer, State: StatePending})
|
||||
|
||||
_ = run(ctx, &Client{
|
||||
Addr: m.addr(),
|
||||
Login: viewerLogin,
|
||||
Token: accessToken,
|
||||
Streamer: streamer,
|
||||
Debug: m.Debug,
|
||||
Out: m.Out,
|
||||
Events: m.Events,
|
||||
DialContext: m.DialContext,
|
||||
})
|
||||
m.emit(Event{Streamer: streamer, State: StateDisconnected})
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if current, ok := m.active[streamer]; ok && current == client {
|
||||
delete(m.active, streamer)
|
||||
}
|
||||
}
|
||||
|
||||
// Close cancels every active connection and waits for their goroutines to exit.
|
||||
func (m *Manager) Close() {
|
||||
m.mu.Lock()
|
||||
for login, client := range m.active {
|
||||
client.cancel()
|
||||
delete(m.active, login)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
func (m *Manager) addr() string {
|
||||
if m.Addr != "" {
|
||||
return m.Addr
|
||||
}
|
||||
return DefaultAddr
|
||||
}
|
||||
|
||||
func (m *Manager) emit(event Event) {
|
||||
if m.Events == nil {
|
||||
return
|
||||
}
|
||||
m.Events(event)
|
||||
}
|
||||
|
||||
func normalizeTargets(targets []Target) ([]string, map[string]struct{}) {
|
||||
ordered := make([]string, 0, 2)
|
||||
normalized := make(map[string]struct{}, len(targets))
|
||||
for _, target := range targets {
|
||||
login := strings.ToLower(strings.TrimSpace(target.Login))
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := normalized[login]; ok {
|
||||
continue
|
||||
}
|
||||
normalized[login] = struct{}{}
|
||||
ordered = append(ordered, login)
|
||||
if len(normalized) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ordered, normalized
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerSyncStartsAtMostTwoTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan string, 3)
|
||||
manager := &Manager{
|
||||
RunClient: func(ctx context.Context, client *Client) error {
|
||||
started <- client.Streamer
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{
|
||||
{Login: "alpha"},
|
||||
{Login: "beta"},
|
||||
{Login: "gamma"},
|
||||
})
|
||||
|
||||
assertStartSet(t, started, "alpha", "beta")
|
||||
assertNoStart(t, started)
|
||||
}
|
||||
|
||||
func TestManagerSyncKeepsExistingTargetsConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan string, 3)
|
||||
manager := &Manager{
|
||||
RunClient: func(ctx context.Context, client *Client) error {
|
||||
started <- client.Streamer
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
|
||||
assertStartSet(t, started, "alpha")
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
|
||||
assertNoStart(t, started)
|
||||
}
|
||||
|
||||
func TestManagerSyncCancelsRemovedTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan string, 4)
|
||||
canceled := make(chan string, 4)
|
||||
manager := &Manager{
|
||||
RunClient: func(ctx context.Context, client *Client) error {
|
||||
started <- client.Streamer
|
||||
<-ctx.Done()
|
||||
canceled <- client.Streamer
|
||||
return ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}, {Login: "beta"}})
|
||||
assertStartSet(t, started, "alpha", "beta")
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "beta"}, {Login: "gamma"}})
|
||||
assertStartSet(t, started, "gamma")
|
||||
assertCancels(t, canceled, "alpha")
|
||||
assertNoStart(t, started)
|
||||
}
|
||||
|
||||
func TestManagerSyncRestartsTargetAfterFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan string, 2)
|
||||
var mu sync.Mutex
|
||||
attempts := map[string]int{}
|
||||
|
||||
manager := &Manager{
|
||||
RunClient: func(ctx context.Context, client *Client) error {
|
||||
mu.Lock()
|
||||
attempts[client.Streamer]++
|
||||
attempt := attempts[client.Streamer]
|
||||
mu.Unlock()
|
||||
|
||||
started <- client.Streamer
|
||||
if attempt == 1 {
|
||||
return errors.New("boom")
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
|
||||
assertStartSet(t, started, "alpha")
|
||||
|
||||
waitFor(t, func() bool {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
_, ok := manager.active["alpha"]
|
||||
return !ok
|
||||
})
|
||||
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
|
||||
assertStartSet(t, started, "alpha")
|
||||
}
|
||||
|
||||
func TestManagerEmitsPendingAndDisconnectedEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := make(chan Event, 4)
|
||||
manager := &Manager{
|
||||
Events: func(event Event) {
|
||||
events <- event
|
||||
},
|
||||
RunClient: func(ctx context.Context, client *Client) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
|
||||
cancel()
|
||||
manager.Close()
|
||||
|
||||
var got []Event
|
||||
for len(got) < 2 {
|
||||
select {
|
||||
case event := <-events:
|
||||
got = append(got, event)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for manager events")
|
||||
}
|
||||
}
|
||||
|
||||
want := []Event{
|
||||
{Streamer: "alpha", State: StatePending},
|
||||
{Streamer: "alpha", State: StateDisconnected},
|
||||
}
|
||||
if !reflect.DeepEqual(got[:2], want) {
|
||||
t.Fatalf("events = %#v, want %#v", got[:2], want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStartSet(t *testing.T, started <-chan string, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
got := make([]string, 0, len(want))
|
||||
for range want {
|
||||
select {
|
||||
case login := <-started:
|
||||
got = append(got, login)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for %d starts", len(want))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(got)
|
||||
sortedWant := append([]string(nil), want...)
|
||||
sort.Strings(sortedWant)
|
||||
for index := range sortedWant {
|
||||
if got[index] != sortedWant[index] {
|
||||
t.Fatalf("starts = %#v, want %#v", got, sortedWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertCancels(t *testing.T, canceled <-chan string, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
for _, expected := range want {
|
||||
select {
|
||||
case got := <-canceled:
|
||||
if got != expected {
|
||||
t.Fatalf("cancel = %q, want %q", got, expected)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for cancel %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoStart(t *testing.T, started <-chan string) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case got := <-started:
|
||||
t.Fatalf("unexpected start %q", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, condition func() bool) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met before timeout")
|
||||
}
|
||||
+351
-18
@@ -14,7 +14,8 @@ import (
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
|
||||
const liveDot = "\x1b[32m●\x1b[0m"
|
||||
const defaultViewWidth = 80
|
||||
const greenDot = "\x1b[32m●\x1b[0m"
|
||||
|
||||
type viewMode int
|
||||
|
||||
@@ -23,6 +24,15 @@ 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)
|
||||
|
||||
@@ -41,11 +51,19 @@ type AuthUpdate struct {
|
||||
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
|
||||
@@ -77,6 +95,19 @@ type Model struct {
|
||||
resolveErr error
|
||||
viewer *twitch.Viewer
|
||||
mode viewMode
|
||||
width int
|
||||
height int
|
||||
selectedConfig string
|
||||
ircDetails map[string]ircDetail
|
||||
}
|
||||
|
||||
type ircDetail struct {
|
||||
joined bool
|
||||
}
|
||||
|
||||
type streamerRow struct {
|
||||
index int
|
||||
entry twitch.StreamerEntry
|
||||
}
|
||||
|
||||
// New returns a Bubble Tea model for auth and streamer display.
|
||||
@@ -88,10 +119,12 @@ func New(options Options) Model {
|
||||
startResolve: options.StartResolve,
|
||||
authLogs: []string{},
|
||||
mode: authView,
|
||||
ircDetails: make(map[string]ircDetail),
|
||||
}
|
||||
if options.AuthState != nil {
|
||||
model.mode = streamerView
|
||||
}
|
||||
model.ensureSelection()
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -128,6 +161,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.viewer = nil
|
||||
m.resolveErr = nil
|
||||
m.streamers = loadingEntries(m.streamers)
|
||||
m.ircDetails = make(map[string]ircDetail)
|
||||
m.selectedConfig = ""
|
||||
m.ensureSelection()
|
||||
return m, startStreamerResolution(m.startResolve, m.authState)
|
||||
}
|
||||
return m, nil
|
||||
@@ -143,17 +179,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) {
|
||||
m.streamers[msg.Index] = *msg.Entry
|
||||
}
|
||||
if msg.IRC != nil {
|
||||
m.applyIRCUpdate(*msg.IRC)
|
||||
}
|
||||
if msg.Err != nil {
|
||||
m.resolveErr = msg.Err
|
||||
}
|
||||
m.ensureSelection()
|
||||
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
|
||||
}
|
||||
@@ -164,8 +216,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// View renders either the login log screen or the authenticated streamer list.
|
||||
func (m Model) View() string {
|
||||
var builder strings.Builder
|
||||
if m.mode == authView {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Twitch Login\n")
|
||||
if len(m.authLogs) == 0 {
|
||||
builder.WriteString("Starting authentication...\n")
|
||||
@@ -182,36 +234,317 @@ func (m Model) View() string {
|
||||
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\n", m.viewer.Login, m.viewer.ID)
|
||||
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\n", m.resolveErr)
|
||||
fmt.Fprintf(&builder, "Viewer lookup failed: %v\n", m.resolveErr)
|
||||
} else {
|
||||
builder.WriteString("Resolving viewer identity...\n\n")
|
||||
builder.WriteString("Resolving viewer identity...\n")
|
||||
}
|
||||
}
|
||||
builder.WriteString("Streamers\n")
|
||||
for i, streamer := range m.streamers {
|
||||
switch streamer.Status {
|
||||
case twitch.StreamerReady:
|
||||
label := streamer.Login
|
||||
if streamer.Live {
|
||||
label += " " + liveDot
|
||||
}
|
||||
fmt.Fprintf(&builder, "%d. %s (%s)\n", i+1, label, 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)
|
||||
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 == "" {
|
||||
return
|
||||
}
|
||||
|
||||
detail := m.ircDetails[login]
|
||||
switch update.State {
|
||||
case IRCJoined:
|
||||
detail.joined = true
|
||||
case IRCPending, IRCDisconnected:
|
||||
detail.joined = false
|
||||
}
|
||||
if update.Line != "" {
|
||||
}
|
||||
m.ircDetails[login] = detail
|
||||
}
|
||||
|
||||
func (m *Model) ensureSelection() {
|
||||
rows := m.orderedRows()
|
||||
if len(rows) == 0 {
|
||||
m.selectedConfig = ""
|
||||
return
|
||||
}
|
||||
if m.selectedConfig == "" {
|
||||
m.selectedConfig = rows[0].entry.ConfigLogin
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.entry.ConfigLogin == m.selectedConfig {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.selectedConfig = rows[0].entry.ConfigLogin
|
||||
}
|
||||
|
||||
func (m *Model) moveSelection(delta int) {
|
||||
rows := m.orderedRows()
|
||||
if len(rows) == 0 {
|
||||
m.selectedConfig = ""
|
||||
return
|
||||
}
|
||||
m.ensureSelection()
|
||||
|
||||
selected := m.selectedRowIndex(rows)
|
||||
if selected < 0 {
|
||||
selected = 0
|
||||
}
|
||||
selected += delta
|
||||
if selected < 0 {
|
||||
selected = 0
|
||||
}
|
||||
if selected >= len(rows) {
|
||||
selected = len(rows) - 1
|
||||
}
|
||||
m.selectedConfig = rows[selected].entry.ConfigLogin
|
||||
}
|
||||
|
||||
func (m Model) orderedRows() []streamerRow {
|
||||
rows := make([]streamerRow, 0, len(m.streamers))
|
||||
for index, entry := range m.streamers {
|
||||
if isActive(entry) {
|
||||
rows = append(rows, streamerRow{index: index, entry: entry})
|
||||
}
|
||||
}
|
||||
for index, entry := range m.streamers {
|
||||
if isActive(entry) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, streamerRow{index: index, entry: entry})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m Model) selectedRowIndex(rows []streamerRow) int {
|
||||
for index, row := range rows {
|
||||
if row.entry.ConfigLogin == m.selectedConfig {
|
||||
return index
|
||||
}
|
||||
}
|
||||
if len(rows) == 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 = "> "
|
||||
}
|
||||
|
||||
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) rightPaneLines(rows []streamerRow, selected int) []string {
|
||||
lines := []string{"IRC Chat"}
|
||||
if selected < 0 || selected >= len(rows) {
|
||||
return append(lines, "")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func isActive(entry twitch.StreamerEntry) bool {
|
||||
return entry.Status == twitch.StreamerReady && entry.Live
|
||||
}
|
||||
|
||||
func streamerName(entry twitch.StreamerEntry) string {
|
||||
if entry.Login != "" {
|
||||
return entry.Login
|
||||
}
|
||||
return entry.ConfigLogin
|
||||
}
|
||||
|
||||
func normalizeKey(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func renderColumns(left, right []string, totalWidth int) []string {
|
||||
if totalWidth <= 0 {
|
||||
totalWidth = defaultViewWidth
|
||||
}
|
||||
|
||||
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 {
|
||||
return func() tea.Msg {
|
||||
|
||||
+150
-16
@@ -1,26 +1,38 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
|
||||
func TestViewDisplaysStreamers(t *testing.T) {
|
||||
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", Status: twitch.StreamerLoading},
|
||||
{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
|
||||
|
||||
got := model.View()
|
||||
want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live \x1b[32m●\x1b[0m (1)\n2. beta [loading]\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
if !strings.Contains(got, "Watching: alpha_live, beta_live\n\n") {
|
||||
t.Fatalf("View() missing watching summary:\n%s", got)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +80,11 @@ func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
|
||||
|
||||
next := updated.(Model)
|
||||
got := next.View()
|
||||
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)
|
||||
if !strings.Contains(got, "Watching: no live streamers") {
|
||||
t.Fatalf("View() = %q", got)
|
||||
}
|
||||
if next.selectedConfig != "alpha" {
|
||||
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,14 +135,12 @@ func TestStreamerUpdateAppliesEntry(t *testing.T) {
|
||||
}
|
||||
|
||||
next := updated.(Model)
|
||||
got := next.View()
|
||||
want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live \x1b[32m●\x1b[0m (1)\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
if next.selectedConfig != "alpha" {
|
||||
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewDisplaysOfflineReadyStreamerWithoutMarker(t *testing.T) {
|
||||
func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) {
|
||||
model := New(Options{
|
||||
Streamers: []twitch.StreamerEntry{
|
||||
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady},
|
||||
@@ -136,10 +148,132 @@ func TestViewDisplaysOfflineReadyStreamerWithoutMarker(t *testing.T) {
|
||||
})
|
||||
model.mode = streamerView
|
||||
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
||||
model.width = 80
|
||||
|
||||
got := model.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)
|
||||
if !strings.Contains(got, "inactive") {
|
||||
t.Fatalf("View() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
|
||||
model := New(Options{
|
||||
Streamers: []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}
|
||||
want := []string{"beta", "gamma", "alpha", "delta"}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("row order = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
|
||||
model := New(Options{
|
||||
Streamers: []twitch.StreamerEntry{
|
||||
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
|
||||
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
||||
},
|
||||
AuthState: &auth.State{Login: "viewer"},
|
||||
})
|
||||
model.mode = streamerView
|
||||
model.selectedConfig = "beta"
|
||||
|
||||
updated, _ := model.Update(StreamerUpdate{
|
||||
Index: 1,
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
next := updated.(Model)
|
||||
if next.selectedConfig != "beta" {
|
||||
t.Fatalf("selectedConfig after down = %q, want beta", next.selectedConfig)
|
||||
}
|
||||
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||||
next = updated.(Model)
|
||||
if next.selectedConfig != "alpha" {
|
||||
t.Fatalf("selectedConfig after up = %q, want alpha", next.selectedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
Login: "alpha_live",
|
||||
State: IRCJoined,
|
||||
},
|
||||
})
|
||||
next := updated.(Model)
|
||||
|
||||
detail := next.ircDetails["alpha_live"]
|
||||
if !detail.joined {
|
||||
t.Fatal("expected joined detail")
|
||||
}
|
||||
if !strings.Contains(next.View(), greenDot) {
|
||||
t.Fatalf("View() missing green status dot:\n%s", next.View())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user