package providers import ( "context" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // igdb searches the Internet Game Database, authenticating through Twitch. type igdb struct { r *Registry id string secret string } type igdbTokens struct { mu sync.Mutex credKey string token string expires time.Time } func (g *igdb) Name() string { return "igdb" } func (g *igdb) Label() string { return "IGDB" } func (g *igdb) token(ctx context.Context, fresh bool) (string, error) { c := g.r.igdb c.mu.Lock() defer c.mu.Unlock() credKey := g.id + "\x00" + g.secret if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) { return c.token, nil } q := url.Values{"client_id": {g.id}, "client_secret": {g.secret}, "grant_type": {"client_credentials"}} req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://id.twitch.tv/oauth2/token?"+q.Encode(), nil) if err != nil { return "", err } var resp struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` } if err := g.r.do(req, &resp); err != nil { if isStatus(err, 400) || isStatus(err, 403) { return "", fmt.Errorf("Twitch rejected the IGDB client ID/secret; check them under Settings → Integrations") } return "", err } c.credKey, c.token = credKey, resp.AccessToken c.expires = time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second - time.Hour) return c.token, nil } func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) { q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query) body := `search "` + q + `"; fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.company.name,platforms.abbreviation,platforms.name; limit 25;` var games []struct { ID int64 `json:"id"` Name string `json:"name"` FirstReleaseDate int64 `json:"first_release_date"` Summary string `json:"summary"` URL string `json:"url"` Cover struct { ImageID string `json:"image_id"` } `json:"cover"` InvolvedCompanies []struct { Developer bool `json:"developer"` Company struct { Name string `json:"name"` } `json:"company"` } `json:"involved_companies"` Platforms []struct { Abbreviation string `json:"abbreviation"` Name string `json:"name"` } `json:"platforms"` } for attempt := 0; ; attempt++ { tok, err := g.token(ctx, attempt > 0) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.igdb.com/v4/games", strings.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Client-ID", g.id) req.Header.Set("Authorization", "Bearer "+tok) req.Header.Set("Content-Type", "text/plain") err = g.r.do(req, &games) if isStatus(err, 401) && attempt == 0 { continue } if err != nil { return nil, err } break } out := make([]Result, 0, len(games)) for _, gm := range games { var devs, plats []string for _, ic := range gm.InvolvedCompanies { if ic.Developer && ic.Company.Name != "" { devs = append(devs, ic.Company.Name) } } for _, p := range gm.Platforms { name := p.Abbreviation if name == "" { name = p.Name } plats = append(plats, name) } r := Result{ Provider: "igdb", ProviderID: strconv.FormatInt(gm.ID, 10), Title: gm.Name, Subtitle: strings.Join(devs, ", "), ExternalURL: gm.URL, Overview: truncate(gm.Summary, 600), Extra: map[string]string{}, } if gm.FirstReleaseDate > 0 { r.Year = strconv.Itoa(time.Unix(gm.FirstReleaseDate, 0).UTC().Year()) } if gm.Cover.ImageID != "" { r.ImageURL = "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/" + gm.Cover.ImageID + ".jpg" } if len(plats) > 0 { r.Extra["platforms"] = strings.Join(plats, ", ") } out = append(out, r) } return out, nil }