diff --git a/AGENTS.md b/AGENTS.md index ab7d1391..bbf61ed4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,10 +63,13 @@ proto/ buf config + gRPC defs for the sidecar — core, not dea ## Platform gotchas -- **Twitch = Android device-code OAuth over direct HTTP.** Never send a - `Client-Integrity` header — adding it is a self-inflicted integrity wall. Do - not re-implement logged-out catalog scraping; `/drops/campaigns` while logged - out is just a login wall. +- **Twitch clients: Android (legacy sessions) and TV (device-code logins since + v1.4.0) over direct HTTP.** Never send a `Client-Integrity` header on the + Android or TV client; adding it there is a self-inflicted integrity wall. The + `Client-Integrity` header is allowed ONLY on the web-client profile + (browser-captured sessions), where Twitch requires it. Do not re-implement + logged-out catalog scraping; `/drops/campaigns` while logged out is just a + login wall. - **Twitch credit beacon is gated by `HeartbeatInterval`** (1 minute credited per beacon), so anything above 60s under-credits. Keep it at 60s. Kick is immune. diff --git a/CLAUDE.md b/CLAUDE.md index 6dca36d7..60d19483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,11 @@ the emoji-section + bold-subject format above. ## Platform gotchas -- **Twitch = Android device-code OAuth over direct HTTP.** Never send a - `Client-Integrity` header; adding it was a self-inflicted integrity wall. +- **Twitch clients: Android (legacy sessions) and TV (device-code logins since + v1.4.0) over direct HTTP.** Never send a `Client-Integrity` header on the + Android or TV client; adding it there was a self-inflicted integrity wall. + The `Client-Integrity` header is allowed ONLY on the web-client profile + (browser-captured sessions, rangermix-style), where Twitch requires it. Don't re-implement logged-out catalog scraping either — `/drops/campaigns` while logged out is just a login wall. - **Twitch credit beacon is gated by `HeartbeatInterval`** (1 minute credited diff --git a/README.md b/README.md index b6c6091e..c7e21490 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +> [!WARNING] +> **Drop discovery is limited.** +Twitch no longer lists campaigns for new logins, so GrubDrops only finds drops for games on your whitelist. +

GrubDrops

@@ -221,6 +225,7 @@ default shown. | `GRUB_AUTHBYPASS` | `false` | **Disables all auth** when truthy (`1`/`true`). | | `GRUB_TWITCH_BROWSER` | `0` | `1` routes Twitch through the browser sidecar instead of direct HTTP. Experimental; the default direct-HTTP path is recommended. | | `GRUB_CANARY_INTERVAL` | Health-tab value | Overrides the accrual-canary run cadence (e.g. `6h`); falls back to the Settings ▸ Health value. | +| `GRUB_PIPELINE` | `v2` | `v1` falls back to the legacy watcher process-wide; also overridable per account (kv `pipeline_override:`). | > **"Invalid CSRF token"?** `GRUB_SECURE_COOKIES` must match your scheme: `0` > over plain HTTP, `1` over HTTPS (proxy must forward `X-Forwarded-Proto: https`). diff --git a/cmd/miner/main.go b/cmd/miner/main.go index 0ad00aa6..b6f3eff1 100644 --- a/cmd/miner/main.go +++ b/cmd/miner/main.go @@ -31,6 +31,7 @@ import ( mlog "github.com/aalejandrofer/grubdrops/internal/log" "github.com/aalejandrofer/grubdrops/internal/netutil" "github.com/aalejandrofer/grubdrops/internal/notify" + "github.com/aalejandrofer/grubdrops/internal/pipeline/loop" "github.com/aalejandrofer/grubdrops/internal/platform" "github.com/aalejandrofer/grubdrops/internal/platform/kick" "github.com/aalejandrofer/grubdrops/internal/platform/twitch" @@ -252,6 +253,11 @@ func run() error { } else { twitchBackend = twitch.New() } + // One shared Twitch campaign catalog for the whole process: backends on + // the Android/legacy login publish the full campaign list, TV-login + // backends merge the campaigns Twitch hides from the TV client. + twitchCatalog := twitch.NewCatalog() + twitchBackend.SetCatalog(twitchCatalog) if twitchBrowserEnabled && browserClient != nil { // proxyTransport is nil when no proxy is configured; the browser // backend then dials the Spade beacon direct, same as before. @@ -336,6 +342,16 @@ func run() error { // and the /drops Past + /history views stay empty. claimRecorder := store.NewClaimRecorder(q) + // dropStore is the pipeline v2 per-account drop_state table. Backfill + // seeds it from the v1 in-progress/claims state on every boot so a + // GRUB_PIPELINE=v2 opt-in mid-lifecycle doesn't start from scratch. + dropStore := store.NewDropStateStore(q) + if n, err := store.BackfillDropState(ctx, q, time.Now()); err != nil { + logger.Warn("pipeline v2: drop_state backfill failed", "err", err) + } else if n > 0 { + logger.Info("pipeline v2: drop_state backfilled from v1 state", "rows", n) + } + // Per-account direct-Twitch backends. The direct twitch.Backend holds // per-account state (auth, userID/userLogin caches, its own PubSub // socket), so sharing ONE instance across accounts races their tokens @@ -367,6 +383,7 @@ func run() error { } else { bk = twitch.New() } + bk.SetCatalog(twitchCatalog) twitchBackends[a.ID] = bk return bk, true } @@ -461,6 +478,36 @@ func run() error { forceCollected := loadCollectOverrides(ctx, q) persistedSkips, skipRecorder, skipClearer := loadSkipOverrides(ctx, q) + if pipelineModeFor(ctx, q, a.ID) == "v2" { + prio, err := dropStore.StreamerPriority(ctx, a.ID) + if err != nil { + logger.Warn("pipeline v2: load streamer priority failed", "account", a.ID, "err", err) + } + force := v2ForceChannels(ctx, q, a.ID) + l, err := loop.New(loop.Config{ + AccountID: a.ID, AccountLabel: a.DisplayName, Platform: a.Platform, + Backend: b, Session: sess, + Store: dropStore, History: claimRecorder, Persister: campaignPersister, + Notifier: notifier, + AllowGame: allow, AllowChannel: matchAnyChannel(prio), GameRank: rank, + Games: names, + PriorityMode: priorityMode, + ForceLinked: forceLinked, + StreamerPriority: prio, + ForceWatch: force, + ProgressNotifyStepPct: progressStep, + // Twitch hides drops claimed outside the app once their + // campaign leaves the Inventory; Kick keeps them listed. + ClaimProbe: a.Platform == "twitch", + EnrollDiscovery: enrollDiscoveryFor(a.Platform, sess), + }) + if err == nil { + logger.Info("pipeline v2 enabled for account", "account", a.ID, "platform", a.Platform) + return scheduler.NewEntry(a.ID, l), nil + } + logger.Warn("pipeline v2 unavailable, falling back to v1", "account", a.ID, "err", err) + } + acctLabel := a.DisplayName w := watcher.New(watcher.Config{ AccountID: a.ID, AccountLabel: acctLabel, Platform: a.Platform, @@ -908,6 +955,52 @@ func decodeKickChannels(s platform.Session) []string { return out } +// pipelineModeFor picks v1 or v2 for an account: kv override first, then +// GRUB_PIPELINE, default v2. Set GRUB_PIPELINE=v1 (or a per-account +// override) to fall back to the legacy watcher; build() also falls back +// automatically per-account when loop.New itself fails (e.g. no +// BrowserBackend wired for Kick). +func pipelineModeFor(ctx context.Context, q *gen.Queries, accountID string) string { + if v, err := q.GetSettingString(ctx, store.PipelineOverridePrefix+accountID); err == nil { + if s := string(v); s == "v1" || s == "v2" { + return s + } + } + if os.Getenv("GRUB_PIPELINE") == "v1" { + return "v1" + } + return "v2" +} + +// enrollDiscoveryFor turns on pipeline v2 watch-to-enroll discovery for +// TV-client Twitch sessions: they can't read the campaign list, so they +// find campaigns by watching a drops-enabled channel per whitelisted game +// until Inventory lists what Twitch enrolled them in. Android sessions see +// the list directly and Kick has no such gap, so both stay off. +func enrollDiscoveryFor(platformName string, sess platform.Session) bool { + return platformName == "twitch" && sess.ClientID == twitch.ClientTV +} + +// matchAnyChannel reports whether any campaign channel is one of logins. +// Nil when logins is empty, so the null-game gate stays off. +func matchAnyChannel(logins []string) func([]string) bool { + if len(logins) == 0 { + return nil + } + set := make(map[string]bool, len(logins)) + for _, l := range logins { + set[strings.ToLower(l)] = true + } + return func(chs []string) bool { + for _, c := range chs { + if set[strings.ToLower(c)] { + return true + } + } + return false + } +} + // loadAccountWhitelist materialises the per-account game allow-list // into match + rank closures the watcher consumes, plus the plain // display names (as stored in the games table) that feed @@ -1006,8 +1099,7 @@ func loadAccountChannels(ctx context.Context, q *gen.Queries, accountID string) type forceWatchStore struct{ q *gen.Queries } func (f forceWatchStore) Next(ctx context.Context, accountID string) (watcher.ForceTask, bool) { - v, err := f.q.GetSettingString(ctx, api.ForceWatchEnabledKey(accountID)) - if err != nil || string(v) != "1" { + if !forceWatchEnabled(ctx, f.q, accountID) { return watcher.ForceTask{}, false } rows, err := f.q.ListForceChannels(ctx, accountID) @@ -1017,6 +1109,35 @@ func (f forceWatchStore) Next(ctx context.Context, accountID string) (watcher.Fo return watcher.ForceTask{Channel: rows[0].Channel}, true } +// forceWatchEnabled reports whether the per-account force-watch toggle +// (force_watch: KV flag, set via /accounts/:id/force-watch) is +// on. Shared by v1's forceWatchStore and the v2 wiring so both agree on +// when a configured force-watch channel actually applies. +func forceWatchEnabled(ctx context.Context, q *gen.Queries, accountID string) bool { + v, err := q.GetSettingString(ctx, api.ForceWatchEnabledKey(accountID)) + return err == nil && string(v) == "1" +} + +// v2ForceChannels returns the account's configured force-watch channel +// logins when the force-watch toggle is enabled, or nil otherwise. Used by +// the pipeline v2 branch in build() so a disabled toggle behaves like v1's +// forceWatchStore.Next (no forced channel), instead of always passing the +// configured list. +func v2ForceChannels(ctx context.Context, q *gen.Queries, accountID string) []string { + if !forceWatchEnabled(ctx, q, accountID) { + return nil + } + rows, err := q.ListForceChannels(ctx, accountID) + if err != nil { + return nil + } + var force []string + for _, r := range rows { + force = append(force, r.Channel) + } + return force +} + // parseDuration parses a Go duration string (e.g. "5m", "30s") with a // fallback when the env var is empty or malformed. Matches the pattern // used elsewhere in the codebase for config-by-env. diff --git a/cmd/miner/main_test.go b/cmd/miner/main_test.go index b3eff46f..3c841bfd 100644 --- a/cmd/miner/main_test.go +++ b/cmd/miner/main_test.go @@ -8,10 +8,22 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/aalejandrofer/grubdrops/internal/api" + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/platform/twitch" "github.com/aalejandrofer/grubdrops/internal/store" "github.com/aalejandrofer/grubdrops/internal/store/gen" ) +// TestEnrollDiscoveryFor: watch-to-enroll discovery is for TV-client Twitch +// sessions only; Android (legacy empty ClientID) and Kick stay off. +func TestEnrollDiscoveryFor(t *testing.T) { + assert.True(t, enrollDiscoveryFor("twitch", platform.Session{ClientID: twitch.ClientTV})) + assert.False(t, enrollDiscoveryFor("twitch", platform.Session{}), "Android / legacy session") + assert.False(t, enrollDiscoveryFor("twitch", platform.Session{ClientID: "android"})) + assert.False(t, enrollDiscoveryFor("kick", platform.Session{ClientID: twitch.ClientTV})) +} + func TestLoadAccountChannels(t *testing.T) { ctx := context.Background() db, err := store.Open(ctx, t.TempDir()+"/t.db") @@ -64,3 +76,76 @@ func TestGenerateMasterKey_IsAcceptedByCryptor(t *testing.T) { require.NoError(t, err) assert.NotEqual(t, key, key2, "each keygen must be unique") } + +func TestPipelineModeFor(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, t.TempDir()+"/t.db") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + t.Setenv("GRUB_PIPELINE", "") + assert.Equal(t, "v2", pipelineModeFor(ctx, q, "acc"), "v2 is the default") + t.Setenv("GRUB_PIPELINE", "v1") + assert.Equal(t, "v1", pipelineModeFor(ctx, q, "acc"), "GRUB_PIPELINE=v1 falls back to the legacy watcher") + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: store.PipelineOverridePrefix + "acc", Value: []byte("v2")})) + assert.Equal(t, "v2", pipelineModeFor(ctx, q, "acc"), "per-account override beats env") + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: store.PipelineOverridePrefix + "acc", Value: []byte("junk")})) + assert.Equal(t, "v1", pipelineModeFor(ctx, q, "acc"), "invalid override falls through to env (still v1 here)") +} + +func TestMatchAnyChannel(t *testing.T) { + assert.Nil(t, matchAnyChannel(nil)) + m := matchAnyChannel([]string{"Fav"}) + assert.True(t, m([]string{"x", "fav"})) + assert.False(t, m([]string{"x"})) +} + +// TestForceWatchEnabled proves the extracted helper reads the same +// force_watch: KV flag forceWatchStore.Next checks, so v1 and v2 +// agree on whether force-watch is on for an account. +func TestForceWatchEnabled(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, t.TempDir()+"/t.db") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + assert.False(t, forceWatchEnabled(ctx, q, "acc-1"), "flag unset -> disabled") + + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{ + Key: api.ForceWatchEnabledKey("acc-1"), Value: []byte("1"), + })) + assert.True(t, forceWatchEnabled(ctx, q, "acc-1"), "flag=1 -> enabled") + + assert.False(t, forceWatchEnabled(ctx, q, "acc-2"), "different account unaffected") +} + +// TestV2ForceChannels proves the v2 wiring only surfaces ListForceChannels +// rows when the account's force-watch flag is enabled — matching v1's +// forceWatchStore.Next gate (previously v2 passed the list unconditionally). +func TestV2ForceChannels(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, t.TempDir()+"/t.db") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + now := time.Now().Unix() + _, err = q.CreateAccount(ctx, gen.CreateAccountParams{ + ID: "acc-1", Platform: "kick", DisplayName: "k", + Status: "idle", FingerprintJson: "{}", Enabled: 1, + CreatedAt: now, UpdatedAt: now, + }) + require.NoError(t, err) + require.NoError(t, q.AddForceChannel(ctx, gen.AddForceChannelParams{ + AccountID: "acc-1", Channel: "somechannel", Rank: 0, CreatedAt: now, + })) + + assert.Empty(t, v2ForceChannels(ctx, q, "acc-1"), "flag off -> no force channels even though rows exist") + + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{ + Key: api.ForceWatchEnabledKey("acc-1"), Value: []byte("1"), + })) + assert.Equal(t, []string{"somechannel"}, v2ForceChannels(ctx, q, "acc-1"), "flag on -> rows surfaced") +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c202cc9c..9588529f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes to GrubDrops. ## [Unreleased] +### Added + +- Pipeline v2 (opt-in, `GRUB_PIPELINE=v2` or kv `pipeline_override:`): drop state now comes from each campaign's platform details, so drops claimed on the website or with a lost claim response show as claimed and mining moves on (#50). +- Streamer priority for pipeline v2: per-account priority streamers are watched first, then any live channel in the category (#49). Seeded from the existing null-game channel list. Until the settings editor lands, v2 reads a one-time copy of the null-game channel list taken at upgrade. +- `drop_state` and `account_streamer_priority` tables (migration 0016), with a one-time backfill from claim history, manual marks and ghost-skips. +- Accounts on the Twitch TV login share the full campaign list found by any Android-login account in the same GrubDrops, so campaigns hidden from the TV client (for example behind a global badge campaign) are still found. +- A banner explains that Twitch limits campaign discovery for new (TV) logins and points to the game whitelist. +- Accounts on the Twitch TV login find campaigns by briefly watching a top drops-enabled channel for each whitelisted game when there is nothing else to mine, so Twitch enrolls them and the campaigns appear. (Pipeline v2: 10 minutes per game, each game at most once every 6 hours; dashboard state `discovering`.) (the dashboard shows these accounts as Discovering) + +### Changed + +- Twitch claim code split into status fetch + classification (behaviour unchanged). +- Scheduler reads dashboard snapshots and discoveries through interfaces, so v1 and v2 runners both report state. +- Pipeline v2 is now the default. Set `GRUB_PIPELINE=v1` (or a per-account override) to fall back to the legacy watcher. +- Reloading accounts builds them in parallel, so a slow account no longer holds up the others. + +### Fixed + +- Streamers' own channel campaigns with no game no longer flood the drops page, and campaigns past their end time show as ended. +- Twitch TV-channel discovery now matches a campaign's game by ID first (falling back to the display-name slug only when either ID is missing), so a campaign whose display name doesn't match the live directory's is no longer wrongly skipped. +- The shared Twitch discovery catalog no longer accumulates stale entries from recycled account backends; sources older than the TTL are evicted, not just ignored. +- Idle Kick accounts no longer flood the log with state changes every few seconds. + +### Fixed (pipeline v2 only) + +- Claim failures back off 1m, 5m, 30m and stop after 5 attempts instead of looping forever. +- Kick link-required claims block with a reason instead of retrying every poll. +- Kick required minutes come from the platform, not an invented 120. +- Ghost-skipped drops are re-checked instead of being skipped forever. +- Migrated Kick ghost-skips pick up the campaign's required minutes instead of staying blocked as sub-only; backfilled rows now carry required minutes. +- Manual "mark collected" marks migrate as user claims, not final platform claims. +- A campaign link block lifts on the next sync after the user links, instead of holding for 24h. +- Twitch priority streamers are not probed for restricted campaigns whose allow-list is unknown, so v2 never watches a channel the campaign doesn't credit. +- Twitch drops with no viewer record are no longer read as a definite "not enrolled". +- The Twitch integrity wall shows the account as needing re-auth and stops the loop, like v1. +- Session restarts wait for the previous watch to stop, and a restart on the same channel keeps its PubSub subscription. +- A drop that falls back from claimable resets its claim failure count. +- v2 discovers campaigns for TV-client Twitch accounts (the whitelisted games now reach the session). +- v2 reads TV-account Twitch progress from the inventory, and sends every v2 request under the account's own Twitch client. +- v2 no longer mines subscription-only Twitch drops. +- A Twitch drop claimed outside GrubDrops whose campaign left the inventory is detected with one claim check when watching it stalls, instead of being watched again and again. +- v2 heartbeats appear in the dashboard events feed, like v1's. +- The stall claim check only runs on a drop's second stall, and an empty Twitch claim status counts as a failure, so a blind check can never mark an unenrolled drop claimed. +- Drops already claimed under v1 no longer look unclaimed to v2 when an account switches over; v2 reads the claim history after every sync, not only at startup. +- v2 no longer watches drops from campaigns that need an unlinked account between syncs. +- Channel whitelist edits on the drops page now reach v2 accounts. +- The force-watch on/off switch now applies to v2 accounts. +- A newly added Twitch account that already finished its drops is recognised within a few syncs instead of being watched drop by drop. +- A Twitch claim notification no longer overrides a drop you skipped or that needs a linked account. +- v2 no longer bounces between two channels every few minutes when a channel drops out of Twitch's directory list. + ## [1.4.2] — 2026-09-28 ### Fixed diff --git a/docs/superpowers/plans/2026-09-27-pipeline-v2-core.md b/docs/superpowers/plans/2026-09-27-pipeline-v2-core.md new file mode 100644 index 00000000..a8f63fb1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-pipeline-v2-core.md @@ -0,0 +1,4298 @@ +# Pipeline v2 Core Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless pipeline v2 (platform-authoritative drop state, reconciler, pure planner with streamer priority, claimer, session, per-account loop), selectable per account behind `GRUB_PIPELINE=v2`, with v1 untouched. + +**Architecture:** One `drop_state` row per (account, drop) is the single mining truth. A reconciler folds platform per-campaign detail into it; a pure planner turns state + live channels into a decision (channel + drops served); a session runs accrual on that channel; a claimer turns claimable rows into claimed/blocked. One goroutine per account (the loop) owns every state transition. v1 (`internal/watcher`) is not modified except where noted (scheduler interfaces, a Twitch claim refactor that keeps behaviour identical). + +**Tech Stack:** Go 1.x, SQLite via sqlc + goose migrations, testify. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-09-27-pipeline-v2-design.md` + +**Scope split:** This is plan 1 of 2. Plan 2 (written after this lands) covers the UI: inventory page reading `drop_state`, mark/skip/retry actions via `Loop.Nudge`, streamer-priority editor, per-account pipeline toggle. Plan 1 is usable on its own: v2 mines headlessly, and the existing UI keeps working (manual marks reach v2 through a claims-table bridge on loop start, because the mark handler already triggers a reload). + +**Refinements to the spec made here (recorded in the spec in Task 13):** +- Reconcile runs serially on the loop goroutine (not its own goroutine). Single writer, no result races; session and PubSub events buffer meanwhile. +- Timestamps are INTEGER unix seconds (repo convention), `0` = unset. `block_reason` is `NOT NULL DEFAULT ''`. +- `user_skip` has no auto-expiry (`retry_after = 0`); it is explicit user intent and is cleared only by the user (Plan 2 "retry"). +- Platform interface names: `DropProgressSource`, `DropClaimer`, `ChannelProber` in `internal/platform`. + +## Global Constraints + +- Twitch heartbeat cadence is **fixed at 60s** (`BeatEvery = 60 * time.Second`, not configurable). Anything above 60s under-credits Twitch. +- Never send a `Client-Integrity` header (no Twitch HTTP changes in this plan add headers). +- **Never put `?` or parentheses in a `queries/*.sql` comment.** +- Do not delete or modify `internal/watcher`, the browser sidecar stack, `proto/`, `internal/dockerctl`, `internal/auth/browser`. +- `gofmt -w .` before every commit; `go build ./...` and `go test ./...` green before every commit. +- Every change gets a line in `docs/CHANGELOG.md` under `## [Unreleased]` (done in Task 13 for the whole feature). +- Default pipeline stays `v1`. v2 is enabled only by `GRUB_PIPELINE=v2` or kv `pipeline_override:` = `v2`. +- Accrual/claim change: **no version tag** until live-drop verification on staging (Task 14) passes on Twitch and Kick. +- Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) `. + +## Review Focus + +1. **Platform error mid-reconcile** (Twitch details 500, Kick progress 403): state must not change at all. Pinned in Task 7 (`TestRun_ProgressErrorChangesNothing`). +2. **Stale session events after a channel swap**: a `StreamDown` from the old channel must not put the new channel on cooldown. Pinned in Task 11 (`TestLoop_StaleStreamDownIgnored`). +3. **Twitch drop with `self: null`** (campaign not yet enrolled): must be `eligible`, never `claimed`. Pinned in Task 5 (`TestDropProgress_NullSelfIsUnclaimed`). +4. **Kick reward absent from `/drops/progress`**: never-watched stays `eligible`; absent after accruing goes `blocked:not_enrolled` then re-derives after 1h (no permanent skip). Pinned in Task 2 table cases. +5. **Multi-item Twitch drop** (one drop id repeated across benefit edges): exactly one state row and one claim. Pinned in Task 5 (`TestDropProgress_DedupesMultiItemDrop`) and Task 7 (`TestRun_MultiItemDropOneRow`). + +--- + +### Task 1: Live verification probes (gate) + +Answers spec §9 before anything builds on it. Probes are committed behind the `live` build tag so they never run in CI. + +**Files:** +- Create: `internal/store/live_session_test.go` +- Create: `internal/platform/twitch/live_probe_test.go` +- Create: `internal/platform/kick/live_probe_test.go` +- Modify: `docs/superpowers/specs/2026-09-27-pipeline-v2-design.md` (§9 results) + +**Interfaces:** +- Consumes: `store.Open`, `store.NewCryptor`, `store.NewSessionStore`, `(*SessionStore).Get`; twitch `New()`, `(*discovery).resolveCurrentLogin`, `(*client).gql`, `OpDropCampaignDetails`; kick `New(...)`, `b.api.d.do`, `dropsBase`. +- Produces: nothing used by later tasks except the recorded answers. + +- [ ] **Step 1: Session dump helper** + +```go +//go:build live + +package store + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// TestLive_DumpSession prints one account's decrypted session as JSON. +// Run against a COPY of the DB file: Open runs migrations. Output holds +// live tokens; do not paste it anywhere. +// +// GRUB_DB_PATH=/tmp/miner-copy.db GRUB_MASTER_KEY=... GRUB_LIVE_ACCOUNT=acc_x \ +// go test -tags live -run TestLive_DumpSession -v ./internal/store/ +func TestLive_DumpSession(t *testing.T) { + path, key, acct := os.Getenv("GRUB_DB_PATH"), os.Getenv("GRUB_MASTER_KEY"), os.Getenv("GRUB_LIVE_ACCOUNT") + if path == "" || key == "" || acct == "" { + t.Skip("set GRUB_DB_PATH, GRUB_MASTER_KEY, GRUB_LIVE_ACCOUNT") + } + ctx := context.Background() + db, err := Open(ctx, path) + require.NoError(t, err) + defer db.Close() + c, err := NewCryptor(key) + require.NoError(t, err) + s, ok, err := NewSessionStore(db, gen.New(db), c).Get(ctx, acct) + require.NoError(t, err) + require.True(t, ok, "no session for account") + b, err := json.Marshal(s) + require.NoError(t, err) + t.Logf("%s", b) +} +``` + +- [ ] **Step 2: Twitch probe** + +```go +//go:build live + +package twitch + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// TestLive_DetailsSelf dumps raw DropCampaignDetails for a campaign the +// account is enrolled in (ideally one fully claimed), to confirm +// timeBasedDrops[].self carries isClaimed + currentMinutesWatched. +// +// GRUB_LIVE_TWITCH_TOKEN=... GRUB_LIVE_CAMPAIGN_ID=... \ +// go test -tags live -run TestLive_DetailsSelf -v ./internal/platform/twitch/ +func TestLive_DetailsSelf(t *testing.T) { + tok, camp := os.Getenv("GRUB_LIVE_TWITCH_TOKEN"), os.Getenv("GRUB_LIVE_CAMPAIGN_ID") + if tok == "" || camp == "" { + t.Skip("set GRUB_LIVE_TWITCH_TOKEN, GRUB_LIVE_CAMPAIGN_ID") + } + ctx := context.Background() + b := New() + login, err := b.disc.resolveCurrentLogin(ctx, platform.Session{AccessToken: tok}) + require.NoError(t, err) + var raw json.RawMessage + require.NoError(t, b.c.gql(ctx, tok, OpDropCampaignDetails, + map[string]any{"dropID": camp, "channelLogin": login}, &raw)) + t.Logf("%s", raw) +} +``` + +- [ ] **Step 3: Kick probe** + +```go +//go:build live + +package kick + +import ( + "context" + "encoding/json" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// TestLive_ProgressRaw dumps raw /drops/progress for a Kick account that has +// at least one CLAIMED reward, to learn whether claimed rewards stay listed. +// +// GRUB_LIVE_KICK_SESSION_JSON='{"cookies":{...}}' \ +// go test -tags live -run TestLive_ProgressRaw -v ./internal/platform/kick/ +func TestLive_ProgressRaw(t *testing.T) { + js := os.Getenv("GRUB_LIVE_KICK_SESSION_JSON") + if js == "" { + t.Skip("set GRUB_LIVE_KICK_SESSION_JSON") + } + var s platform.Session + require.NoError(t, json.Unmarshal([]byte(js), &s)) + b := New(nil, nil, "grubdrops-browser-{slug}", 9090, 10*time.Minute) + body, status, err := b.api.d.do(context.Background(), s, http.MethodGet, dropsBase+"/api/v1/drops/progress", nil) + require.NoError(t, err) + t.Logf("status=%d body=%s", status, body) +} +``` + +- [ ] **Step 4: Confirm they compile and skip without env** + +Run: `go vet -tags live ./internal/store/ ./internal/platform/twitch/ ./internal/platform/kick/ && go test -tags live -run 'TestLive_' ./internal/store/ ./internal/platform/twitch/ ./internal/platform/kick/ -v` +Expected: three `--- SKIP` lines, exit 0. If `b.api.d` does not compile (field names differ), read `internal/platform/kick/api.go` for the doer field and adjust. + +- [ ] **Step 5: HUMAN step: run against real accounts** + +Ask the user to run Steps 1 to 3 with real values (DB copy from prod host, one Twitch account with a completed claimed campaign, one Kick account with a claimed reward). Do not run them yourself with prod secrets unless the user hands them over. + +- [ ] **Step 6: Record answers in spec §9** + +Replace §9 body with the two answers, for example: + +```markdown +## 9. Verification results (2026-09-XX) + +1. Twitch `DropCampaignDetails` for an enrolled completed campaign: `self` present = YES/NO. + Sample: `{"id":"...","self":{"isClaimed":true,"currentMinutesWatched":120,...}}` +2. Kick `/drops/progress` keeps claimed rewards listed: YES/NO. +``` + +If Twitch answer is NO: stop and tell the user; §4.2 needs revisiting before Task 5. If Kick answer is NO: no code change; the absent-reward rule in Task 2 already covers it. + +- [ ] **Step 7: Commit** + +```bash +gofmt -w internal/store internal/platform +git add internal/store/live_session_test.go internal/platform/twitch/live_probe_test.go internal/platform/kick/live_probe_test.go docs/superpowers/specs/2026-09-27-pipeline-v2-design.md +git commit -m "test(pipeline-v2): live probes for Twitch details self and Kick claimed retention + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 2: Drop state model (`dropstate`) + +**Files:** +- Create: `internal/pipeline/dropstate/dropstate.go` +- Test: `internal/pipeline/dropstate/dropstate_test.go` + +**Interfaces:** +- Consumes: nothing (stdlib `time` only). +- Produces: + - `type Status string` with `Eligible, Accruing, Claimable, Claimed, Blocked` + - `type Reason string` with `NoReason, NeedsLink, ClaimFailed, SubOnly, NoChannels, Expired, NotEnrolled, UserSkip` + - `type Source string` with `FromPlatform, FromUser` + - `type Row struct { AccountID, DropID, CampaignID, Platform string; Status Status; Reason Reason; Minutes, Required int; Source Source; FailCount int; RetryAfter, SyncedAt, UpdatedAt time.Time }`, `func (r Row) IsZero() bool` + - `type Observation struct { Known, Unmineable, Claimed bool; Minutes, Required int }` + - `func Apply(prev Row, obs Observation, now time.Time) Row` + - `func ClaimOK(prev Row, now time.Time) Row`, `func ClaimNeedsLink(prev Row, now time.Time) Row`, `func ClaimFailedAttempt(prev Row, now time.Time) Row` + - `func MarkCollected(prev Row, now time.Time) Row`, `func Skip(prev Row, now time.Time) Row`, `func Retry(prev Row, now time.Time) Row`, `func Expire(prev Row, now time.Time) Row`, `func BlockLink(prev Row, now time.Time) Row` + - `func Mineable(r Row) bool`, `func ReadyToClaim(r Row, now time.Time) bool` + - consts `RetryNeedsLink=24h, RetryClaimFailed=6h, RetryNotEnrolled=1h, RetrySubOnly=24h, RetryExpired=24h, MaxClaimFailures=5` + +- [ ] **Step 1: Write the failing tests** + +```go +package dropstate + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var t0 = time.Unix(1_000_000, 0) + +func base() Row { + return Row{AccountID: "a", DropID: "d", CampaignID: "c", Platform: "twitch", Source: FromPlatform} +} + +func row(s Status, r Reason, min, req int) Row { + x := base() + x.Status, x.Reason, x.Minutes, x.Required = s, r, min, req + return x +} + +func TestApply(t *testing.T) { + userClaimed := row(Claimed, NoReason, 0, 60) + userClaimed.Source = FromUser + notEnrolled := row(Blocked, NotEnrolled, 10, 60) + notEnrolled.RetryAfter = t0.Add(time.Hour) + skipped := row(Blocked, UserSkip, 0, 60) + skipped.Source = FromUser + needsLink := row(Blocked, NeedsLink, 60, 60) + needsLink.RetryAfter = t0.Add(RetryNeedsLink) + + cases := []struct { + name string + prev Row + obs Observation + at time.Time + want Status + reason Reason + src Source + }{ + {"new drop, platform silent -> eligible", base(), Observation{Required: 60}, t0, Eligible, NoReason, FromPlatform}, + {"new drop, platform silent, zero required -> sub_only", base(), Observation{}, t0, Blocked, SubOnly, FromPlatform}, + {"known progress -> accruing", row(Eligible, NoReason, 0, 60), Observation{Known: true, Minutes: 10, Required: 60}, t0, Accruing, NoReason, FromPlatform}, + {"known full -> claimable", row(Accruing, NoReason, 50, 60), Observation{Known: true, Minutes: 60, Required: 60}, t0, Claimable, NoReason, FromPlatform}, + {"known claimed -> claimed", row(Accruing, NoReason, 10, 60), Observation{Known: true, Claimed: true, Minutes: 60, Required: 60}, t0, Claimed, NoReason, FromPlatform}, + {"platform claimed never demoted", row(Claimed, NoReason, 60, 60), Observation{Known: true, Minutes: 0, Required: 60}, t0, Claimed, NoReason, FromPlatform}, + {"user mark kept while platform silent", userClaimed, Observation{Required: 60}, t0, Claimed, NoReason, FromUser}, + {"user mark overwritten by definite platform answer", userClaimed, Observation{Known: true, Minutes: 5, Required: 60}, t0, Accruing, NoReason, FromPlatform}, + {"accruing then platform silent -> not_enrolled", row(Accruing, NoReason, 10, 60), Observation{Required: 60}, t0, Blocked, NotEnrolled, FromPlatform}, + {"not_enrolled held inside window", notEnrolled, Observation{Required: 60}, t0, Blocked, NotEnrolled, FromPlatform}, + {"not_enrolled re-derived after window", notEnrolled, Observation{Required: 60}, t0.Add(2 * time.Hour), Accruing, NoReason, FromPlatform}, + {"unmineable placeholder -> not_enrolled", base(), Observation{Known: true, Unmineable: true}, t0, Blocked, NotEnrolled, FromPlatform}, + {"user skip survives progress", skipped, Observation{Known: true, Minutes: 30, Required: 60}, t0, Blocked, UserSkip, FromUser}, + {"claimed beats user skip", skipped, Observation{Known: true, Claimed: true, Minutes: 60, Required: 60}, t0, Claimed, NoReason, FromPlatform}, + {"needs_link held inside window", needsLink, Observation{Known: true, Minutes: 60, Required: 60}, t0, Blocked, NeedsLink, FromPlatform}, + {"needs_link re-derived after window", needsLink, Observation{Known: true, Minutes: 60, Required: 60}, t0.Add(25 * time.Hour), Claimable, NoReason, FromPlatform}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Apply(tc.prev, tc.obs, tc.at) + assert.Equal(t, tc.want, got.Status) + assert.Equal(t, tc.reason, got.Reason) + assert.Equal(t, tc.src, got.Source) + assert.Equal(t, "d", got.DropID, "identity preserved") + }) + } +} + +func TestApply_PlatformClaimedKeepsHighestMinutes(t *testing.T) { + got := Apply(row(Claimed, NoReason, 60, 60), Observation{Known: true, Minutes: 0, Required: 60}, t0) + assert.Equal(t, 60, got.Minutes) +} + +func TestApply_KeepsClaimBackoff(t *testing.T) { + r := ClaimFailedAttempt(row(Claimable, NoReason, 60, 60), t0) + got := Apply(r, Observation{Known: true, Minutes: 60, Required: 60}, t0.Add(10*time.Second)) + assert.Equal(t, Claimable, got.Status) + assert.Equal(t, r.RetryAfter, got.RetryAfter, "sync must not reset claim backoff") + assert.Equal(t, 1, got.FailCount) +} + +func TestClaimFailedAttempt_Ladder(t *testing.T) { + r := row(Claimable, NoReason, 60, 60) + for _, wait := range []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 30 * time.Minute} { + r = ClaimFailedAttempt(r, t0) + require.Equal(t, Claimable, r.Status) + require.Equal(t, t0.Add(wait), r.RetryAfter) + require.False(t, ReadyToClaim(r, t0)) + require.True(t, ReadyToClaim(r, t0.Add(wait))) + } + r = ClaimFailedAttempt(r, t0) + assert.Equal(t, Blocked, r.Status) + assert.Equal(t, ClaimFailed, r.Reason) + assert.Equal(t, t0.Add(RetryClaimFailed), r.RetryAfter) +} + +func TestClaimOutcomes(t *testing.T) { + ok := ClaimOK(row(Claimable, NoReason, 60, 60), t0) + assert.Equal(t, Claimed, ok.Status) + assert.Equal(t, FromPlatform, ok.Source) + link := ClaimNeedsLink(row(Claimable, NoReason, 60, 60), t0) + assert.Equal(t, Blocked, link.Status) + assert.Equal(t, NeedsLink, link.Reason) + assert.Equal(t, t0.Add(RetryNeedsLink), link.RetryAfter) +} + +func TestUserActions(t *testing.T) { + m := MarkCollected(row(Accruing, NoReason, 10, 60), t0) + assert.Equal(t, Claimed, m.Status) + assert.Equal(t, FromUser, m.Source) + + s := Skip(row(Accruing, NoReason, 10, 60), t0) + assert.Equal(t, Blocked, s.Status) + assert.Equal(t, UserSkip, s.Reason) + assert.True(t, s.RetryAfter.IsZero(), "user_skip never auto-expires") + + failed := row(Blocked, ClaimFailed, 60, 60) + failed.FailCount = 5 + r := Retry(failed, t0) + assert.Equal(t, Claimable, r.Status) + assert.Equal(t, 0, r.FailCount) +} + +func TestExpireAndBlockLink_LeaveClaimedAlone(t *testing.T) { + c := row(Claimed, NoReason, 60, 60) + assert.Equal(t, Claimed, Expire(c, t0).Status) + assert.Equal(t, Claimed, BlockLink(c, t0).Status) + assert.Equal(t, Expired, Expire(row(Accruing, NoReason, 10, 60), t0).Reason) + assert.Equal(t, NeedsLink, BlockLink(row(Eligible, NoReason, 0, 60), t0).Reason) + sk := row(Blocked, UserSkip, 0, 60) + assert.Equal(t, UserSkip, BlockLink(sk, t0).Reason) +} + +func TestMineable(t *testing.T) { + assert.True(t, Mineable(row(Eligible, NoReason, 0, 60))) + assert.True(t, Mineable(row(Accruing, NoReason, 1, 60))) + assert.False(t, Mineable(row(Claimable, NoReason, 60, 60))) + assert.False(t, Mineable(row(Blocked, NotEnrolled, 0, 60))) + assert.False(t, Mineable(Row{})) +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/pipeline/dropstate/ -v` +Expected: FAIL, `undefined: Row` (package has no source yet). + +- [ ] **Step 3: Implement** + +```go +// Package dropstate is the per-(account, drop) state model for pipeline v2. +// Every transition is a pure function of (previous row, input, now) so the +// rules are table-testable and the account loop is the only writer. +package dropstate + +import "time" + +type Status string + +const ( + Eligible Status = "eligible" + Accruing Status = "accruing" + Claimable Status = "claimable" + Claimed Status = "claimed" + Blocked Status = "blocked" +) + +type Reason string + +const ( + NoReason Reason = "" + NeedsLink Reason = "needs_link" + ClaimFailed Reason = "claim_failed" + SubOnly Reason = "sub_only" + NoChannels Reason = "no_channels" + Expired Reason = "expired" + NotEnrolled Reason = "not_enrolled" + UserSkip Reason = "user_skip" +) + +type Source string + +const ( + FromPlatform Source = "platform" + FromUser Source = "user" +) + +// Retry windows for blocked rows. user_skip never auto-expires. +const ( + RetryNeedsLink = 24 * time.Hour + RetryClaimFailed = 6 * time.Hour + RetryNotEnrolled = time.Hour + RetrySubOnly = 24 * time.Hour + RetryExpired = 24 * time.Hour + MaxClaimFailures = 5 +) + +var claimBackoff = []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute} + +type Row struct { + AccountID string + DropID string + CampaignID string + Platform string + Status Status + Reason Reason + Minutes int + Required int + Source Source + FailCount int + RetryAfter time.Time + SyncedAt time.Time + UpdatedAt time.Time +} + +// IsZero reports a row that has never been stored or derived. +func (r Row) IsZero() bool { return r.Status == "" } + +// Observation is what the platform said about one drop in one sync. +type Observation struct { + // Known is true when the platform returned a definite record for the + // drop. False means it said nothing, which proves nothing. + Known bool + // Unmineable marks drops the platform cannot track (scrape placeholders). + Unmineable bool + Claimed bool + Minutes int + Required int +} + +func derive(minutes, required int) (Status, Reason) { + switch { + case required <= 0: + return Blocked, SubOnly + case minutes >= required: + return Claimable, NoReason + case minutes > 0: + return Accruing, NoReason + default: + return Eligible, NoReason + } +} + +func retryFor(reason Reason, now time.Time) time.Time { + switch reason { + case NeedsLink: + return now.Add(RetryNeedsLink) + case ClaimFailed: + return now.Add(RetryClaimFailed) + case NotEnrolled, NoChannels: + return now.Add(RetryNotEnrolled) + case SubOnly: + return now.Add(RetrySubOnly) + case Expired: + return now.Add(RetryExpired) + } + return time.Time{} +} + +func (r Row) with(s Status, reason Reason, now time.Time) Row { + r.Status, r.Reason, r.UpdatedAt = s, reason, now + r.RetryAfter = retryFor(reason, now) + return r +} + +// Apply folds one platform observation into the previous row. The caller +// seeds identity fields (AccountID, DropID, CampaignID, Platform) on a new row. +func Apply(prev Row, obs Observation, now time.Time) Row { + r := prev + if obs.Known && !obs.Unmineable { + r.Minutes, r.Required, r.SyncedAt = obs.Minutes, obs.Required, now + } + // A platform-confirmed claim is final. + if prev.Status == Claimed && prev.Source == FromPlatform { + if r.Minutes < prev.Minutes { + r.Minutes = prev.Minutes + } + return r + } + if !obs.Known { + switch { + case prev.IsZero(): + r.Required = obs.Required + r.Source = FromPlatform + st, rs := derive(0, obs.Required) + return r.with(st, rs, now) + case (prev.Status == Accruing || prev.Status == Claimable) && prev.Source == FromPlatform: + return r.with(Blocked, NotEnrolled, now) + case prev.Status == Blocked && prev.Reason == NotEnrolled && !now.Before(prev.RetryAfter): + st, rs := derive(prev.Minutes, prev.Required) + return r.with(st, rs, now) + } + return r + } + if obs.Unmineable { + if prev.Status == Claimed { + return r + } + r.Source = FromPlatform + return r.with(Blocked, NotEnrolled, now) + } + if obs.Claimed { + r.Source, r.FailCount = FromPlatform, 0 + return r.with(Claimed, NoReason, now) + } + if prev.Status == Blocked && prev.Reason == UserSkip { + return r + } + if prev.Status == Blocked && (prev.Reason == NeedsLink || prev.Reason == ClaimFailed) && now.Before(prev.RetryAfter) { + return r + } + st, rs := derive(r.Minutes, r.Required) + if st == Claimable && prev.Status == Claimable && now.Before(prev.RetryAfter) { + return r // keep claim backoff + } + if prev.Status == Blocked { + r.FailCount = 0 + } + r.Source = FromPlatform + return r.with(st, rs, now) +} + +// ClaimOK records a successful or already-claimed claim. +func ClaimOK(prev Row, now time.Time) Row { + r := prev.with(Claimed, NoReason, now) + r.Source, r.FailCount = FromPlatform, 0 + return r +} + +// ClaimNeedsLink blocks a drop whose claim needs an external account link. +func ClaimNeedsLink(prev Row, now time.Time) Row { + r := prev.with(Blocked, NeedsLink, now) + r.Source = FromPlatform + return r +} + +// ClaimFailedAttempt counts a failed claim: backoff 1m, 5m, 30m, then +// blocked:claim_failed after MaxClaimFailures. +func ClaimFailedAttempt(prev Row, now time.Time) Row { + r := prev + r.FailCount++ + r.UpdatedAt = now + if r.FailCount >= MaxClaimFailures { + return r.with(Blocked, ClaimFailed, now) + } + i := r.FailCount - 1 + if i >= len(claimBackoff) { + i = len(claimBackoff) - 1 + } + r.RetryAfter = now.Add(claimBackoff[i]) + return r +} + +// MarkCollected is the user asserting the drop is claimed. +func MarkCollected(prev Row, now time.Time) Row { + r := prev.with(Claimed, NoReason, now) + r.Source = FromUser + return r +} + +// Skip is the user excluding a drop from mining until they retry it. +func Skip(prev Row, now time.Time) Row { + r := prev.with(Blocked, UserSkip, now) + r.Source = FromUser + return r +} + +// Retry clears any block and re-derives from the last known minutes. +func Retry(prev Row, now time.Time) Row { + st, rs := derive(prev.Minutes, prev.Required) + r := prev.with(st, rs, now) + r.FailCount, r.Source = 0, FromPlatform + return r +} + +// Expire blocks an unclaimed drop whose campaign has ended. +func Expire(prev Row, now time.Time) Row { + if prev.Status == Claimed { + return prev + } + return prev.with(Blocked, Expired, now) +} + +// BlockLink blocks a drop whose campaign needs an unlinked external account. +func BlockLink(prev Row, now time.Time) Row { + if prev.Status == Claimed || (prev.Status == Blocked && prev.Reason == UserSkip) { + return prev + } + return prev.with(Blocked, NeedsLink, now) +} + +// Mineable reports whether watching can still advance the drop. +func Mineable(r Row) bool { return r.Status == Eligible || r.Status == Accruing } + +// ReadyToClaim reports a claimable row whose backoff has elapsed. +func ReadyToClaim(r Row, now time.Time) bool { + return r.Status == Claimable && !now.Before(r.RetryAfter) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/pipeline/dropstate/ -v` +Expected: PASS, all cases. + +- [ ] **Step 5: Revert-proof check** + +Temporarily change the `prev.Status == Claimed && prev.Source == FromPlatform` early return to `false &&`. Run tests: `platform claimed never demoted` must FAIL. Restore. + +- [ ] **Step 6: Commit** + +```bash +gofmt -w internal/pipeline +git add internal/pipeline/dropstate +git commit -m "feat(pipeline-v2): per-drop state model with pure transitions + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 3: `drop_state` + streamer priority storage + +**Files:** +- Create: `internal/store/migrations/0016_pipeline_v2.sql` +- Create: `internal/store/queries/drop_state.sql` +- Create: `internal/store/queries/streamer_priority.sql` +- Regenerate: `internal/store/gen/*` (sqlc) +- Create: `internal/store/dropstate_store.go` +- Test: `internal/store/dropstate_store_test.go` + +**Interfaces:** +- Consumes: `dropstate.Row` and constants (Task 2). +- Produces: + - `type DropStateStore struct{ Q *gen.Queries }`, `func NewDropStateStore(q *gen.Queries) *DropStateStore` + - `func (s *DropStateStore) List(ctx context.Context, accountID string) ([]dropstate.Row, error)` + - `func (s *DropStateStore) Upsert(ctx context.Context, r dropstate.Row) error` + - `func (s *DropStateStore) StreamerPriority(ctx context.Context, accountID string) ([]string, error)` + - gen: `ListDropStates`, `UpsertDropState`, `ListStreamerPriority`, models `gen.DropState`, `gen.UpsertDropStateParams` + +- [ ] **Step 1: Migration** + +```sql +-- +goose Up +-- +goose StatementBegin +-- Pipeline v2: one authoritative mining state row per account and drop. +-- Timestamps are unix seconds, 0 means unset. +CREATE TABLE drop_state ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + drop_id TEXT NOT NULL, + campaign_id TEXT NOT NULL, + platform TEXT NOT NULL, + status TEXT NOT NULL, + block_reason TEXT NOT NULL DEFAULT '', + minutes INTEGER NOT NULL DEFAULT 0, + required INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'platform', + fail_count INTEGER NOT NULL DEFAULT 0, + retry_after INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (account_id, drop_id) +); +CREATE INDEX idx_drop_state_status ON drop_state(account_id, status); + +-- Ordered per-account priority streamers. Seeded from account_channels, +-- the null-game channel whitelist, which is the same concept. +CREATE TABLE account_streamer_priority ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + login TEXT NOT NULL, + rank INTEGER NOT NULL, + PRIMARY KEY (account_id, login) +); +CREATE INDEX idx_account_streamer_priority_acct ON account_streamer_priority(account_id, rank); + +INSERT INTO account_streamer_priority (account_id, platform, login, rank) +SELECT ac.account_id, a.platform, ac.channel, ac.rank +FROM account_channels ac +JOIN accounts a ON a.id = ac.account_id; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_account_streamer_priority_acct; +DROP TABLE IF EXISTS account_streamer_priority; +DROP INDEX IF EXISTS idx_drop_state_status; +DROP TABLE IF EXISTS drop_state; +-- +goose StatementEnd +``` + +- [ ] **Step 2: Queries** + +`internal/store/queries/drop_state.sql`: + +```sql +-- name: ListDropStates :many +SELECT * FROM drop_state WHERE account_id = ? ORDER BY campaign_id, drop_id; + +-- name: UpsertDropState :exec +INSERT INTO drop_state ( + account_id, drop_id, campaign_id, platform, status, block_reason, + minutes, required, source, fail_count, retry_after, synced_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, drop_id) DO UPDATE SET + campaign_id = excluded.campaign_id, + platform = excluded.platform, + status = excluded.status, + block_reason = excluded.block_reason, + minutes = excluded.minutes, + required = excluded.required, + source = excluded.source, + fail_count = excluded.fail_count, + retry_after = excluded.retry_after, + synced_at = excluded.synced_at, + updated_at = excluded.updated_at; +``` + +`internal/store/queries/streamer_priority.sql`: + +```sql +-- name: ListStreamerPriority :many +SELECT login, rank FROM account_streamer_priority +WHERE account_id = ? +ORDER BY rank ASC, login ASC; +``` + +- [ ] **Step 3: Generate** + +Run: `cd internal/store && sqlc generate && cd ../.. && go build ./...` +Expected: new files `internal/store/gen/drop_state.sql.go`, `streamer_priority.sql.go`; `models.go` gains `DropState` and `AccountStreamerPriority`; build OK. Open `gen/drop_state.sql.go` and confirm `UpsertDropStateParams` field names match Step 5 below (`AccountID, DropID, CampaignID, Platform, Status, BlockReason, Minutes, Required, Source, FailCount, RetryAfter, SyncedAt, UpdatedAt`, integers as `int64`). If sqlc named anything differently, use its names in Step 5. + +- [ ] **Step 4: Failing store test** + +```go +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +func seedAccount(t *testing.T, q *gen.Queries, id, platform string) { + t.Helper() + now := time.Now().Unix() + _, err := q.CreateAccount(context.Background(), gen.CreateAccountParams{ + ID: id, Platform: platform, DisplayName: id, + Status: "idle", FingerprintJson: "{}", Enabled: 1, + CreatedAt: now, UpdatedAt: now, + }) + require.NoError(t, err) +} + +func TestDropStateStore_RoundTrip(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "twitch") + s := NewDropStateStore(q) + + at := time.Unix(1_700_000_000, 0) + in := dropstate.Row{ + AccountID: "acc-1", DropID: "d1", CampaignID: "c1", Platform: "twitch", + Status: dropstate.Blocked, Reason: dropstate.ClaimFailed, + Minutes: 60, Required: 60, Source: dropstate.FromPlatform, FailCount: 5, + RetryAfter: at.Add(time.Hour), SyncedAt: at, UpdatedAt: at, + } + require.NoError(t, s.Upsert(ctx, in)) + got, err := s.List(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, in, got[0]) + + in.Status, in.Reason, in.RetryAfter = dropstate.Claimed, dropstate.NoReason, time.Time{} + require.NoError(t, s.Upsert(ctx, in)) + got, err = s.List(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, got, 1, "upsert must not duplicate") + assert.Equal(t, dropstate.Claimed, got[0].Status) + assert.True(t, got[0].RetryAfter.IsZero(), "0 maps back to zero time") +} + +func TestDropStateStore_StreamerPrioritySeededFromAccountChannels(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "kick") + s := NewDropStateStore(q) + + // Migration already ran; insert straight into the new table like the + // seed does, then read ordered by rank. + _, err := db.ExecContext(ctx, `INSERT INTO account_streamer_priority (account_id, platform, login, rank) VALUES + ('acc-1','kick','zeta',1), ('acc-1','kick','alpha',0)`) + require.NoError(t, err) + got, err := s.StreamerPriority(ctx, "acc-1") + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "zeta"}, got) +} +``` + +Run: `go test ./internal/store/ -run DropStateStore -v` +Expected: FAIL, `undefined: NewDropStateStore`. + +- [ ] **Step 5: Implement store** + +```go +package store + +import ( + "context" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// DropStateStore persists pipeline v2 drop_state rows and reads the +// per-account streamer priority list. +type DropStateStore struct { + Q *gen.Queries +} + +func NewDropStateStore(q *gen.Queries) *DropStateStore { return &DropStateStore{Q: q} } + +func dsUnix(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +func dsTime(v int64) time.Time { + if v == 0 { + return time.Time{} + } + return time.Unix(v, 0) +} + +func (s *DropStateStore) List(ctx context.Context, accountID string) ([]dropstate.Row, error) { + rows, err := s.Q.ListDropStates(ctx, accountID) + if err != nil { + return nil, err + } + out := make([]dropstate.Row, 0, len(rows)) + for _, r := range rows { + out = append(out, dropstate.Row{ + AccountID: r.AccountID, + DropID: r.DropID, + CampaignID: r.CampaignID, + Platform: r.Platform, + Status: dropstate.Status(r.Status), + Reason: dropstate.Reason(r.BlockReason), + Minutes: int(r.Minutes), + Required: int(r.Required), + Source: dropstate.Source(r.Source), + FailCount: int(r.FailCount), + RetryAfter: dsTime(r.RetryAfter), + SyncedAt: dsTime(r.SyncedAt), + UpdatedAt: dsTime(r.UpdatedAt), + }) + } + return out, nil +} + +func (s *DropStateStore) Upsert(ctx context.Context, r dropstate.Row) error { + return s.Q.UpsertDropState(ctx, gen.UpsertDropStateParams{ + AccountID: r.AccountID, + DropID: r.DropID, + CampaignID: r.CampaignID, + Platform: r.Platform, + Status: string(r.Status), + BlockReason: string(r.Reason), + Minutes: int64(r.Minutes), + Required: int64(r.Required), + Source: string(r.Source), + FailCount: int64(r.FailCount), + RetryAfter: dsUnix(r.RetryAfter), + SyncedAt: dsUnix(r.SyncedAt), + UpdatedAt: dsUnix(r.UpdatedAt), + }) +} + +func (s *DropStateStore) StreamerPriority(ctx context.Context, accountID string) ([]string, error) { + rows, err := s.Q.ListStreamerPriority(ctx, accountID) + if err != nil { + return nil, err + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Login) + } + return out, nil +} +``` + +- [ ] **Step 6: Migration seed test** + +Append to `dropstate_store_test.go`. It proves the `INSERT ... SELECT` seed ran by re-running goose Down/Up is overkill; instead assert on an account_channels row inserted BEFORE the migration is impossible here, so test the seed SQL directly: + +```go +func TestMigration0016_SeedCopiesAccountChannels(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "kick") + require.NoError(t, q.AddAccountChannel(ctx, gen.AddAccountChannelParams{AccountID: "acc-1", Channel: "oilrats", Rank: 0})) + // Re-run the seed statement from 0016 exactly. + _, err := db.ExecContext(ctx, `INSERT INTO account_streamer_priority (account_id, platform, login, rank) + SELECT ac.account_id, a.platform, ac.channel, ac.rank FROM account_channels ac JOIN accounts a ON a.id = ac.account_id`) + require.NoError(t, err) + got, err := NewDropStateStore(q).StreamerPriority(ctx, "acc-1") + require.NoError(t, err) + assert.Equal(t, []string{"oilrats"}, got) +} +``` + +- [ ] **Step 7: Run all store tests** + +Run: `go test ./internal/store/ -v 2>&1 | tail -20` +Expected: PASS (existing tests unaffected by the new migration). + +- [ ] **Step 8: Commit** + +```bash +gofmt -w internal/store +git add internal/store +git commit -m "feat(pipeline-v2): drop_state and streamer priority tables + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 4: One-shot backfill from v1 state + +**Files:** +- Modify: `internal/store/queries/drop_state.sql` (append two queries) +- Regenerate: `internal/store/gen/*` +- Create: `internal/store/dropstate_backfill.go` +- Test: `internal/store/dropstate_backfill_test.go` + +**Interfaces:** +- Consumes: `DropStateStore` (Task 3), `SkipOverridePrefix`, `CollectOverridePrefix` (existing), gen `ListKVByPrefix(ctx, sql.NullString) ([]gen.Kv, error)`, `GetSettingString(ctx, key) ([]byte, error)`, `UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key string; Value []byte})`. +- Produces: `func BackfillDropState(ctx context.Context, q *gen.Queries, now time.Time) (int, error)`, const `DropStateBackfilledKey = "drop_state_backfilled"`. + +- [ ] **Step 1: Queries** (append to `drop_state.sql`) + +```sql +-- name: ListClaimsForBackfill :many +SELECT c.account_id, c.benefit_id, b.campaign_id, a.platform +FROM claims c +JOIN benefits b ON b.id = c.benefit_id +JOIN accounts a ON a.id = c.account_id; + +-- name: GetBenefitCampaign :one +SELECT b.campaign_id, cp.platform +FROM benefits b +JOIN campaigns cp ON cp.id = b.campaign_id +WHERE b.id = ?; +``` + +Run: `cd internal/store && sqlc generate && cd ../.. && go build ./...` +Expected: `ListClaimsForBackfillRow{AccountID, BenefitID, CampaignID, Platform}` and `GetBenefitCampaignRow{CampaignID, Platform}` generated. + +- [ ] **Step 2: Failing test** + +```go +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +func TestBackfillDropState(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "twitch") + require.NoError(t, NewCampaignPersister(q).PersistCampaigns(ctx, []platform.Campaign{{ + ID: "c1", Platform: "twitch", Game: "G", Name: "C", Status: "active", + Benefits: []platform.DropBenefit{ + {ID: "claimed1", CampaignID: "c1", Name: "A", RequiredMinutes: 60}, + {ID: "marked1", CampaignID: "c1", Name: "B", RequiredMinutes: 60}, + {ID: "skipped1", CampaignID: "c1", Name: "C", RequiredMinutes: 60}, + {ID: "both1", CampaignID: "c1", Name: "D", RequiredMinutes: 60}, + }, + }})) + rec := NewClaimRecorder(q) + require.NoError(t, rec.RecordClaim(ctx, "acc-1", platform.DropBenefit{ID: "claimed1"})) + require.NoError(t, rec.RecordClaim(ctx, "acc-1", platform.DropBenefit{ID: "both1"})) + for _, k := range []string{ + CollectOverridePrefix + "marked1:acc-1", + SkipOverridePrefix + "skipped1:acc-1", + SkipOverridePrefix + "both1:acc-1", // claim history must win over a skip + SkipOverridePrefix + "gone:acc-1", // benefit row missing: ignored + } { + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: k, Value: []byte("1")})) + } + + now := time.Unix(1_700_000_000, 0) + n, err := BackfillDropState(ctx, q, now) + require.NoError(t, err) + assert.Equal(t, 4, n) + + rows, err := NewDropStateStore(q).List(ctx, "acc-1") + require.NoError(t, err) + by := map[string]dropstate.Row{} + for _, r := range rows { + by[r.DropID] = r + } + assert.Equal(t, dropstate.Claimed, by["claimed1"].Status) + assert.Equal(t, dropstate.FromPlatform, by["claimed1"].Source) + assert.Equal(t, dropstate.Claimed, by["marked1"].Status) + assert.Equal(t, dropstate.FromUser, by["marked1"].Source) + assert.Equal(t, dropstate.Blocked, by["skipped1"].Status) + assert.Equal(t, dropstate.NotEnrolled, by["skipped1"].Reason) + assert.Equal(t, now, by["skipped1"].RetryAfter, "skips get one fresh evaluation") + assert.Equal(t, dropstate.Claimed, by["both1"].Status) + assert.Equal(t, "c1", by["claimed1"].CampaignID) + + // Idempotent: second run is a no-op. + n, err = BackfillDropState(ctx, q, now) + require.NoError(t, err) + assert.Equal(t, 0, n) +} +``` + +Run: `go test ./internal/store/ -run TestBackfillDropState -v` +Expected: FAIL, `undefined: BackfillDropState`. + +- [ ] **Step 3: Implement** + +```go +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// DropStateBackfilledKey marks the one-shot v1 to v2 state backfill as done. +const DropStateBackfilledKey = "drop_state_backfilled" + +// BackfillDropState seeds drop_state from v1 state: ghost-skips become +// not_enrolled with an immediate retry, manual marks become user claims, and +// claim history becomes platform claims. Later sources win. Runs once; the v1 +// kv keys are left in place so rolling back to v1 is safe. +func BackfillDropState(ctx context.Context, q *gen.Queries, now time.Time) (int, error) { + if v, err := q.GetSettingString(ctx, DropStateBackfilledKey); err == nil && string(v) == "1" { + return 0, nil + } + rows := map[string]dropstate.Row{} + put := func(r dropstate.Row) { rows[r.AccountID+"|"+r.DropID] = r } + + fromKV := func(prefix string, mk func(acct, drop, campaign, plat string) dropstate.Row) error { + kvs, err := q.ListKVByPrefix(ctx, sql.NullString{String: prefix, Valid: true}) + if err != nil { + return fmt.Errorf("list %s: %w", prefix, err) + } + for _, kv := range kvs { + drop, acct, ok := splitOverrideKey(kv.Key, prefix) + if !ok { + continue + } + bc, err := q.GetBenefitCampaign(ctx, drop) + if err != nil { + continue // benefit row gone, nothing to seed + } + put(mk(acct, drop, bc.CampaignID, bc.Platform)) + } + return nil + } + if err := fromKV(SkipOverridePrefix, func(acct, drop, campaign, plat string) dropstate.Row { + return dropstate.Row{AccountID: acct, DropID: drop, CampaignID: campaign, Platform: plat, + Status: dropstate.Blocked, Reason: dropstate.NotEnrolled, Source: dropstate.FromPlatform, + RetryAfter: now, UpdatedAt: now} + }); err != nil { + return 0, err + } + if err := fromKV(CollectOverridePrefix, func(acct, drop, campaign, plat string) dropstate.Row { + return dropstate.Row{AccountID: acct, DropID: drop, CampaignID: campaign, Platform: plat, + Status: dropstate.Claimed, Source: dropstate.FromUser, UpdatedAt: now} + }); err != nil { + return 0, err + } + claims, err := q.ListClaimsForBackfill(ctx) + if err != nil { + return 0, fmt.Errorf("list claims: %w", err) + } + for _, c := range claims { + put(dropstate.Row{AccountID: c.AccountID, DropID: c.BenefitID, CampaignID: c.CampaignID, Platform: c.Platform, + Status: dropstate.Claimed, Source: dropstate.FromPlatform, UpdatedAt: now}) + } + + st := NewDropStateStore(q) + for _, r := range rows { + if err := st.Upsert(ctx, r); err != nil { + return 0, fmt.Errorf("upsert %s/%s: %w", r.AccountID, r.DropID, err) + } + } + if err := q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: DropStateBackfilledKey, Value: []byte("1")}); err != nil { + return 0, fmt.Errorf("mark backfilled: %w", err) + } + return len(rows), nil +} + +// splitOverrideKey parses prefix + dropID + ":" + accountID. +func splitOverrideKey(key, prefix string) (drop, acct string, ok bool) { + rest := strings.TrimPrefix(key, prefix) + i := strings.LastIndex(rest, ":") + if i <= 0 || i == len(rest)-1 { + return "", "", false + } + return rest[:i], rest[i+1:], true +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/store/ -v 2>&1 | tail -20` +Expected: PASS. If `gen.Kv.Key` is not a `string`, adapt the field access. + +- [ ] **Step 5: Commit** + +```bash +gofmt -w internal/store +git add internal/store +git commit -m "feat(pipeline-v2): backfill drop_state from claims and v1 overrides + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 5: Platform capability types + Twitch adapter + +**Files:** +- Create: `internal/platform/pipeline.go` +- Modify: `internal/platform/twitch/campaigns.go` (add `Self` to details struct) +- Modify: `internal/platform/twitch/claim.go` (split out `claimStatus`, behaviour unchanged) +- Create: `internal/platform/twitch/pipeline.go` +- Test: `internal/platform/twitch/pipeline_test.go` + +**Interfaces:** +- Consumes: twitch `discovery`, `client.gql`, `OpDropCampaignDetails`, `(*discovery).inventory`, `(*channels).listEligible`, `(*watch).resolveUserID`, `claimer`. +- Produces (in `internal/platform`): + +```go +type DropProgress struct { + DropID, CampaignID string + Minutes, Required int + Claimed bool + InstanceID string + Known bool + Unmineable bool +} +type ClaimOutcome int // ClaimOK, ClaimAlready, ClaimNeedsLink, ClaimFailed; String() +type ClaimResult struct { Outcome ClaimOutcome; LinkURL, Detail string } +type DropProgressSource interface { DropProgress(ctx context.Context, s Session, camps []Campaign) ([]DropProgress, error) } +type DropClaimer interface { ClaimDrop(ctx context.Context, s Session, d DropProgress) ClaimResult } +type ChannelProber interface { ProbeChannels(ctx context.Context, s Session, c Campaign, logins []string) ([]Stream, error) } +``` +- Produces (twitch): `(*Backend).DropProgress`, `(*Backend).ClaimDrop`, `(*Backend).ProbeChannels`, `classifyClaimStatus(string) platform.ClaimResult`, `isSyntheticID(string) bool`. + +- [ ] **Step 1: Platform types** (`internal/platform/pipeline.go`) + +```go +package platform + +import "context" + +// DropProgress is one drop's per-account state as the platform reports it. +// Pipeline v2's reconciler folds these into drop_state. +type DropProgress struct { + DropID string + CampaignID string + Minutes int + Required int + Claimed bool + InstanceID string + // Known is true when the platform returned a definite record for this + // drop. False means the platform said nothing about it (Kick lists a + // reward only after it accrues), which is not evidence of anything. + Known bool + // Unmineable marks drops whose progress the platform cannot track, + // such as Twitch scrape placeholders. The reconciler blocks them. + Unmineable bool +} + +type ClaimOutcome int + +const ( + ClaimOK ClaimOutcome = iota + ClaimAlready + ClaimNeedsLink + ClaimFailed +) + +func (o ClaimOutcome) String() string { + switch o { + case ClaimOK: + return "ok" + case ClaimAlready: + return "already_claimed" + case ClaimNeedsLink: + return "needs_link" + default: + return "failed" + } +} + +// ClaimResult is a typed claim outcome, replacing string-matched errors. +type ClaimResult struct { + Outcome ClaimOutcome + LinkURL string + Detail string +} + +// DropProgressSource reports authoritative per-drop progress for the given +// campaigns. It returns an error rather than a partial result, so a failed +// sync never looks like "the platform forgot these drops". +type DropProgressSource interface { + DropProgress(ctx context.Context, s Session, camps []Campaign) ([]DropProgress, error) +} + +// DropClaimer claims one drop and classifies the outcome. +type DropClaimer interface { + ClaimDrop(ctx context.Context, s Session, d DropProgress) ClaimResult +} + +// ChannelProber checks specific channels for liveness and, when c.Game is +// set, that they stream the campaign's game. Used for priority streamers +// that platform directories may not list. +type ChannelProber interface { + ProbeChannels(ctx context.Context, s Session, c Campaign, logins []string) ([]Stream, error) +} +``` + +- [ ] **Step 2: Add `Self` to the details struct** (`campaigns.go`, inside `TimeBasedDrops []struct { ... }`, after `PreconditionDrops`) + +```go + // self is the viewer's own progress on this drop. Null when + // the account is not enrolled in the campaign yet. + Self *struct { + CurrentMinutesWatched int `json:"currentMinutesWatched"` + IsClaimed bool `json:"isClaimed"` + DropInstanceID string `json:"dropInstanceID"` + } `json:"self"` +``` + +- [ ] **Step 3: Split `claimStatus` out of `claim`** (`claim.go`, replace the `claim` function) + +```go +func (cl *claimer) claim(ctx context.Context, sess platform.Session, b platform.DropBenefit, userID int64) error { + status, err := cl.claimStatus(ctx, sess, b, userID) + if err != nil { + return err + } + switch status { + case "ELIGIBLE_FOR_ALL", "DROP_INSTANCE_ALREADY_CLAIMED", "": + return nil + default: + return fmt.Errorf("claim status: %s", status) + } +} + +// claimStatus sends the claim mutation and returns Twitch's raw status. +func (cl *claimer) claimStatus(ctx context.Context, sess platform.Session, b platform.DropBenefit, userID int64) (string, error) { + // Prefer the per-account instance id captured at progress time. + // When it's missing, construct DevilXD's synthetic instance id + // `userID#campaignID#dropID` (inventory.py generate_claim) — Twitch + // accepts it and rejects the bare drop-template id with + // INVALID_DROP_INSTANCE. Only fall back to the template id as a last + // resort when we couldn't resolve the user id. + id := b.InstanceID + if id == "" && userID > 0 && b.CampaignID != "" && b.ID != "" { + id = fmt.Sprintf("%d#%s#%s", userID, b.CampaignID, b.ID) + } + if id == "" { + id = b.ID + } + var out claimResult + if err := cl.c.gql(ctx, sess.AccessToken, OpClaimDrop, + map[string]any{"input": map[string]any{"dropInstanceID": id}}, &out); err != nil { + return "", fmt.Errorf("claim %s: %w", id, err) + } + return out.ClaimDropRewards.Status, nil +} +``` + +Run: `go test ./internal/platform/twitch/ -run Claim -v` +Expected: PASS (existing claim tests prove the refactor is behaviour-neutral). + +- [ ] **Step 4: Failing adapter tests** (`pipeline_test.go`) + +```go +package twitch + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// pipelineTestBackend serves DropCampaignDetails + Inventory from fixtures. +func pipelineTestBackend(t *testing.T, details map[string]string, inventory string, detailsStatus int) *Backend { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req gqlRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + switch req.OperationName { + case OpDropCampaignDetails.Name: + if detailsStatus != 0 { + w.WriteHeader(detailsStatus) + return + } + id, _ := req.Variables["dropID"].(string) + _, _ = w.Write([]byte(details[id])) + case OpInventory.Name: + _, _ = w.Write([]byte(inventory)) + default: + t.Fatalf("unexpected op %q", req.OperationName) + } + })) + t.Cleanup(srv.Close) + return &Backend{disc: &discovery{c: newTestClient(srv.URL), userLogin: "testuser"}} +} + +const emptyInventory = `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[]}}}}` + +func TestDropProgress_ReadsSelfForCompletedCampaign(t *testing.T) { + // Campaign left dropCampaignsInProgress (fully claimed), but details self + // still reports isClaimed. This is the P1 fix. + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":{"currentMinutesWatched":60,"isClaimed":true,"dropInstanceID":"i1"}} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, platform.DropProgress{DropID: "d1", CampaignID: "c1", Minutes: 60, Required: 60, Claimed: true, InstanceID: "i1", Known: true}, got[0]) +} + +func TestDropProgress_NullSelfIsUnclaimed(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":null} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.False(t, got[0].Claimed) + assert.Equal(t, 0, got[0].Minutes) + assert.True(t, got[0].Known) +} + +func TestDropProgress_DedupesMultiItemDrop(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[{"benefit":{"id":"b1","name":"A"}},{"benefit":{"id":"b2","name":"B"}}],"self":null}, + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":null} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + assert.Len(t, got, 1) +} + +func TestDropProgress_InventoryFillsInstanceAndMinutes(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":{"currentMinutesWatched":10,"isClaimed":false,"dropInstanceID":""}} + ]}}}}`}, `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[ + {"id":"c1","timeBasedDrops":[{"id":"d1","self":{"currentMinutesWatched":25,"isClaimed":false,"dropInstanceID":"inst"}}]} + ]}}}}`, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, 25, got[0].Minutes) + assert.Equal(t, "inst", got[0].InstanceID) +} + +func TestDropProgress_SyntheticCampaignIsUnmineable(t *testing.T) { + b := pipelineTestBackend(t, nil, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "Game|Name", Platform: "twitch", Benefits: []platform.DropBenefit{{ID: "Game|Name_default"}}}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Unmineable) + assert.True(t, got[0].Known) +} + +func TestDropProgress_DetailsErrorFailsWhole(t *testing.T) { + b := pipelineTestBackend(t, nil, emptyInventory, http.StatusInternalServerError) + _, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.Error(t, err) +} + +func TestClassifyClaimStatus(t *testing.T) { + assert.Equal(t, platform.ClaimOK, classifyClaimStatus("ELIGIBLE_FOR_ALL").Outcome) + assert.Equal(t, platform.ClaimOK, classifyClaimStatus("").Outcome) + assert.Equal(t, platform.ClaimAlready, classifyClaimStatus("DROP_INSTANCE_ALREADY_CLAIMED").Outcome) + r := classifyClaimStatus("DROP_INSTANCE_ALREADY_EXPIRED") + assert.Equal(t, platform.ClaimFailed, r.Outcome) + assert.Contains(t, r.Detail, "DROP_INSTANCE_ALREADY_EXPIRED") +} + +func TestBackend_SatisfiesPipelineInterfaces(t *testing.T) { + var _ platform.DropProgressSource = (*Backend)(nil) + var _ platform.DropClaimer = (*Backend)(nil) + var _ platform.ChannelProber = (*Backend)(nil) +} +``` + +Run: `go test ./internal/platform/twitch/ -run 'DropProgress|ClassifyClaim|PipelineInterfaces' -v` +Expected: FAIL, `b.DropProgress undefined`. + +- [ ] **Step 5: Implement** (`internal/platform/twitch/pipeline.go`) + +```go +package twitch + +import ( + "context" + "fmt" + "strings" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// isSyntheticID reports scrape-fallback placeholder ids ("Game|Name"). +// Twitch can't report progress for them. +func isSyntheticID(id string) bool { return strings.ContainsAny(id, "| ") } + +// dropProgress reads DropCampaignDetails uncached, because self changes as +// the viewer watches and claims. One entry per drop id. +func (d *discovery) dropProgress(ctx context.Context, sess platform.Session, campaignID string) ([]platform.DropProgress, error) { + channelLogin := d.userLogin + if channelLogin == "" { + channelLogin = "twitch" + } + var det campaignDetailsData + if err := d.c.gql(ctx, sess.AccessToken, OpDropCampaignDetails, + map[string]any{"dropID": campaignID, "channelLogin": channelLogin}, &det); err != nil { + return nil, fmt.Errorf("drop progress %s: %w", campaignID, err) + } + seen := map[string]bool{} + var out []platform.DropProgress + for _, td := range det.User.DropCampaign.TimeBasedDrops { + if td.ID == "" || seen[td.ID] { + continue + } + seen[td.ID] = true + dp := platform.DropProgress{DropID: td.ID, CampaignID: campaignID, Required: td.RequiredMinutesWatched, Known: true} + if td.Self != nil { + dp.Minutes = td.Self.CurrentMinutesWatched + dp.Claimed = td.Self.IsClaimed + dp.InstanceID = td.Self.DropInstanceID + } + out = append(out, dp) + } + return out, nil +} + +// DropProgress satisfies platform.DropProgressSource. Per-campaign details +// are the truth (they survive the campaign leaving the in-progress +// inventory); the in-progress inventory only fills fresher minutes and the +// instance id. +func (b *Backend) DropProgress(ctx context.Context, s platform.Session, camps []platform.Campaign) ([]platform.DropProgress, error) { + inv, err := b.disc.inventory(ctx, s) + if err != nil { + return nil, err + } + byID := make(map[string]platform.Progress, len(inv)) + for _, p := range inv { + byID[p.BenefitID] = p + } + var out []platform.DropProgress + for _, c := range camps { + if isSyntheticID(c.ID) { + for _, bf := range c.Benefits { + out = append(out, platform.DropProgress{DropID: bf.ID, CampaignID: c.ID, Known: true, Unmineable: true}) + } + continue + } + dps, err := b.disc.dropProgress(ctx, s, c.ID) + if err != nil { + return nil, err + } + for i := range dps { + p, ok := byID[dps[i].DropID] + if !ok { + continue + } + if p.MinutesWatched > dps[i].Minutes { + dps[i].Minutes = p.MinutesWatched + } + dps[i].Claimed = dps[i].Claimed || p.Claimed + if dps[i].InstanceID == "" { + dps[i].InstanceID = p.InstanceID + } + } + out = append(out, dps...) + } + return out, nil +} + +// classifyClaimStatus maps Twitch's claimDropRewards.status to an outcome. +func classifyClaimStatus(status string) platform.ClaimResult { + switch status { + case "ELIGIBLE_FOR_ALL", "": + return platform.ClaimResult{Outcome: platform.ClaimOK} + case "DROP_INSTANCE_ALREADY_CLAIMED": + return platform.ClaimResult{Outcome: platform.ClaimAlready} + default: + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: "claim status: " + status} + } +} + +// ClaimDrop satisfies platform.DropClaimer. Unlinked campaigns are blocked +// before claiming (reconciler reads self.isAccountConnected), so Twitch has +// no link outcome here. +func (b *Backend) ClaimDrop(ctx context.Context, s platform.Session, d platform.DropProgress) platform.ClaimResult { + userID, _ := b.watch.resolveUserID(ctx, s) + status, err := b.claim.claimStatus(ctx, s, platform.DropBenefit{ID: d.DropID, CampaignID: d.CampaignID, InstanceID: d.InstanceID}, userID) + if err != nil { + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: err.Error()} + } + return classifyClaimStatus(status) +} + +// ProbeChannels satisfies platform.ChannelProber: parallel live check of the +// given logins, filtered to c.Game when set. +func (b *Backend) ProbeChannels(ctx context.Context, s platform.Session, c platform.Campaign, logins []string) ([]platform.Stream, error) { + return b.chans.listEligible(ctx, s, c, logins) +} + +var ( + _ platform.DropProgressSource = (*Backend)(nil) + _ platform.DropClaimer = (*Backend)(nil) + _ platform.ChannelProber = (*Backend)(nil) +) +``` + +- [ ] **Step 6: Run tests** + +Run: `go test ./internal/platform/... -v 2>&1 | tail -30` +Expected: PASS. If the 500 test does not error, check `client.gql` treats non-2xx as an error; if it doesn't, make the fixture return `{"errors":[{"message":"boom"}]}` instead, whichever `gql` rejects. + +- [ ] **Step 7: Commit** + +```bash +gofmt -w internal/platform +git add internal/platform +git commit -m "feat(pipeline-v2): platform progress/claim/probe capabilities, Twitch adapter + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 6: Kick adapter + +**Files:** +- Modify: `internal/platform/kick/api.go` (add `Required` to `progressReward`) +- Create: `internal/platform/kick/pipeline.go` +- Test: `internal/platform/kick/pipeline_test.go` + +**Interfaces:** +- Consumes: `platform.DropProgress`, `ClaimResult`, capability interfaces (Task 5); kick `api.progressDetail`, `api.Claim`, `ClaimNeedsLinkError`, `probeLive`, `kickChannel`. +- Produces: `(*kick.Backend).DropProgress`, `ClaimDrop`, `ProbeChannels`. + +- [ ] **Step 1: Add `Required` to `progressReward`** + +In `api.go`, add field `Required int // required_units` to `progressReward`, and in `progressDetail`'s append add: + +```go + Required: mnum(rm, "required_units", "required_minutes", "requiredMinutes", "minutes"), +``` + +- [ ] **Step 2: Failing tests** (`pipeline_test.go`) + +```go +package kick + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +const progressURL = "https://web.kick.com/api/v1/drops/progress" + +func TestKickDropProgress_RequiredFromPlatform(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {200, `{"data":[ + {"id":"c1","rewards":[{"id":"r1","claimed":false,"progress":0.5,"required_units":30}]} + ]}`}}} + b := withFake(f) + got, err := b.DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ + ID: "c1", Benefits: []platform.DropBenefit{{ID: "r1", CampaignID: "c1", RequiredMinutes: 120}}, + }}) + require.NoError(t, err) + require.Len(t, got, 1) + // Platform says 30, adapter's invented 120 must not win (P6). + assert.Equal(t, platform.DropProgress{DropID: "r1", CampaignID: "c1", Minutes: 15, Required: 30, Known: true}, got[0]) +} + +func TestKickDropProgress_AbsentRewardIsUnknown(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {200, `{"data":[]}`}}} + b := withFake(f) + got, err := b.DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ + ID: "c1", Benefits: []platform.DropBenefit{{ID: "r1", CampaignID: "c1", RequiredMinutes: 120}}, + }}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.False(t, got[0].Known, "absence proves nothing") + assert.Equal(t, 120, got[0].Required) +} + +func TestKickDropProgress_ErrorFailsWhole(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {403, `{}`}}} + _, err := withFake(f).DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ID: "c1"}}) + require.Error(t, err) +} + +func TestKickClaimDrop_Outcomes(t *testing.T) { + claimURL := "https://web.kick.com/api/v1/drops/claim" + ok := withFake(&fakeDoer{resp: map[string]fakeResp{claimURL: {200, `{}`}}}) + assert.Equal(t, platform.ClaimOK, ok.ClaimDrop(context.Background(), sess("a"), platform.DropProgress{DropID: "r1", CampaignID: "c1"}).Outcome) + + link := withFake(&fakeDoer{resp: map[string]fakeResp{claimURL: {400, `{"connect_url":"https://kick.com/connect/riot"}`}}}) + r := link.ClaimDrop(context.Background(), sess("a"), platform.DropProgress{DropID: "r1", CampaignID: "c1"}) + assert.Equal(t, platform.ClaimNeedsLink, r.Outcome) + assert.Equal(t, "https://kick.com/connect/riot", r.LinkURL) + + fail := withFake(&fakeDoer{resp: map[string]fakeResp{claimURL: {500, `{}`}}}) + assert.Equal(t, platform.ClaimFailed, fail.ClaimDrop(context.Background(), sess("a"), platform.DropProgress{DropID: "r1", CampaignID: "c1"}).Outcome) +} + +func TestKickProbeChannels(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + "https://kick.com/api/v2/channels/fav/livestream": {200, `{"data":{"id":7,"viewer_count":3,"categories":[{"name":"Rust","slug":"rust"}]}}`}, + "https://kick.com/api/v2/channels/off/livestream": {200, `{"data":null}`}, + "https://kick.com/api/v2/channels/other/livestream": {200, `{"data":{"id":8,"viewer_count":9,"categories":[{"name":"Slots","slug":"slots"}]}}`}, + }} + got, err := withFake(f).ProbeChannels(context.Background(), sess("a"), platform.Campaign{Game: "Rust"}, []string{"Fav", "off", "other"}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "fav", got[0].Channel) +} + +func TestKickBackend_SatisfiesPipelineInterfaces(t *testing.T) { + var _ platform.DropProgressSource = (*Backend)(nil) + var _ platform.DropClaimer = (*Backend)(nil) + var _ platform.ChannelProber = (*Backend)(nil) +} +``` + +Run: `go test ./internal/platform/kick/ -run 'KickDropProgress|KickClaimDrop|KickProbe|PipelineInterfaces' -v` +Expected: FAIL, `b.DropProgress undefined`. + +- [ ] **Step 3: Implement** (`internal/platform/kick/pipeline.go`) + +```go +package kick + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// DropProgress satisfies platform.DropProgressSource. Required minutes come +// from /drops/progress required_units whenever Kick lists the reward. Kick +// lists a reward only after it accrues, so an unlisted reward is reported +// with Known=false instead of guessing. +func (b *Backend) DropProgress(ctx context.Context, s platform.Session, camps []platform.Campaign) ([]platform.DropProgress, error) { + rewards, err := b.api.progressDetail(ctx, s) + if err != nil { + return nil, fmt.Errorf("kick drop progress: %w", err) + } + byID := make(map[string]progressReward, len(rewards)) + for _, r := range rewards { + byID[r.RewardID] = r + } + var out []platform.DropProgress + for _, c := range camps { + for _, bf := range c.Benefits { + r, ok := byID[bf.ID] + if !ok { + out = append(out, platform.DropProgress{DropID: bf.ID, CampaignID: c.ID, Required: bf.RequiredMinutes}) + continue + } + req := r.Required + if req <= 0 { + req = bf.RequiredMinutes + } + minutes := 0 + if req > 0 && r.Fraction > 0 { + minutes = int(math.Round(r.Fraction * float64(req))) + } + out = append(out, platform.DropProgress{ + DropID: bf.ID, CampaignID: c.ID, Minutes: minutes, Required: req, + Claimed: r.Claimed, Known: true, + }) + } + } + return out, nil +} + +// ClaimDrop satisfies platform.DropClaimer. A claim error on an already +// auto-granted reward shows up as ClaimFailed; the post-claim reconcile +// then sees claimed=true and corrects the state. +func (b *Backend) ClaimDrop(ctx context.Context, s platform.Session, d platform.DropProgress) platform.ClaimResult { + err := b.api.Claim(ctx, s, d.DropID, d.CampaignID) + if err == nil { + return platform.ClaimResult{Outcome: platform.ClaimOK} + } + var linkErr *ClaimNeedsLinkError + if errors.As(err, &linkErr) { + return platform.ClaimResult{Outcome: platform.ClaimNeedsLink, LinkURL: linkErr.ConnectURL, Detail: err.Error()} + } + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: err.Error()} +} + +// ProbeChannels satisfies platform.ChannelProber on top of probeLive. +func (b *Backend) ProbeChannels(ctx context.Context, s platform.Session, c platform.Campaign, logins []string) ([]platform.Stream, error) { + pool := make([]kickChannel, 0, len(logins)) + for _, l := range logins { + if l = strings.ToLower(strings.TrimSpace(l)); l != "" { + pool = append(pool, kickChannel{Slug: l}) + } + } + return b.probeLive(ctx, s, c, pool), nil +} + +var ( + _ platform.DropProgressSource = (*Backend)(nil) + _ platform.DropClaimer = (*Backend)(nil) + _ platform.ChannelProber = (*Backend)(nil) +) +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/platform/kick/ -v 2>&1 | tail -30` +Expected: PASS (including existing sweep tests, since `progressReward` only gained a field). + +- [ ] **Step 5: Commit** + +```bash +gofmt -w internal/platform +git add internal/platform/kick +git commit -m "feat(pipeline-v2): Kick adapter with platform-sourced required minutes + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 7: Reconciler + +**Files:** +- Create: `internal/pipeline/reconcile/reconcile.go` +- Test: `internal/pipeline/reconcile/reconcile_test.go` + +**Interfaces:** +- Consumes: `dropstate` (Task 2), `platform.Backend.ListActiveCampaigns`, `platform.DropProgressSource` (Task 5). +- Produces: + +```go +type CampaignPersister interface { PersistCampaigns(ctx context.Context, camps []platform.Campaign) error } +type Config struct { + AccountID, Platform string + Backend platform.Backend + Progress platform.DropProgressSource + Session platform.Session + AllowGame func(game string) bool + AllowChannel func(channels []string) bool + ForceLinked func(campaignID string) bool + Persister CampaignPersister +} +type Result struct { + Campaigns []platform.Campaign // active, in scope + Rows []dropstate.Row // every row touched this sync + Progress map[string]platform.DropProgress // by drop id +} +func Run(ctx context.Context, cfg Config, prev map[string]dropstate.Row, now time.Time) (Result, error) +func InScope(camps []platform.Campaign, allowGame func(string) bool, allowChannel func([]string) bool) []platform.Campaign +``` + +- [ ] **Step 1: Failing tests** + +```go +package reconcile + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type fakeBackend struct { + platform.Backend // nil: unimplemented methods panic + camps []platform.Campaign + progress []platform.DropProgress + progErr error +} + +func (f *fakeBackend) ListActiveCampaigns(context.Context, platform.Session) ([]platform.Campaign, error) { + return f.camps, nil +} + +func (f *fakeBackend) DropProgress(context.Context, platform.Session, []platform.Campaign) ([]platform.DropProgress, error) { + return f.progress, f.progErr +} + +type fakePersister struct{ got []platform.Campaign } + +func (p *fakePersister) PersistCampaigns(_ context.Context, c []platform.Campaign) error { + p.got = append(p.got, c...) + return nil +} + +var now = time.Unix(1_700_000_000, 0) + +func camp(id, game string, drops ...string) platform.Campaign { + c := platform.Campaign{ID: id, Platform: "twitch", Game: game, Status: "active", AccountLinked: true, AccountLinkChecked: true} + for _, d := range drops { + c.Benefits = append(c.Benefits, platform.DropBenefit{ID: d, CampaignID: id, RequiredMinutes: 60}) + } + return c +} + +func cfg(f *fakeBackend, p *fakePersister) Config { + return Config{AccountID: "a", Platform: "twitch", Backend: f, Progress: f, Persister: p, + AllowGame: func(g string) bool { return g == "G" }} +} + +func byDrop(rows []dropstate.Row) map[string]dropstate.Row { + m := map[string]dropstate.Row{} + for _, r := range rows { + m[r.DropID] = r + } + return m +} + +func TestRun_ClaimedAfterLeavingInventory(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1")}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 60, Required: 60, Claimed: true, Known: true}}} + prev := map[string]dropstate.Row{"d1": {AccountID: "a", DropID: "d1", CampaignID: "c1", Platform: "twitch", Status: dropstate.Accruing, Minutes: 30, Required: 60, Source: dropstate.FromPlatform}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Claimed, byDrop(res.Rows)["d1"].Status) +} + +func TestRun_NewDropSeedsIdentity(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1")}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 5, Required: 60, Known: true}}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), nil, now) + require.NoError(t, err) + r := byDrop(res.Rows)["d1"] + assert.Equal(t, "a", r.AccountID) + assert.Equal(t, "c1", r.CampaignID) + assert.Equal(t, "twitch", r.Platform) + assert.Equal(t, dropstate.Accruing, r.Status) +} + +func TestRun_ProgressErrorChangesNothing(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1")}, progErr: errors.New("details 500")} + prev := map[string]dropstate.Row{"d1": {DropID: "d1", Status: dropstate.Accruing, Minutes: 30, Required: 60}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.Error(t, err) + assert.Empty(t, res.Rows) +} + +func TestRun_MultiItemDropOneRow(t *testing.T) { + c := camp("c1", "G", "d1", "d1") + f := &fakeBackend{camps: []platform.Campaign{c}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Required: 60, Known: true}}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), nil, now) + require.NoError(t, err) + assert.Len(t, res.Rows, 1) +} + +func TestRun_UnlinkedCampaignBlocked_UnlessForced(t *testing.T) { + c := camp("c1", "G", "d1") + c.AccountLinked = false + f := &fakeBackend{camps: []platform.Campaign{c}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Required: 60, Known: true}}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), nil, now) + require.NoError(t, err) + assert.Equal(t, dropstate.NeedsLink, byDrop(res.Rows)["d1"].Reason) + + cf := cfg(f, &fakePersister{}) + cf.ForceLinked = func(id string) bool { return id == "c1" } + res, err = Run(context.Background(), cf, nil, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Eligible, byDrop(res.Rows)["d1"].Status) +} + +func TestRun_ExpiredCampaign(t *testing.T) { + c := camp("c1", "G", "d1") + c.Status = "expired" + f := &fakeBackend{camps: []platform.Campaign{c}} + prev := map[string]dropstate.Row{"d1": {DropID: "d1", Status: dropstate.Accruing, Minutes: 30, Required: 60}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Expired, byDrop(res.Rows)["d1"].Reason) + assert.Empty(t, res.Campaigns, "expired campaigns are not mining candidates") +} + +func TestRun_ScopeAndPersist(t *testing.T) { + nullGame := camp("c2", "", "d2") + nullGame.AllowedChannels = []string{"fav"} + reward := camp("c3", "G", "d3") + reward.Kind = "reward" + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "Other", "d1"), nullGame, reward}, + progress: []platform.DropProgress{{DropID: "d2", CampaignID: "c2", Required: 60, Known: true}}} + p := &fakePersister{} + cf := cfg(f, p) + cf.AllowChannel = func(chs []string) bool { return len(chs) > 0 && chs[0] == "fav" } + res, err := Run(context.Background(), cf, nil, now) + require.NoError(t, err) + got := byDrop(res.Rows) + assert.NotContains(t, got, "d1", "non-whitelisted game") + assert.Contains(t, got, "d2", "null-game campaign allowed by channel") + assert.NotContains(t, got, "d3", "reward kind has no watch time") + require.Len(t, p.got, 1) + assert.Equal(t, "c2", p.got[0].ID) +} +``` + +Run: `go test ./internal/pipeline/reconcile/ -v` +Expected: FAIL, `undefined: Run`. + +- [ ] **Step 2: Implement** + +```go +// Package reconcile folds platform per-campaign progress into drop_state. +// It never decides what to watch. +package reconcile + +import ( + "context" + "fmt" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type CampaignPersister interface { + PersistCampaigns(ctx context.Context, camps []platform.Campaign) error +} + +type Config struct { + AccountID string + Platform string + Backend platform.Backend + Progress platform.DropProgressSource + Session platform.Session + AllowGame func(game string) bool + AllowChannel func(channels []string) bool + ForceLinked func(campaignID string) bool + Persister CampaignPersister +} + +type Result struct { + Campaigns []platform.Campaign + Rows []dropstate.Row + Progress map[string]platform.DropProgress +} + +// InScope keeps campaigns the account mines: whitelisted game, or a +// campaign whose channels match the account's priority streamers +// (null-game drops). One-click reward campaigns have no watch time. +func InScope(camps []platform.Campaign, allowGame func(string) bool, allowChannel func([]string) bool) []platform.Campaign { + var out []platform.Campaign + for _, c := range camps { + if c.Kind == "reward" { + continue + } + ok := allowGame == nil || allowGame(c.Game) + if !ok && allowChannel != nil && allowChannel(c.AllowedChannels) { + ok = true + } + if ok { + out = append(out, c) + } + } + return out +} + +func isActive(c platform.Campaign, now time.Time) bool { + if c.Status != "" && c.Status != "active" { + return false + } + return c.EndsAt.IsZero() || now.Before(c.EndsAt) +} + +// Run lists campaigns, persists the in-scope ones, reads their progress and +// returns every drop row it touched. On any platform error it returns the +// error and no rows, so a failed sync never changes state. +func Run(ctx context.Context, cfg Config, prev map[string]dropstate.Row, now time.Time) (Result, error) { + camps, err := cfg.Backend.ListActiveCampaigns(ctx, cfg.Session) + if err != nil { + return Result{}, fmt.Errorf("list campaigns: %w", err) + } + scope := InScope(camps, cfg.AllowGame, cfg.AllowChannel) + if cfg.Persister != nil && len(scope) > 0 { + if err := cfg.Persister.PersistCampaigns(ctx, scope); err != nil { + return Result{}, fmt.Errorf("persist campaigns: %w", err) + } + } + var active []platform.Campaign + for _, c := range scope { + if isActive(c, now) { + active = append(active, c) + } + } + byDrop := map[string]platform.DropProgress{} + if len(active) > 0 { + obs, err := cfg.Progress.DropProgress(ctx, cfg.Session, active) + if err != nil { + return Result{}, fmt.Errorf("drop progress: %w", err) + } + for _, o := range obs { + byDrop[o.DropID] = o + } + } + + res := Result{Campaigns: active, Progress: byDrop} + seen := map[string]bool{} + for _, c := range scope { + if c.Status == "upcoming" { + continue + } + expired := !isActive(c, now) + linked := c.AccountLinked || !c.AccountLinkChecked || (cfg.ForceLinked != nil && cfg.ForceLinked(c.ID)) + for _, b := range c.Benefits { + if b.ID == "" || seen[b.ID] { + continue + } + seen[b.ID] = true + p, ok := prev[b.ID] + if !ok || p.IsZero() { + p = dropstate.Row{AccountID: cfg.AccountID, DropID: b.ID, CampaignID: c.ID, Platform: cfg.Platform, Source: dropstate.FromPlatform} + } + var next dropstate.Row + if expired { + if p.IsZero() { + continue // never tracked, nothing to expire + } + next = dropstate.Expire(p, now) + } else { + o, known := byDrop[b.ID] + req := b.RequiredMinutes + if known { + req = o.Required + } + next = dropstate.Apply(p, dropstate.Observation{ + Known: known && o.Known, Unmineable: o.Unmineable, Claimed: o.Claimed, + Minutes: o.Minutes, Required: req, + }, now) + if !linked { + next = dropstate.BlockLink(next, now) + } + } + res.Rows = append(res.Rows, next) + } + } + return res, nil +} +``` + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/pipeline/... -v 2>&1 | tail -30` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +gofmt -w internal/pipeline +git add internal/pipeline/reconcile +git commit -m "feat(pipeline-v2): reconciler folds platform progress into drop state + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 8: Planner (incl. streamer priority, #49) + +**Files:** +- Create: `internal/pipeline/planner/planner.go` +- Test: `internal/pipeline/planner/planner_test.go` + +**Interfaces:** +- Consumes: `dropstate.Row`, `dropstate.Mineable`, `dropstate.Claimed`; `platform.Campaign`, `platform.Stream`. +- Produces: + +```go +type Kind int // Idle, Mine, ForceWatch; String() "idle" | "mine" | "force_watch" +type Settings struct { + GameRank func(game string) int + PriorityMode string // "ordered" | "ending_soonest" + StreamerPriority []string + ForceWatch []platform.Stream // live force-watch channels, rank order +} +type Input struct { + Now time.Time + Rows map[string]dropstate.Row + Campaigns []platform.Campaign + Settings Settings + Live map[string][]platform.Stream // campaign id -> live eligible channels + Cooldowns map[string]time.Time // lowercased channel -> until + Current *Decision + LastSwap time.Time +} +type Decision struct { + Kind Kind + CampaignID string + Channel platform.Stream + Serves []string + Reason string +} +func (d Decision) Same(o Decision) bool +func Plan(in Input) Decision +func Candidates(in Input) []platform.Campaign +const SwapHold = 10 * time.Minute +const ReasonNothingToMine = "nothing_to_mine"; const ReasonNoLiveChannels = "no_live_channels" +``` + +- [ ] **Step 1: Failing tests** + +```go +package planner + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +var now = time.Unix(1_700_000_000, 0) + +func camp(id, game string, ends time.Time, drops ...platform.DropBenefit) platform.Campaign { + return platform.Campaign{ID: id, Game: game, Status: "active", EndsAt: ends, Benefits: drops} +} + +func drop(id, campaign string, req int, pre ...string) platform.DropBenefit { + return platform.DropBenefit{ID: id, CampaignID: campaign, RequiredMinutes: req, Preconditions: pre} +} + +func rows(rs ...dropstate.Row) map[string]dropstate.Row { + m := map[string]dropstate.Row{} + for _, r := range rs { + m[r.DropID] = r + } + return m +} + +func st(id string, s dropstate.Status) dropstate.Row { return dropstate.Row{DropID: id, Status: s} } + +func stream(ch string, viewers int) platform.Stream { + return platform.Stream{Channel: ch, ViewerCount: viewers} +} + +func TestPlan_PicksHighestViewerByDefault(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("small", 5), stream("big", 500)}}, + } + d := Plan(in) + assert.Equal(t, Mine, d.Kind) + assert.Equal(t, "big", d.Channel.Channel) + assert.Equal(t, []string{"d1"}, d.Serves) +} + +func TestPlan_PriorityStreamerFirst(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("big", 500), stream("Fav2", 1), stream("fav1", 2)}}, + Settings: Settings{StreamerPriority: []string{"fav1", "fav2"}}, + } + assert.Equal(t, "fav1", Plan(in).Channel.Channel) + in.Live["c1"] = []platform.Stream{stream("big", 500), stream("Fav2", 1)} + assert.Equal(t, "Fav2", Plan(in).Channel.Channel, "case-insensitive, next priority") + in.Live["c1"] = []platform.Stream{stream("big", 500)} + assert.Equal(t, "big", Plan(in).Channel.Channel, "fallback to category") +} + +func TestPlan_GameRankThenEndingSoonest(t *testing.T) { + soon, late := now.Add(time.Hour), now.Add(48*time.Hour) + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("late", "A", late, drop("d1", "late", 60)), camp("soon", "B", soon, drop("d2", "soon", 60))}, + Rows: rows(st("d1", dropstate.Eligible), st("d2", dropstate.Eligible)), + Live: map[string][]platform.Stream{"late": {stream("x", 1)}, "soon": {stream("y", 1)}}, + Settings: Settings{GameRank: func(g string) int { return map[string]int{"A": 0, "B": 1}[g] }}, + } + assert.Equal(t, "late", Plan(in).CampaignID, "ordered: game rank wins") + in.Settings.PriorityMode = "ending_soonest" + assert.Equal(t, "soon", Plan(in).CampaignID) +} + +func TestPlan_SkipsNonMineableAndLockedPreconditions(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, + drop("claimed", "c1", 30), drop("locked", "c1", 60, "gate"), drop("gate", "c1", 10))}, + Rows: rows(st("claimed", dropstate.Claimed), st("locked", dropstate.Eligible), st("gate", dropstate.Blocked)), + Live: map[string][]platform.Stream{"c1": {stream("x", 1)}}, + } + assert.Equal(t, Idle, Plan(in).Kind, "gate is blocked, locked waits on it") + + in.Rows["gate"] = dropstate.Row{DropID: "gate", Status: dropstate.Claimed, Source: dropstate.FromUser} + d := Plan(in) + assert.Equal(t, Mine, d.Kind, "user-marked precondition unlocks (P9)") + assert.Equal(t, []string{"locked"}, d.Serves) +} + +func TestPlan_ServesDropsAcrossCampaignsOnSameChannel(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{ + camp("c1", "Rust", time.Time{}, drop("r1", "c1", 60), drop("r2", "c1", 30)), + camp("c2", "Rust", time.Time{}, drop("r3", "c2", 120)), + }, + Rows: rows(st("r1", dropstate.Eligible), st("r2", dropstate.Accruing), st("r3", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("oilrats", 9)}, "c2": {stream("OILRATS", 9)}}, + } + d := Plan(in) + assert.Equal(t, []string{"r2", "r1", "r3"}, d.Serves, "lowest tier first, then other campaigns") +} + +func TestPlan_CooldownSkipsChannel(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("big", 500), stream("small", 5)}}, + Cooldowns: map[string]time.Time{"big": now.Add(time.Minute)}, + } + assert.Equal(t, "small", Plan(in).Channel.Channel) +} + +func TestPlan_HysteresisHoldsThenBypassesDeadChannel(t *testing.T) { + cur := Decision{Kind: Mine, CampaignID: "c1", Channel: stream("big", 500), Serves: []string{"d1"}} + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("big", 500), stream("fav", 1)}}, + Settings: Settings{StreamerPriority: []string{"fav"}}, + Current: &cur, LastSwap: now.Add(-time.Minute), + } + assert.Equal(t, "big", Plan(in).Channel.Channel, "inside hold: keep current") + in.LastSwap = now.Add(-SwapHold - time.Second) + assert.Equal(t, "fav", Plan(in).Channel.Channel, "hold over: priority streamer wins") + in.LastSwap = now.Add(-time.Minute) + in.Live["c1"] = []platform.Stream{stream("fav", 1)} + assert.Equal(t, "fav", Plan(in).Channel.Channel, "current went offline: bypass hold") +} + +func TestPlan_ForceWatchOnlyWhenNothingToMine(t *testing.T) { + in := Input{Now: now, Settings: Settings{ForceWatch: []platform.Stream{stream("pts", 1)}}} + d := Plan(in) + assert.Equal(t, ForceWatch, d.Kind) + assert.Equal(t, "pts", d.Channel.Channel) + + in.Settings.ForceWatch = nil + d = Plan(in) + assert.Equal(t, Idle, d.Kind) + assert.Equal(t, ReasonNothingToMine, d.Reason) +} + +func TestPlan_NoLiveChannelsIdleReason(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + } + assert.Equal(t, ReasonNoLiveChannels, Plan(in).Reason) +} + +func TestPlan_OutsideWindowIgnored(t *testing.T) { + c := camp("c1", "G", now.Add(-time.Minute), drop("d1", "c1", 60)) + in := Input{Now: now, Campaigns: []platform.Campaign{c}, Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("x", 1)}}} + assert.Equal(t, Idle, Plan(in).Kind) +} + +func TestDecision_Same(t *testing.T) { + a := Decision{Kind: Mine, Channel: stream("Big", 1)} + assert.True(t, a.Same(Decision{Kind: Mine, Channel: stream("big", 9)})) + assert.False(t, a.Same(Decision{Kind: ForceWatch, Channel: stream("big", 9)})) + assert.True(t, Decision{Kind: Idle}.Same(Decision{Kind: Idle, Reason: "x"})) +} +``` + +Run: `go test ./internal/pipeline/planner/ -v` +Expected: FAIL, `undefined: Input`. + +- [ ] **Step 2: Implement** + +```go +// Package planner turns drop state, campaigns and live channels into one +// decision: which channel to watch and which drops that serves. Pure: no +// I/O, no clock reads, no goroutines. +package planner + +import ( + "sort" + "strings" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type Kind int + +const ( + Idle Kind = iota + Mine + ForceWatch +) + +func (k Kind) String() string { + switch k { + case Mine: + return "mine" + case ForceWatch: + return "force_watch" + default: + return "idle" + } +} + +// SwapHold is the minimum time on a channel before the planner switches to +// a better one, unless the current channel is dead or cooling down. +const SwapHold = 10 * time.Minute + +const ( + ReasonNothingToMine = "nothing_to_mine" + ReasonNoLiveChannels = "no_live_channels" +) + +type Settings struct { + GameRank func(game string) int + PriorityMode string + StreamerPriority []string + ForceWatch []platform.Stream +} + +type Input struct { + Now time.Time + Rows map[string]dropstate.Row + Campaigns []platform.Campaign + Settings Settings + Live map[string][]platform.Stream + Cooldowns map[string]time.Time + Current *Decision + LastSwap time.Time +} + +type Decision struct { + Kind Kind + CampaignID string + Channel platform.Stream + Serves []string + Reason string +} + +// Same reports whether two decisions watch the same thing. +func (d Decision) Same(o Decision) bool { + return d.Kind == o.Kind && strings.EqualFold(d.Channel.Channel, o.Channel.Channel) +} + +func key(ch string) string { return strings.ToLower(ch) } + +func cooling(in Input, ch string) bool { + until, ok := in.Cooldowns[key(ch)] + return ok && in.Now.Before(until) +} + +func inWindow(c platform.Campaign, now time.Time) bool { + if c.Status != "" && c.Status != "active" { + return false + } + if !c.StartsAt.IsZero() && now.Before(c.StartsAt) { + return false + } + return c.EndsAt.IsZero() || now.Before(c.EndsAt) +} + +// mineable returns the campaign's mineable drop ids, lowest tier first. +func mineable(in Input, c platform.Campaign) []string { + type d struct { + id string + req int + } + seen := map[string]bool{} + var ds []d + for _, b := range c.Benefits { + if seen[b.ID] || !dropstate.Mineable(in.Rows[b.ID]) { + continue + } + ok := true + for _, p := range b.Preconditions { + if in.Rows[p].Status != dropstate.Claimed { + ok = false + break + } + } + if !ok { + continue + } + seen[b.ID] = true + ds = append(ds, d{b.ID, b.RequiredMinutes}) + } + sort.SliceStable(ds, func(i, j int) bool { + if ds[i].req != ds[j].req { + return ds[i].req < ds[j].req + } + return ds[i].id < ds[j].id + }) + out := make([]string, len(ds)) + for i, x := range ds { + out[i] = x.id + } + return out +} + +func ends(c platform.Campaign) time.Time { + if c.EndsAt.IsZero() { + return time.Unix(1<<62, 0) + } + return c.EndsAt +} + +// Candidates returns in-window campaigns with at least one mineable drop, +// best first: game rank then ending soonest (ending soonest first when +// PriorityMode is "ending_soonest"). +func Candidates(in Input) []platform.Campaign { + var out []platform.Campaign + for _, c := range in.Campaigns { + if inWindow(c, in.Now) && len(mineable(in, c)) > 0 { + out = append(out, c) + } + } + rank := func(g string) int { + if in.Settings.GameRank == nil { + return 0 + } + return in.Settings.GameRank(g) + } + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i], out[j] + if in.Settings.PriorityMode == "ending_soonest" && !ends(a).Equal(ends(b)) { + return ends(a).Before(ends(b)) + } + if ra, rb := rank(a.Game), rank(b.Game); ra != rb { + return ra < rb + } + if !ends(a).Equal(ends(b)) { + return ends(a).Before(ends(b)) + } + return a.ID < b.ID + }) + return out +} + +func hasChannel(streams []platform.Stream, ch string) bool { + for _, s := range streams { + if strings.EqualFold(s.Channel, ch) { + return true + } + } + return false +} + +func servedBy(in Input, cands []platform.Campaign, ch string) []string { + var out []string + for _, c := range cands { + if hasChannel(in.Live[c.ID], ch) { + out = append(out, mineable(in, c)...) + } + } + return out +} + +func pickChannel(in Input, c platform.Campaign) (platform.Stream, bool) { + prio := map[string]int{} + for i, l := range in.Settings.StreamerPriority { + if _, dup := prio[key(l)]; !dup { + prio[key(l)] = i + } + } + live := append([]platform.Stream(nil), in.Live[c.ID]...) + sort.SliceStable(live, func(i, j int) bool { + pi, iok := prio[key(live[i].Channel)] + pj, jok := prio[key(live[j].Channel)] + if iok != jok { + return iok + } + if iok && pi != pj { + return pi < pj + } + return live[i].ViewerCount > live[j].ViewerCount + }) + for _, s := range live { + if !cooling(in, s.Channel) { + return s, true + } + } + return platform.Stream{}, false +} + +func forceOrIdle(in Input, reason string) Decision { + for _, s := range in.Settings.ForceWatch { + if !cooling(in, s.Channel) { + return Decision{Kind: ForceWatch, Channel: s, Reason: "force-watch " + s.Channel} + } + } + return Decision{Kind: Idle, Reason: reason} +} + +// Plan decides what the account should watch now. +func Plan(in Input) Decision { + cands := Candidates(in) + if len(cands) == 0 { + return forceOrIdle(in, ReasonNothingToMine) + } + if cur := in.Current; cur != nil && cur.Kind == Mine && in.Now.Sub(in.LastSwap) < SwapHold && !cooling(in, cur.Channel.Channel) { + if serves := servedBy(in, cands, cur.Channel.Channel); len(serves) > 0 { + d := *cur + d.Serves, d.Reason = serves, "holding current channel" + return d + } + } + for _, c := range cands { + ch, ok := pickChannel(in, c) + if !ok { + continue + } + reason := "top viewers" + for _, l := range in.Settings.StreamerPriority { + if strings.EqualFold(l, ch.Channel) { + reason = "priority streamer" + break + } + } + return Decision{Kind: Mine, CampaignID: c.ID, Channel: ch, Serves: servedBy(in, cands, ch.Channel), Reason: reason} + } + return forceOrIdle(in, ReasonNoLiveChannels) +} +``` + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/pipeline/planner/ -v` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +gofmt -w internal/pipeline +git add internal/pipeline/planner +git commit -m "feat(pipeline-v2): pure planner with streamer priority and swap hysteresis + +Refs #49 + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 9: Claimer + +**Files:** +- Create: `internal/pipeline/claimer/claimer.go` +- Test: `internal/pipeline/claimer/claimer_test.go` + +**Interfaces:** +- Consumes: `platform.DropClaimer`, `platform.ClaimResult` (Task 5); `dropstate.ClaimOK/ClaimNeedsLink/ClaimFailedAttempt` (Task 2). +- Produces: `func Attempt(ctx context.Context, c platform.DropClaimer, s platform.Session, row dropstate.Row, dp platform.DropProgress, now time.Time) (dropstate.Row, platform.ClaimResult)` + +- [ ] **Step 1: Failing test** + +```go +package claimer + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type fakeClaimer struct { + res platform.ClaimResult + got platform.DropProgress +} + +func (f *fakeClaimer) ClaimDrop(_ context.Context, _ platform.Session, d platform.DropProgress) platform.ClaimResult { + f.got = d + return f.res +} + +func TestAttempt(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + row := dropstate.Row{DropID: "d1", CampaignID: "c1", Status: dropstate.Claimable, Minutes: 60, Required: 60} + cases := []struct { + out platform.ClaimOutcome + status dropstate.Status + reason dropstate.Reason + }{ + {platform.ClaimOK, dropstate.Claimed, dropstate.NoReason}, + {platform.ClaimAlready, dropstate.Claimed, dropstate.NoReason}, + {platform.ClaimNeedsLink, dropstate.Blocked, dropstate.NeedsLink}, + {platform.ClaimFailed, dropstate.Claimable, dropstate.NoReason}, + } + for _, tc := range cases { + t.Run(tc.out.String(), func(t *testing.T) { + fc := &fakeClaimer{res: platform.ClaimResult{Outcome: tc.out}} + got, res := Attempt(context.Background(), fc, platform.Session{}, row, platform.DropProgress{DropID: "d1", InstanceID: "i"}, now) + assert.Equal(t, tc.status, got.Status) + assert.Equal(t, tc.reason, got.Reason) + assert.Equal(t, tc.out, res.Outcome) + assert.Equal(t, "i", fc.got.InstanceID, "instance id forwarded") + }) + } + fc := &fakeClaimer{res: platform.ClaimResult{Outcome: platform.ClaimFailed}} + got, _ := Attempt(context.Background(), fc, platform.Session{}, row, platform.DropProgress{DropID: "d1"}, now) + assert.Equal(t, 1, got.FailCount) + assert.Equal(t, now.Add(time.Minute), got.RetryAfter) +} +``` + +Run: `go test ./internal/pipeline/claimer/ -v` +Expected: FAIL, `undefined: Attempt`. + +- [ ] **Step 2: Implement** + +```go +// Package claimer turns a claimable drop into claimed or blocked. +package claimer + +import ( + "context" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// Attempt claims one drop and returns its next state. The caller must +// reconcile afterwards: a lost response shows up as ClaimFailed here and is +// corrected when the platform reports isClaimed. +func Attempt(ctx context.Context, c platform.DropClaimer, s platform.Session, row dropstate.Row, dp platform.DropProgress, now time.Time) (dropstate.Row, platform.ClaimResult) { + res := c.ClaimDrop(ctx, s, dp) + switch res.Outcome { + case platform.ClaimOK, platform.ClaimAlready: + return dropstate.ClaimOK(row, now), res + case platform.ClaimNeedsLink: + return dropstate.ClaimNeedsLink(row, now), res + default: + return dropstate.ClaimFailedAttempt(row, now), res + } +} +``` + +- [ ] **Step 3: Run tests and commit** + +Run: `go test ./internal/pipeline/claimer/ -v` +Expected: PASS. + +```bash +gofmt -w internal/pipeline +git add internal/pipeline/claimer +git commit -m "feat(pipeline-v2): claimer maps typed claim outcomes to drop state + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 10: Session + +**Files:** +- Create: `internal/pipeline/session/session.go` +- Test: `internal/pipeline/session/session_test.go` + +**Interfaces:** +- Consumes: `platform.Backend` (`StartWatch`, `Heartbeat`, `StopWatch`, `InventoryProgress`). +- Produces: + +```go +type Kind int // Progress, StreamDown, Stalled +type Event struct { Kind Kind; Channel string; Progress []platform.Progress; Err error } +type Config struct { + Backend platform.Backend + Session platform.Session + Stream platform.Stream + Serves []string + Ticks <-chan time.Time // 60s ticker in production + StallPolls int // consecutive polls with no gain before Stalled +} +func Run(ctx context.Context, cfg Config, out chan<- Event) +``` + +- [ ] **Step 1: Failing tests** + +```go +package session + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type fakeBackend struct { + platform.Backend + mu sync.Mutex + startErr error + beatErr error + minutes []int // minutes for d1 per poll; last value repeats + polls int + beats int + stopped bool +} + +func (f *fakeBackend) StartWatch(_ context.Context, _ platform.Session, s platform.Stream) (platform.WatchHandle, error) { + return platform.WatchHandle{Channel: s.Channel}, f.startErr +} +func (f *fakeBackend) Heartbeat(context.Context, platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + f.beats++ + return f.beatErr +} +func (f *fakeBackend) StopWatch(context.Context, platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + f.stopped = true + return nil +} +func (f *fakeBackend) InventoryProgress(context.Context, platform.Session) ([]platform.Progress, error) { + f.mu.Lock() + defer f.mu.Unlock() + i := f.polls + if i >= len(f.minutes) { + i = len(f.minutes) - 1 + } + f.polls++ + return []platform.Progress{{BenefitID: "d1", MinutesWatched: f.minutes[i]}}, nil +} + +func recv(t *testing.T, ch <-chan Event) Event { + t.Helper() + select { + case e := <-ch: + return e + case <-time.After(2 * time.Second): + t.Fatal("no event") + return Event{} + } +} + +func TestRun_ProgressEachTickAndStopOnCancel(t *testing.T) { + f := &fakeBackend{minutes: []int{1, 2}} + ticks := make(chan time.Time) + out := make(chan Event, 8) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { Run(ctx, Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Serves: []string{"d1"}, Ticks: ticks, StallPolls: 5}, out); close(done) }() + + e := recv(t, out) + assert.Equal(t, Progress, e.Kind, "first beat is immediate") + assert.Equal(t, 1, e.Progress[0].MinutesWatched) + ticks <- time.Now() + assert.Equal(t, 2, recv(t, out).Progress[0].MinutesWatched) + cancel() + <-done + assert.True(t, f.stopped) +} + +func TestRun_HeartbeatErrorIsStreamDown(t *testing.T) { + f := &fakeBackend{minutes: []int{1}, beatErr: errors.New("offline")} + out := make(chan Event, 8) + Run(context.Background(), Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Ticks: make(chan time.Time)}, out) + e := recv(t, out) + assert.Equal(t, StreamDown, e.Kind) + assert.Equal(t, "ch", e.Channel) + assert.True(t, f.stopped) +} + +func TestRun_StartErrorIsStreamDown(t *testing.T) { + f := &fakeBackend{startErr: errors.New("sidecar")} + out := make(chan Event, 8) + Run(context.Background(), Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Ticks: make(chan time.Time)}, out) + assert.Equal(t, StreamDown, recv(t, out).Kind) +} + +func TestRun_StalledOnceAfterNoGain(t *testing.T) { + f := &fakeBackend{minutes: []int{5}} + ticks := make(chan time.Time) + out := make(chan Event, 32) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Run(ctx, Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Serves: []string{"d1"}, Ticks: ticks, StallPolls: 2}, out) + require.Equal(t, Progress, recv(t, out).Kind) // poll 1 sets baseline + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) // no gain 1 + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) // no gain 2 + assert.Equal(t, Stalled, recv(t, out).Kind) + ticks <- time.Now() + assert.Equal(t, Progress, recv(t, out).Kind) + select { + case e := <-out: + t.Fatalf("unexpected second event %v", e.Kind) + case <-time.After(50 * time.Millisecond): + } +} +``` + +Run: `go test ./internal/pipeline/session/ -v` +Expected: FAIL, `undefined: Run`. + +- [ ] **Step 2: Implement** + +```go +// Package session runs one accrual session on one channel and reports what +// it sees. It makes no decisions. +package session + +import ( + "context" + "time" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type Kind int + +const ( + Progress Kind = iota + StreamDown + Stalled +) + +type Event struct { + Kind Kind + Channel string + Progress []platform.Progress + Err error +} + +type Config struct { + Backend platform.Backend + Session platform.Session + Stream platform.Stream + Serves []string + // Ticks drives beats. Production passes a 60s ticker: Twitch credits one + // minute per beacon, so a slower cadence under-credits. + Ticks <-chan time.Time + StallPolls int +} + +// Run starts the watch, beats immediately and on every tick, and stops the +// watch when ctx ends or the stream goes down. +func Run(ctx context.Context, cfg Config, out chan<- Event) { + ch := cfg.Stream.Channel + send := func(e Event) { + e.Channel = ch + select { + case out <- e: + case <-ctx.Done(): + } + } + h, err := cfg.Backend.StartWatch(ctx, cfg.Session, cfg.Stream) + if err != nil { + send(Event{Kind: StreamDown, Err: err}) + return + } + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = cfg.Backend.StopWatch(stopCtx, h) + }() + + serves := make(map[string]bool, len(cfg.Serves)) + for _, id := range cfg.Serves { + serves[id] = true + } + best, still, stalled := -1, 0, false + beat := func() bool { + if err := cfg.Backend.Heartbeat(ctx, h); err != nil { + if ctx.Err() == nil { + send(Event{Kind: StreamDown, Err: err}) + } + return false + } + prog, err := cfg.Backend.InventoryProgress(ctx, cfg.Session) + if err != nil { + return true // transient; the next tick retries + } + send(Event{Kind: Progress, Progress: prog}) + total := 0 + for _, p := range prog { + if serves[p.BenefitID] { + total += p.MinutesWatched + } + } + if total > best { + best, still = total, 0 + } else { + still++ + } + if !stalled && cfg.StallPolls > 0 && still >= cfg.StallPolls { + stalled = true + send(Event{Kind: Stalled}) + } + return true + } + if !beat() { + return + } + for { + select { + case <-ctx.Done(): + return + case <-cfg.Ticks: + if !beat() { + return + } + } + } +} +``` + +- [ ] **Step 3: Run tests and commit** + +Run: `go test ./internal/pipeline/session/ -race -v` +Expected: PASS, no races. + +```bash +gofmt -w internal/pipeline +git add internal/pipeline/session +git commit -m "feat(pipeline-v2): accrual session reports progress, stream-down, stall + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 11: Account loop + +**Files:** +- Create: `internal/pipeline/loop/loop.go` +- Test: `internal/pipeline/loop/loop_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 2, 5, 7 to 10; `watcher.Snapshot` (existing, for dashboard compatibility); `platform.PubSubAware`, `platform.PubSubHooks`, `platform.ChannelSubscriber` (existing). +- Produces: + +```go +type Notifier interface { Notify(ctx context.Context, event string, fields map[string]any) error } +type Store interface { + List(ctx context.Context, accountID string) ([]dropstate.Row, error) + Upsert(ctx context.Context, r dropstate.Row) error +} +type ClaimHistory interface { + RecordClaimIfNew(ctx context.Context, accountID string, b platform.DropBenefit) (bool, error) + ClaimedBenefitIDs(ctx context.Context, accountID string) (map[string]bool, error) +} +type Config struct { ... see code ... } +func New(cfg Config) (*Loop, error) +func (l *Loop) Run(ctx context.Context) error +func (l *Loop) Snapshot() watcher.Snapshot +func (l *Loop) LastDiscovery() ([]platform.Campaign, time.Time) +func (l *Loop) AllowGame() func(game string) bool +func (l *Loop) Nudge() +``` + +`*store.DropStateStore` satisfies `Store`; `*store.ClaimRecorder` satisfies `ClaimHistory`; `*store.CampaignPersister` satisfies `reconcile.CampaignPersister`. + +- [ ] **Step 1: Failing tests** + +```go +package loop + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// fakeBackend: one game "G", campaign c1 with drop d1 (60 min). Tests +// mutate the fields under mu to script the platform. +type fakeBackend struct { + platform.Backend + mu sync.Mutex + camps []platform.Campaign + progress []platform.DropProgress + inventory []platform.Progress + live []platform.Stream + claimRes platform.ClaimResult + claims int + watching []string + stops int + beatFailOn string +} + +func (f *fakeBackend) ListActiveCampaigns(context.Context, platform.Session) ([]platform.Campaign, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.camps, nil +} +func (f *fakeBackend) DropProgress(context.Context, platform.Session, []platform.Campaign) ([]platform.DropProgress, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]platform.DropProgress(nil), f.progress...), nil +} +func (f *fakeBackend) ListEligibleChannels(context.Context, platform.Session, platform.Campaign) ([]platform.Stream, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]platform.Stream(nil), f.live...), nil +} +func (f *fakeBackend) InventoryProgress(context.Context, platform.Session) ([]platform.Progress, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]platform.Progress(nil), f.inventory...), nil +} +func (f *fakeBackend) StartWatch(_ context.Context, _ platform.Session, s platform.Stream) (platform.WatchHandle, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.watching = append(f.watching, s.Channel) + return platform.WatchHandle{Channel: s.Channel}, nil +} +func (f *fakeBackend) Heartbeat(_ context.Context, h platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.beatFailOn != "" && strings.EqualFold(h.Channel, f.beatFailOn) { + return context.DeadlineExceeded + } + return nil +} +func (f *fakeBackend) StopWatch(context.Context, platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + f.stops++ + return nil +} +func (f *fakeBackend) ClaimDrop(context.Context, platform.Session, platform.DropProgress) platform.ClaimResult { + f.mu.Lock() + defer f.mu.Unlock() + f.claims++ + return f.claimRes +} + +type memStore struct { + mu sync.Mutex + rows map[string]dropstate.Row +} + +func (m *memStore) List(context.Context, string) ([]dropstate.Row, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []dropstate.Row + for _, r := range m.rows { + out = append(out, r) + } + return out, nil +} +func (m *memStore) Upsert(_ context.Context, r dropstate.Row) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[r.DropID] = r + return nil +} +func (m *memStore) get(id string) dropstate.Row { + m.mu.Lock() + defer m.mu.Unlock() + return m.rows[id] +} + +type memHistory struct { + mu sync.Mutex + recorded []string + claimed map[string]bool +} + +func (h *memHistory) RecordClaimIfNew(_ context.Context, _ string, b platform.DropBenefit) (bool, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.recorded = append(h.recorded, b.ID) + return true, nil +} +func (h *memHistory) ClaimedBenefitIDs(context.Context, string) (map[string]bool, error) { + return h.claimed, nil +} + +func setup(t *testing.T) (*fakeBackend, *memStore, *memHistory, Config) { + f := &fakeBackend{ + camps: []platform.Campaign{{ID: "c1", Platform: "twitch", Game: "G", Name: "Camp", Status: "active", + AccountLinked: true, AccountLinkChecked: true, + Benefits: []platform.DropBenefit{{ID: "d1", CampaignID: "c1", Name: "Drop", RequiredMinutes: 60}}}}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 10, Required: 60, Known: true}}, + inventory: []platform.Progress{{BenefitID: "d1", MinutesWatched: 10}}, + live: []platform.Stream{{Channel: "ch1", ViewerCount: 10}, {Channel: "ch2", ViewerCount: 5}}, + claimRes: platform.ClaimResult{Outcome: platform.ClaimOK}, + } + st := &memStore{rows: map[string]dropstate.Row{}} + h := &memHistory{} + cfg := Config{ + AccountID: "acc", Platform: "twitch", Backend: f, Store: st, History: h, + AllowGame: func(g string) bool { return g == "G" }, + ReconcileEvery: time.Hour, LiveEvery: time.Hour, + BeatEvery: 10 * time.Millisecond, StallPolls: 1000, // tests opt into stalls + } + return f, st, h, cfg +} + +func run(t *testing.T, cfg Config) (*Loop, context.CancelFunc) { + l, err := New(cfg) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = l.Run(ctx); close(done) }() + t.Cleanup(func() { cancel(); <-done }) + return l, cancel +} + +func TestLoop_ClaimedOnWebsiteMidWatch(t *testing.T) { + f, st, h, cfg := setup(t) + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().Channel == "ch1" }, 2*time.Second, 5*time.Millisecond) + + f.mu.Lock() + f.inventory = []platform.Progress{{BenefitID: "d1", MinutesWatched: 20, Claimed: true}} + f.mu.Unlock() + + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 2*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 5*time.Millisecond) + h.mu.Lock() + assert.Contains(t, h.recorded, "d1") + h.mu.Unlock() + f.mu.Lock() + assert.Equal(t, 0, f.claims, "no claim call for an already-claimed drop") + assert.GreaterOrEqual(t, f.stops, 1) + f.mu.Unlock() +} + +func TestLoop_ClaimsWhenClaimable(t *testing.T) { + f, st, _, cfg := setup(t) + f.progress[0].Minutes = 60 + f.inventory[0].MinutesWatched = 60 + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 2*time.Second, 5*time.Millisecond) + f.mu.Lock() + assert.Equal(t, 1, f.claims) + f.mu.Unlock() +} + +func TestLoop_NeedsLinkBlocksWithoutLooping(t *testing.T) { + f, st, _, cfg := setup(t) + f.progress[0].Minutes = 60 + f.inventory[0].MinutesWatched = 60 + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimNeedsLink} + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Reason == dropstate.NeedsLink }, 2*time.Second, 5*time.Millisecond) + time.Sleep(100 * time.Millisecond) + f.mu.Lock() + assert.Equal(t, 1, f.claims, "needs_link is not retried") + f.mu.Unlock() +} + +func TestLoop_StallCoolsChannelAndSwaps(t *testing.T) { + _, _, _, cfg := setup(t) + cfg.StallPolls = 3 + l, _ := run(t, cfg) + // Minutes never move, so after StallPolls the loop cools ch1 and moves on. + require.Eventually(t, func() bool { return l.Snapshot().Channel == "ch2" }, 3*time.Second, 5*time.Millisecond) +} + +func TestLoop_StaleStreamDownIgnored(t *testing.T) { + f, _, _, cfg := setup(t) + l, err := New(cfg) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Drive the loop manually: load, reconcile, refresh, plan. + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + require.Equal(t, "ch1", l.current.Channel.Channel) + l.onSession(ctx, sessionEvent("ch-old-down")) + assert.Empty(t, l.cooldowns, "event from another channel must not cool anything") + l.stopSession() + _ = f +} + +func TestLoop_BridgesManualMarksOnLoad(t *testing.T) { + _, st, h, cfg := setup(t) + st.rows["d1"] = dropstate.Row{AccountID: "acc", DropID: "d1", CampaignID: "c1", Platform: "twitch", Status: dropstate.Accruing, Minutes: 10, Required: 60, Source: dropstate.FromPlatform} + h.claimed = map[string]bool{"d1": true} + l, err := New(cfg) + require.NoError(t, err) + require.NoError(t, l.load(context.Background())) + assert.Equal(t, dropstate.Claimed, st.get("d1").Status) + assert.Equal(t, dropstate.FromUser, st.get("d1").Source) +} + +func TestNew_RejectsBackendWithoutCapabilities(t *testing.T) { + type bare struct{ platform.Backend } + _, err := New(Config{AccountID: "a", Backend: bare{}}) + require.Error(t, err) +} +``` + +Add this helper at the bottom of the test file (it builds a StreamDown for a given channel): + +```go +func sessionEvent(ch string) session.Event { return session.Event{Kind: session.StreamDown, Channel: ch} } +``` + +and add `"github.com/aalejandrofer/grubdrops/internal/pipeline/session"` to the test imports. + +Run: `go test ./internal/pipeline/loop/ -v` +Expected: FAIL, `undefined: New`. + +- [ ] **Step 2: Implement** + +```go +// Package loop is the per-account pipeline v2 runner. One goroutine owns +// every drop state transition: it reconciles, plans, runs one session at a +// time, and claims. +package loop + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/claimer" + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/pipeline/planner" + "github.com/aalejandrofer/grubdrops/internal/pipeline/reconcile" + "github.com/aalejandrofer/grubdrops/internal/pipeline/session" + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/watcher" +) + +type Notifier interface { + Notify(ctx context.Context, event string, fields map[string]any) error +} + +type Store interface { + List(ctx context.Context, accountID string) ([]dropstate.Row, error) + Upsert(ctx context.Context, r dropstate.Row) error +} + +type ClaimHistory interface { + RecordClaimIfNew(ctx context.Context, accountID string, b platform.DropBenefit) (bool, error) + ClaimedBenefitIDs(ctx context.Context, accountID string) (map[string]bool, error) +} + +type Config struct { + AccountID string + AccountLabel string + Platform string + Backend platform.Backend + Session platform.Session + Store Store + History ClaimHistory + Persister reconcile.CampaignPersister + Notifier Notifier + + AllowGame func(game string) bool + AllowChannel func(channels []string) bool + GameRank func(game string) int + PriorityMode string + ForceLinked func(campaignID string) bool + StreamerPriority []string + ForceWatch []string + ProgressNotifyStepPct int + + Now func() time.Time + ReconcileEvery time.Duration // default 15m + LiveEvery time.Duration // default 5m + BeatEvery time.Duration // default 60s; production never overrides (Twitch credit gate) + StallPolls int // default 5 + StallCooldown time.Duration // default 30m + DownCooldown time.Duration // default 2m +} + +// maxLiveCampaigns bounds channel lookups per refresh. +const maxLiveCampaigns = 8 + +type pubsubEvent struct { + kind string // "progress" | "claimable" | "down" + drop string + instance string + minutes int + required int + channel string +} + +type Loop struct { + cfg Config + prog platform.DropProgressSource + claimer platform.DropClaimer + prober platform.ChannelProber + subs platform.ChannelSubscriber + + nudge chan struct{} + pubsub chan pubsubEvent + sessEvents chan session.Event + + mu sync.Mutex + snap watcher.Snapshot + discovery []platform.Campaign + discoverAt time.Time + + // Owned by the Run goroutine. + rows map[string]dropstate.Row + camps []platform.Campaign + progress map[string]platform.DropProgress + live map[string][]platform.Stream + forceLive []platform.Stream + cooldowns map[string]time.Time + current *planner.Decision + lastSwap time.Time + sessCancel context.CancelFunc + subscribed string + milestones map[string]int +} + +// New assembles the loop, checking the backend's pipeline capabilities once. +func New(cfg Config) (*Loop, error) { + prog, ok := cfg.Backend.(platform.DropProgressSource) + if !ok { + return nil, fmt.Errorf("backend %T has no DropProgress: pipeline v2 unsupported", cfg.Backend) + } + cl, ok := cfg.Backend.(platform.DropClaimer) + if !ok { + return nil, fmt.Errorf("backend %T has no ClaimDrop: pipeline v2 unsupported", cfg.Backend) + } + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.ReconcileEvery <= 0 { + cfg.ReconcileEvery = 15 * time.Minute + } + if cfg.LiveEvery <= 0 { + cfg.LiveEvery = 5 * time.Minute + } + if cfg.BeatEvery <= 0 { + cfg.BeatEvery = 60 * time.Second + } + if cfg.StallPolls <= 0 { + cfg.StallPolls = 5 + } + if cfg.StallCooldown <= 0 { + cfg.StallCooldown = 30 * time.Minute + } + if cfg.DownCooldown <= 0 { + cfg.DownCooldown = 2 * time.Minute + } + cfg.Session.AccountID = cfg.AccountID + if cfg.AllowGame != nil { + cfg.Session.GameFilter = cfg.AllowGame + } + l := &Loop{ + cfg: cfg, prog: prog, claimer: cl, + nudge: make(chan struct{}, 1), + pubsub: make(chan pubsubEvent, 64), + sessEvents: make(chan session.Event, 64), + rows: map[string]dropstate.Row{}, + progress: map[string]platform.DropProgress{}, + live: map[string][]platform.Stream{}, + cooldowns: map[string]time.Time{}, + milestones: map[string]int{}, + } + l.prober, _ = cfg.Backend.(platform.ChannelProber) + l.subs, _ = cfg.Backend.(platform.ChannelSubscriber) + l.snap = watcher.Snapshot{AccountID: cfg.AccountID, State: "pick_campaign"} + if ps, ok := cfg.Backend.(platform.PubSubAware); ok { + ps.SetAccountPubSubHooks(cfg.AccountID, l.hooks()) + } + return l, nil +} + +func (l *Loop) hooks() platform.PubSubHooks { + push := func(e pubsubEvent) { + select { + case l.pubsub <- e: + default: // never block the PubSub reader + } + } + return platform.PubSubHooks{ + OnDropProgress: func(dropID string, cur, req int64) { + push(pubsubEvent{kind: "progress", drop: dropID, minutes: int(cur), required: int(req)}) + }, + OnDropClaim: func(dropID, instanceID string) { + push(pubsubEvent{kind: "claimable", drop: dropID, instance: instanceID}) + }, + OnStreamDown: func(channelID string) { + push(pubsubEvent{kind: "down", channel: channelID}) + }, + } +} + +// Nudge asks the loop to reconcile and refresh channels now. +func (l *Loop) Nudge() { + select { + case l.nudge <- struct{}{}: + default: + } +} + +func (l *Loop) Snapshot() watcher.Snapshot { + l.mu.Lock() + defer l.mu.Unlock() + return l.snap +} + +func (l *Loop) LastDiscovery() ([]platform.Campaign, time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + return append([]platform.Campaign(nil), l.discovery...), l.discoverAt +} + +func (l *Loop) AllowGame() func(game string) bool { return l.cfg.AllowGame } + +func (l *Loop) Run(ctx context.Context) error { + if err := l.load(ctx); err != nil { + return err + } + l.reconcile(ctx) + l.refreshLive(ctx) + l.claimReady(ctx) + l.replan(ctx) + recT := time.NewTicker(l.cfg.ReconcileEvery) + liveT := time.NewTicker(l.cfg.LiveEvery) + defer recT.Stop() + defer liveT.Stop() + defer l.stopSession() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-recT.C: + l.reconcile(ctx) + case <-liveT.C: + l.refreshLive(ctx) + case <-l.nudge: + l.reconcile(ctx) + l.refreshLive(ctx) + case ev := <-l.sessEvents: + l.onSession(ctx, ev) + case pe := <-l.pubsub: + l.onPubSub(ctx, pe) + } + l.claimReady(ctx) + l.replan(ctx) + } +} + +// load reads stored state, then bridges claim rows written by the v1-era +// mark-collected UI (that handler reloads the scheduler, so this runs). +func (l *Loop) load(ctx context.Context) error { + rows, err := l.cfg.Store.List(ctx, l.cfg.AccountID) + if err != nil { + return fmt.Errorf("load drop state: %w", err) + } + for _, r := range rows { + l.rows[r.DropID] = r + } + if l.cfg.History == nil { + return nil + } + ids, err := l.cfg.History.ClaimedBenefitIDs(ctx, l.cfg.AccountID) + if err != nil { + slog.Warn("pipeline: read claim history failed", "kind", "error", "account", l.cfg.AccountID, "err", err) + return nil + } + now := l.cfg.Now() + for id := range ids { + if r, ok := l.rows[id]; ok && r.Status != dropstate.Claimed { + next := dropstate.MarkCollected(r, now) + l.rows[id] = next + if err := l.cfg.Store.Upsert(ctx, next); err != nil { + slog.Warn("pipeline: persist bridged mark failed", "kind", "error", "account", l.cfg.AccountID, "drop", id, "err", err) + } + } + } + return nil +} + +func (l *Loop) reconcile(ctx context.Context) { + res, err := reconcile.Run(ctx, reconcile.Config{ + AccountID: l.cfg.AccountID, Platform: l.cfg.Platform, + Backend: l.cfg.Backend, Progress: l.prog, Session: l.cfg.Session, + AllowGame: l.cfg.AllowGame, AllowChannel: l.cfg.AllowChannel, + ForceLinked: l.cfg.ForceLinked, Persister: l.cfg.Persister, + }, l.rows, l.cfg.Now()) + if err != nil { + slog.Warn("pipeline reconcile failed; keeping last state", "kind", "error", "account", l.cfg.AccountID, "err", err) + return + } + l.camps = res.Campaigns + for id, dp := range res.Progress { + l.progress[id] = dp + } + l.mu.Lock() + l.discovery, l.discoverAt = res.Campaigns, l.cfg.Now() + l.mu.Unlock() + for _, r := range res.Rows { + l.commit(ctx, r) + } +} + +func (l *Loop) benefit(dropID string) (platform.DropBenefit, platform.Campaign) { + for _, c := range l.camps { + for _, b := range c.Benefits { + if b.ID == dropID { + return b, c + } + } + } + return platform.DropBenefit{ID: dropID}, platform.Campaign{} +} + +// commit stores a row and handles the transition into claimed. +func (l *Loop) commit(ctx context.Context, next dropstate.Row) { + prev := l.rows[next.DropID] + l.rows[next.DropID] = next + if err := l.cfg.Store.Upsert(ctx, next); err != nil { + slog.Warn("pipeline: persist drop state failed", "kind", "error", "account", l.cfg.AccountID, "drop", next.DropID, "err", err) + } + if next.Status != dropstate.Claimed || prev.Status == dropstate.Claimed { + return + } + b, c := l.benefit(next.DropID) + if l.cfg.History != nil { + if _, err := l.cfg.History.RecordClaimIfNew(ctx, l.cfg.AccountID, b); err != nil { + slog.Warn("pipeline: record claim history failed", "kind", "error", "account", l.cfg.AccountID, "drop", next.DropID, "err", err) + } + } + slog.Info("pipeline drop claimed", "kind", "claim", "account", l.cfg.AccountID, "drop", next.DropID, "source", string(next.Source)) + // Only notify live transitions, not historical claims seen on first sync. + if !prev.IsZero() && next.Source == dropstate.FromPlatform { + l.notify(ctx, "claim", c, b, nil) + } +} + +func (l *Loop) notify(ctx context.Context, event string, c platform.Campaign, b platform.DropBenefit, extra map[string]any) { + if l.cfg.Notifier == nil { + return + } + f := map[string]any{"account": l.cfg.AccountID} + if l.cfg.AccountLabel != "" { + f["account_label"] = l.cfg.AccountLabel + } + if l.cfg.Platform != "" { + f["platform"] = l.cfg.Platform + } + if c.Game != "" { + f["game"] = c.Game + } + if c.Name != "" { + f["campaign"] = c.Name + } + if b.Name != "" { + f["drop"] = b.Name + } + for k, v := range extra { + f[k] = v + } + _ = l.cfg.Notifier.Notify(ctx, event, f) +} + +func (l *Loop) plannerInput() planner.Input { + return planner.Input{ + Now: l.cfg.Now(), Rows: l.rows, Campaigns: l.camps, + Settings: planner.Settings{ + GameRank: l.cfg.GameRank, PriorityMode: l.cfg.PriorityMode, + StreamerPriority: l.cfg.StreamerPriority, ForceWatch: l.forceLive, + }, + Live: l.live, Cooldowns: l.cooldowns, Current: l.current, LastSwap: l.lastSwap, + } +} + +// allowedPriority returns the priority logins a campaign accepts. +func allowedPriority(c platform.Campaign, prio []string) []string { + if len(c.AllowedChannels) == 0 { + return prio + } + allowed := map[string]bool{} + for _, a := range c.AllowedChannels { + allowed[strings.ToLower(a)] = true + } + var out []string + for _, p := range prio { + if allowed[strings.ToLower(p)] { + out = append(out, p) + } + } + return out +} + +func mergeStreams(first, rest []platform.Stream) []platform.Stream { + seen := map[string]bool{} + var out []platform.Stream + for _, s := range append(append([]platform.Stream(nil), first...), rest...) { + k := strings.ToLower(s.Channel) + if !seen[k] { + seen[k] = true + out = append(out, s) + } + } + return out +} + +func (l *Loop) refreshLive(ctx context.Context) { + live := map[string][]platform.Stream{} + for i, c := range planner.Candidates(l.plannerInput()) { + if i >= maxLiveCampaigns { + break + } + streams, err := l.cfg.Backend.ListEligibleChannels(ctx, l.cfg.Session, c) + if err != nil { + slog.Debug("pipeline: list channels failed", "account", l.cfg.AccountID, "campaign", c.ID, "err", err) + } + if l.prober != nil { + if logins := allowedPriority(c, l.cfg.StreamerPriority); len(logins) > 0 { + if ps, err := l.prober.ProbeChannels(ctx, l.cfg.Session, c, logins); err == nil { + streams = mergeStreams(ps, streams) + } + } + } + live[c.ID] = streams + } + l.live = live + l.forceLive = nil + if l.prober != nil && len(l.cfg.ForceWatch) > 0 { + if ps, err := l.prober.ProbeChannels(ctx, l.cfg.Session, platform.Campaign{}, l.cfg.ForceWatch); err == nil { + l.forceLive = ps + } + } +} + +func (l *Loop) isCurrent(channel string) bool { + return l.current != nil && l.current.Kind != planner.Idle && strings.EqualFold(l.current.Channel.Channel, channel) +} + +func (l *Loop) onSession(ctx context.Context, ev session.Event) { + now := l.cfg.Now() + switch ev.Kind { + case session.Progress: + l.applyProgress(ctx, ev.Progress) + case session.StreamDown: + if l.isCurrent(ev.Channel) { + l.cooldowns[strings.ToLower(ev.Channel)] = now.Add(l.cfg.DownCooldown) + } + case session.Stalled: + if l.isCurrent(ev.Channel) { + l.cooldowns[strings.ToLower(ev.Channel)] = now.Add(l.cfg.StallCooldown) + l.reconcile(ctx) + } + } +} + +func (l *Loop) applyProgress(ctx context.Context, progs []platform.Progress) { + now := l.cfg.Now() + for _, p := range progs { + prev, ok := l.rows[p.BenefitID] + if !ok { + continue // reconcile creates the row first + } + if p.InstanceID != "" { + dp := l.progress[p.BenefitID] + dp.DropID, dp.CampaignID, dp.InstanceID = p.BenefitID, prev.CampaignID, p.InstanceID + l.progress[p.BenefitID] = dp + } + next := dropstate.Apply(prev, dropstate.Observation{ + Known: true, Claimed: p.Claimed, Minutes: p.MinutesWatched, Required: prev.Required, + }, now) + l.maybeNotifyProgress(ctx, next) + l.commit(ctx, next) + } +} + +func (l *Loop) onPubSub(ctx context.Context, e pubsubEvent) { + switch e.kind { + case "progress": + if r, ok := l.rows[e.drop]; ok && r.Required == 0 && e.required > 0 { + r.Required = e.required + l.rows[e.drop] = r + } + l.applyProgress(ctx, []platform.Progress{{BenefitID: e.drop, MinutesWatched: e.minutes}}) + case "claimable": + r, ok := l.rows[e.drop] + if !ok || r.Status == dropstate.Claimed { + return + } + dp := l.progress[e.drop] + dp.DropID, dp.CampaignID, dp.InstanceID = e.drop, r.CampaignID, e.instance + l.progress[e.drop] = dp + minutes := r.Minutes + if minutes < r.Required { + minutes = r.Required + } + l.commit(ctx, dropstate.Apply(r, dropstate.Observation{Known: true, Minutes: minutes, Required: r.Required}, l.cfg.Now())) + case "down": + if l.current != nil && l.current.Channel.ChannelID != "" && l.current.Channel.ChannelID == e.channel { + l.cooldowns[strings.ToLower(l.current.Channel.Channel)] = l.cfg.Now().Add(l.cfg.DownCooldown) + } + } +} + +func (l *Loop) maybeNotifyProgress(ctx context.Context, r dropstate.Row) { + step := l.cfg.ProgressNotifyStepPct + if step <= 0 || r.Required <= 0 || l.cfg.Notifier == nil { + return + } + pct := r.Minutes * 100 / r.Required + if pct > 100 { + pct = 100 + } + m := pct / step * step + last, seen := l.milestones[r.DropID] + l.milestones[r.DropID] = m + if !seen || m <= last { + return // first sight records a baseline; no restart storms + } + b, c := l.benefit(r.DropID) + l.notify(ctx, "progress", c, b, map[string]any{"cur_min": r.Minutes, "req_min": r.Required}) +} + +func (l *Loop) claimReady(ctx context.Context) { + now := l.cfg.Now() + attempted := false + for id, r := range l.rows { + if !dropstate.ReadyToClaim(r, now) { + continue + } + dp, ok := l.progress[id] + if !ok || dp.DropID == "" { + dp = platform.DropProgress{DropID: id, CampaignID: r.CampaignID} + } + next, res := claimer.Attempt(ctx, l.claimer, l.cfg.Session, r, dp, now) + slog.Info("pipeline claim attempt", "kind", "claim", "account", l.cfg.AccountID, "drop", id, + "outcome", res.Outcome.String(), "detail", res.Detail, "link", res.LinkURL) + l.commit(ctx, next) + attempted = true + } + if attempted { + l.reconcile(ctx) // corrects lost responses and auto-granted rewards + } +} + +func (l *Loop) replan(ctx context.Context) { + d := planner.Plan(l.plannerInput()) + if l.current != nil && l.current.Same(d) { + l.current.Serves, l.current.Reason = d.Serves, d.Reason + l.updateSnapshot() + return + } + l.stopSession() + l.current, l.lastSwap = &d, l.cfg.Now() + slog.Info("pipeline decision", "kind", "state", "account", l.cfg.AccountID, + "decision", d.Kind.String(), "channel", d.Channel.Channel, "serves", len(d.Serves), "reason", d.Reason) + if l.cfg.Notifier != nil { + _ = l.cfg.Notifier.Notify(ctx, "state", map[string]any{"account": l.cfg.AccountID, "state": stateString(&d)}) + } + if d.Kind != planner.Idle { + l.startSession(ctx, d) + } + l.updateSnapshot() +} + +func (l *Loop) startSession(ctx context.Context, d planner.Decision) { + sctx, cancel := context.WithCancel(ctx) + l.sessCancel = cancel + if l.subs != nil && d.Channel.ChannelID != "" { + l.subs.SubscribeChannel(l.cfg.AccountID, d.Channel.ChannelID) + l.subscribed = d.Channel.ChannelID + } + ticker := time.NewTicker(l.cfg.BeatEvery) + cfg := session.Config{ + Backend: l.cfg.Backend, Session: l.cfg.Session, Stream: d.Channel, + Serves: d.Serves, Ticks: ticker.C, StallPolls: l.cfg.StallPolls, + } + if d.Kind == planner.ForceWatch { + cfg.StallPolls = 0 // channel-points farming has no drop progress to stall on + } + go func() { + defer ticker.Stop() + session.Run(sctx, cfg, l.sessEvents) + }() +} + +func (l *Loop) stopSession() { + if l.sessCancel != nil { + l.sessCancel() + l.sessCancel = nil + } + if l.subs != nil && l.subscribed != "" { + l.subs.UnsubscribeChannel(l.cfg.AccountID, l.subscribed) + l.subscribed = "" + } +} + +// stateString maps a decision onto the v1 dashboard state vocabulary. +func stateString(d *planner.Decision) string { + if d == nil { + return "pick_campaign" + } + switch d.Kind { + case planner.Mine: + return "watching" + case planner.ForceWatch: + return "force_watch" + default: + return "idle" + } +} + +func (l *Loop) updateSnapshot() { + s := watcher.Snapshot{AccountID: l.cfg.AccountID, State: stateString(l.current)} + if d := l.current; d != nil && d.Kind != planner.Idle { + s.Channel, s.ViewerCount, s.StartedAt = d.Channel.Channel, d.Channel.ViewerCount, l.lastSwap + if len(d.Serves) > 0 { + b, c := l.benefit(d.Serves[0]) + r := l.rows[d.Serves[0]] + s.CampaignID, s.CampaignName, s.CampaignGame = c.ID, c.Name, c.Game + s.BenefitID, s.BenefitName, s.BenefitImage = b.ID, b.Name, b.ImageURL + s.MinutesWatched, s.RequiredMinutes = r.Minutes, r.Required + } + } + l.mu.Lock() + l.snap = s + l.mu.Unlock() +} +``` + +- [ ] **Step 3: Run tests with race detector** + +Run: `go test ./internal/pipeline/loop/ -race -v` +Expected: PASS, no races. If `TestLoop_StallCoolsChannelAndSwaps` flakes, raise its timeout, not the stall logic. + +- [ ] **Step 4: Revert-proof checks** + +1. In `onSession`, remove the `l.isCurrent(ev.Channel)` guard for StreamDown: `TestLoop_StaleStreamDownIgnored` must FAIL. Restore. +2. In `claimReady`, remove `l.reconcile(ctx)`: run whole package; confirm tests still pass (reconcile is belt-and-braces there) and note it in the task report. +3. In `commit`, delete the `RecordClaimIfNew` call: `TestLoop_ClaimedOnWebsiteMidWatch` must FAIL. Restore. + +- [ ] **Step 5: Commit** + +```bash +gofmt -w internal/pipeline +git add internal/pipeline/loop +git commit -m "feat(pipeline-v2): per-account loop owning all drop state transitions + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 12: Scheduler interfaces + `GRUB_PIPELINE` wiring + +**Files:** +- Modify: `internal/scheduler/state.go:30-62` (use interfaces, not `*watcher.Watcher`) +- Modify: `internal/scheduler/discovery.go:25-45` (same) +- Test: `internal/scheduler/v2_runner_test.go` +- Modify: `internal/store/campaign_persister.go` (add `PipelineOverridePrefix`) +- Modify: `cmd/miner/main.go` (backfill at startup, `dropStore`, v2 branch in `build`, helpers) +- Test: `cmd/miner/main_test.go` (helpers) + +**Interfaces:** +- Consumes: `loop.New`, `loop.Config` (Task 11), `store.DropStateStore`, `store.BackfillDropState` (Tasks 3 and 4). +- Produces: `pipelineModeFor(ctx, q, accountID) string`, `matchAnyChannel(logins []string) func([]string) bool` in `cmd/miner`; `store.PipelineOverridePrefix = "pipeline_override:"`. + +- [ ] **Step 1: Failing scheduler test** + +First read `internal/scheduler/*_test.go` for how entries are added and snapshots read (existing tests use `NewEntry` + `AddEntry`). Then: + +```go +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/watcher" +) + +type v2Runner struct{} + +func (v2Runner) Run(ctx context.Context) error { <-ctx.Done(); return ctx.Err() } +func (v2Runner) Snapshot() watcher.Snapshot { + return watcher.Snapshot{AccountID: "acc", State: "watching", Channel: "ch1"} +} +func (v2Runner) LastDiscovery() ([]platform.Campaign, time.Time) { + return []platform.Campaign{{ID: "c1"}}, time.Unix(1, 0) +} +func (v2Runner) AllowGame() func(string) bool { return func(string) bool { return true } } + +func TestScheduler_NonWatcherRunnerWithSnapshot(t *testing.T) { + s := New(Options{}) + s.AddEntry(NewEntry("acc", v2Runner{})) + snaps := s.WatcherSnapshots() + assert.Equal(t, "ch1", snaps[0].Channel) + assert.Equal(t, "watching", s.Snapshot()[0].State, "must not fall back to needs_auth") + disc := s.WatcherDiscoveries() + assert.Len(t, disc[0].Campaigns, 1) +} +``` + +Run: `go test ./internal/scheduler/ -run NonWatcherRunner -v` +Expected: FAIL (`needs_auth` / empty channel). + +- [ ] **Step 2: Implement scheduler interfaces** + +In `state.go` add: + +```go +// snapshotter is any runner that can describe its current work. Both the v1 +// *watcher.Watcher and the pipeline v2 loop implement it. +type snapshotter interface { + Snapshot() watcher.Snapshot +} +``` + +Replace the body of `Snapshot()`'s loop with: + +```go + for _, e := range s.entries { + if sn, ok := e.runner.(snapshotter); ok { + out = append(out, AccountState{AccountID: e.id, State: sn.Snapshot().State}) + continue + } + out = append(out, AccountState{AccountID: e.id, State: idleState(e.runner)}) + } +``` + +and `WatcherSnapshots()`'s loop with: + +```go + for _, e := range s.entries { + if sn, ok := e.runner.(snapshotter); ok { + out = append(out, sn.Snapshot()) + continue + } + out = append(out, watcher.Snapshot{AccountID: e.id, State: idleState(e.runner)}) + } +``` + +In `discovery.go` add (import `time` and `platform` as needed): + +```go +// discoveryReporter is any runner that caches its last campaign discovery. +type discoveryReporter interface { + LastDiscovery() ([]platform.Campaign, time.Time) + AllowGame() func(game string) bool +} +``` + +and in `WatcherDiscoveries` replace `w, ok := e.runner.(*watcher.Watcher)` with `w, ok := e.runner.(discoveryReporter)`. Remove the `watcher` import from `discovery.go` if now unused. + +Run: `go test ./internal/scheduler/ -v` +Expected: PASS (new + existing). + +- [ ] **Step 3: Failing main helper tests** (append to `cmd/miner/main_test.go`) + +```go +func TestPipelineModeFor(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, t.TempDir()+"/t.db") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + t.Setenv("GRUB_PIPELINE", "") + assert.Equal(t, "v1", pipelineModeFor(ctx, q, "acc")) + t.Setenv("GRUB_PIPELINE", "v2") + assert.Equal(t, "v2", pipelineModeFor(ctx, q, "acc")) + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: store.PipelineOverridePrefix + "acc", Value: []byte("v1")})) + assert.Equal(t, "v1", pipelineModeFor(ctx, q, "acc"), "per-account override beats env") + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: store.PipelineOverridePrefix + "acc", Value: []byte("junk")})) + assert.Equal(t, "v2", pipelineModeFor(ctx, q, "acc"), "invalid override falls through") +} + +func TestMatchAnyChannel(t *testing.T) { + assert.Nil(t, matchAnyChannel(nil)) + m := matchAnyChannel([]string{"Fav"}) + assert.True(t, m([]string{"x", "fav"})) + assert.False(t, m([]string{"x"})) +} +``` + +(add `assert` import if missing.) + +Run: `go test ./cmd/miner/ -run 'PipelineModeFor|MatchAnyChannel' -v` +Expected: FAIL, undefined. + +- [ ] **Step 4: Implement helpers + wiring** + +In `internal/store/campaign_persister.go`, next to the other prefixes: + +```go +// PipelineOverridePrefix keys a per-account pipeline choice in kv. Full key: +// PipelineOverridePrefix + accountID, value "v1" or "v2". Beats GRUB_PIPELINE. +const PipelineOverridePrefix = "pipeline_override:" +``` + +In `cmd/miner/main.go`, add helpers near `loadAccountWhitelist`: + +```go +// pipelineModeFor picks v1 or v2 for an account: kv override first, then +// GRUB_PIPELINE, default v1. +func pipelineModeFor(ctx context.Context, q *gen.Queries, accountID string) string { + if v, err := q.GetSettingString(ctx, store.PipelineOverridePrefix+accountID); err == nil { + if s := string(v); s == "v1" || s == "v2" { + return s + } + } + if os.Getenv("GRUB_PIPELINE") == "v2" { + return "v2" + } + return "v1" +} + +// matchAnyChannel reports whether any campaign channel is one of logins. +// Nil when logins is empty, so the null-game gate stays off. +func matchAnyChannel(logins []string) func([]string) bool { + if len(logins) == 0 { + return nil + } + set := make(map[string]bool, len(logins)) + for _, l := range logins { + set[strings.ToLower(l)] = true + } + return func(chs []string) bool { + for _, c := range chs { + if set[strings.ToLower(c)] { + return true + } + } + return false + } +} +``` + +After `claimRecorder := store.NewClaimRecorder(q)` (around line 337) add: + +```go + dropStore := store.NewDropStateStore(q) + if n, err := store.BackfillDropState(ctx, q, time.Now()); err != nil { + logger.Warn("pipeline v2: drop_state backfill failed", "err", err) + } else if n > 0 { + logger.Info("pipeline v2: drop_state backfilled from v1 state", "rows", n) + } +``` + +In `build`, immediately before `acctLabel := a.DisplayName` / `w := watcher.New(...)`, insert: + +```go + if pipelineModeFor(ctx, q, a.ID) == "v2" { + prio, err := dropStore.StreamerPriority(ctx, a.ID) + if err != nil { + logger.Warn("pipeline v2: load streamer priority failed", "account", a.ID, "err", err) + } + var force []string + if rows, err := q.ListForceChannels(ctx, a.ID); err == nil { + for _, r := range rows { + force = append(force, r.Channel) + } + } + l, err := loop.New(loop.Config{ + AccountID: a.ID, AccountLabel: a.DisplayName, Platform: a.Platform, + Backend: b, Session: sess, + Store: dropStore, History: claimRecorder, Persister: campaignPersister, + Notifier: notifier, + AllowGame: allow, AllowChannel: matchAnyChannel(prio), GameRank: rank, + PriorityMode: priorityMode, ForceLinked: forceLinked, + StreamerPriority: prio, ForceWatch: force, + ProgressNotifyStepPct: progressStep, + }) + if err == nil { + logger.Info("pipeline v2 enabled for account", "account", a.ID, "platform", a.Platform) + return scheduler.NewEntry(a.ID, l), nil + } + logger.Warn("pipeline v2 unavailable, falling back to v1", "account", a.ID, "err", err) + } +``` + +Add import `"github.com/aalejandrofer/grubdrops/internal/pipeline/loop"`. `notifier` must satisfy `loop.Notifier` (same `Notify` signature as `watcher.Notifier`); if the variable's type is a concrete notify type, it already has the method. If `go build` reports `forceCollected` / `persistedSkips` unused only in some path, leave v1 code as is: they are still used by the v1 branch below. + +- [ ] **Step 5: Build + full tests** + +Run: `gofmt -w . && go build ./... && go test ./... 2>&1 | tail -30` +Expected: all packages `ok`. + +- [ ] **Step 6: Local smoke run** + +```bash +mkdir -p /tmp/grub-v2 && GRUB_MASTER_KEY=$(go run ./cmd/miner keygen) \ + GRUB_DB_PATH=/tmp/grub-v2/miner.db GRUB_SECURE_COOKIES=0 GRUB_PIPELINE=v2 \ + timeout 20 go run ./cmd/miner 2>&1 | grep -E "pipeline|backfill|error" | head -20 +``` + +Expected: starts without panic. With no accounts, there are no `pipeline v2 enabled` lines, and that's fine. This only proves boot + migration + backfill. + +- [ ] **Step 7: Commit** + +```bash +git add internal/scheduler internal/store/campaign_persister.go cmd/miner +git commit -m "feat(pipeline-v2): opt-in GRUB_PIPELINE=v2 wiring and scheduler interfaces + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 13: Changelog + spec refinements + whole-branch verification + +**Files:** +- Modify: `docs/CHANGELOG.md` (`## [Unreleased]`) +- Modify: `docs/superpowers/specs/2026-09-27-pipeline-v2-design.md` + +- [ ] **Step 1: Changelog entries** under `## [Unreleased]` + +```markdown +### Added +- Pipeline v2 (opt-in, `GRUB_PIPELINE=v2` or kv `pipeline_override:`): drop state now comes from each campaign's platform details, so drops claimed on the website or with a lost claim response show as claimed and mining moves on (#50). +- Streamer priority for pipeline v2: per-account priority streamers are watched first, then any live channel in the category (#49). Seeded from the existing null-game channel list. +- `drop_state` and `account_streamer_priority` tables (migration 0016), with a one-time backfill from claim history, manual marks and ghost-skips. + +### Changed +- Twitch claim code split into status fetch + classification (behaviour unchanged). +- Scheduler reads dashboard snapshots and discoveries through interfaces, so v1 and v2 runners both report state. + +### Fixed (pipeline v2 only) +- Claim failures back off 1m, 5m, 30m and stop after 5 attempts instead of looping forever. +- Kick link-required claims block with a reason instead of retrying every poll. +- Kick required minutes come from the platform, not an invented 120. +- Ghost-skipped drops are re-checked instead of being skipped forever. +``` + +- [ ] **Step 2: Record spec refinements** + +In the spec, add a short "## 10. Refinements during planning" section listing the four refinements from this plan's header (serial reconcile, unix INTEGER timestamps, `user_skip` no auto-expiry, interface names). + +- [ ] **Step 3: Whole-branch verification** + +Run: `gofmt -l . ; go vet ./... && go build ./... && go test -race ./... 2>&1 | tail -40` +Expected: `gofmt -l` prints nothing; all packages `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/CHANGELOG.md docs/superpowers/specs/2026-09-27-pipeline-v2-design.md +git commit -m "docs(pipeline-v2): changelog and spec refinements + +Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 14: Staging live verification (HUMAN-GATED, no tag) + +Accrual/claim change: CLAUDE.md requires a live drop before any tag. This task never tags. + +- [ ] **Step 1:** Push the branch and ask the user before deploying (staging runbook lives in the user's memory notes: local build, scp tar, `docker compose up` on the staging host; accounts are disabled by default there). +- [ ] **Step 2:** On staging, enable ONE Twitch account and ONE Kick account that have a live drop available, and set `GRUB_PIPELINE=v2` (or kv `pipeline_override:=v2` via `sqlite3`). +- [ ] **Step 3:** Watch logs for `pipeline decision`, `pipeline claim attempt`, `pipeline drop claimed`. Confirm on the platform's own site that minutes accrue and the claim lands. Confirm a drop claimed manually on the website flips to claimed within one reconcile (15 min) without a watch. +- [ ] **Step 4:** Record results (drop, channel, minutes, claim outcome) in the PR description. +- [ ] **Step 5:** Tear staging down (`docker compose down`) as soon as verification ends. +- [ ] **Step 6:** Hand back to the user for PR review and Plan 2. Do not merge or tag without their say-so. diff --git a/docs/superpowers/specs/2026-09-27-pipeline-v2-design.md b/docs/superpowers/specs/2026-09-27-pipeline-v2-design.md new file mode 100644 index 00000000..dbf307c7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-27-pipeline-v2-design.md @@ -0,0 +1,416 @@ +# Pipeline v2: campaign / progress / claim / inventory redesign + +Issue: #50 (redesign), #49 (streamer priority, folded in). +Status: design approved in brainstorming 2026-09-27; spec pending review. + +## 1. Goal + +Replace the accreted watcher state machine (`internal/watcher/watcher.go`, +~2,050 LOC, ~20 layered special cases) with a pipeline built around one +authoritative per-drop state model reconciled from the platform. + +### Success criteria + +1. A drop claimed anywhere (in app, on the website, Kick sweep, or with a lost + claim response) ends up `claimed` in GrubDrops and the account moves on + without user action. +2. The inventory / drops page shows correct status without manual marking. +3. For any drop, one state + reason explains why it is or isn't being mined. +4. Streamer priority works: per-account priority streamers first, category + fallback after (#49). +5. No drop is stuck forever: every `blocked` state carries a `retry_after` and + a UI "retry now". + +### Scope + +Starting point: **the account already has a valid session.** + +In scope: campaign discovery + filtering, campaign/channel selection +(incl. #49), watcher lifecycle, accrual orchestration, progress checks, +claiming, drop/claim state + reconciliation, inventory page state, the +backend interface the pipeline calls. + +Out of scope: login / session acquisition, authcheck, #48, general web UI, +settings (except the streamer priority control), notifications, SSO, proxy, +sidecar internals (browser stack stays core, per CLAUDE.md). + +Invariants: +- Accrual mechanics unchanged: Twitch Spade beacon at a fixed 60s + `HeartbeatInterval`; Kick WS / IVS watch path as-is. Only orchestration + around them changes. +- Existing DB data migrates in place. No forced reset. +- Stay Go + html/template + HTMX. + +## 2. Problems being fixed (from code map, master @ efb8b39) + +| # | Problem | Current cause | +|---|---|---| +| P1 | Externally claimed Twitch drop shows unclaimed forever, wastes a watch | Reconcile reads only `dropCampaignsInProgress`; finished campaigns vanish from it; ghost-skip persists a skip but never a claim row | +| P2 | Lost claim response → same as P1 | Claim error path relies on reconcile that can't see finished campaigns | +| P3 | Endless claim retry loop | No failure counter; non-success statuses loop pick → watch → claim | +| P4 | Kick link-gated claim loops in the watcher | `needsLink` guards only the sweep; Kick forces `AccountLinked=true` | +| P5 | Watch continues after drop claimed mid-watch | `tickWatch` never reads `Progress.Claimed` | +| P6 | Kick minutes never reach required | Adapter maps 0 → 120 required; progress uses `required_units` | +| P7 | Kick ghost-skip deadlock | Reward listed only after accrual; skip blocks accrual; no UI to clear `skip_override` | +| P8 | Prune flip-flop deletes genuine claim rows | Prune on transient `IsClaimed=false` | +| P9 | Precondition never unlocks after manual mark | Gate reads inventory `claimed`, not local state | +| P10 | Manual mark restarts every watcher | `addClaim` calls full `loadAndStart` reload | +| P11 | Kick sweep claims without writing state | Sweep bypasses `claims` | +| P12 | Platform logic leaks into generic code | `Platform == "twitch"` checks, `oilrats`, `Game: "Rust"`, placeholder-ID heuristic | + +## 3. Architecture + +``` + ┌──────────────┐ state changes ┌────────────┐ + platform ───▶ │ Reconciler │ ───────────────▶ │ │ + (detail/ └──────────────┘ │ │ + progress) │ Account │──▶ planner.Plan(snapshot) + ┌──────────────┐ Progress/ │ loop │ │ + platform ◀──▶ │ Session │ StreamDown/ │ (1 gorout- │◀───────┘ Decision + (watch) └──────────────┘ Stalled ──────▶ │ ine owns │ + ┌──────────────┐ │ state) │ + platform ◀─── │ Claimer │ ◀── claimable ── │ │ + └──────────────┘ ── result ─────▶ │ │ + UI pings (mark / skip / retry / priority) ────▶ └────────────┘ + │ + drop_state (DB) ──▶ /drops, dashboard +``` + +New packages (names indicative, final in plan): + +- `internal/pipeline/dropstate`: state model, transitions, `DropStateStore`. +- `internal/pipeline/reconcile`: platform → drop_state sync. +- `internal/pipeline/planner`: pure `Plan(PlanInput) Decision`. +- `internal/pipeline/session`: one accrual run on one channel. +- `internal/pipeline/claimer`: claim policy + backoff. +- `internal/pipeline/loop`: per-account event loop wiring the above. + +v1 (`internal/watcher`) is untouched and stays selectable until v2 is +live-verified; then deleted in a follow-up release. + +## 4. Drop state model + reconciler + +### 4.1 `drop_state` table (new migration `0016_drop_state.sql`) + +One row per (account_id, drop_id). + +| column | type | meaning | +|---|---|---| +| `account_id` | TEXT | FK accounts | +| `drop_id` | TEXT | platform drop / reward id (Twitch timeBasedDrop id; Kick reward id) | +| `campaign_id` | TEXT | owning campaign | +| `platform` | TEXT | `twitch` / `kick` | +| `status` | TEXT | `eligible`, `accruing`, `claimable`, `claimed`, `blocked` | +| `block_reason` | TEXT NULL | `needs_link` (claim-level, 24h hold), `unlinked` (campaign-level, lifted on the next definite answer), `claim_failed`, `sub_only`, `no_channels`, `expired`, `not_enrolled`, `user_skip` | +| `minutes` | INTEGER | platform-reported watched minutes | +| `required` | INTEGER | platform-reported required minutes | +| `source` | TEXT | `platform` or `user` (who last set status) | +| `fail_count` | INTEGER | consecutive claim failures | +| `retry_after` | TIMESTAMP NULL | when a `blocked` row is re-evaluated | +| `synced_at` | TIMESTAMP | last platform confirmation | +| `updated_at` | TIMESTAMP | | + +PK (account_id, drop_id). Index on (account_id, status). + +Status meaning: +- `eligible`: mineable, no progress yet. +- `accruing`: 0 < minutes < required. +- `claimable`: minutes ≥ required (platform-reported) and not claimed. +- `claimed`: platform says claimed, or our claim call succeeded, or user mark. +- `blocked`: not mineable now; `block_reason` says why, `retry_after` says when to look again. + +Multi-item Twitch drops (one timeBasedDrop, several rewards) are **one** +drop_state row keyed by the timeBasedDrop id; the reward names are display +data held on the benefits side, not separate state. + +### 4.2 Reconciler + +Per account, own goroutine. Emits `StateChanged` events to the loop; never +decides what to watch. + +**Triggers:** start-up; every `GRUB_RECONCILE_INTERVAL` (default 15 min); +immediately after any claim attempt; on `Stalled` from a session; on UI +"retry now". + +**Twitch:** +1. `ListActiveCampaigns`, filtered to whitelisted games for this account. +2. For each in-scope campaign, `DropCampaignDetails` (already wired, + `twitch/campaigns.go` with `detailsCache`). Per `timeBasedDrop.self`: + `isClaimed`, `currentMinutesWatched`, `dropInstanceID`. Details cache is + bypassed on post-claim reconciles. +3. Map to status. This works for campaigns that have left + `dropCampaignsInProgress`, which is the fix for P1/P2. + +Verification item (plan task 1): confirm the persisted query response +carries `self { isClaimed currentMinutesWatched dropInstanceID }` for an +enrolled but completed campaign. If `self` is absent, fall back to +`Inventory.dropCampaignsInProgress` + per-drop claim result, and the spec is +revisited before building on it. + +**Kick:** +1. `/drops/progress` (Bearer session) + `CampaignDetails`. +2. `required` always comes from the platform (`required_units`); the adapter + no longer invents 120 (fixes P6). A reward with no platform minutes and no + progress entry is `eligible` with `required` from campaign detail. +3. Verification item (plan task 1): does `/drops/progress` keep claimed + rewards listed with `claimed=true`, or drop them? + - If kept: straightforward mapping. + - If dropped: a reward that disappears is `claimed` **only** if our own + claim call returned success for it; otherwise `blocked:not_enrolled` + with `retry_after` (no inference from absence). + +**Rules:** +- `claimed` from the platform is monotonic: never demoted by a later sync. + No prune (fixes P8). +- A `user` mark stands while the platform is silent about the drop; a + definite platform answer overwrites it (`source` flips to `platform`). +- `blocked` rows are re-evaluated when `now ≥ retry_after`; there is no + permanent skip (replaces ghost-skip + self-heal, fixes P7). +- Every transition into `claimed` also appends to `claims` (history), via + `RecordClaimIfNew`, regardless of who claimed (fixes P11). + +### 4.3 Removed by this section (v2 path) + +`tracked` set + prune, ghost-skip + self-heal, vanish detection, +`skip_override:` / `collect_override:` kv reads, full reload on manual mark. + +### 4.4 Migration + +`0016_drop_state.sql` creates the table. A one-shot Go backfill at startup +(idempotent, guarded by a kv flag `drop_state_backfilled`): +- `claims` rows → `claimed`, `source=platform`. +- `collect_override:` → `claimed`, `source=user`. +- `skip_override:` → `blocked:not_enrolled`, `retry_after=now` (so they get + one fresh evaluation instead of staying dead). + +Old kv keys are left in place until v1 is deleted, so rollback to v1 is safe. + +sqlc: no `?` or parentheses in `queries/*.sql` comments. + +## 5. Planner + channel selection (#49) + +### 5.1 Contract + +```go +func Plan(in PlanInput) Decision + +type PlanInput struct { + Now time.Time + Drops []dropstate.Row // this account + Campaigns []platform.Campaign // in-scope, with windows + preconditions + Settings AccountSettings // game priority, streamer priority, force-watch + Live map[CampaignID][]Stream // candidate live channels per campaign + Cooldowns map[ChannelKey]time.Time // stalled channels + Current *Decision // for swap hysteresis + LastSwap time.Time +} + +type Decision struct { + Kind DecisionKind // Mine, ForceWatch, Idle + Channel Stream + Serves []DropID // all candidate drops this channel accrues + Reason string // human-readable, shown on dashboard +} +``` + +Pure: no I/O, no clock reads, no goroutines. + +### 5.2 Algorithm + +1. **Candidates:** drops in `eligible` / `accruing`, game whitelisted, now + inside campaign window, preconditions satisfied. Preconditions read + `drop_state` (a user-marked or platform-claimed precondition unlocks; + fixes P9). `sub_only` (0-minute Twitch) drops are `blocked:sub_only` + upstream and never candidates. +2. **Rank campaigns:** game priority → ends soonest → lowest-tier first. +3. **Channel for top campaign:** + 1. Streamer priority list (per account, ordered) ∩ live ∩ right category + ∩ campaign allow-list (if restricted) ∩ not on cooldown. + 2. Fallback: other eligible live channels by viewers. + If none, try next campaign. If no campaign yields a channel → force-watch + or idle. +4. **Serves:** every candidate drop (any campaign) that the chosen channel + accrues. Kick: typically several; Twitch: typically one. +5. **Hysteresis:** if `Current` is still valid (channel live, serves ≥1 + candidate) and `Now - LastSwap < 10m`, keep it. A dead/stalled current + channel bypasses the hold. +6. **Force-watch:** only when no Mine decision exists; first **live** + force-watch channel for the account. Idle otherwise, with reason + (`no_games`, `no_live_channels`, `all_claimed`, ...). + +### 5.3 Channel probing + +New optional backend capability: + +```go +type ChannelProber interface { + ProbeChannels(ctx context.Context, s Session, c Campaign, logins []string) ([]Stream, error) +} +``` + +The loop calls it for priority streamers so low-viewer favourites outside +Twitch's top-30 directory are still found. Kick implements it on top of the +existing `probeLive`. Twitch implements it with the existing per-login live ++ game check, taking logins from the argument (not the cached allow-list). + +### 5.4 Storage + UI + +New table `account_streamer_priority(account_id, platform, login, rank)`, +created in `0016` alongside drop_state. Existing `account_channels` +(null-game whitelist) rows are copied in, keeping order; the null-game gate +reads the new table. `account_channels` is dropped when v1 is deleted. + +UI: ordered streamer list per account next to game priority (drag-reorder +or up/down, reuse existing priority control pattern). Editing pings the +loop; no reload. + +### 5.5 Removed + +`Platform == "twitch"` check in pick, no-stream campaign set, `oilrats` +preference, scattered sorts in `pickCampaign`, force-watch without liveness. + +## 6. Session, claimer, loop, backend interface + +### 6.1 Session + +Wraps existing `StartWatch` / `Heartbeat` / `StopWatch`. Emits: +- `Progress{DropID, Minutes, Required, Claimed}` (Twitch PubSub or Kick poll) +- `StreamDown` +- `Stalled` (no minute gain for N consecutive polls; N from current freeze + detector) + +Reports only; makes no decisions. `Progress.Claimed=true` is honoured by the +loop immediately (fixes P5). + +### 6.2 Claimer + +Triggered when a drop enters `claimable`. + +| Outcome | Action | +|---|---| +| Success / Twitch `DROP_INSTANCE_ALREADY_CLAIMED` | → `claimed`; trigger reconcile | +| Link required (Kick connect_url, Twitch unlinked) | → `blocked:needs_link`, no retry; UI "link, then retry" (fixes P4) | +| Other error | `fail_count++`, backoff 1m → 5m → 30m; after 5 → `blocked:claim_failed`, `retry_after` 6h (fixes P3) | +| Lost response | post-claim reconcile reads `isClaimed` and corrects (fixes P2) | + +Kick sweep becomes claimer policy: on each Kick progress poll, claim +every drop in `claimable` for that account. Same state writes as a normal +claim, so no unrecorded claims. Hardcoded `Game: "Rust"` removed. + +### 6.3 Account loop + +```go +events := merge(reconciler.Events, session.Events, ticker.C, uiPings) +for ev := range events { + apply(ev) // mutate drop_state via DropStateStore + d := planner.Plan(snapshot()) + if !d.Equal(current) { swapSession(d) } +} +``` + +- Single goroutine owns all transitions. +- Live re-check ticker: 5 min (feeds `Live` for the planner, including + priority-streamer probes). +- UI actions (mark collected, skip, retry, priority edit) send a ping; no + `Reload` (fixes P10). +- Exposes a snapshot (status, decision reason, channel, serving drops) to the + scheduler for the dashboard, via the existing scheduler state API. + +### 6.4 Backend interface + +Split for the pipeline (login methods stay on the existing `Backend`, out of +the pipeline): + +```go +type CampaignSource interface { ListActiveCampaigns(ctx, s) ([]Campaign, error) } +type ProgressSource interface { DropProgress(ctx, s, campaigns []Campaign) ([]DropProgress, error) } +type ChannelSource interface { ListEligibleChannels(ctx, s, c) ([]Stream, error) } +type ChannelProber interface { ProbeChannels(ctx, s, c, logins) ([]Stream, error) } +type WatchSource interface { StartWatch(...); Heartbeat(...); StopWatch(...) } +type ClaimSink interface { Claim(ctx, s, DropProgress) (ClaimResult, error) } +``` + +`ClaimResult` carries typed outcomes (`Claimed`, `AlreadyClaimed`, +`NeedsLink`, `Failed`) instead of string-matched errors. + +A `pipeline.Platform` struct is assembled once per backend at construction, +holding whichever capabilities it has; no type assertions in the loop. Store +access through one `DropStateStore` interface (replaces anonymous +assertions `ClaimedBenefitIDs` / `RecordClaimIfNew` / `PruneClaim` / +`PersistAccountLinks`). + +Platform quirks stay in adapters: Kick required-minutes normalisation, +Twitch placeholder-ID handling, Kick channel preferences. + +### 6.5 Rollout + +- `GRUB_PIPELINE=v1|v2` (default `v2` as of 1.4.5), overridable per account + (account settings toggle / kv `pipeline_override:`) so a single + account can still fall back to v1 without affecting the rest. +- `cmd/miner/main.go` `build()` branches on the selection; v1 wiring + unchanged. `build()` also falls back to v1 automatically, per account, + when `loop.New` itself fails (e.g. no `BrowserBackend` wired for Kick). +- Default flipped from `v1` to `v2` in 1.4.5 batch B. v1 deletion (incl. + `watcher.go`, `skip_override`/`collect_override` loaders, `account_channels`) + is a separate follow-up release. + +## 7. Error handling + +- Platform call errors inside reconcile: log, keep last known state, retry + next tick. Never demote state on error. +- Session start failure: channel onto cooldown, re-plan. +- DB write failure: loop logs and retries the apply on next event; planner + runs on in-memory snapshot so mining continues. +- `ErrIntegrityBlocked` and session-expiry surface to the scheduler as + today (auth is out of scope); v2 does not change their handling. + +## 8. Testing + +- **Planner:** table-driven tests. Cases: priority streamer live / offline / + not in allow-list, fallback, hysteresis hold and dead-channel bypass, + preconditions via user mark, force-watch liveness, idle reasons, Kick + multi-serve. +- **Claimer:** table tests over `ClaimResult` outcomes and backoff ladder. +- **Reconciler:** fake backend responses. Cases: finished Twitch campaign + with `isClaimed=true` (P1), lost claim response (P2), Kick claimed + retained vs. dropped, user mark overwritten by platform, `claimed` never + demoted, blocked retry_after expiry. +- **Loop:** event-sequence tests with fake clock. Cases: claimed on website + mid-watch (P5), stall → cooldown → swap, UI ping re-plans without reload. +- **Migration:** backfill from claims + kv overrides, idempotent on rerun. +- **Adapters:** Kick required-minutes from platform (P6); Twitch + `DropProgress` from details `self`. +- Every regression test must fail against a deliberate revert of its fix. + +Release gate: green build + unit tests + Kick WS canary, then live-drop +verification (Twitch and Kick) on staging before any tag that enables v2. + +## 9. Open verification items (plan task 1, before building on them) + +1. Twitch `DropCampaignDetails` returns `self` for enrolled completed + campaigns. +2. Kick `/drops/progress` retention of claimed rewards. + +Either answer may adjust §4.2 mapping; the rest of the design is unaffected. + +Results (2026-09-28, probes behind the `live` build tag): + +- Kick `/drops/progress` keeps claimed rewards listed with `claimed=true` + (15/15 on a prod account). +- Twitch `DropCampaignDetails` (Android client) returns `self: null` for + drops of campaigns the account has already claimed; the TV client has no + details at all. So Twitch claimed-outside-app detection for campaigns that + left Inventory relies on the stall claim-probe (§10 item 9). Active-campaign + `self` presence is untested (no in-progress campaigns on the probe account). + +## 10. Refinements during planning + +1. Reconcile runs serially on the loop goroutine (single writer; session/PubSub events buffer meanwhile). +2. Timestamps stored as INTEGER unix seconds, 0 = unset; block_reason NOT NULL DEFAULT ''. +3. user_skip has no auto-expiry; cleared only by the user. +4. Platform interfaces named DropProgressSource, DropClaimer (ClaimDrop returns a typed ClaimResult), ChannelProber in internal/platform. +5. Every dropstate mutator leaves a platform-confirmed claim untouched; sub_only re-derives after its window like not_enrolled. +6. Session stall detection is per-drop, and the loop restarts the session on the same channel when the served-drop set changes; session events carry a generation so stale events are dropped. +7. A claimed=true from either Twitch source (details self or in-progress inventory) wins. +8. Kick rows listed without required_units fall back to campaign minutes (reporting 0 would block as sub_only). +9. Twitch stall claim-probe: when a watched Eligible drop is absent from Inventory and the session stalls on it for the second time (the first stall is a plain cooldown), the loop sends one claim; ALREADY_CLAIMED/OK marks it claimed, failure (including an empty claim status) blocks it not_enrolled (1h re-check) without counting a claim failure. diff --git a/internal/api/handlers_dashboard.go b/internal/api/handlers_dashboard.go index ede25340..a663c572 100644 --- a/internal/api/handlers_dashboard.go +++ b/internal/api/handlers_dashboard.go @@ -62,6 +62,10 @@ type dashboardDeps struct { s *store.Settings start time.Time loc *timeutil.Zone // display timezone (live; setting → TZ env → UTC) + // sessions loads decrypted platform sessions, used only to detect a + // Twitch TV-client login for the tv_discovery banner. Nil-safe: + // tvDiscoveryAlert returns nil when unset. + sessions *store.SessionStore // channelCounters is keyed by platform name ("twitch", "kick"). Nil // or missing entries make the dashboard fall back to zero for that // platform — safer than panicking when a backend isn't wired up. @@ -250,10 +254,15 @@ type dashPage struct { } type dashAlert struct { - Kind string // "needs_auth" | "no_drops" - Account string // display @login - URL string // direct CTA link - Action string // button label + Kind string // "needs_auth" | "no_drops" | "tv_discovery" | ... + Account string // display @login, or (for account-agnostic alerts like + // tv_discovery) the alert's bold title text + URL string // direct CTA link + Action string // button label + // Warning styles the alert with the red/warning accent instead of the + // default info accent (same copy, different emphasis). Used by + // tv_discovery when the affected account's effective whitelist is empty. + Warning bool } func (d dashboardDeps) collectPage(r *http.Request) dashPage { @@ -758,6 +767,13 @@ func mineCardFromSnap(a gen.Account, s watcher.Snapshot, lang string) dashMineCa c.Channel = s.Channel c.ChannelInitial = initial(s.Channel) c.ChannelURL = channelURL(a.Platform, s.Channel) + case "discovering": + c.StateSub = "mining.discovering" + c.Uptime = formatShort(time.Since(s.StartedAt), lang) + c.Channel = s.Channel + c.ChannelInitial = initial(s.Channel) + c.ChannelGame = s.CampaignGame + c.ChannelURL = channelURL(a.Platform, s.Channel) case "pick_stream": c.StateSub = "mining.scanning_channels" c.DropName = s.BenefitName @@ -952,12 +968,20 @@ func (d dashboardDeps) page(w http.ResponseWriter, r *http.Request) { if d.sm != nil { flash = d.sm.PopString(r.Context(), "flash") } + page := d.collectPage(r) + // tv_discovery alert goes first — it's an install-wide notice, not tied to + // one account, so it leads the other per-account alerts. Only computed on + // full-page renders, not on polled HTMX partials (cards/telemetry). + lang := i18n.DetectLang(r) + if tv := tvDiscoveryAlert(r.Context(), d.q, d.sessions, lang); tv != nil { + page.Alerts = append([]dashAlert{*tv}, page.Alerts...) + } render(w, r, d.t, "dashboard.html", templateData{ AuthedAdmin: true, CSRFToken: csrfToken(r), Active: "dashboard", Flash: flash, - Page: d.collectPage(r), + Page: page, }) } diff --git a/internal/api/handlers_dashboard_test.go b/internal/api/handlers_dashboard_test.go index c5b7f57f..ddeb4329 100644 --- a/internal/api/handlers_dashboard_test.go +++ b/internal/api/handlers_dashboard_test.go @@ -239,3 +239,29 @@ func TestCompletedByAllConnected_NilQueriesCountsTotalOnly(t *testing.T) { assert.Equal(t, 2, total) assert.Equal(t, 0, done) } + +// TestMineCardFromSnap_Discovering mirrors the "force_watch" case: the v2 +// enroll loop reports Snapshot State "discovering" while it briefly watches +// a channel to enroll the account in a campaign (see +// internal/pipeline/loop/enroll.go). The dashboard mining row must show it +// like a live activity (channel + game), not the raw state word, so the +// i18n key resolves and the pill/label mirror force_watch's handling. +func TestMineCardFromSnap_Discovering(t *testing.T) { + acc := gen.Account{ID: "acc1", Platform: "twitch", DisplayName: "Foo", Enabled: 1} + started := time.Now().Add(-2 * time.Minute) + snap := watcher.Snapshot{ + AccountID: "acc1", + State: "discovering", + Channel: "some_channel", + ViewerCount: 42, + CampaignGame: "Some Game", + StartedAt: started, + } + card := mineCardFromSnap(acc, snap, "en") + assert.Equal(t, "discovering", card.State) + assert.Equal(t, "mining.discovering", card.StateSub) + assert.Equal(t, "some_channel", card.Channel) + assert.Equal(t, "Some Game", card.ChannelGame) + assert.Equal(t, channelURL("twitch", "some_channel"), card.ChannelURL) + assert.NotEmpty(t, card.Uptime) +} diff --git a/internal/api/handlers_drops.go b/internal/api/handlers_drops.go index 382a7c9f..2292e28c 100644 --- a/internal/api/handlers_drops.go +++ b/internal/api/handlers_drops.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/aalejandrofer/grubdrops/internal/gameslug" + "github.com/aalejandrofer/grubdrops/internal/i18n" "github.com/aalejandrofer/grubdrops/internal/platform" "github.com/aalejandrofer/grubdrops/internal/store" "github.com/aalejandrofer/grubdrops/internal/store/gen" @@ -286,6 +287,9 @@ type dropsPage struct { // a bootstrap CTA in this case instead of misleading "discovery populates // this" empty text. NoWhitelist bool + // Alerts is the same top-of-page banner component the dashboard renders + // (dashAlert) — reused here for the tv_discovery notice. + Alerts []dashAlert } type dropsAccount struct { @@ -437,6 +441,7 @@ func (d *dropsDeps) list(w http.ResponseWriter, r *http.Request) { tab = tabCurrent } + lang := i18n.DetectLang(r) allow, hasWhitelist := allowedGamesUnion(r.Context(), d.q) now := time.Now().Unix() const limit = 200 @@ -526,6 +531,9 @@ func (d *dropsDeps) list(w http.ResponseWriter, r *http.Request) { CSRFToken: csrfToken(r), NoWhitelist: !hasWhitelist, } + if tv := tvDiscoveryAlert(r.Context(), d.q, d.sessions, lang); tv != nil { + page.Alerts = append(page.Alerts, *tv) + } switch tab { case tabPast: page.Rows = pastRows @@ -1022,10 +1030,22 @@ func (d *dropsDeps) addChannelWhitelist(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, "/drops", http.StatusSeeOther) return } - if _, err := d.q.GetAccount(r.Context(), accID); err != nil { + acct, err := d.q.GetAccount(r.Context(), accID) + if err != nil { http.NotFound(w, r) return } + // v2 reads account_streamer_priority exclusively (account_channels was + // only ever copied there once, by the 0016 migration seed), so every + // add here must land in both tables to reach v2 accounts too. + nextRank := int64(0) + if prio, err := d.q.ListStreamerPriority(r.Context(), accID); err == nil { + for _, p := range prio { + if p.Rank >= nextRank { + nextRank = p.Rank + 1 + } + } + } seen := map[string]struct{}{} added := 0 for _, raw := range r.Form["channel"] { @@ -1046,6 +1066,16 @@ func (d *dropsDeps) addChannelWhitelist(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, "/drops", http.StatusSeeOther) return } + if err := d.q.AddStreamerPriority(r.Context(), gen.AddStreamerPriorityParams{ + AccountID: accID, Platform: acct.Platform, Login: ch, Rank: nextRank, + }); err != nil { + if d.sm != nil { + d.sm.Put(r.Context(), "flash", "flash.channel_whitelist_failed") + } + http.Redirect(w, r, "/drops", http.StatusSeeOther) + return + } + nextRank++ added++ } // Reload the scheduler so the watcher re-picks immediately and starts @@ -1090,6 +1120,12 @@ func (d *dropsDeps) removeChannelWhitelist(w http.ResponseWriter, r *http.Reques http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := d.q.RemoveStreamerPriority(r.Context(), gen.RemoveStreamerPriorityParams{ + AccountID: accID, Login: ch, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } removed++ } if removed > 0 && d.reload != nil { diff --git a/internal/api/handlers_drops_channel_test.go b/internal/api/handlers_drops_channel_test.go index 707e0d89..9a2581c1 100644 --- a/internal/api/handlers_drops_channel_test.go +++ b/internal/api/handlers_drops_channel_test.go @@ -54,3 +54,107 @@ func TestAddChannelWhitelist_InsertsRows(t *testing.T) { require.Len(t, rows, 1) assert.Equal(t, "adrianozendejas32", rows[0].Channel) } + +// TestAddChannelWhitelist_WritesBothTables proves that whitelisting a +// channel writes account_channels (v1) AND account_streamer_priority (v2), +// with the platform pulled from the account and rank = next rank (max+1) +// for that account — v2's dropStore.StreamerPriority never saw edits after +// the one-time 0016 migration seed, since it reads account_streamer_priority +// exclusively. +func TestAddChannelWhitelist_WritesBothTables(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, filepath.Join(t.TempDir(), "test.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + now := time.Now().Unix() + _, err = q.CreateAccount(ctx, gen.CreateAccountParams{ + ID: "acc-1", Platform: "kick", DisplayName: "TTik3r", + Status: "idle", FingerprintJson: "{}", Enabled: 1, + CreatedAt: now, UpdatedAt: now, + }) + require.NoError(t, err) + + // Pre-seed one existing priority row so the new one must land at + // max+1, not a hardcoded rank. + require.NoError(t, q.AddStreamerPriority(ctx, gen.AddStreamerPriorityParams{ + AccountID: "acc-1", Platform: "kick", Login: "existingchan", Rank: 0, + })) + + d := &dropsDeps{q: q} + + form := url.Values{} + form.Set("account_id", "acc-1") + form.Add("channel", "adrianozendejas32") + + req := httptest.NewRequest(http.MethodPost, "/drops/whitelist/channel", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + d.addChannelWhitelist(rec, req) + + require.Equal(t, http.StatusSeeOther, rec.Code) + + acRows, err := q.ListAccountChannels(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, acRows, 1) + assert.Equal(t, "adrianozendejas32", acRows[0].Channel) + + prioRows, err := q.ListStreamerPriority(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, prioRows, 2) + var found bool + for _, r := range prioRows { + if r.Login == "adrianozendejas32" { + found = true + assert.Equal(t, int64(1), r.Rank, "new entry ranks after the existing max (0)") + } + } + assert.True(t, found, "adrianozendejas32 must be written to account_streamer_priority") +} + +// TestRemoveChannelWhitelist_DeletesFromBothTables proves the ✕ un-whitelist +// action deletes from account_channels (v1) AND account_streamer_priority +// (v2), so a removed channel actually stops being mined under v2 too. +func TestRemoveChannelWhitelist_DeletesFromBothTables(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, filepath.Join(t.TempDir(), "test.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + now := time.Now().Unix() + _, err = q.CreateAccount(ctx, gen.CreateAccountParams{ + ID: "acc-1", Platform: "kick", DisplayName: "TTik3r", + Status: "idle", FingerprintJson: "{}", Enabled: 1, + CreatedAt: now, UpdatedAt: now, + }) + require.NoError(t, err) + require.NoError(t, q.AddAccountChannel(ctx, gen.AddAccountChannelParams{ + AccountID: "acc-1", Channel: "adrianozendejas32", Rank: 0, + })) + require.NoError(t, q.AddStreamerPriority(ctx, gen.AddStreamerPriorityParams{ + AccountID: "acc-1", Platform: "kick", Login: "adrianozendejas32", Rank: 0, + })) + + d := &dropsDeps{q: q} + + form := url.Values{} + form.Set("account_id", "acc-1") + form.Add("channel", "adrianozendejas32") + + req := httptest.NewRequest(http.MethodPost, "/drops/whitelist/channel/remove", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + d.removeChannelWhitelist(rec, req) + + require.Equal(t, http.StatusSeeOther, rec.Code) + + acRows, err := q.ListAccountChannels(ctx, "acc-1") + require.NoError(t, err) + assert.Empty(t, acRows) + + prioRows, err := q.ListStreamerPriority(ctx, "acc-1") + require.NoError(t, err) + assert.Empty(t, prioRows) +} diff --git a/internal/api/handlers_drops_collect_test.go b/internal/api/handlers_drops_collect_test.go index 5b0aa77e..bf1d95d7 100644 --- a/internal/api/handlers_drops_collect_test.go +++ b/internal/api/handlers_drops_collect_test.go @@ -276,3 +276,44 @@ func renderCampaignItems_render(t *testing.T, detail campaignDetailRow) string { require.NoError(t, tmpl.ExecuteTemplate(&buf, "drops_campaign_items", detail)) return buf.String() } + +// TestCollectAll_EndedCampaignIsPastWhateverItsStatus: a campaign whose end +// time has passed belongs to Past even when its stored status still says +// "active" (e.g. TV AvailableDrops rows never refreshed to expired). It +// must never show in Current or the Current Discoverable list. +func TestCollectAll_EndedCampaignIsPastWhateverItsStatus(t *testing.T) { + ctx := context.Background() + db, err := store.Open(ctx, filepath.Join(t.TempDir(), "test.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + + now := time.Now().Unix() + for _, c := range []struct{ id, game string }{{"ended-wl", "Rust"}, {"ended-nogame", ""}} { + require.NoError(t, q.UpsertCampaign(ctx, gen.UpsertCampaignParams{ + ID: c.id, Platform: "twitch", Game: c.game, Name: c.id, + StartsAt: now - 7200, EndsAt: now - 60, Status: "active", + RawJson: "{}", DiscoveredAt: now, Kind: "drop", AccountLinked: 1, + })) + } + + d := &dropsDeps{q: q, loc: timeutil.NewZone(time.UTC)} + allow := map[string]struct{}{"rust": {}} + ids := func(rows []dropsRow) map[string]bool { + out := map[string]bool{} + for _, r := range rows { + out[r.CampaignID] = true + } + return out + } + + past, current, _, unlistedCur, err := d.collectAll(ctx, allow, true, now, 200, tabCurrent) + require.NoError(t, err) + require.True(t, ids(past)["ended-wl"], "ended whitelisted campaign shows as past") + require.False(t, ids(current)["ended-wl"], "ended campaign must not show as current despite status=active") + require.False(t, ids(unlistedCur)["ended-nogame"], "ended game-less campaign must not show in Current Discoverable") + + _, _, _, unlistedPast, err := d.collectAll(ctx, allow, true, now, 200, tabPast) + require.NoError(t, err) + require.True(t, ids(unlistedPast)["ended-nogame"], "ended game-less campaign is listed under Past") +} diff --git a/internal/api/server.go b/internal/api/server.go index 0e553a25..33a60128 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -177,6 +177,7 @@ func NewRouter(d Deps) http.Handler { loc: d.Zone, channelCounters: channelCountersFromRegistry(d.Registry), kickPath: d.KickActivePath, + sessions: d.Sessions, } accs := accountsDeps{q: d.Q, db: d.DB, t: d.Templates, sm: d.Session, sch: d.Scheduler, reload: d.Reload, authCheck: d.AuthCheck, reloadAccount: d.ReloadAccount, rootCtx: d.RootCtx, loc: d.Zone} loginTwitch := newLoginTwitchDeps(d, d.RootCtx) diff --git a/internal/api/tv_discovery.go b/internal/api/tv_discovery.go new file mode 100644 index 00000000..7bb9f731 --- /dev/null +++ b/internal/api/tv_discovery.go @@ -0,0 +1,70 @@ +package api + +import ( + "context" + + "github.com/aalejandrofer/grubdrops/internal/i18n" + "github.com/aalejandrofer/grubdrops/internal/platform/twitch" + "github.com/aalejandrofer/grubdrops/internal/store" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// tvDiscoveryStatus reports whether at least one ENABLED Twitch account's +// session was minted by the TV client (Session.ClientID == twitch.ClientTV — +// every login since Twitch blocked the Android device-code client, #48) and, +// when so, whether any such account's effective whitelist is empty. A TV +// session can't see Twitch's drops dashboard at all, so its campaign +// discovery depends entirely on the whitelist (own games, falling back to +// the global list — the same resolution gameNamesForAccount already uses); +// an empty one means the account finds nothing. +// +// Cheap by construction: scans ENABLED accounts only (few, per +// buildDashAlerts' own assumption) and decrypts each Twitch session once. +// Returns booleans only — never session/token material — so callers can +// pass the result straight to a template without risk. +func tvDiscoveryStatus(ctx context.Context, q *gen.Queries, sessions *store.SessionStore) (anyTV bool, whitelistEmpty bool) { + if q == nil || sessions == nil { + return false, false + } + accs, err := q.ListEnabledAccounts(ctx) + if err != nil { + return false, false + } + for _, a := range accs { + if a.Platform != "twitch" { + continue + } + sess, ok, err := sessions.Get(ctx, a.ID) + if err != nil || !ok { + continue + } + if sess.ClientID != twitch.ClientTV { + continue + } + anyTV = true + if len(gameNamesForAccount(ctx, q, a.ID)) == 0 { + whitelistEmpty = true + } + } + return anyTV, whitelistEmpty +} + +// tvDiscoveryAlert builds the top-of-page banner warning that Twitch's +// TV-client login can't see the drops dashboard, so GrubDrops only finds +// campaigns for whitelisted games. It reuses the existing dashAlert +// component (the same one handlers_dashboard.go renders for needs_auth, +// etc.) so the dashboard and /drops share one code path and one CSS +// component. Returns nil when no enabled Twitch account has a TV session. +func tvDiscoveryAlert(ctx context.Context, q *gen.Queries, sessions *store.SessionStore, lang string) *dashAlert { + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + if !anyTV { + return nil + } + return &dashAlert{ + Kind: "tv_discovery", + Account: i18n.T(lang, "dashboard.tv_discovery_title"), + URL: "/priority", + Action: i18n.T(lang, "dashboard.action_whitelist_games"), + Warning: empty, + } +} diff --git a/internal/api/tv_discovery_test.go b/internal/api/tv_discovery_test.go new file mode 100644 index 00000000..2eb64509 --- /dev/null +++ b/internal/api/tv_discovery_test.go @@ -0,0 +1,265 @@ +package api + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/platform/twitch" + "github.com/aalejandrofer/grubdrops/internal/store" + "github.com/aalejandrofer/grubdrops/internal/store/gen" + "github.com/aalejandrofer/grubdrops/internal/timeutil" + "github.com/aalejandrofer/grubdrops/internal/web" +) + +// tvDiscoveryTestKey is an age secret key used only in tests. +const tvDiscoveryTestKey = "AGE-SECRET-KEY-1DZCAXYWJM6M42NSX5GR4QWZZ2JXEYKJ9ZKWYFYSNU997775JJ6XSY85FK9" + +func tvDiscoverySetup(t *testing.T) (context.Context, *gen.Queries, *store.SessionStore) { + t.Helper() + ctx := context.Background() + db, err := store.Open(ctx, filepath.Join(t.TempDir(), "tv.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + q := gen.New(db) + c, err := store.NewCryptor(tvDiscoveryTestKey) + require.NoError(t, err) + sessions := store.NewSessionStore(db, q, c) + return ctx, q, sessions +} + +func createTwitchAccount(t *testing.T, ctx context.Context, q *gen.Queries, id string, enabled bool) { + t.Helper() + now := time.Now().Unix() + en := int64(0) + if enabled { + en = 1 + } + _, err := q.CreateAccount(ctx, gen.CreateAccountParams{ + ID: id, Platform: "twitch", DisplayName: id, + Status: "idle", FingerprintJson: "{}", Enabled: en, + CreatedAt: now, UpdatedAt: now, + }) + require.NoError(t, err) +} + +// No enabled Twitch account at all → no alert (Android-only or empty install). +func TestTVDiscoveryStatus_NoAccounts(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + require.False(t, anyTV) + require.False(t, empty) +} + +// An Android-login account (ClientID "") must never trigger the banner. +func TestTVDiscoveryStatus_AndroidOnlyDoesNotTrigger(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_android", true) + require.NoError(t, sessions.Put(ctx, "acc_android", platform.Session{ClientID: ""})) + + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + require.False(t, anyTV) + require.False(t, empty) +} + +// A TV-client session on an enabled Twitch account triggers the banner. With +// at least one whitelisted game (account-level), the whitelist is NOT empty. +func TestTVDiscoveryStatus_TVAccountWithWhitelist(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + require.NoError(t, q.UpsertGame(ctx, gen.UpsertGameParams{ID: "g_test_tv", Name: "Test TV Game", Slug: "test-tv-game"})) + require.NoError(t, q.AddAccountGame(ctx, gen.AddAccountGameParams{AccountID: "acc_tv", GameID: "g_test_tv", Rank: 0})) + + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + require.True(t, anyTV) + require.False(t, empty) +} + +// A TV-client session with NO account games and NO global games → empty +// effective whitelist, the warning-variant trigger. +func TestTVDiscoveryStatus_TVAccountEmptyWhitelist(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + require.True(t, anyTV) + require.True(t, empty) +} + +// A TV account with no account-level games but a non-empty GLOBAL whitelist +// falls back to it (same resolution as gameNamesForAccount) — not empty. +func TestTVDiscoveryStatus_TVAccountFallsBackToGlobal(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + require.NoError(t, q.UpsertGame(ctx, gen.UpsertGameParams{ID: "g_test_tv", Name: "Test TV Game", Slug: "test-tv-game"})) + require.NoError(t, q.AddGlobalGame(ctx, gen.AddGlobalGameParams{GameID: "g_test_tv", Rank: 0})) + + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + require.True(t, anyTV) + require.False(t, empty) +} + +// A DISABLED Twitch account with a TV session must not trigger the banner — +// only ENABLED accounts count, matching buildDashAlerts' own rule. +func TestTVDiscoveryStatus_DisabledAccountIgnored(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv_off", false) + require.NoError(t, sessions.Put(ctx, "acc_tv_off", platform.Session{ClientID: twitch.ClientTV})) + + anyTV, empty := tvDiscoveryStatus(ctx, q, sessions) + require.False(t, anyTV) + require.False(t, empty) +} + +// tvDiscoveryAlert renders nil when there is nothing to warn about. +func TestTVDiscoveryAlert_NilWhenNoTVAccount(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + require.Nil(t, tvDiscoveryAlert(ctx, q, sessions, "en")) +} + +// tvDiscoveryAlert renders the info variant (Warning=false) when a TV +// account has a non-empty whitelist, and points at /priority. +func TestTVDiscoveryAlert_InfoVariant(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + require.NoError(t, q.UpsertGame(ctx, gen.UpsertGameParams{ID: "g_test_tv", Name: "Test TV Game", Slug: "test-tv-game"})) + require.NoError(t, q.AddAccountGame(ctx, gen.AddAccountGameParams{AccountID: "acc_tv", GameID: "g_test_tv", Rank: 0})) + + a := tvDiscoveryAlert(ctx, q, sessions, "en") + require.NotNil(t, a) + require.Equal(t, "tv_discovery", a.Kind) + require.False(t, a.Warning) + require.Equal(t, "/priority", a.URL) + require.NotEmpty(t, a.Account) + require.NotEmpty(t, a.Action) +} + +// tvDiscoveryAlert renders the warning variant when the TV account's +// effective whitelist is empty. +func TestTVDiscoveryAlert_WarningVariant(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + + a := tvDiscoveryAlert(ctx, q, sessions, "en") + require.NotNil(t, a) + require.True(t, a.Warning) +} + +// renderDashAlerts renders the shared dash_alerts partial (the same +// component both dashboard.html and drops.html use) with an ad-hoc page +// value carrying only the Alerts field, matching the field-by-name lookup +// html/template uses. +func renderDashAlerts(t *testing.T, alerts []dashAlert) string { + t.Helper() + tmpl, err := web.Templates() + require.NoError(t, err) + var buf bytes.Buffer + page := struct{ Alerts []dashAlert }{Alerts: alerts} + require.NoError(t, tmpl.ExecuteTemplate(&buf, "dash_alerts", page)) + return buf.String() +} + +// The tv_discovery banner (info variant) renders the title, body, CTA link +// to /priority, and does NOT carry the warning class. +func TestDashAlerts_TVDiscoveryInfoVariant(t *testing.T) { + out := renderDashAlerts(t, []dashAlert{{ + Kind: "tv_discovery", + Account: "Drop discovery is limited.", + URL: "/priority", + Action: "Whitelist games →", + Warning: false, + }}) + require.Contains(t, out, `href="/priority"`) + require.Contains(t, out, "Drop discovery is limited.") + require.Contains(t, out, "Twitch no longer lists campaigns for new logins") + require.Contains(t, out, "Whitelist games") + require.Contains(t, out, `class="alert alert-tv_discovery"`) + require.NotContains(t, out, "warning") +} + +// The warning variant (empty effective whitelist) adds the warning class to +// the same markup/copy. +func TestDashAlerts_TVDiscoveryWarningVariant(t *testing.T) { + out := renderDashAlerts(t, []dashAlert{{ + Kind: "tv_discovery", + Account: "Drop discovery is limited.", + URL: "/priority", + Action: "Whitelist games →", + Warning: true, + }}) + require.Contains(t, out, `class="alert alert-tv_discovery warning"`) +} + +// No alerts at all → the section doesn't render. +func TestDashAlerts_EmptyWhenNoAlerts(t *testing.T) { + out := renderDashAlerts(t, nil) + require.False(t, strings.Contains(out, `class="alerts"`)) +} + +// collectPage does NOT compute the tv_discovery alert — it's only added by +// the full-page handler. This guards against the alert decryption running on +// every polled HTMX partial (cards/telemetry, every 10s). +func TestDashboardCollectPage_NoTVDiscoveryComputed(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + + d := dashboardDeps{q: q, sessions: sessions, start: time.Now(), loc: timeutil.NewZone(time.UTC)} + req := httptest.NewRequest(http.MethodGet, "/", nil) + + // collectPage should return alerts from buildDashAlerts only, not tvDiscoveryAlert. + page := d.collectPage(req) + for _, a := range page.Alerts { + require.NotEqual(t, "tv_discovery", a.Kind, "collectPage must not compute TV discovery alert") + } + + // Verify tvDiscoveryAlert still works when called directly (as the page handler does). + tv := tvDiscoveryAlert(ctx, q, sessions, "en") + require.NotNil(t, tv) + require.Equal(t, "tv_discovery", tv.Kind) + require.True(t, tv.Warning) +} + +// collectPage excludes tv_discovery alert even when called with sessions present, +// since the page handler is responsible for adding it (not collectPage). This +// ensures polled partials (cards/telemetry) never trigger session decryption. +func TestDashboardCollectPage_ExcludesTVDiscoveryAlertAlways(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_android", true) + require.NoError(t, sessions.Put(ctx, "acc_android", platform.Session{ClientID: ""})) + + d := dashboardDeps{q: q, sessions: sessions, start: time.Now(), loc: timeutil.NewZone(time.UTC)} + req := httptest.NewRequest(http.MethodGet, "/", nil) + page := d.collectPage(req) + + for _, a := range page.Alerts { + require.NotEqual(t, "tv_discovery", a.Kind, "collectPage must never compute TV discovery alert") + } +} + +// The /drops page shares the same tvDiscoveryAlert construction: a TV +// account with a non-empty whitelist gets the info variant, not warning. +func TestDropsPage_TVDiscoveryBannerInfoVariant(t *testing.T) { + ctx, q, sessions := tvDiscoverySetup(t) + createTwitchAccount(t, ctx, q, "acc_tv", true) + require.NoError(t, sessions.Put(ctx, "acc_tv", platform.Session{ClientID: twitch.ClientTV})) + require.NoError(t, q.UpsertGame(ctx, gen.UpsertGameParams{ID: "g_test_tv2", Name: "Test TV Game 2", Slug: "test-tv-game-2"})) + require.NoError(t, q.AddAccountGame(ctx, gen.AddAccountGameParams{AccountID: "acc_tv", GameID: "g_test_tv2", Rank: 0})) + + tv := tvDiscoveryAlert(ctx, q, sessions, "en") + require.NotNil(t, tv) + require.False(t, tv.Warning) +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index cc2279cc..db474aaf 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -54,6 +54,10 @@ "dashboard.action_device_code": "Switch to device-code login →", "dashboard.action_connect": "Connect account →", "dashboard.action_add_games": "Add games →", + "dashboard.tv_discovery": "discovery limited", + "dashboard.tv_discovery_title": "Drop discovery is limited.", + "dashboard.alert_tv_discovery": "Twitch no longer lists campaigns for new logins, so GrubDrops only finds drops for games on your whitelist.", + "dashboard.action_whitelist_games": "Whitelist games →", "dashboard_modal.account": "account", "dashboard_modal.close": "Close", @@ -222,6 +226,7 @@ "mining.scanning": "scanning", "mining.scanning_channels": "scanning channels", "mining.force_watching": "watching", + "mining.discovering": "discovering", "mining.picking": "picking", "mining.idle": "idle", "mining.reload_account": "Reload this account", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 5370d3e2..6702ad84 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -54,6 +54,10 @@ "dashboard.action_device_code": "Cambiar a inicio con código de dispositivo →", "dashboard.action_connect": "Conectar cuenta →", "dashboard.action_add_games": "Añadir juegos →", + "dashboard.tv_discovery": "descubrimiento limitado", + "dashboard.tv_discovery_title": "El descubrimiento de drops está limitado.", + "dashboard.alert_tv_discovery": "Twitch ya no lista campañas para inicios de sesión nuevos, así que GrubDrops solo encuentra drops de los juegos en tu lista blanca.", + "dashboard.action_whitelist_games": "Añadir juegos a la lista blanca →", "dashboard_modal.account": "cuenta", "dashboard_modal.close": "Cerrar", @@ -222,6 +226,7 @@ "mining.scanning": "escaneando", "mining.scanning_channels": "escaneando canales", "mining.force_watching": "viendo", + "mining.discovering": "descubriendo", "mining.picking": "eligiendo", "mining.idle": "inactivo", "mining.reload_account": "Recargar esta cuenta", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 99d18662..76dfbfee 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -54,6 +54,10 @@ "dashboard.action_device_code": "切换到设备代码登录 →", "dashboard.action_connect": "关联账号 →", "dashboard.action_add_games": "添加游戏 →", + "dashboard.tv_discovery": "发现受限", + "dashboard.tv_discovery_title": "掉宝发现功能受限。", + "dashboard.alert_tv_discovery": "Twitch 不再为新登录列出活动,因此 GrubDrops 只能找到白名单游戏的掉宝。", + "dashboard.action_whitelist_games": "加入游戏白名单 →", "dashboard_modal.account": "账号", "dashboard_modal.close": "关闭", @@ -222,6 +226,7 @@ "mining.scanning": "扫描中", "mining.scanning_channels": "正在扫描频道", "mining.force_watching": "观看中", + "mining.discovering": "探索中", "mining.picking": "选择中", "mining.idle": "空闲", "mining.reload_account": "重新加载此账号", diff --git a/internal/pipeline/claimer/claimer.go b/internal/pipeline/claimer/claimer.go new file mode 100644 index 00000000..277ca782 --- /dev/null +++ b/internal/pipeline/claimer/claimer.go @@ -0,0 +1,25 @@ +// Package claimer turns a claimable drop into claimed or blocked. +package claimer + +import ( + "context" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// Attempt claims one drop and returns its next state. The caller must +// reconcile afterwards: a lost response shows up as ClaimFailed here and is +// corrected when the platform reports isClaimed. +func Attempt(ctx context.Context, c platform.DropClaimer, s platform.Session, row dropstate.Row, dp platform.DropProgress, now time.Time) (dropstate.Row, platform.ClaimResult) { + res := c.ClaimDrop(ctx, s, dp) + switch res.Outcome { + case platform.ClaimOK, platform.ClaimAlready: + return dropstate.ClaimOK(row, now), res + case platform.ClaimNeedsLink: + return dropstate.ClaimNeedsLink(row, now), res + default: + return dropstate.ClaimFailedAttempt(row, now), res + } +} diff --git a/internal/pipeline/claimer/claimer_test.go b/internal/pipeline/claimer/claimer_test.go new file mode 100644 index 00000000..4e122dba --- /dev/null +++ b/internal/pipeline/claimer/claimer_test.go @@ -0,0 +1,51 @@ +package claimer + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type fakeClaimer struct { + res platform.ClaimResult + got platform.DropProgress +} + +func (f *fakeClaimer) ClaimDrop(_ context.Context, _ platform.Session, d platform.DropProgress) platform.ClaimResult { + f.got = d + return f.res +} + +func TestAttempt(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + row := dropstate.Row{DropID: "d1", CampaignID: "c1", Status: dropstate.Claimable, Minutes: 60, Required: 60} + cases := []struct { + out platform.ClaimOutcome + status dropstate.Status + reason dropstate.Reason + }{ + {platform.ClaimOK, dropstate.Claimed, dropstate.NoReason}, + {platform.ClaimAlready, dropstate.Claimed, dropstate.NoReason}, + {platform.ClaimNeedsLink, dropstate.Blocked, dropstate.NeedsLink}, + {platform.ClaimFailed, dropstate.Claimable, dropstate.NoReason}, + } + for _, tc := range cases { + t.Run(tc.out.String(), func(t *testing.T) { + fc := &fakeClaimer{res: platform.ClaimResult{Outcome: tc.out}} + got, res := Attempt(context.Background(), fc, platform.Session{}, row, platform.DropProgress{DropID: "d1", InstanceID: "i"}, now) + assert.Equal(t, tc.status, got.Status) + assert.Equal(t, tc.reason, got.Reason) + assert.Equal(t, tc.out, res.Outcome) + assert.Equal(t, "i", fc.got.InstanceID, "instance id forwarded") + }) + } + fc := &fakeClaimer{res: platform.ClaimResult{Outcome: platform.ClaimFailed}} + got, _ := Attempt(context.Background(), fc, platform.Session{}, row, platform.DropProgress{DropID: "d1"}, now) + assert.Equal(t, 1, got.FailCount) + assert.Equal(t, now.Add(time.Minute), got.RetryAfter) +} diff --git a/internal/pipeline/dropstate/dropstate.go b/internal/pipeline/dropstate/dropstate.go new file mode 100644 index 00000000..78078f5e --- /dev/null +++ b/internal/pipeline/dropstate/dropstate.go @@ -0,0 +1,288 @@ +// Package dropstate is the per-(account, drop) state model for pipeline v2. +// Every transition is a pure function of (previous row, input, now) so the +// rules are table-testable and the account loop is the only writer. +package dropstate + +import "time" + +type Status string + +const ( + Eligible Status = "eligible" + Accruing Status = "accruing" + Claimable Status = "claimable" + Claimed Status = "claimed" + Blocked Status = "blocked" +) + +type Reason string + +const ( + NoReason Reason = "" + NeedsLink Reason = "needs_link" + ClaimFailed Reason = "claim_failed" + SubOnly Reason = "sub_only" + NoChannels Reason = "no_channels" + Expired Reason = "expired" + NotEnrolled Reason = "not_enrolled" + UserSkip Reason = "user_skip" + // Unlinked is the campaign-level link block: the campaign needs an + // external account the user has not linked. Like UserSkip, a non-claimed + // observation never lifts it on its own — only the reconciler may, once + // it re-checks the campaign's link status and confirms it is linked + // (dropstate.Retry). A platform-confirmed claim still wins, same as any + // other block. + Unlinked Reason = "unlinked" +) + +type Source string + +const ( + FromPlatform Source = "platform" + FromUser Source = "user" +) + +// Retry windows for blocked rows. user_skip never auto-expires. +const ( + RetryNeedsLink = 24 * time.Hour + RetryClaimFailed = 6 * time.Hour + RetryNotEnrolled = time.Hour + RetrySubOnly = 24 * time.Hour + RetryExpired = 24 * time.Hour + MaxClaimFailures = 5 +) + +var claimBackoff = []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute} + +type Row struct { + AccountID string + DropID string + CampaignID string + Platform string + Status Status + Reason Reason + Minutes int + Required int + Source Source + FailCount int + RetryAfter time.Time + SyncedAt time.Time + UpdatedAt time.Time +} + +// IsZero reports a row that has never been stored or derived. +func (r Row) IsZero() bool { return r.Status == "" } + +// Observation is what the platform said about one drop in one sync. +type Observation struct { + // Known is true when the platform returned a definite record for the + // drop. False means it said nothing, which proves nothing. + Known bool + // Unmineable marks drops the platform cannot track (scrape placeholders). + Unmineable bool + Claimed bool + Minutes int + Required int +} + +func derive(minutes, required int) (Status, Reason) { + switch { + case required <= 0: + return Blocked, SubOnly + case minutes >= required: + return Claimable, NoReason + case minutes > 0: + return Accruing, NoReason + default: + return Eligible, NoReason + } +} + +func retryFor(reason Reason, now time.Time) time.Time { + switch reason { + case NeedsLink, Unlinked: + return now.Add(RetryNeedsLink) + case ClaimFailed: + return now.Add(RetryClaimFailed) + case NotEnrolled, NoChannels: + return now.Add(RetryNotEnrolled) + case SubOnly: + return now.Add(RetrySubOnly) + case Expired: + return now.Add(RetryExpired) + } + return time.Time{} +} + +func (r Row) with(s Status, reason Reason, now time.Time) Row { + r.Status, r.Reason, r.UpdatedAt = s, reason, now + r.RetryAfter = retryFor(reason, now) + return r +} + +// Apply folds one platform observation into the previous row. The caller +// seeds identity fields (AccountID, DropID, CampaignID, Platform) on a new row. +func Apply(prev Row, obs Observation, now time.Time) Row { + r := prev + if obs.Known && !obs.Unmineable { + r.Minutes, r.Required, r.SyncedAt = obs.Minutes, obs.Required, now + } + // A platform-confirmed claim is final. + if prev.Status == Claimed && prev.Source == FromPlatform { + if r.Minutes < prev.Minutes { + r.Minutes = prev.Minutes + } + return r + } + if !obs.Known { + // A backfilled row carries no requirement; adopt the catalog's so a + // re-derive does not read (0, 0) as sub_only forever. + if prev.Required <= 0 && obs.Required > 0 { + r.Required = obs.Required + } + switch { + case prev.IsZero(): + r.Required = obs.Required + r.Source = FromPlatform + st, rs := derive(0, obs.Required) + return r.with(st, rs, now) + case (prev.Status == Accruing || prev.Status == Claimable) && prev.Source == FromPlatform: + return r.with(Blocked, NotEnrolled, now) + case prev.Status == Blocked && (prev.Reason == NotEnrolled || prev.Reason == SubOnly) && !now.Before(prev.RetryAfter): + st, rs := derive(prev.Minutes, r.Required) + return r.with(st, rs, now) + } + return r + } + if obs.Unmineable { + if prev.Status == Claimed { + return r + } + r.Source = FromPlatform + return r.with(Blocked, NotEnrolled, now) + } + if obs.Claimed { + r.Source, r.FailCount = FromPlatform, 0 + return r.with(Claimed, NoReason, now) + } + if prev.Status == Blocked && (prev.Reason == UserSkip || prev.Reason == Unlinked) { + return r + } + if prev.Status == Blocked && (prev.Reason == NeedsLink || prev.Reason == ClaimFailed) && now.Before(prev.RetryAfter) { + return r + } + st, rs := derive(r.Minutes, r.Required) + if st == Claimable && prev.Status == Claimable && now.Before(prev.RetryAfter) { + return r // keep claim backoff + } + if prev.Status == Blocked || (prev.Status == Claimable && st != Claimable) { + r.FailCount = 0 + } + r.Source = FromPlatform + return r.with(st, rs, now) +} + +// ClaimOK records a successful or already-claimed claim. +func ClaimOK(prev Row, now time.Time) Row { + r := prev.with(Claimed, NoReason, now) + r.Source, r.FailCount = FromPlatform, 0 + return r +} + +// ClaimNeedsLink blocks a drop whose claim needs an external account link. +func ClaimNeedsLink(prev Row, now time.Time) Row { + if prev.Status == Claimed && prev.Source == FromPlatform { + return prev + } + r := prev.with(Blocked, NeedsLink, now) + r.Source = FromPlatform + return r +} + +// ClaimFailedAttempt counts a failed claim: backoff 1m, 5m, 30m, then +// blocked:claim_failed after MaxClaimFailures. +func ClaimFailedAttempt(prev Row, now time.Time) Row { + if prev.Status == Claimed && prev.Source == FromPlatform { + return prev + } + r := prev + r.FailCount++ + r.UpdatedAt = now + if r.FailCount >= MaxClaimFailures { + return r.with(Blocked, ClaimFailed, now) + } + i := r.FailCount - 1 + if i >= len(claimBackoff) { + i = len(claimBackoff) - 1 + } + r.RetryAfter = now.Add(claimBackoff[i]) + return r +} + +// ProbeNotEnrolled records a failed stall claim-probe: the platform neither +// tracks the drop nor accepts a claim for it, so block it not_enrolled (1h +// re-check). A probe is not a claim of a completed drop, so FailCount is +// left alone. Platform-confirmed claims and user skips are kept. +func ProbeNotEnrolled(prev Row, now time.Time) Row { + if (prev.Status == Claimed && prev.Source == FromPlatform) || (prev.Status == Blocked && prev.Reason == UserSkip) { + return prev + } + r := prev.with(Blocked, NotEnrolled, now) + r.Source = FromPlatform + return r +} + +// MarkCollected is the user asserting the drop is claimed. +func MarkCollected(prev Row, now time.Time) Row { + if prev.Status == Claimed { + return prev + } + r := prev.with(Claimed, NoReason, now) + r.Source = FromUser + return r +} + +// Skip is the user excluding a drop from mining until they retry it. +func Skip(prev Row, now time.Time) Row { + if prev.Status == Claimed && prev.Source == FromPlatform { + return prev + } + r := prev.with(Blocked, UserSkip, now) + r.Source = FromUser + return r +} + +// Retry clears any block and re-derives from the last known minutes. +func Retry(prev Row, now time.Time) Row { + if prev.Status == Claimed && prev.Source == FromPlatform { + return prev + } + st, rs := derive(prev.Minutes, prev.Required) + r := prev.with(st, rs, now) + r.FailCount, r.Source = 0, FromPlatform + return r +} + +// Expire blocks an unclaimed drop whose campaign has ended. +func Expire(prev Row, now time.Time) Row { + if prev.Status == Claimed { + return prev + } + return prev.with(Blocked, Expired, now) +} + +// BlockLink blocks a drop whose campaign needs an unlinked external account. +func BlockLink(prev Row, now time.Time) Row { + if prev.Status == Claimed || (prev.Status == Blocked && prev.Reason == UserSkip) { + return prev + } + return prev.with(Blocked, Unlinked, now) +} + +// Mineable reports whether watching can still advance the drop. +func Mineable(r Row) bool { return r.Status == Eligible || r.Status == Accruing } + +// ReadyToClaim reports a claimable row whose backoff has elapsed. +func ReadyToClaim(r Row, now time.Time) bool { + return r.Status == Claimable && !now.Before(r.RetryAfter) +} diff --git a/internal/pipeline/dropstate/dropstate_test.go b/internal/pipeline/dropstate/dropstate_test.go new file mode 100644 index 00000000..6d9b198d --- /dev/null +++ b/internal/pipeline/dropstate/dropstate_test.go @@ -0,0 +1,266 @@ +package dropstate + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var t0 = time.Unix(1_000_000, 0) + +func base() Row { + return Row{AccountID: "a", DropID: "d", CampaignID: "c", Platform: "twitch", Source: FromPlatform} +} + +func row(s Status, r Reason, min, req int) Row { + x := base() + x.Status, x.Reason, x.Minutes, x.Required = s, r, min, req + return x +} + +func TestApply(t *testing.T) { + userClaimed := row(Claimed, NoReason, 0, 60) + userClaimed.Source = FromUser + notEnrolled := row(Blocked, NotEnrolled, 10, 60) + notEnrolled.RetryAfter = t0.Add(time.Hour) + skipped := row(Blocked, UserSkip, 0, 60) + skipped.Source = FromUser + needsLink := row(Blocked, NeedsLink, 60, 60) + needsLink.RetryAfter = t0.Add(RetryNeedsLink) + + subOnly := row(Blocked, SubOnly, 0, 60) + subOnly.RetryAfter = t0.Add(RetrySubOnly) + + cases := []struct { + name string + prev Row + obs Observation + at time.Time + want Status + reason Reason + src Source + }{ + {"new drop, platform silent -> eligible", base(), Observation{Required: 60}, t0, Eligible, NoReason, FromPlatform}, + {"new drop, platform silent, zero required -> sub_only", base(), Observation{}, t0, Blocked, SubOnly, FromPlatform}, + {"known progress -> accruing", row(Eligible, NoReason, 0, 60), Observation{Known: true, Minutes: 10, Required: 60}, t0, Accruing, NoReason, FromPlatform}, + {"known full -> claimable", row(Accruing, NoReason, 50, 60), Observation{Known: true, Minutes: 60, Required: 60}, t0, Claimable, NoReason, FromPlatform}, + {"known claimed -> claimed", row(Accruing, NoReason, 10, 60), Observation{Known: true, Claimed: true, Minutes: 60, Required: 60}, t0, Claimed, NoReason, FromPlatform}, + {"platform claimed never demoted", row(Claimed, NoReason, 60, 60), Observation{Known: true, Minutes: 0, Required: 60}, t0, Claimed, NoReason, FromPlatform}, + {"user mark kept while platform silent", userClaimed, Observation{Required: 60}, t0, Claimed, NoReason, FromUser}, + {"user mark overwritten by definite platform answer", userClaimed, Observation{Known: true, Minutes: 5, Required: 60}, t0, Accruing, NoReason, FromPlatform}, + {"accruing then platform silent -> not_enrolled", row(Accruing, NoReason, 10, 60), Observation{Required: 60}, t0, Blocked, NotEnrolled, FromPlatform}, + {"not_enrolled held inside window", notEnrolled, Observation{Required: 60}, t0, Blocked, NotEnrolled, FromPlatform}, + {"not_enrolled re-derived after window", notEnrolled, Observation{Required: 60}, t0.Add(2 * time.Hour), Accruing, NoReason, FromPlatform}, + {"unmineable placeholder -> not_enrolled", base(), Observation{Known: true, Unmineable: true}, t0, Blocked, NotEnrolled, FromPlatform}, + {"user skip survives progress", skipped, Observation{Known: true, Minutes: 30, Required: 60}, t0, Blocked, UserSkip, FromUser}, + {"claimed beats user skip", skipped, Observation{Known: true, Claimed: true, Minutes: 60, Required: 60}, t0, Claimed, NoReason, FromPlatform}, + {"needs_link held inside window", needsLink, Observation{Known: true, Minutes: 60, Required: 60}, t0, Blocked, NeedsLink, FromPlatform}, + {"needs_link re-derived after window", needsLink, Observation{Known: true, Minutes: 60, Required: 60}, t0.Add(25 * time.Hour), Claimable, NoReason, FromPlatform}, + {"sub_only held inside window", subOnly, Observation{Required: 60}, t0, Blocked, SubOnly, FromPlatform}, + {"sub_only re-derived after window", subOnly, Observation{Required: 60}, t0.Add(25 * time.Hour), Eligible, NoReason, FromPlatform}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Apply(tc.prev, tc.obs, tc.at) + assert.Equal(t, tc.want, got.Status) + assert.Equal(t, tc.reason, got.Reason) + assert.Equal(t, tc.src, got.Source) + assert.Equal(t, "d", got.DropID, "identity preserved") + }) + } +} + +func TestApply_PlatformClaimedKeepsHighestMinutes(t *testing.T) { + got := Apply(row(Claimed, NoReason, 60, 60), Observation{Known: true, Minutes: 0, Required: 60}, t0) + assert.Equal(t, 60, got.Minutes) +} + +func TestApply_KeepsClaimBackoff(t *testing.T) { + r := ClaimFailedAttempt(row(Claimable, NoReason, 60, 60), t0) + got := Apply(r, Observation{Known: true, Minutes: 60, Required: 60}, t0.Add(10*time.Second)) + assert.Equal(t, Claimable, got.Status) + assert.Equal(t, r.RetryAfter, got.RetryAfter, "sync must not reset claim backoff") + assert.Equal(t, 1, got.FailCount) +} + +func TestClaimFailedAttempt_Ladder(t *testing.T) { + r := row(Claimable, NoReason, 60, 60) + for _, wait := range []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 30 * time.Minute} { + r = ClaimFailedAttempt(r, t0) + require.Equal(t, Claimable, r.Status) + require.Equal(t, t0.Add(wait), r.RetryAfter) + require.False(t, ReadyToClaim(r, t0)) + require.True(t, ReadyToClaim(r, t0.Add(wait))) + } + r = ClaimFailedAttempt(r, t0) + assert.Equal(t, Blocked, r.Status) + assert.Equal(t, ClaimFailed, r.Reason) + assert.Equal(t, t0.Add(RetryClaimFailed), r.RetryAfter) +} + +func TestClaimOutcomes(t *testing.T) { + ok := ClaimOK(row(Claimable, NoReason, 60, 60), t0) + assert.Equal(t, Claimed, ok.Status) + assert.Equal(t, FromPlatform, ok.Source) + link := ClaimNeedsLink(row(Claimable, NoReason, 60, 60), t0) + assert.Equal(t, Blocked, link.Status) + assert.Equal(t, NeedsLink, link.Reason) + assert.Equal(t, t0.Add(RetryNeedsLink), link.RetryAfter) +} + +func TestUserActions(t *testing.T) { + m := MarkCollected(row(Accruing, NoReason, 10, 60), t0) + assert.Equal(t, Claimed, m.Status) + assert.Equal(t, FromUser, m.Source) + + s := Skip(row(Accruing, NoReason, 10, 60), t0) + assert.Equal(t, Blocked, s.Status) + assert.Equal(t, UserSkip, s.Reason) + assert.True(t, s.RetryAfter.IsZero(), "user_skip never auto-expires") + + failed := row(Blocked, ClaimFailed, 60, 60) + failed.FailCount = 5 + r := Retry(failed, t0) + assert.Equal(t, Claimable, r.Status) + assert.Equal(t, 0, r.FailCount) +} + +func TestExpireAndBlockLink_LeaveClaimedAlone(t *testing.T) { + c := row(Claimed, NoReason, 60, 60) + assert.Equal(t, Claimed, Expire(c, t0).Status) + assert.Equal(t, Claimed, BlockLink(c, t0).Status) + assert.Equal(t, Expired, Expire(row(Accruing, NoReason, 10, 60), t0).Reason) + assert.Equal(t, Unlinked, BlockLink(row(Eligible, NoReason, 0, 60), t0).Reason) + sk := row(Blocked, UserSkip, 0, 60) + assert.Equal(t, UserSkip, BlockLink(sk, t0).Reason) +} + +func TestMineable(t *testing.T) { + assert.True(t, Mineable(row(Eligible, NoReason, 0, 60))) + assert.True(t, Mineable(row(Accruing, NoReason, 1, 60))) + assert.False(t, Mineable(row(Claimable, NoReason, 60, 60))) + assert.False(t, Mineable(row(Blocked, NotEnrolled, 0, 60))) + assert.False(t, Mineable(Row{})) +} + +func TestMutators_NeverDemotePlatformClaim(t *testing.T) { + platformClaimed := row(Claimed, NoReason, 60, 60) + platformClaimed.Source = FromPlatform + + // ClaimNeedsLink must not demote a platform claim + got := ClaimNeedsLink(platformClaimed, t0) + assert.Equal(t, Claimed, got.Status) + assert.Equal(t, FromPlatform, got.Source) + + // ClaimFailedAttempt must not demote a platform claim + got = ClaimFailedAttempt(platformClaimed, t0) + assert.Equal(t, Claimed, got.Status) + assert.Equal(t, FromPlatform, got.Source) + + // Skip must not demote a platform claim + got = Skip(platformClaimed, t0) + assert.Equal(t, Claimed, got.Status) + assert.Equal(t, FromPlatform, got.Source) + + // Retry must not demote a platform claim + got = Retry(platformClaimed, t0) + assert.Equal(t, Claimed, got.Status) + assert.Equal(t, FromPlatform, got.Source) + + // MarkCollected must not downgrade a platform claim to user source + got = MarkCollected(platformClaimed, t0) + assert.Equal(t, Claimed, got.Status) + assert.Equal(t, FromPlatform, got.Source) + + // But Skip on a user-claimed row DOES produce Blocked/UserSkip + userClaimed := row(Claimed, NoReason, 60, 60) + userClaimed.Source = FromUser + got = Skip(userClaimed, t0) + assert.Equal(t, Blocked, got.Status) + assert.Equal(t, UserSkip, got.Reason) + assert.Equal(t, FromUser, got.Source) +} + +// C1: a backfilled ghost-skip has Required 0. When the platform stays silent +// (Kick reports unlisted rewards Known=false) but the campaign now carries the +// requirement, the re-derive must use it instead of sticking at sub_only. +func TestApply_UnknownBackfilledRowAdoptsRequired(t *testing.T) { + for _, reason := range []Reason{NotEnrolled, SubOnly} { + prev := row(Blocked, reason, 0, 0) + prev.RetryAfter = t0.Add(-time.Minute) + got := Apply(prev, Observation{Required: 120}, t0) + assert.Equal(t, Eligible, got.Status, string(reason)) + assert.Equal(t, NoReason, got.Reason, string(reason)) + assert.Equal(t, 120, got.Required, string(reason)) + } +} + +// I3 (superseded, see below): a campaign-level link block used to re-derive +// on the next Known observation regardless of source. That let a mid-watch +// session Progress event (loop.applyProgress, also Known=true) lift the +// block between reconciles even though the campaign was still unlinked, so +// the planner mined a drop whose claim could never succeed. Lifting an +// unlinked block is now the reconciler's job alone (dropstate.Retry once it +// confirms the campaign is linked); see TestApply_UnlinkedNeverLiftedByObservation +// and reconcile.TestRun_LinkedAfterUnlinkedUnblocksImmediately. + +// A campaign-level link block behaves like user_skip: only a claimed +// observation can move it. Any other Known observation (including one from +// a mid-watch session Progress event) must leave it Blocked/Unlinked; only +// the reconciler (via dropstate.Retry, once it has confirmed the campaign +// is linked) may lift it. +func TestApply_UnlinkedNeverLiftedByObservation(t *testing.T) { + prev := BlockLink(row(Accruing, NoReason, 10, 60), t0) + require.Equal(t, Unlinked, prev.Reason) + + got := Apply(prev, Observation{Known: true, Minutes: 20, Required: 60}, t0.Add(time.Minute)) + assert.Equal(t, Blocked, got.Status) + assert.Equal(t, Unlinked, got.Reason) + + got = Apply(prev, Observation{Known: true, Minutes: 0, Required: 60}, t0.Add(time.Minute)) + assert.Equal(t, Blocked, got.Status) + assert.Equal(t, Unlinked, got.Reason) + + got = Apply(prev, Observation{Known: true, Claimed: true, Minutes: 60, Required: 60}, t0.Add(time.Minute)) + assert.Equal(t, Claimed, got.Status) + assert.Equal(t, NoReason, got.Reason) +} + +// A row that falls back from Claimable to Accruing/Eligible sheds its claim +// failure count so a later claim starts a fresh backoff ladder. +func TestApply_ClaimableDemotionResetsFailCount(t *testing.T) { + prev := row(Claimable, NoReason, 60, 60) + prev.FailCount = 3 + prev.RetryAfter = t0.Add(-time.Minute) + got := Apply(prev, Observation{Known: true, Minutes: 30, Required: 120}, t0) + assert.Equal(t, Accruing, got.Status) + assert.Equal(t, 0, got.FailCount) + got = Apply(prev, Observation{Known: true, Minutes: 0, Required: 60}, t0) + assert.Equal(t, Eligible, got.Status) + assert.Equal(t, 0, got.FailCount) +} + +// ProbeNotEnrolled is the stall claim-probe's failure outcome: blocked with the +// 1h re-check window, never counted as a claim failure. +func TestProbeNotEnrolled(t *testing.T) { + normal := row(Eligible, NoReason, 0, 60) + normal.Source = FromUser + got := ProbeNotEnrolled(normal, t0) + assert.Equal(t, Blocked, got.Status) + assert.Equal(t, NotEnrolled, got.Reason) + assert.Equal(t, FromPlatform, got.Source) + assert.Equal(t, t0.Add(RetryNotEnrolled), got.RetryAfter) + assert.Equal(t, 0, got.FailCount, "a probe is not a claim failure") + + withFails := row(Eligible, NoReason, 0, 60) + withFails.FailCount = 2 + assert.Equal(t, 2, ProbeNotEnrolled(withFails, t0).FailCount, "FailCount carried, never incremented") + + claimed := row(Claimed, NoReason, 60, 60) + assert.Equal(t, claimed, ProbeNotEnrolled(claimed, t0), "platform-confirmed claim untouched") + skipped := row(Blocked, UserSkip, 0, 60) + skipped.Source = FromUser + assert.Equal(t, skipped, ProbeNotEnrolled(skipped, t0), "user skip untouched") +} diff --git a/internal/pipeline/loop/batch145_test.go b/internal/pipeline/loop/batch145_test.go new file mode 100644 index 00000000..deaf98b0 --- /dev/null +++ b/internal/pipeline/loop/batch145_test.go @@ -0,0 +1,352 @@ +package loop + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// Batch 1.4.5 item 2: a PubSub "claimable" event must not override a user +// skip or a campaign link block, even when Required is still unknown (the +// Required==0 branch used to set Claimable directly and bypass every block). +// Revert-proof: drop the block guard in onPubSub "claimable" and the +// Required==0 cases flip to Claimable. +func TestOnPubSub_ClaimableRespectsUserSkipAndUnlinked(t *testing.T) { + for _, tc := range []struct { + name string + reason dropstate.Reason + required int + }{ + {"user_skip unknown required", dropstate.UserSkip, 0}, + {"unlinked unknown required", dropstate.Unlinked, 0}, + {"user_skip known required", dropstate.UserSkip, 60}, + {"unlinked known required", dropstate.Unlinked, 60}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &memStore{rows: map[string]dropstate.Row{}} + prev := dropstate.Row{AccountID: "acc", DropID: "d1", CampaignID: "c1", Status: dropstate.Blocked, Reason: tc.reason, Required: tc.required, Minutes: 5, Source: dropstate.FromUser} + l := &Loop{ + cfg: Config{AccountID: "acc", Store: st, Now: time.Now}, + rows: map[string]dropstate.Row{"d1": prev}, + progress: map[string]platform.DropProgress{}, + } + l.onPubSub(context.Background(), pubsubEvent{kind: "claimable", drop: "d1", instance: "inst1"}) + assert.Equal(t, prev, l.rows["d1"], "blocked row must be untouched") + assert.Equal(t, dropstate.Row{}, st.get("d1"), "nothing persisted") + assert.Empty(t, l.progress, "no instance recorded for a blocked drop") + }) + } +} + +// Control: a claimable event for a platform-confirmed claim does nothing, +// and one for an ordinary Accruing row with a known Required makes it +// Claimable through Apply. +func TestOnPubSub_ClaimableNormalRowBecomesClaimable(t *testing.T) { + st := &memStore{rows: map[string]dropstate.Row{}} + claimed := dropstate.Row{AccountID: "acc", DropID: "d2", CampaignID: "c1", Status: dropstate.Claimed, Source: dropstate.FromPlatform, Required: 60, Minutes: 60} + l := &Loop{ + cfg: Config{AccountID: "acc", Store: st, Now: time.Now}, + rows: map[string]dropstate.Row{ + "d1": {AccountID: "acc", DropID: "d1", CampaignID: "c1", Status: dropstate.Accruing, Required: 60, Minutes: 30, Source: dropstate.FromPlatform}, + "d2": claimed, + }, + progress: map[string]platform.DropProgress{}, + } + l.onPubSub(context.Background(), pubsubEvent{kind: "claimable", drop: "d1", instance: "inst1"}) + l.onPubSub(context.Background(), pubsubEvent{kind: "claimable", drop: "d2", instance: "inst2"}) + assert.Equal(t, dropstate.Claimable, l.rows["d1"].Status) + assert.Equal(t, 60, l.rows["d1"].Minutes) + assert.Equal(t, "inst1", l.progress["d1"].InstanceID) + assert.Equal(t, claimed, l.rows["d2"]) +} + +// liveProber is a ChannelProber whose answer is scripted per login. +type liveProber struct { + *fakeBackend + pmu sync.Mutex + isLive map[string]bool + calls [][]string // logins per call +} + +func (p *liveProber) ProbeChannels(_ context.Context, _ platform.Session, _ platform.Campaign, logins []string) ([]platform.Stream, error) { + p.pmu.Lock() + defer p.pmu.Unlock() + p.calls = append(p.calls, append([]string(nil), logins...)) + var out []platform.Stream + for _, lg := range logins { + if p.isLive[strings.ToLower(lg)] { + out = append(out, platform.Stream{Channel: lg, ViewerCount: 3}) + } + } + return out, nil +} + +func (f *fakeBackend) setLive(s ...platform.Stream) { + f.mu.Lock() + defer f.mu.Unlock() + f.live = s +} + +// flipSetup lands the loop on ch1 (c1), then makes the next directory page +// omit ch1 (top-N churn) while ch2 stays listed. +func flipSetup(t *testing.T, backend platform.Backend, f *fakeBackend, cfg Config) (*Loop, context.Context) { + t.Helper() + cfg.Backend = backend + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + t.Cleanup(l.stopSession) + require.Equal(t, "ch1", l.current.Channel.Channel) + require.Eventually(t, func() bool { return len(f.watchedChannels()) == 1 }, 2*time.Second, 5*time.Millisecond) + f.setLive(platform.Stream{Channel: "ch2", ViewerCount: 5}) + return l, ctx +} + +// Batch 1.4.5 item 3: the current channel dropping out of a fresh +// ListEligibleChannels page is re-checked with the prober; still live means +// no swap. Revert-proof: remove the refreshLive re-check and v2 swaps to ch2. +func TestLoop_RefreshKeepsCurrentChannelWhenProberSaysLive(t *testing.T) { + f, _, _, cfg := setup(t) + p := &liveProber{fakeBackend: f, isLive: map[string]bool{"ch1": true}} + l, ctx := flipSetup(t, p, f, cfg) + + l.refreshLive(ctx) + l.replan(ctx) + time.Sleep(30 * time.Millisecond) + + assert.Equal(t, "ch1", l.current.Channel.Channel, "no swap while the current channel is still live") + assert.Equal(t, "ch1", l.Snapshot().Channel) + assert.Equal(t, []string{"ch1"}, f.watchedChannels(), "StartWatch count unchanged") + p.pmu.Lock() + assert.Equal(t, [][]string{{"ch1"}}, p.calls, "exactly one re-check, for the current channel only") + p.pmu.Unlock() +} + +// The prober reporting the current channel offline lets the swap happen. +func TestLoop_RefreshSwapsWhenProberSaysCurrentOffline(t *testing.T) { + f, _, _, cfg := setup(t) + p := &liveProber{fakeBackend: f, isLive: map[string]bool{}} + l, ctx := flipSetup(t, p, f, cfg) + + l.refreshLive(ctx) + l.replan(ctx) + + assert.Equal(t, "ch2", l.current.Channel.Channel) + require.Eventually(t, func() bool { return len(f.watchedChannels()) == 2 }, 2*time.Second, 5*time.Millisecond) + assert.Equal(t, []string{"ch1", "ch2"}, f.watchedChannels()) +} + +// No prober: behaviour unchanged, the refresh that omits ch1 swaps to ch2. +func TestLoop_RefreshWithoutProberSwapsAsBefore(t *testing.T) { + f, _, _, cfg := setup(t) + l, ctx := flipSetup(t, f, f, cfg) + + l.refreshLive(ctx) + l.replan(ctx) + + assert.Equal(t, "ch2", l.current.Channel.Channel) +} + +// A current channel the fresh page still lists is not re-probed. +func TestLoop_RefreshDoesNotReprobeListedCurrentChannel(t *testing.T) { + f, _, _, cfg := setup(t) + p := &liveProber{fakeBackend: f, isLive: map[string]bool{"ch1": true}} + l, ctx := flipSetup(t, p, f, cfg) + f.setLive(platform.Stream{Channel: "ch1", ViewerCount: 10}, platform.Stream{Channel: "ch2", ViewerCount: 5}) + + l.refreshLive(ctx) + l.replan(ctx) + + assert.Equal(t, "ch1", l.current.Channel.Channel) + p.pmu.Lock() + assert.Empty(t, p.calls) + p.pmu.Unlock() +} + +// catchUpSetup scripts a Twitch account added after it finished its drops: +// campaign c1 is active and in scope with n drops, and the platform reports +// nothing for any of them (campaign left the Inventory, details self null), +// so reconcile derives every row Eligible from a Known=false observation. +func catchUpSetup(t *testing.T, n int) (*fakeBackend, *memStore, Config) { + f, st, _, cfg := setup(t) + var bs []platform.DropBenefit + for i := 1; i <= n; i++ { + id := fmt.Sprintf("d%d", i) + bs = append(bs, platform.DropBenefit{ID: id, CampaignID: "c1", Name: id, RequiredMinutes: 60 * i}) + } + f.camps[0].Benefits = bs + f.progress = nil + f.inventory = nil + f.live = nil // nothing live: any claim below comes from catch-up, never a watch + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimFailed} + cfg.ClaimProbe = true + cfg.CatchUpEvery = 2 * time.Millisecond + return f, st, cfg +} + +func (f *fakeBackend) claimedIDsCopy() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.claimedIDs...) +} + +func (f *fakeBackend) listCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.listGames) +} + +// Batch 1.4.5 item 1: a new account whose drops are already done. Already +// → Claimed/FromPlatform without any watch; Failed leaves the row Eligible +// and is not re-probed on the next reconcile. Revert-proof: remove the +// catch-up enqueue in reconcile and no claim is ever sent (nothing is live, +// so the stall probe never runs either). +func TestLoop_CatchUpClaimProbe_NewAccountAlreadyClaimed(t *testing.T) { + f, st, cfg := catchUpSetup(t, 3) + f.claimByDrop = map[string]platform.ClaimResult{ + "d1": {Outcome: platform.ClaimAlready}, + "d2": {Outcome: platform.ClaimAlready}, + "d3": {Outcome: platform.ClaimFailed}, + } + l, _ := run(t, cfg) + require.Eventually(t, func() bool { + return st.get("d1").Status == dropstate.Claimed && st.get("d2").Status == dropstate.Claimed && f.claimCount() == 3 + }, 2*time.Second, 2*time.Millisecond) + for _, id := range []string{"d1", "d2"} { + assert.Equal(t, dropstate.FromPlatform, st.get(id).Source, id) + } + r3 := st.get("d3") + assert.Equal(t, dropstate.Eligible, r3.Status, "a failed catch-up probe leaves the row unchanged") + assert.Equal(t, dropstate.NoReason, r3.Reason) + assert.Equal(t, 0, r3.FailCount, "a catch-up probe is not a claim failure") + assert.Empty(t, f.watchedChannels(), "claimed without any watch") + + before := f.listCount() + l.Nudge() + require.Eventually(t, func() bool { return f.listCount() > before }, 2*time.Second, 2*time.Millisecond) + time.Sleep(40 * time.Millisecond) // many CatchUpEvery periods + assert.ElementsMatch(t, []string{"d1", "d2", "d3"}, f.claimedIDsCopy(), "each drop probed once per loop run") + assert.Equal(t, dropstate.Eligible, st.get("d3").Status) +} + +// Fix round 1 ruling: an ALREADY_CLAIMED catch-up answer is a historical +// claim (recorded in history, no notification); only a claim that succeeded +// just now (OK) notifies. Revert-proof: make commitProbeResult always call +// commit and the Already count becomes 1. +func TestLoop_CatchUpClaimProbe_NotifiesOnlyOK(t *testing.T) { + f, st, cfg := catchUpSetup(t, 2) + f.claimByDrop = map[string]platform.ClaimResult{ + "d1": {Outcome: platform.ClaimAlready}, + "d2": {Outcome: platform.ClaimOK}, + } + h := &memHistory{} + cfg.History = h + n := &recNotifier{} + cfg.Notifier = n + run(t, cfg) + require.Eventually(t, func() bool { + return st.get("d1").Status == dropstate.Claimed && st.get("d2").Status == dropstate.Claimed + }, 2*time.Second, 2*time.Millisecond) + time.Sleep(20 * time.Millisecond) + assert.Equal(t, 1, n.count("claim"), "only the OK claim notifies") + h.mu.Lock() + assert.ElementsMatch(t, []string{"d1", "d2"}, h.recorded, "both claims still reach the claim history") + h.mu.Unlock() +} + +// NeedsLink maps to the needs_link block. +func TestLoop_CatchUpClaimProbe_NeedsLinkBlocks(t *testing.T) { + f, st, cfg := catchUpSetup(t, 1) + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimNeedsLink} + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Reason == dropstate.NeedsLink }, 2*time.Second, 2*time.Millisecond) + assert.Equal(t, dropstate.Blocked, st.get("d1").Status) +} + +// Cap: with 8 candidates one reconcile queues only catchUpPerReconcile; the +// rest wait for the next reconcile, and nothing is probed twice. Driven by +// hand so the drain is deterministic. +func TestLoop_CatchUpClaimProbe_CapPerReconcile(t *testing.T) { + f, _, cfg := catchUpSetup(t, 8) + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + drain := func() { + for i := 0; i < 20; i++ { + l.catchUpOne(ctx) + } + } + + l.reconcile(ctx) + assert.Equal(t, 0, f.claimCount(), "reconcile only queues; probes are spaced by the loop timer") + drain() + assert.Equal(t, catchUpPerReconcile, f.claimCount()) + + l.reconcile(ctx) + drain() + assert.Equal(t, 8, f.claimCount(), "the remaining candidates go on the next reconcile") + + l.reconcile(ctx) + drain() + assert.Equal(t, 8, f.claimCount(), "no drop is probed twice") + assert.ElementsMatch(t, []string{"d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8"}, f.claimedIDsCopy()) +} + +// Only Eligible rows with a Known=false observation, Required>0 and an +// active campaign qualify. +func TestLoop_CatchUpClaimProbe_SkipsNonCandidates(t *testing.T) { + f, _, cfg := catchUpSetup(t, 3) + f.progress = []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 0, Required: 60, Known: true}} + f.camps = append(f.camps, platform.Campaign{ID: "c2", Platform: "twitch", Game: "G", Name: "Old", Status: "expired", + AccountLinked: true, AccountLinkChecked: true, + Benefits: []platform.DropBenefit{{ID: "e1", CampaignID: "c2", RequiredMinutes: 60}}}) + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + r := dropstate.Row{AccountID: "acc", DropID: "d2", CampaignID: "c1", Platform: "twitch", Status: dropstate.Accruing, Minutes: 5, Required: 120, Source: dropstate.FromPlatform} + l.rows["d2"] = r + l.reconcile(ctx) + for i := 0; i < 10; i++ { + l.catchUpOne(ctx) + } + assert.Equal(t, []string{"d3"}, f.claimedIDsCopy(), "d1 is Known, d2 is not Eligible (Accruing then not_enrolled), e1 is in an expired campaign") +} + +// Probes are not sent inline: with an hour between probes, none is sent. +func TestLoop_CatchUpClaimProbe_SpacedNotInline(t *testing.T) { + f, _, cfg := catchUpSetup(t, 3) + cfg.CatchUpEvery = time.Hour + run(t, cfg) + require.Eventually(t, func() bool { return f.listCount() >= 1 }, 2*time.Second, 2*time.Millisecond) + time.Sleep(40 * time.Millisecond) + assert.Equal(t, 0, f.claimCount()) +} + +// Kick keeps claimed rewards listed, so main.go leaves ClaimProbe off: no +// catch-up probes. +func TestLoop_CatchUpClaimProbe_KickOff(t *testing.T) { + f, st, cfg := catchUpSetup(t, 3) + cfg.Platform = "kick" + cfg.ClaimProbe = false + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimAlready} + run(t, cfg) + require.Eventually(t, func() bool { return f.listCount() >= 1 }, 2*time.Second, 2*time.Millisecond) + time.Sleep(40 * time.Millisecond) + assert.Equal(t, 0, f.claimCount()) + assert.Equal(t, dropstate.Eligible, st.get("d1").Status) +} diff --git a/internal/pipeline/loop/enroll.go b/internal/pipeline/loop/enroll.go new file mode 100644 index 00000000..a399f34b --- /dev/null +++ b/internal/pipeline/loop/enroll.go @@ -0,0 +1,158 @@ +package loop + +import ( + "context" + "log/slog" + "sort" + "strings" + "time" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// Watch-to-enroll discovery (Config.EnrollDiscovery). A TV-client Twitch +// token can't read the campaign list, but Inventory lists every campaign +// the viewer is enrolled in, and Twitch enrolls a viewer who watches a +// channel that counts toward a campaign. When the planner says Idle, the +// loop watches the top drops-enabled channel of the next whitelisted game +// for EnrollWatch, then reconciles so the enrolled campaigns become rows. + +// maybeEnroll starts an enroll watch on the next whitelisted game (round- +// robin from enrollNext) that is off cooldown and has a usable channel. +// Called only when the planner said Idle and no enroll watch runs. Games +// probed within EnrollGameCooldown are skipped without I/O; a game whose +// lookup found nothing is skipped for LiveEvery, so an all-dark whitelist +// costs at most one directory call per game per LiveEvery. +func (l *Loop) maybeEnroll(ctx context.Context) { + games := l.cfg.Games + if !l.cfg.EnrollDiscovery || l.enroll != nil || l.authBlocked || len(games) == 0 { + return + } + now := l.cfg.Now() + n := len(games) + for i := 0; i < n; i++ { + idx := (l.enrollNext + i) % n + g := games[idx] + if g == "" { + continue + } + k := strings.ToLower(g) + if t, ok := l.enrollProbed[k]; ok && now.Sub(t) < l.cfg.EnrollGameCooldown { + continue + } + if t, ok := l.enrollEmpty[k]; ok && now.Sub(t) < l.cfg.LiveEvery { + continue + } + s, ok := l.enrollChannel(ctx, g, now) + if !ok { + l.enrollEmpty[k] = now + continue + } + l.enrollNext = (idx + 1) % n + l.startEnroll(ctx, g, idx, s) + return + } +} + +// enrollChannel returns the top live drops-enabled channel for game that +// is not cooling down. It lists the channels of a synthetic open campaign +// (no ID, so no allow-list), which the Twitch backend answers from the +// DROPS_ENABLED game directory sorted by viewers. +func (l *Loop) enrollChannel(ctx context.Context, game string, now time.Time) (platform.Stream, bool) { + streams, err := l.cfg.Backend.ListEligibleChannels(ctx, l.cfg.Session, platform.Campaign{Platform: l.cfg.Platform, Game: game}) + if err != nil { + // WARN, not Debug: this is the only signal an operator gets that + // enroll discovery can't see a game's directory. The caller + // (maybeEnroll) backs the game off for LiveEvery on any failed + // lookup (enrollEmpty), so this fires at most once per game per + // backoff window, not on every idle nudge. + slog.Warn("pipeline enroll: list channels failed", "account", l.cfg.AccountID, "game", game, "err", err) + return platform.Stream{}, false + } + sort.SliceStable(streams, func(i, j int) bool { return streams[i].ViewerCount > streams[j].ViewerCount }) + for _, s := range streams { + if s.Channel == "" || !s.DropsEnabled { + continue + } + if until, ok := l.cooldowns[strings.ToLower(s.Channel)]; ok && now.Before(until) { + continue + } + return s, true + } + return platform.Stream{}, false +} + +// startEnroll runs a stall-free session on s and arms the EnrollWatch +// timer. No PubSub subscription: the watch serves no known drop. +func (l *Loop) startEnroll(ctx context.Context, game string, idx int, s platform.Stream) { + l.enroll = &enrollRun{game: game, idx: idx, stream: s, started: l.cfg.Now()} + l.runSession(ctx, s, nil, 0) + l.stopEnrollTimer() + l.enrollT = time.NewTimer(l.cfg.EnrollWatch) + l.enrollC = l.enrollT.C + slog.Info("pipeline enroll discovery", "kind", "discovery", "account", l.cfg.AccountID, + "phase", "start", "game", game, "channel", s.Channel, "viewers", s.ViewerCount, "watch", l.cfg.EnrollWatch.String()) +} + +func (l *Loop) stopEnrollTimer() { + if l.enrollT != nil { + l.enrollT.Stop() + } + l.enrollT, l.enrollC = nil, nil +} + +// endEnroll stops the enroll watch without marking its game probed (stream +// down, preempted, auth wall); the cursor rewinds so the game is retried. +func (l *Loop) endEnroll(reason string) { + e := l.enroll + if e == nil { + return + } + l.stopEnrollTimer() + l.haltSession() + l.enroll = nil + l.enrollNext = e.idx + slog.Info("pipeline enroll discovery", "kind", "discovery", "account", l.cfg.AccountID, + "phase", "stop", "game", e.game, "channel", e.stream.Channel, "reason", reason) +} + +// finishEnroll ends an enroll watch whose EnrollWatch is up: stop the +// session, mark the game probed, then reconcile (Inventory now lists any +// campaign the watch enrolled the account in) and refresh channels so the +// replan that follows can mine what it found. +func (l *Loop) finishEnroll(ctx context.Context) { + e := l.enroll + l.enrollT, l.enrollC = nil, nil // already fired + if e == nil { + return + } + l.haltSession() + l.enroll = nil + l.enrollProbed[strings.ToLower(e.game)] = l.cfg.Now() + before := make(map[string]bool, len(l.rows)) + for id := range l.rows { + before[id] = true + } + l.reconcile(ctx) + // campaigns_found/new_drops reflect reconcile alone (refreshLive only + // probes channels for candidates already in l.rows, it adds no rows), + // so this INFO line fires even when the reconcile itself trips + // authBlocked (integrity wall) and refreshLive never runs. Without it, + // an enroll watch that ends in an auth block would leave no + // phase=stop reason=done line at all. + newDrops := 0 + camps := map[string]bool{} + for id, r := range l.rows { + if !before[id] { + newDrops++ + camps[r.CampaignID] = true + } + } + slog.Info("pipeline enroll discovery", "kind", "discovery", "account", l.cfg.AccountID, + "phase", "stop", "game", e.game, "channel", e.stream.Channel, "reason", "done", + "campaigns_found", len(camps), "new_drops", newDrops) + if l.authBlocked { + return + } + l.refreshLive(ctx) +} diff --git a/internal/pipeline/loop/enroll_test.go b/internal/pipeline/loop/enroll_test.go new file mode 100644 index 00000000..7b641cae --- /dev/null +++ b/internal/pipeline/loop/enroll_test.go @@ -0,0 +1,395 @@ +package loop + +import ( + "context" + "errors" + "log/slog" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// recordCapture is a slog.Handler that stores every record it sees, so +// tests can assert on a message's level and attrs together (a plain +// message->level map can't tell two records with the same message but +// different attrs apart, e.g. two "pipeline enroll discovery" lines with +// different "reason" values). +type recordCapture struct { + mu sync.Mutex + records []slog.Record +} + +func (h *recordCapture) Enabled(context.Context, slog.Level) bool { return true } +func (h *recordCapture) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r.Clone()) + return nil +} +func (h *recordCapture) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *recordCapture) WithGroup(string) slog.Handler { return h } + +func recordAttr(r slog.Record, key string) (string, bool) { + var val string + var ok bool + r.Attrs(func(a slog.Attr) bool { + if a.Key == key { + val, ok = a.Value.String(), true + return false + } + return true + }) + return val, ok +} + +// has reports whether some captured record matches msg, level, and every +// key/value in attrs (extra attrs on the record are ignored). +func (h *recordCapture) has(msg string, level slog.Level, attrs map[string]string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for _, r := range h.records { + if r.Message != msg || r.Level != level { + continue + } + ok := true + for k, want := range attrs { + got, present := recordAttr(r, k) + if !present || got != want { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +// fakeClock is a settable Config.Now. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +// enrollSetup is an idle TV account: no campaigns visible, whitelist +// [A, B], each game's directory listing one drops-enabled channel. +func enrollSetup(t *testing.T) (*fakeBackend, Config) { + f, _, _, cfg := setup(t) + f.camps, f.progress, f.inventory = nil, nil, nil + f.gameLive = map[string][]platform.Stream{ + // A higher-viewer stream without drops must never be picked. + "A": {{Channel: "nodrops", ViewerCount: 500}, {Channel: "a1", ViewerCount: 100, DropsEnabled: true}}, + "B": {{Channel: "b1", ViewerCount: 50, DropsEnabled: true}}, + } + cfg.Games = []string{"A", "B"} + cfg.AllowGame = func(g string) bool { return g == "A" || g == "B" } + cfg.EnrollDiscovery = true + cfg.EnrollWatch = time.Hour // tests shorten it where they need a finish + return f, cfg +} + +func (f *fakeBackend) watched() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.watching...) +} + +func (f *fakeBackend) reconciles() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.listGames) +} + +func TestEnroll_RoundRobinWithCooldown(t *testing.T) { + f, cfg := enrollSetup(t) + cfg.EnrollWatch = 80 * time.Millisecond + l, _ := run(t, cfg) + + require.Eventually(t, func() bool { + s := l.Snapshot() + return s.State == "discovering" && s.Channel == "a1" + }, 2*time.Second, 2*time.Millisecond, "enroll starts on A's top drops-enabled channel") + assert.Equal(t, "A", l.Snapshot().CampaignGame) + recBefore := f.reconciles() + + // After EnrollWatch the A session stops, a reconcile runs, and the next + // idle cycle moves on to B (A is on cooldown). + require.Eventually(t, func() bool { + w := f.watched() + return len(w) == 2 && w[1] == "b1" + }, 2*time.Second, 2*time.Millisecond) + assert.Greater(t, f.reconciles(), recBefore, "post-enroll reconcile ran") + f.mu.Lock() + assert.GreaterOrEqual(t, f.stops, 1, "A session stopped before B started") + f.mu.Unlock() + require.Eventually(t, func() bool { return l.Snapshot().Channel == "b1" }, time.Second, 2*time.Millisecond) + + // Both games probed: back to plain idle, no further StartWatch. + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 2*time.Millisecond) + l.Nudge() + time.Sleep(150 * time.Millisecond) + assert.Equal(t, []string{"a1", "b1"}, f.watched(), "every game on cooldown: stay idle") + assert.Equal(t, "idle", l.Snapshot().State) +} + +func TestEnroll_ReconcileFindsCampaignThenMines(t *testing.T) { + f, cfg := enrollSetup(t) + cfg.EnrollWatch = 60 * time.Millisecond + // Watching a1 enrolls the account: the next reconcile lists c1 (game A). + f.onStartWatch = func(ch string) { + if ch != "a1" { + return + } + f.camps = []platform.Campaign{{ID: "c1", Platform: "twitch", Game: "A", Name: "Camp", Status: "active", + AccountLinked: true, AccountLinkChecked: true, + Benefits: []platform.DropBenefit{{ID: "d1", CampaignID: "c1", Name: "Drop", RequiredMinutes: 60}}}} + f.progress = []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 0, Required: 60, Known: true}} + f.live = []platform.Stream{{Channel: "ch1", ViewerCount: 10}} + } + l, _ := run(t, cfg) + + require.Eventually(t, func() bool { + s := l.Snapshot() + return s.State == "watching" && s.Channel == "ch1" + }, 2*time.Second, 2*time.Millisecond, "post-enroll reconcile surfaced a mineable drop: mine it") + time.Sleep(150 * time.Millisecond) + assert.Equal(t, []string{"a1", "ch1"}, f.watched(), "mining preempts further enrolling (no b1)") + assert.Equal(t, "watching", l.Snapshot().State) +} + +func TestEnroll_MineMidEnrollPreempts(t *testing.T) { + f, cfg := enrollSetup(t) // EnrollWatch 1h: only preemption can end it + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().State == "discovering" }, 2*time.Second, 2*time.Millisecond) + + f.mu.Lock() + f.camps = []platform.Campaign{{ID: "c1", Platform: "twitch", Game: "B", Name: "Camp", Status: "active", + AccountLinked: true, AccountLinkChecked: true, + Benefits: []platform.DropBenefit{{ID: "d1", CampaignID: "c1", Name: "Drop", RequiredMinutes: 60}}}} + f.progress = []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 0, Required: 60, Known: true}} + f.live = []platform.Stream{{Channel: "ch1", ViewerCount: 10}} + f.mu.Unlock() + l.Nudge() + + require.Eventually(t, func() bool { + s := l.Snapshot() + return s.State == "watching" && s.Channel == "ch1" + }, 2*time.Second, 2*time.Millisecond) + assert.Equal(t, []string{"a1", "ch1"}, f.watched()) + f.mu.Lock() + assert.GreaterOrEqual(t, f.stops, 1, "enroll session stopped") + f.mu.Unlock() +} + +func TestEnroll_DisabledNeverEnrolls(t *testing.T) { + f, cfg := enrollSetup(t) + cfg.EnrollDiscovery = false // Android / Kick + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 2*time.Millisecond) + l.Nudge() + time.Sleep(150 * time.Millisecond) + assert.Empty(t, f.watched()) + f.mu.Lock() + assert.Empty(t, f.gameLookups, "no directory lookups when enroll discovery is off") + f.mu.Unlock() +} + +func TestEnroll_CooldownFollowsConfigNow(t *testing.T) { + f, cfg := enrollSetup(t) + cfg.Games = []string{"A"} + cfg.EnrollWatch = 40 * time.Millisecond + clk := &fakeClock{t: time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)} + cfg.Now = clk.Now + l, _ := run(t, cfg) + + require.Eventually(t, func() bool { return len(f.watched()) == 1 }, 2*time.Second, 2*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 2*time.Millisecond) + clk.Advance(EnrollGameCooldown - time.Minute) + l.Nudge() + time.Sleep(150 * time.Millisecond) + assert.Len(t, f.watched(), 1, "A still on cooldown: stay idle, no StartWatch") + + clk.Advance(2 * time.Minute) // past the 6h cooldown + l.Nudge() + require.Eventually(t, func() bool { return len(f.watched()) == 2 }, 2*time.Second, 2*time.Millisecond) + assert.Equal(t, []string{"a1", "a1"}, f.watched()) +} + +func TestEnroll_SkipsGameWithoutChannelsAndBacksOff(t *testing.T) { + f, cfg := enrollSetup(t) + f.gameLive["A"] = nil // nothing live for A + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().Channel == "b1" }, 2*time.Second, 2*time.Millisecond) + for i := 0; i < 5; i++ { + l.Nudge() + time.Sleep(10 * time.Millisecond) + } + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, 1, f.gameLookups["A"], "an empty game is not re-queried while B enrolls") + assert.Equal(t, []string{"b1"}, f.watching) +} + +func TestEnroll_AllDarkWhitelistBacksOffLookups(t *testing.T) { + f, cfg := enrollSetup(t) + f.gameLive = map[string][]platform.Stream{} // nothing live anywhere + clk := &fakeClock{t: time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)} + cfg.Now = clk.Now + cfg.LiveEvery = time.Hour + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 2*time.Millisecond) + for i := 0; i < 5; i++ { + l.Nudge() + time.Sleep(10 * time.Millisecond) + } + f.mu.Lock() + assert.Equal(t, 1, f.gameLookups["A"], "a dark game is looked up once per LiveEvery, not every iteration") + assert.Equal(t, 1, f.gameLookups["B"]) + assert.Empty(t, f.watching) + f.gameLive["B"] = []platform.Stream{{Channel: "b1", ViewerCount: 5, DropsEnabled: true}} + f.mu.Unlock() + + clk.Advance(time.Hour + time.Minute) + l.Nudge() + require.Eventually(t, func() bool { return l.Snapshot().Channel == "b1" }, 2*time.Second, 2*time.Millisecond, + "after LiveEvery the dark games are retried") +} + +func TestEnroll_ChannelDownCoolsItAndPicksNext(t *testing.T) { + f, cfg := enrollSetup(t) + f.gameLive["A"] = []platform.Stream{ + {Channel: "a1", ViewerCount: 100, DropsEnabled: true}, + {Channel: "a2", ViewerCount: 50, DropsEnabled: true}, + } + f.beatFailOn = "a1" + l, _ := run(t, cfg) + require.Eventually(t, func() bool { + s := l.Snapshot() + return s.State == "discovering" && s.Channel == "a2" + }, 2*time.Second, 2*time.Millisecond, "a1 went down: cooled, A retried on a2") + assert.Equal(t, []string{"a1", "a2"}, f.watched()) +} + +// TestEnroll_ChannelLookupErrorLogsWarn: a directory lookup failure is the +// only signal an operator gets that enroll discovery can't see a game's +// channels. It must log at WARN, not DEBUG, and (since maybeEnroll backs +// the game off via enrollEmpty on any failed lookup, the same as a +// legitimately-empty result) it must not fire again on a nudge that lands +// inside that backoff window. +func TestEnroll_ChannelLookupErrorLogsWarn(t *testing.T) { + f, cfg := enrollSetup(t) + cfg.Games = []string{"A"} + f.gameErr = map[string]error{"A": errors.New("directory unavailable")} + + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + cap := &recordCapture{} + slog.SetDefault(slog.New(cap)) + + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 2*time.Millisecond) + for i := 0; i < 5; i++ { + l.Nudge() + time.Sleep(10 * time.Millisecond) + } + + assert.True(t, cap.has("pipeline enroll: list channels failed", slog.LevelWarn, map[string]string{"game": "A"}), + "a directory lookup error must log at WARN") + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, 1, f.gameLookups["A"], "the failed lookup backs the game off like an empty one: not retried every nudge") +} + +// TestRun_ClearsEnrollOnExit: Run's defer must clear l.enroll itself, not +// just its timer. Without that, a re-entered Run on the same Loop would +// inherit a stale enrollRun with no timer left to end it: maybeEnroll's +// "enroll != nil" guard would block forever and updateSnapshot would +// report "discovering" forever, with nothing left running to finish it. +func TestRun_ClearsEnrollOnExit(t *testing.T) { + _, cfg := enrollSetup(t) + cfg.Games = []string{"A"} + cfg.EnrollWatch = time.Hour // outlives this test; only ctx cancel ends it + l, err := New(cfg) + require.NoError(t, err) + + ctx1, cancel1 := context.WithCancel(context.Background()) + done1 := make(chan struct{}) + go func() { _ = l.Run(ctx1); close(done1) }() + require.Eventually(t, func() bool { return l.Snapshot().State == "discovering" }, 2*time.Second, 2*time.Millisecond) + cancel1() + select { + case <-done1: + case <-time.After(2 * time.Second): + t.Fatal("first Run did not exit") + } + assert.Nil(t, l.enroll, "Run's defer must clear the enroll run on exit, not just its timer") + assert.Nil(t, l.enrollT) + assert.Nil(t, l.enrollC) + + // Re-entering Run must be able to enroll again (a stale enroll would + // wedge maybeEnroll's guard and Snapshot would never move off whatever + // state it last held). + ctx2, cancel2 := context.WithCancel(context.Background()) + defer cancel2() + done2 := make(chan struct{}) + go func() { _ = l.Run(ctx2); close(done2) }() + require.Eventually(t, func() bool { + s := l.Snapshot() + return s.State == "discovering" && s.Channel == "a1" + }, 2*time.Second, 2*time.Millisecond, "re-entered Run must be able to start a fresh enroll watch") + cancel2() + select { + case <-done2: + case <-time.After(2 * time.Second): + t.Fatal("second Run did not exit") + } +} + +// TestFinishEnroll_LogsDoneEvenWhenReconcileAuthBlocks: the post-enroll +// reconcile can trip the integrity wall (authBlocked). The phase=stop +// reason=done line (campaigns_found/new_drops from reconcile alone; +// refreshLive adds no rows) must still be logged, so an enroll watch that +// ends in an auth block doesn't vanish from the logs. Calls finishEnroll +// directly (no Run goroutine) to keep the integrity failure isolated to +// the reconcile finishEnroll triggers, not the loop's startup reconcile. +func TestFinishEnroll_LogsDoneEvenWhenReconcileAuthBlocks(t *testing.T) { + f, cfg := enrollSetup(t) + cfg.Backend = integrityBackend{f} // ListActiveCampaigns always integrity-blocks + + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + l.enroll = &enrollRun{game: "A", idx: 0, stream: platform.Stream{Channel: "a1", ViewerCount: 100}, started: l.cfg.Now()} + + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + cap := &recordCapture{} + slog.SetDefault(slog.New(cap)) + + l.finishEnroll(ctx) + + assert.True(t, l.authBlocked, "reconcile hit the integrity wall") + assert.Nil(t, l.enroll) + assert.True(t, cap.has("pipeline enroll discovery", slog.LevelInfo, map[string]string{ + "phase": "stop", "reason": "done", "game": "A", "channel": "a1", + }), "phase=stop reason=done must log even though the post-enroll reconcile auth-blocked") +} diff --git a/internal/pipeline/loop/loop.go b/internal/pipeline/loop/loop.go new file mode 100644 index 00000000..49ae8aff --- /dev/null +++ b/internal/pipeline/loop/loop.go @@ -0,0 +1,1171 @@ +// Package loop is the per-account pipeline v2 runner. One goroutine owns +// every drop state transition: it reconciles, plans, runs one session at a +// time, and claims. +package loop + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/claimer" + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/pipeline/planner" + "github.com/aalejandrofer/grubdrops/internal/pipeline/reconcile" + "github.com/aalejandrofer/grubdrops/internal/pipeline/session" + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/watcher" +) + +type Notifier interface { + Notify(ctx context.Context, event string, fields map[string]any) error +} + +type Store interface { + List(ctx context.Context, accountID string) ([]dropstate.Row, error) + Upsert(ctx context.Context, r dropstate.Row) error +} + +type ClaimHistory interface { + RecordClaimIfNew(ctx context.Context, accountID string, b platform.DropBenefit) (bool, error) + ClaimedBenefitIDs(ctx context.Context, accountID string) (map[string]bool, error) +} + +type Config struct { + AccountID string + AccountLabel string + Platform string + Backend platform.Backend + Session platform.Session + Store Store + History ClaimHistory + Persister reconcile.CampaignPersister + Notifier Notifier + + AllowGame func(game string) bool + // Games is the whitelisted game display names, copied into + // Session.Games when the session carries none. TV-client Twitch + // sessions walk one game directory per name (chandisc.go); Android + // sessions ignore it. Mirrors watcher.Config.Games. + Games []string + AllowChannel func(channels []string) bool + GameRank func(game string) int + PriorityMode string + ForceLinked func(campaignID string) bool + StreamerPriority []string + ForceWatch []string + ProgressNotifyStepPct int + // ClaimProbe (Twitch) turns on both blind claim checks for drops the + // platform says nothing about. Twitch drops claimed outside the app whose + // campaign left the Inventory are otherwise invisible (DropCampaignDetails + // self: null) and would be watched forever. Kick keeps claimed rewards + // listed, so it leaves this off. + // - catch-up: after each reconcile, one claim per Eligible drop of an + // active campaign that the reconcile read had no record of, once per + // loop run, spaced CatchUpEvery apart (catchUpPerReconcile queued per + // reconcile). Recognises an account added after it finished its drops. + // - stall: on a stall, one claim for each served Eligible drop absent + // from the last inventory read, on that drop's second stall. + ClaimProbe bool + // CatchUpEvery spaces catch-up claim probes (default 3s). + CatchUpEvery time.Duration + // EnrollDiscovery (TV-client Twitch sessions) turns on watch-to-enroll + // discovery. A TV token can't read the campaign list, but Inventory + // lists every campaign the viewer is enrolled in, and Twitch enrolls a + // viewer who watches a channel that counts toward a campaign. So when + // the planner has nothing to do, the loop watches the top live + // drops-enabled channel of the next whitelisted game (round-robin over + // Games) for EnrollWatch, then reconciles. A game is probed at most + // once per EnrollGameCooldown. Kick and Android sessions leave it off. + EnrollDiscovery bool + EnrollWatch time.Duration // default EnrollWatch (10m) + EnrollGameCooldown time.Duration // default EnrollGameCooldown (6h) + + Now func() time.Time + ReconcileEvery time.Duration // default 15m + LiveEvery time.Duration // default 5m + BeatEvery time.Duration // default 60s; production never overrides (Twitch credit gate) + StallPolls int // default 5 + StallCooldown time.Duration // default 30m + DownCooldown time.Duration // default 2m +} + +// Enroll discovery defaults (see Config.EnrollDiscovery). +const ( + EnrollWatch = 10 * time.Minute + EnrollGameCooldown = 6 * time.Hour +) + +// maxLiveCampaigns bounds channel lookups per refresh. +const maxLiveCampaigns = 8 + +// catchUpPerReconcile bounds the catch-up claim-probe queue: a reconcile +// tops it up to this many, and the loop drains one every CatchUpEvery. +// Probes never run inline in reconcile, so the loop stays responsive (at +// most one claim call per timer fire) and the rate stays at or below one +// claim per CatchUpEvery (3s) and catchUpPerReconcile (5, well under 20) +// per reconcile. Candidates past the cap wait for the next reconcile. +const catchUpPerReconcile = 5 + +type pubsubEvent struct { + kind string // "progress" | "claimable" | "down" + drop string + instance string + minutes int + required int + channel string +} + +type Loop struct { + cfg Config + prog platform.DropProgressSource + claimer platform.DropClaimer + prober platform.ChannelProber + subs platform.ChannelSubscriber + + nudge chan struct{} + pubsub chan pubsubEvent + sessEvents chan session.Event + + mu sync.Mutex + snap watcher.Snapshot + discovery []platform.Campaign + discoverAt time.Time + + // Owned by the Run goroutine. + rows map[string]dropstate.Row + camps []platform.Campaign + progress map[string]platform.DropProgress + live map[string][]platform.Stream + forceLive []platform.Stream + cooldowns map[string]time.Time + current *planner.Decision + lastSwap time.Time + sessCancel context.CancelFunc + sessDone chan struct{} // closed by the session goroutine once session.Run returns + subscribed string + authBlocked bool // integrity wall hit: Run exits, the next reload re-spins the loop + milestones map[string]int + gen int // bumped on every startSession; tags session events + pendingHistory map[string]bool // drop ids whose RecordClaimIfNew failed, retried each iteration + // lastInv is the set of drop ids in the current session's most recent + // Progress event (the full inventory read). Reset on every session start. + lastInv map[string]bool + // stallCount counts current-channel stalls per probe-qualifying drop + // (Eligible, absent from lastInv). The probe fires on the second stall; + // an entry is dropped once probed or once the row leaves Eligible. + stallCount map[string]int + // Catch-up claim probes (ClaimProbe). probed holds every drop id probed + // this loop run (a restart may probe once more); catchUpQ is the pending + // queue, drained one per catchUpC fire (nil while no timer is armed). + probed map[string]bool + catchUpQ []string + catchUpT *time.Timer + catchUpC <-chan time.Time + // Enroll discovery (EnrollDiscovery). enroll is the running enroll + // watch (nil when none); enrollC fires once its EnrollWatch is up. + // enrollProbed holds each game's last completed probe (lower-cased + // name), enrollEmpty the last lookup that found no usable channel + // (retried after LiveEvery), enrollNext the round-robin cursor into + // Games. In memory only: a restart may probe each game once more. + enroll *enrollRun + enrollT *time.Timer + enrollC <-chan time.Time + enrollProbed map[string]time.Time + enrollEmpty map[string]time.Time + enrollNext int +} + +// enrollRun is one enroll-discovery watch: game Games[idx] on stream. +type enrollRun struct { + game string + idx int + stream platform.Stream + started time.Time +} + +// New assembles the loop, checking the backend's pipeline capabilities once. +func New(cfg Config) (*Loop, error) { + prog, ok := cfg.Backend.(platform.DropProgressSource) + if !ok { + return nil, fmt.Errorf("backend %T has no DropProgress: pipeline v2 unsupported", cfg.Backend) + } + cl, ok := cfg.Backend.(platform.DropClaimer) + if !ok { + return nil, fmt.Errorf("backend %T has no ClaimDrop: pipeline v2 unsupported", cfg.Backend) + } + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.ReconcileEvery <= 0 { + cfg.ReconcileEvery = 15 * time.Minute + } + if cfg.LiveEvery <= 0 { + cfg.LiveEvery = 5 * time.Minute + } + if cfg.BeatEvery <= 0 { + cfg.BeatEvery = 60 * time.Second + } + if cfg.StallPolls <= 0 { + cfg.StallPolls = 5 + } + if cfg.StallCooldown <= 0 { + cfg.StallCooldown = 30 * time.Minute + } + if cfg.DownCooldown <= 0 { + cfg.DownCooldown = 2 * time.Minute + } + if cfg.CatchUpEvery <= 0 { + cfg.CatchUpEvery = 3 * time.Second + } + if cfg.EnrollWatch <= 0 { + cfg.EnrollWatch = EnrollWatch + } + if cfg.EnrollGameCooldown <= 0 { + cfg.EnrollGameCooldown = EnrollGameCooldown + } + cfg.Session.AccountID = cfg.AccountID + if cfg.Session.Games == nil { + cfg.Session.Games = cfg.Games + } + if cfg.AllowGame != nil { + cfg.Session.GameFilter = cfg.AllowGame + } + l := &Loop{ + cfg: cfg, prog: prog, claimer: cl, + nudge: make(chan struct{}, 1), + pubsub: make(chan pubsubEvent, 64), + sessEvents: make(chan session.Event, 64), + rows: map[string]dropstate.Row{}, + progress: map[string]platform.DropProgress{}, + live: map[string][]platform.Stream{}, + cooldowns: map[string]time.Time{}, + milestones: map[string]int{}, + pendingHistory: map[string]bool{}, + stallCount: map[string]int{}, + probed: map[string]bool{}, + enrollProbed: map[string]time.Time{}, + enrollEmpty: map[string]time.Time{}, + } + l.prober, _ = cfg.Backend.(platform.ChannelProber) + l.subs, _ = cfg.Backend.(platform.ChannelSubscriber) + l.snap = watcher.Snapshot{AccountID: cfg.AccountID, State: "pick_campaign"} + if ps, ok := cfg.Backend.(platform.PubSubAware); ok { + ps.SetAccountPubSubHooks(cfg.AccountID, l.hooks()) + } + return l, nil +} + +func (l *Loop) hooks() platform.PubSubHooks { + push := func(e pubsubEvent) { + select { + case l.pubsub <- e: + default: // never block the PubSub reader + } + } + return platform.PubSubHooks{ + OnDropProgress: func(dropID string, cur, req int64) { + push(pubsubEvent{kind: "progress", drop: dropID, minutes: int(cur), required: int(req)}) + }, + OnDropClaim: func(dropID, instanceID string) { + push(pubsubEvent{kind: "claimable", drop: dropID, instance: instanceID}) + }, + OnStreamDown: func(channelID string) { + push(pubsubEvent{kind: "down", channel: channelID}) + }, + } +} + +// Nudge asks the loop to reconcile and refresh channels now. +func (l *Loop) Nudge() { + select { + case l.nudge <- struct{}{}: + default: + } +} + +func (l *Loop) Snapshot() watcher.Snapshot { + l.mu.Lock() + defer l.mu.Unlock() + return l.snap +} + +func (l *Loop) LastDiscovery() ([]platform.Campaign, time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + return append([]platform.Campaign(nil), l.discovery...), l.discoverAt +} + +func (l *Loop) AllowGame() func(game string) bool { return l.cfg.AllowGame } + +func (l *Loop) Run(ctx context.Context) error { + if err := l.load(ctx); err != nil { + return err + } + defer l.stopSession() + // Registered before the first reconcile, which may arm the timer. + defer func() { + if l.catchUpT != nil { + l.catchUpT.Stop() + } + l.stopEnrollTimer() + // A Loop is meant to run once per Run call, but clear the enroll + // run itself (not just its timer) so a re-entered Run can never + // inherit a stale enrollRun and report "discovering" forever with + // no timer left to end it. + l.enroll = nil + }() + l.reconcile(ctx) + if l.authBlocked { + return nil + } + l.refreshLive(ctx) + l.claimReady(ctx) + if l.authBlocked { + return nil + } + l.retryPendingHistory(ctx) + l.replan(ctx) + recT := time.NewTicker(l.cfg.ReconcileEvery) + liveT := time.NewTicker(l.cfg.LiveEvery) + defer recT.Stop() + defer liveT.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-recT.C: + l.reconcile(ctx) + case <-liveT.C: + l.refreshLive(ctx) + case <-l.nudge: + l.reconcile(ctx) + l.refreshLive(ctx) + case ev := <-l.sessEvents: + l.onSession(ctx, ev) + case pe := <-l.pubsub: + l.onPubSub(ctx, pe) + case <-l.catchUpC: + l.catchUpC, l.catchUpT = nil, nil + l.catchUpOne(ctx) + l.armCatchUp() + case <-l.enrollC: + l.finishEnroll(ctx) + } + if l.authBlocked { + return nil + } + l.claimReady(ctx) + if l.authBlocked { + return nil + } + l.retryPendingHistory(ctx) + l.replan(ctx) + } +} + +// load reads stored state, then bridges claim rows written by the v1-era +// mark-collected UI (that handler reloads the scheduler, so this runs). +func (l *Loop) load(ctx context.Context) error { + rows, err := l.cfg.Store.List(ctx, l.cfg.AccountID) + if err != nil { + return fmt.Errorf("load drop state: %w", err) + } + for _, r := range rows { + l.rows[r.DropID] = r + } + l.bridgeClaimHistory(ctx, nil) + return nil +} + +// bridgeClaimHistory marks rows the claim history already lists as claimed +// but the local row hasn't caught up to. only, when non-nil, restricts which +// history ids are eligible: reconcile passes a filter that keeps only ids +// whose row didn't exist before that reconcile (an account's first sync +// under v2 has no drop_state rows yet, so a claim v1 recorded for one of +// them would otherwise never be bridged — see bridge-brief 2026-09-27). +// +// A row that already existed is deliberately left alone here: the next +// definite platform answer must overwrite a user mark (dropstate.Apply, spec +// 4.2), and re-asserting the bridge on every reconcile would fight that rule +// forever — Apply demotes Claimed/FromUser to Accruing/FromPlatform on a +// definite answer, then this bridge would immediately re-claim it from the +// same (still-present) history id, in the same reconcile and every one +// after. load() bridges every existing row unconditionally (only=nil): a +// v1-era mark-collected click, or v1 itself, reloads the scheduler on write, +// so load() runs again right after such a claim lands. +func (l *Loop) bridgeClaimHistory(ctx context.Context, only func(id string) bool) { + if l.cfg.History == nil { + return + } + ids, err := l.cfg.History.ClaimedBenefitIDs(ctx, l.cfg.AccountID) + if err != nil { + slog.Warn("pipeline: read claim history failed", "kind", "error", "account", l.cfg.AccountID, "err", err) + return + } + now := l.cfg.Now() + for id := range ids { + if only != nil && !only(id) { + continue + } + r, ok := l.rows[id] + if !ok || r.Status == dropstate.Claimed { + continue + } + l.commit(ctx, dropstate.MarkCollected(r, now)) + } +} + +func (l *Loop) reconcile(ctx context.Context) { + res, err := reconcile.Run(ctx, reconcile.Config{ + AccountID: l.cfg.AccountID, Platform: l.cfg.Platform, + Backend: l.cfg.Backend, Progress: l.prog, Session: l.cfg.Session, + AllowGame: l.cfg.AllowGame, AllowChannel: l.cfg.AllowChannel, + ForceLinked: l.cfg.ForceLinked, Persister: l.cfg.Persister, + }, l.rows, l.cfg.Now()) + if err != nil { + if errors.Is(err, platform.ErrIntegrityBlocked) { + l.onIntegrityBlocked(ctx) + return + } + slog.Warn("pipeline reconcile failed; keeping last state", "kind", "error", "account", l.cfg.AccountID, "err", err) + return + } + l.camps = res.Campaigns + for id, dp := range res.Progress { + l.progress[id] = dp + } + l.mu.Lock() + l.discovery, l.discoverAt = res.Campaigns, l.cfg.Now() + l.mu.Unlock() + existed := make(map[string]bool, len(l.rows)) + for id := range l.rows { + existed[id] = true + } + for _, r := range res.Rows { + l.commit(ctx, r) + } + // Only bridge rows this reconcile just created (not in existed): an + // already-existing row is left to dropstate.Apply's own rules so a + // definite platform answer can overwrite a stale user mark and stick. + l.bridgeClaimHistory(ctx, func(id string) bool { return !existed[id] }) + if l.cfg.ClaimProbe { + l.queueCatchUp(res) + } +} + +// queueCatchUp tops the catch-up queue up to catchUpPerReconcile with drops +// this reconcile left Eligible although the platform had no record of them +// (Known=false: absent from Inventory and campaign details), in an active +// in-scope campaign, with a known requirement, not yet probed this run. On +// Twitch that is what an account added after finishing its drops looks +// like; without this each one needs two stalls to be recognised. +func (l *Loop) queueCatchUp(res reconcile.Result) { + queued := make(map[string]bool, len(l.catchUpQ)) + for _, id := range l.catchUpQ { + queued[id] = true + } + for _, c := range res.Campaigns { + for _, b := range c.Benefits { + if len(l.catchUpQ) >= catchUpPerReconcile { + l.armCatchUp() + return + } + id := b.ID + if id == "" || queued[id] || l.probed[id] || res.Progress[id].Known { + continue + } + r, ok := l.rows[id] + if !ok || r.CampaignID != c.ID || r.Status != dropstate.Eligible || r.Required <= 0 { + continue + } + queued[id] = true + l.catchUpQ = append(l.catchUpQ, id) + } + } + l.armCatchUp() +} + +// armCatchUp starts the catch-up timer when work is queued and none runs. +func (l *Loop) armCatchUp() { + if len(l.catchUpQ) == 0 || l.catchUpT != nil { + return + } + l.catchUpT = time.NewTimer(l.cfg.CatchUpEvery) + l.catchUpC = l.catchUpT.C +} + +// catchUpOne sends one catch-up claim probe: the first queued drop still +// Eligible and unprobed. OK/ALREADY_CLAIMED marks it claimed; NeedsLink +// blocks it needs_link; a failure leaves the row untouched (it may simply +// be unstarted) and counts nothing. Either way it is not probed again this +// loop run. +func (l *Loop) catchUpOne(ctx context.Context) { + for len(l.catchUpQ) > 0 { + id := l.catchUpQ[0] + l.catchUpQ = l.catchUpQ[1:] + r, ok := l.rows[id] + if !ok || r.Status != dropstate.Eligible || l.probed[id] { + continue + } + l.probed[id] = true + dp, ok := l.progress[id] + if !ok || dp.DropID == "" { + dp = platform.DropProgress{DropID: id, CampaignID: r.CampaignID} + } + res := l.claimer.ClaimDrop(ctx, l.cfg.Session, dp) + now := l.cfg.Now() + kind := "probe" + var next dropstate.Row + switch res.Outcome { + case platform.ClaimOK, platform.ClaimAlready: + next, kind = dropstate.ClaimOK(r, now), "claim" + case platform.ClaimNeedsLink: + next = dropstate.ClaimNeedsLink(r, now) + } + slog.Info("pipeline catch-up claim-probe", "kind", kind, "account", l.cfg.AccountID, "drop", id, + "outcome", res.Outcome.String(), "detail", res.Detail) + if !next.IsZero() { + l.commitProbeResult(ctx, next, res.Outcome) + } + return + } +} + +// onIntegrityBlocked mirrors v1: stop watching, surface auth_required so the +// dashboard shows a re-auth banner, and let Run exit cleanly. The next reload +// re-spins the loop once the session is refreshed. +func (l *Loop) onIntegrityBlocked(ctx context.Context) { + slog.Warn("pipeline: integrity blocked, marking account needs_auth", "kind", "auth", "account", l.cfg.AccountID) + l.authBlocked = true + if l.enroll != nil { + l.endEnroll("auth_blocked") + } + l.stopSession() + l.current = nil + l.mu.Lock() + l.snap = watcher.Snapshot{AccountID: l.cfg.AccountID, State: "auth_required"} + l.mu.Unlock() + if l.cfg.Notifier != nil { + _ = l.cfg.Notifier.Notify(ctx, "state", map[string]any{"account": l.cfg.AccountID, "state": "auth_required"}) + } +} + +func (l *Loop) benefit(dropID string) (platform.DropBenefit, platform.Campaign) { + for _, c := range l.camps { + for _, b := range c.Benefits { + if b.ID == dropID { + return b, c + } + } + } + return platform.DropBenefit{ID: dropID}, platform.Campaign{} +} + +// commit stores a row and handles the transition into claimed. +func (l *Loop) commit(ctx context.Context, next dropstate.Row) { l.commitRow(ctx, next, true) } + +// commitQuiet is commit without the claim notification, for historical +// claims a blind probe uncovers (ClaimAlready): the claim history is still +// recorded, but nothing was claimed just now, so nothing is announced. +func (l *Loop) commitQuiet(ctx context.Context, next dropstate.Row) { l.commitRow(ctx, next, false) } + +// commitProbeResult commits a probe outcome row, quietly for ClaimAlready. +func (l *Loop) commitProbeResult(ctx context.Context, next dropstate.Row, o platform.ClaimOutcome) { + if o == platform.ClaimAlready { + l.commitQuiet(ctx, next) + return + } + l.commit(ctx, next) +} + +func (l *Loop) commitRow(ctx context.Context, next dropstate.Row, notify bool) { + prev := l.rows[next.DropID] + l.rows[next.DropID] = next + if next.Status != dropstate.Eligible { + delete(l.stallCount, next.DropID) + } + if err := l.cfg.Store.Upsert(ctx, next); err != nil { + slog.Warn("pipeline: persist drop state failed", "kind", "error", "account", l.cfg.AccountID, "drop", next.DropID, "err", err) + } + if next.Status != dropstate.Claimed || prev.Status == dropstate.Claimed { + return + } + b, c := l.benefit(next.DropID) + if l.cfg.History != nil { + if _, err := l.cfg.History.RecordClaimIfNew(ctx, l.cfg.AccountID, b); err != nil { + slog.Warn("pipeline: record claim history failed; will retry", "kind", "error", "account", l.cfg.AccountID, "drop", next.DropID, "err", err) + l.pendingHistory[next.DropID] = true + } + } + slog.Info("pipeline drop claimed", "kind", "claim", "account", l.cfg.AccountID, "drop", next.DropID, "source", string(next.Source)) + // Only notify live transitions, not historical claims seen on first sync. + if notify && !prev.IsZero() && next.Source == dropstate.FromPlatform { + l.notify(ctx, "claim", c, b, nil) + } +} + +// retryPendingHistory retries any RecordClaimIfNew calls that failed on a +// prior commit, so a transient history-store outage doesn't permanently +// drop the record (spec 4.2/7). Runs after every loop iteration. +func (l *Loop) retryPendingHistory(ctx context.Context) { + if l.cfg.History == nil || len(l.pendingHistory) == 0 { + return + } + for id := range l.pendingHistory { + b, _ := l.benefit(id) + if _, err := l.cfg.History.RecordClaimIfNew(ctx, l.cfg.AccountID, b); err != nil { + slog.Warn("pipeline: retry claim history failed", "kind", "error", "account", l.cfg.AccountID, "drop", id, "err", err) + continue + } + delete(l.pendingHistory, id) + } +} + +func (l *Loop) notify(ctx context.Context, event string, c platform.Campaign, b platform.DropBenefit, extra map[string]any) { + if l.cfg.Notifier == nil { + return + } + f := map[string]any{"account": l.cfg.AccountID} + if l.cfg.AccountLabel != "" { + f["account_label"] = l.cfg.AccountLabel + } + if l.cfg.Platform != "" { + f["platform"] = l.cfg.Platform + } + if c.Game != "" { + f["game"] = c.Game + } + if c.Name != "" { + f["campaign"] = c.Name + } + if b.Name != "" { + f["drop"] = b.Name + } + for k, v := range extra { + f[k] = v + } + _ = l.cfg.Notifier.Notify(ctx, event, f) +} + +func (l *Loop) plannerInput() planner.Input { + return planner.Input{ + Now: l.cfg.Now(), Rows: l.rows, Campaigns: l.camps, + Settings: planner.Settings{ + GameRank: l.cfg.GameRank, PriorityMode: l.cfg.PriorityMode, + StreamerPriority: l.cfg.StreamerPriority, ForceWatch: l.forceLive, + }, + Live: l.live, Cooldowns: l.cooldowns, Current: l.current, LastSwap: l.lastSwap, + } +} + +// allowedPriority returns the priority logins a campaign accepts. +func allowedPriority(c platform.Campaign, prio []string) []string { + if len(c.AllowedChannels) == 0 { + return prio + } + allowed := map[string]bool{} + for _, a := range c.AllowedChannels { + allowed[strings.ToLower(a)] = true + } + var out []string + for _, p := range prio { + if allowed[strings.ToLower(p)] { + out = append(out, p) + } + } + return out +} + +func mergeStreams(first, rest []platform.Stream) []platform.Stream { + seen := map[string]bool{} + var out []platform.Stream + for _, s := range append(append([]platform.Stream(nil), first...), rest...) { + k := strings.ToLower(s.Channel) + if !seen[k] { + seen[k] = true + out = append(out, s) + } + } + return out +} + +func (l *Loop) refreshLive(ctx context.Context) { + live := map[string][]platform.Stream{} + for i, c := range planner.Candidates(l.plannerInput()) { + if i >= maxLiveCampaigns { + break + } + streams, err := l.cfg.Backend.ListEligibleChannels(ctx, l.cfg.Session, c) + if err != nil { + slog.Debug("pipeline: list channels failed", "account", l.cfg.AccountID, "campaign", c.ID, "err", err) + } + // A restricted campaign whose allow-list the loop can't see (Twitch + // reports only a count) would credit none of the probed priority + // streamers it doesn't list, so skip probing it. + restrictedUnknown := c.AllowedChannelCount > 0 && len(c.AllowedChannels) == 0 + if l.prober != nil && !restrictedUnknown { + if logins := allowedPriority(c, l.cfg.StreamerPriority); len(logins) > 0 { + if ps, err := l.prober.ProbeChannels(ctx, l.cfg.Session, c, logins); err == nil { + streams = mergeStreams(ps, streams) + } + } + } + live[c.ID] = streams + } + l.keepCurrentLive(ctx, live) + l.live = live + l.forceLive = nil + if l.prober != nil && len(l.cfg.ForceWatch) > 0 { + if ps, err := l.prober.ProbeChannels(ctx, l.cfg.Session, platform.Campaign{}, l.cfg.ForceWatch); err == nil { + l.forceLive = ps + } + } +} + +// keepCurrentLive re-checks the channel being mined when a fresh directory +// page omits it for every campaign it serves. Directory pages are top-N and +// churn, so a still-live channel can drop out of one refresh; without this +// the planner reads it as gone, swaps away, and swaps back on the next +// refresh that lists it again (a flip-flop every LiveEvery). One prober call +// per served campaign, only in that case; without a prober nothing changes. +func (l *Loop) keepCurrentLive(ctx context.Context, live map[string][]platform.Stream) { + d := l.current + if l.prober == nil || d == nil || d.Kind != planner.Mine || d.Channel.Channel == "" { + return + } + ch := d.Channel.Channel + var campIDs []string + seen := map[string]bool{} + add := func(id string) { + if id != "" && !seen[id] { + seen[id] = true + campIDs = append(campIDs, id) + } + } + add(d.CampaignID) + for _, id := range d.Serves { + add(l.rows[id].CampaignID) + } + for _, id := range campIDs { + for _, s := range live[id] { + if strings.EqualFold(s.Channel, ch) { + return // still listed for a campaign it serves + } + } + } + // A restricted campaign with an unknown allow-list is re-probed too (the + // probe filters by game only); the exposure is bounded by the stall + // detector, which cools a channel that stops crediting. + for _, id := range campIDs { + c, ok := l.campaign(id) + if !ok { + continue + } + ps, err := l.prober.ProbeChannels(ctx, l.cfg.Session, c, []string{ch}) + if err != nil { + slog.Debug("pipeline: current channel re-check failed", "account", l.cfg.AccountID, "campaign", id, "channel", ch, "err", err) + continue + } + for _, s := range ps { + if strings.EqualFold(s.Channel, ch) { + live[id] = mergeStreams(live[id], []platform.Stream{s}) + break + } + } + } +} + +func (l *Loop) campaign(id string) (platform.Campaign, bool) { + for _, c := range l.camps { + if c.ID == id { + return c, true + } + } + return platform.Campaign{}, false +} + +func (l *Loop) isCurrent(channel string) bool { + return l.current != nil && l.current.Kind != planner.Idle && strings.EqualFold(l.current.Channel.Channel, channel) +} + +func (l *Loop) onSession(ctx context.Context, ev session.Event) { + if ev.Gen != l.gen { + return // stale event from a session already stopped/restarted + } + now := l.cfg.Now() + switch ev.Kind { + case session.Progress: + inv := make(map[string]bool, len(ev.Progress)) + for _, p := range ev.Progress { + inv[p.BenefitID] = true + } + l.lastInv = inv + l.applyProgress(ctx, ev.Progress) + case session.StreamDown: + if l.isCurrent(ev.Channel) { + l.cooldowns[strings.ToLower(ev.Channel)] = now.Add(l.cfg.DownCooldown) + } + if e := l.enroll; e != nil && strings.EqualFold(e.stream.Channel, ev.Channel) { + // Cool the channel and end the watch unprobed: the replan that + // follows retries the same game on its next channel. + l.cooldowns[strings.ToLower(ev.Channel)] = now.Add(l.cfg.DownCooldown) + l.endEnroll("stream_down") + } + case session.Stalled: + if l.isCurrent(ev.Channel) { + if l.cfg.ClaimProbe { + l.stallClaimProbe(ctx, now) + } + l.cooldowns[strings.ToLower(ev.Channel)] = now.Add(l.cfg.StallCooldown) + l.reconcile(ctx) + } + } +} + +// stallClaimProbe sends one claim for a served Eligible drop the last +// inventory read did not list, on that drop's second stall (the first is a +// plain cooldown). On Twitch such a drop is usually one claimed outside the +// app whose campaign left the Inventory: nothing reports it, so the claim +// answer is the only signal. OK/ALREADY_CLAIMED marks it claimed; a failure +// blocks it not_enrolled (1h re-check) without counting a claim failure, +// since it was never a completed drop. +func (l *Loop) stallClaimProbe(ctx context.Context, now time.Time) { + if l.current == nil { + return + } + for _, id := range l.current.Serves { + r, ok := l.rows[id] + if !ok || r.Status != dropstate.Eligible || l.lastInv[id] { + continue + } + l.stallCount[id]++ + if l.stallCount[id] < 2 { + continue + } + delete(l.stallCount, id) + dp, ok := l.progress[id] + if !ok || dp.DropID == "" { + dp = platform.DropProgress{DropID: id, CampaignID: r.CampaignID} + } + res := l.claimer.ClaimDrop(ctx, l.cfg.Session, dp) + var next dropstate.Row + kind := "probe" + switch res.Outcome { + case platform.ClaimOK, platform.ClaimAlready: + next, kind = dropstate.ClaimOK(r, now), "claim" + case platform.ClaimNeedsLink: + next = dropstate.ClaimNeedsLink(r, now) + default: + next = dropstate.ProbeNotEnrolled(r, now) + } + slog.Info("pipeline stall claim-probe", "kind", kind, "account", l.cfg.AccountID, "drop", id, + "outcome", res.Outcome.String(), "detail", res.Detail) + l.commitProbeResult(ctx, next, res.Outcome) + } +} + +func (l *Loop) applyProgress(ctx context.Context, progs []platform.Progress) { + now := l.cfg.Now() + for _, p := range progs { + prev, ok := l.rows[p.BenefitID] + if !ok { + continue // reconcile creates the row first + } + if p.InstanceID != "" { + dp := l.progress[p.BenefitID] + dp.DropID, dp.CampaignID, dp.InstanceID = p.BenefitID, prev.CampaignID, p.InstanceID + l.progress[p.BenefitID] = dp + } + next := dropstate.Apply(prev, dropstate.Observation{ + Known: true, Claimed: p.Claimed, Minutes: p.MinutesWatched, Required: prev.Required, + }, now) + l.maybeNotifyProgress(ctx, next) + l.commit(ctx, next) + } +} + +func (l *Loop) onPubSub(ctx context.Context, e pubsubEvent) { + switch e.kind { + case "progress": + if r, ok := l.rows[e.drop]; ok && r.Required == 0 && e.required > 0 { + r.Required = e.required + l.rows[e.drop] = r + } + l.applyProgress(ctx, []platform.Progress{{BenefitID: e.drop, MinutesWatched: e.minutes}}) + case "claimable": + r, ok := l.rows[e.drop] + if !ok || r.Status == dropstate.Claimed || userOrLinkBlocked(r) { + // A skip or a campaign link block is lifted only by the user or + // the reconciler, never by a claim notification: the Required==0 + // branch below would otherwise bypass it entirely. + return + } + dp := l.progress[e.drop] + dp.DropID, dp.CampaignID, dp.InstanceID = e.drop, r.CampaignID, e.instance + l.progress[e.drop] = dp + if r.Required == 0 { + // Required isn't known yet (platforms that surface the reward + // only via pubsub, e.g. Kick). A claimable event is itself proof + // the drop is done; routing it through Apply/derive would read + // required<=0 as "blocked: sub_only", which is wrong here. + next := r + next.Status, next.Reason = dropstate.Claimable, dropstate.NoReason + next.RetryAfter = time.Time{} + next.Source = dropstate.FromPlatform + next.UpdatedAt = l.cfg.Now() + l.commit(ctx, next) + return + } + minutes := r.Minutes + if minutes < r.Required { + minutes = r.Required + } + l.commit(ctx, dropstate.Apply(r, dropstate.Observation{Known: true, Minutes: minutes, Required: r.Required}, l.cfg.Now())) + case "down": + if l.current != nil && l.current.Channel.ChannelID != "" && l.current.Channel.ChannelID == e.channel { + l.cooldowns[strings.ToLower(l.current.Channel.Channel)] = l.cfg.Now().Add(l.cfg.DownCooldown) + } + } +} + +// userOrLinkBlocked reports a row blocked by a user skip or a campaign link +// block, the two blocks only the user or the reconciler may lift. +func userOrLinkBlocked(r dropstate.Row) bool { + return r.Status == dropstate.Blocked && (r.Reason == dropstate.UserSkip || r.Reason == dropstate.Unlinked) +} + +func (l *Loop) maybeNotifyProgress(ctx context.Context, r dropstate.Row) { + step := l.cfg.ProgressNotifyStepPct + if step <= 0 || r.Required <= 0 || l.cfg.Notifier == nil { + return + } + pct := r.Minutes * 100 / r.Required + if pct > 100 { + pct = 100 + } + m := pct / step * step + last, seen := l.milestones[r.DropID] + if !seen { + l.milestones[r.DropID] = m + return // first sight records a baseline; no restart storms + } + if m <= last { + return // never regress the recorded milestone (e.g. a stale re-sync) + } + l.milestones[r.DropID] = m + b, c := l.benefit(r.DropID) + l.notify(ctx, "progress", c, b, map[string]any{"cur_min": r.Minutes, "req_min": r.Required}) +} + +func (l *Loop) claimReady(ctx context.Context) { + now := l.cfg.Now() + attempted := false + for id, r := range l.rows { + if !dropstate.ReadyToClaim(r, now) { + continue + } + dp, ok := l.progress[id] + if !ok || dp.DropID == "" { + dp = platform.DropProgress{DropID: id, CampaignID: r.CampaignID} + } + next, res := claimer.Attempt(ctx, l.claimer, l.cfg.Session, r, dp, now) + slog.Info("pipeline claim attempt", "kind", "claim", "account", l.cfg.AccountID, "drop", id, + "outcome", res.Outcome.String(), "detail", res.Detail, "link", res.LinkURL) + l.commit(ctx, next) + attempted = true + } + if attempted { + l.reconcile(ctx) // corrects lost responses and auto-granted rewards + } +} + +// sameServeSet reports whether a and b hold the same drop ids, ignoring order. +func sameServeSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + counts := make(map[string]int, len(a)) + for _, id := range a { + counts[id]++ + } + for _, id := range b { + counts[id]-- + } + for _, n := range counts { + if n != 0 { + return false + } + } + return true +} + +// replan runs the planner and applies its decision. Enroll discovery sits +// on top of it, loop-side, so the planner stays pure: an Idle decision +// keeps (or starts) an enroll watch; anything else preempts it at once. +func (l *Loop) replan(ctx context.Context) { + d := planner.Plan(l.plannerInput()) + if l.enroll != nil { + if d.Kind == planner.Idle { + if l.current != nil { + l.current.Serves, l.current.Reason = d.Serves, d.Reason + } + l.updateSnapshot() + return + } + l.endEnroll("preempted") + } + l.apply(ctx, d) + if l.current != nil && l.current.Kind == planner.Idle && l.enroll == nil { + l.maybeEnroll(ctx) + l.updateSnapshot() + } +} + +func (l *Loop) apply(ctx context.Context, d planner.Decision) { + if l.current != nil && l.current.Same(d) { + servesChanged := !sameServeSet(l.current.Serves, d.Serves) + l.current.Serves, l.current.Reason = d.Serves, d.Reason + if servesChanged && l.current.Kind != planner.Idle { + // The running session pinned its stall/claim bookkeeping to the + // serves set it started with (e.g. a precondition drop that just + // got claimed, handing the baton to the drop it gated). Restart + // on the same channel so the session tracks the new set instead + // of reading "no progress on my old drops" as a stall. Don't + // touch lastSwap: this isn't a channel swap, so the hysteresis + // window keeps counting from when we actually landed here. + // Same channel, so the PubSub subscription stays. + l.haltSession() + l.startSession(ctx, *l.current) + } + l.updateSnapshot() + return + } + l.haltSession() + if d.Kind == planner.Idle { + l.unsubscribe() + } + l.current, l.lastSwap = &d, l.cfg.Now() + slog.Info("pipeline decision", "kind", "state", "account", l.cfg.AccountID, + "decision", d.Kind.String(), "channel", d.Channel.Channel, "serves", len(d.Serves), "reason", d.Reason) + if l.cfg.Notifier != nil { + _ = l.cfg.Notifier.Notify(ctx, "state", map[string]any{"account": l.cfg.AccountID, "state": stateString(&d)}) + } + if d.Kind != planner.Idle { + l.startSession(ctx, d) + } + l.updateSnapshot() +} + +func (l *Loop) startSession(ctx context.Context, d planner.Decision) { + if want := d.Channel.ChannelID; l.subs != nil && want != l.subscribed { + l.unsubscribe() + if want != "" { + l.subs.SubscribeChannel(l.cfg.AccountID, want) + l.subscribed = want + } + } + stall := l.cfg.StallPolls + if d.Kind == planner.ForceWatch { + stall = 0 // channel-points farming has no drop progress to stall on + } + l.runSession(ctx, d.Channel, d.Serves, stall) +} + +// runSession starts one session goroutine under a fresh generation stamp. +func (l *Loop) runSession(ctx context.Context, stream platform.Stream, serves []string, stallPolls int) { + sctx, cancel := context.WithCancel(ctx) + l.sessCancel = cancel + l.gen++ + l.lastInv = nil + ticker := time.NewTicker(l.cfg.BeatEvery) + cfg := session.Config{ + Backend: l.cfg.Backend, Session: l.cfg.Session, Stream: stream, + Serves: serves, Ticks: ticker.C, StallPolls: stallPolls, Gen: l.gen, + AccountID: l.cfg.AccountID, + } + done := make(chan struct{}) + l.sessDone = done + go func() { + defer close(done) + defer ticker.Stop() + session.Run(sctx, cfg, l.sessEvents) + }() +} + +// sessionStopWait bounds how long haltSession waits for the session +// goroutine (and its StopWatch) to finish. +const sessionStopWait = 15 * time.Second + +// haltSession cancels the running session and waits, bounded, for its +// goroutine to exit so StopWatch never overlaps the next StartWatch. It +// leaves the PubSub subscription alone. +func (l *Loop) haltSession() { + if l.sessCancel != nil { + l.sessCancel() + l.sessCancel = nil + } + if l.sessDone != nil { + t := time.NewTimer(sessionStopWait) + select { + case <-l.sessDone: + case <-t.C: + slog.Warn("pipeline: session did not stop in time", "kind", "error", "account", l.cfg.AccountID) + } + t.Stop() + l.sessDone = nil + } +} + +func (l *Loop) unsubscribe() { + if l.subs != nil && l.subscribed != "" { + l.subs.UnsubscribeChannel(l.cfg.AccountID, l.subscribed) + l.subscribed = "" + } +} + +// stopSession is the final stop: halt the session and drop the subscription. +func (l *Loop) stopSession() { + l.haltSession() + l.unsubscribe() +} + +// stateString maps a decision onto the v1 dashboard state vocabulary. +func stateString(d *planner.Decision) string { + if d == nil { + return "pick_campaign" + } + switch d.Kind { + case planner.Mine: + return "watching" + case planner.ForceWatch: + return "force_watch" + default: + return "idle" + } +} + +func (l *Loop) updateSnapshot() { + s := watcher.Snapshot{AccountID: l.cfg.AccountID, State: stateString(l.current)} + if e := l.enroll; e != nil { + s.State = "discovering" + s.Channel, s.ViewerCount, s.StartedAt = e.stream.Channel, e.stream.ViewerCount, e.started + s.CampaignGame = e.game + } else if d := l.current; d != nil && d.Kind != planner.Idle { + s.Channel, s.ViewerCount, s.StartedAt = d.Channel.Channel, d.Channel.ViewerCount, l.lastSwap + if len(d.Serves) > 0 { + b, c := l.benefit(d.Serves[0]) + r := l.rows[d.Serves[0]] + s.CampaignID, s.CampaignName, s.CampaignGame = c.ID, c.Name, c.Game + s.BenefitID, s.BenefitName, s.BenefitImage = b.ID, b.Name, b.ImageURL + s.MinutesWatched, s.RequiredMinutes = r.Minutes, r.Required + } + } + l.mu.Lock() + l.snap = s + l.mu.Unlock() +} diff --git a/internal/pipeline/loop/loop_test.go b/internal/pipeline/loop/loop_test.go new file mode 100644 index 00000000..bc9e5d5e --- /dev/null +++ b/internal/pipeline/loop/loop_test.go @@ -0,0 +1,993 @@ +package loop + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/pipeline/session" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// fakeBackend: one game "G", campaign c1 with drop d1 (60 min). Tests +// mutate the fields under mu to script the platform. +type fakeBackend struct { + platform.Backend + mu sync.Mutex + camps []platform.Campaign + progress []platform.DropProgress + inventory []platform.Progress + live []platform.Stream + claimRes platform.ClaimResult + claims int + watching []string + stops int + beatFailOn string + // claimedAfterFail simulates a "lost claim response": the platform + // actually recorded the claim server-side even though ClaimDrop reports + // claimRes (e.g. ClaimFailed) to the caller. Flips every progress item's + // Claimed flag the moment ClaimDrop is called, so the next reconcile's + // DropProgress read sees it. + claimedAfterFail bool + // listGames records the Session.Games each ListActiveCampaigns saw. + listGames [][]string + // claimWatchLen is len(watching) at the first ClaimDrop call. + claimWatchLen int + // claimByDrop overrides claimRes per drop id; claimedIDs records every + // ClaimDrop call's drop id in order. + claimByDrop map[string]platform.ClaimResult + claimedIDs []string + // gameLive answers ListEligibleChannels for a synthetic open campaign + // (ID "", Game set): the Twitch directory fallback enroll discovery + // uses. gameLookups counts those calls per game. + gameLive map[string][]platform.Stream + gameLookups map[string]int + // gameErr, keyed by game, fails a synthetic-campaign ListEligibleChannels + // lookup instead of answering from gameLive (enroll's directory-error path). + gameErr map[string]error + // onStartWatch, when set, runs (under mu) on every StartWatch. + onStartWatch func(channel string) +} + +func (f *fakeBackend) ListActiveCampaigns(_ context.Context, s platform.Session) ([]platform.Campaign, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.listGames = append(f.listGames, s.Games) + return f.camps, nil +} +func (f *fakeBackend) DropProgress(context.Context, platform.Session, []platform.Campaign) ([]platform.DropProgress, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]platform.DropProgress(nil), f.progress...), nil +} +func (f *fakeBackend) ListEligibleChannels(_ context.Context, _ platform.Session, c platform.Campaign) ([]platform.Stream, error) { + f.mu.Lock() + defer f.mu.Unlock() + if c.ID == "" && c.Game != "" { + if f.gameLookups == nil { + f.gameLookups = map[string]int{} + } + f.gameLookups[c.Game]++ + if err := f.gameErr[c.Game]; err != nil { + return nil, err + } + return append([]platform.Stream(nil), f.gameLive[c.Game]...), nil + } + return append([]platform.Stream(nil), f.live...), nil +} +func (f *fakeBackend) InventoryProgress(context.Context, platform.Session) ([]platform.Progress, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]platform.Progress(nil), f.inventory...), nil +} +func (f *fakeBackend) StartWatch(_ context.Context, _ platform.Session, s platform.Stream) (platform.WatchHandle, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.watching = append(f.watching, s.Channel) + if f.onStartWatch != nil { + f.onStartWatch(s.Channel) + } + return platform.WatchHandle{Channel: s.Channel}, nil +} +func (f *fakeBackend) Heartbeat(_ context.Context, h platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.beatFailOn != "" && strings.EqualFold(h.Channel, f.beatFailOn) { + return context.DeadlineExceeded + } + return nil +} +func (f *fakeBackend) StopWatch(context.Context, platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + f.stops++ + return nil +} +func (f *fakeBackend) ClaimDrop(_ context.Context, _ platform.Session, dp platform.DropProgress) platform.ClaimResult { + f.mu.Lock() + defer f.mu.Unlock() + f.claims++ + f.claimedIDs = append(f.claimedIDs, dp.DropID) + if r, ok := f.claimByDrop[dp.DropID]; ok { + return r + } + if f.claims == 1 { + f.claimWatchLen = len(f.watching) + } + if f.claimedAfterFail { + for i := range f.progress { + f.progress[i].Claimed = true + } + } + return f.claimRes +} + +type memStore struct { + mu sync.Mutex + rows map[string]dropstate.Row +} + +func (m *memStore) List(context.Context, string) ([]dropstate.Row, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []dropstate.Row + for _, r := range m.rows { + out = append(out, r) + } + return out, nil +} +func (m *memStore) Upsert(_ context.Context, r dropstate.Row) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[r.DropID] = r + return nil +} +func (m *memStore) get(id string) dropstate.Row { + m.mu.Lock() + defer m.mu.Unlock() + return m.rows[id] +} + +type memHistory struct { + mu sync.Mutex + recorded []string + claimed map[string]bool +} + +func (h *memHistory) RecordClaimIfNew(_ context.Context, _ string, b platform.DropBenefit) (bool, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.recorded = append(h.recorded, b.ID) + return true, nil +} +func (h *memHistory) ClaimedBenefitIDs(context.Context, string) (map[string]bool, error) { + return h.claimed, nil +} + +func setup(t *testing.T) (*fakeBackend, *memStore, *memHistory, Config) { + f := &fakeBackend{ + camps: []platform.Campaign{{ID: "c1", Platform: "twitch", Game: "G", Name: "Camp", Status: "active", + AccountLinked: true, AccountLinkChecked: true, + Benefits: []platform.DropBenefit{{ID: "d1", CampaignID: "c1", Name: "Drop", RequiredMinutes: 60}}}}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 10, Required: 60, Known: true}}, + inventory: []platform.Progress{{BenefitID: "d1", MinutesWatched: 10}}, + live: []platform.Stream{{Channel: "ch1", ViewerCount: 10}, {Channel: "ch2", ViewerCount: 5}}, + claimRes: platform.ClaimResult{Outcome: platform.ClaimOK}, + } + st := &memStore{rows: map[string]dropstate.Row{}} + h := &memHistory{} + cfg := Config{ + AccountID: "acc", Platform: "twitch", Backend: f, Store: st, History: h, + AllowGame: func(g string) bool { return g == "G" }, + ReconcileEvery: time.Hour, LiveEvery: time.Hour, + BeatEvery: 10 * time.Millisecond, StallPolls: 1000, // tests opt into stalls + } + return f, st, h, cfg +} + +func run(t *testing.T, cfg Config) (*Loop, context.CancelFunc) { + l, err := New(cfg) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = l.Run(ctx); close(done) }() + t.Cleanup(func() { cancel(); <-done }) + return l, cancel +} + +func TestLoop_ClaimedOnWebsiteMidWatch(t *testing.T) { + f, st, h, cfg := setup(t) + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().Channel == "ch1" }, 2*time.Second, 5*time.Millisecond) + + f.mu.Lock() + f.inventory = []platform.Progress{{BenefitID: "d1", MinutesWatched: 20, Claimed: true}} + f.mu.Unlock() + + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 2*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 5*time.Millisecond) + h.mu.Lock() + assert.Contains(t, h.recorded, "d1") + h.mu.Unlock() + f.mu.Lock() + assert.Equal(t, 0, f.claims, "no claim call for an already-claimed drop") + assert.GreaterOrEqual(t, f.stops, 1) + f.mu.Unlock() +} + +func TestLoop_ClaimsWhenClaimable(t *testing.T) { + f, st, _, cfg := setup(t) + f.progress[0].Minutes = 60 + f.inventory[0].MinutesWatched = 60 + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 2*time.Second, 5*time.Millisecond) + f.mu.Lock() + assert.Equal(t, 1, f.claims) + f.mu.Unlock() +} + +func TestLoop_NeedsLinkBlocksWithoutLooping(t *testing.T) { + f, st, _, cfg := setup(t) + f.progress[0].Minutes = 60 + f.inventory[0].MinutesWatched = 60 + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimNeedsLink} + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Reason == dropstate.NeedsLink }, 2*time.Second, 5*time.Millisecond) + time.Sleep(100 * time.Millisecond) + f.mu.Lock() + assert.Equal(t, 1, f.claims, "needs_link is not retried") + f.mu.Unlock() +} + +func TestLoop_StallCoolsChannelAndSwaps(t *testing.T) { + _, _, _, cfg := setup(t) + cfg.StallPolls = 3 + l, _ := run(t, cfg) + // Minutes never move, so after StallPolls the loop cools ch1 and moves on. + require.Eventually(t, func() bool { return l.Snapshot().Channel == "ch2" }, 3*time.Second, 5*time.Millisecond) +} + +func TestLoop_StaleStreamDownIgnored(t *testing.T) { + f, _, _, cfg := setup(t) + l, err := New(cfg) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Drive the loop manually: load, reconcile, refresh, plan. + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + require.Equal(t, "ch1", l.current.Channel.Channel) + // Same generation as the live session: this exercises the channel check, + // not the generation check (see TestLoop_StaleGenerationIgnored for that). + l.onSession(ctx, sessionEvent(l.gen, "ch-old-down")) + assert.Empty(t, l.cooldowns, "event from another channel must not cool anything") + l.stopSession() + _ = f +} + +// TestLoop_StaleGenerationIgnored covers the generation stamp itself +// (item 4): an event tagged with a generation older than the current +// session must be dropped even though it matches the current channel/kind, +// otherwise a leftover event from a just-stopped session (e.g. a +// serves-change restart) could set a bogus cooldown. +func TestLoop_StaleGenerationIgnored(t *testing.T) { + f, _, _, cfg := setup(t) + l, err := New(cfg) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + require.Equal(t, "ch1", l.current.Channel.Channel) + staleGen := l.gen - 1 + l.onSession(ctx, sessionEvent(staleGen, "ch1")) + assert.Empty(t, l.cooldowns, "event from a stale generation must not cool the live channel") + l.stopSession() + _ = f +} + +// TestLoop_UnlinkedCampaign_SessionProgressNeverLiftsBlock covers the prod +// bug (2026-09-30): an unlinked campaign's Blocked/Unlinked drop was re-derived +// to Accruing by the next mid-watch session Progress event (loop.applyProgress, +// a Known=true observation just like reconcile's), so the planner mined it +// between syncs even though its claim could never succeed. Revert-proof: +// revert the dropstate.Apply Unlinked guard and this fails — applyProgress +// flips d1 back to Accruing and a following replan starts watching ch1 again. +func TestLoop_UnlinkedCampaign_SessionProgressNeverLiftsBlock(t *testing.T) { + f, st, _, cfg := setup(t) + f.camps[0].AccountLinked = false + l, err := New(cfg) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + require.Equal(t, dropstate.Blocked, st.get("d1").Status) + require.Equal(t, dropstate.Unlinked, st.get("d1").Reason) + + l.refreshLive(ctx) + l.replan(ctx) + assert.Equal(t, "idle", l.Snapshot().State, "the only drop is blocked, so there is nothing to mine") + + // A session Progress event (as loop.applyProgress folds mid-watch, the + // same path a live watch session feeds between reconciles) must not lift + // the campaign link block on its own. + l.applyProgress(ctx, []platform.Progress{{BenefitID: "d1", MinutesWatched: 50}}) + assert.Equal(t, dropstate.Blocked, st.get("d1").Status, "session progress must not lift a campaign link block") + assert.Equal(t, dropstate.Unlinked, st.get("d1").Reason) + + l.refreshLive(ctx) + l.replan(ctx) + assert.Equal(t, "idle", l.Snapshot().State, "the planner must still not pick the unlinked drop") + f.mu.Lock() + assert.Empty(t, f.watching, "no StartWatch for an unlinked campaign's drop") + f.mu.Unlock() + l.stopSession() +} + +func TestLoop_BridgesManualMarksOnLoad(t *testing.T) { + _, st, h, cfg := setup(t) + st.rows["d1"] = dropstate.Row{AccountID: "acc", DropID: "d1", CampaignID: "c1", Platform: "twitch", Status: dropstate.Accruing, Minutes: 10, Required: 60, Source: dropstate.FromPlatform} + h.claimed = map[string]bool{"d1": true} + l, err := New(cfg) + require.NoError(t, err) + require.NoError(t, l.load(context.Background())) + assert.Equal(t, dropstate.Claimed, st.get("d1").Status) + assert.Equal(t, dropstate.FromUser, st.get("d1").Source) +} + +// TestLoop_BridgesManualMarksAfterReconcile covers the gap TestLoop_BridgesManualMarksOnLoad +// doesn't: an account switching from v1 to v2 has claims rows in history +// before drop_state ever has a row for that drop. load() finds nothing to +// bridge (no row yet); the first reconcile creates the row from the +// platform's silent (Known=false) answer, so the bridge must also run after +// reconcile, on the row it just created, or the drop is stuck looking +// unclaimed forever. Revert-proof: remove the post-reconcile bridge call and +// this fails (d1 stays Eligible/FromPlatform, and the planner mines it). +func TestLoop_BridgesManualMarksAfterReconcile(t *testing.T) { + f, st, h, cfg := setup(t) + f.progress = nil // d1 absent from the platform's DropProgress answer (Known=false) + h.claimed = map[string]bool{"d1": true} + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + require.True(t, st.get("d1").IsZero(), "no row exists yet; nothing for load() to bridge") + + l.reconcile(ctx) + + assert.Equal(t, dropstate.Claimed, st.get("d1").Status, "claim history must be bridged onto the row reconcile just created") + assert.Equal(t, dropstate.FromUser, st.get("d1").Source) + + l.refreshLive(ctx) + l.replan(ctx) + assert.Equal(t, "idle", l.Snapshot().State, "an already-claimed drop must not be planned for mining") + f.mu.Lock() + assert.Empty(t, f.watching, "no session should start for a drop claimed under v1") + f.mu.Unlock() +} + +// TestLoop_PostReconcileBridgeDoesNotRefightDefiniteAnswer covers review +// finding round 1 (Critical): the post-reconcile bridge must only apply to +// rows that didn't exist before that reconcile. d1 is bridged to +// Claimed/FromUser at load. A later sync then gives a *definite* platform +// answer (Known=true, Claimed=false) contradicting that user mark; per spec +// 4.2 (dropstate.Apply) that answer must win and stick — a second reconcile +// must not flip it back to Claimed just because history still lists it. +// Revert-proof: drop the "only rows new this sync" filter (bridge every +// history id unconditionally on every reconcile) and this fails, because the +// post-reconcile bridge re-claims d1 in the same reconcile that just +// demoted it, and again on the following one. +func TestLoop_PostReconcileBridgeDoesNotRefightDefiniteAnswer(t *testing.T) { + f, st, h, cfg := setup(t) + st.rows["d1"] = dropstate.Row{AccountID: "acc", DropID: "d1", CampaignID: "c1", Platform: "twitch", Status: dropstate.Accruing, Minutes: 10, Required: 60, Source: dropstate.FromPlatform} + h.claimed = map[string]bool{"d1": true} + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + require.Equal(t, dropstate.Claimed, st.get("d1").Status, "bridged at load") + require.Equal(t, dropstate.FromUser, st.get("d1").Source, "bridged at load") + + f.mu.Lock() + f.progress = []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 5, Required: 60, Known: true, Claimed: false}} + f.mu.Unlock() + + l.reconcile(ctx) + assert.Equal(t, dropstate.Accruing, st.get("d1").Status, "a definite platform answer must overwrite the user mark") + assert.Equal(t, dropstate.FromPlatform, st.get("d1").Source) + + l.reconcile(ctx) + assert.Equal(t, dropstate.Accruing, st.get("d1").Status, "post-reconcile bridge must not re-assert history on a row that already existed") + assert.Equal(t, dropstate.FromPlatform, st.get("d1").Source) +} + +func TestNew_RejectsBackendWithoutCapabilities(t *testing.T) { + type bare struct{ platform.Backend } + _, err := New(Config{AccountID: "a", Backend: bare{}}) + require.Error(t, err) +} + +func sessionEvent(gen int, ch string) session.Event { + return session.Event{Kind: session.StreamDown, Channel: ch, Gen: gen} +} + +// TestLoop_ServesChangeRestartsSessionNoFalseStall covers item 1: a +// precondition chain (d1 -> d2) on the same channel. The running session +// pins its stall bookkeeping to the serves set it started with (d1). Once +// d1 is claimed, d2 becomes mineable and the decision's Serves flips to +// [d2] while Kind/Channel stay the same ("Same" per the planner). Without +// restarting the session on the new serves set, the session keeps watching +// for gains on d1 (now absent from inventory) and never credits d2's real +// gains, so it falsely stalls and cools ch1 even though d2 is accruing. +func TestLoop_ServesChangeRestartsSessionNoFalseStall(t *testing.T) { + f := &fakeBackend{ + camps: []platform.Campaign{{ + ID: "c1", Platform: "twitch", Game: "G", Name: "Camp", Status: "active", + AccountLinked: true, AccountLinkChecked: true, + Benefits: []platform.DropBenefit{ + {ID: "d1", CampaignID: "c1", Name: "Drop1", RequiredMinutes: 60}, + {ID: "d2", CampaignID: "c1", Name: "Drop2", RequiredMinutes: 100000, Preconditions: []string{"d1"}}, + }, + }}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 58, Required: 60, Known: true}}, + inventory: []platform.Progress{{BenefitID: "d1", MinutesWatched: 58}}, + live: []platform.Stream{{Channel: "ch1", ViewerCount: 10}}, + claimRes: platform.ClaimResult{Outcome: platform.ClaimOK}, + } + st := &memStore{rows: map[string]dropstate.Row{}} + cfg := Config{ + AccountID: "acc", Platform: "twitch", Backend: f, Store: st, + AllowGame: func(g string) bool { return g == "G" }, + ReconcileEvery: time.Hour, LiveEvery: time.Hour, + BeatEvery: 10 * time.Millisecond, StallPolls: 3, + } + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return l.Snapshot().Channel == "ch1" }, 2*time.Second, 5*time.Millisecond) + + // Push d1 to completion so it gets claimed and d2's precondition clears. + for m := 59; m <= 60; m++ { + f.mu.Lock() + f.progress[0].Minutes = m + f.inventory[0].MinutesWatched = m + f.mu.Unlock() + time.Sleep(15 * time.Millisecond) + } + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 2*time.Second, 5*time.Millisecond) + + // d1 leaves inventory (claimed, as real platforms do); d2 becomes the + // only servable drop and keeps rising. Cap well under its (huge) + // required minutes so it never itself completes mid-test. + f.mu.Lock() + f.progress = []platform.DropProgress{{DropID: "d2", CampaignID: "c1", Minutes: 0, Required: 100000, Known: true}} + f.inventory = []platform.Progress{{BenefitID: "d2", MinutesWatched: 0}} + f.mu.Unlock() + + minute := 0 + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + minute++ + f.mu.Lock() + f.progress[0].Minutes = minute + f.inventory[0].MinutesWatched = minute + f.mu.Unlock() + time.Sleep(3 * time.Millisecond) + } + + assert.Equal(t, "ch1", l.Snapshot().Channel, "must not have swapped/cooled off ch1 on a false stall") + assert.NotEqual(t, "idle", l.Snapshot().State, "must still be watching, not idled out by a false stall") + assert.Greater(t, st.get("d2").Minutes, 0, "d2 must have kept accruing after the serves-set handoff") +} + +type flakyHistory struct { + mu sync.Mutex + failed bool + recorded []string +} + +func (h *flakyHistory) RecordClaimIfNew(_ context.Context, _ string, b platform.DropBenefit) (bool, error) { + h.mu.Lock() + defer h.mu.Unlock() + if !h.failed { + h.failed = true + return false, errors.New("transient history store error") + } + h.recorded = append(h.recorded, b.ID) + return true, nil +} +func (h *flakyHistory) ClaimedBenefitIDs(context.Context, string) (map[string]bool, error) { + return nil, nil +} +func (h *flakyHistory) has(id string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for _, x := range h.recorded { + if x == id { + return true + } + } + return false +} + +// TestLoop_FailedHistoryRecordIsRetried covers item 2: a RecordClaimIfNew +// failure must not be dropped on the floor. The drop id goes into +// pendingHistory and gets retried on a later loop iteration. +func TestLoop_FailedHistoryRecordIsRetried(t *testing.T) { + f, st, _, cfg := setup(t) + f.progress[0].Minutes = 60 + f.inventory[0].MinutesWatched = 60 + fh := &flakyHistory{} + cfg.History = fh + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 2*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return fh.has("d1") }, 2*time.Second, 5*time.Millisecond) +} + +// TestLoop_LostClaimResponseCorrectedByReconcile covers item 3: ClaimDrop +// reports failure (a "lost response" - the platform actually recorded the +// claim but the client saw an error), but the platform's own progress read +// on the post-claim reconcile shows Claimed:true. The row must still end +// up Claimed/FromPlatform. +func TestLoop_LostClaimResponseCorrectedByReconcile(t *testing.T) { + f, st, _, cfg := setup(t) + f.progress[0].Minutes = 60 + f.inventory[0].MinutesWatched = 60 + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimFailed} + f.claimedAfterFail = true + run(t, cfg) + require.Eventually(t, func() bool { + r := st.get("d1") + return r.Status == dropstate.Claimed && r.Source == dropstate.FromPlatform + }, 2*time.Second, 5*time.Millisecond) + f.mu.Lock() + assert.GreaterOrEqual(t, f.claims, 1, "a claim attempt must have happened and been reported as failed") + f.mu.Unlock() +} + +type recNotifier struct { + mu sync.Mutex + events []string +} + +func (n *recNotifier) Notify(_ context.Context, event string, _ map[string]any) error { + n.mu.Lock() + defer n.mu.Unlock() + n.events = append(n.events, event) + return nil +} + +func (n *recNotifier) count(event string) int { + n.mu.Lock() + defer n.mu.Unlock() + c := 0 + for _, e := range n.events { + if e == event { + c++ + } + } + return c +} + +// TestMaybeNotifyProgress_MilestoneNeverLowers covers item 5: a later, +// lower reading of Minutes (e.g. a stale re-sync) must not lower the +// recorded milestone nor re-fire a notification once real progress passes it. +func TestMaybeNotifyProgress_MilestoneNeverLowers(t *testing.T) { + n := &recNotifier{} + l := &Loop{ + cfg: Config{ProgressNotifyStepPct: 10, Notifier: n}, + milestones: map[string]int{}, + } + ctx := context.Background() + l.maybeNotifyProgress(ctx, dropstate.Row{DropID: "d1", Minutes: 50, Required: 100}) // baseline @ 50% + l.maybeNotifyProgress(ctx, dropstate.Row{DropID: "d1", Minutes: 30, Required: 100}) // regressed reading + assert.Equal(t, 50, l.milestones["d1"], "milestone must not be lowered by a regressed reading") + l.maybeNotifyProgress(ctx, dropstate.Row{DropID: "d1", Minutes: 60, Required: 100}) // real forward progress + assert.Equal(t, 60, l.milestones["d1"]) + n.mu.Lock() + defer n.mu.Unlock() + assert.Equal(t, []string{"progress"}, n.events, "only genuine forward progress notifies") +} + +// TestOnPubSub_ClaimableWithUnknownRequiredSetsClaimableDirectly covers +// item 6: a claimable pubsub event for a drop whose Required is still 0 +// (not yet known) must set the row Claimable directly rather than going +// through Apply/derive, which reads required<=0 as "blocked: sub_only". +func TestOnPubSub_ClaimableWithUnknownRequiredSetsClaimableDirectly(t *testing.T) { + st := &memStore{rows: map[string]dropstate.Row{}} + l := &Loop{ + cfg: Config{AccountID: "acc", Store: st, Now: time.Now}, + rows: map[string]dropstate.Row{"d1": {AccountID: "acc", DropID: "d1", CampaignID: "c1", Status: dropstate.Eligible, Required: 0}}, + progress: map[string]platform.DropProgress{}, + } + l.onPubSub(context.Background(), pubsubEvent{kind: "claimable", drop: "d1", instance: "inst1"}) + r := l.rows["d1"] + assert.Equal(t, dropstate.Claimable, r.Status) + assert.Equal(t, dropstate.NoReason, r.Reason) + assert.True(t, r.RetryAfter.IsZero()) + assert.Equal(t, dropstate.FromPlatform, r.Source) + assert.Equal(t, "inst1", l.progress["d1"].InstanceID) +} + +// probeBackend adds a ChannelProber that reports a priority streamer live. +type probeBackend struct { + *fakeBackend + pmu sync.Mutex + probed []string // campaign ids probed +} + +func (p *probeBackend) ProbeChannels(_ context.Context, _ platform.Session, c platform.Campaign, _ []string) ([]platform.Stream, error) { + p.pmu.Lock() + defer p.pmu.Unlock() + p.probed = append(p.probed, c.ID) + return []platform.Stream{{Channel: "prio", ViewerCount: 1}}, nil +} + +// I4: a restricted campaign whose allow-list the loop can't see (Twitch +// reports only a count) must not get priority streamers probed in, or the +// planner would watch a channel the campaign doesn't credit. +func TestLoop_RestrictedCampaignWithoutAllowListSkipsPriorityProbe(t *testing.T) { + f, _, _, cfg := setup(t) + f.camps[0].AllowedChannelCount = 3 + pb := &probeBackend{fakeBackend: f} + cfg.Backend = pb + cfg.StreamerPriority = []string{"prio"} + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + defer l.stopSession() + + pb.pmu.Lock() + assert.NotContains(t, pb.probed, "c1", "restricted campaign with unknown allow-list must not be probed") + pb.pmu.Unlock() + for _, s := range l.live["c1"] { + assert.NotEqual(t, "prio", s.Channel) + } + require.NotNil(t, l.current) + assert.Equal(t, "ch1", l.current.Channel.Channel) +} + +// Control for the above: an unrestricted campaign still gets priority probing. +func TestLoop_UnrestrictedCampaignProbesPriority(t *testing.T) { + f, _, _, cfg := setup(t) + pb := &probeBackend{fakeBackend: f} + cfg.Backend = pb + cfg.StreamerPriority = []string{"prio"} + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + pb.pmu.Lock() + assert.Contains(t, pb.probed, "c1") + pb.pmu.Unlock() +} + +// integrityBackend fails discovery with the integrity wall. +type integrityBackend struct{ *fakeBackend } + +func (integrityBackend) ListActiveCampaigns(context.Context, platform.Session) ([]platform.Campaign, error) { + return nil, fmt.Errorf("gql: %w", platform.ErrIntegrityBlocked) +} + +// I6: the integrity wall surfaces as auth_required and the loop exits cleanly +// (like v1) so the next reload re-spins it once the session is refreshed. +func TestLoop_IntegrityBlockedExitsAuthRequired(t *testing.T) { + f, _, _, cfg := setup(t) + cfg.Backend = integrityBackend{f} + l, err := New(cfg) + require.NoError(t, err) + done := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { done <- l.Run(ctx) }() + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Run did not exit on integrity wall") + } + assert.Equal(t, "auth_required", l.Snapshot().State) + f.mu.Lock() + assert.Empty(t, f.watching, "no session starts behind an integrity wall") + f.mu.Unlock() +} + +// subBackend records PubSub subscriptions and detects StopWatch overlapping +// the next StartWatch. +type subBackend struct { + *fakeBackend + smu sync.Mutex + subs []string + unsubs []string + inStop bool + overlaps int + starts int +} + +func (s *subBackend) SubscribeChannel(_, id string) { + s.smu.Lock() + defer s.smu.Unlock() + s.subs = append(s.subs, id) +} +func (s *subBackend) UnsubscribeChannel(_, id string) { + s.smu.Lock() + defer s.smu.Unlock() + s.unsubs = append(s.unsubs, id) +} +func (s *subBackend) StartWatch(ctx context.Context, sess platform.Session, st platform.Stream) (platform.WatchHandle, error) { + s.smu.Lock() + if s.inStop { + s.overlaps++ + } + s.starts++ + s.smu.Unlock() + return s.fakeBackend.StartWatch(ctx, sess, st) +} +func (s *subBackend) StopWatch(ctx context.Context, h platform.WatchHandle) error { + s.smu.Lock() + s.inStop = true + s.smu.Unlock() + time.Sleep(50 * time.Millisecond) + s.smu.Lock() + s.inStop = false + s.smu.Unlock() + return s.fakeBackend.StopWatch(ctx, h) +} +func (s *subBackend) counts() (subs, unsubs, starts, overlaps int) { + s.smu.Lock() + defer s.smu.Unlock() + return len(s.subs), len(s.unsubs), s.starts, s.overlaps +} + +// A serves-change restart on the same channel waits for the old session's +// StopWatch before the new StartWatch, and keeps the PubSub subscription. +func TestLoop_ServesRestartWaitsForStopAndKeepsSubscription(t *testing.T) { + f, _, _, cfg := setup(t) + f.live = []platform.Stream{{Channel: "ch1", ChannelID: "id1", ViewerCount: 10}} + sb := &subBackend{fakeBackend: f} + cfg.Backend = sb + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + require.Eventually(t, func() bool { _, _, starts, _ := sb.counts(); return starts == 1 }, 2*time.Second, 5*time.Millisecond) + + // Force a serves-set change on the same decision. + l.current.Serves = []string{"stale"} + l.replan(ctx) + require.Eventually(t, func() bool { _, _, starts, _ := sb.counts(); return starts == 2 }, 2*time.Second, 5*time.Millisecond) + + subs, unsubs, _, overlaps := sb.counts() + assert.Equal(t, 0, overlaps, "StartWatch must not run while the previous StopWatch is in flight") + assert.Equal(t, 1, subs, "same-channel restart must not resubscribe") + assert.Equal(t, 0, unsubs, "same-channel restart must not unsubscribe") + + l.stopSession() + subs, unsubs, _, _ = sb.counts() + assert.Equal(t, 1, subs) + assert.Equal(t, 1, unsubs, "final stop unsubscribes") + f.mu.Lock() + assert.Equal(t, 2, f.stops, "final stop waited for StopWatch") + f.mu.Unlock() +} + +// TV-client Twitch sessions discover channel-first and need Session.Games +// (the whitelisted names); Config.Games must reach every discovery call. +func TestLoop_ConfigGamesReachSession(t *testing.T) { + f, _, _, cfg := setup(t) + cfg.Games = []string{"G", "Other Game"} + run(t, cfg) + require.Eventually(t, func() bool { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.listGames) > 0 + }, 2*time.Second, 5*time.Millisecond) + f.mu.Lock() + defer f.mu.Unlock() + for _, g := range f.listGames { + assert.Equal(t, []string{"G", "Other Game"}, g) + } +} + +// probeSetup scripts the Twitch "claimed outside the app, campaign left the +// Inventory" case: the platform reports nothing for d1 (DropCampaignDetails +// self: null, not in Inventory), so reconcile derives it Eligible and the +// session never sees a gain on it. +func probeSetup(t *testing.T) (*fakeBackend, *memStore, *memHistory, Config) { + f, st, h, cfg := setup(t) + f.progress = nil + f.inventory = nil + cfg.StallPolls = 3 + cfg.ClaimProbe = true + // These tests isolate the stall probe: ClaimProbe also enables the + // catch-up pass (batch145_test.go), which would claim d1 one + // CatchUpEvery (default 3s) after the first reconcile. An hour keeps it + // out explicitly instead of relying on the tests finishing within 3s. + cfg.CatchUpEvery = time.Hour + return f, st, h, cfg +} + +func (f *fakeBackend) claimCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.claims +} + +// watchedChannels returns every channel a session has started on, in order. +func (f *fakeBackend) watchedChannels() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.watching...) +} + +// (a) An Eligible served drop absent from the inventory is probed on its +// SECOND stall (ch1 stalls: plain cooldown; ch2 stalls: one claim). +// ALREADY_CLAIMED marks it claimed from the platform, records history, +// does not notify (historical claim), and the planner moves on instead of +// re-watching it. +func TestLoop_StallClaimProbe_AlreadyClaimedMarksClaimed(t *testing.T) { + f, st, h, cfg := probeSetup(t) + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimAlready} + n := &recNotifier{} + cfg.Notifier = n + l, _ := run(t, cfg) + require.Eventually(t, func() bool { + r := st.get("d1") + return r.Status == dropstate.Claimed && r.Source == dropstate.FromPlatform + }, 2*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 5*time.Millisecond) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, f.claimCount(), "exactly one probe claim") + f.mu.Lock() + assert.Equal(t, 2, f.claimWatchLen, "claim sent only after the second stall (ch1, then ch2)") + f.mu.Unlock() + assert.Equal(t, []string{"ch1", "ch2"}, f.watchedChannels()) + h.mu.Lock() + assert.Contains(t, h.recorded, "d1") + h.mu.Unlock() + n.mu.Lock() + claimEvents := 0 + for _, e := range n.events { + if e == "claim" { + claimEvents++ + } + } + n.mu.Unlock() + assert.Equal(t, 0, claimEvents, "ALREADY_CLAIMED is a historical claim: no claim notification") +} + +// A stall probe answered OK did claim the drop just now, so it notifies once. +func TestLoop_StallClaimProbe_OKNotifiesOnce(t *testing.T) { + f, st, _, cfg := probeSetup(t) + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimOK} + n := &recNotifier{} + cfg.Notifier = n + run(t, cfg) + require.Eventually(t, func() bool { return st.get("d1").Status == dropstate.Claimed }, 3*time.Second, 5*time.Millisecond) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, n.count("claim")) +} + +// A single stall is a plain cooldown: no claim is sent. +func TestLoop_StallClaimProbe_FirstStallNoClaim(t *testing.T) { + f, st, _, cfg := probeSetup(t) + f.live = []platform.Stream{{Channel: "ch1", ViewerCount: 10}} // one channel: one stall, then idle + l, _ := run(t, cfg) + require.Eventually(t, func() bool { return len(f.watchedChannels()) == 1 }, 2*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 5*time.Millisecond) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 0, f.claimCount(), "first stall must not probe") + assert.Equal(t, dropstate.Eligible, st.get("d1").Status) +} + +// A drop's stall count is cleared once its row leaves Eligible, so a later +// return to Eligible starts counting from zero again. +func TestLoop_StallClaimProbe_CountClearedWhenRowLeavesEligible(t *testing.T) { + f, _, _, cfg := probeSetup(t) + cfg.StallPolls = 1000 // the live session must not stall on its own + l, err := New(cfg) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, l.load(ctx)) + l.reconcile(ctx) + l.refreshLive(ctx) + l.replan(ctx) + defer l.stopSession() + require.Equal(t, "ch1", l.current.Channel.Channel) + require.Equal(t, dropstate.Eligible, l.rows["d1"].Status) + stall := func() { l.onSession(ctx, session.Event{Kind: session.Stalled, Channel: "ch1", Gen: l.gen}) } + + stall() // count 1 + assert.Equal(t, 0, f.claimCount()) + r := l.rows["d1"] + r.Status = dropstate.Accruing + l.commit(ctx, r) // leaves Eligible: count cleared + r.Status = dropstate.Eligible + l.commit(ctx, r) + stall() // count 1 again, not 2 + assert.Equal(t, 0, f.claimCount(), "count must restart after the row left Eligible") + stall() // count 2: probe + assert.Equal(t, 1, f.claimCount()) +} + +// (b) A failed probe blocks the drop not_enrolled without counting a claim +// failure, survives the follow-up reconcile, and is not probed again. +func TestLoop_StallClaimProbe_FailedBlocksNotEnrolled(t *testing.T) { + f, st, _, cfg := probeSetup(t) + f.claimRes = platform.ClaimResult{Outcome: platform.ClaimFailed} + l, _ := run(t, cfg) + require.Eventually(t, func() bool { + r := st.get("d1") + return r.Status == dropstate.Blocked && r.Reason == dropstate.NotEnrolled + }, 2*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 2*time.Second, 5*time.Millisecond) + time.Sleep(50 * time.Millisecond) + r := st.get("d1") + assert.Equal(t, dropstate.Blocked, r.Status, "block survives the follow-up reconcile") + assert.Equal(t, dropstate.NotEnrolled, r.Reason) + assert.Equal(t, 0, r.FailCount, "a probe is not a claim failure") + assert.Equal(t, dropstate.FromPlatform, r.Source) + assert.Equal(t, 1, f.claimCount(), "exactly one probe claim") +} + +// waitBothStalled waits until ch1 and ch2 have both been watched and the +// loop is idle, i.e. the drop has stalled twice. +func waitBothStalled(t *testing.T, f *fakeBackend, l *Loop) { + t.Helper() + require.Eventually(t, func() bool { return len(f.watchedChannels()) >= 2 }, 3*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return l.Snapshot().State == "idle" }, 3*time.Second, 5*time.Millisecond) + time.Sleep(50 * time.Millisecond) +} + +// (c) A drop present in the inventory (really tracked, just frozen) is a +// plain stall: no probe claim, even on the second stall. +func TestLoop_StallClaimProbe_SkipsDropInInventory(t *testing.T) { + f, st, _, cfg := probeSetup(t) + f.inventory = []platform.Progress{{BenefitID: "d1", MinutesWatched: 0}} + l, _ := run(t, cfg) + waitBothStalled(t, f, l) + assert.Equal(t, 0, f.claimCount()) + assert.Equal(t, dropstate.Eligible, st.get("d1").Status) +} + +// (d) Probe disabled (Kick): stall behaviour unchanged, no claim. +func TestLoop_StallClaimProbe_DisabledNoClaim(t *testing.T) { + f, st, _, cfg := probeSetup(t) + cfg.ClaimProbe = false + l, _ := run(t, cfg) + waitBothStalled(t, f, l) + assert.Equal(t, 0, f.claimCount()) + assert.Equal(t, dropstate.Eligible, st.get("d1").Status) +} + +// (e) Only Eligible rows are probed: an Accruing row missing from the +// inventory is not claimed. +func TestLoop_StallClaimProbe_SkipsAccruing(t *testing.T) { + f, st, _, cfg := probeSetup(t) + f.progress = []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 10, Required: 60, Known: true}} + l, _ := run(t, cfg) + waitBothStalled(t, f, l) + assert.Equal(t, 0, f.claimCount()) + assert.Equal(t, dropstate.Accruing, st.get("d1").Status) +} diff --git a/internal/pipeline/planner/planner.go b/internal/pipeline/planner/planner.go new file mode 100644 index 00000000..504859d6 --- /dev/null +++ b/internal/pipeline/planner/planner.go @@ -0,0 +1,251 @@ +// Package planner turns drop state, campaigns and live channels into one +// decision: which channel to watch and which drops that serves. Pure: no +// I/O, no clock reads, no goroutines. +package planner + +import ( + "sort" + "strings" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type Kind int + +const ( + Idle Kind = iota + Mine + ForceWatch +) + +func (k Kind) String() string { + switch k { + case Mine: + return "mine" + case ForceWatch: + return "force_watch" + default: + return "idle" + } +} + +// SwapHold is the minimum time on a channel before the planner switches to +// a better one, unless the current channel is dead or cooling down. +const SwapHold = 10 * time.Minute + +const ( + ReasonNothingToMine = "nothing_to_mine" + ReasonNoLiveChannels = "no_live_channels" +) + +type Settings struct { + GameRank func(game string) int + PriorityMode string + StreamerPriority []string + ForceWatch []platform.Stream +} + +type Input struct { + Now time.Time + Rows map[string]dropstate.Row + Campaigns []platform.Campaign + Settings Settings + Live map[string][]platform.Stream + Cooldowns map[string]time.Time + Current *Decision + LastSwap time.Time +} + +type Decision struct { + Kind Kind + CampaignID string + Channel platform.Stream + Serves []string + Reason string +} + +// Same reports whether two decisions watch the same thing. +func (d Decision) Same(o Decision) bool { + return d.Kind == o.Kind && strings.EqualFold(d.Channel.Channel, o.Channel.Channel) +} + +func key(ch string) string { return strings.ToLower(ch) } + +func cooling(in Input, ch string) bool { + until, ok := in.Cooldowns[key(ch)] + return ok && in.Now.Before(until) +} + +func inWindow(c platform.Campaign, now time.Time) bool { + if c.Status != "" && c.Status != "active" { + return false + } + if !c.StartsAt.IsZero() && now.Before(c.StartsAt) { + return false + } + return c.EndsAt.IsZero() || now.Before(c.EndsAt) +} + +// mineable returns the campaign's mineable drop ids, lowest tier first. +func mineable(in Input, c platform.Campaign) []string { + type d struct { + id string + req int + } + seen := map[string]bool{} + var ds []d + for _, b := range c.Benefits { + if seen[b.ID] || !dropstate.Mineable(in.Rows[b.ID]) { + continue + } + ok := true + for _, p := range b.Preconditions { + if in.Rows[p].Status != dropstate.Claimed { + ok = false + break + } + } + if !ok { + continue + } + seen[b.ID] = true + ds = append(ds, d{b.ID, b.RequiredMinutes}) + } + sort.SliceStable(ds, func(i, j int) bool { + if ds[i].req != ds[j].req { + return ds[i].req < ds[j].req + } + return ds[i].id < ds[j].id + }) + out := make([]string, len(ds)) + for i, x := range ds { + out[i] = x.id + } + return out +} + +func ends(c platform.Campaign) time.Time { + if c.EndsAt.IsZero() { + return time.Unix(1<<62, 0) + } + return c.EndsAt +} + +// Candidates returns in-window campaigns with at least one mineable drop, +// best first: game rank then ending soonest (ending soonest first when +// PriorityMode is "ending_soonest"). +func Candidates(in Input) []platform.Campaign { + var out []platform.Campaign + for _, c := range in.Campaigns { + if inWindow(c, in.Now) && len(mineable(in, c)) > 0 { + out = append(out, c) + } + } + rank := func(g string) int { + if in.Settings.GameRank == nil { + return 0 + } + return in.Settings.GameRank(g) + } + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i], out[j] + if in.Settings.PriorityMode == "ending_soonest" && !ends(a).Equal(ends(b)) { + return ends(a).Before(ends(b)) + } + if ra, rb := rank(a.Game), rank(b.Game); ra != rb { + return ra < rb + } + if !ends(a).Equal(ends(b)) { + return ends(a).Before(ends(b)) + } + return a.ID < b.ID + }) + return out +} + +func hasChannel(streams []platform.Stream, ch string) bool { + for _, s := range streams { + if strings.EqualFold(s.Channel, ch) { + return true + } + } + return false +} + +func servedBy(in Input, cands []platform.Campaign, ch string) []string { + var out []string + for _, c := range cands { + if hasChannel(in.Live[c.ID], ch) { + out = append(out, mineable(in, c)...) + } + } + return out +} + +func pickChannel(in Input, c platform.Campaign) (platform.Stream, bool) { + prio := map[string]int{} + for i, l := range in.Settings.StreamerPriority { + if _, dup := prio[key(l)]; !dup { + prio[key(l)] = i + } + } + live := append([]platform.Stream(nil), in.Live[c.ID]...) + sort.SliceStable(live, func(i, j int) bool { + pi, iok := prio[key(live[i].Channel)] + pj, jok := prio[key(live[j].Channel)] + if iok != jok { + return iok + } + if iok && pi != pj { + return pi < pj + } + return live[i].ViewerCount > live[j].ViewerCount + }) + for _, s := range live { + if !cooling(in, s.Channel) { + return s, true + } + } + return platform.Stream{}, false +} + +func forceOrIdle(in Input, reason string) Decision { + for _, s := range in.Settings.ForceWatch { + if !cooling(in, s.Channel) { + return Decision{Kind: ForceWatch, Channel: s, Reason: "force-watch " + s.Channel} + } + } + return Decision{Kind: Idle, Reason: reason} +} + +// Plan decides what the account should watch now. +func Plan(in Input) Decision { + cands := Candidates(in) + if len(cands) == 0 { + return forceOrIdle(in, ReasonNothingToMine) + } + if cur := in.Current; cur != nil && cur.Kind == Mine && in.Now.Sub(in.LastSwap) < SwapHold && !cooling(in, cur.Channel.Channel) { + if serves := servedBy(in, cands, cur.Channel.Channel); len(serves) > 0 { + d := *cur + d.Serves, d.Reason = serves, "holding current channel" + return d + } + } + for _, c := range cands { + ch, ok := pickChannel(in, c) + if !ok { + continue + } + reason := "top viewers" + for _, l := range in.Settings.StreamerPriority { + if strings.EqualFold(l, ch.Channel) { + reason = "priority streamer" + break + } + } + return Decision{Kind: Mine, CampaignID: c.ID, Channel: ch, Serves: servedBy(in, cands, ch.Channel), Reason: reason} + } + return forceOrIdle(in, ReasonNoLiveChannels) +} diff --git a/internal/pipeline/planner/planner_test.go b/internal/pipeline/planner/planner_test.go new file mode 100644 index 00000000..89e0d486 --- /dev/null +++ b/internal/pipeline/planner/planner_test.go @@ -0,0 +1,163 @@ +package planner + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +var now = time.Unix(1_700_000_000, 0) + +func camp(id, game string, ends time.Time, drops ...platform.DropBenefit) platform.Campaign { + return platform.Campaign{ID: id, Game: game, Status: "active", EndsAt: ends, Benefits: drops} +} + +func drop(id, campaign string, req int, pre ...string) platform.DropBenefit { + return platform.DropBenefit{ID: id, CampaignID: campaign, RequiredMinutes: req, Preconditions: pre} +} + +func rows(rs ...dropstate.Row) map[string]dropstate.Row { + m := map[string]dropstate.Row{} + for _, r := range rs { + m[r.DropID] = r + } + return m +} + +func st(id string, s dropstate.Status) dropstate.Row { return dropstate.Row{DropID: id, Status: s} } + +func stream(ch string, viewers int) platform.Stream { + return platform.Stream{Channel: ch, ViewerCount: viewers} +} + +func TestPlan_PicksHighestViewerByDefault(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("small", 5), stream("big", 500)}}, + } + d := Plan(in) + assert.Equal(t, Mine, d.Kind) + assert.Equal(t, "big", d.Channel.Channel) + assert.Equal(t, []string{"d1"}, d.Serves) +} + +func TestPlan_PriorityStreamerFirst(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("big", 500), stream("Fav2", 1), stream("fav1", 2)}}, + Settings: Settings{StreamerPriority: []string{"fav1", "fav2"}}, + } + assert.Equal(t, "fav1", Plan(in).Channel.Channel) + in.Live["c1"] = []platform.Stream{stream("big", 500), stream("Fav2", 1)} + assert.Equal(t, "Fav2", Plan(in).Channel.Channel, "case-insensitive, next priority") + in.Live["c1"] = []platform.Stream{stream("big", 500)} + assert.Equal(t, "big", Plan(in).Channel.Channel, "fallback to category") +} + +func TestPlan_GameRankThenEndingSoonest(t *testing.T) { + soon, late := now.Add(time.Hour), now.Add(48*time.Hour) + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("late", "A", late, drop("d1", "late", 60)), camp("soon", "B", soon, drop("d2", "soon", 60))}, + Rows: rows(st("d1", dropstate.Eligible), st("d2", dropstate.Eligible)), + Live: map[string][]platform.Stream{"late": {stream("x", 1)}, "soon": {stream("y", 1)}}, + Settings: Settings{GameRank: func(g string) int { return map[string]int{"A": 0, "B": 1}[g] }}, + } + assert.Equal(t, "late", Plan(in).CampaignID, "ordered: game rank wins") + in.Settings.PriorityMode = "ending_soonest" + assert.Equal(t, "soon", Plan(in).CampaignID) +} + +func TestPlan_SkipsNonMineableAndLockedPreconditions(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, + drop("claimed", "c1", 30), drop("locked", "c1", 60, "gate"), drop("gate", "c1", 10))}, + Rows: rows(st("claimed", dropstate.Claimed), st("locked", dropstate.Eligible), st("gate", dropstate.Blocked)), + Live: map[string][]platform.Stream{"c1": {stream("x", 1)}}, + } + assert.Equal(t, Idle, Plan(in).Kind, "gate is blocked, locked waits on it") + + in.Rows["gate"] = dropstate.Row{DropID: "gate", Status: dropstate.Claimed, Source: dropstate.FromUser} + d := Plan(in) + assert.Equal(t, Mine, d.Kind, "user-marked precondition unlocks (P9)") + assert.Equal(t, []string{"locked"}, d.Serves) +} + +func TestPlan_ServesDropsAcrossCampaignsOnSameChannel(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{ + camp("c1", "Rust", time.Time{}, drop("r1", "c1", 60), drop("r2", "c1", 30)), + camp("c2", "Rust", time.Time{}, drop("r3", "c2", 120)), + }, + Rows: rows(st("r1", dropstate.Eligible), st("r2", dropstate.Accruing), st("r3", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("oilrats", 9)}, "c2": {stream("OILRATS", 9)}}, + } + d := Plan(in) + assert.Equal(t, []string{"r2", "r1", "r3"}, d.Serves, "lowest tier first, then other campaigns") +} + +func TestPlan_CooldownSkipsChannel(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("big", 500), stream("small", 5)}}, + Cooldowns: map[string]time.Time{"big": now.Add(time.Minute)}, + } + assert.Equal(t, "small", Plan(in).Channel.Channel) +} + +func TestPlan_HysteresisHoldsThenBypassesDeadChannel(t *testing.T) { + cur := Decision{Kind: Mine, CampaignID: "c1", Channel: stream("big", 500), Serves: []string{"d1"}} + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("big", 500), stream("fav", 1)}}, + Settings: Settings{StreamerPriority: []string{"fav"}}, + Current: &cur, LastSwap: now.Add(-time.Minute), + } + assert.Equal(t, "big", Plan(in).Channel.Channel, "inside hold: keep current") + in.LastSwap = now.Add(-SwapHold - time.Second) + assert.Equal(t, "fav", Plan(in).Channel.Channel, "hold over: priority streamer wins") + in.LastSwap = now.Add(-time.Minute) + in.Live["c1"] = []platform.Stream{stream("fav", 1)} + assert.Equal(t, "fav", Plan(in).Channel.Channel, "current went offline: bypass hold") +} + +func TestPlan_ForceWatchOnlyWhenNothingToMine(t *testing.T) { + in := Input{Now: now, Settings: Settings{ForceWatch: []platform.Stream{stream("pts", 1)}}} + d := Plan(in) + assert.Equal(t, ForceWatch, d.Kind) + assert.Equal(t, "pts", d.Channel.Channel) + + in.Settings.ForceWatch = nil + d = Plan(in) + assert.Equal(t, Idle, d.Kind) + assert.Equal(t, ReasonNothingToMine, d.Reason) +} + +func TestPlan_NoLiveChannelsIdleReason(t *testing.T) { + in := Input{Now: now, + Campaigns: []platform.Campaign{camp("c1", "G", time.Time{}, drop("d1", "c1", 60))}, + Rows: rows(st("d1", dropstate.Eligible)), + } + assert.Equal(t, ReasonNoLiveChannels, Plan(in).Reason) +} + +func TestPlan_OutsideWindowIgnored(t *testing.T) { + c := camp("c1", "G", now.Add(-time.Minute), drop("d1", "c1", 60)) + in := Input{Now: now, Campaigns: []platform.Campaign{c}, Rows: rows(st("d1", dropstate.Eligible)), + Live: map[string][]platform.Stream{"c1": {stream("x", 1)}}} + assert.Equal(t, Idle, Plan(in).Kind) +} + +func TestDecision_Same(t *testing.T) { + a := Decision{Kind: Mine, Channel: stream("Big", 1)} + assert.True(t, a.Same(Decision{Kind: Mine, Channel: stream("big", 9)})) + assert.False(t, a.Same(Decision{Kind: ForceWatch, Channel: stream("big", 9)})) + assert.True(t, Decision{Kind: Idle}.Same(Decision{Kind: Idle, Reason: "x"})) +} diff --git a/internal/pipeline/reconcile/reconcile.go b/internal/pipeline/reconcile/reconcile.go new file mode 100644 index 00000000..cbf28d47 --- /dev/null +++ b/internal/pipeline/reconcile/reconcile.go @@ -0,0 +1,143 @@ +// Package reconcile folds platform per-campaign progress into drop_state. +// It never decides what to watch. +package reconcile + +import ( + "context" + "fmt" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type CampaignPersister interface { + PersistCampaigns(ctx context.Context, camps []platform.Campaign) error +} + +type Config struct { + AccountID string + Platform string + Backend platform.Backend + Progress platform.DropProgressSource + Session platform.Session + AllowGame func(game string) bool + AllowChannel func(channels []string) bool + ForceLinked func(campaignID string) bool + Persister CampaignPersister +} + +type Result struct { + Campaigns []platform.Campaign + Rows []dropstate.Row + Progress map[string]platform.DropProgress +} + +// InScope keeps campaigns the account mines: whitelisted game, or a +// campaign whose channels match the account's priority streamers +// (null-game drops). One-click reward campaigns have no watch time. +func InScope(camps []platform.Campaign, allowGame func(string) bool, allowChannel func([]string) bool) []platform.Campaign { + var out []platform.Campaign + for _, c := range camps { + if c.Kind == "reward" { + continue + } + ok := allowGame == nil || allowGame(c.Game) + if !ok && allowChannel != nil && allowChannel(c.AllowedChannels) { + ok = true + } + if ok { + out = append(out, c) + } + } + return out +} + +func isActive(c platform.Campaign, now time.Time) bool { + if c.Status != "" && c.Status != "active" { + return false + } + return c.EndsAt.IsZero() || now.Before(c.EndsAt) +} + +// Run lists campaigns, persists the in-scope ones, reads their progress and +// returns every drop row it touched. On any platform error it returns the +// error and no rows, so a failed sync never changes state. +func Run(ctx context.Context, cfg Config, prev map[string]dropstate.Row, now time.Time) (Result, error) { + camps, err := cfg.Backend.ListActiveCampaigns(ctx, cfg.Session) + if err != nil { + return Result{}, fmt.Errorf("list campaigns: %w", err) + } + scope := InScope(camps, cfg.AllowGame, cfg.AllowChannel) + if cfg.Persister != nil && len(scope) > 0 { + if err := cfg.Persister.PersistCampaigns(ctx, scope); err != nil { + return Result{}, fmt.Errorf("persist campaigns: %w", err) + } + } + var active []platform.Campaign + for _, c := range scope { + if isActive(c, now) { + active = append(active, c) + } + } + byDrop := map[string]platform.DropProgress{} + if len(active) > 0 { + obs, err := cfg.Progress.DropProgress(ctx, cfg.Session, active) + if err != nil { + return Result{}, fmt.Errorf("drop progress: %w", err) + } + for _, o := range obs { + byDrop[o.DropID] = o + } + } + + res := Result{Campaigns: active, Progress: byDrop} + seen := map[string]bool{} + for _, c := range scope { + if c.Status == "upcoming" { + continue + } + expired := !isActive(c, now) + linked := c.AccountLinked || !c.AccountLinkChecked || (cfg.ForceLinked != nil && cfg.ForceLinked(c.ID)) + for _, b := range c.Benefits { + if b.ID == "" || seen[b.ID] { + continue + } + seen[b.ID] = true + p, ok := prev[b.ID] + if !ok || p.IsZero() { + p = dropstate.Row{AccountID: cfg.AccountID, DropID: b.ID, CampaignID: c.ID, Platform: cfg.Platform, Source: dropstate.FromPlatform} + } + var next dropstate.Row + if expired { + if p.IsZero() { + continue // never tracked, nothing to expire + } + next = dropstate.Expire(p, now) + } else { + o, known := byDrop[b.ID] + req := b.RequiredMinutes + if known { + req = o.Required + } + applyFrom := p + if linked && p.Status == dropstate.Blocked && p.Reason == dropstate.Unlinked { + // Only the reconciler may lift a campaign link block, and + // only once it has confirmed the campaign is linked: a + // non-claimed observation alone (e.g. a mid-watch session + // Progress event) must never lift it. + applyFrom = dropstate.Retry(p, now) + } + next = dropstate.Apply(applyFrom, dropstate.Observation{ + Known: known && o.Known, Unmineable: o.Unmineable, Claimed: o.Claimed, + Minutes: o.Minutes, Required: req, + }, now) + if !linked { + next = dropstate.BlockLink(next, now) + } + } + res.Rows = append(res.Rows, next) + } + } + return res, nil +} diff --git a/internal/pipeline/reconcile/reconcile_test.go b/internal/pipeline/reconcile/reconcile_test.go new file mode 100644 index 00000000..8d6351e2 --- /dev/null +++ b/internal/pipeline/reconcile/reconcile_test.go @@ -0,0 +1,201 @@ +package reconcile + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type fakeBackend struct { + platform.Backend // nil: unimplemented methods panic + camps []platform.Campaign + progress []platform.DropProgress + progErr error +} + +func (f *fakeBackend) ListActiveCampaigns(context.Context, platform.Session) ([]platform.Campaign, error) { + return f.camps, nil +} + +func (f *fakeBackend) DropProgress(context.Context, platform.Session, []platform.Campaign) ([]platform.DropProgress, error) { + return f.progress, f.progErr +} + +type fakePersister struct{ got []platform.Campaign } + +func (p *fakePersister) PersistCampaigns(_ context.Context, c []platform.Campaign) error { + p.got = append(p.got, c...) + return nil +} + +var now = time.Unix(1_700_000_000, 0) + +func camp(id, game string, drops ...string) platform.Campaign { + c := platform.Campaign{ID: id, Platform: "twitch", Game: game, Status: "active", AccountLinked: true, AccountLinkChecked: true} + for _, d := range drops { + c.Benefits = append(c.Benefits, platform.DropBenefit{ID: d, CampaignID: id, RequiredMinutes: 60}) + } + return c +} + +func cfg(f *fakeBackend, p *fakePersister) Config { + return Config{AccountID: "a", Platform: "twitch", Backend: f, Progress: f, Persister: p, + AllowGame: func(g string) bool { return g == "G" }} +} + +func byDrop(rows []dropstate.Row) map[string]dropstate.Row { + m := map[string]dropstate.Row{} + for _, r := range rows { + m[r.DropID] = r + } + return m +} + +func TestRun_ClaimedAfterLeavingInventory(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1")}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 60, Required: 60, Claimed: true, Known: true}}} + prev := map[string]dropstate.Row{"d1": {AccountID: "a", DropID: "d1", CampaignID: "c1", Platform: "twitch", Status: dropstate.Accruing, Minutes: 30, Required: 60, Source: dropstate.FromPlatform}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Claimed, byDrop(res.Rows)["d1"].Status) +} + +func TestRun_NewDropSeedsIdentity(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1")}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 5, Required: 60, Known: true}}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), nil, now) + require.NoError(t, err) + r := byDrop(res.Rows)["d1"] + assert.Equal(t, "a", r.AccountID) + assert.Equal(t, "c1", r.CampaignID) + assert.Equal(t, "twitch", r.Platform) + assert.Equal(t, dropstate.Accruing, r.Status) +} + +func TestRun_ProgressErrorChangesNothing(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1")}, progErr: errors.New("details 500")} + prev := map[string]dropstate.Row{"d1": {DropID: "d1", Status: dropstate.Accruing, Minutes: 30, Required: 60}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.Error(t, err) + assert.Empty(t, res.Rows) +} + +func TestRun_MultiItemDropOneRow(t *testing.T) { + c := camp("c1", "G", "d1", "d1") + f := &fakeBackend{camps: []platform.Campaign{c}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Required: 60, Known: true}}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), nil, now) + require.NoError(t, err) + assert.Len(t, res.Rows, 1) +} + +func TestRun_UnlinkedCampaignBlocked_UnlessForced(t *testing.T) { + c := camp("c1", "G", "d1") + c.AccountLinked = false + f := &fakeBackend{camps: []platform.Campaign{c}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Required: 60, Known: true}}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), nil, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Unlinked, byDrop(res.Rows)["d1"].Reason) + + cf := cfg(f, &fakePersister{}) + cf.ForceLinked = func(id string) bool { return id == "c1" } + res, err = Run(context.Background(), cf, nil, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Eligible, byDrop(res.Rows)["d1"].Status) +} + +// I3: once the user links, the next sync lifts the campaign link block at once. +func TestRun_LinkedAfterUnlinkedUnblocksImmediately(t *testing.T) { + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "G", "d1", "d2")}, + progress: []platform.DropProgress{ + {DropID: "d1", CampaignID: "c1", Minutes: 20, Required: 60, Known: true}, + {DropID: "d2", CampaignID: "c1", Minutes: 0, Required: 60, Known: true}, + }} + blocked := func(id string) dropstate.Row { + return dropstate.Row{AccountID: "a", DropID: id, CampaignID: "c1", Platform: "twitch", + Status: dropstate.Blocked, Reason: dropstate.Unlinked, Minutes: 10, Required: 60, + Source: dropstate.FromPlatform, RetryAfter: now.Add(dropstate.RetryNeedsLink - time.Minute)} + } + prev := map[string]dropstate.Row{"d1": blocked("d1"), "d2": blocked("d2")} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Accruing, byDrop(res.Rows)["d1"].Status) + assert.Equal(t, dropstate.Eligible, byDrop(res.Rows)["d2"].Status) +} + +// Companion to TestRun_LinkedAfterUnlinkedUnblocksImmediately: when the +// campaign is still unlinked, a Blocked/Unlinked row must stay that way +// (dropstate.Apply alone no longer lifts it, and reconcile.Run re-applies +// BlockLink after Apply). +func TestRun_StillUnlinkedStaysBlocked(t *testing.T) { + c := camp("c1", "G", "d1") + c.AccountLinked = false + f := &fakeBackend{camps: []platform.Campaign{c}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Minutes: 20, Required: 60, Known: true}}} + prev := map[string]dropstate.Row{"d1": {AccountID: "a", DropID: "d1", CampaignID: "c1", Platform: "twitch", + Status: dropstate.Blocked, Reason: dropstate.Unlinked, Minutes: 10, Required: 60, + Source: dropstate.FromPlatform, RetryAfter: now.Add(dropstate.RetryNeedsLink - time.Minute)}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.NoError(t, err) + r := byDrop(res.Rows)["d1"] + assert.Equal(t, dropstate.Blocked, r.Status) + assert.Equal(t, dropstate.Unlinked, r.Reason) +} + +// C1: a backfilled ghost-skip (Required 0, immediate retry) on Kick, where the +// platform reports unlisted rewards Known=false, becomes mineable again. +func TestRun_BackfilledKickSkipBecomesEligible(t *testing.T) { + c := camp("c1", "G", "d1") + c.Platform = "kick" + f := &fakeBackend{camps: []platform.Campaign{c}, + progress: []platform.DropProgress{{DropID: "d1", CampaignID: "c1", Required: 120, Known: false}}} + prev := map[string]dropstate.Row{"d1": {AccountID: "a", DropID: "d1", CampaignID: "c1", Platform: "kick", + Status: dropstate.Blocked, Reason: dropstate.NotEnrolled, Source: dropstate.FromPlatform, RetryAfter: now}} + cf := cfg(f, &fakePersister{}) + cf.Platform = "kick" + res, err := Run(context.Background(), cf, prev, now) + require.NoError(t, err) + r := byDrop(res.Rows)["d1"] + assert.Equal(t, dropstate.Eligible, r.Status) + assert.Equal(t, dropstate.NoReason, r.Reason) + assert.Equal(t, 120, r.Required) +} + +func TestRun_ExpiredCampaign(t *testing.T) { + c := camp("c1", "G", "d1") + c.Status = "expired" + f := &fakeBackend{camps: []platform.Campaign{c}} + prev := map[string]dropstate.Row{"d1": {DropID: "d1", Status: dropstate.Accruing, Minutes: 30, Required: 60}} + res, err := Run(context.Background(), cfg(f, &fakePersister{}), prev, now) + require.NoError(t, err) + assert.Equal(t, dropstate.Expired, byDrop(res.Rows)["d1"].Reason) + assert.Empty(t, res.Campaigns, "expired campaigns are not mining candidates") +} + +func TestRun_ScopeAndPersist(t *testing.T) { + nullGame := camp("c2", "", "d2") + nullGame.AllowedChannels = []string{"fav"} + reward := camp("c3", "G", "d3") + reward.Kind = "reward" + f := &fakeBackend{camps: []platform.Campaign{camp("c1", "Other", "d1"), nullGame, reward}, + progress: []platform.DropProgress{{DropID: "d2", CampaignID: "c2", Required: 60, Known: true}}} + p := &fakePersister{} + cf := cfg(f, p) + cf.AllowChannel = func(chs []string) bool { return len(chs) > 0 && chs[0] == "fav" } + res, err := Run(context.Background(), cf, nil, now) + require.NoError(t, err) + got := byDrop(res.Rows) + assert.NotContains(t, got, "d1", "non-whitelisted game") + assert.Contains(t, got, "d2", "null-game campaign allowed by channel") + assert.NotContains(t, got, "d3", "reward kind has no watch time") + require.Len(t, p.got, 1) + assert.Equal(t, "c2", p.got[0].ID) +} diff --git a/internal/pipeline/session/session.go b/internal/pipeline/session/session.go new file mode 100644 index 00000000..cf02de94 --- /dev/null +++ b/internal/pipeline/session/session.go @@ -0,0 +1,127 @@ +// Package session runs one accrual session on one channel and reports what +// it sees. It makes no decisions. +package session + +import ( + "context" + "log/slog" + "time" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type Kind int + +const ( + Progress Kind = iota + StreamDown + Stalled +) + +type Event struct { + Kind Kind + Channel string + Progress []platform.Progress + Err error + // Gen is the generation stamp of the session that produced this event + // (Config.Gen, echoed verbatim). The loop bumps its generation on every + // startSession and drops events whose Gen doesn't match the current one, + // so a stale event queued by a session that already got stopped (e.g. on + // a serves-set restart) can't be mistaken for one from the live session. + Gen int +} + +type Config struct { + Backend platform.Backend + Session platform.Session + Stream platform.Stream + Serves []string + // Ticks drives beats. Production passes a 60s ticker: Twitch credits one + // minute per beacon, so a slower cadence under-credits. + Ticks <-chan time.Time + StallPolls int + // Gen is stamped onto every Event this run produces. See Event.Gen. + Gen int + // AccountID tags the heartbeat log line shown in the dashboard events feed. + AccountID string +} + +// Run starts the watch, beats immediately and on every tick, and stops the +// watch when ctx ends or the stream goes down. +func Run(ctx context.Context, cfg Config, out chan<- Event) { + ch := cfg.Stream.Channel + send := func(e Event) { + e.Channel = ch + e.Gen = cfg.Gen + select { + case out <- e: + case <-ctx.Done(): + } + } + h, err := cfg.Backend.StartWatch(ctx, cfg.Session, cfg.Stream) + if err != nil { + send(Event{Kind: StreamDown, Err: err}) + return + } + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = cfg.Backend.StopWatch(stopCtx, h) + }() + + serves := make(map[string]bool, len(cfg.Serves)) + for _, id := range cfg.Serves { + serves[id] = true + } + best := make(map[string]int) + still, stalled := 0, false + beat := func() bool { + if err := cfg.Backend.Heartbeat(ctx, h); err != nil { + if ctx.Err() == nil { + send(Event{Kind: StreamDown, Err: err}) + } + return false + } + // Same line as v1's watcher, so v2 heartbeats appear in the + // dashboard events feed like v1's. + slog.Info("watcher heartbeat sent", "kind", "heartbeat", "account", cfg.AccountID, "channel", ch) + prog, err := cfg.Backend.InventoryProgress(ctx, cfg.Session) + if err != nil { + return true // transient; the next tick retries + } + send(Event{Kind: Progress, Progress: prog}) + gained := false + for _, p := range prog { + if serves[p.BenefitID] { + // First time seeing this id counts as gain (baseline), or if it improved + if _, seen := best[p.BenefitID]; !seen || p.MinutesWatched > best[p.BenefitID] { + best[p.BenefitID] = p.MinutesWatched + gained = true + } + } + } + if gained { + still = 0 + } else { + still++ + } + if !stalled && cfg.StallPolls > 0 && still >= cfg.StallPolls { + stalled = true + send(Event{Kind: Stalled}) + } + return true + } + if !beat() { + return + } + for { + select { + case <-ctx.Done(): + return + case <-cfg.Ticks: + if !beat() { + return + } + } + } +} diff --git a/internal/pipeline/session/session_test.go b/internal/pipeline/session/session_test.go new file mode 100644 index 00000000..38ca65ae --- /dev/null +++ b/internal/pipeline/session/session_test.go @@ -0,0 +1,171 @@ +package session + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +type fakeBackend struct { + platform.Backend + mu sync.Mutex + startErr error + beatErr error + minutes []int // minutes for d1 per poll; last value repeats + script [][]platform.Progress // scripted responses per poll; overrides minutes + polls int + beats int + stopped bool +} + +func (f *fakeBackend) StartWatch(_ context.Context, _ platform.Session, s platform.Stream) (platform.WatchHandle, error) { + return platform.WatchHandle{Channel: s.Channel}, f.startErr +} +func (f *fakeBackend) Heartbeat(context.Context, platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + f.beats++ + return f.beatErr +} +func (f *fakeBackend) StopWatch(context.Context, platform.WatchHandle) error { + f.mu.Lock() + defer f.mu.Unlock() + f.stopped = true + return nil +} +func (f *fakeBackend) InventoryProgress(context.Context, platform.Session) ([]platform.Progress, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.script != nil { + i := f.polls + if i >= len(f.script) { + i = len(f.script) - 1 + } + f.polls++ + return f.script[i], nil + } + i := f.polls + if i >= len(f.minutes) { + i = len(f.minutes) - 1 + } + f.polls++ + return []platform.Progress{{BenefitID: "d1", MinutesWatched: f.minutes[i]}}, nil +} + +func recv(t *testing.T, ch <-chan Event) Event { + t.Helper() + select { + case e := <-ch: + return e + case <-time.After(2 * time.Second): + t.Fatal("no event") + return Event{} + } +} + +func TestRun_ProgressEachTickAndStopOnCancel(t *testing.T) { + f := &fakeBackend{minutes: []int{1, 2}} + ticks := make(chan time.Time) + out := make(chan Event, 8) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + Run(ctx, Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Serves: []string{"d1"}, Ticks: ticks, StallPolls: 5}, out) + close(done) + }() + + e := recv(t, out) + assert.Equal(t, Progress, e.Kind, "first beat is immediate") + assert.Equal(t, 1, e.Progress[0].MinutesWatched) + ticks <- time.Now() + assert.Equal(t, 2, recv(t, out).Progress[0].MinutesWatched) + cancel() + <-done + assert.True(t, f.stopped) +} + +func TestRun_HeartbeatErrorIsStreamDown(t *testing.T) { + f := &fakeBackend{minutes: []int{1}, beatErr: errors.New("offline")} + out := make(chan Event, 8) + Run(context.Background(), Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Ticks: make(chan time.Time)}, out) + e := recv(t, out) + assert.Equal(t, StreamDown, e.Kind) + assert.Equal(t, "ch", e.Channel) + assert.True(t, f.stopped) +} + +func TestRun_StartErrorIsStreamDown(t *testing.T) { + f := &fakeBackend{startErr: errors.New("sidecar")} + out := make(chan Event, 8) + Run(context.Background(), Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Ticks: make(chan time.Time)}, out) + assert.Equal(t, StreamDown, recv(t, out).Kind) +} + +func TestRun_StalledOnceAfterNoGain(t *testing.T) { + f := &fakeBackend{minutes: []int{5}} + ticks := make(chan time.Time) + out := make(chan Event, 32) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Run(ctx, Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Serves: []string{"d1"}, Ticks: ticks, StallPolls: 2}, out) + require.Equal(t, Progress, recv(t, out).Kind) // poll 1 sets baseline + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) // no gain 1 + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) // no gain 2 + assert.Equal(t, Stalled, recv(t, out).Kind) + ticks <- time.Now() + assert.Equal(t, Progress, recv(t, out).Kind) + select { + case e := <-out: + t.Fatalf("unexpected second event %v", e.Kind) + case <-time.After(50 * time.Millisecond): + } +} + +func TestRun_ClaimedDropLeavingInventoryIsNotAStall(t *testing.T) { + // Simulate d1 being claimed and disappearing from inventory while d2 keeps accruing. + // With summed watermark (buggy), the claim would cause false stall. + // With per-drop watermark (fixed), d2's gain prevents stall. + f := &fakeBackend{script: [][]platform.Progress{ + {{BenefitID: "d1", MinutesWatched: 30}, {BenefitID: "d2", MinutesWatched: 1}}, + {{BenefitID: "d1", MinutesWatched: 30}, {BenefitID: "d2", MinutesWatched: 2}}, + {{BenefitID: "d2", MinutesWatched: 3}}, // d1 claimed, absent + {{BenefitID: "d2", MinutesWatched: 4}}, + {{BenefitID: "d2", MinutesWatched: 5}}, + }} + ticks := make(chan time.Time) + out := make(chan Event, 32) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Run(ctx, Config{Backend: f, Stream: platform.Stream{Channel: "ch"}, Serves: []string{"d1", "d2"}, Ticks: ticks, StallPolls: 2}, out) + + // Poll 1: d1=30, d2=1 (total=31), baseline set + require.Equal(t, Progress, recv(t, out).Kind) + // Poll 2: d1=30, d2=2 (total=32), gain + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) + // Poll 3: d1 absent, d2=3, gain on d2 despite d1 gone + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) + // Poll 4: d2=4, continued gain + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) + // Poll 5: d2=5, still gaining + ticks <- time.Now() + require.Equal(t, Progress, recv(t, out).Kind) + + // No Stalled event should arrive + select { + case e := <-out: + t.Fatalf("unexpected stalled event %v", e.Kind) + case <-time.After(50 * time.Millisecond): + } +} diff --git a/internal/platform/kick/api.go b/internal/platform/kick/api.go index 042fd17f..2eb3c813 100644 --- a/internal/platform/kick/api.go +++ b/internal/platform/kick/api.go @@ -374,6 +374,7 @@ type progressReward struct { Name string Fraction float64 // 0..1 watched toward this reward Claimed bool + Required int // required_units } // progressDetail returns every in-progress reward with its parent campaign id, @@ -401,6 +402,7 @@ func (a *api) progressDetail(ctx context.Context, sess platform.Session) ([]prog Name: mstr(rm, "name", "title"), Fraction: mfloat(rm, "progress", "progress_fraction"), Claimed: mbool(rm, "claimed", "is_claimed", "isClaimed"), + Required: mnum(rm, "required_units", "required_minutes", "requiredMinutes", "minutes"), }) } } diff --git a/internal/platform/kick/live_probe_test.go b/internal/platform/kick/live_probe_test.go new file mode 100644 index 00000000..4e6e3bc0 --- /dev/null +++ b/internal/platform/kick/live_probe_test.go @@ -0,0 +1,34 @@ +//go:build live + +package kick + +import ( + "context" + "encoding/json" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// TestLive_ProgressRaw dumps raw /drops/progress for a Kick account that has +// at least one CLAIMED reward, to learn whether claimed rewards stay listed. +// +// GRUB_LIVE_KICK_SESSION_JSON='{"cookies":{...}}' \ +// go test -tags live -run TestLive_ProgressRaw -v ./internal/platform/kick/ +func TestLive_ProgressRaw(t *testing.T) { + js := os.Getenv("GRUB_LIVE_KICK_SESSION_JSON") + if js == "" { + t.Skip("set GRUB_LIVE_KICK_SESSION_JSON") + } + var s platform.Session + require.NoError(t, json.Unmarshal([]byte(js), &s)) + b := New(nil, nil, "grubdrops-browser-{slug}", 9090, 10*time.Minute) + body, status, err := b.api.d.do(context.Background(), s, http.MethodGet, dropsBase+"/api/v1/drops/progress", nil) + require.NoError(t, err) + t.Logf("status=%d body=%s", status, body) +} diff --git a/internal/platform/kick/pipeline.go b/internal/platform/kick/pipeline.go new file mode 100644 index 00000000..a72b8336 --- /dev/null +++ b/internal/platform/kick/pipeline.go @@ -0,0 +1,84 @@ +package kick + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// DropProgress satisfies platform.DropProgressSource. Required minutes come +// from /drops/progress required_units whenever Kick lists the reward. Kick +// lists a reward only after it accrues, so an unlisted reward is reported +// with Known=false instead of guessing. +func (b *Backend) DropProgress(ctx context.Context, s platform.Session, camps []platform.Campaign) ([]platform.DropProgress, error) { + rewards, err := b.api.progressDetail(ctx, s) + if err != nil { + return nil, fmt.Errorf("kick drop progress: %w", err) + } + byID := make(map[string]progressReward, len(rewards)) + for _, r := range rewards { + byID[r.RewardID] = r + } + var out []platform.DropProgress + for _, c := range camps { + for _, bf := range c.Benefits { + r, ok := byID[bf.ID] + if !ok { + out = append(out, platform.DropProgress{DropID: bf.ID, CampaignID: c.ID, Required: bf.RequiredMinutes}) + continue + } + req := r.Required + // Kick's live payload always carries required_units; this fallback + // only covers a malformed row. Reporting 0 would wrongly block the + // drop as sub_only, so we use the campaign's RequiredMinutes instead. + if req <= 0 { + req = bf.RequiredMinutes + } + minutes := 0 + if req > 0 && r.Fraction > 0 { + minutes = int(math.Round(r.Fraction * float64(req))) + } + out = append(out, platform.DropProgress{ + DropID: bf.ID, CampaignID: c.ID, Minutes: minutes, Required: req, + Claimed: r.Claimed, Known: true, + }) + } + } + return out, nil +} + +// ClaimDrop satisfies platform.DropClaimer. A claim error on an already +// auto-granted reward shows up as ClaimFailed; the post-claim reconcile +// then sees claimed=true and corrects the state. +func (b *Backend) ClaimDrop(ctx context.Context, s platform.Session, d platform.DropProgress) platform.ClaimResult { + err := b.api.Claim(ctx, s, d.DropID, d.CampaignID) + if err == nil { + return platform.ClaimResult{Outcome: platform.ClaimOK} + } + var linkErr *ClaimNeedsLinkError + if errors.As(err, &linkErr) { + return platform.ClaimResult{Outcome: platform.ClaimNeedsLink, LinkURL: linkErr.ConnectURL, Detail: err.Error()} + } + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: err.Error()} +} + +// ProbeChannels satisfies platform.ChannelProber on top of probeLive. +func (b *Backend) ProbeChannels(ctx context.Context, s platform.Session, c platform.Campaign, logins []string) ([]platform.Stream, error) { + pool := make([]kickChannel, 0, len(logins)) + for _, l := range logins { + if l = strings.ToLower(strings.TrimSpace(l)); l != "" { + pool = append(pool, kickChannel{Slug: l}) + } + } + return b.probeLive(ctx, s, c, pool), nil +} + +var ( + _ platform.DropProgressSource = (*Backend)(nil) + _ platform.DropClaimer = (*Backend)(nil) + _ platform.ChannelProber = (*Backend)(nil) +) diff --git a/internal/platform/kick/pipeline_test.go b/internal/platform/kick/pipeline_test.go new file mode 100644 index 00000000..778388e6 --- /dev/null +++ b/internal/platform/kick/pipeline_test.go @@ -0,0 +1,90 @@ +package kick + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +const progressURL = "https://web.kick.com/api/v1/drops/progress" + +func TestKickDropProgress_RequiredFromPlatform(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {200, `{"data":[ + {"id":"c1","rewards":[{"id":"r1","claimed":false,"progress":0.5,"required_units":30}]} + ]}`}}} + b := withFake(f) + got, err := b.DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ + ID: "c1", Benefits: []platform.DropBenefit{{ID: "r1", CampaignID: "c1", RequiredMinutes: 120}}, + }}) + require.NoError(t, err) + require.Len(t, got, 1) + // Platform says 30, adapter's invented 120 must not win (P6). + assert.Equal(t, platform.DropProgress{DropID: "r1", CampaignID: "c1", Minutes: 15, Required: 30, Known: true}, got[0]) +} + +func TestKickDropProgress_AbsentRewardIsUnknown(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {200, `{"data":[]}`}}} + b := withFake(f) + got, err := b.DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ + ID: "c1", Benefits: []platform.DropBenefit{{ID: "r1", CampaignID: "c1", RequiredMinutes: 120}}, + }}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.False(t, got[0].Known, "absence proves nothing") + assert.Equal(t, 120, got[0].Required) +} + +func TestKickDropProgress_ListedWithoutRequiredUsesCampaign(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {200, `{"data":[ + {"id":"c1","rewards":[{"id":"r1","claimed":false,"progress":0.5}]} + ]}`}}} + b := withFake(f) + got, err := b.DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ + ID: "c1", Benefits: []platform.DropBenefit{{ID: "r1", CampaignID: "c1", RequiredMinutes: 60}}, + }}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, platform.DropProgress{DropID: "r1", CampaignID: "c1", Minutes: 30, Required: 60, Known: true}, got[0]) +} + +func TestKickDropProgress_ErrorFailsWhole(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{progressURL: {403, `{}`}}} + _, err := withFake(f).DropProgress(context.Background(), sess("acc1"), []platform.Campaign{{ID: "c1"}}) + require.Error(t, err) +} + +func TestKickClaimDrop_Outcomes(t *testing.T) { + claimURL := "https://web.kick.com/api/v1/drops/claim" + ok := withFake(&fakeDoer{resp: map[string]fakeResp{claimURL: {200, `{}`}}}) + assert.Equal(t, platform.ClaimOK, ok.ClaimDrop(context.Background(), sess("a"), platform.DropProgress{DropID: "r1", CampaignID: "c1"}).Outcome) + + link := withFake(&fakeDoer{resp: map[string]fakeResp{claimURL: {400, `{"connect_url":"https://kick.com/connect/riot"}`}}}) + r := link.ClaimDrop(context.Background(), sess("a"), platform.DropProgress{DropID: "r1", CampaignID: "c1"}) + assert.Equal(t, platform.ClaimNeedsLink, r.Outcome) + assert.Equal(t, "https://kick.com/connect/riot", r.LinkURL) + + fail := withFake(&fakeDoer{resp: map[string]fakeResp{claimURL: {500, `{}`}}}) + assert.Equal(t, platform.ClaimFailed, fail.ClaimDrop(context.Background(), sess("a"), platform.DropProgress{DropID: "r1", CampaignID: "c1"}).Outcome) +} + +func TestKickProbeChannels(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + "https://kick.com/api/v2/channels/fav/livestream": {200, `{"data":{"id":7,"viewer_count":3,"categories":[{"name":"Rust","slug":"rust"}]}}`}, + "https://kick.com/api/v2/channels/off/livestream": {200, `{"data":null}`}, + "https://kick.com/api/v2/channels/other/livestream": {200, `{"data":{"id":8,"viewer_count":9,"categories":[{"name":"Slots","slug":"slots"}]}}`}, + }} + got, err := withFake(f).ProbeChannels(context.Background(), sess("a"), platform.Campaign{Game: "Rust"}, []string{"Fav", "off", "other"}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "fav", got[0].Channel) +} + +func TestKickBackend_SatisfiesPipelineInterfaces(t *testing.T) { + var _ platform.DropProgressSource = (*Backend)(nil) + var _ platform.DropClaimer = (*Backend)(nil) + var _ platform.ChannelProber = (*Backend)(nil) +} diff --git a/internal/platform/pipeline.go b/internal/platform/pipeline.go new file mode 100644 index 00000000..6490fcc1 --- /dev/null +++ b/internal/platform/pipeline.go @@ -0,0 +1,69 @@ +package platform + +import "context" + +// DropProgress is one drop's per-account state as the platform reports it. +// Pipeline v2's reconciler folds these into drop_state. +type DropProgress struct { + DropID string + CampaignID string + Minutes int + Required int + Claimed bool + InstanceID string + // Known is true when the platform returned a definite record for this + // drop. False means the platform said nothing about it (Kick lists a + // reward only after it accrues), which is not evidence of anything. + Known bool + // Unmineable marks drops whose progress the platform cannot track, + // such as Twitch scrape placeholders. The reconciler blocks them. + Unmineable bool +} + +type ClaimOutcome int + +const ( + ClaimOK ClaimOutcome = iota + ClaimAlready + ClaimNeedsLink + ClaimFailed +) + +func (o ClaimOutcome) String() string { + switch o { + case ClaimOK: + return "ok" + case ClaimAlready: + return "already_claimed" + case ClaimNeedsLink: + return "needs_link" + default: + return "failed" + } +} + +// ClaimResult is a typed claim outcome, replacing string-matched errors. +type ClaimResult struct { + Outcome ClaimOutcome + LinkURL string + Detail string +} + +// DropProgressSource reports authoritative per-drop progress for the given +// campaigns. It returns an error rather than a partial result, so a failed +// sync never looks like "the platform forgot these drops". +type DropProgressSource interface { + DropProgress(ctx context.Context, s Session, camps []Campaign) ([]DropProgress, error) +} + +// DropClaimer claims one drop and classifies the outcome. +type DropClaimer interface { + ClaimDrop(ctx context.Context, s Session, d DropProgress) ClaimResult +} + +// ChannelProber checks specific channels for liveness and, when c.Game is +// set, that they stream the campaign's game. Used for priority streamers +// that platform directories may not list. +type ChannelProber interface { + ProbeChannels(ctx context.Context, s Session, c Campaign, logins []string) ([]Stream, error) +} diff --git a/internal/platform/twitch/backend.go b/internal/platform/twitch/backend.go index b73f3bd5..f953dbb1 100644 --- a/internal/platform/twitch/backend.go +++ b/internal/platform/twitch/backend.go @@ -119,6 +119,10 @@ type Backend struct { tvDetails map[string][]platform.DropBenefit tvDetailsAt time.Time + // catalog is the process-wide shared campaign list: non-TV sessions + // publish to it, TV sessions merge from it. Nil = no sharing. + catalog *Catalog + // PubSub WebSocket — one per backend (per platform-account). Lazy // init on first ListActiveCampaigns once we have the user_id + // auth token. Real-time progress / claim / stream-down events feed @@ -255,6 +259,10 @@ func (b *Backend) ListActiveCampaigns(ctx context.Context, s platform.Session) ( if err != nil { return nil, err } + b.mu.Lock() + cat := b.catalog + b.mu.Unlock() + camps = mergeCatalog(cat, s, camps, allowed) details := make(map[string][]platform.DropBenefit, len(camps)) for _, c := range camps { details[c.ID] = c.Benefits @@ -280,13 +288,37 @@ func (b *Backend) ListActiveCampaigns(ctx context.Context, s platform.Session) ( for cid, logins := range allowed { b.allowedLoginsByCampaign[cid] = logins } + cat := b.catalog b.mu.Unlock() + // Share the full list with TV-client backends (which can't see the + // dashboard). Publish copies account-neutral link state. + if cat != nil { + cat.Publish(b.catalogSource(s), camps, allowed) + } // Best-effort PubSub bootstrap. Once-only — subsequent calls noop. // Failures are non-fatal: the watcher falls back to polling. b.ensurePubSub(s) return camps, nil } +// SetCatalog attaches the process-wide shared campaign catalog. Non-TV +// sessions publish their discovered campaigns to it; TV sessions merge +// campaigns hidden from the TV client from it. Nil disables sharing. +func (b *Backend) SetCatalog(c *Catalog) { + b.mu.Lock() + b.catalog = c + b.mu.Unlock() +} + +// catalogSource keys this backend's catalog entry: the account when the +// scheduler set it, else the backend instance (one per account in prod). +func (b *Backend) catalogSource(s platform.Session) string { + if s.AccountID != "" { + return s.AccountID + } + return fmt.Sprintf("%p", b) +} + // SetPubSubHandlers wires real-time callbacks. Must be called before // the first ListActiveCampaigns (which lazily connects). Safe to leave // nil — PubSub still connects + logs events for diagnostic purposes. diff --git a/internal/platform/twitch/backend_test.go b/internal/platform/twitch/backend_test.go index e3a5c69a..41816a02 100644 --- a/internal/platform/twitch/backend_test.go +++ b/internal/platform/twitch/backend_test.go @@ -136,3 +136,33 @@ func TestBackend_FetchAvatar_NullUser(t *testing.T) { _, err := b.FetchAvatar(context.Background(), platform.Session{AccessToken: "tok"}) require.Error(t, err) } + +// TestBackend_ListEligibleChannels_SyntheticGameCampaign pins the path +// pipeline v2 enroll discovery relies on: a synthetic open campaign (no +// ID, only a game name) has no allow-list, so the backend answers from the +// DROPS_ENABLED game directory with streams flagged DropsEnabled. +func TestBackend_ListEligibleChannels_SyntheticGameCampaign(t *testing.T) { + var slug, filters any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req gqlRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Equal(t, OpGameDirectory.Name, req.OperationName) + slug = req.Variables["slug"] + if opts, ok := req.Variables["options"].(map[string]any); ok { + filters = opts["systemFilters"] + } + _, _ = w.Write([]byte(dirRust)) + })) + defer srv.Close() + + b := newForTest(srv.URL) + out, err := b.ListEligibleChannels(context.Background(), platform.Session{AccessToken: "tok", ClientID: ClientTV}, + platform.Campaign{Platform: "twitch", Game: "Rust"}) + require.NoError(t, err) + assert.Equal(t, "rust", slug) + assert.Equal(t, []any{"DROPS_ENABLED"}, filters) + require.Len(t, out, 2) + assert.Equal(t, "alpha", out[0].Channel) + assert.True(t, out[0].DropsEnabled) + assert.Equal(t, "c1", out[0].ChannelID) +} diff --git a/internal/platform/twitch/campaigns.go b/internal/platform/twitch/campaigns.go index fbf3b786..c6b719f9 100644 --- a/internal/platform/twitch/campaigns.go +++ b/internal/platform/twitch/campaigns.go @@ -136,6 +136,13 @@ type campaignDetailsData struct { PreconditionDrops []struct { ID string `json:"id"` } `json:"preconditionDrops"` + // self is the viewer's own progress on this drop. Null when + // the account is not enrolled in the campaign yet. + Self *struct { + CurrentMinutesWatched int `json:"currentMinutesWatched"` + IsClaimed bool `json:"isClaimed"` + DropInstanceID string `json:"dropInstanceID"` + } `json:"self"` } `json:"timeBasedDrops"` } `json:"dropCampaign"` } `json:"user"` @@ -467,6 +474,41 @@ func (d *discovery) drainAllowed() map[string][]string { return out } +// inventoryDropProgress reads the Inventory query as pipeline v2 per-drop +// observations keyed by campaign id. Used for TV-client sessions, whose +// DropCampaignDetails returns dropCampaign:null (#48): Inventory is the +// only per-viewer progress source. Unlike inventory() it keeps the +// campaign id and the drop's requirement (Required 0 for sub-gated drops, +// #47). Every entry is Known — Inventory always carries self for in-progress +// campaigns. Duplicate drop ids within a campaign are collapsed. +func (d *discovery) inventoryDropProgress(ctx context.Context, sess platform.Session) (map[string][]platform.DropProgress, error) { + var inv inventoryData + if err := d.c.gql(ctx, sess.AccessToken, OpInventory, nil, &inv); err != nil { + return nil, fmt.Errorf("inventory: %w", err) + } + out := map[string][]platform.DropProgress{} + seen := map[string]bool{} + for _, camp := range inv.CurrentUser.Inventory.DropCampaignsInProgress { + for _, td := range camp.TimeBasedDrops { + key := camp.ID + "\x00" + td.ID + if td.ID == "" || seen[key] { + continue + } + seen[key] = true + out[camp.ID] = append(out[camp.ID], platform.DropProgress{ + DropID: td.ID, + CampaignID: camp.ID, + Minutes: td.Self.CurrentMinutesWatched, + Required: requiredMinutes(td.RequiredMinutesWatched, td.RequiredSubs), + Claimed: td.Self.IsClaimed, + InstanceID: td.Self.DropInstanceID, + Known: true, + }) + } + } + return out, nil +} + // inventory returns the current watch progress for all in-progress drop campaigns. func (d *discovery) inventory(ctx context.Context, sess platform.Session) ([]platform.Progress, error) { var inv inventoryData diff --git a/internal/platform/twitch/catalog.go b/internal/platform/twitch/catalog.go new file mode 100644 index 00000000..ba3d0e6f --- /dev/null +++ b/internal/platform/twitch/catalog.go @@ -0,0 +1,209 @@ +package twitch + +import ( + "log/slog" + "sort" + "sync" + "time" + + "github.com/aalejandrofer/grubdrops/internal/gameslug" + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// catalogTTL bounds how long a published campaign list is trusted. The +// non-TV discovery pass republishes every tick, so an older entry means +// its publisher stopped (account removed, session dead). +const catalogTTL = 45 * time.Minute + +// Catalog is a process-wide, read-only store of the Twitch campaign list +// found by non-TV (Android/legacy) sessions through ViewerDropsDashboard + +// DropCampaignDetails. Twitch hides both from TV-client tokens, and the +// TV channel-first path (AvailableDrops) returns one campaign per channel, +// so a global badge campaign can mask every other campaign of a game. TV +// backends merge this catalog into their own discovery. The data is +// campaign-level only: watch/claim requests keep each session's own +// client profile. +type Catalog struct { + mu sync.Mutex + sources map[string]catalogEntry // publisher key -> its latest list +} + +type catalogEntry struct { + campaigns []platform.Campaign + allowed map[string][]string + at time.Time +} + +// NewCatalog returns an empty catalog. +func NewCatalog() *Catalog { + return &Catalog{sources: map[string]catalogEntry{}} +} + +// Publish replaces source's campaign list. The stored copy is made +// account-neutral: link flags become optimistic (AccountLinked=true, +// AccountLinkChecked=false) so one account's link state never leaks to +// another; the campaign-level AccountLinkURL is kept. +func (c *Catalog) Publish(source string, camps []platform.Campaign, allowed map[string][]string) { + if c == nil { + return + } + cp := make([]platform.Campaign, 0, len(camps)) + for _, camp := range camps { + camp.AccountLinked = true + camp.AccountLinkChecked = false + camp.Benefits = append([]platform.DropBenefit(nil), camp.Benefits...) + camp.AllowedChannels = append([]string(nil), camp.AllowedChannels...) + cp = append(cp, camp) + } + al := make(map[string][]string, len(allowed)) + for id, logins := range allowed { + al[id] = append([]string(nil), logins...) + } + c.mu.Lock() + c.sources[source] = catalogEntry{campaigns: cp, allowed: al, at: time.Now()} + c.evictStale() + c.mu.Unlock() +} + +// evictStale drops sources whose last Publish is older than catalogTTL. +// Called by Publish and snapshot, both already holding mu. Without this a +// source keyed by backend pointer (catalogSource's no-AccountID fallback) +// accumulates forever across scheduler Reloads: the old *Backend becomes +// garbage, but nothing ever deletes its map entry — snapshot merely +// skipped it, so c.sources grew without bound over the process lifetime. +func (c *Catalog) evictStale() { + now := time.Now() + for k, e := range c.sources { + if now.Sub(e.at) >= catalogTTL { + delete(c.sources, k) + } + } +} + +// snapshot unions every fresh source. For a campaign more than one source +// carries, the entry with the most benefits wins (a publisher whose +// whitelist lacks the game emits a benefit-less shell), newest breaking +// ties; its allow-list rides along. age is the freshest source's age; ok +// is false when no source is fresh. +func (c *Catalog) snapshot() (camps []platform.Campaign, allowed map[string][]string, age time.Duration, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.evictStale() + keys := make([]string, 0, len(c.sources)) + for k := range c.sources { + keys = append(keys, k) + } + sort.Strings(keys) // deterministic order across map iteration + type pick struct { + camp platform.Campaign + allowed []string + hasAl bool + at time.Time + } + best := map[string]pick{} + var order []string + var newest time.Time + for _, k := range keys { + e := c.sources[k] + if time.Since(e.at) >= catalogTTL { + continue + } + ok = true + if e.at.After(newest) { + newest = e.at + } + for _, camp := range e.campaigns { + al, hasAl := e.allowed[camp.ID] + cur, seen := best[camp.ID] + if !seen { + order = append(order, camp.ID) + } else if len(camp.Benefits) < len(cur.camp.Benefits) || + (len(camp.Benefits) == len(cur.camp.Benefits) && !e.at.After(cur.at)) { + continue + } + best[camp.ID] = pick{camp: camp, allowed: al, hasAl: hasAl, at: e.at} + } + } + if !ok { + return nil, nil, 0, false + } + allowed = map[string][]string{} + for _, id := range order { + p := best[id] + camp := p.camp + camp.Benefits = append([]platform.DropBenefit(nil), camp.Benefits...) + camp.AllowedChannels = append([]string(nil), camp.AllowedChannels...) + camps = append(camps, camp) + if p.hasAl { + allowed[id] = append([]string(nil), p.allowed...) + } + } + return camps, allowed, time.Since(newest), true +} + +// sessionWantsGame reports whether game is on the session's whitelist: +// GameFilter when set, else a gameslug match against Session.Games. +func sessionWantsGame(s platform.Session, game string) bool { + if game == "" { + return false + } + if s.GameFilter != nil { + return s.GameFilter(game) + } + slug := gameslug.Slug(game) + for _, g := range s.Games { + if gameslug.Slug(g) == slug { + return true + } + } + return false +} + +// mergeCatalog appends to camps the fresh catalog campaigns the TV pass +// didn't find: not already present by ID (so the TV account's own +// Inventory stays authoritative), active with a future end time, and on +// the session's whitelist. Their catalog allow-lists are added to allowed +// when the TV pass supplied none. +func mergeCatalog(cat *Catalog, s platform.Session, camps []platform.Campaign, allowed map[string][]string) []platform.Campaign { + if cat == nil { + return camps + } + cc, cal, age, ok := cat.snapshot() + if !ok { + return camps + } + have := make(map[string]struct{}, len(camps)) + for _, c := range camps { + have[c.ID] = struct{}{} + } + now := time.Now() + merged := 0 + for _, c := range cc { + if _, dup := have[c.ID]; dup { + continue + } + if c.Status != "active" || c.EndsAt.IsZero() || !c.EndsAt.After(now) { + continue + } + if !sessionWantsGame(s, c.Game) { + continue + } + camps = append(camps, c) + have[c.ID] = struct{}{} + if al, has := cal[c.ID]; has { + if _, tvHas := allowed[c.ID]; !tvHas { + allowed[c.ID] = al + } + } + merged++ + } + // Only interesting at INFO when it actually added a campaign; an + // every-tick no-op merge (the common case once the catalog has + // converged) would otherwise flood INFO logs for every account. + if merged > 0 { + slog.Info("tv discovery: merged shared catalog", "merged", merged, "catalog_age", age.Round(time.Second)) + } else { + slog.Debug("tv discovery: merged shared catalog", "merged", merged, "catalog_age", age.Round(time.Second)) + } + return camps +} diff --git a/internal/platform/twitch/catalog_test.go b/internal/platform/twitch/catalog_test.go new file mode 100644 index 00000000..18186221 --- /dev/null +++ b/internal/platform/twitch/catalog_test.go @@ -0,0 +1,364 @@ +package twitch + +import ( + "context" + "fmt" + "log/slog" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// dashboardAndroid is the ViewerDropsDashboard an Android-client session +// sees: the watch campaign campR6 (hidden from the TV client behind a +// badge campaign), a non-whitelisted game, an expired campaign, and an +// ACTIVE-labelled campaign whose end time already passed. The Android +// account is NOT linked for campR6 — that flag must never leak to other +// accounts through the catalog. +const dashboardAndroid = `{"data":{"currentUser":{"dropCampaigns":[ + {"id":"campR6","name":"Rust S2 Watch","status":"ACTIVE","startAt":"2020-01-01T00:00:00Z","endAt":"2030-01-01T00:00:00Z", + "accountLinkURL":"https://link/r6","self":{"isAccountConnected":false},"game":{"id":"263490","displayName":"Rust"}}, + {"id":"campApex","name":"Apex Watch","status":"ACTIVE","startAt":"2020-01-01T00:00:00Z","endAt":"2030-01-01T00:00:00Z", + "self":{"isAccountConnected":true},"game":{"id":"1","displayName":"Apex Legends"}}, + {"id":"campOld","name":"Rust S1","status":"EXPIRED","startAt":"2020-01-01T00:00:00Z","endAt":"2020-02-01T00:00:00Z", + "self":{"isAccountConnected":true},"game":{"id":"263490","displayName":"Rust"}}, + {"id":"campPast","name":"Rust Ended","status":"ACTIVE","startAt":"2020-01-01T00:00:00Z","endAt":"2020-02-01T00:00:00Z", + "self":{"isAccountConnected":true},"game":{"id":"263490","displayName":"Rust"}}]}}}` + +const detailsR6 = `{"data":{"user":{"dropCampaign":{"id":"campR6","name":"Rust S2 Watch", + "allow":{"isEnabled":true,"channels":[{"id":"c7","name":"r6chan"}]}, + "timeBasedDrops":[{"id":"dR1","name":"Charm","requiredMinutesWatched":120,"requiredSubs":0, + "benefitEdges":[{"benefit":{"id":"bR1","name":"Charm","imageAssetURL":""}}]}]}}}}` + +const detailsEmpty = `{"data":{"user":{"dropCampaign":{"id":"x","timeBasedDrops":[]}}}}` + +// availBadge mimics the live masking: AvailableDrops returns ONE campaign +// per channel, a global 0-minute chat badge, hiding campR6. +const availBadge = `{"data":{"channel":{"viewerDropCampaigns":[{"id":"campBadge","name":"Chat Badge","game":{"id":"263490","name":"Rust"},"endAt":"2030-01-01T00:00:00Z", + "timeBasedDrops":[{"id":"dB","name":"Badge","requiredMinutesWatched":0,"requiredSubs":0,"benefitEdges":[{"benefit":{"id":"bB","name":"Badge","imageAssetURL":""}}]}]}]}}}` + +const inventoryEmpty = `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[],"gameEventDrops":[]}}}}` + +func androidBackend(t *testing.T) *Backend { + t.Helper() + srv := fakeGQL(t, map[string]func(map[string]any) string{ + "ViewerDropsDashboard": func(map[string]any) string { return dashboardAndroid }, + "DropCampaignDetails": func(v map[string]any) string { + if v["dropID"] == "campR6" { + return detailsR6 + } + return detailsEmpty + }, + }) + t.Cleanup(srv.Close) + return newForTest(srv.URL) +} + +func tvBackend(t *testing.T, inventory string) *Backend { + t.Helper() + srv := fakeGQL(t, map[string]func(map[string]any) string{ + "DirectoryPage_Game": func(map[string]any) string { return dirRust }, + "DropsHighlightService_AvailableDrops": func(map[string]any) string { + return availBadge + }, + "Inventory": func(map[string]any) string { return inventory }, + }) + t.Cleanup(srv.Close) + return newForTest(srv.URL) +} + +func tvSess() platform.Session { + return platform.Session{AccessToken: "tv", ClientID: ClientTV, Games: []string{"Rust"}} +} + +func campsByID(camps []platform.Campaign) map[string]platform.Campaign { + out := map[string]platform.Campaign{} + for _, c := range camps { + out[c.ID] = c + } + return out +} + +// publishFromAndroid runs a non-TV ListActiveCampaigns against cat. +func publishFromAndroid(t *testing.T, cat *Catalog) { + t.Helper() + ab := androidBackend(t) + ab.SetCatalog(cat) + _, err := ab.ListActiveCampaigns(context.Background(), platform.Session{AccessToken: "android"}) + require.NoError(t, err) +} + +// (a) A campaign the TV client can't see (masked by a badge campaign) is +// found through the catalog an Android account published: optimistic, +// non-account-specific link state, its allow-list feeds the TV backend's +// eligible-channel cache, and its benefits serve TV CampaignDetails. +func TestCatalog_TVMergesAndroidCampaigns(t *testing.T) { + cat := NewCatalog() + publishFromAndroid(t, cat) + + tb := tvBackend(t, inventoryEmpty) + tb.SetCatalog(cat) + camps, err := tb.ListActiveCampaigns(context.Background(), tvSess()) + require.NoError(t, err) + byID := campsByID(camps) + require.Contains(t, byID, "campBadge", "the TV path's own campaign is kept") + require.Contains(t, byID, "campR6", "catalog campaign hidden from TV must be merged") + + r6 := byID["campR6"] + assert.True(t, r6.AccountLinked, "catalog copy must not carry the Android account's link flags") + assert.False(t, r6.AccountLinkChecked, "link state is unknown for another account: optimistic") + assert.Equal(t, "https://link/r6", r6.AccountLinkURL, "campaign-level link URL kept") + assert.Equal(t, "active", r6.Status) + require.Len(t, r6.Benefits, 1) + assert.Equal(t, "dR1", r6.Benefits[0].ID) + assert.Equal(t, 120, r6.Benefits[0].RequiredMinutes) + + assert.Equal(t, 1, tb.AllowedChannelCount("campR6"), "catalog allow-list feeds ListEligibleChannels") + tb.mu.Lock() + assert.Equal(t, []string{"r6chan"}, tb.allowedLoginsByCampaign["campR6"]) + tb.mu.Unlock() + + benefits, err := tb.CampaignDetails(context.Background(), tvSess(), "campR6") + require.NoError(t, err) + require.Len(t, benefits, 1, "merged campaigns serve /drops CampaignDetails") +} + +// (b) The TV account's own Inventory is authoritative for link state and +// status of a campaign the catalog also carries. +func TestCatalog_InventoryWinsOverCatalog(t *testing.T) { + const invR6 = `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[ + {"id":"campR6","name":"Rust S2 Watch","game":{"id":"263490","name":"Rust"},"endAt":"2030-01-01T00:00:00Z", + "status":"ACTIVE","self":{"isAccountConnected":false},"accountLinkURL":"https://link/r6", + "timeBasedDrops":[{"id":"dR1","name":"Charm","requiredMinutesWatched":120,"requiredSubs":0, + "benefitEdges":[{"benefit":{"id":"bR1","name":"Charm","imageAssetURL":""}}], + "self":{"currentMinutesWatched":5,"isClaimed":false,"dropInstanceID":""}}]}], + "gameEventDrops":[]}}}}` + cat := NewCatalog() + publishFromAndroid(t, cat) + + tb := tvBackend(t, invR6) + tb.SetCatalog(cat) + camps, err := tb.ListActiveCampaigns(context.Background(), tvSess()) + require.NoError(t, err) + n := 0 + for _, c := range camps { + if c.ID == "campR6" { + n++ + } + } + assert.Equal(t, 1, n, "catalog must not duplicate an Inventory campaign") + r6 := campsByID(camps)["campR6"] + assert.False(t, r6.AccountLinked, "Inventory's link state wins over the catalog's optimistic one") + assert.True(t, r6.AccountLinkChecked) + require.Len(t, r6.Benefits, 1) +} + +// (c) Non-whitelisted, expired, and past-end catalog campaigns are not +// merged; a session GameFilter takes precedence over Session.Games. +func TestCatalog_FiltersGameAndWindow(t *testing.T) { + cat := NewCatalog() + publishFromAndroid(t, cat) + + tb := tvBackend(t, inventoryEmpty) + tb.SetCatalog(cat) + camps, err := tb.ListActiveCampaigns(context.Background(), tvSess()) + require.NoError(t, err) + byID := campsByID(camps) + assert.Contains(t, byID, "campR6") + assert.NotContains(t, byID, "campApex", "non-whitelisted game not merged") + assert.NotContains(t, byID, "campOld", "expired campaign not merged") + assert.NotContains(t, byID, "campPast", "campaign past its end time not merged") + + sess := tvSess() + sess.GameFilter = func(string) bool { return false } + tb2 := tvBackend(t, inventoryEmpty) + tb2.SetCatalog(cat) + camps, err = tb2.ListActiveCampaigns(context.Background(), sess) + require.NoError(t, err) + assert.NotContains(t, campsByID(camps), "campR6", "GameFilter, when set, decides membership") +} + +// (d) A catalog older than catalogTTL is ignored. +func TestCatalog_StaleIgnored(t *testing.T) { + cat := NewCatalog() + publishFromAndroid(t, cat) + cat.mu.Lock() + for k, e := range cat.sources { + e.at = e.at.Add(-catalogTTL - time.Minute) + cat.sources[k] = e + } + cat.mu.Unlock() + + tb := tvBackend(t, inventoryEmpty) + tb.SetCatalog(cat) + camps, err := tb.ListActiveCampaigns(context.Background(), tvSess()) + require.NoError(t, err) + assert.NotContains(t, campsByID(camps), "campR6", "stale catalog must be ignored") + assert.Equal(t, 0, tb.AllowedChannelCount("campR6")) +} + +// (e) No catalog set: TV discovery is unchanged, and a non-TV backend +// without a catalog still lists normally. +func TestCatalog_NoneSetUnchanged(t *testing.T) { + ab := androidBackend(t) + _, err := ab.ListActiveCampaigns(context.Background(), platform.Session{AccessToken: "android"}) + require.NoError(t, err) + + tb := tvBackend(t, inventoryEmpty) + camps, err := tb.ListActiveCampaigns(context.Background(), tvSess()) + require.NoError(t, err) + require.Len(t, camps, 1) + assert.Equal(t, "campBadge", camps[0].ID) +} + +// A publisher whose whitelist lacks a game emits that campaign as a +// benefit-less shell; it must not wipe another publisher's full entry. +func TestCatalog_PrefersEntryWithBenefits(t *testing.T) { + cat := NewCatalog() + full := platform.Campaign{ID: "c1", Game: "Rust", Status: "active", EndsAt: time.Now().Add(time.Hour), + Benefits: []platform.DropBenefit{{ID: "d1", CampaignID: "c1", RequiredMinutes: 60}}} + shell := full + shell.Benefits = nil + cat.Publish("acc-a", []platform.Campaign{full}, map[string][]string{"c1": {"x"}}) + cat.Publish("acc-b", []platform.Campaign{shell}, nil) + + camps, allowed, _, ok := cat.snapshot() + require.True(t, ok) + require.Len(t, camps, 1) + assert.Len(t, camps[0].Benefits, 1) + assert.Equal(t, []string{"x"}, allowed["c1"]) +} + +// TestCatalog_PublishEvictsStaleSources: Publish must sweep any source +// entry older than catalogTTL out of the map, not just skip it in +// snapshot. Without this, a source keyed by backend pointer (the +// no-AccountID fallback in catalogSource) accumulates forever across +// Reloads, since the old *Backend is garbage but its map entry never is. +func TestCatalog_PublishEvictsStaleSources(t *testing.T) { + cat := NewCatalog() + cat.Publish("stale-src", []platform.Campaign{{ID: "c1", Game: "Rust"}}, nil) + cat.mu.Lock() + e := cat.sources["stale-src"] + e.at = e.at.Add(-catalogTTL - time.Minute) + cat.sources["stale-src"] = e + cat.mu.Unlock() + + cat.Publish("fresh-src", []platform.Campaign{{ID: "c2", Game: "Rust"}}, nil) + + cat.mu.Lock() + _, stillThere := cat.sources["stale-src"] + n := len(cat.sources) + cat.mu.Unlock() + assert.False(t, stillThere, "Publish must evict sources older than catalogTTL") + assert.Equal(t, 1, n, "only the fresh source should remain") +} + +// TestCatalog_SnapshotEvictsStaleSources: snapshot must also sweep stale +// entries out of the map (not merely skip them while computing the +// result), so a catalog that's never Published to again still shrinks. +func TestCatalog_SnapshotEvictsStaleSources(t *testing.T) { + cat := NewCatalog() + cat.Publish("stale-src", []platform.Campaign{{ID: "c1", Game: "Rust"}}, nil) + cat.mu.Lock() + e := cat.sources["stale-src"] + e.at = e.at.Add(-catalogTTL - time.Minute) + cat.sources["stale-src"] = e + cat.mu.Unlock() + + _, _, _, ok := cat.snapshot() + assert.False(t, ok, "no fresh source: snapshot reports not-ok") + + cat.mu.Lock() + n := len(cat.sources) + cat.mu.Unlock() + assert.Equal(t, 0, n, "snapshot must evict the stale source from the map") +} + +// levelCapture is a minimal slog.Handler that records the level each +// message was logged at, keyed by message text. +type levelCapture struct { + mu sync.Mutex + levels map[string]slog.Level +} + +func (h *levelCapture) Enabled(context.Context, slog.Level) bool { return true } +func (h *levelCapture) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.levels == nil { + h.levels = map[string]slog.Level{} + } + h.levels[r.Message] = r.Level + return nil +} +func (h *levelCapture) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *levelCapture) WithGroup(string) slog.Handler { return h } + +func (h *levelCapture) levelOf(t *testing.T, msg string) slog.Level { + t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + lvl, ok := h.levels[msg] + require.True(t, ok, "message %q was never logged", msg) + return lvl +} + +// TestMergeCatalog_LogsInfoOnlyWhenSomethingMerged: the "merged shared +// catalog" line is only interesting at INFO when it actually added a +// campaign (merged > 0); an every-tick no-op merge must log at DEBUG so it +// doesn't flood INFO logs for accounts whose catalog never contributes. +func TestMergeCatalog_LogsInfoOnlyWhenSomethingMerged(t *testing.T) { + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + + const msg = "tv discovery: merged shared catalog" + + t.Run("merged=0 logs at DEBUG", func(t *testing.T) { + cap := &levelCapture{} + slog.SetDefault(slog.New(cap)) + cat := NewCatalog() + cat.Publish("src", []platform.Campaign{{ID: "c1", Game: "Rust", Status: "active", EndsAt: time.Now().Add(time.Hour)}}, nil) + sess := platform.Session{Games: []string{"OtherGame"}} + mergeCatalog(cat, sess, nil, map[string][]string{}) + assert.Equal(t, slog.LevelDebug, cap.levelOf(t, msg)) + }) + + t.Run("merged>0 logs at INFO", func(t *testing.T) { + cap := &levelCapture{} + slog.SetDefault(slog.New(cap)) + cat := NewCatalog() + cat.Publish("src", []platform.Campaign{{ID: "c1", Game: "Rust", Status: "active", EndsAt: time.Now().Add(time.Hour)}}, nil) + sess := platform.Session{Games: []string{"Rust"}} + mergeCatalog(cat, sess, nil, map[string][]string{}) + assert.Equal(t, slog.LevelInfo, cap.levelOf(t, msg)) + }) +} + +// TestCatalog_ConcurrentPublishSnapshot exercises Publish and snapshot +// concurrently from many goroutines (run with -race) to guard the shared +// sources map + the new eviction sweep against data races. +func TestCatalog_ConcurrentPublishSnapshot(t *testing.T) { + cat := NewCatalog() + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + i := i + wg.Add(1) + go func() { + defer wg.Done() + src := fmt.Sprintf("src-%d", i) + for j := 0; j < 25; j++ { + cat.Publish(src, []platform.Campaign{{ + ID: fmt.Sprintf("c-%d-%d", i, j), Game: "Rust", + Status: "active", EndsAt: time.Now().Add(time.Hour), + }}, map[string][]string{fmt.Sprintf("c-%d-%d", i, j): {"chan"}}) + _, _, _, _ = cat.snapshot() + } + }() + } + wg.Wait() +} diff --git a/internal/platform/twitch/chandisc.go b/internal/platform/twitch/chandisc.go index efc505e0..3a45893f 100644 --- a/internal/platform/twitch/chandisc.go +++ b/internal/platform/twitch/chandisc.go @@ -37,6 +37,7 @@ type availableDropsFull struct { StartAt string `json:"startAt"` EndAt string `json:"endAt"` Game struct { + ID string `json:"id"` Name string `json:"name"` } `json:"game"` TimeBasedDrops []tvDrop `json:"timeBasedDrops"` @@ -159,6 +160,12 @@ func (d *discovery) listByChannels(ctx context.Context, sess platform.Session, c continue } for _, vc := range resp.Channel.ViewerDropCampaigns { + // AvailableDrops also returns the streamer's own channel + // campaigns (no game) and campaigns for other games. Keep + // only the game being walked; they flooded /drops otherwise. + if !vcMatchesGame(vc.Game.ID, s.GameID, vc.Game.Name, slug) { + continue + } start, end := parseISO(vc.StartAt), parseISO(vc.EndAt) add(platform.Campaign{ ID: vc.ID, Platform: "twitch", Game: vc.Game.Name, Name: vc.Name, @@ -240,6 +247,21 @@ func (d *discovery) listByChannels(ctx context.Context, sess platform.Session, c return out, allowed, nil } +// vcMatchesGame reports whether an AvailableDrops campaign belongs to the +// game being walked. The campaign's game ID and the directory stream's +// game ID -- both already read off Twitch's own payloads (the directory +// query resolves streamGameID; see listForGameDirectory) -- are the +// authoritative signal: a display name can vary between the two payloads +// (localization, punctuation) even for the identical game. The +// slug-vs-directory-slug compare is only a fallback for when either side's +// ID is empty (older/incomplete payload shapes). +func vcMatchesGame(vcGameID, streamGameID, vcGameName, slug string) bool { + if vcGameID != "" && streamGameID != "" { + return vcGameID == streamGameID + } + return vcGameName != "" && gameslug.Slug(vcGameName) == slug +} + func appendUnique(xs []string, x string) []string { for _, e := range xs { if e == x { diff --git a/internal/platform/twitch/chandisc_test.go b/internal/platform/twitch/chandisc_test.go index c7a70488..283ffc06 100644 --- a/internal/platform/twitch/chandisc_test.go +++ b/internal/platform/twitch/chandisc_test.go @@ -470,3 +470,71 @@ func TestListByChannels_InventoryFailureKeepsDirectoryResults(t *testing.T) { assert.Equal(t, "campA", camps[0].ID) assert.Equal(t, 1, b.AllowedChannelCount("campA")) } + +// TestListByChannels_SkipsAvailableDropsForOtherGames: AvailableDrops also +// returns streamers' own channel campaigns, which carry no game (or a +// different game than the directory being walked). Those flooded /drops +// with hundreds of "no game" rows. Only campaigns for the queried game are +// kept; Inventory campaigns are unaffected. +func TestListByChannels_SkipsAvailableDropsForOtherGames(t *testing.T) { + const availMixed = `{"data":{"channel":{"viewerDropCampaigns":[ + {"id":"campNoGame","name":"Streamer Channel Drop","game":null,"endAt":"2030-01-01T00:00:00Z", + "timeBasedDrops":[{"id":"dN","name":"N","requiredMinutesWatched":30,"requiredSubs":0,"benefitEdges":[{"benefit":{"id":"bN","name":"N","imageAssetURL":""}}]}]}, + {"id":"campOther","name":"Other Game Drop","game":{"id":"1","name":"Apex Legends"},"endAt":"2030-01-01T00:00:00Z", + "timeBasedDrops":[{"id":"dO","name":"O","requiredMinutesWatched":30,"requiredSubs":0,"benefitEdges":[{"benefit":{"id":"bO","name":"O","imageAssetURL":""}}]}]}, + {"id":"campA","name":"Rust Isles General","game":{"id":"263490","name":"Rust"},"endAt":"2030-01-01T00:00:00Z", + "timeBasedDrops":[{"id":"dWatch","name":"Box","requiredMinutesWatched":60,"requiredSubs":0,"benefitEdges":[{"benefit":{"id":"bW","name":"Box","imageAssetURL":""}}]}]}]}}}` + srv := fakeGQL(t, map[string]func(map[string]any) string{ + "DirectoryPage_Game": func(v map[string]any) string { return dirRust }, + "DropsHighlightService_AvailableDrops": func(v map[string]any) string { + return availMixed + }, + "Inventory": func(map[string]any) string { return inventoryTV }, + }) + defer srv.Close() + + b := newForTest(srv.URL) + sess := platform.Session{AccessToken: "tv", ClientID: ClientTV, Games: []string{"Rust"}} + camps, err := b.ListActiveCampaigns(context.Background(), sess) + require.NoError(t, err) + ids := map[string]bool{} + for _, c := range camps { + ids[c.ID] = true + } + assert.True(t, ids["campA"], "matching-game AvailableDrops campaign kept") + assert.True(t, ids["campB"], "Inventory campaign unaffected") + assert.False(t, ids["campNoGame"], "game-less channel campaign skipped") + assert.False(t, ids["campOther"], "campaign for a different game skipped") + assert.Len(t, camps, 2) +} + +// TestListByChannels_GameIDMatchOverridesNameSlugMismatch: Twitch's +// AvailableDrops game display name can diverge from the directory's own +// display name for the same game (e.g. a differently punctuated/localized +// variant) even though both payloads carry the same game ID. The ID -- not +// the display-name slug -- is the authoritative match signal; a campaign +// whose name wouldn't slug-match the directory's game must still be kept +// when its game ID matches the directory stream's game ID. +func TestListByChannels_GameIDMatchOverridesNameSlugMismatch(t *testing.T) { + const availIDMatchNameMismatch = `{"data":{"channel":{"viewerDropCampaigns":[ + {"id":"campIDMatch","name":"Renamed Drop","game":{"id":"263490","name":"RUST: Legacy Display"},"endAt":"2030-01-01T00:00:00Z", + "timeBasedDrops":[{"id":"dI","name":"I","requiredMinutesWatched":30,"requiredSubs":0,"benefitEdges":[{"benefit":{"id":"bI","name":"I","imageAssetURL":""}}]}]}]}}}` + srv := fakeGQL(t, map[string]func(map[string]any) string{ + "DirectoryPage_Game": func(v map[string]any) string { return dirRust }, + "DropsHighlightService_AvailableDrops": func(v map[string]any) string { + return availIDMatchNameMismatch + }, + "Inventory": func(map[string]any) string { return inventoryEmpty }, + }) + defer srv.Close() + + b := newForTest(srv.URL) + sess := platform.Session{AccessToken: "tv", ClientID: ClientTV, Games: []string{"Rust"}} + camps, err := b.ListActiveCampaigns(context.Background(), sess) + require.NoError(t, err) + ids := map[string]bool{} + for _, c := range camps { + ids[c.ID] = true + } + assert.True(t, ids["campIDMatch"], "game ID match must keep the campaign despite a display-name/slug mismatch") +} diff --git a/internal/platform/twitch/claim.go b/internal/platform/twitch/claim.go index 8194379e..3a5112a6 100644 --- a/internal/platform/twitch/claim.go +++ b/internal/platform/twitch/claim.go @@ -18,6 +18,20 @@ type claimResult struct { } func (cl *claimer) claim(ctx context.Context, sess platform.Session, b platform.DropBenefit, userID int64) error { + status, err := cl.claimStatus(ctx, sess, b, userID) + if err != nil { + return err + } + switch status { + case "ELIGIBLE_FOR_ALL", "DROP_INSTANCE_ALREADY_CLAIMED", "": + return nil + default: + return fmt.Errorf("claim status: %s", status) + } +} + +// claimStatus sends the claim mutation and returns Twitch's raw status. +func (cl *claimer) claimStatus(ctx context.Context, sess platform.Session, b platform.DropBenefit, userID int64) (string, error) { // Prefer the per-account instance id captured at progress time. // When it's missing, construct DevilXD's synthetic instance id // `userID#campaignID#dropID` (inventory.py generate_claim) — Twitch @@ -32,18 +46,12 @@ func (cl *claimer) claim(ctx context.Context, sess platform.Session, b platform. id = b.ID } var out claimResult - err := cl.c.gql(ctx, sess.AccessToken, OpClaimDrop, - map[string]any{"input": map[string]any{"dropInstanceID": id}}, &out) - if err != nil { - return fmt.Errorf("claim %s: %w", id, err) + if err := cl.c.gql(ctx, sess.AccessToken, OpClaimDrop, + map[string]any{"input": map[string]any{"dropInstanceID": id}}, &out); err != nil { + return "", fmt.Errorf("claim %s: %w", id, err) } if out.ClaimDropRewards == nil { - return fmt.Errorf("claim %s: twitch returned no claim result (unknown or invalid drop instance)", id) - } - switch out.ClaimDropRewards.Status { - case "ELIGIBLE_FOR_ALL", "DROP_INSTANCE_ALREADY_CLAIMED", "": - return nil - default: - return fmt.Errorf("claim status: %s", out.ClaimDropRewards.Status) + return "", fmt.Errorf("claim %s: twitch returned no claim result (unknown or invalid drop instance)", id) } + return out.ClaimDropRewards.Status, nil } diff --git a/internal/platform/twitch/live_probe_test.go b/internal/platform/twitch/live_probe_test.go new file mode 100644 index 00000000..38f2d88e --- /dev/null +++ b/internal/platform/twitch/live_probe_test.go @@ -0,0 +1,35 @@ +//go:build live + +package twitch + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// TestLive_DetailsSelf dumps raw DropCampaignDetails for a campaign the +// account is enrolled in (ideally one fully claimed), to confirm +// timeBasedDrops[].self carries isClaimed + currentMinutesWatched. +// +// GRUB_LIVE_TWITCH_TOKEN=... GRUB_LIVE_CAMPAIGN_ID=... \ +// go test -tags live -run TestLive_DetailsSelf -v ./internal/platform/twitch/ +func TestLive_DetailsSelf(t *testing.T) { + tok, camp := os.Getenv("GRUB_LIVE_TWITCH_TOKEN"), os.Getenv("GRUB_LIVE_CAMPAIGN_ID") + if tok == "" || camp == "" { + t.Skip("set GRUB_LIVE_TWITCH_TOKEN, GRUB_LIVE_CAMPAIGN_ID") + } + ctx := context.Background() + b := New() + login, err := b.disc.resolveCurrentLogin(ctx, platform.Session{AccessToken: tok}) + require.NoError(t, err) + var raw json.RawMessage + require.NoError(t, b.c.gql(ctx, tok, OpDropCampaignDetails, + map[string]any{"dropID": camp, "channelLogin": login}, &raw)) + t.Logf("%s", raw) +} diff --git a/internal/platform/twitch/pipeline.go b/internal/platform/twitch/pipeline.go new file mode 100644 index 00000000..c79fe0a0 --- /dev/null +++ b/internal/platform/twitch/pipeline.go @@ -0,0 +1,171 @@ +package twitch + +import ( + "context" + "fmt" + "strings" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// isSyntheticID reports scrape-fallback placeholder ids ("Game|Name"). +// Twitch can't report progress for them. +func isSyntheticID(id string) bool { return strings.ContainsAny(id, "| ") } + +// requiredMinutes maps a drop's watch requirement to the app-wide marker: +// sub-gated drops (requiredSubs > 0) are not watch-earnable whatever minutes +// Twitch lists, so they report 0 (issue #47, same rule as fetchDetails). +func requiredMinutes(minutes, subs int) int { + if subs > 0 { + return 0 + } + return minutes +} + +// dropProgress reads DropCampaignDetails uncached, because self changes as +// the viewer watches and claims. One entry per drop id. +func (d *discovery) dropProgress(ctx context.Context, sess platform.Session, campaignID string) ([]platform.DropProgress, error) { + channelLogin := d.userLogin + if channelLogin == "" { + channelLogin = "twitch" + } + var det campaignDetailsData + if err := d.c.gql(ctx, sess.AccessToken, OpDropCampaignDetails, + map[string]any{"dropID": campaignID, "channelLogin": channelLogin}, &det); err != nil { + return nil, fmt.Errorf("drop progress %s: %w", campaignID, err) + } + seen := map[string]bool{} + var out []platform.DropProgress + for _, td := range det.User.DropCampaign.TimeBasedDrops { + if td.ID == "" || seen[td.ID] { + continue + } + seen[td.ID] = true + // self:null means Twitch said nothing about this viewer, which does + // not prove "not enrolled"; only a self object is a definite answer. + dp := platform.DropProgress{DropID: td.ID, CampaignID: campaignID, Required: requiredMinutes(td.RequiredMinutesWatched, td.RequiredSubs), Known: td.Self != nil} + if td.Self != nil { + dp.Minutes = td.Self.CurrentMinutesWatched + dp.Claimed = td.Self.IsClaimed + dp.InstanceID = td.Self.DropInstanceID + } + out = append(out, dp) + } + return out, nil +} + +// DropProgress satisfies platform.DropProgressSource. Per-campaign details +// are the truth (they survive the campaign leaving the in-progress +// inventory); the in-progress inventory fills fresher minutes and the +// instance id, and a claimed=true from either source wins — isClaimed is +// a positive statement from the same self object and never reverts on +// Twitch. TV-client sessions read Inventory only (see tvDropProgress). +func (b *Backend) DropProgress(ctx context.Context, s platform.Session, camps []platform.Campaign) ([]platform.DropProgress, error) { + b.c.bind(s) + if s.ClientID == ClientTV { + return b.tvDropProgress(ctx, s, camps) + } + inv, err := b.disc.inventory(ctx, s) + if err != nil { + return nil, err + } + byID := make(map[string]platform.Progress, len(inv)) + for _, p := range inv { + byID[p.BenefitID] = p + } + var out []platform.DropProgress + for _, c := range camps { + if isSyntheticID(c.ID) { + for _, bf := range c.Benefits { + out = append(out, platform.DropProgress{DropID: bf.ID, CampaignID: c.ID, Known: true, Unmineable: true}) + } + continue + } + dps, err := b.disc.dropProgress(ctx, s, c.ID) + if err != nil { + return nil, err + } + for i := range dps { + p, ok := byID[dps[i].DropID] + if !ok { + continue + } + dps[i].Known = true // present in the in-progress inventory + if p.MinutesWatched > dps[i].Minutes { + dps[i].Minutes = p.MinutesWatched + } + dps[i].Claimed = dps[i].Claimed || p.Claimed + if dps[i].InstanceID == "" { + dps[i].InstanceID = p.InstanceID + } + } + out = append(out, dps...) + } + return out, nil +} + +// tvDropProgress serves TV-client sessions from Inventory alone: Twitch +// returns dropCampaign:null for DropCampaignDetails on TV tokens (#48), so +// the details call is never made. Drops missing from Inventory get no entry, +// which the reconciler treats as unknown. +func (b *Backend) tvDropProgress(ctx context.Context, s platform.Session, camps []platform.Campaign) ([]platform.DropProgress, error) { + byCamp, err := b.disc.inventoryDropProgress(ctx, s) + if err != nil { + return nil, err + } + var out []platform.DropProgress + for _, c := range camps { + if isSyntheticID(c.ID) { + for _, bf := range c.Benefits { + out = append(out, platform.DropProgress{DropID: bf.ID, CampaignID: c.ID, Known: true, Unmineable: true}) + } + continue + } + out = append(out, byCamp[c.ID]...) + } + return out, nil +} + +// classifyClaimStatus maps Twitch's claimDropRewards.status to an outcome. +// An empty status is a failure: v2 sends blind stall claim-probes, and +// reading "" as OK would permanently mark an unenrolled drop claimed. A +// real claim that returned "" is corrected by the post-claim reconcile. +// (v1's claim() in claim.go keeps its own mapping.) +func classifyClaimStatus(status string) platform.ClaimResult { + switch status { + case "": + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: "empty claim status"} + case "ELIGIBLE_FOR_ALL": + return platform.ClaimResult{Outcome: platform.ClaimOK} + case "DROP_INSTANCE_ALREADY_CLAIMED": + return platform.ClaimResult{Outcome: platform.ClaimAlready} + default: + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: "claim status: " + status} + } +} + +// ClaimDrop satisfies platform.DropClaimer. Unlinked campaigns are blocked +// before claiming (reconciler reads self.isAccountConnected), so Twitch has +// no link outcome here. +func (b *Backend) ClaimDrop(ctx context.Context, s platform.Session, d platform.DropProgress) platform.ClaimResult { + b.c.bind(s) + userID, _ := b.watch.resolveUserID(ctx, s) + status, err := b.claim.claimStatus(ctx, s, platform.DropBenefit{ID: d.DropID, CampaignID: d.CampaignID, InstanceID: d.InstanceID}, userID) + if err != nil { + return platform.ClaimResult{Outcome: platform.ClaimFailed, Detail: err.Error()} + } + return classifyClaimStatus(status) +} + +// ProbeChannels satisfies platform.ChannelProber: parallel live check of the +// given logins, filtered to c.Game when set. +func (b *Backend) ProbeChannels(ctx context.Context, s platform.Session, c platform.Campaign, logins []string) ([]platform.Stream, error) { + b.c.bind(s) + return b.chans.listEligible(ctx, s, c, logins) +} + +var ( + _ platform.DropProgressSource = (*Backend)(nil) + _ platform.DropClaimer = (*Backend)(nil) + _ platform.ChannelProber = (*Backend)(nil) +) diff --git a/internal/platform/twitch/pipeline_test.go b/internal/platform/twitch/pipeline_test.go new file mode 100644 index 00000000..be9edda3 --- /dev/null +++ b/internal/platform/twitch/pipeline_test.go @@ -0,0 +1,294 @@ +package twitch + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// pipelineTestBackend serves DropCampaignDetails + Inventory from fixtures. +func pipelineTestBackend(t *testing.T, details map[string]string, inventory string, detailsStatus int) *Backend { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req gqlRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + switch req.OperationName { + case OpDropCampaignDetails.Name: + if detailsStatus != 0 { + w.WriteHeader(detailsStatus) + return + } + id, _ := req.Variables["dropID"].(string) + _, _ = w.Write([]byte(details[id])) + case OpInventory.Name: + _, _ = w.Write([]byte(inventory)) + default: + t.Fatalf("unexpected op %q", req.OperationName) + } + })) + t.Cleanup(srv.Close) + c := newTestClient(srv.URL) + return &Backend{c: c, disc: &discovery{c: c, userLogin: "testuser"}} +} + +const emptyInventory = `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[]}}}}` + +func TestDropProgress_ReadsSelfForCompletedCampaign(t *testing.T) { + // Campaign left dropCampaignsInProgress (fully claimed), but details self + // still reports isClaimed. This is the P1 fix. + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":{"currentMinutesWatched":60,"isClaimed":true,"dropInstanceID":"i1"}} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, platform.DropProgress{DropID: "d1", CampaignID: "c1", Minutes: 60, Required: 60, Claimed: true, InstanceID: "i1", Known: true}, got[0]) +} + +func TestDropProgress_NullSelfIsUnclaimed(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":null} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.False(t, got[0].Claimed) + assert.Equal(t, 0, got[0].Minutes) + assert.False(t, got[0].Known, "self:null proves nothing about enrollment") +} + +// A null self in details but a hit in the in-progress inventory is a definite +// answer: the inventory entry proves enrollment. +func TestDropProgress_NullSelfKnownViaInventory(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":null} + ]}}}}`}, `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[ + {"id":"c1","timeBasedDrops":[{"id":"d1","self":{"currentMinutesWatched":7,"isClaimed":false,"dropInstanceID":""}}]} + ]}}}}`, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Known) + assert.Equal(t, 7, got[0].Minutes) +} + +func TestDropProgress_DedupesMultiItemDrop(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[{"benefit":{"id":"b1","name":"A"}},{"benefit":{"id":"b2","name":"B"}}],"self":null}, + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":null} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + assert.Len(t, got, 1) +} + +func TestDropProgress_InventoryFillsInstanceAndMinutes(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":{"currentMinutesWatched":10,"isClaimed":false,"dropInstanceID":""}} + ]}}}}`}, `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[ + {"id":"c1","timeBasedDrops":[{"id":"d1","self":{"currentMinutesWatched":25,"isClaimed":false,"dropInstanceID":"inst"}}]} + ]}}}}`, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, 25, got[0].Minutes) + assert.Equal(t, "inst", got[0].InstanceID) +} + +func TestDropProgress_InventoryClaimedWins(t *testing.T) { + // Details self says isClaimed:false but inventory reports isClaimed:true + // for the same drop — claimed=true from either source wins because + // isClaimed never reverts on Twitch. + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"benefitEdges":[],"self":{"currentMinutesWatched":60,"isClaimed":false,"dropInstanceID":"i1"}} + ]}}}}`}, `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[ + {"id":"c1","timeBasedDrops":[{"id":"d1","self":{"currentMinutesWatched":60,"isClaimed":true,"dropInstanceID":"i1"}}]} + ]}}}}`, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Claimed) +} + +func TestDropProgress_SyntheticCampaignIsUnmineable(t *testing.T) { + b := pipelineTestBackend(t, nil, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "Game|Name", Platform: "twitch", Benefits: []platform.DropBenefit{{ID: "Game|Name_default"}}}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Unmineable) + assert.True(t, got[0].Known) +} + +func TestDropProgress_DetailsErrorFailsWhole(t *testing.T) { + b := pipelineTestBackend(t, nil, emptyInventory, http.StatusInternalServerError) + _, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.Error(t, err) +} + +func TestClassifyClaimStatus(t *testing.T) { + assert.Equal(t, platform.ClaimOK, classifyClaimStatus("ELIGIBLE_FOR_ALL").Outcome) + // An empty status proves nothing; a blind stall claim-probe must not + // read it as a claim. + empty := classifyClaimStatus("") + assert.Equal(t, platform.ClaimFailed, empty.Outcome) + assert.Equal(t, "empty claim status", empty.Detail) + assert.Equal(t, platform.ClaimAlready, classifyClaimStatus("DROP_INSTANCE_ALREADY_CLAIMED").Outcome) + r := classifyClaimStatus("DROP_INSTANCE_ALREADY_EXPIRED") + assert.Equal(t, platform.ClaimFailed, r.Outcome) + assert.Contains(t, r.Detail, "DROP_INSTANCE_ALREADY_EXPIRED") +} + +func TestBackend_SatisfiesPipelineInterfaces(t *testing.T) { + var _ platform.DropProgressSource = (*Backend)(nil) + var _ platform.DropClaimer = (*Backend)(nil) + var _ platform.ChannelProber = (*Backend)(nil) +} + +// tvPipelineBackend serves only Inventory; any DropCampaignDetails request +// fails the test (Twitch returns dropCampaign:null for TV-client tokens). +func tvPipelineBackend(t *testing.T, inventory string) *Backend { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req gqlRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + switch req.OperationName { + case OpInventory.Name: + _, _ = w.Write([]byte(inventory)) + default: + t.Errorf("unexpected op %q for a TV session", req.OperationName) + w.WriteHeader(http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + c := newTestClient(srv.URL) + return &Backend{c: c, disc: &discovery{c: c, userLogin: "testuser"}} +} + +func TestDropProgress_TVSessionReadsInventoryNoDetails(t *testing.T) { + b := tvPipelineBackend(t, `{"data":{"currentUser":{"inventory":{"dropCampaignsInProgress":[ + {"id":"c1","timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"requiredSubs":0,"self":{"currentMinutesWatched":60,"isClaimed":true,"dropInstanceID":"i1"}}, + {"id":"d2","requiredMinutesWatched":120,"requiredSubs":0,"self":{"currentMinutesWatched":30,"isClaimed":false,"dropInstanceID":""}}, + {"id":"d3","requiredMinutesWatched":60,"requiredSubs":1,"self":{"currentMinutesWatched":0,"isClaimed":false,"dropInstanceID":""}} + ]}, + {"id":"cOther","timeBasedDrops":[ + {"id":"dx","requiredMinutesWatched":60,"self":{"currentMinutesWatched":5,"isClaimed":false,"dropInstanceID":""}} + ]} + ]}}}}`) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t", ClientID: ClientTV}, + []platform.Campaign{ + {ID: "c1", Platform: "twitch", Benefits: []platform.DropBenefit{{ID: "d1"}, {ID: "d2"}, {ID: "d3"}, {ID: "d4"}}}, + {ID: "c2", Platform: "twitch", Benefits: []platform.DropBenefit{{ID: "e1"}}}, + }) + require.NoError(t, err) + assert.ElementsMatch(t, []platform.DropProgress{ + {DropID: "d1", CampaignID: "c1", Minutes: 60, Required: 60, Claimed: true, InstanceID: "i1", Known: true}, + {DropID: "d2", CampaignID: "c1", Minutes: 30, Required: 120, Known: true}, + {DropID: "d3", CampaignID: "c1", Minutes: 0, Required: 0, Known: true}, // sub-gated + }, got, "only requested campaigns' drops present in Inventory; absent drops get no entry") +} + +func TestDropProgress_TVSessionSyntheticStillUnmineable(t *testing.T) { + b := tvPipelineBackend(t, emptyInventory) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t", ClientID: ClientTV}, + []platform.Campaign{{ID: "Game|Name", Platform: "twitch", Benefits: []platform.DropBenefit{{ID: "Game|Name_default"}}}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Unmineable) +} + +// clientIDRecorder is a gql server that records every request's Client-Id +// and answers each op with a minimal valid body. +func clientIDRecorder(t *testing.T) (*Backend, func() []string) { + t.Helper() + var mu sync.Mutex + var ids []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + ids = append(ids, r.Header.Get("Client-Id")) + mu.Unlock() + var req gqlRequest + _ = json.NewDecoder(r.Body).Decode(&req) + switch req.OperationName { + case "CurrentUser": + _, _ = w.Write([]byte(`{"data":{"currentUser":{"id":"123","login":"u"}}}`)) + case OpClaimDrop.Name: + _, _ = w.Write([]byte(`{"data":{"claimDropRewards":{"status":"ELIGIBLE_FOR_ALL"}}}`)) + case OpInventory.Name: + _, _ = w.Write([]byte(emptyInventory)) + default: + _, _ = w.Write([]byte(`{"data":{"user":{"stream":null}}}`)) + } + })) + t.Cleanup(srv.Close) + return newForTest(srv.URL), func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), ids...) + } +} + +func assertAllClientIDs(t *testing.T, ids []string, want string) { + t.Helper() + require.NotEmpty(t, ids, "no requests reached the server") + for _, id := range ids { + assert.Equal(t, want, id) + } +} + +// The v2 loop can call ClaimDrop before any v1 entry point bound the token, +// so ClaimDrop itself must bind the session's client profile. +func TestClaimDrop_TVSessionSendsTVClientID(t *testing.T) { + b, ids := clientIDRecorder(t) + res := b.ClaimDrop(context.Background(), platform.Session{AccessToken: "tv-tok", ClientID: ClientTV}, + platform.DropProgress{DropID: "d1", CampaignID: "c1", InstanceID: "i1"}) + assert.Equal(t, platform.ClaimOK, res.Outcome) + assertAllClientIDs(t, ids(), profileTV.ID) +} + +func TestDropProgress_TVSessionSendsTVClientID(t *testing.T) { + b, ids := clientIDRecorder(t) + _, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "tv-tok", ClientID: ClientTV}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + assertAllClientIDs(t, ids(), profileTV.ID) +} + +func TestProbeChannels_TVSessionSendsTVClientID(t *testing.T) { + b, ids := clientIDRecorder(t) + _, err := b.ProbeChannels(context.Background(), platform.Session{AccessToken: "tv-tok", ClientID: ClientTV}, + platform.Campaign{}, []string{"somechan"}) + require.NoError(t, err) + assertAllClientIDs(t, ids(), profileTV.ID) +} + +// #47: a sub-gated drop can't be earned by watching, whatever minutes Twitch +// lists. The details path reports Required 0 (the "not watch-earnable" marker). +func TestDropProgress_SubGatedDetailsRequiredZero(t *testing.T) { + b := pipelineTestBackend(t, map[string]string{"c1": `{"data":{"user":{"dropCampaign":{"timeBasedDrops":[ + {"id":"d1","requiredMinutesWatched":60,"requiredSubs":1,"benefitEdges":[],"self":{"currentMinutesWatched":0,"isClaimed":false,"dropInstanceID":""}}, + {"id":"d2","requiredMinutesWatched":60,"requiredSubs":0,"benefitEdges":[],"self":{"currentMinutesWatched":0,"isClaimed":false,"dropInstanceID":""}} + ]}}}}`}, emptyInventory, 0) + got, err := b.DropProgress(context.Background(), platform.Session{AccessToken: "t"}, + []platform.Campaign{{ID: "c1", Platform: "twitch"}}) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, 0, got[0].Required, "sub-gated drop is not watch-earnable") + assert.Equal(t, 60, got[1].Required) +} diff --git a/internal/scheduler/control.go b/internal/scheduler/control.go index 8fad37d9..6753eab7 100644 --- a/internal/scheduler/control.go +++ b/internal/scheduler/control.go @@ -107,12 +107,39 @@ func (s *Scheduler) Reload(parent context.Context, builders []EntryBuilder) erro s.mu.Lock() s.entries = nil s.mu.Unlock() - for _, b := range builders { - s.AddEntry(b()) + for _, e := range buildEntriesConcurrently(builders) { + s.AddEntry(e) } return s.Start(parent) } +// reloadBuildConcurrency caps how many EntryBuilders run at once. A single +// slow builder (e.g. a browser-sidecar cold start) must not serialize every +// other account's rebuild, but an unbounded fan-out on a large roster would +// hammer the backend/session-store all at once; 4 balances the two. +const reloadBuildConcurrency = 4 + +// buildEntriesConcurrently runs builders with a cap of reloadBuildConcurrency +// concurrent workers and returns their entries in the SAME order as +// builders, regardless of completion order. +func buildEntriesConcurrently(builders []EntryBuilder) []entry { + entries := make([]entry, len(builders)) + sem := make(chan struct{}, reloadBuildConcurrency) + var wg sync.WaitGroup + for i, b := range builders { + i, b := i, b + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + entries[i] = b() + }() + } + wg.Wait() + return entries +} + // ReloadAccount restarts a SINGLE account's entry (build a fresh one) while // every other account keeps running untouched. Used for targeted account // edits so we don't tear down the whole roster. If the scheduler isn't diff --git a/internal/scheduler/control_test.go b/internal/scheduler/control_test.go index 7de9c01a..539fa6e2 100644 --- a/internal/scheduler/control_test.go +++ b/internal/scheduler/control_test.go @@ -150,3 +150,39 @@ func TestScheduler_ReloadAccountSurvivesTriggerContextCancel(t *testing.T) { require.Eventually(t, func() bool { return !fresh.running(20 * time.Millisecond) }, time.Second, 2*time.Millisecond, "watcher should stop when the root context is cancelled") } + +// TestScheduler_ReloadBuildsBuildersConcurrently: Reload must build the +// EntryBuilders concurrently (capped) rather than one at a time — a single +// slow account (e.g. a browser-sidecar cold start) must not hold up every +// other account's rebuild. 4 builders that each block 100ms must complete +// well under their serial total (400ms); the resulting entry order must +// still match the builders slice order, not completion order. +func TestScheduler_ReloadBuildsBuildersConcurrently(t *testing.T) { + s := New(Options{Notifier: &counterNotifier{}}) + + mkBuilder := func(id string) EntryBuilder { + return func() Entry { + time.Sleep(100 * time.Millisecond) + return NewEntry(id, &liveRunner{}) + } + } + + start := time.Now() + require.NoError(t, s.Reload(context.Background(), []EntryBuilder{ + mkBuilder("a"), mkBuilder("b"), mkBuilder("c"), mkBuilder("d"), + })) + elapsed := time.Since(start) + require.Less(t, elapsed, 250*time.Millisecond, + "4 concurrent 100ms builders should overlap, not serialize to ~400ms+") + + s.mu.Lock() + ids := make([]string, len(s.entries)) + for i, e := range s.entries { + ids[i] = e.id + } + s.mu.Unlock() + require.Equal(t, []string{"a", "b", "c", "d"}, ids, + "entry order must match builder order regardless of completion timing") + + s.Stop(context.Background()) +} diff --git a/internal/scheduler/discovery.go b/internal/scheduler/discovery.go index 49e168da..610af4d6 100644 --- a/internal/scheduler/discovery.go +++ b/internal/scheduler/discovery.go @@ -6,9 +6,14 @@ import ( "time" "github.com/aalejandrofer/grubdrops/internal/platform" - "github.com/aalejandrofer/grubdrops/internal/watcher" ) +// discoveryReporter is any runner that caches its last campaign discovery. +type discoveryReporter interface { + LastDiscovery() ([]platform.Campaign, time.Time) + AllowGame() func(game string) bool +} + // AccountDiscovery is the per-account view of the most recent // ListActiveCampaigns result. CapturedAt is zero when the watcher has // never completed a successful discovery tick yet (e.g. nopRunner-backed @@ -30,7 +35,7 @@ func (s *Scheduler) WatcherDiscoveries() []AccountDiscovery { defer s.mu.Unlock() out := make([]AccountDiscovery, 0, len(s.entries)) for _, e := range s.entries { - w, ok := e.runner.(*watcher.Watcher) + w, ok := e.runner.(discoveryReporter) if !ok { out = append(out, AccountDiscovery{AccountID: e.id}) continue diff --git a/internal/scheduler/state.go b/internal/scheduler/state.go index 58e702b7..849c569e 100644 --- a/internal/scheduler/state.go +++ b/internal/scheduler/state.go @@ -27,17 +27,22 @@ func idleState(r runner) string { return "needs_auth" } +// snapshotter is any runner that can describe its current work. Both the v1 +// *watcher.Watcher and the pipeline v2 loop implement it. +type snapshotter interface { + Snapshot() watcher.Snapshot +} + func (s *Scheduler) Snapshot() []AccountState { s.mu.Lock() defer s.mu.Unlock() out := make([]AccountState, 0, len(s.entries)) for _, e := range s.entries { - w, ok := e.runner.(*watcher.Watcher) - if !ok { - out = append(out, AccountState{AccountID: e.id, State: idleState(e.runner)}) + if sn, ok := e.runner.(snapshotter); ok { + out = append(out, AccountState{AccountID: e.id, State: sn.Snapshot().State}) continue } - out = append(out, AccountState{AccountID: e.id, State: w.State().String()}) + out = append(out, AccountState{AccountID: e.id, State: idleState(e.runner)}) } return out } @@ -51,12 +56,11 @@ func (s *Scheduler) WatcherSnapshots() []watcher.Snapshot { defer s.mu.Unlock() out := make([]watcher.Snapshot, 0, len(s.entries)) for _, e := range s.entries { - w, ok := e.runner.(*watcher.Watcher) - if !ok { - out = append(out, watcher.Snapshot{AccountID: e.id, State: idleState(e.runner)}) + if sn, ok := e.runner.(snapshotter); ok { + out = append(out, sn.Snapshot()) continue } - out = append(out, w.Snapshot()) + out = append(out, watcher.Snapshot{AccountID: e.id, State: idleState(e.runner)}) } return out } diff --git a/internal/scheduler/v2_runner_test.go b/internal/scheduler/v2_runner_test.go new file mode 100644 index 00000000..cd9f2152 --- /dev/null +++ b/internal/scheduler/v2_runner_test.go @@ -0,0 +1,33 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/watcher" +) + +type v2Runner struct{} + +func (v2Runner) Run(ctx context.Context) error { <-ctx.Done(); return ctx.Err() } +func (v2Runner) Snapshot() watcher.Snapshot { + return watcher.Snapshot{AccountID: "acc", State: "watching", Channel: "ch1"} +} +func (v2Runner) LastDiscovery() ([]platform.Campaign, time.Time) { + return []platform.Campaign{{ID: "c1"}}, time.Unix(1, 0) +} +func (v2Runner) AllowGame() func(string) bool { return func(string) bool { return true } } + +func TestScheduler_NonWatcherRunnerWithSnapshot(t *testing.T) { + s := New(Options{}) + s.AddEntry(NewEntry("acc", v2Runner{})) + snaps := s.WatcherSnapshots() + assert.Equal(t, "ch1", snaps[0].Channel) + assert.Equal(t, "watching", s.Snapshot()[0].State, "must not fall back to needs_auth") + disc := s.WatcherDiscoveries() + assert.Len(t, disc[0].Campaigns, 1) +} diff --git a/internal/store/campaign_persister.go b/internal/store/campaign_persister.go index 8aa39dd5..b10f1817 100644 --- a/internal/store/campaign_persister.go +++ b/internal/store/campaign_persister.go @@ -32,6 +32,10 @@ const CollectOverridePrefix = "collect_override:" // watcher pre-loads these into its skippedBenefits set at New() time. const SkipOverridePrefix = "skip_override:" +// PipelineOverridePrefix keys a per-account pipeline choice in kv. Full key: +// PipelineOverridePrefix + accountID, value "v1" or "v2". Beats GRUB_PIPELINE. +const PipelineOverridePrefix = "pipeline_override:" + // CampaignPersister upserts every Campaign + Benefit the watcher discovers // into the local DB so the /drops page can render past + current + // upcoming tabs even before anything has been claimed. diff --git a/internal/store/dropstate_backfill.go b/internal/store/dropstate_backfill.go new file mode 100644 index 00000000..d9f4011a --- /dev/null +++ b/internal/store/dropstate_backfill.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// DropStateBackfilledKey marks the one-shot v1 to v2 state backfill as done. +const DropStateBackfilledKey = "drop_state_backfilled" + +// BackfillDropState seeds drop_state from v1 state: ghost-skips become +// not_enrolled with an immediate retry, claim history becomes platform +// claims, and manual marks become user claims. Later sources win: manual +// marks go last because the mark-collected handler also writes a claims row, +// and a user assertion must not become a final platform claim. Every row +// carries the benefit's required minutes. Runs once; the v1 kv keys are left +// in place so rolling back to v1 is safe. +func BackfillDropState(ctx context.Context, q *gen.Queries, now time.Time) (int, error) { + if v, err := q.GetSettingString(ctx, DropStateBackfilledKey); err == nil && string(v) == "1" { + return 0, nil + } + rows := map[string]dropstate.Row{} + put := func(r dropstate.Row) { rows[r.AccountID+"|"+r.DropID] = r } + + fromKV := func(prefix string, mk func(acct, drop, campaign, plat string, required int) dropstate.Row) error { + kvs, err := q.ListKVByPrefix(ctx, sql.NullString{String: prefix, Valid: true}) + if err != nil { + return fmt.Errorf("list %s: %w", prefix, err) + } + for _, kv := range kvs { + drop, acct, ok := splitOverrideKey(kv.Key, prefix) + if !ok { + continue + } + if _, err := q.GetAccount(ctx, acct); err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue // account deleted, kv leftover: nothing to seed + } + return fmt.Errorf("get account %s: %w", acct, err) + } + bc, err := q.GetBenefitCampaign(ctx, drop) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue // benefit row gone, nothing to seed + } + return fmt.Errorf("get benefit campaign %s: %w", drop, err) + } + put(mk(acct, drop, bc.CampaignID, bc.Platform, int(bc.RequiredMinutes))) + } + return nil + } + if err := fromKV(SkipOverridePrefix, func(acct, drop, campaign, plat string, required int) dropstate.Row { + return dropstate.Row{AccountID: acct, DropID: drop, CampaignID: campaign, Platform: plat, + Status: dropstate.Blocked, Reason: dropstate.NotEnrolled, Source: dropstate.FromPlatform, + Required: required, RetryAfter: now, UpdatedAt: now} + }); err != nil { + return 0, err + } + claims, err := q.ListClaimsForBackfill(ctx) + if err != nil { + return 0, fmt.Errorf("list claims: %w", err) + } + for _, c := range claims { + put(dropstate.Row{AccountID: c.AccountID, DropID: c.BenefitID, CampaignID: c.CampaignID, Platform: c.Platform, + Status: dropstate.Claimed, Source: dropstate.FromPlatform, Required: int(c.RequiredMinutes), UpdatedAt: now}) + } + if err := fromKV(CollectOverridePrefix, func(acct, drop, campaign, plat string, required int) dropstate.Row { + return dropstate.Row{AccountID: acct, DropID: drop, CampaignID: campaign, Platform: plat, + Status: dropstate.Claimed, Source: dropstate.FromUser, Required: required, UpdatedAt: now} + }); err != nil { + return 0, err + } + + st := NewDropStateStore(q) + for _, r := range rows { + if err := st.Upsert(ctx, r); err != nil { + return 0, fmt.Errorf("upsert %s/%s: %w", r.AccountID, r.DropID, err) + } + } + if err := q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: DropStateBackfilledKey, Value: []byte("1")}); err != nil { + return 0, fmt.Errorf("mark backfilled: %w", err) + } + return len(rows), nil +} + +// splitOverrideKey parses prefix + dropID + ":" + accountID. +func splitOverrideKey(key, prefix string) (drop, acct string, ok bool) { + rest := strings.TrimPrefix(key, prefix) + i := strings.LastIndex(rest, ":") + if i <= 0 || i == len(rest)-1 { + return "", "", false + } + return rest[:i], rest[i+1:], true +} diff --git a/internal/store/dropstate_backfill_test.go b/internal/store/dropstate_backfill_test.go new file mode 100644 index 00000000..2347a484 --- /dev/null +++ b/internal/store/dropstate_backfill_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/platform" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +func TestBackfillDropState(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "twitch") + require.NoError(t, NewCampaignPersister(q).PersistCampaigns(ctx, []platform.Campaign{{ + ID: "c1", Platform: "twitch", Game: "G", Name: "C", Status: "active", + Benefits: []platform.DropBenefit{ + {ID: "claimed1", CampaignID: "c1", Name: "A", RequiredMinutes: 60}, + {ID: "marked1", CampaignID: "c1", Name: "B", RequiredMinutes: 60}, + {ID: "skipped1", CampaignID: "c1", Name: "C", RequiredMinutes: 60}, + {ID: "both1", CampaignID: "c1", Name: "D", RequiredMinutes: 60}, + }, + }})) + rec := NewClaimRecorder(q) + require.NoError(t, rec.RecordClaim(ctx, "acc-1", platform.DropBenefit{ID: "claimed1"})) + require.NoError(t, rec.RecordClaim(ctx, "acc-1", platform.DropBenefit{ID: "both1"})) + // The mark-collected handler writes a claims row AND a collect_override. + require.NoError(t, rec.RecordClaim(ctx, "acc-1", platform.DropBenefit{ID: "marked1"})) + for _, k := range []string{ + CollectOverridePrefix + "marked1:acc-1", + SkipOverridePrefix + "skipped1:acc-1", + SkipOverridePrefix + "both1:acc-1", // claim history must win over a skip + SkipOverridePrefix + "gone:acc-1", // benefit row missing: ignored + } { + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: k, Value: []byte("1")})) + } + + now := time.Unix(1_700_000_000, 0) + n, err := BackfillDropState(ctx, q, now) + require.NoError(t, err) + assert.Equal(t, 4, n) + + rows, err := NewDropStateStore(q).List(ctx, "acc-1") + require.NoError(t, err) + by := map[string]dropstate.Row{} + for _, r := range rows { + by[r.DropID] = r + } + assert.Equal(t, dropstate.Claimed, by["claimed1"].Status) + assert.Equal(t, dropstate.FromPlatform, by["claimed1"].Source) + assert.Equal(t, dropstate.Claimed, by["marked1"].Status) + assert.Equal(t, dropstate.FromUser, by["marked1"].Source) + assert.Equal(t, dropstate.Blocked, by["skipped1"].Status) + assert.Equal(t, dropstate.NotEnrolled, by["skipped1"].Reason) + assert.Equal(t, now, by["skipped1"].RetryAfter, "skips get one fresh evaluation") + assert.Equal(t, dropstate.Claimed, by["both1"].Status) + assert.Equal(t, "c1", by["claimed1"].CampaignID) + // Seeded rows carry the benefit's requirement so a later re-derive does + // not read Required 0 as sub_only. + for _, id := range []string{"claimed1", "marked1", "skipped1", "both1"} { + assert.Equal(t, 60, by[id].Required, id) + } + + // Idempotent: second run is a no-op. + n, err = BackfillDropState(ctx, q, now) + require.NoError(t, err) + assert.Equal(t, 0, n) +} + +func TestBackfillDropState_SkipsDeletedAccount(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "twitch") + require.NoError(t, NewCampaignPersister(q).PersistCampaigns(ctx, []platform.Campaign{{ + ID: "c1", Platform: "twitch", Game: "G", Name: "C", Status: "active", + Benefits: []platform.DropBenefit{ + {ID: "valid1", CampaignID: "c1", Name: "A", RequiredMinutes: 60}, + }, + }})) + for _, k := range []string{ + SkipOverridePrefix + "valid1:acc-1", + SkipOverridePrefix + "valid1:acc-deleted", + CollectOverridePrefix + "valid1:acc-deleted", + } { + require.NoError(t, q.UpsertSettingString(ctx, gen.UpsertSettingStringParams{Key: k, Value: []byte("1")})) + } + + now := time.Unix(1_700_000_000, 0) + n, err := BackfillDropState(ctx, q, now) + require.NoError(t, err) + assert.Equal(t, 1, n) + + rows, err := NewDropStateStore(q).List(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "valid1", rows[0].DropID) + assert.Equal(t, dropstate.Blocked, rows[0].Status) + + // Idempotent: the flag was still set despite the orphaned overrides. + n, err = BackfillDropState(ctx, q, now) + require.NoError(t, err) + assert.Equal(t, 0, n) +} diff --git a/internal/store/dropstate_store.go b/internal/store/dropstate_store.go new file mode 100644 index 00000000..d09b83b8 --- /dev/null +++ b/internal/store/dropstate_store.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "time" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// DropStateStore persists pipeline v2 drop_state rows and reads the +// per-account streamer priority list. +type DropStateStore struct { + Q *gen.Queries +} + +func NewDropStateStore(q *gen.Queries) *DropStateStore { return &DropStateStore{Q: q} } + +func dsUnix(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +func dsTime(v int64) time.Time { + if v == 0 { + return time.Time{} + } + return time.Unix(v, 0) +} + +func (s *DropStateStore) List(ctx context.Context, accountID string) ([]dropstate.Row, error) { + rows, err := s.Q.ListDropStates(ctx, accountID) + if err != nil { + return nil, err + } + out := make([]dropstate.Row, 0, len(rows)) + for _, r := range rows { + out = append(out, dropstate.Row{ + AccountID: r.AccountID, + DropID: r.DropID, + CampaignID: r.CampaignID, + Platform: r.Platform, + Status: dropstate.Status(r.Status), + Reason: dropstate.Reason(r.BlockReason), + Minutes: int(r.Minutes), + Required: int(r.Required), + Source: dropstate.Source(r.Source), + FailCount: int(r.FailCount), + RetryAfter: dsTime(r.RetryAfter), + SyncedAt: dsTime(r.SyncedAt), + UpdatedAt: dsTime(r.UpdatedAt), + }) + } + return out, nil +} + +func (s *DropStateStore) Upsert(ctx context.Context, r dropstate.Row) error { + return s.Q.UpsertDropState(ctx, gen.UpsertDropStateParams{ + AccountID: r.AccountID, + DropID: r.DropID, + CampaignID: r.CampaignID, + Platform: r.Platform, + Status: string(r.Status), + BlockReason: string(r.Reason), + Minutes: int64(r.Minutes), + Required: int64(r.Required), + Source: string(r.Source), + FailCount: int64(r.FailCount), + RetryAfter: dsUnix(r.RetryAfter), + SyncedAt: dsUnix(r.SyncedAt), + UpdatedAt: dsUnix(r.UpdatedAt), + }) +} + +func (s *DropStateStore) StreamerPriority(ctx context.Context, accountID string) ([]string, error) { + rows, err := s.Q.ListStreamerPriority(ctx, accountID) + if err != nil { + return nil, err + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Login) + } + return out, nil +} diff --git a/internal/store/dropstate_store_test.go b/internal/store/dropstate_store_test.go new file mode 100644 index 00000000..6577308d --- /dev/null +++ b/internal/store/dropstate_store_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/pipeline/dropstate" + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +func seedAccount(t *testing.T, q *gen.Queries, id, platform string) { + t.Helper() + now := time.Now().Unix() + _, err := q.CreateAccount(context.Background(), gen.CreateAccountParams{ + ID: id, Platform: platform, DisplayName: id, + Status: "idle", FingerprintJson: "{}", Enabled: 1, + CreatedAt: now, UpdatedAt: now, + }) + require.NoError(t, err) +} + +func TestDropStateStore_RoundTrip(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "twitch") + s := NewDropStateStore(q) + + at := time.Unix(1_700_000_000, 0) + in := dropstate.Row{ + AccountID: "acc-1", DropID: "d1", CampaignID: "c1", Platform: "twitch", + Status: dropstate.Blocked, Reason: dropstate.ClaimFailed, + Minutes: 60, Required: 60, Source: dropstate.FromPlatform, FailCount: 5, + RetryAfter: at.Add(time.Hour), SyncedAt: at, UpdatedAt: at, + } + require.NoError(t, s.Upsert(ctx, in)) + got, err := s.List(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, in, got[0]) + + in.Status, in.Reason, in.RetryAfter = dropstate.Claimed, dropstate.NoReason, time.Time{} + require.NoError(t, s.Upsert(ctx, in)) + got, err = s.List(ctx, "acc-1") + require.NoError(t, err) + require.Len(t, got, 1, "upsert must not duplicate") + assert.Equal(t, dropstate.Claimed, got[0].Status) + assert.True(t, got[0].RetryAfter.IsZero(), "0 maps back to zero time") +} + +func TestDropStateStore_StreamerPrioritySeededFromAccountChannels(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "kick") + s := NewDropStateStore(q) + + // Migration already ran; insert straight into the new table like the + // seed does, then read ordered by rank. + _, err := db.ExecContext(ctx, `INSERT INTO account_streamer_priority (account_id, platform, login, rank) VALUES + ('acc-1','kick','zeta',1), ('acc-1','kick','alpha',0)`) + require.NoError(t, err) + got, err := s.StreamerPriority(ctx, "acc-1") + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "zeta"}, got) +} + +func TestMigration0016_SeedCopiesAccountChannels(t *testing.T) { + db := openTest(t) + q := gen.New(db) + ctx := context.Background() + seedAccount(t, q, "acc-1", "kick") + require.NoError(t, q.AddAccountChannel(ctx, gen.AddAccountChannelParams{AccountID: "acc-1", Channel: "oilrats", Rank: 0})) + // Re-run the seed statement from 0016 exactly. + _, err := db.ExecContext(ctx, `INSERT INTO account_streamer_priority (account_id, platform, login, rank) + SELECT ac.account_id, a.platform, ac.channel, ac.rank FROM account_channels ac JOIN accounts a ON a.id = ac.account_id`) + require.NoError(t, err) + got, err := NewDropStateStore(q).StreamerPriority(ctx, "acc-1") + require.NoError(t, err) + assert.Equal(t, []string{"oilrats"}, got) +} diff --git a/internal/store/gen/drop_state.sql.go b/internal/store/gen/drop_state.sql.go new file mode 100644 index 00000000..bfececaf --- /dev/null +++ b/internal/store/gen/drop_state.sql.go @@ -0,0 +1,169 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: drop_state.sql + +package gen + +import ( + "context" +) + +const getBenefitCampaign = `-- name: GetBenefitCampaign :one +SELECT b.campaign_id, cp.platform, b.required_minutes +FROM benefits b +JOIN campaigns cp ON cp.id = b.campaign_id +WHERE b.id = ? +` + +type GetBenefitCampaignRow struct { + CampaignID string `json:"campaign_id"` + Platform string `json:"platform"` + RequiredMinutes int64 `json:"required_minutes"` +} + +func (q *Queries) GetBenefitCampaign(ctx context.Context, id string) (GetBenefitCampaignRow, error) { + row := q.db.QueryRowContext(ctx, getBenefitCampaign, id) + var i GetBenefitCampaignRow + err := row.Scan(&i.CampaignID, &i.Platform, &i.RequiredMinutes) + return i, err +} + +const listClaimsForBackfill = `-- name: ListClaimsForBackfill :many +SELECT c.account_id, c.benefit_id, b.campaign_id, a.platform, b.required_minutes +FROM claims c +JOIN benefits b ON b.id = c.benefit_id +JOIN accounts a ON a.id = c.account_id +` + +type ListClaimsForBackfillRow struct { + AccountID string `json:"account_id"` + BenefitID string `json:"benefit_id"` + CampaignID string `json:"campaign_id"` + Platform string `json:"platform"` + RequiredMinutes int64 `json:"required_minutes"` +} + +func (q *Queries) ListClaimsForBackfill(ctx context.Context) ([]ListClaimsForBackfillRow, error) { + rows, err := q.db.QueryContext(ctx, listClaimsForBackfill) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListClaimsForBackfillRow + for rows.Next() { + var i ListClaimsForBackfillRow + if err := rows.Scan( + &i.AccountID, + &i.BenefitID, + &i.CampaignID, + &i.Platform, + &i.RequiredMinutes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDropStates = `-- name: ListDropStates :many +SELECT account_id, drop_id, campaign_id, platform, status, block_reason, minutes, required, source, fail_count, retry_after, synced_at, updated_at FROM drop_state WHERE account_id = ? ORDER BY campaign_id, drop_id +` + +func (q *Queries) ListDropStates(ctx context.Context, accountID string) ([]DropState, error) { + rows, err := q.db.QueryContext(ctx, listDropStates, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DropState + for rows.Next() { + var i DropState + if err := rows.Scan( + &i.AccountID, + &i.DropID, + &i.CampaignID, + &i.Platform, + &i.Status, + &i.BlockReason, + &i.Minutes, + &i.Required, + &i.Source, + &i.FailCount, + &i.RetryAfter, + &i.SyncedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertDropState = `-- name: UpsertDropState :exec +INSERT INTO drop_state ( + account_id, drop_id, campaign_id, platform, status, block_reason, + minutes, required, source, fail_count, retry_after, synced_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, drop_id) DO UPDATE SET + campaign_id = excluded.campaign_id, + platform = excluded.platform, + status = excluded.status, + block_reason = excluded.block_reason, + minutes = excluded.minutes, + required = excluded.required, + source = excluded.source, + fail_count = excluded.fail_count, + retry_after = excluded.retry_after, + synced_at = excluded.synced_at, + updated_at = excluded.updated_at +` + +type UpsertDropStateParams struct { + AccountID string `json:"account_id"` + DropID string `json:"drop_id"` + CampaignID string `json:"campaign_id"` + Platform string `json:"platform"` + Status string `json:"status"` + BlockReason string `json:"block_reason"` + Minutes int64 `json:"minutes"` + Required int64 `json:"required"` + Source string `json:"source"` + FailCount int64 `json:"fail_count"` + RetryAfter int64 `json:"retry_after"` + SyncedAt int64 `json:"synced_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) UpsertDropState(ctx context.Context, arg UpsertDropStateParams) error { + _, err := q.db.ExecContext(ctx, upsertDropState, + arg.AccountID, + arg.DropID, + arg.CampaignID, + arg.Platform, + arg.Status, + arg.BlockReason, + arg.Minutes, + arg.Required, + arg.Source, + arg.FailCount, + arg.RetryAfter, + arg.SyncedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/internal/store/gen/models.go b/internal/store/gen/models.go index a7493702..dfce45d2 100644 --- a/internal/store/gen/models.go +++ b/internal/store/gen/models.go @@ -49,6 +49,13 @@ type AccountGame struct { Rank int64 `json:"rank"` } +type AccountStreamerPriority struct { + AccountID string `json:"account_id"` + Platform string `json:"platform"` + Login string `json:"login"` + Rank int64 `json:"rank"` +} + type Admin struct { ID int64 `json:"id"` PasswordHash string `json:"password_hash"` @@ -92,6 +99,22 @@ type Claim struct { ValueMetaJson string `json:"value_meta_json"` } +type DropState struct { + AccountID string `json:"account_id"` + DropID string `json:"drop_id"` + CampaignID string `json:"campaign_id"` + Platform string `json:"platform"` + Status string `json:"status"` + BlockReason string `json:"block_reason"` + Minutes int64 `json:"minutes"` + Required int64 `json:"required"` + Source string `json:"source"` + FailCount int64 `json:"fail_count"` + RetryAfter int64 `json:"retry_after"` + SyncedAt int64 `json:"synced_at"` + UpdatedAt int64 `json:"updated_at"` +} + type Game struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/store/gen/querier.go b/internal/store/gen/querier.go index 4000085f..a2b9dfff 100644 --- a/internal/store/gen/querier.go +++ b/internal/store/gen/querier.go @@ -14,6 +14,7 @@ type Querier interface { AddAccountGame(ctx context.Context, arg AddAccountGameParams) error AddForceChannel(ctx context.Context, arg AddForceChannelParams) error AddGlobalGame(ctx context.Context, arg AddGlobalGameParams) error + AddStreamerPriority(ctx context.Context, arg AddStreamerPriorityParams) error AdminExists(ctx context.Context) (bool, error) ClearAccountChannels(ctx context.Context, accountID string) error ClearAccountGames(ctx context.Context, accountID string) error @@ -49,6 +50,7 @@ type Querier interface { DeleteKV(ctx context.Context, key string) error GetAccount(ctx context.Context, id string) (Account, error) GetAdmin(ctx context.Context) (Admin, error) + GetBenefitCampaign(ctx context.Context, id string) (GetBenefitCampaignRow, error) GetCampaign(ctx context.Context, id string) (Campaign, error) GetProgress(ctx context.Context, arg GetProgressParams) (Progress, error) GetSession(ctx context.Context, accountID string) (Session, error) @@ -68,12 +70,14 @@ type Querier interface { // skips re-mining these: a claim is keyed by benefit id which is unique per // drop instance, so owning a claim means THIS exact drop is done. ListClaimedBenefitIDsForAccount(ctx context.Context, accountID string) ([]string, error) + ListClaimsForBackfill(ctx context.Context) ([]ListClaimsForBackfillRow, error) // Which accounts have claimed each benefit in a campaign. Powers the // per-account COLLECTED marks on the /drops expanded item list. ListClaimsForCampaign(ctx context.Context, campaignID string) ([]ListClaimsForCampaignRow, error) // Campaigns currently in flight (starts_at <= now < ends_at). // Whitelist filtering is applied in Go. ListCurrentCampaigns(ctx context.Context, arg ListCurrentCampaignsParams) ([]Campaign, error) + ListDropStates(ctx context.Context, accountID string) ([]DropState, error) ListEnabledAccounts(ctx context.Context) ([]Account, error) ListForceChannels(ctx context.Context, accountID string) ([]ListForceChannelsRow, error) ListGlobalGames(ctx context.Context) ([]ListGlobalGamesRow, error) @@ -81,6 +85,7 @@ type Querier interface { // Campaigns that have ended. Whitelist filtering is applied in Go. ListPastCampaigns(ctx context.Context, arg ListPastCampaignsParams) ([]Campaign, error) ListRecentClaims(ctx context.Context, limit int64) ([]ListRecentClaimsRow, error) + ListStreamerPriority(ctx context.Context, accountID string) ([]ListStreamerPriorityRow, error) ListUnclaimedProgressForAccount(ctx context.Context, arg ListUnclaimedProgressForAccountParams) ([]Progress, error) // Campaigns announced but not yet started. Whitelist filtering is // applied in Go. @@ -88,6 +93,7 @@ type Querier interface { RemoveAccountChannel(ctx context.Context, arg RemoveAccountChannelParams) error RemoveAccountGame(ctx context.Context, arg RemoveAccountGameParams) error RemoveForceChannel(ctx context.Context, arg RemoveForceChannelParams) error + RemoveStreamerPriority(ctx context.Context, arg RemoveStreamerPriorityParams) error SetAccountEnabled(ctx context.Context, arg SetAccountEnabledParams) error // Lifetime watch minutes: sum of per-benefit progress. Persistent, so it // survives restarts (unlike the heartbeat log ring used for today's count). @@ -100,6 +106,7 @@ type Querier interface { UpsertAdmin(ctx context.Context, arg UpsertAdminParams) error UpsertBenefit(ctx context.Context, arg UpsertBenefitParams) error UpsertCampaign(ctx context.Context, arg UpsertCampaignParams) error + UpsertDropState(ctx context.Context, arg UpsertDropStateParams) error UpsertGame(ctx context.Context, arg UpsertGameParams) error UpsertProgress(ctx context.Context, arg UpsertProgressParams) error UpsertSession(ctx context.Context, arg UpsertSessionParams) error diff --git a/internal/store/gen/streamer_priority.sql.go b/internal/store/gen/streamer_priority.sql.go new file mode 100644 index 00000000..de64a43b --- /dev/null +++ b/internal/store/gen/streamer_priority.sql.go @@ -0,0 +1,81 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: streamer_priority.sql + +package gen + +import ( + "context" +) + +const addStreamerPriority = `-- name: AddStreamerPriority :exec +INSERT INTO account_streamer_priority (account_id, platform, login, rank) +VALUES (?, ?, ?, ?) +ON CONFLICT(account_id, login) DO UPDATE SET rank = excluded.rank, platform = excluded.platform +` + +type AddStreamerPriorityParams struct { + AccountID string `json:"account_id"` + Platform string `json:"platform"` + Login string `json:"login"` + Rank int64 `json:"rank"` +} + +func (q *Queries) AddStreamerPriority(ctx context.Context, arg AddStreamerPriorityParams) error { + _, err := q.db.ExecContext(ctx, addStreamerPriority, + arg.AccountID, + arg.Platform, + arg.Login, + arg.Rank, + ) + return err +} + +const listStreamerPriority = `-- name: ListStreamerPriority :many +SELECT login, rank FROM account_streamer_priority +WHERE account_id = ? +ORDER BY rank ASC, login ASC +` + +type ListStreamerPriorityRow struct { + Login string `json:"login"` + Rank int64 `json:"rank"` +} + +func (q *Queries) ListStreamerPriority(ctx context.Context, accountID string) ([]ListStreamerPriorityRow, error) { + rows, err := q.db.QueryContext(ctx, listStreamerPriority, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListStreamerPriorityRow + for rows.Next() { + var i ListStreamerPriorityRow + if err := rows.Scan(&i.Login, &i.Rank); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeStreamerPriority = `-- name: RemoveStreamerPriority :exec +DELETE FROM account_streamer_priority WHERE account_id = ? AND login = ? +` + +type RemoveStreamerPriorityParams struct { + AccountID string `json:"account_id"` + Login string `json:"login"` +} + +func (q *Queries) RemoveStreamerPriority(ctx context.Context, arg RemoveStreamerPriorityParams) error { + _, err := q.db.ExecContext(ctx, removeStreamerPriority, arg.AccountID, arg.Login) + return err +} diff --git a/internal/store/live_session_test.go b/internal/store/live_session_test.go new file mode 100644 index 00000000..eda84b96 --- /dev/null +++ b/internal/store/live_session_test.go @@ -0,0 +1,39 @@ +//go:build live + +package store + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/store/gen" +) + +// TestLive_DumpSession prints one account's decrypted session as JSON. +// Run against a COPY of the DB file: Open runs migrations. Output holds +// live tokens; do not paste it anywhere. +// +// GRUB_DB_PATH=/tmp/miner-copy.db GRUB_MASTER_KEY=... GRUB_LIVE_ACCOUNT=acc_x \ +// go test -tags live -run TestLive_DumpSession -v ./internal/store/ +func TestLive_DumpSession(t *testing.T) { + path, key, acct := os.Getenv("GRUB_DB_PATH"), os.Getenv("GRUB_MASTER_KEY"), os.Getenv("GRUB_LIVE_ACCOUNT") + if path == "" || key == "" || acct == "" { + t.Skip("set GRUB_DB_PATH, GRUB_MASTER_KEY, GRUB_LIVE_ACCOUNT") + } + ctx := context.Background() + db, err := Open(ctx, path) + require.NoError(t, err) + defer db.Close() + c, err := NewCryptor(key) + require.NoError(t, err) + s, ok, err := NewSessionStore(db, gen.New(db), c).Get(ctx, acct) + require.NoError(t, err) + require.True(t, ok, "no session for account") + b, err := json.Marshal(s) + require.NoError(t, err) + t.Logf("%s", b) +} diff --git a/internal/store/migrations/0016_pipeline_v2.sql b/internal/store/migrations/0016_pipeline_v2.sql new file mode 100644 index 00000000..b5bcdefb --- /dev/null +++ b/internal/store/migrations/0016_pipeline_v2.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- +goose StatementBegin +-- Pipeline v2: one authoritative mining state row per account and drop. +-- Timestamps are unix seconds, 0 means unset. +CREATE TABLE drop_state ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + drop_id TEXT NOT NULL, + campaign_id TEXT NOT NULL, + platform TEXT NOT NULL, + status TEXT NOT NULL, + block_reason TEXT NOT NULL DEFAULT '', + minutes INTEGER NOT NULL DEFAULT 0, + required INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'platform', + fail_count INTEGER NOT NULL DEFAULT 0, + retry_after INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (account_id, drop_id) +); +CREATE INDEX idx_drop_state_status ON drop_state(account_id, status); + +-- Ordered per-account priority streamers. Seeded from account_channels, +-- the null-game channel whitelist, which is the same concept. +CREATE TABLE account_streamer_priority ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + login TEXT NOT NULL, + rank INTEGER NOT NULL, + PRIMARY KEY (account_id, login) +); +CREATE INDEX idx_account_streamer_priority_acct ON account_streamer_priority(account_id, rank); + +INSERT INTO account_streamer_priority (account_id, platform, login, rank) +SELECT ac.account_id, a.platform, ac.channel, ac.rank +FROM account_channels ac +JOIN accounts a ON a.id = ac.account_id; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_account_streamer_priority_acct; +DROP TABLE IF EXISTS account_streamer_priority; +DROP INDEX IF EXISTS idx_drop_state_status; +DROP TABLE IF EXISTS drop_state; +-- +goose StatementEnd diff --git a/internal/store/queries/drop_state.sql b/internal/store/queries/drop_state.sql new file mode 100644 index 00000000..2ed034a1 --- /dev/null +++ b/internal/store/queries/drop_state.sql @@ -0,0 +1,32 @@ +-- name: ListDropStates :many +SELECT * FROM drop_state WHERE account_id = ? ORDER BY campaign_id, drop_id; + +-- name: UpsertDropState :exec +INSERT INTO drop_state ( + account_id, drop_id, campaign_id, platform, status, block_reason, + minutes, required, source, fail_count, retry_after, synced_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, drop_id) DO UPDATE SET + campaign_id = excluded.campaign_id, + platform = excluded.platform, + status = excluded.status, + block_reason = excluded.block_reason, + minutes = excluded.minutes, + required = excluded.required, + source = excluded.source, + fail_count = excluded.fail_count, + retry_after = excluded.retry_after, + synced_at = excluded.synced_at, + updated_at = excluded.updated_at; + +-- name: ListClaimsForBackfill :many +SELECT c.account_id, c.benefit_id, b.campaign_id, a.platform, b.required_minutes +FROM claims c +JOIN benefits b ON b.id = c.benefit_id +JOIN accounts a ON a.id = c.account_id; + +-- name: GetBenefitCampaign :one +SELECT b.campaign_id, cp.platform, b.required_minutes +FROM benefits b +JOIN campaigns cp ON cp.id = b.campaign_id +WHERE b.id = ?; diff --git a/internal/store/queries/streamer_priority.sql b/internal/store/queries/streamer_priority.sql new file mode 100644 index 00000000..29d823e2 --- /dev/null +++ b/internal/store/queries/streamer_priority.sql @@ -0,0 +1,12 @@ +-- name: ListStreamerPriority :many +SELECT login, rank FROM account_streamer_priority +WHERE account_id = ? +ORDER BY rank ASC, login ASC; + +-- name: AddStreamerPriority :exec +INSERT INTO account_streamer_priority (account_id, platform, login, rank) +VALUES (?, ?, ?, ?) +ON CONFLICT(account_id, login) DO UPDATE SET rank = excluded.rank, platform = excluded.platform; + +-- name: RemoveStreamerPriority :exec +DELETE FROM account_streamer_priority WHERE account_id = ? AND login = ?; diff --git a/internal/watcher/kick_idle_backoff_test.go b/internal/watcher/kick_idle_backoff_test.go new file mode 100644 index 00000000..15a85373 --- /dev/null +++ b/internal/watcher/kick_idle_backoff_test.go @@ -0,0 +1,246 @@ +package watcher + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aalejandrofer/grubdrops/internal/platform" +) + +// kickIdleRampBackend is a minimal platform.Backend double for driving +// Watcher.Run through repeated no-live-channel discovery rounds and then +// exactly one round that finds a live channel. One campaign, one +// 1-minute benefit (so a single Heartbeat completes it), and a +// toggleable "live" flag controlling ListEligibleChannels. +type kickIdleRampBackend struct { + live atomic.Bool + + mu sync.Mutex + minutes int + claimed bool +} + +var _ platform.Backend = (*kickIdleRampBackend)(nil) + +func (b *kickIdleRampBackend) Name() string { return "kick-idle-ramp-test" } + +func (b *kickIdleRampBackend) StartDeviceLogin(context.Context) (platform.DeviceChallenge, error) { + return platform.DeviceChallenge{}, nil +} + +func (b *kickIdleRampBackend) PollDeviceLogin(context.Context, platform.DeviceChallenge) (platform.Session, error) { + return platform.Session{}, nil +} + +func (b *kickIdleRampBackend) LoginViaBrowser(context.Context, platform.BrowserRPC) (platform.Session, error) { + return platform.Session{}, nil +} + +func (b *kickIdleRampBackend) RefreshSession(_ context.Context, s platform.Session) (platform.Session, error) { + return s, nil +} + +func (b *kickIdleRampBackend) ListActiveCampaigns(context.Context, platform.Session) ([]platform.Campaign, error) { + return []platform.Campaign{{ + ID: "camp1", Game: "Mock", Name: "Mock Campaign", + Benefits: []platform.DropBenefit{{ID: "drop1", CampaignID: "camp1", Name: "Mock Drop", RequiredMinutes: 1}}, + }}, nil +} + +func (b *kickIdleRampBackend) ListEligibleChannels(context.Context, platform.Session, platform.Campaign) ([]platform.Stream, error) { + if b.live.Load() { + return []platform.Stream{{Channel: "mockstreamer", DropsEnabled: true}}, nil + } + return nil, nil +} + +func (b *kickIdleRampBackend) InventoryProgress(context.Context, platform.Session) ([]platform.Progress, error) { + b.mu.Lock() + defer b.mu.Unlock() + return []platform.Progress{{BenefitID: "drop1", MinutesWatched: b.minutes, Claimed: b.claimed}}, nil +} + +func (b *kickIdleRampBackend) StartWatch(context.Context, platform.Session, platform.Stream) (platform.WatchHandle, error) { + return platform.WatchHandle{Channel: "mockstreamer"}, nil +} + +func (b *kickIdleRampBackend) Heartbeat(context.Context, platform.WatchHandle) error { + b.mu.Lock() + b.minutes++ + b.mu.Unlock() + return nil +} + +func (b *kickIdleRampBackend) StopWatch(context.Context, platform.WatchHandle) error { return nil } + +func (b *kickIdleRampBackend) Claim(context.Context, platform.Session, platform.DropBenefit) error { + b.mu.Lock() + b.claimed = true + b.mu.Unlock() + return nil +} + +// stateHit is one "state" notification with the wall-clock time it fired. +type stateHit struct { + at time.Time + state string +} + +// stateTimeline records every "state" Notify call with its arrival time, +// so a test can measure the real wall-clock gap between state changes — +// in particular, the gap between consecutive StateSleeping entries, which +// is exactly one idle-sleep wait (plus a few negligible tick-cadence +// steps). +type stateTimeline struct { + mu sync.Mutex + events []stateHit +} + +func (r *stateTimeline) Notify(_ context.Context, ev string, fields map[string]any) error { + if ev != "state" { + return nil + } + s, _ := fields["state"].(string) + r.mu.Lock() + r.events = append(r.events, stateHit{at: time.Now(), state: s}) + r.mu.Unlock() + return nil +} + +func (r *stateTimeline) snapshot() []stateHit { + r.mu.Lock() + defer r.mu.Unlock() + return append([]stateHit(nil), r.events...) +} + +func (r *stateTimeline) count(state string) int { + n := 0 + for _, e := range r.snapshot() { + if e.state == state { + n++ + } + } + return n +} + +// sleepingGaps returns the wall-clock deltas between consecutive +// StateSleeping hits, in order. +func sleepingGaps(hits []stateHit) []time.Duration { + var at []time.Time + for _, h := range hits { + if h.state == "sleeping" { + at = append(at, h.at) + } + } + gaps := make([]time.Duration, 0, len(at)-1) + for i := 1; i < len(at); i++ { + gaps = append(gaps, at[i].Sub(at[i-1])) + } + return gaps +} + +func sleepingCount(hits []stateHit) int { + n := 0 + for _, h := range hits { + if h.state == "sleeping" { + n++ + } + } + return n +} + +// TestKickIdleWait_RampsWhileOfflineAndResetsOnlyAfterWatching is the +// Run()-level regression test for the critical review finding on batch B +// item 3: idleWait must climb (30s->60s->120s->cap in production; scaled +// down here for test speed) across repeated +// pick_campaign->pick_stream(no live)->sleeping rounds, and must be reset +// ONLY by a round that reaches StateWatching — not by the intermediate +// pick_campaign/pick_stream states every offline round also passes +// through. Revert-proof: restoring the old condition ("reset whenever a +// step returns nil and the state isn't Sleeping/AwaitingConnect") zeroes +// idleWait on every pick_campaign/pick_stream transition, so the ramp +// assertions below (gap 2 clearly bigger than gap 1, etc.) fail. +func TestKickIdleWait_RampsWhileOfflineAndResetsOnlyAfterWatching(t *testing.T) { + prevSteps := kickIdleWaitSteps + kickIdleWaitSteps = []time.Duration{50 * time.Millisecond, 120 * time.Millisecond, 220 * time.Millisecond, 350 * time.Millisecond} + t.Cleanup(func() { kickIdleWaitSteps = prevSteps }) + + backend := &kickIdleRampBackend{} + timeline := &stateTimeline{} + + w := New(Config{ + AccountID: "acc-kick", + Platform: "kick", + Backend: backend, + Session: platform.Session{AccessToken: "x"}, + Notifier: timeline, + TickInterval: 2 * time.Millisecond, + HeartbeatInterval: 2 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- w.Run(ctx) }() + + // Channel stays offline: let the ramp climb through all 4 steps (5 + // "sleeping" hits give 4 gaps: steps 0->1->2 climbing, then the cap + // held into gap 4). + require.Eventually(t, func() bool { return timeline.count("sleeping") >= 5 }, + 5*time.Second, time.Millisecond, "watcher should idle-sleep repeatedly while offline") + + preWatch := timeline.snapshot() + gaps := sleepingGaps(preWatch) + require.GreaterOrEqual(t, len(gaps), 4, "need at least 4 gaps to see the ramp climb + cap") + + // The regression itself: with the old (reverted) reset condition, + // EVERY round zeroes idleWait via the pick_campaign/pick_stream nil + // returns, so every gap collapses to ~the 50ms floor and never climbs. + assert.Greater(t, gaps[1], gaps[0]*3/2, + "gap 2 must be clearly bigger than gap 1 -- proves the ramp climbs instead of resetting every round") + assert.Greater(t, gaps[2], gaps[1]*3/2, + "gap 3 must be clearly bigger than gap 2") + // Cap: once at the top step, the wait must hold, not keep doubling. + assert.Less(t, gaps[3], gaps[2]*2, + "wait must cap at the top step, not keep growing unbounded") + + lastCappedGap := gaps[len(gaps)-1] + preWatchSleepingCount := sleepingCount(preWatch) + + // Flip the channel live: the next pick_campaign->pick_stream round + // must reach StateWatching, mine the 1-minute benefit in a single + // heartbeat, and claim it. + backend.live.Store(true) + require.Eventually(t, func() bool { return timeline.count("watching") >= 1 }, + 2*time.Second, time.Millisecond, "watcher should start watching once the channel is live") + + // After claiming, nothing is left to mine (the benefit is claimed + // forever regardless of live state), so the watcher idles again. Wait + // for a SECOND fresh sleeping hit past the one still using the + // pre-reset (capped) wait, so the gap we measure is fully governed by + // the POST-reset idleWait. + require.Eventually(t, func() bool { return sleepingCount(timeline.snapshot()) >= preWatchSleepingCount+2 }, + 5*time.Second, time.Millisecond, "watcher should idle-sleep again after claiming, twice over") + + allGaps := sleepingGaps(timeline.snapshot()) + postResetGap := allGaps[len(allGaps)-1] + + assert.Less(t, postResetGap, lastCappedGap/2, + "idle wait must reset to the ramp floor once StateWatching is reached, not resume from the capped value") + assert.Less(t, postResetGap, 100*time.Millisecond, + "post-reset gap should be close to the 50ms floor step, not mid-ramp") + + cancel() + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after cancel") + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index e65648b1..6804bedd 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -700,10 +700,26 @@ func (w *Watcher) Run(ctx context.Context) error { backoff := time.Duration(0) const maxBackoff = 5 * time.Minute + // Kick idle-sleep ramp (see nextKickIdleWait). Reset to 0 only when the + // watcher actually starts watching a stream — NOT on every non-idle + // step. pickCampaign->StatePickStream and pickStream's no-live-channel + // path->StatePickCampaign both return nil too, so resetting on "any + // nil, non-idle state" (the original version of this fix) zeroed + // idleWait every round for an account with matched-but-offline + // campaigns: the ramp never climbed past its first step, which is + // worse than the flat recheckInterval it replaced. Gating the reset on + // StateWatching specifically means it only fires once a live channel + // was actually found, restarting the ramp at its 30s floor the next + // time the account goes idle. + idleWait := time.Duration(0) + for { err := w.step(ctx) if err == nil { backoff = 0 + if w.State() == StateWatching { + idleWait = 0 + } } else if errors.Is(err, errIdle) { // Idle (sleeping / awaiting connect): nothing to mine right now. // As the LOWEST-priority fallback, run a queued force-watch task @@ -716,7 +732,12 @@ func (w *Watcher) Run(ctx context.Context) error { continue } w.setState(ctx, StatePickCampaign) - timer := time.NewTimer(recheckInterval) + wait := recheckInterval + if w.cfg.Platform == "kick" { + idleWait = nextKickIdleWait(idleWait) + wait = idleWait + } + timer := time.NewTimer(wait) select { case <-ctx.Done(): timer.Stop() @@ -798,9 +819,37 @@ var errIdle = errors.New("idle; recheck later") // recheckInterval is how long a sleeping/awaiting-connect watcher waits // before re-discovering. Matches the discovery scraper cadence (~5m) so -// the watcher's view and the persisted /drops view converge. +// the watcher's view and the persisted /drops view converge. Twitch +// accounts always wait exactly this long; see nextKickIdleWait for Kick's +// ramped variant. const recheckInterval = 5 * time.Minute +// kickIdleWaitSteps is the ramp nextKickIdleWait climbs through before +// holding at recheckInterval. +var kickIdleWaitSteps = []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second, recheckInterval} + +// nextKickIdleWait computes how long a Kick watcher should wait before its +// next re-discovery after landing on StateSleeping/StateAwaitingConnect, +// given the PREVIOUS wait it used (0 the first time since going idle). A +// Kick account with no live channel for any of its campaigns otherwise +// re-picks and exhausts every campaign, then idles, then immediately +// re-arms on the very next tick — cycling +// pick_stream→pick_campaign→sleeping roughly every TickInterval and +// spamming state-change logs. Ramping 30s→60s→120s before holding at the +// same recheckInterval cap Twitch uses cuts that churn while still +// re-checking quickly the first couple of times (a channel may go live +// any moment). The caller resets prev to 0 the moment a pick succeeds, so +// the ramp always restarts at its 30s floor rather than resuming from +// wherever it had climbed to. +func nextKickIdleWait(prev time.Duration) time.Duration { + for _, step := range kickIdleWaitSteps { + if prev < step { + return step + } + } + return kickIdleWaitSteps[len(kickIdleWaitSteps)-1] +} + // stepErrBackoff is the first retry delay after a failed step (doubling up // to 5 min). A var only so tests can shrink it. var stepErrBackoff = 5 * time.Second diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 198b1308..ca32da9d 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -1764,3 +1764,38 @@ func TestWatcher_GhostSkip_SelfHealsWhenBenefitReappears(t *testing.T) { w.mu.Unlock() assert.False(t, stillSkipped, "healme must be removed from skippedBenefits after self-heal") } + +// TestNextKickIdleWait_RampsThenCaps: a Kick account with no live channel +// for any campaign otherwise cycles pick_stream→pick_campaign→sleeping on +// every tick, spamming state-change logs. nextKickIdleWait ramps the idle +// re-discovery wait 30s→60s→120s, then holds at recheckInterval (5m) — +// never exceeding it. +func TestNextKickIdleWait_RampsThenCaps(t *testing.T) { + seq := make([]time.Duration, 0, 6) + wait := time.Duration(0) + for i := 0; i < 6; i++ { + wait = nextKickIdleWait(wait) + seq = append(seq, wait) + } + assert.Equal(t, []time.Duration{ + 30 * time.Second, + 60 * time.Second, + 120 * time.Second, + recheckInterval, + recheckInterval, + recheckInterval, + }, seq) +} + +// TestNextKickIdleWait_ResetStartsOverAtFloor: resetting to 0 (a +// successful pick) must restart the ramp from its 30s floor, not resume +// from wherever it had climbed to. +func TestNextKickIdleWait_ResetStartsOverAtFloor(t *testing.T) { + wait := nextKickIdleWait(0) + wait = nextKickIdleWait(wait) // 60s + wait = nextKickIdleWait(wait) // 120s + require.Equal(t, 120*time.Second, wait) + + wait = 0 // simulated reset on successful pick + assert.Equal(t, 30*time.Second, nextKickIdleWait(wait)) +} diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index c8a44ea2..6428c57d 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -340,6 +340,16 @@ color: var(--accent); } .alert-cta:hover { color: var(--text); background: none; border: none; } + /* Warning emphasis — same flat alert row, red accent instead of the + default info orange (matches the red-for-warning convention used + elsewhere: .login-flash, .dot.error). Used when the condition the + alert describes has no fallback (e.g. an empty whitelist). */ + .alert.warning { + box-shadow: inset 3px 0 0 var(--red); + background: rgba(198, 74, 60, 0.06); + } + .alert.warning .alert-tag, + .alert.warning .alert-cta { color: var(--red); } /* TELEMETRY STRIP — 6 stats */ /* Flat stat strip — no boxed grid; cards separated by space, the strip @@ -989,7 +999,8 @@ .pill-force_watch { color: var(--green); border-color: rgba(111, 181, 107, 0.45); background: rgba(111, 181, 107, 0.06); } .pill-claiming { color: var(--amber); border-color: rgba(225, 176, 74, 0.45); background: rgba(225, 176, 74, 0.06); } .pill-pick_stream, - .pill-pick_campaign { color: var(--blue); border-color: rgba(111, 141, 181, 0.45); background: rgba(111, 141, 181, 0.06); } + .pill-pick_campaign, + .pill-discovering { color: var(--blue); border-color: rgba(111, 141, 181, 0.45); background: rgba(111, 141, 181, 0.06); } .pill-sleeping { color: var(--muted); border-color: var(--line); } .pill-awaiting_connect { color: #e8d44d; border-color: rgba(232, 212, 77, 0.5); background: rgba(232, 212, 77, 0.08); } .pill-needs_auth { color: var(--accent); border-color: rgba(212, 99, 30, 0.5); background: rgba(212, 99, 30, 0.08); } diff --git a/internal/web/templates/_dash_alerts.html b/internal/web/templates/_dash_alerts.html new file mode 100644 index 00000000..359bc6a3 --- /dev/null +++ b/internal/web/templates/_dash_alerts.html @@ -0,0 +1,19 @@ +{{/* Shared top-of-page alert/banner component (dashAlert). Reused by + dashboard.html and drops.html so both pages render CTA banners + (needs_auth, no_games, tv_discovery, ...) from the same markup + CSS. */}} +{{define "dash_alerts"}} +{{if .Alerts}} +
+ {{range .Alerts}} +
+ {{t (printf "dashboard.%s" .Kind)}} + + {{.Account}} + — {{t (printf "dashboard.alert_%s" .Kind)}} + + {{.Action}} +
+ {{end}} +
+{{end}} +{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 820ff8e3..1d65845b 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -18,20 +18,7 @@

{{t "dashboard.heading"}}

-{{if .Alerts}} -
- {{range .Alerts}} -
- {{t (printf "dashboard.%s" .Kind)}} - - {{.Account}} - — {{t (printf "dashboard.alert_%s" .Kind)}} - - {{.Action}} -
- {{end}} -
-{{end}} +{{template "dash_alerts" .}}
diff --git a/internal/web/templates/dashboard_mining_columns.html b/internal/web/templates/dashboard_mining_columns.html index 6e821f23..8b987547 100644 --- a/internal/web/templates/dashboard_mining_columns.html +++ b/internal/web/templates/dashboard_mining_columns.html @@ -46,6 +46,8 @@

{{t "mining.kick"}}

{{t "mining.claim_in_flight"}} {{else if eq .State "force_watch"}} {{t "mining.force_watching"}}{{if .Channel}} {{.Channel}}{{end}} + {{else if eq .State "discovering"}} + {{t "mining.discovering"}}{{if .Channel}} {{.Channel}}{{end}} {{else if eq .State "pick_stream"}} {{if .DropCampaign}}{{.DropCampaign}}{{end}} {{t "mining.scanning_channels"}} @@ -66,6 +68,7 @@

{{t "mining.kick"}}

{{else if eq .State "sleeping"}}{{t "mining.sleeping"}} {{else if eq .State "awaiting_connect"}}{{t "mining.awaiting_connect"}} {{else if eq .State "pick_stream"}}{{t "mining.scanning"}} + {{else if eq .State "discovering"}}{{t "mining.discovering"}} {{else if eq .State "pick_campaign"}}{{t "mining.picking"}} {{else if eq .State "idle"}}{{t "mining.idle"}} {{else}}{{.State}}{{end}} @@ -119,6 +122,19 @@

{{t "mining.kick"}}

{{t "mining.state"}} {{t "mining.force_watching"}}{{if .Uptime}} · {{.Uptime}} {{t "mining.on_stream"}}{{end}} + {{else if eq .State "discovering"}} +
+ {{t "mining.game"}} + {{if .ChannelGame}}{{.ChannelGame}}{{end}} +
+
+ {{t "mining.channel"}} + {{if .Channel}}{{.Channel}} ↗{{end}} +
+
+ {{t "mining.state"}} + {{t "mining.discovering"}}{{if .Uptime}} · {{.Uptime}} {{t "mining.on_stream"}}{{end}} +
{{else}}
{{t "mining.state"}} diff --git a/internal/web/templates/drops.html b/internal/web/templates/drops.html index 77bcbeeb..92749cf4 100644 --- a/internal/web/templates/drops.html +++ b/internal/web/templates/drops.html @@ -15,6 +15,8 @@

{{t "drops.heading"}}

+ {{template "dash_alerts" .}} +