small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
@@ -0,0 +1,253 @@
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
}