refactor: simplify codebase

This commit is contained in:
2026-07-17 22:26:53 +00:00
parent 0ae1b094c5
commit 20209a9b6d
26 changed files with 291 additions and 904 deletions
+8 -4
View File
@@ -1,6 +1,5 @@
// main.go is the executable entrypoint for the rewritten CLI.
// It owns process-level concerns such as signal handling, exit codes,
// and delegating the actual application startup to internal/app.
// It owns process-level concerns such as signal handling and exit codes.
package main
import (
@@ -11,7 +10,8 @@ import (
"os/signal"
"syscall"
"parasocial/internal/app"
"parasocial/internal/config"
"parasocial/internal/tui"
)
// main sets up process cancellation and reports fatal startup errors to stderr.
@@ -19,7 +19,11 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := app.Run(ctx); err != nil {
cfg, err := config.Load("config.toml")
if err == nil {
err = tui.Run(ctx, tui.Options{Streamers: cfg.Streamers})
}
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
)
require (
@@ -19,7 +20,6 @@ require (
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/gorilla/websocket v1.5.3 // 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
-18
View File
@@ -1,18 +0,0 @@
// app.go owns process-level application bootstrap before handing control to the TUI.
package app
import (
"context"
"parasocial/internal/config"
"parasocial/internal/tui"
)
// Run loads configuration and starts the terminal UI.
func Run(ctx context.Context) error {
cfg, err := config.LoadDefault()
if err != nil {
return err
}
return tui.Run(ctx, tui.Options{Streamers: cfg.Streamers})
}
+29 -107
View File
@@ -13,6 +13,7 @@ import (
"math/big"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
@@ -27,7 +28,7 @@ const (
tvOrigin = "https://android.tv.twitch.tv"
tvReferer = "https://android.tv.twitch.tv/"
tvUserAgent = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
TVUserAgent = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
)
var RequiredScopes = []string{
@@ -71,20 +72,15 @@ type deviceCodeResponse struct {
// tokenResponse models the token payload returned after device authorization succeeds.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
}
// validationResponse models the token introspection data returned by Twitch validate.
type validationResponse struct {
ClientID string `json:"client_id"`
Login string `json:"login"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
ExpiresIn int `json:"expires_in"`
ClientID string `json:"client_id"`
Login string `json:"login"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
}
// oauthErrorResponse captures structured OAuth errors returned by Twitch endpoints.
@@ -147,7 +143,7 @@ func (c *Client) ReuseAuth(ctx context.Context, path string) (*State, error) {
return nil, nil
}
state.applyValidation(validation, c.now())
state.Login = validation.Login
if err := SaveState(path, state); err != nil {
return nil, fmt.Errorf("save validated auth state: %w", err)
}
@@ -175,7 +171,7 @@ func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc)
c.status(status, "Validating cached token from %s", path)
validation, err := c.ValidateToken(ctx, state.AccessToken)
if err == nil && hasAllScopes(validation.Scopes, RequiredScopes) {
state.applyValidation(validation, c.now())
state.Login = validation.Login
if err := SaveState(path, state); err != nil {
return nil, fmt.Errorf("save validated auth state: %w", err)
}
@@ -203,14 +199,7 @@ func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc)
}
state.AccessToken = tokenResp.AccessToken
state.RefreshToken = tokenResp.RefreshToken
state.TokenType = tokenResp.TokenType
if state.TokenType == "" {
state.TokenType = "bearer"
}
state.Scopes = tokenResp.Scope
state.ExpiresIn = tokenResp.ExpiresIn
state.applyValidation(validation, c.now())
state.Login = validation.Login
if err := SaveState(path, state); err != nil {
return nil, fmt.Errorf("save auth state: %w", err)
@@ -222,13 +211,13 @@ func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc)
// ValidateToken asks Twitch whether an OAuth access token is still valid.
func (c *Client) ValidateToken(ctx context.Context, accessToken string) (*validationResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoints().Validate, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoints.Validate, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "OAuth "+accessToken)
resp, err := c.httpClient().Do(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
@@ -269,7 +258,7 @@ func (c *Client) activate(ctx context.Context, deviceID string, status StatusFun
if interval <= 0 {
interval = 5 * time.Second
}
deadline := c.now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second)
deadline := c.Now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second)
c.status(status, "== Twitch activation ==")
c.status(status, "Open page: %s", deviceResp.VerificationURI)
@@ -286,7 +275,7 @@ func (c *Client) requestDeviceCode(ctx context.Context, deviceID string) (*devic
form.Set("client_id", ClientID)
form.Set("scopes", strings.Join(RequiredScopes, " "))
body, statusCode, err := c.postForm(ctx, c.endpoints().Device, deviceID, form)
body, statusCode, err := c.postForm(ctx, c.Endpoints.Device, deviceID, form)
if err != nil {
return nil, err
}
@@ -311,13 +300,13 @@ func (c *Client) requestDeviceCode(ctx context.Context, deviceID string) (*devic
func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string, interval time.Duration, deadline time.Time, status StatusFunc) (*tokenResponse, error) {
attempt := 1
for {
if !c.now().Before(deadline) {
if !c.Now().Before(deadline) {
return nil, fmt.Errorf("device code expired at %s before authorization completed", deadline.Format(time.RFC3339))
}
remaining := deadline.Sub(c.now()).Round(time.Second)
remaining := deadline.Sub(c.Now()).Round(time.Second)
c.status(status, "[poll %d] waiting %s before token request (%s remaining)", attempt, interval, remaining)
if err := c.sleep(ctx, interval); err != nil {
if err := c.Sleep(ctx, interval); err != nil {
return nil, err
}
@@ -326,7 +315,7 @@ func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string,
form.Set("device_code", deviceCode)
form.Set("grant_type", pollGrantType)
body, statusCode, err := c.postForm(ctx, c.endpoints().Token, deviceID, form)
body, statusCode, err := c.postForm(ctx, c.Endpoints.Token, deviceID, form)
if err != nil {
return nil, fmt.Errorf("poll request failed on attempt %d: %w", attempt, err)
}
@@ -368,12 +357,18 @@ func (c *Client) postForm(ctx context.Context, endpoint, deviceID string, form u
if err != nil {
return nil, 0, err
}
for key, value := range defaultOAuthHeaders(deviceID) {
req.Header.Set(key, value)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Client-Id", ClientID)
req.Header.Set("Origin", tvOrigin)
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Referer", tvReferer)
req.Header.Set("User-Agent", TVUserAgent)
req.Header.Set("X-Device-Id", deviceID)
req.Header.Set("Content-Type", contentTypeForm)
resp, err := c.httpClient().Do(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, 0, err
}
@@ -431,51 +426,10 @@ func (r oauthErrorResponse) summary() string {
return strings.Join(parts, ", ")
}
// defaultOAuthHeaders builds the Twitch TV request headers used for auth requests.
func defaultOAuthHeaders(deviceID string) map[string]string {
return map[string]string{
"Accept": "application/json",
"Accept-Language": "en-US",
"Cache-Control": "no-cache",
"Client-Id": ClientID,
"Origin": tvOrigin,
"Pragma": "no-cache",
"Referer": tvReferer,
"User-Agent": TVUserAgent(),
"X-Device-Id": deviceID,
}
}
// TVUserAgent returns the Twitch TV user agent used to impersonate the device client.
func TVUserAgent() string {
return tvUserAgent
}
// applyValidation copies validated account metadata back onto the persisted auth state.
func (s *State) applyValidation(validation *validationResponse, validatedAt time.Time) {
s.Login = validation.Login
s.UserID = validation.UserID
s.ClientID = validation.ClientID
s.Scopes = validation.Scopes
s.ExpiresIn = validation.ExpiresIn
s.ValidatedAt = validatedAt
s.Cookies = s.persistedCookies()
}
// hasScope reports whether the token scopes include one exact scope string.
func hasScope(scopes []string, scope string) bool {
for _, candidate := range scopes {
if candidate == scope {
return true
}
}
return false
}
// hasAllScopes reports whether the token satisfies the full required scope set.
func hasAllScopes(scopes []string, required []string) bool {
for _, scope := range required {
if !hasScope(scopes, scope) {
if !slices.Contains(scopes, scope) {
return false
}
}
@@ -511,38 +465,6 @@ func sleepContext(ctx context.Context, d time.Duration) error {
}
}
// httpClient returns the configured HTTP client or falls back to the default client.
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
// endpoints returns the configured endpoint set or the package defaults.
func (c *Client) endpoints() Endpoints {
if c.Endpoints == (Endpoints{}) {
return DefaultEndpoints
}
return c.Endpoints
}
// now returns the current time using the injected clock when one is configured.
func (c *Client) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}
// sleep delegates waiting to the injected hook when tests need deterministic timing.
func (c *Client) sleep(ctx context.Context, d time.Duration) error {
if c.Sleep != nil {
return c.Sleep(ctx, d)
}
return sleepContext(ctx, d)
}
// status formats and emits one auth progress line when a status sink is attached.
func (c *Client) status(status StatusFunc, format string, args ...any) {
if status == nil {
+17 -78
View File
@@ -9,24 +9,18 @@ import (
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
func TestSaveStatePersistsCookieEntries(t *testing.T) {
func TestSaveAndLoadState(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cookies.json")
state := &State{
AccessToken: "token",
TokenType: "bearer",
Scopes: RequiredScopes,
Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 3600,
DeviceID: "device",
}
if err := SaveState(path, state); err != nil {
@@ -37,44 +31,8 @@ func TestSaveStatePersistsCookieEntries(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if loaded.AccessToken != "token" {
t.Fatalf("AccessToken = %q", loaded.AccessToken)
}
gotNames := []string{}
for _, cookie := range loaded.Cookies {
gotNames = append(gotNames, cookie.Name)
}
wantNames := []string{"auth-token", "login", "persistent"}
if !slices.Equal(gotNames, wantNames) {
t.Fatalf("cookie names = %#v, want %#v", gotNames, wantNames)
}
}
func TestSaveStateAllowsZeroExpiresIn(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cookies.json")
state := &State{
AccessToken: "token",
TokenType: "bearer",
Scopes: RequiredScopes,
Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 0,
DeviceID: "device",
}
if err := SaveState(path, state); err != nil {
t.Fatalf("SaveState() error = %v", err)
}
loaded, err := LoadState(path)
if err != nil {
t.Fatal(err)
}
if loaded.ExpiresIn != 0 {
t.Fatalf("ExpiresIn = %d, want 0", loaded.ExpiresIn)
if *loaded != *state {
t.Fatalf("state = %#v, want %#v", loaded, state)
}
}
@@ -102,7 +60,7 @@ func TestLoadStateRejectsPartialAuthBundle(t *testing.T) {
if err == nil {
t.Fatal("LoadState() error = nil, want error")
}
if !strings.Contains(err.Error(), "missing token_type") {
if !strings.Contains(err.Error(), "missing login") {
t.Fatalf("error = %q", err)
}
}
@@ -120,11 +78,10 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
t.Fatalf("Authorization = %q", got)
}
writeJSON(t, w, validationResponse{
ClientID: ClientID,
Login: "viewer",
UserID: "123",
Scopes: RequiredScopes,
ExpiresIn: 3600,
ClientID: ClientID,
Login: "viewer",
UserID: "123",
Scopes: RequiredScopes,
})
}))
defer server.Close()
@@ -132,12 +89,7 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "cookies.json")
if err := SaveState(tokenFile, &State{
AccessToken: "cached-token",
TokenType: "bearer",
Scopes: RequiredScopes,
Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 3600,
DeviceID: "device",
}); err != nil {
t.Fatal(err)
@@ -145,8 +97,6 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
client := NewClient(server.Client())
client.Endpoints = Endpoints{Validate: server.URL + "/validate"}
client.Now = func() time.Time { return time.Unix(100, 0).UTC() }
state, err := client.ReuseAuth(context.Background(), tokenFile)
if err != nil {
t.Fatal(err)
@@ -169,11 +119,10 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
validateCalls++
if validateCalls == 1 {
writeJSON(t, w, validationResponse{
ClientID: ClientID,
Login: "viewer",
UserID: "123",
Scopes: []string{"user:read:email"},
ExpiresIn: 100,
ClientID: ClientID,
Login: "viewer",
UserID: "123",
Scopes: []string{"user:read:email"},
})
return
}
@@ -181,11 +130,10 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
t.Fatalf("Authorization = %q", got)
}
writeJSON(t, w, validationResponse{
ClientID: ClientID,
Login: "viewer",
UserID: "123",
Scopes: RequiredScopes,
ExpiresIn: 3600,
ClientID: ClientID,
Login: "viewer",
UserID: "123",
Scopes: RequiredScopes,
})
case "/device":
if err := r.ParseForm(); err != nil {
@@ -214,11 +162,7 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
t.Fatalf("device_code = %q", got)
}
writeJSON(t, w, tokenResponse{
AccessToken: "new-token",
RefreshToken: "refresh-token",
Scope: RequiredScopes,
TokenType: "bearer",
ExpiresIn: 3600,
AccessToken: "new-token",
})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
@@ -229,12 +173,7 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "cookies.json")
if err := SaveState(tokenFile, &State{
AccessToken: "wrong-scope-token",
TokenType: "bearer",
Scopes: []string{"user:read:email"},
Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 3600,
DeviceID: "device",
}); err != nil {
t.Fatal(err)
+4 -39
View File
@@ -9,30 +9,15 @@ import (
"fmt"
"os"
"path/filepath"
"time"
)
const authFileName = "cookies.json"
// Cookie stores one persisted cookie-like name/value entry in the auth bundle.
type Cookie struct {
Name string `json:"name"`
Value string `json:"value"`
}
// State is the persisted cwd auth bundle for the app.
type State struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
Scopes []string `json:"scopes"`
Login string `json:"login"`
UserID string `json:"user_id"`
ClientID string `json:"client_id"`
ExpiresIn int `json:"expires_in"`
ValidatedAt time.Time `json:"validated_at"`
DeviceID string `json:"device_id"`
Cookies []Cookie `json:"cookies,omitempty"`
AccessToken string `json:"access_token"`
Login string `json:"login"`
DeviceID string `json:"device_id"`
}
// DefaultPath returns the cwd auth bundle path.
@@ -72,10 +57,7 @@ func SaveState(path string, state *State) error {
return err
}
copy := *state
copy.Cookies = copy.persistedCookies()
data, err := json.MarshalIndent(copy, "", " ")
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
@@ -83,30 +65,13 @@ func SaveState(path string, state *State) error {
return os.WriteFile(path, data, 0o600)
}
// persistedCookies rebuilds the cookie-like entries that must travel with the saved auth bundle.
func (s *State) persistedCookies() []Cookie {
return []Cookie{
{Name: "auth-token", Value: s.AccessToken},
{Name: "login", Value: s.Login},
{Name: "persistent", Value: s.UserID},
}
}
// validate rejects partial auth bundles so loading and saving use one canonical shape.
func (s *State) validate() error {
switch {
case s.AccessToken == "":
return fmt.Errorf("missing access_token")
case s.TokenType == "":
return fmt.Errorf("missing token_type")
case s.Login == "":
return fmt.Errorf("missing login")
case s.UserID == "":
return fmt.Errorf("missing user_id")
case s.ClientID == "":
return fmt.Errorf("missing client_id")
case len(s.Scopes) == 0:
return fmt.Errorf("missing scopes")
case s.DeviceID == "":
return fmt.Errorf("missing device_id")
default:
-12
View File
@@ -12,23 +12,11 @@ import (
"github.com/BurntSushi/toml"
)
const configFileName = "config.toml"
// Config is the user-owned TOML configuration for parasocial.
type Config struct {
Streamers []string `toml:"streamers"`
}
// DefaultPath returns the config file path relative to the current working directory.
func DefaultPath() string {
return configFileName
}
// LoadDefault reads config from the current working directory.
func LoadDefault() (Config, error) {
return Load(DefaultPath())
}
// Load reads, normalizes, and validates a TOML config file.
func Load(path string) (Config, error) {
var cfg Config
-6
View File
@@ -46,12 +46,6 @@ func TestLoadRejectsInvalidTOML(t *testing.T) {
}
}
func TestDefaultPathUsesCurrentWorkingDirectoryConfig(t *testing.T) {
if got := DefaultPath(); got != "config.toml" {
t.Fatalf("DefaultPath() = %q, want %q", got, "config.toml")
}
}
func TestLoadRejectsMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.toml")
-15
View File
@@ -3,8 +3,6 @@
// and streamer login resolution without exposing raw payload assembly to callers.
package gql
import "strings"
// persistedQuery stores the persisted-query metadata Twitch expects.
type persistedQuery struct {
Version int `json:"version"`
@@ -120,16 +118,3 @@ func VideoPlayerStreamInfoOverlayChannel(login string) Request {
"channel": login,
})
}
// HLSMasterPlaylistURL builds the usher URL Twitch uses for live playback.
func HLSMasterPlaylistURL(login, signature, token string) string {
var builder strings.Builder
builder.Grow(len(login) + len(signature) + len(token) + 80)
builder.WriteString("https://usher.ttvnw.net/api/channel/hls/")
builder.WriteString(login)
builder.WriteString(".m3u8?player=twitchweb&allow_source=true&type=any&p=1&sig=")
builder.WriteString(signature)
builder.WriteString("&token=")
builder.WriteString(token)
return builder.String()
}
+17 -51
View File
@@ -6,7 +6,6 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
@@ -41,14 +40,12 @@ type Client struct {
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 {
func (c *Client) Run(ctx context.Context) (err error) {
if c.Login == "" {
return errors.New("missing Twitch login")
}
@@ -58,23 +55,23 @@ func (c *Client) Run(ctx context.Context) error {
if c.Streamer == "" {
return errors.New("missing streamer")
}
defer func() {
if ctx.Err() != nil {
err = nil
}
}()
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,
}
session := &session{conn: conn}
defer session.close()
done := make(chan struct{})
@@ -82,24 +79,21 @@ func (c *Client) Run(ctx context.Context) error {
go func() {
select {
case <-ctx.Done():
_ = session.send("QUIT :parasocial shutting down", false)
_ = session.send("QUIT :parasocial shutting down")
session.close()
case <-done:
}
}()
if err := session.send("PASS oauth:"+c.Token, true); err != nil {
if err := session.send("PASS oauth:" + c.Token); err != nil {
return err
}
c.status("Sent PASS\n")
if err := session.send("NICK "+c.Login, false); err != nil {
if err := session.send("NICK " + c.Login); err != nil {
return err
}
c.status("Sent NICK %s\n", c.Login)
if err := session.send("JOIN #"+c.Streamer, false); err != nil {
if err := session.send("JOIN #" + c.Streamer); err != nil {
return err
}
c.status("Sent JOIN #%s\n", c.Streamer)
reader := bufio.NewReader(conn)
joined := false
@@ -116,13 +110,10 @@ func (c *Client) Run(ctx context.Context) error {
}
line = strings.TrimRight(line, "\r\n")
session.debugIn(line)
if payload, ok := pingPayload(line); ok {
if err := session.send("PONG "+payload, false); err != nil {
if err := session.send("PONG " + payload); err != nil {
return err
}
c.status("Responded to PING\n")
continue
}
@@ -133,16 +124,14 @@ func (c *Client) Run(ctx context.Context) error {
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 {
if err := session.send("QUIT :parasocial --once complete"); err != nil {
return err
}
return nil
@@ -150,41 +139,25 @@ func (c *Client) Run(ctx context.Context) error {
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
conn net.Conn
mu sync.Mutex
once sync.Once
}
func (s *session) send(line string, redact bool) error {
func (s *session) send(line string) 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())
@@ -200,13 +173,6 @@ func (c *Client) dial(ctx context.Context, addr string) (net.Conn, error) {
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
+8 -5
View File
@@ -64,7 +64,6 @@ func TestRunOnceSendsAuthJoinAndRespondsToPing(t *testing.T) {
Token: "token",
Streamer: "streamer",
Once: true,
Out: io.Discard,
}
if err := client.Run(context.Background()); err != nil {
t.Fatal(err)
@@ -122,7 +121,7 @@ func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) {
for {
line, err := reader.ReadString('\n')
if err != nil {
serverErr <- err
serverErr <- nil
return
}
line = strings.TrimRight(line, "\r\n")
@@ -133,7 +132,7 @@ func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) {
_, _ = 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":
case "QUIT :parasocial shutting down":
serverErr <- nil
return
}
@@ -151,7 +150,6 @@ func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) {
Events: func(event Event) {
events <- event
},
Out: io.Discard,
}
done := make(chan error, 1)
@@ -203,6 +201,12 @@ func TestAuthFailureReturnsError(t *testing.T) {
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
for range 3 {
if _, err := reader.ReadString('\n'); err != nil {
return
}
}
_, _ = conn.Write([]byte(":tmi.twitch.tv NOTICE * :Login authentication failed\r\n"))
}()
@@ -211,7 +215,6 @@ func TestAuthFailureReturnsError(t *testing.T) {
Login: "viewer",
Token: "bad-token",
Streamer: "streamer",
Out: io.Discard,
}
err = client.Run(context.Background())
if err == nil {
+3 -22
View File
@@ -2,22 +2,13 @@ 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
@@ -32,7 +23,7 @@ type managedClient struct {
}
// Sync applies the desired IRC target set using the provided viewer credentials.
func (m *Manager) Sync(ctx context.Context, viewerLogin, accessToken string, targets []Target) {
func (m *Manager) Sync(ctx context.Context, viewerLogin, accessToken string, targets []string) {
orderedTargets, desired := normalizeTargets(targets)
m.mu.Lock()
@@ -82,12 +73,9 @@ func (m *Manager) runClient(ctx context.Context, viewerLogin, accessToken, strea
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,
})
@@ -111,13 +99,6 @@ func (m *Manager) Close() {
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
@@ -125,11 +106,11 @@ func (m *Manager) emit(event Event) {
m.Events(event)
}
func normalizeTargets(targets []Target) ([]string, map[string]struct{}) {
func normalizeTargets(targets []string) ([]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))
login := strings.ToLower(strings.TrimSpace(target))
if login == "" {
continue
}
+8 -12
View File
@@ -25,11 +25,7 @@ func TestManagerSyncStartsAtMostTwoTargets(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{
{Login: "alpha"},
{Login: "beta"},
{Login: "gamma"},
})
manager.Sync(ctx, "viewer", "token", []string{"alpha", "beta", "gamma"})
assertStartSet(t, started, "alpha", "beta")
assertNoStart(t, started)
@@ -50,10 +46,10 @@ func TestManagerSyncKeepsExistingTargetsConnected(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertStartSet(t, started, "alpha")
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertNoStart(t, started)
}
@@ -74,10 +70,10 @@ func TestManagerSyncCancelsRemovedTargets(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}, {Login: "beta"}})
manager.Sync(ctx, "viewer", "token", []string{"alpha", "beta"})
assertStartSet(t, started, "alpha", "beta")
manager.Sync(ctx, "viewer", "token", []Target{{Login: "beta"}, {Login: "gamma"}})
manager.Sync(ctx, "viewer", "token", []string{"beta", "gamma"})
assertStartSet(t, started, "gamma")
assertCancels(t, canceled, "alpha")
assertNoStart(t, started)
@@ -109,7 +105,7 @@ func TestManagerSyncRestartsTargetAfterFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertStartSet(t, started, "alpha")
waitFor(t, func() bool {
@@ -119,7 +115,7 @@ func TestManagerSyncRestartsTargetAfterFailure(t *testing.T) {
return !ok
})
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertStartSet(t, started, "alpha")
}
@@ -138,7 +134,7 @@ func TestManagerEmitsPendingAndDisconnectedEvents(t *testing.T) {
}
ctx, cancel := context.WithCancel(context.Background())
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}})
manager.Sync(ctx, "viewer", "token", []string{"alpha"})
cancel()
manager.Close()
+14 -37
View File
@@ -52,12 +52,11 @@ const (
// StatusEntry is the current miner state for one resolved streamer login.
type StatusEntry struct {
Login string
Watching bool
Reason WatchReason
WatchedMinutes int
WatchStreakMinutes int
WatchStreak *int
Login string
Watching bool
Reason WatchReason
WatchedMinutes int
WatchStreak *int
}
// Manager owns background channel points mining state for the current authenticated user.
@@ -89,7 +88,6 @@ type streamerState struct {
ChannelPoints int
SpadeURL string
Metadata *twitch.StreamMetadata
LastWatchAt time.Time
WatchStreak *int
WatchStreakMissing bool
@@ -98,10 +96,7 @@ type streamerState struct {
CurrentWatchReason WatchReason
OnlineAt time.Time
OfflineAt time.Time
PendingStreamUpAt time.Time
CurrentBroadcastID string
seeded bool
seeding bool
refreshing bool
}
@@ -146,13 +141,9 @@ func (m *Manager) Sync(ctx context.Context, state *auth.State, viewer *twitch.Vi
return
}
type seedTarget struct {
configLogin string
}
var (
channelIDs []string
seedList []seedTarget
seedList []string
statuses []StatusEntry
)
@@ -176,7 +167,7 @@ func (m *Manager) Sync(ctx context.Context, state *auth.State, viewer *twitch.Vi
Login: entry.Login,
ChannelID: entry.ChannelID,
}
seedList = append(seedList, seedTarget{configLogin: entry.ConfigLogin})
seedList = append(seedList, entry.ConfigLogin)
}
current.ConfigLogin = entry.ConfigLogin
@@ -203,8 +194,8 @@ func (m *Manager) Sync(ctx context.Context, state *auth.State, viewer *twitch.Vi
m.emitStatuses(statuses)
_ = m.pubsub.Sync(ctx, viewer.ID, state.AccessToken, channelIDs)
for _, target := range seedList {
m.scheduleSeed(target.configLogin)
for _, configLogin := range seedList {
m.scheduleSeed(configLogin)
}
}
@@ -256,7 +247,6 @@ func (m *Manager) scheduleSeed(configLogin string) {
m.mu.Lock()
if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID {
current.ChannelPoints = channelPoints.Balance
current.seeded = true
}
m.mu.Unlock()
m.logf(login, "seeded channel points balance: %d", channelPoints.Balance)
@@ -312,7 +302,6 @@ func (m *Manager) scheduleRefresh(configLogin string) {
if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID {
current.SpadeURL = spadeURL
current.Metadata = metadata
current.CurrentBroadcastID = metadata.BroadcastID
current.Live = true
}
m.mu.Unlock()
@@ -378,7 +367,6 @@ func (m *Manager) watchOnce(ctx context.Context) error {
m.mu.Lock()
statuses := []StatusEntry{}
if state, ok := m.entries[candidate.configLogin]; ok && state.ChannelID == candidate.channelID {
state.LastWatchAt = m.now()
state.Watched += time.Minute
if candidate.reason == WatchReasonStreak && state.WatchStreakMissing {
state.WatchStreakWatched += time.Minute
@@ -475,7 +463,6 @@ func (m *Manager) updateWatchReasonsLocked(candidates []watchCandidate) []Status
func (m *Manager) markOnlineConfirmedLocked(state *streamerState, now time.Time, watchStreak *int) {
state.Live = true
state.PendingStreamUpAt = time.Time{}
if watchStreak != nil {
state.WatchStreak = cloneInt(watchStreak)
}
@@ -487,7 +474,6 @@ func (m *Manager) markOnlineConfirmedLocked(state *streamerState, now time.Time,
state.WatchStreakWatched = 0
state.SpadeURL = ""
state.Metadata = nil
state.CurrentBroadcastID = ""
}
func (m *Manager) markOfflineLocked(state *streamerState, now time.Time) {
@@ -497,8 +483,6 @@ func (m *Manager) markOfflineLocked(state *streamerState, now time.Time) {
state.Metadata = nil
state.Watched = 0
state.CurrentWatchReason = ""
state.PendingStreamUpAt = time.Time{}
state.CurrentBroadcastID = ""
}
func (m *Manager) emitStatusForConfig(configLogin string) {
@@ -534,12 +518,11 @@ func (m *Manager) emitStatuses(statuses []StatusEntry) {
func statusFromState(state streamerState) StatusEntry {
return StatusEntry{
Login: state.Login,
Watching: state.Live && state.CurrentWatchReason != "",
Reason: state.CurrentWatchReason,
WatchedMinutes: int(state.Watched / time.Minute),
WatchStreakMinutes: int(state.WatchStreakWatched / time.Minute),
WatchStreak: cloneInt(state.WatchStreak),
Login: state.Login,
Watching: state.Live && state.CurrentWatchReason != "",
Reason: state.CurrentWatchReason,
WatchedMinutes: int(state.Watched / time.Minute),
WatchStreak: cloneInt(state.WatchStreak),
}
}
@@ -583,12 +566,6 @@ func (m *Manager) handlePubSubEvent(event Event) {
m.logf(state.Login, "claim failed: %v", err)
}
}
case "stream-up":
m.mu.Lock()
if current, ok := m.entries[configLogin]; ok {
current.PendingStreamUpAt = m.now()
}
m.mu.Unlock()
case "stream-down":
var statuses []StatusEntry
m.mu.Lock()
+29 -31
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
@@ -12,6 +13,7 @@ import (
)
type fakeService struct {
mu sync.Mutex
channelPoints map[string]*twitch.ChannelPointsContext
metadata map[string]*twitch.StreamMetadata
spadeURLs map[string]string
@@ -20,9 +22,8 @@ type fakeService struct {
minuteErr map[string]error
claimErr map[string]error
claimed []string
watched []string
refreshed []string
claimed []string
watched []string
}
func (f *fakeService) LoadChannelPointsContext(_ context.Context, login string) (*twitch.ChannelPointsContext, error) {
@@ -43,6 +44,8 @@ func (f *fakeService) WatchStreak(_ context.Context, login string) (*int, error)
}
func (f *fakeService) ClaimCommunityPoints(_ context.Context, channelID, claimID string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.claimed = append(f.claimed, channelID+":"+claimID)
if err, ok := f.claimErr[channelID+":"+claimID]; ok {
return err
@@ -51,7 +54,6 @@ func (f *fakeService) ClaimCommunityPoints(_ context.Context, channelID, claimID
}
func (f *fakeService) StreamMetadata(_ context.Context, login string) (*twitch.StreamMetadata, error) {
f.refreshed = append(f.refreshed, login)
if result, ok := f.metadata[login]; ok {
return result, nil
}
@@ -73,11 +75,15 @@ func (f *fakeService) PlaybackAccessToken(_ context.Context, login string) (*twi
}
func (f *fakeService) TouchPlayback(_ context.Context, login string, _ *twitch.PlaybackToken) error {
f.mu.Lock()
defer f.mu.Unlock()
f.watched = append(f.watched, "touch:"+login)
return nil
}
func (f *fakeService) SendMinuteWatched(_ context.Context, spadeURL string, _ twitch.MinuteWatchedPayload) error {
f.mu.Lock()
defer f.mu.Unlock()
f.watched = append(f.watched, "post:"+spadeURL)
if err, ok := f.minuteErr[spadeURL]; ok {
return err
@@ -85,6 +91,18 @@ func (f *fakeService) SendMinuteWatched(_ context.Context, spadeURL string, _ tw
return nil
}
func (f *fakeService) claimedCalls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.claimed...)
}
func (f *fakeService) watchedCalls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.watched...)
}
type fakePubSub struct {
syncCalls [][]string
}
@@ -120,7 +138,7 @@ func TestManagerSyncSeedsAndClaims(t *testing.T) {
waitFor(t, func() bool {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(service.claimed) == 1 && manager.entries["alpha"].ChannelPoints == 250 && manager.entries["alpha"].SpadeURL != ""
return len(service.claimedCalls()) == 1 && manager.entries["alpha"].ChannelPoints == 250 && manager.entries["alpha"].SpadeURL != ""
})
if got, want := pubsub.syncCalls, [][]string{{"1"}}; !reflect.DeepEqual(got, want) {
@@ -146,7 +164,7 @@ func TestManagerWatchOnceUsesTopTwoLiveStreamers(t *testing.T) {
}
manager := NewManager(context.Background(), service, &fakePubSub{}, nil)
defer manager.Close()
manager.sleep = cancelSleep
manager.watchStarted = true
manager.Sync(context.Background(), &auth.State{AccessToken: "token"}, &twitch.Viewer{ID: "viewer"}, []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady},
@@ -163,7 +181,7 @@ func TestManagerWatchOnceUsesTopTwoLiveStreamers(t *testing.T) {
if err := manager.watchOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got, want := service.watched, []string{
if got, want := service.watchedCalls(), []string{
"touch:alpha_live",
"post:https://spade.test/alpha",
"touch:beta_live",
@@ -189,7 +207,7 @@ func TestManagerWatchOncePrioritizesMissingWatchStreaks(t *testing.T) {
t.Fatal(err)
}
if got, want := service.watched, []string{
if got, want := service.watchedCalls(), []string{
"touch:beta_live",
"post:https://spade.test/beta_live",
"touch:gamma_live",
@@ -215,7 +233,7 @@ func TestManagerWatchOnceFillsWithPointsCandidates(t *testing.T) {
t.Fatal(err)
}
if got, want := service.watched, []string{
if got, want := service.watchedCalls(), []string{
"touch:beta_live",
"post:https://spade.test/beta_live",
"touch:alpha_live",
@@ -335,7 +353,7 @@ func TestHandlePubSubWatchStreakCompletesMaintenance(t *testing.T) {
CurrentWatchReason: WatchReasonStreak,
}
manager.handlePubSubEvent(Event{MessageType: "points-earned", ChannelID: "1", Balance: 555, ReasonCode: "WATCH_STREAK", TotalPoints: 450, Timestamp: "t1", Topic: "community-points-user-v1"})
manager.handlePubSubEvent(Event{MessageType: "points-earned", ChannelID: "1", Balance: 555, ReasonCode: "WATCH_STREAK", Timestamp: "t1", Topic: "community-points-user-v1"})
waitFor(t, func() bool {
manager.mu.Lock()
@@ -434,26 +452,6 @@ func TestManagerSyncPollingConfirmedOnlineResetsStreakState(t *testing.T) {
}
}
func TestManagerStreamUpWaitsForConfirmation(t *testing.T) {
now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
manager := NewManager(context.Background(), &fakeService{}, &fakePubSub{}, nil)
defer manager.Close()
manager.now = func() time.Time { return now }
manager.entries["alpha"] = &streamerState{ConfigLogin: "alpha", ChannelID: "1", Login: "alpha_live"}
manager.handlePubSubEvent(Event{MessageType: "stream-up", ChannelID: "1", Timestamp: "up", Topic: "video-playback-by-id"})
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.entries["alpha"]
if state.Live {
t.Fatal("stream-up should wait for API/viewcount confirmation")
}
if !state.PendingStreamUpAt.Equal(now) {
t.Fatalf("pending stream-up = %s, want %s", state.PendingStreamUpAt, now)
}
}
func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
service := &fakeService{}
manager := NewManager(context.Background(), service, &fakePubSub{}, nil)
@@ -468,7 +466,7 @@ func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
if manager.entries["alpha"].ChannelPoints != 555 {
t.Fatalf("channelPoints = %d", manager.entries["alpha"].ChannelPoints)
}
if got, want := service.claimed, []string{"1:claim-2"}; !reflect.DeepEqual(got, want) {
if got, want := service.claimedCalls(), []string{"1:claim-2"}; !reflect.DeepEqual(got, want) {
t.Fatalf("claimed = %#v, want %#v", got, want)
}
}
+15 -46
View File
@@ -6,7 +6,8 @@ import (
"errors"
"fmt"
"net/http"
"sort"
"slices"
"strings"
"sync"
"time"
@@ -29,7 +30,6 @@ type Event struct {
Balance int
ClaimID string
ReasonCode string
TotalPoints int
}
func (e Event) key() string {
@@ -45,7 +45,6 @@ type Client struct {
dialer *websocket.Dialer
mu sync.Mutex
conn *websocket.Conn
viewerID string
token string
topics []string
updateCh chan struct{}
@@ -81,12 +80,11 @@ func (c *Client) Sync(_ context.Context, viewerID, accessToken string, channelID
}
topics = append(topics, "video-playback-by-id."+channelID)
}
sort.Strings(topics)
slices.Sort(topics)
c.mu.Lock()
c.viewerID = viewerID
c.token = accessToken
changed := !equalStrings(c.topics, topics)
changed := !slices.Equal(c.topics, topics)
c.topics = topics
conn := c.conn
c.mu.Unlock()
@@ -258,15 +256,13 @@ func (c *Client) snapshot() ([]string, string) {
type frame struct {
Type string
Error string
Event Event
}
func parseFrame(message []byte) (*frame, error) {
var envelope struct {
Type string `json:"type"`
Error string `json:"error"`
Data *struct {
Type string `json:"type"`
Data *struct {
Topic string `json:"topic"`
Message string `json:"message"`
} `json:"data"`
@@ -275,7 +271,7 @@ func parseFrame(message []byte) (*frame, error) {
return nil, err
}
result := &frame{Type: envelope.Type, Error: envelope.Error}
result := &frame{Type: envelope.Type}
if envelope.Type != "MESSAGE" || envelope.Data == nil {
return result, nil
}
@@ -294,14 +290,18 @@ func parseFrame(message []byte) (*frame, error) {
ChannelID string `json:"channel_id"`
} `json:"balance"`
PointGain *struct {
ReasonCode string `json:"reason_code"`
TotalPoints int `json:"total_points"`
ReasonCode string `json:"reason_code"`
} `json:"point_gain"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(envelope.Data.Message), &payload); err != nil {
return nil, err
}
topic := envelope.Data.Topic
topicName, topicChannelID := topic, ""
if i := strings.LastIndexByte(topic, '.'); i >= 0 {
topicName, topicChannelID = topic[:i], topic[i+1:]
}
channelID := payload.Data.ChannelID
if payload.Data.Claim != nil && payload.Data.Claim.ChannelID != "" {
@@ -311,11 +311,11 @@ func parseFrame(message []byte) (*frame, error) {
channelID = payload.Data.Balance.ChannelID
}
if channelID == "" {
channelID = topicSuffix(envelope.Data.Topic)
channelID = topicChannelID
}
result.Event = Event{
Topic: topicPrefix(envelope.Data.Topic),
Topic: topicName,
MessageType: payload.Type,
ChannelID: channelID,
Timestamp: payload.Data.Timestamp,
@@ -328,37 +328,6 @@ func parseFrame(message []byte) (*frame, error) {
}
if payload.Data.PointGain != nil {
result.Event.ReasonCode = payload.Data.PointGain.ReasonCode
result.Event.TotalPoints = payload.Data.PointGain.TotalPoints
}
return result, nil
}
func topicPrefix(topic string) string {
for i := len(topic) - 1; i >= 0; i-- {
if topic[i] == '.' {
return topic[:i]
}
}
return topic
}
func topicSuffix(topic string) string {
for i := len(topic) - 1; i >= 0; i-- {
if topic[i] == '.' {
return topic[i+1:]
}
}
return ""
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
+1 -1
View File
@@ -21,7 +21,7 @@ func TestParseFramePointsEarnedPointGain(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if frame.Event.ReasonCode != "WATCH_STREAK" || frame.Event.TotalPoints != 450 {
if frame.Event.ReasonCode != "WATCH_STREAK" {
t.Fatalf("event = %#v", frame.Event)
}
}
+10 -39
View File
@@ -2,18 +2,11 @@ package tui
import (
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"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
@@ -24,36 +17,14 @@ type AuthUpdate struct {
// StreamerUpdate carries one streamer resolution update into the TUI.
type StreamerUpdate struct {
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *IRCUpdate
Miner *MinerUpdate
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
}
// MinerUpdate carries one miner log line into the TUI.
type MinerUpdate struct {
Login string
Line string
Status *MinerStatus
}
// MinerStatus carries the current active watch state for one streamer.
type MinerStatus struct {
Watching bool
Reason string
WatchedMinutes int
WatchStreakMinutes int
WatchStreak *int
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *irc.Event
MinerLog *miner.LogEntry
MinerStatus *miner.StatusEntry
Index int
Err error
Done bool
}
type authStartedMsg struct {
+23 -34
View File
@@ -8,6 +8,8 @@ import (
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch"
)
@@ -61,7 +63,7 @@ type Model struct {
focus panelFocus
ircDetails map[string]ircDetail
minerDetails map[string][]string
minerStatuses map[string]MinerStatus
minerStatuses map[string]miner.StatusEntry
authViewport viewport.Model
ircViewport viewport.Model
minerViewport viewport.Model
@@ -87,13 +89,13 @@ func New(options Options) Model {
mode: authView,
ircDetails: make(map[string]ircDetail),
minerDetails: make(map[string][]string),
minerStatuses: make(map[string]MinerStatus),
minerStatuses: make(map[string]miner.StatusEntry),
width: defaultViewWidth,
height: 24,
focus: focusStreamers,
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24, "")),
minerViewport: newMinerViewport(
authViewport: viewport.New(contentWidth(defaultViewWidth), authViewportHeight(24)),
ircViewport: viewport.New(detailViewportWidth(defaultViewWidth), detailViewportHeight(24, "")),
minerViewport: viewport.New(
detailViewportWidth(defaultViewWidth),
detailViewportHeight(24, ""),
),
@@ -206,7 +208,7 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
m.streamers = loadingEntries(m.streamers)
m.ircDetails = make(map[string]ircDetail)
m.minerDetails = make(map[string][]string)
m.minerStatuses = make(map[string]MinerStatus)
m.minerStatuses = make(map[string]miner.StatusEntry)
m.selectedConfig = ""
m.focus = focusStreamers
m.ensureSelection()
@@ -233,8 +235,17 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
if msg.IRC != nil {
m.applyIRCUpdate(*msg.IRC)
}
if msg.Miner != nil {
m.applyMinerUpdate(*msg.Miner)
if update := msg.MinerLog; update != nil {
if login := normalizeKey(update.Login); login != "" {
if line := strings.TrimSpace(update.Line); line != "" {
m.minerDetails[login] = appendCappedHistory(m.minerDetails[login], line)
}
}
}
if update := msg.MinerStatus; update != nil {
if login := normalizeKey(update.Login); login != "" {
m.minerStatuses[login] = *update
}
}
if msg.Err != nil {
m.resolveErr = msg.Err
@@ -251,17 +262,17 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) applyIRCUpdate(update IRCUpdate) {
login := normalizeKey(update.Login)
func (m *Model) applyIRCUpdate(update irc.Event) {
login := normalizeKey(update.Streamer)
if login == "" {
return
}
detail := m.ircDetails[login]
switch update.State {
case IRCJoined:
case irc.StateJoined:
detail.joined = true
case IRCPending, IRCDisconnected:
case irc.StatePending, irc.StateDisconnected:
detail.joined = false
}
if message, ok := formatIRCChatLine(update.Line); ok {
@@ -270,20 +281,6 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
m.ircDetails[login] = detail
}
func (m *Model) applyMinerUpdate(update MinerUpdate) {
login := normalizeKey(update.Login)
if login == "" {
return
}
if update.Status != nil {
m.minerStatuses[login] = *update.Status
}
line := strings.TrimSpace(update.Line)
if line != "" {
m.minerDetails[login] = appendCappedHistory(m.minerDetails[login], line)
}
}
func (m *Model) ensureSelection() {
entries := m.orderedStreamers()
if len(entries) == 0 {
@@ -482,14 +479,6 @@ func (m Model) visibleDetailTab() detailTab {
}
}
func (m Model) isStreamersFocused() bool {
return m.focus == focusStreamers
}
func (m Model) isRightPanelFocused() bool {
return m.focus == focusInfo || m.focus == focusIRC || m.focus == focusMiner
}
func isActive(entry twitch.StreamerEntry) bool {
return entry.Status == twitch.StreamerReady && entry.Live
}
+38 -42
View File
@@ -8,6 +8,8 @@ import (
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch"
)
@@ -59,7 +61,7 @@ func TestAuthUpdateAppendsLogLine(t *testing.T) {
func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
runtime := &fakeModelRuntime{}
state := &auth.State{Login: "viewer", UserID: "7"}
state := &auth.State{Login: "viewer"}
updated, cmd := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Update(AuthUpdate{State: state, Done: true})
if cmd == nil {
t.Fatal("expected streamer resolution command")
@@ -87,7 +89,7 @@ func TestInitStartsAuthOrResolution(t *testing.T) {
t.Fatal("expected auth runtime to start")
}
state := &auth.State{Login: "viewer", UserID: "7"}
state := &auth.State{Login: "viewer"}
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")
@@ -177,13 +179,11 @@ func TestInfoTabShowsWatchStreakMinerStatus(t *testing.T) {
Status: twitch.StreamerReady,
WatchStreak: &streak,
})
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Status: &MinerStatus{
Watching: true,
Reason: "watchstreak",
WatchedMinutes: 4,
},
updated, _ := model.Update(StreamerUpdate{MinerStatus: &miner.StatusEntry{
Login: "alpha_live",
Watching: true,
Reason: miner.WatchReasonStreak,
WatchedMinutes: 4,
}})
next := updated.(Model)
entry, ok := next.selectedEntry()
@@ -206,14 +206,12 @@ func TestInfoTabShowsPointsMinerStatus(t *testing.T) {
Status: twitch.StreamerReady,
WatchStreak: &streak,
})
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Status: &MinerStatus{
Watching: true,
Reason: "points",
WatchedMinutes: 6,
WatchStreak: &minerStreak,
},
updated, _ := model.Update(StreamerUpdate{MinerStatus: &miner.StatusEntry{
Login: "alpha_live",
Watching: true,
Reason: miner.WatchReasonPoints,
WatchedMinutes: 6,
WatchStreak: &minerStreak,
}})
next := updated.(Model)
entry, ok := next.selectedEntry()
@@ -233,12 +231,10 @@ func TestInfoTabOmitsMinerStatusWhenInactiveOrUnwatched(t *testing.T) {
Status: twitch.StreamerReady,
WatchStreak: &streak,
})
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Status: &MinerStatus{
Watching: true,
Reason: "points",
},
updated, _ := model.Update(StreamerUpdate{MinerStatus: &miner.StatusEntry{
Login: "alpha_live",
Watching: true,
Reason: miner.WatchReasonPoints,
}})
next := updated.(Model)
entry, ok := next.selectedEntry()
@@ -427,16 +423,16 @@ func TestIRCUpdatesShowJoinedStatusAndFormattedMessages(t *testing.T) {
Login: "alpha_live",
Live: true,
Status: twitch.StreamerReady,
}).Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}})
}).Update(StreamerUpdate{IRC: &irc.Event{Streamer: "alpha_live", State: irc.StateJoined}})
next := updated.(Model)
if !next.ircDetails["alpha_live"].joined {
t.Fatal("expected joined detail")
}
updated, _ = next.Update(StreamerUpdate{IRC: &IRCUpdate{
Login: "alpha_live",
Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there",
updated, _ = next.Update(StreamerUpdate{IRC: &irc.Event{
Streamer: "alpha_live",
Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there",
}})
next = updated.(Model)
next.focus = focusIRC
@@ -482,11 +478,11 @@ func TestIRCUpdatesKeepOnlyLast50ChatMessages(t *testing.T) {
Status: twitch.StreamerReady,
})
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}})
updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{Streamer: "alpha_live", State: irc.StateJoined}})
next := updated.(Model)
for i := 1; i <= maxIRCMessageHistory+5; i++ {
line := fmt.Sprintf(":user%d!user%d@user%d.tmi.twitch.tv PRIVMSG #alpha_live :message %d", i, i, i, i)
updated, _ = next.Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", Line: line}})
updated, _ = next.Update(StreamerUpdate{IRC: &irc.Event{Streamer: "alpha_live", Line: line}})
next = updated.(Model)
}
@@ -512,7 +508,7 @@ func TestMinerUpdatesRenderAndKeepOnlyLast50Messages(t *testing.T) {
next := model
for i := 1; i <= maxIRCMessageHistory+5; i++ {
updated, _ := next.Update(StreamerUpdate{Miner: &MinerUpdate{
updated, _ := next.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live",
Line: fmt.Sprintf("miner message %d", i),
}})
@@ -541,7 +537,7 @@ func TestMinerTabShowsHistoryForOfflineStreamer(t *testing.T) {
Status: twitch.StreamerReady,
})
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
updated, _ := model.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live",
Line: "pubsub stream down",
}})
@@ -560,9 +556,9 @@ func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) {
Status: twitch.StreamerReady,
})
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{
Login: "alpha_live",
Line: "Joined #alpha_live as viewer",
updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{
Streamer: "alpha_live",
Line: "Joined #alpha_live as viewer",
}})
next := updated.(Model)
if len(next.ircDetails["alpha_live"].messages) != 0 {
@@ -588,9 +584,9 @@ func TestIRCViewportAutoScrollsAtBottom(t *testing.T) {
t.Fatal("expected viewport to start at bottom")
}
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{
Login: "alpha_live",
Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest",
updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{
Streamer: "alpha_live",
Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest",
}})
next := updated.(Model)
if !next.ircViewport.AtBottom() {
@@ -613,9 +609,9 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) {
model.syncIRCViewport(true)
model.ircViewport.GotoTop()
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{
Login: "alpha_live",
Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest",
updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{
Streamer: "alpha_live",
Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest",
}})
next := updated.(Model)
if next.ircViewport.YOffset != 0 {
@@ -641,7 +637,7 @@ func TestMinerViewportAutoScrollsAtBottom(t *testing.T) {
t.Fatal("expected miner viewport to start at bottom")
}
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
updated, _ := model.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live",
Line: "newest miner event",
}})
@@ -663,7 +659,7 @@ func TestMinerViewportPreservesManualScrollPosition(t *testing.T) {
model.syncMinerViewport(true)
model.minerViewport.GotoTop()
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
updated, _ := model.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live",
Line: "newest miner event",
}})
+43 -148
View File
@@ -4,9 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
@@ -25,23 +23,16 @@ const streamerRefreshInterval = 5 * time.Minute
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
ctx context.Context
logins []string
httpClient *http.Client
authClient *auth.Client
}
// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package.
@@ -49,11 +40,17 @@ func Run(ctx context.Context, options Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rt := newRuntime(ctx, options)
httpClient := &http.Client{Timeout: 30 * time.Second}
rt := &runtime{
ctx: ctx,
logins: append([]string(nil), options.Streamers...),
httpClient: httpClient,
authClient: auth.NewClient(httpClient),
}
state := options.AuthState
if state == nil {
var err error
state, err = rt.reuseAuth()
state, err = rt.authClient.ReuseAuth(rt.ctx, auth.DefaultPath())
if err != nil {
return err
}
@@ -62,54 +59,19 @@ func Run(ctx context.Context, options Options) error {
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) {
state, err := r.authClient.EnsureAuth(r.ctx, auth.DefaultPath(), func(line string) {
ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")}
})
if err != nil {
@@ -128,20 +90,40 @@ func (r *runtime) startAuth(ch chan<- AuthUpdate) {
func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
go func() {
defer close(ch)
ircManager := newIRCManager(ch)
ircManager := &irc.Manager{Events: func(event irc.Event) {
ch <- StreamerUpdate{IRC: &event}
}}
defer ircManager.Close()
service, err := newTwitchService(r.httpClient, state)
if err != nil {
ch <- StreamerUpdate{Err: err, Done: true}
client := &gql.Client{
HTTPClient: r.httpClient,
Session: gql.Session{
AccessToken: state.AccessToken,
ClientID: auth.ClientID,
DeviceID: state.DeviceID,
UserAgent: auth.TVUserAgent,
},
}
if err := client.Validate(); err != nil {
ch <- StreamerUpdate{Err: fmt.Errorf("configure graphql session: %w", err), Done: true}
return
}
minerManager := newMinerManager(r.ctx, service, ch)
service := &twitch.Service{
GQL: client,
HTTPClient: r.httpClient,
Session: client.Session,
}
minerManager := miner.NewManager(r.ctx, service, nil, func(entry miner.LogEntry) {
ch <- StreamerUpdate{MinerLog: &entry}
})
minerManager.SetStatusSink(func(entry miner.StatusEntry) {
ch <- StreamerUpdate{MinerStatus: &entry}
})
defer minerManager.Close()
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, minerManager, func(update StreamerUpdate) {
ch <- update
}, r.refreshEvery, r.sleep); err != nil {
}, streamerRefreshInterval, sleepContext); err != nil {
if errors.Is(err, context.Canceled) {
return
}
@@ -158,95 +140,16 @@ type streamerService interface {
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
WatchStreak(context.Context, string) (*int, error)
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
LoadChannelPointsContext(context.Context, string) (*twitch.ChannelPointsContext, error)
ClaimCommunityPoints(context.Context, string, string) error
StreamMetadata(context.Context, string) (*twitch.StreamMetadata, error)
FetchSpadeURL(context.Context, string) (string, error)
TouchPlayback(context.Context, string, *twitch.PlaybackToken) error
SendMinuteWatched(context.Context, string, twitch.MinuteWatchedPayload) error
}
type ircSyncer interface {
Sync(context.Context, string, string, []irc.Target)
Sync(context.Context, string, string, []string)
}
type minerSyncer interface {
Sync(context.Context, *auth.State, *twitch.Viewer, []twitch.StreamerEntry)
}
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,
HTTPClient: httpClient,
Session: client.Session,
}, 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 newMinerManager(ctx context.Context, service miner.Service, ch chan<- StreamerUpdate) *miner.Manager {
manager := miner.NewManager(ctx, service, nil, newMinerLogSink(ch))
manager.SetStatusSink(newMinerStatusSink(ch))
return manager
}
func newMinerLogSink(ch chan<- StreamerUpdate) func(miner.LogEntry) {
return func(entry miner.LogEntry) {
ch <- StreamerUpdate{
Miner: &MinerUpdate{
Login: entry.Login,
Line: entry.Line,
},
}
}
}
func newMinerStatusSink(ch chan<- StreamerUpdate) func(miner.StatusEntry) {
return func(entry miner.StatusEntry) {
ch <- StreamerUpdate{
Miner: &MinerUpdate{
Login: entry.Login,
Status: &MinerStatus{
Watching: entry.Watching,
Reason: string(entry.Reason),
WatchedMinutes: entry.WatchedMinutes,
WatchStreakMinutes: entry.WatchStreakMinutes,
WatchStreak: cloneInt(entry.WatchStreak),
},
},
}
}
}
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate)) error {
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, ircSyncer, minerSyncer, send, streamerRefreshInterval, sleepContext)
}
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
viewer, err := service.CurrentUser(ctx)
if err != nil {
@@ -348,24 +251,16 @@ func syncRuntimeState(ctx context.Context, ircSyncer ircSyncer, minerSyncer mine
}
}
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
targets := make([]irc.Target, 0, 2)
func watchedIRCTargets(entries []twitch.StreamerEntry) []string {
targets := make([]string, 0, 2)
for _, entry := range entries {
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
continue
}
targets = append(targets, irc.Target{Login: entry.Login})
targets = append(targets, entry.Login)
if len(targets) == 2 {
break
}
}
return targets
}
func cloneInt(value *int) *int {
if value == nil {
return nil
}
copy := *value
return &copy
}
+2 -49
View File
@@ -8,8 +8,6 @@ import (
"time"
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch"
)
@@ -73,40 +71,12 @@ func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login strin
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil
}
func (f *fakeStreamerService) LoadChannelPointsContext(context.Context, string) (*twitch.ChannelPointsContext, error) {
return &twitch.ChannelPointsContext{Balance: 0}, nil
}
func (f *fakeStreamerService) ClaimCommunityPoints(context.Context, string, string) error {
return nil
}
func (f *fakeStreamerService) StreamMetadata(context.Context, string) (*twitch.StreamMetadata, error) {
return &twitch.StreamMetadata{BroadcastID: "broadcast"}, nil
}
func (f *fakeStreamerService) FetchSpadeURL(context.Context, string) (string, error) {
return "https://spade.test", nil
}
func (f *fakeStreamerService) TouchPlayback(context.Context, string, *twitch.PlaybackToken) error {
return nil
}
func (f *fakeStreamerService) SendMinuteWatched(context.Context, string, twitch.MinuteWatchedPayload) error {
return 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 (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []string) {
f.calls = append(f.calls, append([]string{}, targets...))
}
type fakeMinerSyncer struct {
@@ -249,23 +219,6 @@ func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) {
})
}
func TestNewMinerLogSinkBridgesMinerEntriesIntoStreamerUpdates(t *testing.T) {
ch := make(chan StreamerUpdate, 1)
newMinerLogSink(ch)(miner.LogEntry{
Login: "alpha_live",
Line: "pubsub points earned: balance=42",
})
update := <-ch
if update.Miner == nil {
t.Fatal("expected miner update")
}
if update.Miner.Login != "alpha_live" || update.Miner.Line != "pubsub points earned: balance=42" {
t.Fatalf("miner update = %#v", update.Miner)
}
}
type serviceOption func(*fakeStreamerService)
type watchStreakResult struct {
-19
View File
@@ -1,7 +1,6 @@
package tui
import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
@@ -55,21 +54,3 @@ var (
BorderForeground(accentColor).
Foreground(titleColor)
)
func newAuthViewport(width, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = lipgloss.NewStyle()
return vp
}
func newIRCViewport(width, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = lipgloss.NewStyle()
return vp
}
func newMinerViewport(width, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = lipgloss.NewStyle()
return vp
}
+7 -41
View File
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/lipgloss"
"parasocial/internal/miner"
"parasocial/internal/twitch"
)
@@ -39,10 +40,10 @@ func (m Model) renderStreamerView() string {
header := m.renderDashboardHeader()
leftStyle := panelStyle
rightStyle := panelStyle
if m.isStreamersFocused() {
if m.focus == focusStreamers {
leftStyle = focusedPanelStyle
}
if m.isRightPanelFocused() {
if m.focus != focusStreamers {
rightStyle = focusedPanelStyle
}
@@ -91,9 +92,9 @@ func (m Model) renderDetailPanel() string {
var body string
switch m.visibleDetailTab() {
case ircTab:
body = m.renderIRCTab()
body = m.ircViewport.View()
case minerTab:
body = m.renderMinerTab()
body = m.minerViewport.View()
default:
body = m.renderInfoTab(entry)
}
@@ -143,14 +144,6 @@ func (m Model) renderInfoTab(entry twitch.StreamerEntry) string {
return strings.Join(lines, "\n")
}
func (m Model) renderIRCTab() string {
return m.ircViewport.View()
}
func (m Model) renderMinerTab() string {
return m.minerViewport.View()
}
func (m Model) renderStreamerRows(maxRows int) string {
entries := m.visibleStreamers(maxRows)
if len(entries) == 0 {
@@ -248,9 +241,9 @@ func (m Model) minerWatchingLines(entry twitch.StreamerEntry) []string {
}
var reason string
switch status.Reason {
case string(minerWatchReasonStreak):
case miner.WatchReasonStreak:
reason = "Watching for watchstreak"
case string(minerWatchReasonPoints):
case miner.WatchReasonPoints:
reason = "Watching for points"
default:
return nil
@@ -261,33 +254,6 @@ func (m Model) minerWatchingLines(entry twitch.StreamerEntry) []string {
}
}
type minerWatchReason string
const (
minerWatchReasonStreak minerWatchReason = "watchstreak"
minerWatchReasonPoints minerWatchReason = "points"
)
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
+12 -42
View File
@@ -74,25 +74,14 @@ type ChannelPointsContext struct {
// Game describes the current Twitch category for a live stream.
type Game struct {
ID string
Name string
DisplayName string
ID string
Name string
}
// StreamMetadata describes the live stream state used for watch telemetry.
type StreamMetadata struct {
BroadcastID string
Title string
Game *Game
ViewerCount int
Tags []Tag
ChannelLogin string
}
// Tag is one stream tag entry returned by Twitch.
type Tag struct {
ID string
LocalizedName string
BroadcastID string
Game *Game
}
// MinuteWatchedPayload is the Spade telemetry body Twitch accepts for simulated viewing.
@@ -317,20 +306,13 @@ func (s *Service) StreamMetadata(ctx context.Context, login string) (*StreamMeta
var data struct {
User *struct {
BroadcastSettings *struct {
Title string `json:"title"`
Game *struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Game *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"game"`
} `json:"broadcastSettings"`
Stream *struct {
ID string `json:"id"`
ViewersCount int `json:"viewersCount"`
Tags []struct {
ID string `json:"id"`
LocalizedName string `json:"localizedName"`
} `json:"tags"`
ID string `json:"id"`
} `json:"stream"`
} `json:"user"`
}
@@ -344,25 +326,13 @@ func (s *Service) StreamMetadata(ctx context.Context, login string) (*StreamMeta
return nil, fmt.Errorf("stream metadata missing broadcast settings for %s", login)
}
metadata := &StreamMetadata{
BroadcastID: data.User.Stream.ID,
Title: strings.TrimSpace(data.User.BroadcastSettings.Title),
ViewerCount: data.User.Stream.ViewersCount,
ChannelLogin: login,
}
metadata := &StreamMetadata{BroadcastID: data.User.Stream.ID}
if game := data.User.BroadcastSettings.Game; game != nil {
metadata.Game = &Game{
ID: game.ID,
Name: game.Name,
DisplayName: game.DisplayName,
ID: game.ID,
Name: game.Name,
}
}
for _, tag := range data.User.Stream.Tags {
metadata.Tags = append(metadata.Tags, Tag{
ID: tag.ID,
LocalizedName: tag.LocalizedName,
})
}
if metadata.BroadcastID == "" {
return nil, fmt.Errorf("stream metadata missing broadcast id for %s", login)
}
@@ -423,7 +393,7 @@ func (s *Service) TouchPlayback(ctx context.Context, login string, token *Playba
if token == nil || token.Signature == "" || token.Value == "" {
return errors.New("playback access token is incomplete")
}
masterURL := gql.HLSMasterPlaylistURL(login, token.Signature, token.Value)
masterURL := "https://usher.ttvnw.net/api/channel/hls/" + login + ".m3u8?player=twitchweb&allow_source=true&type=any&p=1&sig=" + token.Signature + "&token=" + token.Value
variantURL, err := s.fetchPlaylistTarget(ctx, masterURL)
if err != nil {
return fmt.Errorf("fetch master playlist: %w", err)
+2 -5
View File
@@ -214,22 +214,19 @@ func TestStreamMetadata(t *testing.T) {
t.Parallel()
client := &fakeGQL{data: map[string]string{
"VideoPlayerStreamInfoOverlayChannel": `{"user":{"broadcastSettings":{"title":" Live title ","game":{"id":"99","name":"game-name","displayName":"Game Name"}},"stream":{"id":"broadcast","viewersCount":77,"tags":[{"id":"1","localizedName":"English"}]}}}`,
"VideoPlayerStreamInfoOverlayChannel": `{"user":{"broadcastSettings":{"game":{"id":"99","name":"game-name"}},"stream":{"id":"broadcast"}}}`,
}}
service := &Service{GQL: client}
metadata, err := service.StreamMetadata(context.Background(), "streamer")
if err != nil {
t.Fatal(err)
}
if metadata.BroadcastID != "broadcast" || metadata.Title != "Live title" {
if metadata.BroadcastID != "broadcast" {
t.Fatalf("metadata = %#v", metadata)
}
if metadata.Game == nil || metadata.Game.Name != "game-name" {
t.Fatalf("game = %#v", metadata.Game)
}
if len(metadata.Tags) != 1 || metadata.Tags[0].LocalizedName != "English" {
t.Fatalf("tags = %#v", metadata.Tags)
}
}
func TestFetchSpadeURL(t *testing.T) {