refactor: simplify codebase
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ©
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user