feat: add miner event log tab
This commit is contained in:
@@ -2,6 +2,7 @@ package miner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,11 +29,18 @@ type PubSubSyncer interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogEntry is one miner log line associated with a resolved streamer login.
|
||||||
|
type LogEntry struct {
|
||||||
|
Login string
|
||||||
|
Line string
|
||||||
|
}
|
||||||
|
|
||||||
// Manager owns background channel points mining state for the current authenticated user.
|
// Manager owns background channel points mining state for the current authenticated user.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
service Service
|
service Service
|
||||||
pubsub PubSubSyncer
|
pubsub PubSubSyncer
|
||||||
|
onLog func(LogEntry)
|
||||||
watchInterval time.Duration
|
watchInterval time.Duration
|
||||||
sleep func(context.Context, time.Duration) error
|
sleep func(context.Context, time.Duration) error
|
||||||
|
|
||||||
@@ -62,11 +70,12 @@ type streamerState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewManager constructs one background miner manager.
|
// NewManager constructs one background miner manager.
|
||||||
func NewManager(ctx context.Context, service Service, pubsub PubSubSyncer) *Manager {
|
func NewManager(ctx context.Context, service Service, pubsub PubSubSyncer, onLog func(LogEntry)) *Manager {
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
service: service,
|
service: service,
|
||||||
pubsub: pubsub,
|
pubsub: pubsub,
|
||||||
|
onLog: onLog,
|
||||||
watchInterval: defaultWatchInterval,
|
watchInterval: defaultWatchInterval,
|
||||||
sleep: sleepContext,
|
sleep: sleepContext,
|
||||||
entries: make(map[string]*streamerState),
|
entries: make(map[string]*streamerState),
|
||||||
@@ -174,15 +183,21 @@ func (m *Manager) scheduleSeed(configLogin string) {
|
|||||||
defer m.finishSeeding(configLogin)
|
defer m.finishSeeding(configLogin)
|
||||||
|
|
||||||
channelPoints, err := m.service.LoadChannelPointsContext(m.ctx, login)
|
channelPoints, err := m.service.LoadChannelPointsContext(m.ctx, login)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
|
m.logf(login, "channel points seed failed: %v", err)
|
||||||
|
} else {
|
||||||
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
|
current.seeded = true
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
m.logf(login, "seeded channel points balance: %d", channelPoints.Balance)
|
||||||
if channelPoints.ClaimID != "" {
|
if channelPoints.ClaimID != "" {
|
||||||
_ = m.service.ClaimCommunityPoints(m.ctx, channelID, channelPoints.ClaimID)
|
m.logf(login, "claiming bonus chest: %s", channelPoints.ClaimID)
|
||||||
|
if err := m.service.ClaimCommunityPoints(m.ctx, channelID, channelPoints.ClaimID); err != nil {
|
||||||
|
m.logf(login, "claim failed: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,20 +232,23 @@ func (m *Manager) scheduleRefresh(configLogin string) {
|
|||||||
|
|
||||||
spadeURL, err := m.service.FetchSpadeURL(m.ctx, login)
|
spadeURL, err := m.service.FetchSpadeURL(m.ctx, login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
m.logf(login, "metadata refresh failed: fetch spade url: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
metadata, err := m.service.StreamMetadata(m.ctx, login)
|
metadata, err := m.service.StreamMetadata(m.ctx, login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
m.logf(login, "metadata refresh failed: stream metadata: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
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.Live = true
|
current.Live = true
|
||||||
}
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.logf(login, "refreshed playback metadata")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,14 +315,17 @@ func (m *Manager) watchOnce(ctx context.Context) error {
|
|||||||
|
|
||||||
token, err := m.service.PlaybackAccessToken(ctx, candidate.login)
|
token, err := m.service.PlaybackAccessToken(ctx, candidate.login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
m.logf(candidate.login, "watch failed: playback token: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := m.service.TouchPlayback(ctx, candidate.login, token); err != nil {
|
if err := m.service.TouchPlayback(ctx, candidate.login, token); err != nil {
|
||||||
|
m.logf(candidate.login, "watch failed: touch playback: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := twitch.BuildMinuteWatchedPayload(viewer.ID, candidate.channelID, candidate.login, candidate.metadata)
|
payload := twitch.BuildMinuteWatchedPayload(viewer.ID, candidate.channelID, candidate.login, candidate.metadata)
|
||||||
if err := m.service.SendMinuteWatched(ctx, candidate.spadeURL, payload); err != nil {
|
if err := m.service.SendMinuteWatched(ctx, candidate.spadeURL, payload); err != nil {
|
||||||
|
m.logf(candidate.login, "watch failed: minute watched: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +344,8 @@ func (m *Manager) handlePubSubEvent(event Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.logEvent(state.Login, event)
|
||||||
|
|
||||||
switch event.MessageType {
|
switch event.MessageType {
|
||||||
case "points-earned", "points-spent":
|
case "points-earned", "points-spent":
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -332,7 +355,10 @@ func (m *Manager) handlePubSubEvent(event Event) {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
case "claim-available":
|
case "claim-available":
|
||||||
if event.ClaimID != "" {
|
if event.ClaimID != "" {
|
||||||
_ = m.service.ClaimCommunityPoints(m.ctx, state.ChannelID, event.ClaimID)
|
m.logf(state.Login, "claiming bonus chest: %s", event.ClaimID)
|
||||||
|
if err := m.service.ClaimCommunityPoints(m.ctx, state.ChannelID, event.ClaimID); err != nil {
|
||||||
|
m.logf(state.Login, "claim failed: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "stream-up":
|
case "stream-up":
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -359,6 +385,39 @@ func (m *Manager) handlePubSubEvent(event Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) logEvent(login string, event Event) {
|
||||||
|
switch event.MessageType {
|
||||||
|
case "points-earned":
|
||||||
|
m.logf(login, "pubsub points earned: balance=%d", event.Balance)
|
||||||
|
case "points-spent":
|
||||||
|
m.logf(login, "pubsub points spent: balance=%d", event.Balance)
|
||||||
|
case "claim-available":
|
||||||
|
if event.ClaimID != "" {
|
||||||
|
m.logf(login, "pubsub claim available: %s", event.ClaimID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.logf(login, "pubsub claim available")
|
||||||
|
case "stream-up":
|
||||||
|
m.logf(login, "pubsub stream up")
|
||||||
|
case "stream-down":
|
||||||
|
m.logf(login, "pubsub stream down")
|
||||||
|
case "viewcount":
|
||||||
|
m.logf(login, "pubsub viewcount heartbeat")
|
||||||
|
default:
|
||||||
|
m.logf(login, "pubsub %s", event.MessageType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) logf(login, format string, args ...any) {
|
||||||
|
if m.onLog == nil || login == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.onLog(LogEntry{
|
||||||
|
Login: login,
|
||||||
|
Line: fmt.Sprintf(format, args...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) streamerForChannel(channelID string) (string, *streamerState) {
|
func (m *Manager) streamerForChannel(channelID string) (string, *streamerState) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type fakeService struct {
|
|||||||
metadata map[string]*twitch.StreamMetadata
|
metadata map[string]*twitch.StreamMetadata
|
||||||
spadeURLs map[string]string
|
spadeURLs map[string]string
|
||||||
playbackErr map[string]error
|
playbackErr map[string]error
|
||||||
|
claimErr map[string]error
|
||||||
|
|
||||||
claimed []string
|
claimed []string
|
||||||
watched []string
|
watched []string
|
||||||
@@ -31,6 +32,9 @@ func (f *fakeService) LoadChannelPointsContext(_ context.Context, login string)
|
|||||||
|
|
||||||
func (f *fakeService) ClaimCommunityPoints(_ context.Context, channelID, claimID string) error {
|
func (f *fakeService) ClaimCommunityPoints(_ context.Context, channelID, claimID string) error {
|
||||||
f.claimed = append(f.claimed, channelID+":"+claimID)
|
f.claimed = append(f.claimed, channelID+":"+claimID)
|
||||||
|
if err, ok := f.claimErr[channelID+":"+claimID]; ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ func TestManagerSyncSeedsAndClaims(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
pubsub := &fakePubSub{}
|
pubsub := &fakePubSub{}
|
||||||
manager := NewManager(context.Background(), service, pubsub)
|
manager := NewManager(context.Background(), service, pubsub, nil)
|
||||||
defer manager.Close()
|
defer manager.Close()
|
||||||
manager.sleep = cancelSleep
|
manager.sleep = cancelSleep
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ func TestManagerWatchOnceUsesTopTwoLiveStreamers(t *testing.T) {
|
|||||||
"gamma_live": {Balance: 30},
|
"gamma_live": {Balance: 30},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
manager := NewManager(context.Background(), service, &fakePubSub{})
|
manager := NewManager(context.Background(), service, &fakePubSub{}, nil)
|
||||||
defer manager.Close()
|
defer manager.Close()
|
||||||
manager.sleep = cancelSleep
|
manager.sleep = cancelSleep
|
||||||
|
|
||||||
@@ -156,7 +160,7 @@ func TestManagerWatchOnceUsesTopTwoLiveStreamers(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
|
func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
|
||||||
service := &fakeService{}
|
service := &fakeService{}
|
||||||
manager := NewManager(context.Background(), service, &fakePubSub{})
|
manager := NewManager(context.Background(), service, &fakePubSub{}, nil)
|
||||||
defer manager.Close()
|
defer manager.Close()
|
||||||
manager.entries["alpha"] = &streamerState{ConfigLogin: "alpha", ChannelID: "1", Login: "alpha_live", Live: true}
|
manager.entries["alpha"] = &streamerState{ConfigLogin: "alpha", ChannelID: "1", Login: "alpha_live", Live: true}
|
||||||
|
|
||||||
@@ -173,6 +177,32 @@ func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagerLogsPubSubEventsAndFailures(t *testing.T) {
|
||||||
|
service := &fakeService{
|
||||||
|
claimErr: map[string]error{
|
||||||
|
"1:claim-2": errors.New("claim rejected"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var logs []LogEntry
|
||||||
|
manager := NewManager(context.Background(), service, &fakePubSub{}, func(entry LogEntry) {
|
||||||
|
logs = append(logs, entry)
|
||||||
|
})
|
||||||
|
defer manager.Close()
|
||||||
|
manager.entries["alpha"] = &streamerState{ConfigLogin: "alpha", ChannelID: "1", Login: "alpha_live", Live: true}
|
||||||
|
|
||||||
|
manager.handlePubSubEvent(Event{MessageType: "points-earned", ChannelID: "1", Balance: 555, Timestamp: "t1", Topic: "community-points-user-v1"})
|
||||||
|
manager.handlePubSubEvent(Event{MessageType: "claim-available", ChannelID: "1", ClaimID: "claim-2", Timestamp: "t2", Topic: "community-points-user-v1"})
|
||||||
|
|
||||||
|
if got, want := logs, []LogEntry{
|
||||||
|
{Login: "alpha_live", Line: "pubsub points earned: balance=555"},
|
||||||
|
{Login: "alpha_live", Line: "pubsub claim available: claim-2"},
|
||||||
|
{Login: "alpha_live", Line: "claiming bonus chest: claim-2"},
|
||||||
|
{Login: "alpha_live", Line: "claim failed: claim rejected"},
|
||||||
|
}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("logs = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cancelSleep(context.Context, time.Duration) error { return context.Canceled }
|
func cancelSleep(context.Context, time.Duration) error { return context.Canceled }
|
||||||
|
|
||||||
func waitFor(t *testing.T, condition func() bool) {
|
func waitFor(t *testing.T, condition func() bool) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type StreamerUpdate struct {
|
|||||||
Viewer *twitch.Viewer
|
Viewer *twitch.Viewer
|
||||||
Entry *twitch.StreamerEntry
|
Entry *twitch.StreamerEntry
|
||||||
IRC *IRCUpdate
|
IRC *IRCUpdate
|
||||||
|
Miner *MinerUpdate
|
||||||
Index int
|
Index int
|
||||||
Err error
|
Err error
|
||||||
Done bool
|
Done bool
|
||||||
@@ -39,6 +40,12 @@ type IRCUpdate struct {
|
|||||||
Line string
|
Line string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinerUpdate carries one miner log line into the TUI.
|
||||||
|
type MinerUpdate struct {
|
||||||
|
Login string
|
||||||
|
Line string
|
||||||
|
}
|
||||||
|
|
||||||
type authStartedMsg struct {
|
type authStartedMsg struct {
|
||||||
Updates <-chan AuthUpdate
|
Updates <-chan AuthUpdate
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-17
@@ -26,7 +26,8 @@ type panelFocus int
|
|||||||
const (
|
const (
|
||||||
focusStreamers panelFocus = iota
|
focusStreamers panelFocus = iota
|
||||||
focusInfo
|
focusInfo
|
||||||
focusChat
|
focusIRC
|
||||||
|
focusMiner
|
||||||
)
|
)
|
||||||
|
|
||||||
type detailTab int
|
type detailTab int
|
||||||
@@ -34,6 +35,7 @@ type detailTab int
|
|||||||
const (
|
const (
|
||||||
infoTab detailTab = iota
|
infoTab detailTab = iota
|
||||||
ircTab
|
ircTab
|
||||||
|
minerTab
|
||||||
)
|
)
|
||||||
|
|
||||||
type modelRuntime interface {
|
type modelRuntime interface {
|
||||||
@@ -58,8 +60,10 @@ type Model struct {
|
|||||||
selectedConfig string
|
selectedConfig string
|
||||||
focus panelFocus
|
focus panelFocus
|
||||||
ircDetails map[string]ircDetail
|
ircDetails map[string]ircDetail
|
||||||
|
minerDetails map[string][]string
|
||||||
authViewport viewport.Model
|
authViewport viewport.Model
|
||||||
ircViewport viewport.Model
|
ircViewport viewport.Model
|
||||||
|
minerViewport viewport.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
type ircDetail struct {
|
type ircDetail struct {
|
||||||
@@ -81,18 +85,23 @@ func New(options Options) Model {
|
|||||||
authLogs: []string{},
|
authLogs: []string{},
|
||||||
mode: authView,
|
mode: authView,
|
||||||
ircDetails: make(map[string]ircDetail),
|
ircDetails: make(map[string]ircDetail),
|
||||||
|
minerDetails: make(map[string][]string),
|
||||||
width: defaultViewWidth,
|
width: defaultViewWidth,
|
||||||
height: 24,
|
height: 24,
|
||||||
focus: focusStreamers,
|
focus: focusStreamers,
|
||||||
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
|
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
|
||||||
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)),
|
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)),
|
||||||
|
minerViewport: newMinerViewport(
|
||||||
|
detailViewportWidth(defaultViewWidth),
|
||||||
|
detailViewportHeight(24),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if options.AuthState != nil {
|
if options.AuthState != nil {
|
||||||
model.mode = streamerView
|
model.mode = streamerView
|
||||||
}
|
}
|
||||||
model.ensureSelection()
|
model.ensureSelection()
|
||||||
model.syncAuthViewport()
|
model.syncAuthViewport()
|
||||||
model.syncIRCViewport(true)
|
model.syncDetailViewports(true)
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +131,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.resizeComponents()
|
m.resizeComponents()
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
m.syncIRCViewport(false)
|
m.syncDetailViewports(false)
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
@@ -162,11 +171,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.authViewport, cmd = m.authViewport.Update(msg)
|
m.authViewport, cmd = m.authViewport.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
if m.focus == focusChat {
|
if m.focus == focusIRC {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.ircViewport, cmd = m.ircViewport.Update(msg)
|
m.ircViewport, cmd = m.ircViewport.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
if m.focus == focusMiner {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.minerViewport, cmd = m.minerViewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +203,12 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
|
|||||||
m.resolveErr = nil
|
m.resolveErr = nil
|
||||||
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.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
m.focus = focusStreamers
|
m.focus = focusStreamers
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
m.resizeComponents()
|
m.resizeComponents()
|
||||||
m.syncIRCViewport(true)
|
m.syncDetailViewports(true)
|
||||||
return m, startStreamerResolution(m.runtime, m.authState)
|
return m, startStreamerResolution(m.runtime, m.authState)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -215,12 +230,15 @@ 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 {
|
||||||
|
m.applyMinerUpdate(*msg.Miner)
|
||||||
|
}
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
m.resolveErr = msg.Err
|
m.resolveErr = msg.Err
|
||||||
}
|
}
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
m.resizeComponents()
|
m.resizeComponents()
|
||||||
m.syncIRCViewport(false)
|
m.syncDetailViewports(false)
|
||||||
if msg.Done {
|
if msg.Done {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -244,14 +262,20 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
|
|||||||
detail.joined = false
|
detail.joined = false
|
||||||
}
|
}
|
||||||
if message, ok := formatIRCChatLine(update.Line); ok {
|
if message, ok := formatIRCChatLine(update.Line); ok {
|
||||||
detail.messages = append(detail.messages, message)
|
detail.messages = appendCappedHistory(detail.messages, message)
|
||||||
if len(detail.messages) > maxIRCMessageHistory {
|
|
||||||
detail.messages = append([]string(nil), detail.messages[len(detail.messages)-maxIRCMessageHistory:]...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
m.ircDetails[login] = detail
|
m.ircDetails[login] = detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) applyMinerUpdate(update MinerUpdate) {
|
||||||
|
login := normalizeKey(update.Login)
|
||||||
|
line := strings.TrimSpace(update.Line)
|
||||||
|
if login == "" || line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
@@ -274,7 +298,7 @@ func (m *Model) moveSelection(delta int) {
|
|||||||
entries := m.orderedStreamers()
|
entries := m.orderedStreamers()
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
m.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
m.syncIRCViewport(true)
|
m.syncDetailViewports(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +312,7 @@ func (m *Model) moveSelection(delta int) {
|
|||||||
}
|
}
|
||||||
m.selectedConfig = entries[selected].ConfigLogin
|
m.selectedConfig = entries[selected].ConfigLogin
|
||||||
if m.selectedConfig != current {
|
if m.selectedConfig != current {
|
||||||
m.syncIRCViewport(true)
|
m.syncDetailViewports(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +320,10 @@ func (m *Model) moveFocusLeft() {
|
|||||||
switch m.focus {
|
switch m.focus {
|
||||||
case focusInfo:
|
case focusInfo:
|
||||||
m.focus = focusStreamers
|
m.focus = focusStreamers
|
||||||
case focusChat:
|
case focusIRC:
|
||||||
m.focus = focusInfo
|
m.focus = focusInfo
|
||||||
|
case focusMiner:
|
||||||
|
m.focus = focusIRC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +332,9 @@ func (m *Model) moveFocusRight() {
|
|||||||
case focusStreamers:
|
case focusStreamers:
|
||||||
m.focus = focusInfo
|
m.focus = focusInfo
|
||||||
case focusInfo:
|
case focusInfo:
|
||||||
m.focus = focusChat
|
m.focus = focusIRC
|
||||||
|
case focusIRC:
|
||||||
|
m.focus = focusMiner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +371,8 @@ func (m *Model) resizeComponents() {
|
|||||||
m.authViewport.Height = authViewportHeight(m.height)
|
m.authViewport.Height = authViewportHeight(m.height)
|
||||||
m.ircViewport.Width = detailViewportWidth(m.width)
|
m.ircViewport.Width = detailViewportWidth(m.width)
|
||||||
m.ircViewport.Height = detailViewportHeight(m.height)
|
m.ircViewport.Height = detailViewportHeight(m.height)
|
||||||
|
m.minerViewport.Width = detailViewportWidth(m.width)
|
||||||
|
m.minerViewport.Height = detailViewportHeight(m.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncAuthViewport() {
|
func (m *Model) syncAuthViewport() {
|
||||||
@@ -369,6 +399,23 @@ func (m *Model) syncIRCViewport(forceBottom bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) syncMinerViewport(forceBottom bool) {
|
||||||
|
if m.minerViewport.Width <= 0 || m.minerViewport.Height <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stickToBottom := forceBottom || m.minerViewport.AtBottom()
|
||||||
|
m.minerViewport.SetContent(m.minerViewportContent())
|
||||||
|
if stickToBottom {
|
||||||
|
m.minerViewport.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) syncDetailViewports(forceBottom bool) {
|
||||||
|
m.syncIRCViewport(forceBottom)
|
||||||
|
m.syncMinerViewport(forceBottom)
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) ircViewportContent() string {
|
func (m Model) ircViewportContent() string {
|
||||||
entry, ok := m.selectedEntry()
|
entry, ok := m.selectedEntry()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -388,6 +435,19 @@ func (m Model) ircViewportContent() string {
|
|||||||
return strings.Join(detail.messages, "\n")
|
return strings.Join(detail.messages, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) minerViewportContent() string {
|
||||||
|
entry, ok := m.selectedEntry()
|
||||||
|
if !ok {
|
||||||
|
return "No streamers configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
logins := m.minerDetails[normalizeKey(entry.Login)]
|
||||||
|
if len(logins) == 0 {
|
||||||
|
return "Waiting for miner activity..."
|
||||||
|
}
|
||||||
|
return strings.Join(logins, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) selectedEntry() (twitch.StreamerEntry, bool) {
|
func (m Model) selectedEntry() (twitch.StreamerEntry, bool) {
|
||||||
entries := m.orderedStreamers()
|
entries := m.orderedStreamers()
|
||||||
selected := m.selectedRowIndex(entries)
|
selected := m.selectedRowIndex(entries)
|
||||||
@@ -398,10 +458,14 @@ func (m Model) selectedEntry() (twitch.StreamerEntry, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) visibleDetailTab() detailTab {
|
func (m Model) visibleDetailTab() detailTab {
|
||||||
if m.focus == focusChat {
|
switch m.focus {
|
||||||
|
case focusIRC:
|
||||||
return ircTab
|
return ircTab
|
||||||
|
case focusMiner:
|
||||||
|
return minerTab
|
||||||
|
default:
|
||||||
|
return infoTab
|
||||||
}
|
}
|
||||||
return infoTab
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) isStreamersFocused() bool {
|
func (m Model) isStreamersFocused() bool {
|
||||||
@@ -409,7 +473,7 @@ func (m Model) isStreamersFocused() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) isRightPanelFocused() bool {
|
func (m Model) isRightPanelFocused() bool {
|
||||||
return m.focus == focusInfo || m.focus == focusChat
|
return m.focus == focusInfo || m.focus == focusIRC || m.focus == focusMiner
|
||||||
}
|
}
|
||||||
|
|
||||||
func isActive(entry twitch.StreamerEntry) bool {
|
func isActive(entry twitch.StreamerEntry) bool {
|
||||||
@@ -450,6 +514,14 @@ func formatIRCChatLine(line string) (string, bool) {
|
|||||||
return user + ": " + message, true
|
return user + ": " + message, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendCappedHistory(history []string, line string) []string {
|
||||||
|
history = append(history, line)
|
||||||
|
if len(history) <= maxIRCMessageHistory {
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
return append([]string(nil), history[len(history)-maxIRCMessageHistory:]...)
|
||||||
|
}
|
||||||
|
|
||||||
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
||||||
logins := make([]string, 0, len(entries))
|
logins := make([]string, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
+131
-10
@@ -37,6 +37,7 @@ func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
|
|||||||
"Watching: alpha_live, beta_live",
|
"Watching: alpha_live, beta_live",
|
||||||
"Info",
|
"Info",
|
||||||
"IRC",
|
"IRC",
|
||||||
|
"Miner",
|
||||||
"live | irc idle",
|
"live | irc idle",
|
||||||
"gamma",
|
"gamma",
|
||||||
"loading",
|
"loading",
|
||||||
@@ -204,20 +205,32 @@ func TestFocusNavigationMovesBetweenPanels(t *testing.T) {
|
|||||||
|
|
||||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
|
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
next = updated.(Model)
|
next = updated.(Model)
|
||||||
if next.focus != focusChat || next.visibleDetailTab() != ircTab {
|
if next.focus != focusIRC || next.visibleDetailTab() != ircTab {
|
||||||
t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusChat, ircTab)
|
t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusIRC, ircTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
|
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
next = updated.(Model)
|
next = updated.(Model)
|
||||||
if next.focus != focusChat {
|
if next.focus != focusMiner || next.visibleDetailTab() != minerTab {
|
||||||
t.Fatalf("focus after right on chat = %v, want %v", next.focus, focusChat)
|
t.Fatalf("focus after third right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusMiner, minerTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
|
next = updated.(Model)
|
||||||
|
if next.focus != focusMiner {
|
||||||
|
t.Fatalf("focus after right on miner = %v, want %v", next.focus, focusMiner)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
next = updated.(Model)
|
||||||
|
if next.focus != focusIRC {
|
||||||
|
t.Fatalf("focus after left from miner = %v, want %v", next.focus, focusIRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
next = updated.(Model)
|
next = updated.(Model)
|
||||||
if next.focus != focusInfo {
|
if next.focus != focusInfo {
|
||||||
t.Fatalf("focus after left from chat = %v, want %v", next.focus, focusInfo)
|
t.Fatalf("focus after left from irc = %v, want %v", next.focus, focusInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
@@ -275,7 +288,7 @@ func TestIRCUpdatesShowJoinedStatusAndFormattedMessages(t *testing.T) {
|
|||||||
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 = focusChat
|
next.focus = focusIRC
|
||||||
next.syncIRCViewport(true)
|
next.syncIRCViewport(true)
|
||||||
|
|
||||||
assertContainsAll(t, next.View(), "someone: hello there")
|
assertContainsAll(t, next.View(), "someone: hello there")
|
||||||
@@ -286,7 +299,7 @@ func TestChatFocusUsesUpDownForViewportScroll(t *testing.T) {
|
|||||||
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
|
||||||
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
||||||
)
|
)
|
||||||
model.focus = focusChat
|
model.focus = focusIRC
|
||||||
model.ircDetails["alpha_live"] = ircDetail{
|
model.ircDetails["alpha_live"] = ircDetail{
|
||||||
joined: true,
|
joined: true,
|
||||||
messages: numberedMessages(20),
|
messages: numberedMessages(20),
|
||||||
@@ -338,6 +351,56 @@ func TestIRCUpdatesKeepOnlyLast50ChatMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinerUpdatesRenderAndKeepOnlyLast50Messages(t *testing.T) {
|
||||||
|
model := dashboardModel(twitch.StreamerEntry{
|
||||||
|
ConfigLogin: "alpha",
|
||||||
|
Login: "alpha_live",
|
||||||
|
Live: true,
|
||||||
|
Status: twitch.StreamerReady,
|
||||||
|
})
|
||||||
|
|
||||||
|
next := model
|
||||||
|
for i := 1; i <= maxIRCMessageHistory+5; i++ {
|
||||||
|
updated, _ := next.Update(StreamerUpdate{Miner: &MinerUpdate{
|
||||||
|
Login: "alpha_live",
|
||||||
|
Line: fmt.Sprintf("miner message %d", i),
|
||||||
|
}})
|
||||||
|
next = updated.(Model)
|
||||||
|
}
|
||||||
|
next.focus = focusMiner
|
||||||
|
next.syncMinerViewport(true)
|
||||||
|
|
||||||
|
logs := next.minerDetails["alpha_live"]
|
||||||
|
if len(logs) != maxIRCMessageHistory {
|
||||||
|
t.Fatalf("miner message count = %d, want %d", len(logs), maxIRCMessageHistory)
|
||||||
|
}
|
||||||
|
if logs[0] != "miner message 6" {
|
||||||
|
t.Fatalf("oldest retained miner message = %q", logs[0])
|
||||||
|
}
|
||||||
|
if logs[len(logs)-1] != "miner message 55" {
|
||||||
|
t.Fatalf("newest retained miner message = %q", logs[len(logs)-1])
|
||||||
|
}
|
||||||
|
assertContainsAll(t, next.View(), "miner message 55")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinerTabShowsHistoryForOfflineStreamer(t *testing.T) {
|
||||||
|
model := dashboardModel(twitch.StreamerEntry{
|
||||||
|
ConfigLogin: "alpha",
|
||||||
|
Login: "alpha_live",
|
||||||
|
Status: twitch.StreamerReady,
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
|
||||||
|
Login: "alpha_live",
|
||||||
|
Line: "pubsub stream down",
|
||||||
|
}})
|
||||||
|
next := updated.(Model)
|
||||||
|
next.focus = focusMiner
|
||||||
|
next.syncMinerViewport(true)
|
||||||
|
|
||||||
|
assertContainsAll(t, next.View(), "pubsub stream down")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) {
|
func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) {
|
||||||
model := dashboardModel(twitch.StreamerEntry{
|
model := dashboardModel(twitch.StreamerEntry{
|
||||||
ConfigLogin: "alpha",
|
ConfigLogin: "alpha",
|
||||||
@@ -363,7 +426,7 @@ func TestIRCViewportAutoScrollsAtBottom(t *testing.T) {
|
|||||||
Live: true,
|
Live: true,
|
||||||
Status: twitch.StreamerReady,
|
Status: twitch.StreamerReady,
|
||||||
})
|
})
|
||||||
model.focus = focusChat
|
model.focus = focusIRC
|
||||||
model.ircDetails["alpha_live"] = ircDetail{
|
model.ircDetails["alpha_live"] = ircDetail{
|
||||||
joined: true,
|
joined: true,
|
||||||
messages: numberedMessages(20),
|
messages: numberedMessages(20),
|
||||||
@@ -391,7 +454,7 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) {
|
|||||||
Live: true,
|
Live: true,
|
||||||
Status: twitch.StreamerReady,
|
Status: twitch.StreamerReady,
|
||||||
})
|
})
|
||||||
model.focus = focusChat
|
model.focus = focusIRC
|
||||||
model.ircDetails["alpha_live"] = ircDetail{
|
model.ircDetails["alpha_live"] = ircDetail{
|
||||||
joined: true,
|
joined: true,
|
||||||
messages: numberedMessages(20),
|
messages: numberedMessages(20),
|
||||||
@@ -412,6 +475,56 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinerViewportAutoScrollsAtBottom(t *testing.T) {
|
||||||
|
model := dashboardModel(twitch.StreamerEntry{
|
||||||
|
ConfigLogin: "alpha",
|
||||||
|
Login: "alpha_live",
|
||||||
|
Live: true,
|
||||||
|
Status: twitch.StreamerReady,
|
||||||
|
})
|
||||||
|
model.focus = focusMiner
|
||||||
|
model.minerDetails["alpha_live"] = numberedMinerMessages(20)
|
||||||
|
model.syncMinerViewport(true)
|
||||||
|
|
||||||
|
if !model.minerViewport.AtBottom() {
|
||||||
|
t.Fatal("expected miner viewport to start at bottom")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
|
||||||
|
Login: "alpha_live",
|
||||||
|
Line: "newest miner event",
|
||||||
|
}})
|
||||||
|
next := updated.(Model)
|
||||||
|
if !next.minerViewport.AtBottom() {
|
||||||
|
t.Fatal("expected miner viewport to stay at bottom after new message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinerViewportPreservesManualScrollPosition(t *testing.T) {
|
||||||
|
model := dashboardModel(twitch.StreamerEntry{
|
||||||
|
ConfigLogin: "alpha",
|
||||||
|
Login: "alpha_live",
|
||||||
|
Live: true,
|
||||||
|
Status: twitch.StreamerReady,
|
||||||
|
})
|
||||||
|
model.focus = focusMiner
|
||||||
|
model.minerDetails["alpha_live"] = numberedMinerMessages(20)
|
||||||
|
model.syncMinerViewport(true)
|
||||||
|
model.minerViewport.GotoTop()
|
||||||
|
|
||||||
|
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
|
||||||
|
Login: "alpha_live",
|
||||||
|
Line: "newest miner event",
|
||||||
|
}})
|
||||||
|
next := updated.(Model)
|
||||||
|
if next.minerViewport.YOffset != 0 {
|
||||||
|
t.Fatalf("miner viewport YOffset = %d, want 0", next.minerViewport.YOffset)
|
||||||
|
}
|
||||||
|
if next.minerViewport.AtBottom() {
|
||||||
|
t.Fatal("expected miner viewport to remain off bottom after manual scroll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
|
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
|
||||||
model := dashboardModel(
|
model := dashboardModel(
|
||||||
twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady},
|
||||||
@@ -432,7 +545,7 @@ func dashboardModel(entries ...twitch.StreamerEntry) Model {
|
|||||||
model.width = 100
|
model.width = 100
|
||||||
model.height = 28
|
model.height = 28
|
||||||
model.resizeComponents()
|
model.resizeComponents()
|
||||||
model.syncIRCViewport(true)
|
model.syncDetailViewports(true)
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +557,14 @@ func numberedMessages(count int) []string {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func numberedMinerMessages(count int) []string {
|
||||||
|
lines := make([]string, 0, count)
|
||||||
|
for i := 1; i <= count; i++ {
|
||||||
|
lines = append(lines, fmt.Sprintf("miner message %d", i))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func assertContainsAll(t *testing.T, got string, wants ...string) {
|
func assertContainsAll(t *testing.T, got string, wants ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, want := range wants {
|
for _, want := range wants {
|
||||||
|
|||||||
+16
-1
@@ -136,7 +136,7 @@ func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
|
|||||||
ch <- StreamerUpdate{Err: err, Done: true}
|
ch <- StreamerUpdate{Err: err, Done: true}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
minerManager := miner.NewManager(r.ctx, service, nil)
|
minerManager := newMinerManager(r.ctx, service, ch)
|
||||||
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) {
|
||||||
@@ -208,6 +208,21 @@ func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newMinerManager(ctx context.Context, service miner.Service, ch chan<- StreamerUpdate) *miner.Manager {
|
||||||
|
return miner.NewManager(ctx, service, nil, newMinerLogSink(ch))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMinerLogSink(ch chan<- StreamerUpdate) func(miner.LogEntry) {
|
||||||
|
return func(entry miner.LogEntry) {
|
||||||
|
ch <- StreamerUpdate{
|
||||||
|
Miner: &MinerUpdate{
|
||||||
|
Login: entry.Login,
|
||||||
|
Line: entry.Line,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate)) error {
|
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)
|
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, ircSyncer, minerSyncer, send, streamerRefreshInterval, sleepContext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"parasocial/internal/auth"
|
"parasocial/internal/auth"
|
||||||
"parasocial/internal/irc"
|
"parasocial/internal/irc"
|
||||||
|
"parasocial/internal/miner"
|
||||||
"parasocial/internal/twitch"
|
"parasocial/internal/twitch"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -206,6 +207,23 @@ 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)
|
||||||
|
|
||||||
func streamService(options ...serviceOption) *fakeStreamerService {
|
func streamService(options ...serviceOption) *fakeStreamerService {
|
||||||
|
|||||||
@@ -67,3 +67,9 @@ func newIRCViewport(width, height int) viewport.Model {
|
|||||||
vp.Style = lipgloss.NewStyle()
|
vp.Style = lipgloss.NewStyle()
|
||||||
return vp
|
return vp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newMinerViewport(width, height int) viewport.Model {
|
||||||
|
vp := viewport.New(width, height)
|
||||||
|
vp.Style = lipgloss.NewStyle()
|
||||||
|
return vp
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ func (m Model) renderDetailPanel() string {
|
|||||||
switch m.visibleDetailTab() {
|
switch m.visibleDetailTab() {
|
||||||
case ircTab:
|
case ircTab:
|
||||||
body = m.renderIRCTab()
|
body = m.renderIRCTab()
|
||||||
|
case minerTab:
|
||||||
|
body = m.renderMinerTab()
|
||||||
default:
|
default:
|
||||||
body = m.renderInfoTab(entry)
|
body = m.renderInfoTab(entry)
|
||||||
}
|
}
|
||||||
@@ -95,6 +97,7 @@ func (m Model) renderDetailTabs() string {
|
|||||||
tabs := []string{
|
tabs := []string{
|
||||||
m.renderDetailTabButton(infoTab, "Info"),
|
m.renderDetailTabButton(infoTab, "Info"),
|
||||||
m.renderDetailTabButton(ircTab, "IRC"),
|
m.renderDetailTabButton(ircTab, "IRC"),
|
||||||
|
m.renderDetailTabButton(minerTab, "Miner"),
|
||||||
}
|
}
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
return lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
}
|
}
|
||||||
@@ -132,6 +135,10 @@ func (m Model) renderIRCTab() string {
|
|||||||
return m.ircViewport.View()
|
return m.ircViewport.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) renderMinerTab() string {
|
||||||
|
return m.minerViewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) renderStreamerRows() string {
|
func (m Model) renderStreamerRows() string {
|
||||||
entries := m.visibleStreamers()
|
entries := m.visibleStreamers()
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user