Files
canon/internal/providers/igdb.go
T
cdnutterandClaude Opus 5.5 1f3fa4fb2b Pace IGDB requests; rewrite the new help text
IGDB allows 4 requests a second, and an import searching three entries
at once (plus detail lookups) went over it. Every IGDB request now waits
at least 300 ms after the last one, and a 429 is retried after 1, 2 and
4 seconds before giving up.

The import dialog, ranking editor and their error messages are reworded
in plainer language, and talk about "spots" instead of "places".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:51:09 -07:00

260 lines
7.1 KiB
Go

package providers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"unicode"
)
// 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
}
const igdbFields = `fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.publisher,involved_companies.company.name,involved_companies.company.parent.name,involved_companies.company.parent.parent.name,involved_companies.company.parent.parent.parent.name,platforms.abbreviation,platforms.name;`
func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) {
q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query)
return g.query(ctx, `search "`+q+`";
`+igdbFields+`
limit 25;`)
}
// Lookup refreshes one game by its IGDB ID.
func (g *igdb) Lookup(ctx context.Context, providerID string) (Result, error) {
id, err := strconv.ParseInt(providerID, 10, 64)
if err != nil {
return Result{}, ErrGone
}
res, err := g.query(ctx, igdbFields+"\nwhere id = "+strconv.FormatInt(id, 10)+";")
if err != nil {
return Result{}, err
}
if len(res) == 0 {
return Result{}, ErrGone
}
return res[0], nil
}
func (g *igdb) query(ctx context.Context, body string) ([]Result, error) {
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 []igdbInvolved `json:"involved_companies"`
Platforms []struct {
Abbreviation string `json:"abbreviation"`
Name string `json:"name"`
} `json:"platforms"`
}
refreshed, backoff := false, time.Second
for {
tok, err := g.token(ctx, refreshed)
if err != nil {
return nil, err
}
if err := g.r.igdbPace.wait(ctx); 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) && !refreshed {
refreshed = true
continue
}
// Rate limited anyway (another app on the same key, say): back off
// and try again, up to about 7 seconds in all.
if isStatus(err, 429) && backoff <= 4*time.Second {
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
backoff *= 2
continue
}
if err != nil {
return nil, err
}
break
}
out := make([]Result, 0, len(games))
for _, gm := range games {
var plats []string
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(igdbStudios(gm.InvolvedCompanies), ", "),
ExternalURL: gm.URL,
Overview: truncate(gm.Summary, maxDescription),
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
}
type igdbInvolved struct {
Developer bool `json:"developer"`
Publisher bool `json:"publisher"`
Company igdbCompany `json:"company"`
}
// igdbStudios names who made a game: its developers, or its publishers when
// IGDB lists no developer, with internal divisions credited to their company.
func igdbStudios(involved []igdbInvolved) []string {
var devs, pubs []string
for _, ic := range involved {
if ic.Company.Name == "" {
continue
}
if ic.Developer {
devs = appendUnique(devs, studioName(&ic.Company))
}
if ic.Publisher {
pubs = appendUnique(pubs, studioName(&ic.Company))
}
}
if len(devs) == 0 {
return pubs
}
return devs
}
// igdbCompany is a company with as much of its parent chain as was
// requested. IGDB sends an unexpanded parent as a bare ID, which is ignored.
type igdbCompany struct {
Name string
Parent *igdbCompany
}
func (c *igdbCompany) UnmarshalJSON(b []byte) error {
if len(b) == 0 || b[0] != '{' {
return nil
}
var raw struct {
Name string `json:"name"`
Parent *igdbCompany `json:"parent"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
c.Name, c.Parent = raw.Name, raw.Parent
if c.Parent != nil && c.Parent.Name == "" {
c.Parent = nil
}
return nil
}
// studioName credits an internal division to the company it belongs to.
// IGDB lists the exact team, so Super Mario Sunshine's developer is
// "Nintendo EAD" and DK Bananza's is "Nintendo EPD Production Group No. 8".
// When a studio's name starts with the same word as its parent's, it's a
// division of that company and we show the parent ("Nintendo"). Studios
// with their own identity keep their name: Retro Studios (owned by
// Nintendo) or Naughty Dog (owned by Sony).
func studioName(c *igdbCompany) string {
for c.Parent != nil && sameFirstWord(c.Name, c.Parent.Name) {
c = c.Parent
}
return c.Name
}
func sameFirstWord(a, b string) bool {
first := func(s string) string {
f := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
if len(f) == 0 {
return ""
}
return f[0]
}
fa := first(a)
return fa != "" && fa == first(b)
}
func appendUnique(list []string, s string) []string {
for _, x := range list {
if x == s {
return list
}
}
return append(list, s)
}