refactor: simplify codebase

This commit is contained in:
2026-07-17 22:26:53 +00:00
parent 0ae1b094c5
commit 20209a9b6d
26 changed files with 291 additions and 904 deletions
+43 -148
View File
@@ -4,9 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
@@ -25,23 +23,16 @@ const streamerRefreshInterval = 5 * time.Minute
type Options struct {
Streamers []string
AuthState *auth.State
AuthPath string
Input io.Reader
Output io.Writer
runtime modelRuntime
initialStreamers []twitch.StreamerEntry
httpClient *http.Client
}
type runtime struct {
ctx context.Context
logins []string
httpClient *http.Client
authClient *auth.Client
authPath string
refreshEvery time.Duration
sleep func(context.Context, time.Duration) error
ctx context.Context
logins []string
httpClient *http.Client
authClient *auth.Client
}
// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package.
@@ -49,11 +40,17 @@ func Run(ctx context.Context, options Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rt := newRuntime(ctx, options)
httpClient := &http.Client{Timeout: 30 * time.Second}
rt := &runtime{
ctx: ctx,
logins: append([]string(nil), options.Streamers...),
httpClient: httpClient,
authClient: auth.NewClient(httpClient),
}
state := options.AuthState
if state == nil {
var err error
state, err = rt.reuseAuth()
state, err = rt.authClient.ReuseAuth(rt.ctx, auth.DefaultPath())
if err != nil {
return err
}
@@ -62,54 +59,19 @@ func Run(ctx context.Context, options Options) error {
options.AuthState = state
options.runtime = rt
input := options.Input
if input == nil {
input = os.Stdin
}
output := options.Output
if output == nil {
output = os.Stdout
}
program := tea.NewProgram(
New(options),
tea.WithContext(ctx),
tea.WithInput(input),
tea.WithOutput(output),
)
_, err := program.Run()
return err
}
func newRuntime(ctx context.Context, options Options) *runtime {
httpClient := options.httpClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
authPath := options.AuthPath
if authPath == "" {
authPath = auth.DefaultPath()
}
return &runtime{
ctx: ctx,
logins: append([]string(nil), options.Streamers...),
httpClient: httpClient,
authClient: auth.NewClient(httpClient),
authPath: authPath,
refreshEvery: streamerRefreshInterval,
sleep: sleepContext,
}
}
func (r *runtime) reuseAuth() (*auth.State, error) {
return r.authClient.ReuseAuth(r.ctx, r.authPath)
}
func (r *runtime) startAuth(ch chan<- AuthUpdate) {
go func() {
defer close(ch)
state, err := r.authClient.EnsureAuth(r.ctx, r.authPath, func(line string) {
state, err := r.authClient.EnsureAuth(r.ctx, auth.DefaultPath(), func(line string) {
ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")}
})
if err != nil {
@@ -128,20 +90,40 @@ func (r *runtime) startAuth(ch chan<- AuthUpdate) {
func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
go func() {
defer close(ch)
ircManager := newIRCManager(ch)
ircManager := &irc.Manager{Events: func(event irc.Event) {
ch <- StreamerUpdate{IRC: &event}
}}
defer ircManager.Close()
service, err := newTwitchService(r.httpClient, state)
if err != nil {
ch <- StreamerUpdate{Err: err, Done: true}
client := &gql.Client{
HTTPClient: r.httpClient,
Session: gql.Session{
AccessToken: state.AccessToken,
ClientID: auth.ClientID,
DeviceID: state.DeviceID,
UserAgent: auth.TVUserAgent,
},
}
if err := client.Validate(); err != nil {
ch <- StreamerUpdate{Err: fmt.Errorf("configure graphql session: %w", err), Done: true}
return
}
minerManager := newMinerManager(r.ctx, service, ch)
service := &twitch.Service{
GQL: client,
HTTPClient: r.httpClient,
Session: client.Session,
}
minerManager := miner.NewManager(r.ctx, service, nil, func(entry miner.LogEntry) {
ch <- StreamerUpdate{MinerLog: &entry}
})
minerManager.SetStatusSink(func(entry miner.StatusEntry) {
ch <- StreamerUpdate{MinerStatus: &entry}
})
defer minerManager.Close()
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, minerManager, func(update StreamerUpdate) {
ch <- update
}, r.refreshEvery, r.sleep); err != nil {
}, streamerRefreshInterval, sleepContext); err != nil {
if errors.Is(err, context.Canceled) {
return
}
@@ -158,95 +140,16 @@ type streamerService interface {
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
WatchStreak(context.Context, string) (*int, error)
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
LoadChannelPointsContext(context.Context, string) (*twitch.ChannelPointsContext, error)
ClaimCommunityPoints(context.Context, string, string) error
StreamMetadata(context.Context, string) (*twitch.StreamMetadata, error)
FetchSpadeURL(context.Context, string) (string, error)
TouchPlayback(context.Context, string, *twitch.PlaybackToken) error
SendMinuteWatched(context.Context, string, twitch.MinuteWatchedPayload) error
}
type ircSyncer interface {
Sync(context.Context, string, string, []irc.Target)
Sync(context.Context, string, string, []string)
}
type minerSyncer interface {
Sync(context.Context, *auth.State, *twitch.Viewer, []twitch.StreamerEntry)
}
func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) {
client := &gql.Client{
HTTPClient: httpClient,
Session: gql.Session{
AccessToken: state.AccessToken,
ClientID: state.ClientID,
DeviceID: state.DeviceID,
UserAgent: auth.TVUserAgent(),
},
}
if err := client.Validate(); err != nil {
return nil, fmt.Errorf("configure graphql session: %w", err)
}
return &twitch.Service{
GQL: client,
HTTPClient: httpClient,
Session: client.Session,
}, nil
}
func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager {
return &irc.Manager{
Addr: irc.DefaultAddr,
Events: func(event irc.Event) {
ch <- StreamerUpdate{
IRC: &IRCUpdate{
Login: event.Streamer,
State: IRCState(event.State),
Line: event.Line,
},
}
},
}
}
func newMinerManager(ctx context.Context, service miner.Service, ch chan<- StreamerUpdate) *miner.Manager {
manager := miner.NewManager(ctx, service, nil, newMinerLogSink(ch))
manager.SetStatusSink(newMinerStatusSink(ch))
return manager
}
func newMinerLogSink(ch chan<- StreamerUpdate) func(miner.LogEntry) {
return func(entry miner.LogEntry) {
ch <- StreamerUpdate{
Miner: &MinerUpdate{
Login: entry.Login,
Line: entry.Line,
},
}
}
}
func newMinerStatusSink(ch chan<- StreamerUpdate) func(miner.StatusEntry) {
return func(entry miner.StatusEntry) {
ch <- StreamerUpdate{
Miner: &MinerUpdate{
Login: entry.Login,
Status: &MinerStatus{
Watching: entry.Watching,
Reason: string(entry.Reason),
WatchedMinutes: entry.WatchedMinutes,
WatchStreakMinutes: entry.WatchStreakMinutes,
WatchStreak: cloneInt(entry.WatchStreak),
},
},
}
}
}
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate)) error {
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, ircSyncer, minerSyncer, send, streamerRefreshInterval, sleepContext)
}
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
viewer, err := service.CurrentUser(ctx)
if err != nil {
@@ -348,24 +251,16 @@ func syncRuntimeState(ctx context.Context, ircSyncer ircSyncer, minerSyncer mine
}
}
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
targets := make([]irc.Target, 0, 2)
func watchedIRCTargets(entries []twitch.StreamerEntry) []string {
targets := make([]string, 0, 2)
for _, entry := range entries {
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
continue
}
targets = append(targets, irc.Target{Login: entry.Login})
targets = append(targets, entry.Login)
if len(targets) == 2 {
break
}
}
return targets
}
func cloneInt(value *int) *int {
if value == nil {
return nil
}
copy := *value
return &copy
}