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.
+
@@ -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.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" .}}
+