Skip to content

Commit 80ad362

Browse files
authored
Merge pull request #172 from 0xjuanma/feat/improvedgoallinks
feat[reddit]: paced goal-link queue with reddit block recovery
2 parents f2d0c2a + 9e81da7 commit 80ad362

13 files changed

Lines changed: 982 additions & 286 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Changed
1313
- **Caching** — FotMob league page bodies are now cached for 60s and shared across the live, stats, World Cup, and standings views, reducing redundant network calls during quick navigation.
14+
- **Reddit goal-link retrieval** — goal replay links now load one-by-one in the match panel and recover gracefully when Reddit rate-limits the app, instead of all attempts failing in a burst.
1415

1516
### Fixed
1617
- **Live matches view** — matches that kicked off before the user's UTC midnight (e.g. evening kickoffs for users in the Americas) are no longer dropped from the Live view.

internal/app/commands.go

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -362,31 +362,52 @@ func fetchStatsMatchDetailsFotmob(client *fotmob.Client, matchID int, useMockDat
362362
}
363363
}
364364

365-
// fetchGoalLinks fetches goal replay links from Reddit for all goals in a match.
366-
// This is called on-demand when match details are loaded/displayed.
367-
// Links are cached persistently to avoid redundant API calls.
365+
// fetchGoalLinks opens a streaming subscription to the reddit queue for all
366+
// goals in `details`. Returns a tea.Cmd that emits a goalLinkStreamMsg
367+
// carrying the result channel; the Update loop stashes the channel and arms
368+
// successive waitForGoalLink reader Cmds at the queue's cadence.
369+
//
370+
// Goals already in the persistent cache surface immediately (they're written
371+
// into the channel by GoalLinksAsync before queueing begins); uncached goals
372+
// arrive at QueueInterval cadence. Each emitted goalLinkMsg carries a single
373+
// result so the UI updates progressively instead of waiting for the entire
374+
// match's goals to resolve.
368375
func fetchGoalLinks(redditClient *reddit.Client, details *api.MatchDetails) tea.Cmd {
369-
return func() tea.Msg {
370-
if redditClient == nil || details == nil {
371-
return goalLinksMsg{matchID: 0, links: nil}
372-
}
376+
if redditClient == nil || details == nil {
377+
return nil
378+
}
373379

374-
goals := buildGoalInfos(details)
375-
if len(goals) == 0 {
376-
return goalLinksMsg{matchID: details.ID, links: nil}
377-
}
380+
goals := buildGoalInfos(details)
381+
if len(goals) == 0 {
382+
return nil
383+
}
378384

379-
// Log the per-goal running scores so the corrected matcher inputs are
380-
// observable in golazo_debug.log without trawling individual search
381-
// queries. Useful for verifying World Cup / national-team retrievals.
382-
redditClient.DebugLog(fmt.Sprintf("fetchGoalLinks: match=%d %s vs %s — %d goals: %s",
383-
details.ID, details.HomeTeam.Name, details.AwayTeam.Name, len(goals),
384-
formatGoalSummary(goals)))
385+
// Log the per-goal running scores so the corrected matcher inputs are
386+
// observable in golazo_debug.log without trawling individual search
387+
// queries. Useful for verifying World Cup / national-team retrievals.
388+
redditClient.DebugLog(fmt.Sprintf("fetchGoalLinks: match=%d %s vs %s — %d goals: %s",
389+
details.ID, details.HomeTeam.Name, details.AwayTeam.Name, len(goals),
390+
formatGoalSummary(goals)))
385391

386-
// Fetch links for all goals (uses cache internally)
387-
links := redditClient.GoalLinks(goals)
392+
matchID := details.ID
393+
results := redditClient.GoalLinksAsync(goals)
388394

389-
return goalLinksMsg{matchID: details.ID, links: links}
395+
return func() tea.Msg {
396+
return goalLinkStreamMsg{matchID: matchID, ch: results}
397+
}
398+
}
399+
400+
// waitForGoalLink returns a tea.Cmd that blocks until the next GoalResult
401+
// arrives on results (or the channel closes), then emits a goalLinkMsg or a
402+
// terminal goalLinksDoneMsg. The Update handler re-arms this Cmd after each
403+
// goalLinkMsg, forming the subscription loop.
404+
func waitForGoalLink(matchID int, results <-chan reddit.GoalResult) tea.Cmd {
405+
return func() tea.Msg {
406+
r, ok := <-results
407+
if !ok {
408+
return goalLinksDoneMsg{matchID: matchID}
409+
}
410+
return goalLinkMsg{matchID: matchID, key: r.Key, link: r.Link}
390411
}
391412
}
392413

internal/app/commands_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
package app
22

33
import (
4+
"io"
5+
"log/slog"
46
"testing"
57
"time"
68

79
"github.com/0xjuanma/golazo/internal/api"
10+
"github.com/0xjuanma/golazo/internal/reddit"
811
)
912

13+
// testLogger returns a slog.Logger that discards output, matching the
14+
// debugLog-disabled path of initLogger. Required because handleGoalLink
15+
// calls m.debugLog which panics with nil m.logger.
16+
func testLogger() *slog.Logger {
17+
return slog.New(slog.NewTextHandler(io.Discard, nil))
18+
}
19+
1020
func TestBuildGoalInfosRunningScore(t *testing.T) {
1121
home := api.Team{ID: 1, Name: "Australia", ShortName: "AUS"}
1222
away := api.Team{ID: 2, Name: "Türkiye", ShortName: "TUR"}
@@ -145,3 +155,61 @@ func TestBuildGoalInfosNilDetails(t *testing.T) {
145155
t.Errorf("expected nil for nil details, got %d goals", len(got))
146156
}
147157
}
158+
159+
// TestHandleGoalLinkMergesSingle exercises the per-goal merge that the
160+
// subscription path relies on: a single goalLinkMsg must be applied to
161+
// m.goalLinks[key] and the reader Cmd must be re-armed against the same
162+
// channel. The behavior under test is the merge inside handleGoalLink — not
163+
// any wrapping fetch logic — so the test wires a channel directly without
164+
// invoking GoalLinksAsync.
165+
func TestHandleGoalLinkMergesSingle(t *testing.T) {
166+
ch := make(chan reddit.GoalResult, 1)
167+
defer close(ch)
168+
169+
m := model{
170+
goalLinks: make(map[reddit.GoalLinkKey]*reddit.GoalLink),
171+
goalLinkChans: map[int]<-chan reddit.GoalResult{42: ch},
172+
logger: testLogger(),
173+
}
174+
175+
link := &reddit.GoalLink{
176+
MatchID: 42,
177+
Minute: 17,
178+
URL: "https://example.com/replay",
179+
Title: "Iran 0 - [1] New Zealand - E. Just 7'",
180+
}
181+
key := reddit.GoalLinkKey{MatchID: 42, Minute: 17}
182+
183+
newModel, cmd := m.handleGoalLink(goalLinkMsg{matchID: 42, key: key, link: link})
184+
mm, ok := newModel.(model)
185+
if !ok {
186+
t.Fatalf("handleGoalLink returned wrong model type: %T", newModel)
187+
}
188+
if got := mm.goalLinks[key]; got != link {
189+
t.Errorf("goalLinks[%+v] = %+v, want %+v", key, got, link)
190+
}
191+
if cmd == nil {
192+
t.Fatal("handleGoalLink returned nil Cmd; expected re-armed waitForGoalLink")
193+
}
194+
}
195+
196+
// TestHandleGoalLinkNotFoundIsRecorded verifies that nil/not-found results
197+
// are still stored in goalLinks so the UI can distinguish "search resolved
198+
// to no match" from "search still pending".
199+
func TestHandleGoalLinkNotFoundIsRecorded(t *testing.T) {
200+
ch := make(chan reddit.GoalResult, 1)
201+
defer close(ch)
202+
203+
m := model{
204+
goalLinks: make(map[reddit.GoalLinkKey]*reddit.GoalLink),
205+
goalLinkChans: map[int]<-chan reddit.GoalResult{99: ch},
206+
logger: testLogger(),
207+
}
208+
key := reddit.GoalLinkKey{MatchID: 99, Minute: 45}
209+
210+
newModel, _ := m.handleGoalLink(goalLinkMsg{matchID: 99, key: key, link: nil})
211+
mm := newModel.(model)
212+
if _, ok := mm.goalLinks[key]; !ok {
213+
t.Errorf("expected nil-link sentinel entry in goalLinks for %+v", key)
214+
}
215+
}

internal/app/messages.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,34 @@ type pollTickMsg struct {
6666
// This allows the "Updating..." spinner to be visible for at least 1 second.
6767
type pollDisplayCompleteMsg struct{}
6868

69-
// goalLinksMsg contains goal replay links fetched from Reddit.
70-
// Sent after searching r/soccer for Media posts matching goal events.
71-
type goalLinksMsg struct {
69+
// goalLinkStreamMsg hands a freshly-opened goal-link subscription channel to
70+
// the Update loop. The handler stashes the channel on the model (keyed by
71+
// matchID) and arms the first reader Cmd. Emitted once per match-details
72+
// load, before any goalLinkMsg for that match.
73+
type goalLinkStreamMsg struct {
74+
matchID int
75+
ch <-chan reddit.GoalResult
76+
}
77+
78+
// goalLinkMsg streams a single goal-link outcome from the reddit queue's
79+
// subscription channel. The match-level goalLinksMsg above remains for
80+
// initial cache-hit batches; goalLinkMsg carries per-goal results that
81+
// arrive at the queue's 30s cadence so the UI can render replay links
82+
// progressively instead of waiting for the entire match's goals to resolve.
83+
// Link is nil when the goal was searched but not found, was dropped because
84+
// Reddit returned ErrBlocked, or hit a transient fetch error.
85+
type goalLinkMsg struct {
86+
matchID int
87+
key reddit.GoalLinkKey
88+
link *reddit.GoalLink
89+
}
90+
91+
// goalLinksDoneMsg signals that the reddit queue's subscription channel for
92+
// a given match has closed — every queued goal has produced exactly one
93+
// goalLinkMsg. Used to stop the subscription tea.Cmd loop without leaking
94+
// goroutines.
95+
type goalLinksDoneMsg struct {
7296
matchID int
73-
links map[reddit.GoalLinkKey]*reddit.GoalLink
7497
}
7598

7699
// standingsMsg contains league standings from API response.

internal/app/model.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@ type model struct {
159159
// Goal replay links from Reddit (keyed by matchID:minute)
160160
goalLinks map[reddit.GoalLinkKey]*reddit.GoalLink
161161

162+
// Active goal-link subscriptions keyed by matchID. The reddit client's
163+
// GoalLinksAsync streams one GoalResult per goal at the queue's cadence;
164+
// the Update loop drives a reader Cmd that re-arms from the same
165+
// channel until it closes. Stored on the model so handleGoalLink can
166+
// re-issue a wait Cmd without losing the channel reference.
167+
goalLinkChans map[int]<-chan reddit.GoalResult
168+
162169
// Logging
163170
logger *slog.Logger
164171
logFile *os.File // kept open for logger lifetime
@@ -280,6 +287,7 @@ func New(useMockData bool, debugMode bool, isDevBuild bool, newVersionAvailable
280287
parser: fotmob.NewLiveUpdateParser(),
281288
redditClient: redditClient,
282289
goalLinks: make(map[reddit.GoalLinkKey]*reddit.GoalLink),
290+
goalLinkChans: make(map[int]<-chan reddit.GoalResult),
283291
logger: logger,
284292
logFile: logFile,
285293
notifier: notify.NewDesktopNotifier(),

internal/app/update.go

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6666
// Route filter matches message to the appropriate list based on current view
6767
return m.handleFilterMatches(msg)
6868

69-
case goalLinksMsg:
70-
return m.handleGoalLinks(msg)
69+
case goalLinkStreamMsg:
70+
return m.handleGoalLinkStream(msg)
71+
72+
case goalLinkMsg:
73+
return m.handleGoalLink(msg)
74+
75+
case goalLinksDoneMsg:
76+
return m.handleGoalLinksDone(msg)
7177

7278
case standingsMsg:
7379
return m.handleStandings(msg)
@@ -1289,37 +1295,55 @@ func max(a, b int) int {
12891295
return b
12901296
}
12911297

1292-
// handleGoalLinks processes goal replay links fetched from Reddit.
1293-
func (m model) handleGoalLinks(msg goalLinksMsg) (tea.Model, tea.Cmd) {
1294-
m.debugLog(fmt.Sprintf("handleGoalLinks called for match %d with %d links", msg.matchID, len(msg.links)))
1295-
if len(msg.links) == 0 {
1296-
m.debugLog(fmt.Sprintf("GoalLinks completed for match %d: no links found", msg.matchID))
1297-
return m, nil
1298-
}
1299-
1300-
m.debugLog(fmt.Sprintf("GoalLinks completed for match %d: processing %d links", msg.matchID, len(msg.links)))
1298+
// handleGoalLinkStream stashes a freshly-opened goal-link subscription
1299+
// channel on the model and arms the first reader Cmd. One stream is tracked
1300+
// per matchID; re-opening for the same match replaces the previous channel
1301+
// (the old reader Cmd will receive a closed-channel signal next read and
1302+
// exit cleanly via goalLinksDoneMsg).
1303+
func (m model) handleGoalLinkStream(msg goalLinkStreamMsg) (tea.Model, tea.Cmd) {
1304+
if m.goalLinkChans == nil {
1305+
m.goalLinkChans = make(map[int]<-chan reddit.GoalResult)
1306+
}
1307+
m.goalLinkChans[msg.matchID] = msg.ch
1308+
m.debugLog(fmt.Sprintf("goalLinkStream: opened subscription for match %d", msg.matchID))
1309+
return m, waitForGoalLink(msg.matchID, msg.ch)
1310+
}
13011311

1302-
// Merge new links into the goal links map
1312+
// handleGoalLink merges a single goal-link result into the model's
1313+
// goalLinks map and re-arms the reader Cmd against the same stream. This is
1314+
// where the per-goal behavior change lives: each link is applied
1315+
// individually so the UI re-renders progressively at the queue's cadence.
1316+
func (m model) handleGoalLink(msg goalLinkMsg) (tea.Model, tea.Cmd) {
13031317
if m.goalLinks == nil {
13041318
m.goalLinks = make(map[reddit.GoalLinkKey]*reddit.GoalLink)
13051319
}
13061320

1307-
validLinks := 0
1308-
failedLinks := 0
1309-
1310-
for key, link := range msg.links {
1311-
m.goalLinks[key] = link
1312-
if link != nil && link.URL != "" && link.URL != "__NOT_FOUND__" {
1313-
validLinks++
1314-
m.debugLog(fmt.Sprintf("Cached goal link: %d:%d → %s (post: %s)", key.MatchID, key.Minute, link.URL, link.PostURL))
1315-
} else if link != nil && link.URL == "__NOT_FOUND__" {
1316-
failedLinks++
1317-
m.debugLog(fmt.Sprintf("No link found: %d:%d", key.MatchID, key.Minute))
1318-
}
1321+
if msg.link != nil && msg.link.URL != "" && msg.link.URL != reddit.NotFoundMarker {
1322+
m.goalLinks[msg.key] = msg.link
1323+
m.debugLog(fmt.Sprintf("goalLink: match=%d %d:%d → %s",
1324+
msg.matchID, msg.key.MatchID, msg.key.Minute, msg.link.URL))
1325+
} else {
1326+
// Record nil/not-found so the UI knows the search resolved (vs.
1327+
// pending) without rendering a broken link.
1328+
m.goalLinks[msg.key] = msg.link
1329+
m.debugLog(fmt.Sprintf("goalLink: match=%d %d:%d → no link",
1330+
msg.matchID, msg.key.MatchID, msg.key.Minute))
13191331
}
13201332

1321-
m.debugLog(fmt.Sprintf("Goal link batch complete: %d valid, %d failed", validLinks, failedLinks))
1333+
ch, ok := m.goalLinkChans[msg.matchID]
1334+
if !ok {
1335+
// Stream was torn down (e.g., user navigated away) — don't re-arm.
1336+
return m, nil
1337+
}
1338+
return m, waitForGoalLink(msg.matchID, ch)
1339+
}
13221340

1341+
// handleGoalLinksDone removes the closed subscription channel from the
1342+
// model. Emitted by waitForGoalLink when the reddit queue's result channel
1343+
// for this match has been fully drained.
1344+
func (m model) handleGoalLinksDone(msg goalLinksDoneMsg) (tea.Model, tea.Cmd) {
1345+
delete(m.goalLinkChans, msg.matchID)
1346+
m.debugLog(fmt.Sprintf("goalLinkStream: closed subscription for match %d", msg.matchID))
13231347
return m, nil
13241348
}
13251349

internal/reddit/cache.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ const (
1717
// 7 days keeps the cache file small while covering recent matches.
1818
CacheTTL = 7 * 24 * time.Hour // 7 days
1919
// NotFoundTTL defines how long to cache "not found" results.
20-
// Shorter than CacheTTL since links might appear later.
21-
NotFoundTTL = 5 * time.Minute // 5 minutes
20+
// Shorter than CacheTTL since links might appear later. 1h is a balance:
21+
// long enough to avoid re-hitting Reddit for the same missing goal within
22+
// a single viewing session (queue is paced at 30s/request), short enough
23+
// that late-uploaded videos surface on the next app run.
24+
NotFoundTTL = 1 * time.Hour
2225
// NotFoundMarker is a special URL indicating "searched but not found"
2326
NotFoundMarker = "__NOT_FOUND__"
2427
)

0 commit comments

Comments
 (0)