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>
260 lines
7.1 KiB
Go
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)
|
|
}
|