package metadata import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // IGDB matches games. It uses Twitch's client-credentials flow: the app // exchanges its client id and secret for a bearer token that lasts about two // months, so the token is cached rather than re-fetched per search. type IGDB struct { ClientID string ClientSecret string // BaseURL and TokenURL are overridable so tests can point at a local server. BaseURL string TokenURL string Client *http.Client mu sync.Mutex token string tokenExpiry time.Time } // NewIGDB builds the provider. func NewIGDB(clientID, clientSecret string) *IGDB { return &IGDB{ ClientID: clientID, ClientSecret: clientSecret, BaseURL: "https://api.igdb.com/v4", TokenURL: "https://id.twitch.tv/oauth2/token", Client: &http.Client{Timeout: 15 * time.Second}, } } func (p *IGDB) Name() string { return "igdb" } func (p *IGDB) Kinds() []string { return []string{"games"} } type igdbGame struct { ID int `json:"id"` Name string `json:"name"` Summary string `json:"summary"` FirstReleaseDate int64 `json:"first_release_date"` Cover struct { URL string `json:"url"` } `json:"cover"` } func (p *IGDB) Search(ctx context.Context, q Query) ([]Candidate, error) { if p.ClientID == "" || p.ClientSecret == "" || strings.TrimSpace(q.Title) == "" { return nil, nil } token, err := p.accessToken(ctx) if err != nil { return nil, err } // IGDB uses its own query language (Apicalypse) sent as the request body. body := fmt.Sprintf( `search %q; fields name,summary,first_release_date,cover.url; limit 5;`, q.Title) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.BaseURL, "/")+"/games", strings.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Client-ID", p.ClientID) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") resp, err := p.Client.Do(req) if err != nil { return nil, fmt.Errorf("igdb: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { // The cached token was rejected; drop it so the next call re-auths. p.mu.Lock() p.token, p.tokenExpiry = "", time.Time{} p.mu.Unlock() return nil, fmt.Errorf("igdb: unauthorized (token rejected)") } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("igdb: status %d", resp.StatusCode) } var games []igdbGame if err := json.NewDecoder(resp.Body).Decode(&games); err != nil { return nil, fmt.Errorf("igdb: decode: %w", err) } out := make([]Candidate, 0, len(games)) for _, g := range games { if g.Name == "" { continue } c := Candidate{ Provider: p.Name(), ExternalID: strconv.Itoa(g.ID), Title: g.Name, Description: g.Summary, } if g.FirstReleaseDate > 0 { c.Year = time.Unix(g.FirstReleaseDate, 0).UTC().Year() } if g.Cover.URL != "" { c.CoverURL = igdbCover(g.Cover.URL) } out = append(out, c) } return out, nil } // accessToken returns a cached bearer token, fetching a new one when absent or // near expiry. func (p *IGDB) accessToken(ctx context.Context) (string, error) { p.mu.Lock() defer p.mu.Unlock() // Refresh a minute early to avoid racing the expiry. if p.token != "" && time.Now().Before(p.tokenExpiry.Add(-time.Minute)) { return p.token, nil } params := url.Values{} params.Set("client_id", p.ClientID) params.Set("client_secret", p.ClientSecret) params.Set("grant_type", "client_credentials") req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.TokenURL+"?"+params.Encode(), nil) if err != nil { return "", err } resp, err := p.Client.Do(req) if err != nil { return "", fmt.Errorf("igdb: token request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("igdb: token status %d", resp.StatusCode) } var parsed struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return "", fmt.Errorf("igdb: token decode: %w", err) } if parsed.AccessToken == "" { return "", fmt.Errorf("igdb: empty access token") } p.token = parsed.AccessToken p.tokenExpiry = time.Now().Add(time.Duration(parsed.ExpiresIn) * time.Second) return p.token, nil } // igdbCover normalises an IGDB image URL: protocol-relative to https, and the // thumbnail size swapped for a larger one. func igdbCover(u string) string { u = strings.Replace(u, "t_thumb", "t_cover_big", 1) if strings.HasPrefix(u, "//") { u = "https:" + u } return u }