Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 23 additions & 29 deletions internal/discovery/kick.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,24 +73,22 @@ 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)
if err != nil {
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 {
Expand All @@ -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
}
26 changes: 26 additions & 0 deletions internal/discovery/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
132 changes: 108 additions & 24 deletions internal/platform/kick/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,49 +77,111 @@ 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 {
Data []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"`
IsLive bool `json:"is_live"`
ViewerCount int `json:"viewer_count"`
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
Expand Down Expand Up @@ -161,6 +223,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
Expand Down Expand Up @@ -207,6 +270,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"),
Expand Down Expand Up @@ -541,6 +605,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})
Expand Down
10 changes: 8 additions & 2 deletions internal/platform/kick/api_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading