package igdb import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/tdvorak/seen/backend/internal/config" "github.com/tdvorak/seen/backend/internal/services/catalog" ) var ErrIGDBCredentialsMissing = errors.New("igdb client credentials missing") type Client struct { cfg config.IGDBConfig httpClient *http.Client mu sync.Mutex accessToken string accessTokenExp time.Time } type tokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` } type gameResponse struct { ID int64 `json:"id"` Name string `json:"name"` Summary string `json:"summary"` FirstReleaseDate int64 `json:"first_release_date"` Rating float64 `json:"rating"` Genres []struct { Name string `json:"name"` } `json:"genres"` Platforms []struct { Name string `json:"name"` } `json:"platforms"` } func NewClient(cfg config.IGDBConfig) *Client { return &Client{ cfg: cfg, httpClient: &http.Client{ Timeout: 12 * time.Second, }, } } func (c *Client) Enabled() bool { return strings.TrimSpace(c.cfg.ClientID) != "" && strings.TrimSpace(c.cfg.ClientSecret) != "" } func (c *Client) SearchGames(ctx context.Context, query string, limit int) ([]catalog.MediaItem, error) { if !c.Enabled() { return nil, ErrIGDBCredentialsMissing } cleanQuery := strings.TrimSpace(query) if cleanQuery == "" { return []catalog.MediaItem{}, nil } if limit < 1 { limit = 12 } token, err := c.token(ctx) if err != nil { return nil, err } body := fmt.Sprintf( "fields id,name,summary,first_release_date,rating,genres.name,platforms.name; search %q; limit %d;", cleanQuery, limit, ) request, err := http.NewRequestWithContext( ctx, http.MethodPost, strings.TrimRight(c.cfg.BaseURL, "/")+"/games", strings.NewReader(body), ) if err != nil { return nil, err } request.Header.Set("Client-ID", c.cfg.ClientID) request.Header.Set("Authorization", "Bearer "+token) request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "text/plain") response, err := c.httpClient.Do(request) if err != nil { return nil, err } defer response.Body.Close() if response.StatusCode >= http.StatusBadRequest { message, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) return nil, fmt.Errorf("igdb search failed: %s", strings.TrimSpace(string(message))) } var payload []gameResponse if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { return nil, err } items := make([]catalog.MediaItem, 0, len(payload)) for _, game := range payload { title := strings.TrimSpace(game.Name) if title == "" { continue } overview := strings.TrimSpace(game.Summary) if overview == "" { overview = fmt.Sprintf("%s is available through the live IGDB provider search.", title) } items = append(items, catalog.MediaItem{ ID: int(game.ID) + 900000, Provider: catalog.MediaProviderIGDB, ProviderID: int(game.ID), Title: title, Overview: overview, Type: catalog.MediaTypeGame, ReleaseDate: unixDate(game.FirstReleaseDate), Genres: genreNames(game.Genres), Platforms: platformNames(game.Platforms), Rating: normalizeRating(game.Rating), RuntimeMinutes: 0, ArtworkKey: fmt.Sprintf("igdb-%d", game.ID), }) } return items, nil } func (c *Client) token(ctx context.Context) (string, error) { c.mu.Lock() if c.accessToken != "" && time.Now().Before(c.accessTokenExp.Add(-1*time.Minute)) { token := c.accessToken c.mu.Unlock() return token, nil } c.mu.Unlock() form := url.Values{} form.Set("client_id", c.cfg.ClientID) form.Set("client_secret", c.cfg.ClientSecret) form.Set("grant_type", "client_credentials") request, err := http.NewRequestWithContext( ctx, http.MethodPost, c.cfg.TokenURL, strings.NewReader(form.Encode()), ) if err != nil { return "", err } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := c.httpClient.Do(request) if err != nil { return "", err } defer response.Body.Close() if response.StatusCode >= http.StatusBadRequest { message, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) return "", fmt.Errorf("igdb token request failed: %s", strings.TrimSpace(string(message))) } var payload tokenResponse if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { return "", err } if payload.AccessToken == "" { return "", errors.New("igdb token response missing access token") } c.mu.Lock() c.accessToken = payload.AccessToken c.accessTokenExp = time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second) token := c.accessToken c.mu.Unlock() return token, nil } func unixDate(value int64) string { if value <= 0 { return "" } return time.Unix(value, 0).UTC().Format("2006-01-02") } func normalizeRating(value float64) float64 { if value > 10 { return value / 10 } return value } func genreNames(values []struct { Name string `json:"name"` }) []string { if len(values) == 0 { return []string{} } names := make([]string, 0, len(values)) for _, value := range values { name := strings.TrimSpace(value.Name) if name != "" { names = append(names, name) } } return names } func platformNames(values []struct { Name string `json:"name"` }) []string { if len(values) == 0 { return []string{} } names := make([]string, 0, len(values)) for _, value := range values { name := strings.TrimSpace(value.Name) if name != "" { names = append(names, name) } } return names }