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. // main.go is the executable entrypoint for the rewritten CLI.
// It owns process-level concerns such as signal handling, exit codes, // It owns process-level concerns such as signal handling and exit codes.
// and delegating the actual application startup to internal/app.
package main package main
import ( import (
@@ -11,7 +10,8 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"parasocial/internal/app" "parasocial/internal/config"
"parasocial/internal/tui"
) )
// main sets up process cancellation and reports fatal startup errors to stderr. // 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) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() 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) { if errors.Is(err, context.Canceled) {
return return
} }
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
) )
require ( require (
@@ -19,7 +20,6 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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" "math/big"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"time" "time"
) )
@@ -27,7 +28,7 @@ const (
tvOrigin = "https://android.tv.twitch.tv" tvOrigin = "https://android.tv.twitch.tv"
tvReferer = "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{ var RequiredScopes = []string{
@@ -71,20 +72,15 @@ type deviceCodeResponse struct {
// tokenResponse models the token payload returned after device authorization succeeds. // tokenResponse models the token payload returned after device authorization succeeds.
type tokenResponse struct { type tokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
} }
// validationResponse models the token introspection data returned by Twitch validate. // validationResponse models the token introspection data returned by Twitch validate.
type validationResponse struct { type validationResponse struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
Login string `json:"login"` Login string `json:"login"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
ExpiresIn int `json:"expires_in"`
} }
// oauthErrorResponse captures structured OAuth errors returned by Twitch endpoints. // 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 return nil, nil
} }
state.applyValidation(validation, c.now()) state.Login = validation.Login
if err := SaveState(path, state); err != nil { if err := SaveState(path, state); err != nil {
return nil, fmt.Errorf("save validated auth state: %w", err) 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) c.status(status, "Validating cached token from %s", path)
validation, err := c.ValidateToken(ctx, state.AccessToken) validation, err := c.ValidateToken(ctx, state.AccessToken)
if err == nil && hasAllScopes(validation.Scopes, RequiredScopes) { if err == nil && hasAllScopes(validation.Scopes, RequiredScopes) {
state.applyValidation(validation, c.now()) state.Login = validation.Login
if err := SaveState(path, state); err != nil { if err := SaveState(path, state); err != nil {
return nil, fmt.Errorf("save validated auth state: %w", err) 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.AccessToken = tokenResp.AccessToken
state.RefreshToken = tokenResp.RefreshToken state.Login = validation.Login
state.TokenType = tokenResp.TokenType
if state.TokenType == "" {
state.TokenType = "bearer"
}
state.Scopes = tokenResp.Scope
state.ExpiresIn = tokenResp.ExpiresIn
state.applyValidation(validation, c.now())
if err := SaveState(path, state); err != nil { if err := SaveState(path, state); err != nil {
return nil, fmt.Errorf("save auth state: %w", err) 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. // ValidateToken asks Twitch whether an OAuth access token is still valid.
func (c *Client) ValidateToken(ctx context.Context, accessToken string) (*validationResponse, error) { 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 { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "OAuth "+accessToken) req.Header.Set("Authorization", "OAuth "+accessToken)
resp, err := c.httpClient().Do(req) resp, err := c.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -269,7 +258,7 @@ func (c *Client) activate(ctx context.Context, deviceID string, status StatusFun
if interval <= 0 { if interval <= 0 {
interval = 5 * time.Second 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, "== Twitch activation ==")
c.status(status, "Open page: %s", deviceResp.VerificationURI) 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("client_id", ClientID)
form.Set("scopes", strings.Join(RequiredScopes, " ")) 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 { if err != nil {
return nil, err 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) { func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string, interval time.Duration, deadline time.Time, status StatusFunc) (*tokenResponse, error) {
attempt := 1 attempt := 1
for { 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)) 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) 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 return nil, err
} }
@@ -326,7 +315,7 @@ func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string,
form.Set("device_code", deviceCode) form.Set("device_code", deviceCode)
form.Set("grant_type", pollGrantType) 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 { if err != nil {
return nil, fmt.Errorf("poll request failed on attempt %d: %w", attempt, err) 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
for key, value := range defaultOAuthHeaders(deviceID) { req.Header.Set("Accept", "application/json")
req.Header.Set(key, value) 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) req.Header.Set("Content-Type", contentTypeForm)
resp, err := c.httpClient().Do(req) resp, err := c.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -431,51 +426,10 @@ func (r oauthErrorResponse) summary() string {
return strings.Join(parts, ", ") 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. // hasAllScopes reports whether the token satisfies the full required scope set.
func hasAllScopes(scopes []string, required []string) bool { func hasAllScopes(scopes []string, required []string) bool {
for _, scope := range required { for _, scope := range required {
if !hasScope(scopes, scope) { if !slices.Contains(scopes, scope) {
return false 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. // status formats and emits one auth progress line when a status sink is attached.
func (c *Client) status(status StatusFunc, format string, args ...any) { func (c *Client) status(status StatusFunc, format string, args ...any) {
if status == nil { if status == nil {
+17 -78
View File
@@ -9,24 +9,18 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
func TestSaveStatePersistsCookieEntries(t *testing.T) { func TestSaveAndLoadState(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "cookies.json") path := filepath.Join(t.TempDir(), "cookies.json")
state := &State{ state := &State{
AccessToken: "token", AccessToken: "token",
TokenType: "bearer",
Scopes: RequiredScopes,
Login: "viewer", Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 3600,
DeviceID: "device", DeviceID: "device",
} }
if err := SaveState(path, state); err != nil { if err := SaveState(path, state); err != nil {
@@ -37,44 +31,8 @@ func TestSaveStatePersistsCookieEntries(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if loaded.AccessToken != "token" { if *loaded != *state {
t.Fatalf("AccessToken = %q", loaded.AccessToken) t.Fatalf("state = %#v, want %#v", loaded, state)
}
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)
} }
} }
@@ -102,7 +60,7 @@ func TestLoadStateRejectsPartialAuthBundle(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("LoadState() error = nil, want error") 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) t.Fatalf("error = %q", err)
} }
} }
@@ -120,11 +78,10 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
t.Fatalf("Authorization = %q", got) t.Fatalf("Authorization = %q", got)
} }
writeJSON(t, w, validationResponse{ writeJSON(t, w, validationResponse{
ClientID: ClientID, ClientID: ClientID,
Login: "viewer", Login: "viewer",
UserID: "123", UserID: "123",
Scopes: RequiredScopes, Scopes: RequiredScopes,
ExpiresIn: 3600,
}) })
})) }))
defer server.Close() defer server.Close()
@@ -132,12 +89,7 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "cookies.json") tokenFile := filepath.Join(t.TempDir(), "cookies.json")
if err := SaveState(tokenFile, &State{ if err := SaveState(tokenFile, &State{
AccessToken: "cached-token", AccessToken: "cached-token",
TokenType: "bearer",
Scopes: RequiredScopes,
Login: "viewer", Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 3600,
DeviceID: "device", DeviceID: "device",
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -145,8 +97,6 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
client := NewClient(server.Client()) client := NewClient(server.Client())
client.Endpoints = Endpoints{Validate: server.URL + "/validate"} 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) state, err := client.ReuseAuth(context.Background(), tokenFile)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -169,11 +119,10 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
validateCalls++ validateCalls++
if validateCalls == 1 { if validateCalls == 1 {
writeJSON(t, w, validationResponse{ writeJSON(t, w, validationResponse{
ClientID: ClientID, ClientID: ClientID,
Login: "viewer", Login: "viewer",
UserID: "123", UserID: "123",
Scopes: []string{"user:read:email"}, Scopes: []string{"user:read:email"},
ExpiresIn: 100,
}) })
return return
} }
@@ -181,11 +130,10 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
t.Fatalf("Authorization = %q", got) t.Fatalf("Authorization = %q", got)
} }
writeJSON(t, w, validationResponse{ writeJSON(t, w, validationResponse{
ClientID: ClientID, ClientID: ClientID,
Login: "viewer", Login: "viewer",
UserID: "123", UserID: "123",
Scopes: RequiredScopes, Scopes: RequiredScopes,
ExpiresIn: 3600,
}) })
case "/device": case "/device":
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -214,11 +162,7 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
t.Fatalf("device_code = %q", got) t.Fatalf("device_code = %q", got)
} }
writeJSON(t, w, tokenResponse{ writeJSON(t, w, tokenResponse{
AccessToken: "new-token", AccessToken: "new-token",
RefreshToken: "refresh-token",
Scope: RequiredScopes,
TokenType: "bearer",
ExpiresIn: 3600,
}) })
default: default:
t.Fatalf("unexpected path %s", r.URL.Path) t.Fatalf("unexpected path %s", r.URL.Path)
@@ -229,12 +173,7 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "cookies.json") tokenFile := filepath.Join(t.TempDir(), "cookies.json")
if err := SaveState(tokenFile, &State{ if err := SaveState(tokenFile, &State{
AccessToken: "wrong-scope-token", AccessToken: "wrong-scope-token",
TokenType: "bearer",
Scopes: []string{"user:read:email"},
Login: "viewer", Login: "viewer",
UserID: "123",
ClientID: ClientID,
ExpiresIn: 3600,
DeviceID: "device", DeviceID: "device",
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
+4 -39
View File
@@ -9,30 +9,15 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time"
) )
const authFileName = "cookies.json" 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. // State is the persisted cwd auth bundle for the app.
type State struct { type State struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"` Login string `json:"login"`
TokenType string `json:"token_type"` DeviceID string `json:"device_id"`
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"`
} }
// DefaultPath returns the cwd auth bundle path. // DefaultPath returns the cwd auth bundle path.
@@ -72,10 +57,7 @@ func SaveState(path string, state *State) error {
return err return err
} }
copy := *state data, err := json.MarshalIndent(state, "", " ")
copy.Cookies = copy.persistedCookies()
data, err := json.MarshalIndent(copy, "", " ")
if err != nil { if err != nil {
return err return err
} }
@@ -83,30 +65,13 @@ func SaveState(path string, state *State) error {
return os.WriteFile(path, data, 0o600) 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. // validate rejects partial auth bundles so loading and saving use one canonical shape.
func (s *State) validate() error { func (s *State) validate() error {
switch { switch {
case s.AccessToken == "": case s.AccessToken == "":
return fmt.Errorf("missing access_token") return fmt.Errorf("missing access_token")
case s.TokenType == "":
return fmt.Errorf("missing token_type")
case s.Login == "": case s.Login == "":
return fmt.Errorf("missing 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 == "": case s.DeviceID == "":
return fmt.Errorf("missing device_id") return fmt.Errorf("missing device_id")
default: default:
-12
View File
@@ -12,23 +12,11 @@ import (
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
const configFileName = "config.toml"
// Config is the user-owned TOML configuration for parasocial. // Config is the user-owned TOML configuration for parasocial.
type Config struct { type Config struct {
Streamers []string `toml:"streamers"` 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. // Load reads, normalizes, and validates a TOML config file.
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
var cfg Config 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) { func TestLoadRejectsMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.toml") 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. // and streamer login resolution without exposing raw payload assembly to callers.
package gql package gql
import "strings"
// persistedQuery stores the persisted-query metadata Twitch expects. // persistedQuery stores the persisted-query metadata Twitch expects.
type persistedQuery struct { type persistedQuery struct {
Version int `json:"version"` Version int `json:"version"`
@@ -120,16 +118,3 @@ func VideoPlayerStreamInfoOverlayChannel(login string) Request {
"channel": login, "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" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"strings" "strings"
"sync" "sync"
@@ -41,14 +40,12 @@ type Client struct {
Token string Token string
Streamer string Streamer string
Once bool Once bool
Debug bool
Out io.Writer
Events EventSink Events EventSink
DialContext func(context.Context, string, string) (net.Conn, error) DialContext func(context.Context, string, string) (net.Conn, error)
} }
// Run connects, authenticates, joins the configured streamer channel, and stays connected. // 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 == "" { if c.Login == "" {
return errors.New("missing Twitch login") return errors.New("missing Twitch login")
} }
@@ -58,23 +55,23 @@ func (c *Client) Run(ctx context.Context) error {
if c.Streamer == "" { if c.Streamer == "" {
return errors.New("missing streamer") return errors.New("missing streamer")
} }
defer func() {
if ctx.Err() != nil {
err = nil
}
}()
addr := c.Addr addr := c.Addr
if addr == "" { if addr == "" {
addr = DefaultAddr addr = DefaultAddr
} }
c.status("Connecting to %s\n", addr)
conn, err := c.dial(ctx, addr) conn, err := c.dial(ctx, addr)
if err != nil { if err != nil {
return fmt.Errorf("connect to Twitch IRC: %w", err) return fmt.Errorf("connect to Twitch IRC: %w", err)
} }
session := &session{ session := &session{conn: conn}
conn: conn,
out: c.Out,
debug: c.Debug,
}
defer session.close() defer session.close()
done := make(chan struct{}) done := make(chan struct{})
@@ -82,24 +79,21 @@ func (c *Client) Run(ctx context.Context) error {
go func() { go func() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
_ = session.send("QUIT :parasocial shutting down", false) _ = session.send("QUIT :parasocial shutting down")
session.close() session.close()
case <-done: 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 return err
} }
c.status("Sent PASS\n") if err := session.send("NICK " + c.Login); err != nil {
if err := session.send("NICK "+c.Login, false); err != nil {
return err return err
} }
c.status("Sent NICK %s\n", c.Login) if err := session.send("JOIN #" + c.Streamer); err != nil {
if err := session.send("JOIN #"+c.Streamer, false); err != nil {
return err return err
} }
c.status("Sent JOIN #%s\n", c.Streamer)
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
joined := false joined := false
@@ -116,13 +110,10 @@ func (c *Client) Run(ctx context.Context) error {
} }
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
session.debugIn(line)
if payload, ok := pingPayload(line); ok { 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 return err
} }
c.status("Responded to PING\n")
continue continue
} }
@@ -133,16 +124,14 @@ func (c *Client) Run(ctx context.Context) error {
return fmt.Errorf("IRC join denied: %s", line) return fmt.Errorf("IRC join denied: %s", line)
} }
if isWelcome(line) { if isWelcome(line) {
c.status("IRC authentication accepted\n")
continue continue
} }
if isJoinConfirmation(line, c.Login, c.Streamer) { if isJoinConfirmation(line, c.Login, c.Streamer) {
joinedLine := fmt.Sprintf("Joined #%s as %s", c.Streamer, c.Login) joinedLine := fmt.Sprintf("Joined #%s as %s", c.Streamer, c.Login)
c.status("%s\n", joinedLine)
c.emit(StateJoined, joinedLine) c.emit(StateJoined, joinedLine)
joined = true joined = true
if c.Once { 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 err
} }
return nil return nil
@@ -150,41 +139,25 @@ func (c *Client) Run(ctx context.Context) error {
continue continue
} }
if joined { if joined {
c.status("%s\n", line)
c.emit("", line) c.emit("", line)
} }
} }
} }
type session struct { type session struct {
conn net.Conn conn net.Conn
out io.Writer mu sync.Mutex
debug bool once sync.Once
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() s.mu.Lock()
defer s.mu.Unlock() 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) _, err := fmt.Fprintf(s.conn, "%s\r\n", line)
return err 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() { func (s *session) close() {
s.once.Do(func() { s.once.Do(func() {
_ = s.conn.SetDeadline(time.Now()) _ = 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) 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) { func (c *Client) emit(state ConnectionState, line string) {
if c.Events == nil { if c.Events == nil {
return return
+8 -5
View File
@@ -64,7 +64,6 @@ func TestRunOnceSendsAuthJoinAndRespondsToPing(t *testing.T) {
Token: "token", Token: "token",
Streamer: "streamer", Streamer: "streamer",
Once: true, Once: true,
Out: io.Discard,
} }
if err := client.Run(context.Background()); err != nil { if err := client.Run(context.Background()); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -122,7 +121,7 @@ func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) {
for { for {
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
serverErr <- err serverErr <- nil
return return
} }
line = strings.TrimRight(line, "\r\n") 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(":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(":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello\r\n"))
_, _ = conn.Write([]byte("PING :tmi.twitch.tv\r\n")) _, _ = conn.Write([]byte("PING :tmi.twitch.tv\r\n"))
case "PONG :tmi.twitch.tv": case "QUIT :parasocial shutting down":
serverErr <- nil serverErr <- nil
return return
} }
@@ -151,7 +150,6 @@ func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) {
Events: func(event Event) { Events: func(event Event) {
events <- event events <- event
}, },
Out: io.Discard,
} }
done := make(chan error, 1) done := make(chan error, 1)
@@ -203,6 +201,12 @@ func TestAuthFailureReturnsError(t *testing.T) {
return return
} }
defer conn.Close() 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")) _, _ = conn.Write([]byte(":tmi.twitch.tv NOTICE * :Login authentication failed\r\n"))
}() }()
@@ -211,7 +215,6 @@ func TestAuthFailureReturnsError(t *testing.T) {
Login: "viewer", Login: "viewer",
Token: "bad-token", Token: "bad-token",
Streamer: "streamer", Streamer: "streamer",
Out: io.Discard,
} }
err = client.Run(context.Background()) err = client.Run(context.Background())
if err == nil { if err == nil {
+3 -22
View File
@@ -2,22 +2,13 @@ package irc
import ( import (
"context" "context"
"io"
"net" "net"
"strings" "strings"
"sync" "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. // Manager reconciles the active IRC connections against the desired watched set.
type Manager struct { type Manager struct {
Addr string
Debug bool
Out io.Writer
Events EventSink Events EventSink
DialContext func(context.Context, string, string) (net.Conn, error) DialContext func(context.Context, string, string) (net.Conn, error)
RunClient func(context.Context, *Client) 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. // 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) orderedTargets, desired := normalizeTargets(targets)
m.mu.Lock() m.mu.Lock()
@@ -82,12 +73,9 @@ func (m *Manager) runClient(ctx context.Context, viewerLogin, accessToken, strea
m.emit(Event{Streamer: streamer, State: StatePending}) m.emit(Event{Streamer: streamer, State: StatePending})
_ = run(ctx, &Client{ _ = run(ctx, &Client{
Addr: m.addr(),
Login: viewerLogin, Login: viewerLogin,
Token: accessToken, Token: accessToken,
Streamer: streamer, Streamer: streamer,
Debug: m.Debug,
Out: m.Out,
Events: m.Events, Events: m.Events,
DialContext: m.DialContext, DialContext: m.DialContext,
}) })
@@ -111,13 +99,6 @@ func (m *Manager) Close() {
m.wg.Wait() m.wg.Wait()
} }
func (m *Manager) addr() string {
if m.Addr != "" {
return m.Addr
}
return DefaultAddr
}
func (m *Manager) emit(event Event) { func (m *Manager) emit(event Event) {
if m.Events == nil { if m.Events == nil {
return return
@@ -125,11 +106,11 @@ func (m *Manager) emit(event Event) {
m.Events(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) ordered := make([]string, 0, 2)
normalized := make(map[string]struct{}, len(targets)) normalized := make(map[string]struct{}, len(targets))
for _, target := range targets { for _, target := range targets {
login := strings.ToLower(strings.TrimSpace(target.Login)) login := strings.ToLower(strings.TrimSpace(target))
if login == "" { if login == "" {
continue continue
} }
+8 -12
View File
@@ -25,11 +25,7 @@ func TestManagerSyncStartsAtMostTwoTargets(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{ manager.Sync(ctx, "viewer", "token", []string{"alpha", "beta", "gamma"})
{Login: "alpha"},
{Login: "beta"},
{Login: "gamma"},
})
assertStartSet(t, started, "alpha", "beta") assertStartSet(t, started, "alpha", "beta")
assertNoStart(t, started) assertNoStart(t, started)
@@ -50,10 +46,10 @@ func TestManagerSyncKeepsExistingTargetsConnected(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertStartSet(t, started, "alpha") assertStartSet(t, started, "alpha")
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertNoStart(t, started) assertNoStart(t, started)
} }
@@ -74,10 +70,10 @@ func TestManagerSyncCancelsRemovedTargets(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() 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") 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") assertStartSet(t, started, "gamma")
assertCancels(t, canceled, "alpha") assertCancels(t, canceled, "alpha")
assertNoStart(t, started) assertNoStart(t, started)
@@ -109,7 +105,7 @@ func TestManagerSyncRestartsTargetAfterFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertStartSet(t, started, "alpha") assertStartSet(t, started, "alpha")
waitFor(t, func() bool { waitFor(t, func() bool {
@@ -119,7 +115,7 @@ func TestManagerSyncRestartsTargetAfterFailure(t *testing.T) {
return !ok return !ok
}) })
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) manager.Sync(ctx, "viewer", "token", []string{"alpha"})
assertStartSet(t, started, "alpha") assertStartSet(t, started, "alpha")
} }
@@ -138,7 +134,7 @@ func TestManagerEmitsPendingAndDisconnectedEvents(t *testing.T) {
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) manager.Sync(ctx, "viewer", "token", []string{"alpha"})
cancel() cancel()
manager.Close() manager.Close()
+14 -37
View File
@@ -52,12 +52,11 @@ const (
// StatusEntry is the current miner state for one resolved streamer login. // StatusEntry is the current miner state for one resolved streamer login.
type StatusEntry struct { type StatusEntry struct {
Login string Login string
Watching bool Watching bool
Reason WatchReason Reason WatchReason
WatchedMinutes int WatchedMinutes int
WatchStreakMinutes int WatchStreak *int
WatchStreak *int
} }
// Manager owns background channel points mining state for the current authenticated user. // Manager owns background channel points mining state for the current authenticated user.
@@ -89,7 +88,6 @@ type streamerState struct {
ChannelPoints int ChannelPoints int
SpadeURL string SpadeURL string
Metadata *twitch.StreamMetadata Metadata *twitch.StreamMetadata
LastWatchAt time.Time
WatchStreak *int WatchStreak *int
WatchStreakMissing bool WatchStreakMissing bool
@@ -98,10 +96,7 @@ type streamerState struct {
CurrentWatchReason WatchReason CurrentWatchReason WatchReason
OnlineAt time.Time OnlineAt time.Time
OfflineAt time.Time OfflineAt time.Time
PendingStreamUpAt time.Time
CurrentBroadcastID string
seeded bool
seeding bool seeding bool
refreshing bool refreshing bool
} }
@@ -146,13 +141,9 @@ func (m *Manager) Sync(ctx context.Context, state *auth.State, viewer *twitch.Vi
return return
} }
type seedTarget struct {
configLogin string
}
var ( var (
channelIDs []string channelIDs []string
seedList []seedTarget seedList []string
statuses []StatusEntry statuses []StatusEntry
) )
@@ -176,7 +167,7 @@ func (m *Manager) Sync(ctx context.Context, state *auth.State, viewer *twitch.Vi
Login: entry.Login, Login: entry.Login,
ChannelID: entry.ChannelID, ChannelID: entry.ChannelID,
} }
seedList = append(seedList, seedTarget{configLogin: entry.ConfigLogin}) seedList = append(seedList, entry.ConfigLogin)
} }
current.ConfigLogin = 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.emitStatuses(statuses)
_ = m.pubsub.Sync(ctx, viewer.ID, state.AccessToken, channelIDs) _ = m.pubsub.Sync(ctx, viewer.ID, state.AccessToken, channelIDs)
for _, target := range seedList { for _, configLogin := range seedList {
m.scheduleSeed(target.configLogin) m.scheduleSeed(configLogin)
} }
} }
@@ -256,7 +247,6 @@ func (m *Manager) scheduleSeed(configLogin string) {
m.mu.Lock() m.mu.Lock()
if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID { if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID {
current.ChannelPoints = channelPoints.Balance current.ChannelPoints = channelPoints.Balance
current.seeded = true
} }
m.mu.Unlock() m.mu.Unlock()
m.logf(login, "seeded channel points balance: %d", channelPoints.Balance) 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 { if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID {
current.SpadeURL = spadeURL current.SpadeURL = spadeURL
current.Metadata = metadata current.Metadata = metadata
current.CurrentBroadcastID = metadata.BroadcastID
current.Live = true current.Live = true
} }
m.mu.Unlock() m.mu.Unlock()
@@ -378,7 +367,6 @@ func (m *Manager) watchOnce(ctx context.Context) error {
m.mu.Lock() m.mu.Lock()
statuses := []StatusEntry{} statuses := []StatusEntry{}
if state, ok := m.entries[candidate.configLogin]; ok && state.ChannelID == candidate.channelID { if state, ok := m.entries[candidate.configLogin]; ok && state.ChannelID == candidate.channelID {
state.LastWatchAt = m.now()
state.Watched += time.Minute state.Watched += time.Minute
if candidate.reason == WatchReasonStreak && state.WatchStreakMissing { if candidate.reason == WatchReasonStreak && state.WatchStreakMissing {
state.WatchStreakWatched += time.Minute 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) { func (m *Manager) markOnlineConfirmedLocked(state *streamerState, now time.Time, watchStreak *int) {
state.Live = true state.Live = true
state.PendingStreamUpAt = time.Time{}
if watchStreak != nil { if watchStreak != nil {
state.WatchStreak = cloneInt(watchStreak) state.WatchStreak = cloneInt(watchStreak)
} }
@@ -487,7 +474,6 @@ func (m *Manager) markOnlineConfirmedLocked(state *streamerState, now time.Time,
state.WatchStreakWatched = 0 state.WatchStreakWatched = 0
state.SpadeURL = "" state.SpadeURL = ""
state.Metadata = nil state.Metadata = nil
state.CurrentBroadcastID = ""
} }
func (m *Manager) markOfflineLocked(state *streamerState, now time.Time) { 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.Metadata = nil
state.Watched = 0 state.Watched = 0
state.CurrentWatchReason = "" state.CurrentWatchReason = ""
state.PendingStreamUpAt = time.Time{}
state.CurrentBroadcastID = ""
} }
func (m *Manager) emitStatusForConfig(configLogin string) { func (m *Manager) emitStatusForConfig(configLogin string) {
@@ -534,12 +518,11 @@ func (m *Manager) emitStatuses(statuses []StatusEntry) {
func statusFromState(state streamerState) StatusEntry { func statusFromState(state streamerState) StatusEntry {
return StatusEntry{ return StatusEntry{
Login: state.Login, Login: state.Login,
Watching: state.Live && state.CurrentWatchReason != "", Watching: state.Live && state.CurrentWatchReason != "",
Reason: state.CurrentWatchReason, Reason: state.CurrentWatchReason,
WatchedMinutes: int(state.Watched / time.Minute), WatchedMinutes: int(state.Watched / time.Minute),
WatchStreakMinutes: int(state.WatchStreakWatched / time.Minute), WatchStreak: cloneInt(state.WatchStreak),
WatchStreak: cloneInt(state.WatchStreak),
} }
} }
@@ -583,12 +566,6 @@ func (m *Manager) handlePubSubEvent(event Event) {
m.logf(state.Login, "claim failed: %v", err) 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": case "stream-down":
var statuses []StatusEntry var statuses []StatusEntry
m.mu.Lock() m.mu.Lock()
+29 -31
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"reflect" "reflect"
"sync"
"testing" "testing"
"time" "time"
@@ -12,6 +13,7 @@ import (
) )
type fakeService struct { type fakeService struct {
mu sync.Mutex
channelPoints map[string]*twitch.ChannelPointsContext channelPoints map[string]*twitch.ChannelPointsContext
metadata map[string]*twitch.StreamMetadata metadata map[string]*twitch.StreamMetadata
spadeURLs map[string]string spadeURLs map[string]string
@@ -20,9 +22,8 @@ type fakeService struct {
minuteErr map[string]error minuteErr map[string]error
claimErr map[string]error claimErr map[string]error
claimed []string claimed []string
watched []string watched []string
refreshed []string
} }
func (f *fakeService) LoadChannelPointsContext(_ context.Context, login string) (*twitch.ChannelPointsContext, error) { 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 { func (f *fakeService) ClaimCommunityPoints(_ context.Context, channelID, claimID string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.claimed = append(f.claimed, channelID+":"+claimID) f.claimed = append(f.claimed, channelID+":"+claimID)
if err, ok := f.claimErr[channelID+":"+claimID]; ok { if err, ok := f.claimErr[channelID+":"+claimID]; ok {
return err 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) { func (f *fakeService) StreamMetadata(_ context.Context, login string) (*twitch.StreamMetadata, error) {
f.refreshed = append(f.refreshed, login)
if result, ok := f.metadata[login]; ok { if result, ok := f.metadata[login]; ok {
return result, nil 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 { 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) f.watched = append(f.watched, "touch:"+login)
return nil return nil
} }
func (f *fakeService) SendMinuteWatched(_ context.Context, spadeURL string, _ twitch.MinuteWatchedPayload) error { 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) f.watched = append(f.watched, "post:"+spadeURL)
if err, ok := f.minuteErr[spadeURL]; ok { if err, ok := f.minuteErr[spadeURL]; ok {
return err return err
@@ -85,6 +91,18 @@ func (f *fakeService) SendMinuteWatched(_ context.Context, spadeURL string, _ tw
return nil 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 { type fakePubSub struct {
syncCalls [][]string syncCalls [][]string
} }
@@ -120,7 +138,7 @@ func TestManagerSyncSeedsAndClaims(t *testing.T) {
waitFor(t, func() bool { waitFor(t, func() bool {
manager.mu.Lock() manager.mu.Lock()
defer manager.mu.Unlock() 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) { 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) manager := NewManager(context.Background(), service, &fakePubSub{}, nil)
defer manager.Close() defer manager.Close()
manager.sleep = cancelSleep manager.watchStarted = true
manager.Sync(context.Background(), &auth.State{AccessToken: "token"}, &twitch.Viewer{ID: "viewer"}, []twitch.StreamerEntry{ 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}, {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 { if err := manager.watchOnce(context.Background()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, want := service.watched, []string{ if got, want := service.watchedCalls(), []string{
"touch:alpha_live", "touch:alpha_live",
"post:https://spade.test/alpha", "post:https://spade.test/alpha",
"touch:beta_live", "touch:beta_live",
@@ -189,7 +207,7 @@ func TestManagerWatchOncePrioritizesMissingWatchStreaks(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if got, want := service.watched, []string{ if got, want := service.watchedCalls(), []string{
"touch:beta_live", "touch:beta_live",
"post:https://spade.test/beta_live", "post:https://spade.test/beta_live",
"touch:gamma_live", "touch:gamma_live",
@@ -215,7 +233,7 @@ func TestManagerWatchOnceFillsWithPointsCandidates(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if got, want := service.watched, []string{ if got, want := service.watchedCalls(), []string{
"touch:beta_live", "touch:beta_live",
"post:https://spade.test/beta_live", "post:https://spade.test/beta_live",
"touch:alpha_live", "touch:alpha_live",
@@ -335,7 +353,7 @@ func TestHandlePubSubWatchStreakCompletesMaintenance(t *testing.T) {
CurrentWatchReason: WatchReasonStreak, 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 { waitFor(t, func() bool {
manager.mu.Lock() 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) { func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
service := &fakeService{} service := &fakeService{}
manager := NewManager(context.Background(), service, &fakePubSub{}, nil) manager := NewManager(context.Background(), service, &fakePubSub{}, nil)
@@ -468,7 +466,7 @@ func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
if manager.entries["alpha"].ChannelPoints != 555 { if manager.entries["alpha"].ChannelPoints != 555 {
t.Fatalf("channelPoints = %d", manager.entries["alpha"].ChannelPoints) 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) t.Fatalf("claimed = %#v, want %#v", got, want)
} }
} }
+15 -46
View File
@@ -6,7 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"sort" "slices"
"strings"
"sync" "sync"
"time" "time"
@@ -29,7 +30,6 @@ type Event struct {
Balance int Balance int
ClaimID string ClaimID string
ReasonCode string ReasonCode string
TotalPoints int
} }
func (e Event) key() string { func (e Event) key() string {
@@ -45,7 +45,6 @@ type Client struct {
dialer *websocket.Dialer dialer *websocket.Dialer
mu sync.Mutex mu sync.Mutex
conn *websocket.Conn conn *websocket.Conn
viewerID string
token string token string
topics []string topics []string
updateCh chan struct{} 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) topics = append(topics, "video-playback-by-id."+channelID)
} }
sort.Strings(topics) slices.Sort(topics)
c.mu.Lock() c.mu.Lock()
c.viewerID = viewerID
c.token = accessToken c.token = accessToken
changed := !equalStrings(c.topics, topics) changed := !slices.Equal(c.topics, topics)
c.topics = topics c.topics = topics
conn := c.conn conn := c.conn
c.mu.Unlock() c.mu.Unlock()
@@ -258,15 +256,13 @@ func (c *Client) snapshot() ([]string, string) {
type frame struct { type frame struct {
Type string Type string
Error string
Event Event Event Event
} }
func parseFrame(message []byte) (*frame, error) { func parseFrame(message []byte) (*frame, error) {
var envelope struct { var envelope struct {
Type string `json:"type"` Type string `json:"type"`
Error string `json:"error"` Data *struct {
Data *struct {
Topic string `json:"topic"` Topic string `json:"topic"`
Message string `json:"message"` Message string `json:"message"`
} `json:"data"` } `json:"data"`
@@ -275,7 +271,7 @@ func parseFrame(message []byte) (*frame, error) {
return nil, err return nil, err
} }
result := &frame{Type: envelope.Type, Error: envelope.Error} result := &frame{Type: envelope.Type}
if envelope.Type != "MESSAGE" || envelope.Data == nil { if envelope.Type != "MESSAGE" || envelope.Data == nil {
return result, nil return result, nil
} }
@@ -294,14 +290,18 @@ func parseFrame(message []byte) (*frame, error) {
ChannelID string `json:"channel_id"` ChannelID string `json:"channel_id"`
} `json:"balance"` } `json:"balance"`
PointGain *struct { PointGain *struct {
ReasonCode string `json:"reason_code"` ReasonCode string `json:"reason_code"`
TotalPoints int `json:"total_points"`
} `json:"point_gain"` } `json:"point_gain"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal([]byte(envelope.Data.Message), &payload); err != nil { if err := json.Unmarshal([]byte(envelope.Data.Message), &payload); err != nil {
return nil, err 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 channelID := payload.Data.ChannelID
if payload.Data.Claim != nil && payload.Data.Claim.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 channelID = payload.Data.Balance.ChannelID
} }
if channelID == "" { if channelID == "" {
channelID = topicSuffix(envelope.Data.Topic) channelID = topicChannelID
} }
result.Event = Event{ result.Event = Event{
Topic: topicPrefix(envelope.Data.Topic), Topic: topicName,
MessageType: payload.Type, MessageType: payload.Type,
ChannelID: channelID, ChannelID: channelID,
Timestamp: payload.Data.Timestamp, Timestamp: payload.Data.Timestamp,
@@ -328,37 +328,6 @@ func parseFrame(message []byte) (*frame, error) {
} }
if payload.Data.PointGain != nil { if payload.Data.PointGain != nil {
result.Event.ReasonCode = payload.Data.PointGain.ReasonCode result.Event.ReasonCode = payload.Data.PointGain.ReasonCode
result.Event.TotalPoints = payload.Data.PointGain.TotalPoints
} }
return result, nil 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 { if err != nil {
t.Fatal(err) 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) t.Fatalf("event = %#v", frame.Event)
} }
} }
+10 -39
View File
@@ -2,18 +2,11 @@ package tui
import ( import (
"parasocial/internal/auth" "parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch" "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. // AuthUpdate carries one incremental auth log line or completion result into the TUI.
type AuthUpdate struct { type AuthUpdate struct {
Line string Line string
@@ -24,36 +17,14 @@ type AuthUpdate struct {
// StreamerUpdate carries one streamer resolution update into the TUI. // StreamerUpdate carries one streamer resolution update into the TUI.
type StreamerUpdate struct { type StreamerUpdate struct {
Viewer *twitch.Viewer Viewer *twitch.Viewer
Entry *twitch.StreamerEntry Entry *twitch.StreamerEntry
IRC *IRCUpdate IRC *irc.Event
Miner *MinerUpdate MinerLog *miner.LogEntry
Index int MinerStatus *miner.StatusEntry
Err error Index int
Done bool 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
} }
type authStartedMsg struct { type authStartedMsg struct {
+23 -34
View File
@@ -8,6 +8,8 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth" "parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch" "parasocial/internal/twitch"
) )
@@ -61,7 +63,7 @@ type Model struct {
focus panelFocus focus panelFocus
ircDetails map[string]ircDetail ircDetails map[string]ircDetail
minerDetails map[string][]string minerDetails map[string][]string
minerStatuses map[string]MinerStatus minerStatuses map[string]miner.StatusEntry
authViewport viewport.Model authViewport viewport.Model
ircViewport viewport.Model ircViewport viewport.Model
minerViewport viewport.Model minerViewport viewport.Model
@@ -87,13 +89,13 @@ func New(options Options) Model {
mode: authView, mode: authView,
ircDetails: make(map[string]ircDetail), ircDetails: make(map[string]ircDetail),
minerDetails: make(map[string][]string), minerDetails: make(map[string][]string),
minerStatuses: make(map[string]MinerStatus), minerStatuses: make(map[string]miner.StatusEntry),
width: defaultViewWidth, width: defaultViewWidth,
height: 24, height: 24,
focus: focusStreamers, focus: focusStreamers,
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)), authViewport: viewport.New(contentWidth(defaultViewWidth), authViewportHeight(24)),
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24, "")), ircViewport: viewport.New(detailViewportWidth(defaultViewWidth), detailViewportHeight(24, "")),
minerViewport: newMinerViewport( minerViewport: viewport.New(
detailViewportWidth(defaultViewWidth), detailViewportWidth(defaultViewWidth),
detailViewportHeight(24, ""), detailViewportHeight(24, ""),
), ),
@@ -206,7 +208,7 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
m.streamers = loadingEntries(m.streamers) m.streamers = loadingEntries(m.streamers)
m.ircDetails = make(map[string]ircDetail) m.ircDetails = make(map[string]ircDetail)
m.minerDetails = make(map[string][]string) m.minerDetails = make(map[string][]string)
m.minerStatuses = make(map[string]MinerStatus) m.minerStatuses = make(map[string]miner.StatusEntry)
m.selectedConfig = "" m.selectedConfig = ""
m.focus = focusStreamers m.focus = focusStreamers
m.ensureSelection() m.ensureSelection()
@@ -233,8 +235,17 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
if msg.IRC != nil { if msg.IRC != nil {
m.applyIRCUpdate(*msg.IRC) m.applyIRCUpdate(*msg.IRC)
} }
if msg.Miner != nil { if update := msg.MinerLog; update != nil {
m.applyMinerUpdate(*msg.Miner) 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 { if msg.Err != nil {
m.resolveErr = msg.Err m.resolveErr = msg.Err
@@ -251,17 +262,17 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) applyIRCUpdate(update IRCUpdate) { func (m *Model) applyIRCUpdate(update irc.Event) {
login := normalizeKey(update.Login) login := normalizeKey(update.Streamer)
if login == "" { if login == "" {
return return
} }
detail := m.ircDetails[login] detail := m.ircDetails[login]
switch update.State { switch update.State {
case IRCJoined: case irc.StateJoined:
detail.joined = true detail.joined = true
case IRCPending, IRCDisconnected: case irc.StatePending, irc.StateDisconnected:
detail.joined = false detail.joined = false
} }
if message, ok := formatIRCChatLine(update.Line); ok { if message, ok := formatIRCChatLine(update.Line); ok {
@@ -270,20 +281,6 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
m.ircDetails[login] = detail 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() { func (m *Model) ensureSelection() {
entries := m.orderedStreamers() entries := m.orderedStreamers()
if len(entries) == 0 { 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 { func isActive(entry twitch.StreamerEntry) bool {
return entry.Status == twitch.StreamerReady && entry.Live return entry.Status == twitch.StreamerReady && entry.Live
} }
+38 -42
View File
@@ -8,6 +8,8 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth" "parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch" "parasocial/internal/twitch"
) )
@@ -59,7 +61,7 @@ func TestAuthUpdateAppendsLogLine(t *testing.T) {
func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) { func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
runtime := &fakeModelRuntime{} 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}) updated, cmd := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Update(AuthUpdate{State: state, Done: true})
if cmd == nil { if cmd == nil {
t.Fatal("expected streamer resolution command") t.Fatal("expected streamer resolution command")
@@ -87,7 +89,7 @@ func TestInitStartsAuthOrResolution(t *testing.T) {
t.Fatal("expected auth runtime to start") t.Fatal("expected auth runtime to start")
} }
state := &auth.State{Login: "viewer", UserID: "7"} state := &auth.State{Login: "viewer"}
runtime = &fakeModelRuntime{} runtime = &fakeModelRuntime{}
if _, ok := New(Options{Streamers: []string{"alpha"}, AuthState: state, runtime: runtime}).Init()().(streamerStartedMsg); !ok { if _, ok := New(Options{Streamers: []string{"alpha"}, AuthState: state, runtime: runtime}).Init()().(streamerStartedMsg); !ok {
t.Fatal("authenticated Init() did not start streamer resolution") t.Fatal("authenticated Init() did not start streamer resolution")
@@ -177,13 +179,11 @@ func TestInfoTabShowsWatchStreakMinerStatus(t *testing.T) {
Status: twitch.StreamerReady, Status: twitch.StreamerReady,
WatchStreak: &streak, WatchStreak: &streak,
}) })
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ updated, _ := model.Update(StreamerUpdate{MinerStatus: &miner.StatusEntry{
Login: "alpha_live", Login: "alpha_live",
Status: &MinerStatus{ Watching: true,
Watching: true, Reason: miner.WatchReasonStreak,
Reason: "watchstreak", WatchedMinutes: 4,
WatchedMinutes: 4,
},
}}) }})
next := updated.(Model) next := updated.(Model)
entry, ok := next.selectedEntry() entry, ok := next.selectedEntry()
@@ -206,14 +206,12 @@ func TestInfoTabShowsPointsMinerStatus(t *testing.T) {
Status: twitch.StreamerReady, Status: twitch.StreamerReady,
WatchStreak: &streak, WatchStreak: &streak,
}) })
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ updated, _ := model.Update(StreamerUpdate{MinerStatus: &miner.StatusEntry{
Login: "alpha_live", Login: "alpha_live",
Status: &MinerStatus{ Watching: true,
Watching: true, Reason: miner.WatchReasonPoints,
Reason: "points", WatchedMinutes: 6,
WatchedMinutes: 6, WatchStreak: &minerStreak,
WatchStreak: &minerStreak,
},
}}) }})
next := updated.(Model) next := updated.(Model)
entry, ok := next.selectedEntry() entry, ok := next.selectedEntry()
@@ -233,12 +231,10 @@ func TestInfoTabOmitsMinerStatusWhenInactiveOrUnwatched(t *testing.T) {
Status: twitch.StreamerReady, Status: twitch.StreamerReady,
WatchStreak: &streak, WatchStreak: &streak,
}) })
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ updated, _ := model.Update(StreamerUpdate{MinerStatus: &miner.StatusEntry{
Login: "alpha_live", Login: "alpha_live",
Status: &MinerStatus{ Watching: true,
Watching: true, Reason: miner.WatchReasonPoints,
Reason: "points",
},
}}) }})
next := updated.(Model) next := updated.(Model)
entry, ok := next.selectedEntry() entry, ok := next.selectedEntry()
@@ -427,16 +423,16 @@ func TestIRCUpdatesShowJoinedStatusAndFormattedMessages(t *testing.T) {
Login: "alpha_live", Login: "alpha_live",
Live: true, Live: true,
Status: twitch.StreamerReady, 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) next := updated.(Model)
if !next.ircDetails["alpha_live"].joined { if !next.ircDetails["alpha_live"].joined {
t.Fatal("expected joined detail") t.Fatal("expected joined detail")
} }
updated, _ = next.Update(StreamerUpdate{IRC: &IRCUpdate{ updated, _ = next.Update(StreamerUpdate{IRC: &irc.Event{
Login: "alpha_live", Streamer: "alpha_live",
Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there", Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there",
}}) }})
next = updated.(Model) next = updated.(Model)
next.focus = focusIRC next.focus = focusIRC
@@ -482,11 +478,11 @@ func TestIRCUpdatesKeepOnlyLast50ChatMessages(t *testing.T) {
Status: twitch.StreamerReady, 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) next := updated.(Model)
for i := 1; i <= maxIRCMessageHistory+5; i++ { 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) 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) next = updated.(Model)
} }
@@ -512,7 +508,7 @@ func TestMinerUpdatesRenderAndKeepOnlyLast50Messages(t *testing.T) {
next := model next := model
for i := 1; i <= maxIRCMessageHistory+5; i++ { for i := 1; i <= maxIRCMessageHistory+5; i++ {
updated, _ := next.Update(StreamerUpdate{Miner: &MinerUpdate{ updated, _ := next.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live", Login: "alpha_live",
Line: fmt.Sprintf("miner message %d", i), Line: fmt.Sprintf("miner message %d", i),
}}) }})
@@ -541,7 +537,7 @@ func TestMinerTabShowsHistoryForOfflineStreamer(t *testing.T) {
Status: twitch.StreamerReady, Status: twitch.StreamerReady,
}) })
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ updated, _ := model.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live", Login: "alpha_live",
Line: "pubsub stream down", Line: "pubsub stream down",
}}) }})
@@ -560,9 +556,9 @@ func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) {
Status: twitch.StreamerReady, Status: twitch.StreamerReady,
}) })
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{ updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{
Login: "alpha_live", Streamer: "alpha_live",
Line: "Joined #alpha_live as viewer", Line: "Joined #alpha_live as viewer",
}}) }})
next := updated.(Model) next := updated.(Model)
if len(next.ircDetails["alpha_live"].messages) != 0 { 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") t.Fatal("expected viewport to start at bottom")
} }
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{ updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{
Login: "alpha_live", Streamer: "alpha_live",
Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest", Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest",
}}) }})
next := updated.(Model) next := updated.(Model)
if !next.ircViewport.AtBottom() { if !next.ircViewport.AtBottom() {
@@ -613,9 +609,9 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) {
model.syncIRCViewport(true) model.syncIRCViewport(true)
model.ircViewport.GotoTop() model.ircViewport.GotoTop()
updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{ updated, _ := model.Update(StreamerUpdate{IRC: &irc.Event{
Login: "alpha_live", Streamer: "alpha_live",
Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest", Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest",
}}) }})
next := updated.(Model) next := updated.(Model)
if next.ircViewport.YOffset != 0 { if next.ircViewport.YOffset != 0 {
@@ -641,7 +637,7 @@ func TestMinerViewportAutoScrollsAtBottom(t *testing.T) {
t.Fatal("expected miner viewport to start at bottom") 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", Login: "alpha_live",
Line: "newest miner event", Line: "newest miner event",
}}) }})
@@ -663,7 +659,7 @@ func TestMinerViewportPreservesManualScrollPosition(t *testing.T) {
model.syncMinerViewport(true) model.syncMinerViewport(true)
model.minerViewport.GotoTop() model.minerViewport.GotoTop()
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ updated, _ := model.Update(StreamerUpdate{MinerLog: &miner.LogEntry{
Login: "alpha_live", Login: "alpha_live",
Line: "newest miner event", Line: "newest miner event",
}}) }})
+43 -148
View File
@@ -4,9 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -25,23 +23,16 @@ const streamerRefreshInterval = 5 * time.Minute
type Options struct { type Options struct {
Streamers []string Streamers []string
AuthState *auth.State AuthState *auth.State
AuthPath string
Input io.Reader
Output io.Writer
runtime modelRuntime runtime modelRuntime
initialStreamers []twitch.StreamerEntry initialStreamers []twitch.StreamerEntry
httpClient *http.Client
} }
type runtime struct { type runtime struct {
ctx context.Context ctx context.Context
logins []string logins []string
httpClient *http.Client httpClient *http.Client
authClient *auth.Client authClient *auth.Client
authPath string
refreshEvery time.Duration
sleep func(context.Context, time.Duration) error
} }
// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package. // 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) ctx, cancel := context.WithCancel(ctx)
defer cancel() 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 state := options.AuthState
if state == nil { if state == nil {
var err error var err error
state, err = rt.reuseAuth() state, err = rt.authClient.ReuseAuth(rt.ctx, auth.DefaultPath())
if err != nil { if err != nil {
return err return err
} }
@@ -62,54 +59,19 @@ func Run(ctx context.Context, options Options) error {
options.AuthState = state options.AuthState = state
options.runtime = rt options.runtime = rt
input := options.Input
if input == nil {
input = os.Stdin
}
output := options.Output
if output == nil {
output = os.Stdout
}
program := tea.NewProgram( program := tea.NewProgram(
New(options), New(options),
tea.WithContext(ctx), tea.WithContext(ctx),
tea.WithInput(input),
tea.WithOutput(output),
) )
_, err := program.Run() _, err := program.Run()
return err 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) { func (r *runtime) startAuth(ch chan<- AuthUpdate) {
go func() { go func() {
defer close(ch) 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")} ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")}
}) })
if err != nil { if err != nil {
@@ -128,20 +90,40 @@ func (r *runtime) startAuth(ch chan<- AuthUpdate) {
func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) { func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
go func() { go func() {
defer close(ch) defer close(ch)
ircManager := newIRCManager(ch) ircManager := &irc.Manager{Events: func(event irc.Event) {
ch <- StreamerUpdate{IRC: &event}
}}
defer ircManager.Close() defer ircManager.Close()
service, err := newTwitchService(r.httpClient, state) client := &gql.Client{
if err != nil { HTTPClient: r.httpClient,
ch <- StreamerUpdate{Err: err, Done: true} 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 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() defer minerManager.Close()
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, minerManager, func(update StreamerUpdate) { if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, minerManager, func(update StreamerUpdate) {
ch <- update ch <- update
}, r.refreshEvery, r.sleep); err != nil { }, streamerRefreshInterval, sleepContext); err != nil {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return return
} }
@@ -158,95 +140,16 @@ type streamerService interface {
StreamInfo(context.Context, string) (*twitch.StreamInfo, error) StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
WatchStreak(context.Context, string) (*int, error) WatchStreak(context.Context, string) (*int, error)
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, 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 { type ircSyncer interface {
Sync(context.Context, string, string, []irc.Target) Sync(context.Context, string, string, []string)
} }
type minerSyncer interface { type minerSyncer interface {
Sync(context.Context, *auth.State, *twitch.Viewer, []twitch.StreamerEntry) 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 { 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) viewer, err := service.CurrentUser(ctx)
if err != nil { if err != nil {
@@ -348,24 +251,16 @@ func syncRuntimeState(ctx context.Context, ircSyncer ircSyncer, minerSyncer mine
} }
} }
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target { func watchedIRCTargets(entries []twitch.StreamerEntry) []string {
targets := make([]irc.Target, 0, 2) targets := make([]string, 0, 2)
for _, entry := range entries { for _, entry := range entries {
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" { if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
continue continue
} }
targets = append(targets, irc.Target{Login: entry.Login}) targets = append(targets, entry.Login)
if len(targets) == 2 { if len(targets) == 2 {
break break
} }
} }
return targets 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" "time"
"parasocial/internal/auth" "parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch" "parasocial/internal/twitch"
) )
@@ -73,40 +71,12 @@ func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login strin
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil 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 { type fakeIRCSyncer struct {
calls [][]string calls [][]string
} }
func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) { func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []string) {
logins := make([]string, 0, len(targets)) f.calls = append(f.calls, append([]string{}, targets...))
for _, target := range targets {
logins = append(logins, target.Login)
}
f.calls = append(f.calls, logins)
} }
type fakeMinerSyncer struct { 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 serviceOption func(*fakeStreamerService)
type watchStreakResult struct { type watchStreakResult struct {
-19
View File
@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -55,21 +54,3 @@ var (
BorderForeground(accentColor). BorderForeground(accentColor).
Foreground(titleColor) 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" "github.com/charmbracelet/lipgloss"
"parasocial/internal/miner"
"parasocial/internal/twitch" "parasocial/internal/twitch"
) )
@@ -39,10 +40,10 @@ func (m Model) renderStreamerView() string {
header := m.renderDashboardHeader() header := m.renderDashboardHeader()
leftStyle := panelStyle leftStyle := panelStyle
rightStyle := panelStyle rightStyle := panelStyle
if m.isStreamersFocused() { if m.focus == focusStreamers {
leftStyle = focusedPanelStyle leftStyle = focusedPanelStyle
} }
if m.isRightPanelFocused() { if m.focus != focusStreamers {
rightStyle = focusedPanelStyle rightStyle = focusedPanelStyle
} }
@@ -91,9 +92,9 @@ func (m Model) renderDetailPanel() string {
var body string var body string
switch m.visibleDetailTab() { switch m.visibleDetailTab() {
case ircTab: case ircTab:
body = m.renderIRCTab() body = m.ircViewport.View()
case minerTab: case minerTab:
body = m.renderMinerTab() body = m.minerViewport.View()
default: default:
body = m.renderInfoTab(entry) body = m.renderInfoTab(entry)
} }
@@ -143,14 +144,6 @@ func (m Model) renderInfoTab(entry twitch.StreamerEntry) string {
return strings.Join(lines, "\n") 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 { func (m Model) renderStreamerRows(maxRows int) string {
entries := m.visibleStreamers(maxRows) entries := m.visibleStreamers(maxRows)
if len(entries) == 0 { if len(entries) == 0 {
@@ -248,9 +241,9 @@ func (m Model) minerWatchingLines(entry twitch.StreamerEntry) []string {
} }
var reason string var reason string
switch status.Reason { switch status.Reason {
case string(minerWatchReasonStreak): case miner.WatchReasonStreak:
reason = "Watching for watchstreak" reason = "Watching for watchstreak"
case string(minerWatchReasonPoints): case miner.WatchReasonPoints:
reason = "Watching for points" reason = "Watching for points"
default: default:
return nil 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 { func contentWidth(width int) int {
if width <= 0 { if width <= 0 {
return defaultViewWidth return defaultViewWidth
+12 -42
View File
@@ -74,25 +74,14 @@ type ChannelPointsContext struct {
// Game describes the current Twitch category for a live stream. // Game describes the current Twitch category for a live stream.
type Game struct { type Game struct {
ID string ID string
Name string Name string
DisplayName string
} }
// StreamMetadata describes the live stream state used for watch telemetry. // StreamMetadata describes the live stream state used for watch telemetry.
type StreamMetadata struct { type StreamMetadata struct {
BroadcastID string BroadcastID string
Title string Game *Game
Game *Game
ViewerCount int
Tags []Tag
ChannelLogin string
}
// Tag is one stream tag entry returned by Twitch.
type Tag struct {
ID string
LocalizedName string
} }
// MinuteWatchedPayload is the Spade telemetry body Twitch accepts for simulated viewing. // 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 { var data struct {
User *struct { User *struct {
BroadcastSettings *struct { BroadcastSettings *struct {
Title string `json:"title"` Game *struct {
Game *struct { ID string `json:"id"`
ID string `json:"id"` Name string `json:"name"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
} `json:"game"` } `json:"game"`
} `json:"broadcastSettings"` } `json:"broadcastSettings"`
Stream *struct { Stream *struct {
ID string `json:"id"` ID string `json:"id"`
ViewersCount int `json:"viewersCount"`
Tags []struct {
ID string `json:"id"`
LocalizedName string `json:"localizedName"`
} `json:"tags"`
} `json:"stream"` } `json:"stream"`
} `json:"user"` } `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) return nil, fmt.Errorf("stream metadata missing broadcast settings for %s", login)
} }
metadata := &StreamMetadata{ metadata := &StreamMetadata{BroadcastID: data.User.Stream.ID}
BroadcastID: data.User.Stream.ID,
Title: strings.TrimSpace(data.User.BroadcastSettings.Title),
ViewerCount: data.User.Stream.ViewersCount,
ChannelLogin: login,
}
if game := data.User.BroadcastSettings.Game; game != nil { if game := data.User.BroadcastSettings.Game; game != nil {
metadata.Game = &Game{ metadata.Game = &Game{
ID: game.ID, ID: game.ID,
Name: game.Name, Name: game.Name,
DisplayName: game.DisplayName,
} }
} }
for _, tag := range data.User.Stream.Tags {
metadata.Tags = append(metadata.Tags, Tag{
ID: tag.ID,
LocalizedName: tag.LocalizedName,
})
}
if metadata.BroadcastID == "" { if metadata.BroadcastID == "" {
return nil, fmt.Errorf("stream metadata missing broadcast id for %s", login) 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 == "" { if token == nil || token.Signature == "" || token.Value == "" {
return errors.New("playback access token is incomplete") 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) variantURL, err := s.fetchPlaylistTarget(ctx, masterURL)
if err != nil { if err != nil {
return fmt.Errorf("fetch master playlist: %w", err) return fmt.Errorf("fetch master playlist: %w", err)
+2 -5
View File
@@ -214,22 +214,19 @@ func TestStreamMetadata(t *testing.T) {
t.Parallel() t.Parallel()
client := &fakeGQL{data: map[string]string{ 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} service := &Service{GQL: client}
metadata, err := service.StreamMetadata(context.Background(), "streamer") metadata, err := service.StreamMetadata(context.Background(), "streamer")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if metadata.BroadcastID != "broadcast" || metadata.Title != "Live title" { if metadata.BroadcastID != "broadcast" {
t.Fatalf("metadata = %#v", metadata) t.Fatalf("metadata = %#v", metadata)
} }
if metadata.Game == nil || metadata.Game.Name != "game-name" { if metadata.Game == nil || metadata.Game.Name != "game-name" {
t.Fatalf("game = %#v", metadata.Game) 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) { func TestFetchSpadeURL(t *testing.T) {