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 } 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.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; 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 []igdbInvolved `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 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) }