From 3951dde0eda58933038d1390640d47bdd0d321db Mon Sep 17 00:00:00 2001 From: steethebutcher <122177512+steethebutcher@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:20:09 +0100 Subject: [PATCH 1/4] feat(kick): directory fallback for open campaigns with no channel pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most Kick campaigns are open (channels: []) — they accrue on any live channel in the category. ListEligibleChannels only considered the campaign's own channels, a category pool built exclusively from sibling campaigns that embed channels, and manual operator channels. When no sibling campaign carries channels (e.g. "WoW Forever", the only WoW campaign), the pool was empty, the watcher logged "no eligible streams live" forever, and the campaign was unmineable without registering a channel manually (#60). Wire the public /stream/livestreams/{category} directory back in as the open-campaign fallback — behind probeLive's live + on-category verification, which makes the old failure mode (the generic feed's wrong-game junk picks) impossible to regress: a wrong slug now yields zero results, never junk. Restricted campaigns never consult the directory. - api: parse the campaign payload's authoritative category slug (game_slug/category_slug sibling or nested game.slug) into the new kickCampaign.GameSlug — previously discarded, so the directory was addressed with a name-derived slug. - backend: categorySlugs cache (game -> payload slug); the fallback resolves payload slug first, then gameslug.Slug(name). Directory results are viewer-sorted (probeLive's cap lands on the biggest, most stable streams) and merged into categoryChannels so later picks work even if the feed goes away. Tests: happy path, payload-slug precedence, wrong-category rejection, restricted-campaign exclusion, and pool persistence across picks. --- internal/platform/kick/api.go | 22 ++++ internal/platform/kick/backend.go | 70 +++++++++++ internal/platform/kick/backend_test.go | 158 +++++++++++++++++++++++++ 3 files changed, 250 insertions(+) diff --git a/internal/platform/kick/api.go b/internal/platform/kick/api.go index 042fd17..17b55a5 100644 --- a/internal/platform/kick/api.go +++ b/internal/platform/kick/api.go @@ -161,6 +161,7 @@ type kickCampaign struct { ID string Name string Game string + GameSlug string // authoritative category slug when the payload carries one Status string StartsAt string EndsAt string @@ -207,6 +208,7 @@ func (a *api) Campaigns(ctx context.Context, sess platform.Session) ([]kickCampa ID: mstr(m, "id", "campaign_id", "campaignId", "uuid"), Name: mstr(m, "name", "title"), Game: gameName(m), + GameSlug: gameSlug(m), Status: mstr(m, "status", "state"), StartsAt: mstr(m, "starts_at", "startsAt", "start_at", "start_time"), EndsAt: mstr(m, "ends_at", "endsAt", "end_at", "end_time"), @@ -541,6 +543,26 @@ func gameName(m map[string]any) string { return "" } +// gameSlug extracts the campaign's category slug when the payload carries +// one: a top-level game_slug/category_slug sibling, or a nested +// game/category object's slug. Empty when absent — callers fall back to +// deriving the slug from the display name (gameslug.Slug). Keeping the +// authoritative slug matters because derived slugs can diverge from Kick's +// real category paths (renames, punctuation — cf. Twitch issue #61). +func gameSlug(m map[string]any) string { + if s := mstr(m, "game_slug", "gameSlug", "category_slug", "categorySlug"); s != "" { + return s + } + for _, k := range []string{"game", "category"} { + if sub, ok := m[k].(map[string]any); ok { + if s := mstr(sub, "slug"); s != "" { + return s + } + } + } + return "" +} + // Claim claims a drop reward. AUTHED. POST {reward_id, campaign_id}. func (a *api) Claim(ctx context.Context, sess platform.Session, rewardID, campaignID string) error { payload, _ := json.Marshal(map[string]string{"reward_id": rewardID, "campaign_id": campaignID}) diff --git a/internal/platform/kick/backend.go b/internal/platform/kick/backend.go index 4dea437..adf0787 100644 --- a/internal/platform/kick/backend.go +++ b/internal/platform/kick/backend.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" "log/slog" + "sort" "strings" "sync" "time" "github.com/aalejandrofer/grubdrops/internal/auth/browser" "github.com/aalejandrofer/grubdrops/internal/dockerctl" + "github.com/aalejandrofer/grubdrops/internal/gameslug" "github.com/aalejandrofer/grubdrops/internal/netutil" "github.com/aalejandrofer/grubdrops/internal/platform" ) @@ -63,6 +65,7 @@ type Backend struct { channelsByAcc map[string][]string campaignChannels map[string][]kickChannel // campaignID -> eligible channels (slug+id) categoryChannels map[string][]kickChannel // game/category -> union of participating channels across campaigns + categorySlugs map[string]string // game display name -> authoritative category slug (payload-provided) // reaperCancel stops the sidecar reaper goroutine on Close(). reaperCancel context.CancelFunc @@ -129,6 +132,7 @@ func New(c *browser.Client, ctl dockerctl.Controller, template string, port int, channelsByAcc: map[string][]string{}, campaignChannels: map[string][]kickChannel{}, categoryChannels: map[string][]kickChannel{}, + categorySlugs: map[string]string{}, } if ctl != nil { ctx, cancel := context.WithCancel(context.Background()) @@ -405,6 +409,14 @@ func (b *Backend) ListActiveCampaigns(ctx context.Context, s platform.Session) ( } b.mu.Unlock() } + // Remember the payload's authoritative category slug (when present) so + // the open-campaign directory fallback addresses the right category + // instead of re-deriving the slug from the display name. + if c.Game != "" && c.GameSlug != "" { + b.mu.Lock() + b.categorySlugs[c.Game] = c.GameSlug + b.mu.Unlock() + } camp := platform.Campaign{ ID: c.ID, Platform: "kick", @@ -482,6 +494,21 @@ func (b *Backend) ListEligibleChannels(ctx context.Context, s platform.Session, return live, nil } + // Directory fallback for OPEN campaigns: when the pooled candidates (own + + // category union) yielded nothing live — typically because no sibling + // campaign of this game embeds channels (e.g. "WoW Forever", the only WoW + // campaign, so the category pool is empty) — ask the public category + // directory for currently-live channels. Every discovered candidate still + // passes probeLive's live + on-category verification, so a wrong slug + // (the failure mode that got the old generic livestreams feed removed) + // can only produce zero results, never junk picks. Restricted campaigns + // never reach here — only their own listed channels can accrue. + if openCampaign { + if live := b.discoverCategoryChannels(ctx, s, c); len(live) > 0 { + return live, nil + } + } + // Fallback: channels the operator registered manually. Returned as-is (no // liveness probe) — an explicit operator override, and the watch loop will // drop a dead one on the next heartbeat. @@ -528,6 +555,49 @@ func (b *Backend) probeLive(ctx context.Context, s platform.Session, c platform. return live } +// discoverCategoryChannels is the open-campaign directory fallback: fetch the +// public /stream/livestreams/{category} feed for the campaign's category, +// merge the discovered slugs into the category pool (so subsequent picks see +// them without a re-fetch), and return the ones that are live and actually +// streaming the campaign's category. Returns nil when the category slug can't +// be resolved, the feed errors, or nothing verifies — the caller falls +// through to the manual operator channels. +func (b *Backend) discoverCategoryChannels(ctx context.Context, s platform.Session, c platform.Campaign) []platform.Stream { + b.mu.Lock() + slug := b.categorySlugs[c.Game] + b.mu.Unlock() + if slug == "" && c.Game != "" { + slug = gameslug.Slug(c.Game) + } + if slug == "" { + return nil + } + streams, err := b.api.DiscoverChannelsForCategory(ctx, s, slug) + if err != nil { + slog.Debug("kick category directory fetch failed", "game", c.Game, "slug", slug, "err", err) + return nil + } + if len(streams) == 0 { + return nil + } + // Probe biggest-first so probeLive's cap lands on the most-watched (and + // therefore most stable) candidates. The directory only lists is_live + // channels, but the category still needs re-verifying per channel: a + // wrong slug yields channels streaming a DIFFERENT game, which accrues + // nothing — that's exactly what probeLive's category check rejects. + sort.SliceStable(streams, func(i, j int) bool { return streams[i].ViewerCount > streams[j].ViewerCount }) + discovered := make([]kickChannel, 0, len(streams)) + for _, st := range streams { + discovered = append(discovered, kickChannel{Slug: st.Channel}) + } + b.mu.Lock() + if c.Game != "" { + b.categoryChannels[c.Game] = mergeChannels(b.categoryChannels[c.Game], discovered) + } + b.mu.Unlock() + return b.probeLive(ctx, s, c, discovered) +} + // reliableChannels are broadcasters known to stream their drops category // almost continuously with high viewer participation. When an OPEN campaign // (channels: []) lets us watch ANY participating live channel in the diff --git a/internal/platform/kick/backend_test.go b/internal/platform/kick/backend_test.go index cf83238..fc11fcc 100644 --- a/internal/platform/kick/backend_test.go +++ b/internal/platform/kick/backend_test.go @@ -221,6 +221,164 @@ func TestKickBackend_RejectsWrongCategoryChannel(t *testing.T) { assert.Empty(t, out) } +// --- open-campaign directory fallback (issue #60) --------------------------- +// +// An OPEN campaign whose category pool is EMPTY (no sibling campaign embeds +// channels — e.g. "WoW Forever", the only WoW campaign) must fall back to the +// public /stream/livestreams/{category} directory, with every discovered +// candidate still verified live + on-category by probeLive. + +func TestKickBackend_OpenCampaignDirectoryFallback(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + // One OPEN WoW campaign, no channels anywhere in the payload. + "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ + {"id":"c-wow","game":"World of Warcraft","name":"WoW Forever","status":"active", + "rewards":[{"id":"b1","required_units":60}]} + ]}`}, + // The public directory feed: two live channels in the category. + "https://kick.com/stream/livestreams/world-of-warcraft": {200, `{"data":[ + {"id":111,"is_live":true,"viewer_count":4200,"channel":{"slug":"rewardstation"}}, + {"id":222,"is_live":true,"viewer_count":80,"channel":{"slug":"wowstreamer"}} + ]}`}, + // probeLive verification: both live + streaming the campaign's game. + "https://kick.com/api/v2/channels/rewardstation/livestream": {200, `{"data":{"id":111,"viewer_count":4200,"categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}}`}, + "https://kick.com/api/v2/channels/wowstreamer/livestream": {200, `{"data":{"id":222,"viewer_count":80,"categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}}`}, + }} + b := withFake(f) + _, err := b.ListActiveCampaigns(context.Background(), sess("acc1")) + require.NoError(t, err) + + out, err := b.ListEligibleChannels(context.Background(), sess("acc1"), + platform.Campaign{ID: "c-wow", Game: "World of Warcraft"}) + require.NoError(t, err) + require.Len(t, out, 2) + // Viewer-sorted: biggest first so probeLive's cap lands on stable channels. + assert.Equal(t, "rewardstation", out[0].Channel) + assert.Equal(t, "wowstreamer", out[1].Channel) + // ChannelID falls back to the livestream id for directory-discovered + // slugs (the watch handshake accepts it). + assert.Equal(t, "111", out[0].ChannelID) + + // The directory was addressed with the slug derived from the game name + // (payload carried no game_slug). + assert.Contains(t, f.calls, fakeCall{method: "GET", path: "https://kick.com/stream/livestreams/world-of-warcraft"}) +} + +// A payload-provided game_slug is authoritative: the directory must be +// addressed with it, not the name-derived slug. +func TestKickBackend_DirectoryFallbackUsesPayloadSlug(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ + {"id":"c1","game":"Some Game","game_slug":"the-real-slug","status":"active", + "rewards":[{"id":"b1","required_units":60}]} + ]}`}, + "https://kick.com/stream/livestreams/the-real-slug": {200, `{"data":[ + {"id":9,"is_live":true,"viewer_count":10,"channel":{"slug":"someone"}} + ]}`}, + "https://kick.com/api/v2/channels/someone/livestream": {200, `{"data":{"id":9,"viewer_count":10,"categories":[{"name":"Some Game"}]}}`}, + }} + b := withFake(f) + _, err := b.ListActiveCampaigns(context.Background(), sess("acc1")) + require.NoError(t, err) + + out, err := b.ListEligibleChannels(context.Background(), sess("acc1"), + platform.Campaign{ID: "c1", Game: "Some Game"}) + require.NoError(t, err) + require.Len(t, out, 1) + assert.Equal(t, "someone", out[0].Channel) + assert.Contains(t, f.calls, fakeCall{method: "GET", path: "https://kick.com/stream/livestreams/the-real-slug"}) +} + +// The junk guard that got the old generic feed removed must hold for the +// directory fallback too: channels the directory returns that are streaming a +// DIFFERENT category than the campaign are rejected, and the caller receives +// only verified streams (here: none, so the manual fallback result — empty — +// comes back). +func TestKickBackend_DirectoryFallbackRejectsWrongCategory(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ + {"id":"c1","game":"Rust","status":"active","rewards":[{"id":"b1","required_units":60}]} + ]}`}, + // Directory (wrong slug shape would produce this): channels on another game. + "https://kick.com/stream/livestreams/rust": {200, `{"data":[ + {"id":7,"is_live":true,"viewer_count":5,"channel":{"slug":"slotsfan"}} + ]}`}, + "https://kick.com/api/v2/channels/slotsfan/livestream": {200, `{"data":{"id":7,"viewer_count":5,"categories":[{"name":"Slots"}]}}`}, + }} + b := withFake(f) + _, err := b.ListActiveCampaigns(context.Background(), sess("acc1")) + require.NoError(t, err) + + out, err := b.ListEligibleChannels(context.Background(), sess("acc1"), + platform.Campaign{ID: "c1", Game: "Rust"}) + require.NoError(t, err) + assert.Empty(t, out, "wrong-category directory channels must not be returned") +} + +// A RESTRICTED campaign (own channels[]) must never consult the directory — +// only its listed channels can accrue. +func TestKickBackend_RestrictedCampaignSkipsDirectory(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + // Restricted campaign with one offline channel. + "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ + {"id":"c1","game":"Rust","status":"active", + "channels":[{"slug":"offlinechan","id":"3"}], + "rewards":[{"id":"b1","required_units":60}]} + ]}`}, + "https://kick.com/api/v2/channels/offlinechan/livestream": {200, `{"data":null}`}, + // If the directory were consulted it would return a live channel — + // the test asserts it is NOT called. + "https://kick.com/stream/livestreams/rust": {200, `{"data":[ + {"id":55,"is_live":true,"viewer_count":900,"channel":{"slug":"should-not-appear"}} + ]}`}, + }} + b := withFake(f) + _, err := b.ListActiveCampaigns(context.Background(), sess("acc1")) + require.NoError(t, err) + + out, err := b.ListEligibleChannels(context.Background(), sess("acc1"), + platform.Campaign{ID: "c1", Game: "Rust"}) + require.NoError(t, err) + assert.Empty(t, out, "offline restricted channel yields nothing") + for _, c := range f.calls { + assert.NotContains(t, c.path, "/stream/livestreams/", + "restricted campaign must not hit the directory feed") + } +} + +// Directory-discovered slugs merge into the category pool: a second +// ListEligibleChannels finds them even when the directory subsequently +// 404s — the pool, not the feed, is the durable candidate source. +func TestKickBackend_DirectoryDiscoveryPoolsForLaterPicks(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ + {"id":"c1","game":"Rust","status":"active","rewards":[{"id":"b1","required_units":60}]} + ]}`}, + "https://kick.com/stream/livestreams/rust": {200, `{"data":[ + {"id":31,"is_live":true,"viewer_count":100,"channel":{"slug":"pooledchan"}} + ]}`}, + "https://kick.com/api/v2/channels/pooledchan/livestream": {200, `{"data":{"id":31,"viewer_count":100,"categories":[{"name":"Rust"}]}}`}, + }} + b := withFake(f) + _, err := b.ListActiveCampaigns(context.Background(), sess("acc1")) + require.NoError(t, err) + + // First pick: directory fallback discovers + verifies pooledchan. + out, err := b.ListEligibleChannels(context.Background(), sess("acc1"), + platform.Campaign{ID: "c1", Game: "Rust"}) + require.NoError(t, err) + require.Len(t, out, 1) + assert.Equal(t, "pooledchan", out[0].Channel) + + // Kill the directory feed; the second pick must still resolve via the pool. + f.resp["https://kick.com/stream/livestreams/rust"] = fakeResp{404, `{}`} + out2, err := b.ListEligibleChannels(context.Background(), sess("acc1"), + platform.Campaign{ID: "c1", Game: "Rust"}) + require.NoError(t, err) + require.Len(t, out2, 1) + assert.Equal(t, "pooledchan", out2[0].Channel) +} + func TestKickBackend_DeviceLoginRejected(t *testing.T) { b := New(nil, nil, "grubdrops-browser-{slug}", 9090, 10*time.Minute) _, err := b.StartDeviceLogin(context.Background()) From 2c38225068a1fc127b28cc194e6422460641d28f Mon Sep 17 00:00:00 2001 From: steethebutcher <122177512+steethebutcher@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:22:55 +0100 Subject: [PATCH 2/4] feat(kick): emit shell rows for non-whitelisted campaigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-whitelisted Kick campaigns were dropped twice — inside the backend's ListActiveCampaigns (GameFilter continue) and again by the discovery scraper's re-filter — so they never reached the campaigns table or the /drops Discoverable tab. A user couldn't see a campaign like "WoW Forever" to whitelist it; the only way in was adding the game blind on /priority. Twitch deliberately avoids this deadlock by emitting shell rows; now Kick does too. - backend: GameFilter is now a should-map-benefits gate instead of a drop-the-campaign gate. Non-whitelisted campaigns keep ID/Game/Name/ Status/window/link-state/AllowedChannels but carry EMPTY Benefits and skip the channel-pool caching. The watcher's own AllowGame (applied after ListActiveCampaigns) plus its never-mine-empty-Benefits rule keep shells out of mining — the same mechanism that already makes Twitch shells safe. - discovery/kick: stop re-applying the whitelist; pass everything the backend returned (shells included) to the persister so they surface in Discoverable and the game picker. Also refresh the stale sidecar/Cloudflare header comments — Kick data has been pure-HTTP utls since the kick-breakthrough rework. Tests: MapsAndFilters updated to the shell contract (both campaigns emitted; non-whitelisted has empty Benefits + intact status), a new ShellRowsSkipChannelPools guard, and a scraper pass-through test. --- internal/discovery/kick.go | 52 ++++++++++++-------------- internal/discovery/providers_test.go | 26 +++++++++++++ internal/platform/kick/backend.go | 20 +++++++--- internal/platform/kick/backend_test.go | 49 ++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 38 deletions(-) diff --git a/internal/discovery/kick.go b/internal/discovery/kick.go index 65a3b47..2cf4ba5 100644 --- a/internal/discovery/kick.go +++ b/internal/discovery/kick.go @@ -14,25 +14,22 @@ import ( // means no enabled Kick account has a usable session right now. type kickSessionSource func(ctx context.Context) (string, platform.Session, bool, error) -// KickScraper reuses the chromedp sidecar (via the existing kick.Backend) -// to enumerate active drop campaigns. Per ref_kickdropsminer.md, Kick is -// browser-only — Cloudflare's JS challenge defeats every pure-HTTP path -// — so a working sidecar is non-negotiable. When the sidecar isn't -// configured (GRUB_BROWSER_URL empty) the registry never registers a -// Kick backend, so this Scraper's Backend is nil and Scrape no-ops. +// KickScraper enumerates active drop campaigns through the shared kick.Backend +// (pure-HTTP utls client; the chromedp sidecar is no longer used for Kick +// data — Kick's API 403s CDP browsers but accepts the Chrome TLS fingerprint). +// The backend needs no sidecar, but the registry only registers it when a +// Kick account exists, so a nil Backend still means "graceful no-op". // // Like TwitchScraper we borrow ONE enabled Kick account's session (the -// first ListEnabledAccounts row whose platform is "kick"). The session -// pins the Cloudflare cookie + xsrf token the sidecar needs to land on -// kick.com without tripping the challenge. Without any Kick account -// logged in we cannot scrape — Cloudflare will block the navigation. +// first ListEnabledAccounts row whose platform is "kick") — the authed +// /api/v1/drops/campaigns call needs its cookies. // -// Whitelist note: kick.Backend.ListActiveCampaigns scrapes -// https://kick.com/drops via the sidecar and surfaces every active -// drop campaign Kick advertises, regardless of game. The per-account -// GameFilter on the session prunes results down to whitelisted games -// inside the backend (and we re-apply the same filter here as a -// belt-and-suspenders guard). +// Whitelist note: the session's GameFilter (built from the whitelist union) +// makes the backend emit non-whitelisted campaigns as SHELL rows — status + +// game + name, no Benefits — exactly like the Twitch scraper. Those shells +// persist to the campaigns table so the /drops Discoverable tab can list +// them and the user can opt the game in; the watcher's own AllowGame keeps +// them out of mining. type KickScraper struct { Backend platform.Backend Source kickSessionSource @@ -76,8 +73,8 @@ func (s *KickScraper) Name() string { return "kick" } func (s *KickScraper) Scrape(ctx context.Context, whitelist []string) ([]platform.Campaign, error) { if s == nil || s.Backend == nil || s.Source == nil { - // No sidecar configured (GRUB_BROWSER_URL empty) — graceful - // no-op so the Scraper just logs once and continues. + // No Kick backend registered — graceful no-op so the Scraper just + // logs once and continues. return nil, nil } accountID, sess, ok, err := s.Source(ctx) @@ -85,15 +82,13 @@ func (s *KickScraper) Scrape(ctx context.Context, whitelist []string) ([]platfor return nil, fmt.Errorf("kick scraper: load session: %w", err) } if !ok { - // No Kick account logged in — Cloudflare will reject the - // sidecar navigation, so don't even try. + // No Kick account logged in — nothing to scrape with. return nil, nil } if accountID != "" { sess.AccountID = accountID } - allow := buildAllowList(whitelist) - sess.GameFilter = allow + sess.GameFilter = buildAllowList(whitelist) camps, err := s.Backend.ListActiveCampaigns(ctx, sess) if err != nil { @@ -102,11 +97,10 @@ func (s *KickScraper) Scrape(ctx context.Context, whitelist []string) ([]platfor } return nil, fmt.Errorf("kick ListActiveCampaigns: %w", err) } - out := make([]platform.Campaign, 0, len(camps)) - for _, c := range camps { - if allow(c.Game) { - out = append(out, c) - } - } - return out, nil + // Emit every campaign the backend returned — whitelisted with full + // benefits, non-whitelisted as shell rows (Benefits empty, set by + // ListActiveCampaigns when GameFilter rejected the game). The /drops + // Discoverable tab consumes the shell rows; the watcher's mining loop + // ignores them via its own AllowGame check. + return camps, nil } diff --git a/internal/discovery/providers_test.go b/internal/discovery/providers_test.go index 4fc6a04..2c35a3d 100644 --- a/internal/discovery/providers_test.go +++ b/internal/discovery/providers_test.go @@ -190,3 +190,29 @@ func TestKickScraper_AttachesGameFilter(t *testing.T) { require.NotNil(t, b.gotSession.GameFilter) assert.True(t, b.gotSession.GameFilter("Rust")) } + +// The scraper must pass through shell rows (empty Benefits) the backend emits +// for non-whitelisted games — that's how they reach the campaigns table and +// the /drops Discoverable tab. The old scraper re-applied the whitelist and +// dropped them, making non-whitelisted Kick campaigns invisible (#60). +func TestKickScraper_PassesThroughShellRows(t *testing.T) { + b := &stubBackend{result: []platform.Campaign{ + {ID: "kick-rust", Platform: "kick", Game: "Rust", Benefits: []platform.DropBenefit{{ID: "b1", RequiredMinutes: 60}}}, + {ID: "kick-wow", Platform: "kick", Game: "World of Warcraft", Name: "WoW Forever"}, // shell: no Benefits + }} + source := func(context.Context) (string, platform.Session, bool, error) { + return "acc-kick", platform.Session{}, true, nil + } + s := NewKickScraper(b, source) + camps, err := s.Scrape(context.Background(), []string{"rust"}) + require.NoError(t, err) + require.Len(t, camps, 2, "shell rows must survive the scraper") + var sawShell bool + for _, c := range camps { + if c.ID == "kick-wow" { + sawShell = true + assert.Empty(t, c.Benefits, "shell row must carry no benefits") + } + } + assert.True(t, sawShell, "non-whitelisted shell row missing from scraper output") +} diff --git a/internal/platform/kick/backend.go b/internal/platform/kick/backend.go index adf0787..88f020c 100644 --- a/internal/platform/kick/backend.go +++ b/internal/platform/kick/backend.go @@ -382,22 +382,28 @@ func kickRewardsToBenefits(campaignID string, rewards []kickReward) []platform.D func (b *Backend) ListActiveCampaigns(ctx context.Context, s platform.Session) ([]platform.Campaign, error) { // Drops campaigns over the utls HTTP client (GET /api/v1/drops/campaigns). - // The per-account whitelist filters the result — never hardcode a game. + // Non-whitelisted campaigns are emitted as SHELL rows (status + game + + // name, no Benefits) so the /drops Discoverable tab can surface them and + // the user can opt the game in — mirroring the Twitch backend, which treats + // GameFilter as a should-fetch-details gate rather than a drop-the-campaign + // gate. The watcher applies its own AllowGame before mining and never picks + // a campaign with empty Benefits, so shells are never mined. camps, err := b.api.Campaigns(ctx, s) if err != nil { return nil, fmt.Errorf("kick campaigns: %w", err) } out := make([]platform.Campaign, 0, len(camps)) for _, c := range camps { - if s.GameFilter != nil && c.Game != "" && !s.GameFilter(c.Game) { - continue + whitelisted := s.GameFilter == nil || c.Game == "" || s.GameFilter(c.Game) + var benefits []platform.DropBenefit + if whitelisted { + benefits = kickRewardsToBenefits(c.ID, c.Rewards) } - benefits := kickRewardsToBenefits(c.ID, c.Rewards) slugs := make([]string, 0, len(c.Channels)) for _, ch := range c.Channels { slugs = append(slugs, ch.Slug) } - if c.ID != "" && len(c.Channels) > 0 { + if whitelisted && c.ID != "" && len(c.Channels) > 0 { b.mu.Lock() b.campaignChannels[c.ID] = c.Channels // slug+id, for the watch handshake // Kick drops accrue on ANY participating live channel in the @@ -411,7 +417,9 @@ func (b *Backend) ListActiveCampaigns(ctx context.Context, s platform.Session) ( } // Remember the payload's authoritative category slug (when present) so // the open-campaign directory fallback addresses the right category - // instead of re-deriving the slug from the display name. + // instead of re-deriving the slug from the display name. Cached for + // shell rows too — it costs nothing and is ready the moment the user + // whitelists the game. if c.Game != "" && c.GameSlug != "" { b.mu.Lock() b.categorySlugs[c.Game] = c.GameSlug diff --git a/internal/platform/kick/backend_test.go b/internal/platform/kick/backend_test.go index fc11fcc..dcf7218 100644 --- a/internal/platform/kick/backend_test.go +++ b/internal/platform/kick/backend_test.go @@ -74,13 +74,32 @@ func TestKickBackend_ListActiveCampaigns_MapsAndFilters(t *testing.T) { assert.Equal(t, c.ID, c.Benefits[0].CampaignID) } - // GameFilter prunes to Rust only. + // GameFilter turns non-whitelisted campaigns into SHELL rows (Twitch + // parity): still emitted with status + game + name so the /drops + // Discoverable tab can surface them, but with EMPTY Benefits so the + // watcher never mines them. Previously they were dropped outright, + // which made non-whitelisted Kick campaigns invisible — the user + // couldn't discover "WoW Forever" to whitelist it (#60). s := sess("acc1") s.GameFilter = func(g string) bool { return g == "Rust" } only, err := b.ListActiveCampaigns(context.Background(), s) require.NoError(t, err) - require.Len(t, only, 1) - assert.Equal(t, "Rust", only[0].Game) + require.Len(t, only, 2) + + var rust, cs *platform.Campaign + for i := range only { + switch only[i].Game { + case "Rust": + rust = &only[i] + case "Counter-Strike": + cs = &only[i] + } + } + require.NotNil(t, rust, "whitelisted campaign still emitted") + require.NotNil(t, cs, "non-whitelisted campaign emitted as shell row") + require.NotEmpty(t, rust.Benefits, "whitelisted campaign keeps its benefits") + assert.Empty(t, cs.Benefits, "shell row must carry no benefits") + assert.Equal(t, "active", cs.Status, "shell row keeps its status for the Discoverable tab") } // Kick exposes no per-campaign "is the external account linked" signal (unlike @@ -379,6 +398,30 @@ func TestKickBackend_DirectoryDiscoveryPoolsForLaterPicks(t *testing.T) { assert.Equal(t, "pooledchan", out2[0].Channel) } +// Shell rows must not populate the channel pools: a game nobody mines +// shouldn't contribute candidates (or claim IDs) to mining state. +func TestKickBackend_ShellRowsSkipChannelPools(t *testing.T) { + f := &fakeDoer{resp: map[string]fakeResp{ + "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ + {"id":"c-shell","game":"ShellGame","name":"Not Whitelisted","status":"active", + "channels":[{"slug":"shellchan","id":"1"}], + "rewards":[{"id":"b1","required_units":60}]} + ]}`}, + }} + b := withFake(f) + s := sess("acc1") + s.GameFilter = func(g string) bool { return g == "Rust" } // nothing whitelisted matches + _, err := b.ListActiveCampaigns(context.Background(), s) + require.NoError(t, err) + + b.mu.Lock() + _, hasCampaign := b.campaignChannels["c-shell"] + _, hasCategory := b.categoryChannels["ShellGame"] + b.mu.Unlock() + assert.False(t, hasCampaign, "shell row must not populate campaignChannels") + assert.False(t, hasCategory, "shell row must not populate categoryChannels") +} + func TestKickBackend_DeviceLoginRejected(t *testing.T) { b := New(nil, nil, "grubdrops-browser-{slug}", 9090, 10*time.Minute) _, err := b.StartDeviceLogin(context.Background()) From 83e85740b513e34cc704fd1f0c225ad70a7f1ab4 Mon Sep 17 00:00:00 2001 From: steethebutcher <122177512+steethebutcher@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:30:02 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix(kick):=20directory=20feed=20is=20global?= =?UTF-8?q?=20+=20paginated=20=E2=80=94=20filter=20client-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-checking the public /stream/livestreams/{slug} endpoint (2026-10-03) showed two things the dead-code implementation assumed wrong: 1. The feed IGNORES the {category} path segment — querying rust vs world-of-warcraft returns the identical global list (and next_page_url even leaks an unrelated default category). This is the same wrong-game-junk failure mode that got the old generic feed removed from channel selection. 2. It is paginated: per_page=5, ?page=N works, entries carry their own categories[] (name+slug) alongside channel.slug. So DiscoverChannelsForCategory now walks up to 4 pages and filters client-side — an entry qualifies when any embedded category matches the wanted slug or the campaign's display name (case/space-insensitive; real feed even mixes case, e.g. "Project-Zomboid"). Correct whether or not Kick restores per-category filtering, and probeLive still hard- verifies every candidate afterwards, so junk picks remain impossible. Live-verified by simulation against the production feed: 4-page scan extracted categories correctly and the filter matched channels for a category present in the window. The window is a slice of a global feed, so discovery is best-effort progressive — successive watcher cycles keep scanning — not exhaustive; noted in the PR. api_live_test updated to the new signature with a note that zero results may just mean the category wasn't live in the scanned window. --- internal/platform/kick/api.go | 108 +++++++++++++++++++----- internal/platform/kick/api_live_test.go | 10 ++- internal/platform/kick/backend.go | 2 +- internal/platform/kick/backend_test.go | 60 ++++++++----- 4 files changed, 133 insertions(+), 47 deletions(-) diff --git a/internal/platform/kick/api.go b/internal/platform/kick/api.go index 17b55a5..3fd3258 100644 --- a/internal/platform/kick/api.go +++ b/internal/platform/kick/api.go @@ -77,8 +77,15 @@ func newAPI() *api { return &api{d: newHTTPDoer(nil)} } // ---- Public channel discovery (no auth required) ------------------------- -// livestreamsResp is the /stream/livestreams/{category} shape (verified live). +// livestreamsResp is the /stream/livestreams/{category} shape (verified live +// 2026-10-03). The feed is PAGINATED (per_page=5, ?page=N works) and — as of +// that check — serves a GLOBAL feed that ignores the {category} path segment, +// so entries must be filtered client-side against their embedded categories. +// Each entry carries the channel slug and its own category list, which makes +// that filter authoritative rather than inferred. type livestreamsResp struct { + CurrentPage int `json:"current_page"` + NextPageURL any `json:"next_page_url"` // string or null Data []struct { ID int64 `json:"id"` // livestream id (used for the watch ping) SessionTitle string `json:"session_title"` @@ -87,39 +94,94 @@ type livestreamsResp struct { Channel struct { Slug string `json:"slug"` // channel login, e.g. "tippie" } `json:"channel"` + Categories []struct { + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"categories"` } `json:"data"` } +// discoverMaxPages caps how many directory pages one fallback fetch walks +// (per_page=5, so 4 pages ≈ 20 candidates — probeLive caps verification at +// 12 anyway). Later watcher cycles keep scanning, so a category missing from +// this window still gets picked up over successive picks. +const discoverMaxPages = 4 + // DiscoverChannelsForCategory lists currently-live channels in a Kick category // (game). Public endpoint — works without a logged-in session. This is the // auto-discovery that removes manual one-channel-at-a-time entry. -func (a *api) DiscoverChannelsForCategory(ctx context.Context, sess platform.Session, categorySlug string) ([]platform.Stream, error) { - body, status, err := a.d.do(ctx, sess, http.MethodGet, discoveryBase+"/stream/livestreams/"+categorySlug, nil) - if err != nil { - return nil, err - } - if status != 200 { - return nil, fmt.Errorf("livestreams %s: status %d", categorySlug, status) - } - var resp livestreamsResp - if err := json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("decode livestreams: %w", err) - } - out := make([]platform.Stream, 0, len(resp.Data)) - for _, s := range resp.Data { - if !s.IsLive || s.Channel.Slug == "" { - continue +// +// An entry qualifies when any of its embedded categories matches the wanted +// one — by slug, or by display name against gameName (case/space-insensitive; +// the campaign may only carry the display name). Matching client-side keeps +// this correct whether or not the endpoint honors the category segment, and +// the caller (probeLive) independently re-verifies every candidate is live +// and on-category before it's committed, so a sloppy match can't produce a +// junk pick. +func (a *api) DiscoverChannelsForCategory(ctx context.Context, sess platform.Session, categorySlug, gameName string) ([]platform.Stream, error) { + out := []platform.Stream{} + for page := 1; page <= discoverMaxPages; page++ { + url := fmt.Sprintf("%s/stream/livestreams/%s?page=%d", discoveryBase, categorySlug, page) + body, status, err := a.d.do(ctx, sess, http.MethodGet, url, nil) + if err != nil { + if page > 1 { + break // earlier pages already parsed — return what we have + } + return nil, err + } + if status != 200 { + if page > 1 { + break + } + return nil, fmt.Errorf("livestreams %s: status %d", categorySlug, status) + } + var resp livestreamsResp + if err := json.Unmarshal(body, &resp); err != nil { + if page > 1 { + break + } + return nil, fmt.Errorf("decode livestreams: %w", err) + } + for _, s := range resp.Data { + if !s.IsLive || s.Channel.Slug == "" { + continue + } + if !entryMatchesCategory(s.Categories, categorySlug, gameName) { + continue + } + out = append(out, platform.Stream{ + Channel: s.Channel.Slug, + ViewerCount: s.ViewerCount, + DropsEnabled: true, + ChannelID: fmt.Sprintf("%d", s.ID), // livestream id — used by the watch ping + }) + } + if len(resp.Data) == 0 { + break } - out = append(out, platform.Stream{ - Channel: s.Channel.Slug, - ViewerCount: s.ViewerCount, - DropsEnabled: true, - ChannelID: fmt.Sprintf("%d", s.ID), // livestream id — used by the watch ping - }) } return out, nil } +// entryMatchesCategory reports whether any of the entry's embedded categories +// is the wanted one. Slug match is exact; name match is case/space-insensitive +// against the game's display name ("PUBG: BATTLEGROUNDS" vs "PUBG: +// Battlegrounds" etc). +func entryMatchesCategory(cats []struct { + Name string `json:"name"` + Slug string `json:"slug"` +}, categorySlug, gameName string) bool { + for _, c := range cats { + if categorySlug != "" && strings.EqualFold(strings.TrimSpace(c.Slug), strings.TrimSpace(categorySlug)) { + return true + } + if gameName != "" && strings.EqualFold(strings.TrimSpace(c.Name), strings.TrimSpace(gameName)) { + return true + } + } + return false +} + // Avatar returns the authenticated account's profile-picture URL. AUTHED. // // Kick's web app reads the current user from kick.com/api/v1/user, whose diff --git a/internal/platform/kick/api_live_test.go b/internal/platform/kick/api_live_test.go index 7d3fa66..f524ac7 100644 --- a/internal/platform/kick/api_live_test.go +++ b/internal/platform/kick/api_live_test.go @@ -45,13 +45,19 @@ func TestLiveDiscovery(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - chans, err := newAPI().DiscoverChannelsForCategory(ctx, sess, "rust") + // NOTE (2026-10-03): the feed currently serves a GLOBAL directory and + // ignores the {category} path segment, so this also exercises the + // client-side category filter — with a global feed, entries only match + // when their embedded categories really are Rust. A zero here may just + // mean no Rust stream is live in the scanned window; check manually with + // curl before treating it as a parsing failure. + chans, err := newAPI().DiscoverChannelsForCategory(ctx, sess, "rust", "Rust") if err != nil { t.Fatalf("discover channels: %v", err) } t.Logf("discovered %d live rust channels via utls (CF bypassed)", len(chans)) if len(chans) == 0 { - t.Fatal("expected at least one live channel") + t.Log("no rust channels live in the scanned window (feed is global; not necessarily a failure)") } for i, c := range chans { if i >= 5 { diff --git a/internal/platform/kick/backend.go b/internal/platform/kick/backend.go index 88f020c..cbf3e7c 100644 --- a/internal/platform/kick/backend.go +++ b/internal/platform/kick/backend.go @@ -580,7 +580,7 @@ func (b *Backend) discoverCategoryChannels(ctx context.Context, s platform.Sessi if slug == "" { return nil } - streams, err := b.api.DiscoverChannelsForCategory(ctx, s, slug) + streams, err := b.api.DiscoverChannelsForCategory(ctx, s, slug, c.Game) if err != nil { slog.Debug("kick category directory fetch failed", "game", c.Game, "slug", slug, "err", err) return nil diff --git a/internal/platform/kick/backend_test.go b/internal/platform/kick/backend_test.go index dcf7218..7e36c33 100644 --- a/internal/platform/kick/backend_test.go +++ b/internal/platform/kick/backend_test.go @@ -254,10 +254,16 @@ func TestKickBackend_OpenCampaignDirectoryFallback(t *testing.T) { {"id":"c-wow","game":"World of Warcraft","name":"WoW Forever","status":"active", "rewards":[{"id":"b1","required_units":60}]} ]}`}, - // The public directory feed: two live channels in the category. - "https://kick.com/stream/livestreams/world-of-warcraft": {200, `{"data":[ - {"id":111,"is_live":true,"viewer_count":4200,"channel":{"slug":"rewardstation"}}, - {"id":222,"is_live":true,"viewer_count":80,"channel":{"slug":"wowstreamer"}} + // The public directory feed (2026-10-03 live shape: paginated, entries + // carry their own categories). Includes one off-category entry that + // the client-side filter must drop before probing. + "https://kick.com/stream/livestreams/world-of-warcraft?page=1": {200, `{"current_page":1,"data":[ + {"id":111,"is_live":true,"viewer_count":4200,"channel":{"slug":"rewardstation"}, + "categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}, + {"id":222,"is_live":true,"viewer_count":80,"channel":{"slug":"wowstreamer"}, + "categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}, + {"id":333,"is_live":true,"viewer_count":5,"channel":{"slug":"slotsfan"}, + "categories":[{"name":"Slots","slug":"slots"}]} ]}`}, // probeLive verification: both live + streaming the campaign's game. "https://kick.com/api/v2/channels/rewardstation/livestream": {200, `{"data":{"id":111,"viewer_count":4200,"categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}}`}, @@ -279,8 +285,12 @@ func TestKickBackend_OpenCampaignDirectoryFallback(t *testing.T) { assert.Equal(t, "111", out[0].ChannelID) // The directory was addressed with the slug derived from the game name - // (payload carried no game_slug). - assert.Contains(t, f.calls, fakeCall{method: "GET", path: "https://kick.com/stream/livestreams/world-of-warcraft"}) + // (payload carried no game_slug), and the off-category entry was never + // probed. + assert.Contains(t, f.calls, fakeCall{method: "GET", path: "https://kick.com/stream/livestreams/world-of-warcraft?page=1"}) + for _, c := range f.calls { + assert.NotContains(t, c.path, "slotsfan", "off-category entry must be filtered before probing") + } } // A payload-provided game_slug is authoritative: the directory must be @@ -291,8 +301,9 @@ func TestKickBackend_DirectoryFallbackUsesPayloadSlug(t *testing.T) { {"id":"c1","game":"Some Game","game_slug":"the-real-slug","status":"active", "rewards":[{"id":"b1","required_units":60}]} ]}`}, - "https://kick.com/stream/livestreams/the-real-slug": {200, `{"data":[ - {"id":9,"is_live":true,"viewer_count":10,"channel":{"slug":"someone"}} + "https://kick.com/stream/livestreams/the-real-slug?page=1": {200, `{"data":[ + {"id":9,"is_live":true,"viewer_count":10,"channel":{"slug":"someone"}, + "categories":[{"name":"Some Game","slug":"the-real-slug"}]} ]}`}, "https://kick.com/api/v2/channels/someone/livestream": {200, `{"data":{"id":9,"viewer_count":10,"categories":[{"name":"Some Game"}]}}`}, }} @@ -305,23 +316,25 @@ func TestKickBackend_DirectoryFallbackUsesPayloadSlug(t *testing.T) { require.NoError(t, err) require.Len(t, out, 1) assert.Equal(t, "someone", out[0].Channel) - assert.Contains(t, f.calls, fakeCall{method: "GET", path: "https://kick.com/stream/livestreams/the-real-slug"}) + assert.Contains(t, f.calls, fakeCall{method: "GET", path: "https://kick.com/stream/livestreams/the-real-slug?page=1"}) } // The junk guard that got the old generic feed removed must hold for the -// directory fallback too: channels the directory returns that are streaming a -// DIFFERENT category than the campaign are rejected, and the caller receives -// only verified streams (here: none, so the manual fallback result — empty — -// comes back). +// directory fallback too — at BOTH layers: entries whose embedded categories +// don't match are dropped client-side (never probed), and anything that slips +// through is rejected by probeLive's own category check. func TestKickBackend_DirectoryFallbackRejectsWrongCategory(t *testing.T) { f := &fakeDoer{resp: map[string]fakeResp{ "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ {"id":"c1","game":"Rust","status":"active","rewards":[{"id":"b1","required_units":60}]} ]}`}, - // Directory (wrong slug shape would produce this): channels on another game. - "https://kick.com/stream/livestreams/rust": {200, `{"data":[ - {"id":7,"is_live":true,"viewer_count":5,"channel":{"slug":"slotsfan"}} + // Directory returns channels on another game (the global-feed reality). + "https://kick.com/stream/livestreams/rust?page=1": {200, `{"data":[ + {"id":7,"is_live":true,"viewer_count":5,"channel":{"slug":"slotsfan"}, + "categories":[{"name":"Slots","slug":"slots"}]} ]}`}, + // probeLive verification for slotsfan would say Slots — but the + // client-side filter must keep it from ever being probed. "https://kick.com/api/v2/channels/slotsfan/livestream": {200, `{"data":{"id":7,"viewer_count":5,"categories":[{"name":"Slots"}]}}`}, }} b := withFake(f) @@ -332,6 +345,9 @@ func TestKickBackend_DirectoryFallbackRejectsWrongCategory(t *testing.T) { platform.Campaign{ID: "c1", Game: "Rust"}) require.NoError(t, err) assert.Empty(t, out, "wrong-category directory channels must not be returned") + for _, c := range f.calls { + assert.NotContains(t, c.path, "slotsfan", "wrong-category entry must be filtered before probing") + } } // A RESTRICTED campaign (own channels[]) must never consult the directory — @@ -347,8 +363,9 @@ func TestKickBackend_RestrictedCampaignSkipsDirectory(t *testing.T) { "https://kick.com/api/v2/channels/offlinechan/livestream": {200, `{"data":null}`}, // If the directory were consulted it would return a live channel — // the test asserts it is NOT called. - "https://kick.com/stream/livestreams/rust": {200, `{"data":[ - {"id":55,"is_live":true,"viewer_count":900,"channel":{"slug":"should-not-appear"}} + "https://kick.com/stream/livestreams/rust?page=1": {200, `{"data":[ + {"id":55,"is_live":true,"viewer_count":900,"channel":{"slug":"should-not-appear"}, + "categories":[{"name":"Rust","slug":"rust"}]} ]}`}, }} b := withFake(f) @@ -373,8 +390,9 @@ func TestKickBackend_DirectoryDiscoveryPoolsForLaterPicks(t *testing.T) { "https://web.kick.com/api/v1/drops/campaigns": {200, `{"data":[ {"id":"c1","game":"Rust","status":"active","rewards":[{"id":"b1","required_units":60}]} ]}`}, - "https://kick.com/stream/livestreams/rust": {200, `{"data":[ - {"id":31,"is_live":true,"viewer_count":100,"channel":{"slug":"pooledchan"}} + "https://kick.com/stream/livestreams/rust?page=1": {200, `{"data":[ + {"id":31,"is_live":true,"viewer_count":100,"channel":{"slug":"pooledchan"}, + "categories":[{"name":"Rust","slug":"rust"}]} ]}`}, "https://kick.com/api/v2/channels/pooledchan/livestream": {200, `{"data":{"id":31,"viewer_count":100,"categories":[{"name":"Rust"}]}}`}, }} @@ -390,7 +408,7 @@ func TestKickBackend_DirectoryDiscoveryPoolsForLaterPicks(t *testing.T) { assert.Equal(t, "pooledchan", out[0].Channel) // Kill the directory feed; the second pick must still resolve via the pool. - f.resp["https://kick.com/stream/livestreams/rust"] = fakeResp{404, `{}`} + f.resp["https://kick.com/stream/livestreams/rust?page=1"] = fakeResp{404, `{}`} out2, err := b.ListEligibleChannels(context.Background(), sess("acc1"), platform.Campaign{ID: "c1", Game: "Rust"}) require.NoError(t, err) From cea0a3e5051d47797974676efb529cc75b744524 Mon Sep 17 00:00:00 2001 From: steethebutcher <122177512+steethebutcher@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:02:59 +0100 Subject: [PATCH 4/4] style: gofmt alignment in kick files (CI gofmt gate) --- internal/platform/kick/api.go | 4 ++-- internal/platform/kick/backend.go | 2 +- internal/platform/kick/backend_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/platform/kick/api.go b/internal/platform/kick/api.go index 3fd3258..fd7cdfe 100644 --- a/internal/platform/kick/api.go +++ b/internal/platform/kick/api.go @@ -84,9 +84,9 @@ func newAPI() *api { return &api{d: newHTTPDoer(nil)} } // Each entry carries the channel slug and its own category list, which makes // that filter authoritative rather than inferred. type livestreamsResp struct { - CurrentPage int `json:"current_page"` + CurrentPage int `json:"current_page"` NextPageURL any `json:"next_page_url"` // string or null - Data []struct { + Data []struct { ID int64 `json:"id"` // livestream id (used for the watch ping) SessionTitle string `json:"session_title"` IsLive bool `json:"is_live"` diff --git a/internal/platform/kick/backend.go b/internal/platform/kick/backend.go index cbf3e7c..e52f577 100644 --- a/internal/platform/kick/backend.go +++ b/internal/platform/kick/backend.go @@ -65,7 +65,7 @@ type Backend struct { channelsByAcc map[string][]string campaignChannels map[string][]kickChannel // campaignID -> eligible channels (slug+id) categoryChannels map[string][]kickChannel // game/category -> union of participating channels across campaigns - categorySlugs map[string]string // game display name -> authoritative category slug (payload-provided) + categorySlugs map[string]string // game display name -> authoritative category slug (payload-provided) // reaperCancel stops the sidecar reaper goroutine on Close(). reaperCancel context.CancelFunc diff --git a/internal/platform/kick/backend_test.go b/internal/platform/kick/backend_test.go index 7e36c33..19065e8 100644 --- a/internal/platform/kick/backend_test.go +++ b/internal/platform/kick/backend_test.go @@ -267,7 +267,7 @@ func TestKickBackend_OpenCampaignDirectoryFallback(t *testing.T) { ]}`}, // probeLive verification: both live + streaming the campaign's game. "https://kick.com/api/v2/channels/rewardstation/livestream": {200, `{"data":{"id":111,"viewer_count":4200,"categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}}`}, - "https://kick.com/api/v2/channels/wowstreamer/livestream": {200, `{"data":{"id":222,"viewer_count":80,"categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}}`}, + "https://kick.com/api/v2/channels/wowstreamer/livestream": {200, `{"data":{"id":222,"viewer_count":80,"categories":[{"name":"World of Warcraft","slug":"world-of-warcraft"}]}}`}, }} b := withFake(f) _, err := b.ListActiveCampaigns(context.Background(), sess("acc1"))