mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
519 lines
14 KiB
Go
519 lines
14 KiB
Go
package igdb
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
gocache "github.com/robfig/go-cache"
|
|
|
|
"github.com/sbondCo/Watcharr/cache"
|
|
)
|
|
|
|
// inmemory content cache
|
|
var GameStore = gocache.New(time.Hour*24, time.Minute)
|
|
|
|
const (
|
|
igdbHost = "https://api.igdb.com/v4"
|
|
tokenGrantType = "client_credentials"
|
|
|
|
// Fields that we select for "search" services.
|
|
fieldsForSearch = `name,
|
|
cover.image_id,
|
|
version_title,
|
|
summary,
|
|
first_release_date`
|
|
)
|
|
|
|
var tokenRefreshJobCancel context.CancelFunc
|
|
|
|
type IGDB struct {
|
|
ClientID *string `json:"clientId,omitempty"`
|
|
ClientSecret *string `json:"clientSecret,omitempty"`
|
|
AccessToken string `json:"accessToken,omitempty"`
|
|
AccessTokenExpires time.Time `json:"accessTokenExpires,omitzero"`
|
|
onTokenRefreshed *func()
|
|
}
|
|
|
|
func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp interface{}) error {
|
|
// if using igdb host and we have no access token, error before running req
|
|
if host == igdbHost && (i.ClientID == nil || i.AccessToken == "") {
|
|
return errors.New("using igdbHost without a clientID or accessToken")
|
|
}
|
|
|
|
slog.Debug("IGDB->req: Creating a request.", "ep", ep, "body", b)
|
|
|
|
base, err := url.Parse(host)
|
|
if err != nil {
|
|
return errors.New("failed to parse api uri")
|
|
}
|
|
|
|
// Path params
|
|
base.Path += ep
|
|
|
|
// Query params
|
|
params := url.Values{}
|
|
for k, v := range p {
|
|
params.Add(k, v)
|
|
}
|
|
|
|
// Add params to url
|
|
base.RawQuery = params.Encode()
|
|
|
|
slog.Debug("req", "base", base.String())
|
|
|
|
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer([]byte(b)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add igdb auth headers
|
|
if host == igdbHost {
|
|
req.Header.Add("Client-ID", *i.ClientID)
|
|
req.Header.Add("Authorization", "Bearer "+i.AccessToken)
|
|
}
|
|
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
|
slog.Error("game non 2xx status code:", "status_code", res.StatusCode)
|
|
return errors.New(string(body))
|
|
}
|
|
err = json.Unmarshal([]byte(body), &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (i *IGDB) getNewAccessToken() (TwitchTokenResponse, error) {
|
|
var resp TwitchTokenResponse
|
|
err := i.req(
|
|
"https://id.twitch.tv/oauth2",
|
|
"/token",
|
|
map[string]string{"client_id": *i.ClientID, "client_secret": *i.ClientSecret, "grant_type": tokenGrantType},
|
|
"",
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("IGDB init token request failed", "error", err)
|
|
return TwitchTokenResponse{}, errors.New("token request failed, check client id and secret")
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (i *IGDB) refreshToken(ctx context.Context) {
|
|
var exp <-chan time.Time
|
|
|
|
if i.AccessToken != "" && !i.AccessTokenExpires.IsZero() && i.AccessTokenExpires.Compare(time.Now()) == 1 {
|
|
// Stored token not expired.. set exp time to token exp time - 1h.
|
|
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
|
} else {
|
|
// Token expired.. exp now..
|
|
exp = time.After(100 * time.Millisecond)
|
|
}
|
|
|
|
slog.Info("refreshToken running")
|
|
|
|
for {
|
|
select {
|
|
case <-exp:
|
|
slog.Info("IGDB refreshToken: Token expired (or is near expiry date)")
|
|
r, err := i.getNewAccessToken()
|
|
if err != nil {
|
|
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):",
|
|
"error", err)
|
|
exp = time.After(60 * time.Second)
|
|
} else {
|
|
slog.Info("IGDB refreshToken: Token successfully refreshed")
|
|
i.AccessToken = r.AccessToken
|
|
i.AccessTokenExpires = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second)
|
|
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
|
// Call token refresh callback if set
|
|
if i.onTokenRefreshed != nil {
|
|
(*i.onTokenRefreshed)()
|
|
}
|
|
}
|
|
|
|
case <-ctx.Done():
|
|
slog.Info("refreshToken cancelled")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (i *IGDB) OnTokenRefreshed(tokenRefreshed func()) {
|
|
i.onTokenRefreshed = &tokenRefreshed
|
|
}
|
|
|
|
// Get token and stuff
|
|
func (i *IGDB) Init() error {
|
|
// Cancel existing refresh job if we have a cancel func for its context
|
|
if tokenRefreshJobCancel != nil {
|
|
slog.Debug("IGDB init: Refresh job running.. cancelling it.")
|
|
tokenRefreshJobCancel()
|
|
tokenRefreshJobCancel = nil
|
|
}
|
|
// Stop here if we have no client id or secret.
|
|
if i.ClientID == nil || i.ClientSecret == nil {
|
|
slog.Error("IGDB init client id and or secret not provided")
|
|
return errors.New("client id and or secret not provided")
|
|
}
|
|
slog.Debug("IGDB init running.")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
tokenRefreshJobCancel = cancel
|
|
// Get and set first token if needed
|
|
go i.refreshToken(ctx)
|
|
return nil
|
|
}
|
|
|
|
type SearchOptions struct {
|
|
Query string
|
|
Year int
|
|
PrimaryYear int
|
|
}
|
|
|
|
// Turn supported filters from the SearchOptions struct into a `where` we can
|
|
// send to APICalypse.
|
|
func (o SearchOptions) AsWhere() string {
|
|
f := []string{}
|
|
if o.Year != 0 {
|
|
y := strconv.Itoa(o.Year)
|
|
// If `any` release of the game was on this year.
|
|
f = append(f, "release_dates.y = "+y)
|
|
}
|
|
if o.PrimaryYear != 0 {
|
|
start := strconv.FormatInt(
|
|
time.
|
|
Date(o.PrimaryYear, time.January, 1, 0, 0, 0, 0, time.UTC).
|
|
Unix(),
|
|
10)
|
|
end := strconv.FormatInt(
|
|
time.
|
|
// We add one to the year and get the first day of the next year
|
|
// BUT because day=0, we go back one day and end up with the
|
|
// very last day of `PrimaryYear`.
|
|
Date(o.PrimaryYear+1, time.January, 0, 23, 59, 59, 0, time.UTC).
|
|
Unix(),
|
|
10)
|
|
// If `first` release of the game was on this year.
|
|
f = append(f, "first_release_date > "+start)
|
|
f = append(f, "first_release_date < "+end)
|
|
}
|
|
if len(f) <= 0 {
|
|
return ""
|
|
}
|
|
return "where " + strings.Join(f, " & ") + ";"
|
|
|
|
}
|
|
|
|
func (i *IGDB) Search(o SearchOptions) (GameSearchResponse, error) {
|
|
slog.Debug("Search:", "query", o.Query)
|
|
var resp GameSearchResponse
|
|
cacheKey := cache.CreateCacheKey(
|
|
"Search",
|
|
o.Query,
|
|
o.Year,
|
|
o.PrimaryYear)
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("Search: Returning cache.")
|
|
return resp, nil
|
|
}
|
|
apiQuery := "fields " + fieldsForSearch + "; search \"" + o.Query + "\"; limit 40;"
|
|
whereQ := o.AsWhere()
|
|
if whereQ != "" {
|
|
apiQuery += whereQ
|
|
}
|
|
err := i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
apiQuery,
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("Search: request failed!", "error", err)
|
|
return GameSearchResponse{}, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
return resp, nil
|
|
}
|
|
|
|
// Should return same details as `Search`, we both are for search page only minimal details required.
|
|
func (i *IGDB) SearchById(id string) (GameSearchResponse, error) {
|
|
slog.Debug("SearchById:", "id", id)
|
|
var resp GameSearchResponse
|
|
cacheKey := cache.CreateCacheKey("SearchById", id)
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("SearchById: Returning cache.")
|
|
return resp, nil
|
|
}
|
|
err := i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
"fields "+fieldsForSearch+"; where id = "+id+";",
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("SearchById: request failed!", "error", err)
|
|
return GameSearchResponse{}, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
return resp, nil
|
|
}
|
|
|
|
// Should return same details as `Search`, both are for search page only minimal details required.
|
|
func (i *IGDB) SearchBySlug(slug string) (GameSearchResponse, error) {
|
|
slog.Debug("SearchBySlug: Will search.", "slug", slug)
|
|
var resp GameSearchResponse
|
|
cacheKey := cache.CreateCacheKey("SearchBySlug", slug)
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("SearchBySlug: Returning cache.")
|
|
return resp, nil
|
|
}
|
|
err := i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
"fields "+fieldsForSearch+"; where slug = \""+slug+"\";",
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("SearchBySlug: Request failed!", "error", err)
|
|
return GameSearchResponse{}, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
return resp, nil
|
|
}
|
|
|
|
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
|
slog.Debug("GameDetails:", "id", id)
|
|
var resp []GameDetailsResponse
|
|
cacheKey := cache.CreateCacheKey("GameDetails", id)
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("GameDetails: Returning cache.")
|
|
if len(resp) > 0 {
|
|
return resp[0], nil
|
|
}
|
|
return GameDetailsResponse{}, errors.New("no game details recieved")
|
|
}
|
|
err := i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
`fields
|
|
name,
|
|
cover.image_id,
|
|
version_title,
|
|
summary,
|
|
storyline,
|
|
first_release_date,
|
|
artworks.width,
|
|
artworks.height,
|
|
artworks.image_id,
|
|
game_type,
|
|
platforms.name,
|
|
game_modes.name,
|
|
genres.id,
|
|
genres.name,
|
|
involved_companies.developer,
|
|
involved_companies.publisher,
|
|
involved_companies.porting,
|
|
involved_companies.supporting,
|
|
involved_companies.company.name,
|
|
involved_companies.company.description,
|
|
involved_companies.company.slug,
|
|
involved_companies.company.websites.type,
|
|
involved_companies.company.websites.trusted,
|
|
involved_companies.company.websites.url,
|
|
rating,
|
|
rating_count,
|
|
status,
|
|
url,
|
|
websites.trusted,
|
|
websites.type,
|
|
websites.url,
|
|
videos.name,
|
|
videos.video_id,
|
|
similar_games.id,
|
|
similar_games.name,
|
|
similar_games.summary,
|
|
similar_games.cover.image_id,
|
|
similar_games.first_release_date;
|
|
where id = `+id+";",
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("GameDetails: Request failed!", "error", err)
|
|
return GameDetailsResponse{}, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
if len(resp) > 0 {
|
|
return resp[0], nil
|
|
}
|
|
return GameDetailsResponse{}, errors.New("no game details recieved")
|
|
}
|
|
|
|
// Basic game details for when we are using them only to update our cache.
|
|
// In these cases, it's a waste to ask for everything, when we don't need it.
|
|
func (i *IGDB) GameDetailsBasic(id string) (GameDetailsBasicResponse, error) {
|
|
slog.Debug("GameDetailsBasic:", "id", id)
|
|
var resp []GameDetailsBasicResponse
|
|
cacheKey := cache.CreateCacheKey("GameDetailsBasic", id)
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("GameDetailsBasic: Returning cache.")
|
|
if len(resp) > 0 {
|
|
return resp[0], nil
|
|
}
|
|
return GameDetailsBasicResponse{}, errors.New("no game details recieved")
|
|
}
|
|
err := i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
`fields
|
|
name,
|
|
cover.image_id,
|
|
summary,
|
|
storyline,
|
|
first_release_date,
|
|
category,
|
|
platforms.name,
|
|
game_modes.name,
|
|
genres.name,
|
|
rating,
|
|
rating_count,
|
|
status;
|
|
where id = `+id+";",
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("IGDB GameDetails request failed!", "error", err)
|
|
return GameDetailsBasicResponse{}, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
if len(resp) > 0 {
|
|
return resp[0], nil
|
|
}
|
|
return GameDetailsBasicResponse{}, errors.New("no game details recieved")
|
|
}
|
|
|
|
// Getting top visited games in last 24hrs with popscore.
|
|
// There doesn't seem to be a way to get game details directly in this request
|
|
// so it must be combined with another.
|
|
func (i *IGDB) GetTopVisitedGameIds() (PopularityPrimitivesGameIdsResponse, error) {
|
|
slog.Debug("GetTopVisitedGameIds: Running.")
|
|
var resp PopularityPrimitivesGameIdsResponse
|
|
cacheKey := cache.CreateCacheKey("GetTopVisitedGameIds")
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("GetTopVisitedGameIds: Returning cache.")
|
|
return resp, nil
|
|
}
|
|
err := i.req(
|
|
igdbHost,
|
|
"/popularity_primitives",
|
|
map[string]string{},
|
|
`fields game_id;
|
|
sort value desc;
|
|
where popularity_type = 1;
|
|
limit 40;`,
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("GetTopVisitedGameIds: request failed!", "error", err)
|
|
return resp, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
if len(resp) <= 0 {
|
|
slog.Error("GetTopVisitedGameIds: We got zero results!"+
|
|
"Something must be wrong.",
|
|
"error", err)
|
|
return resp, errors.New("got zero results")
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// Trending games using popscores (which suck btw.. they dont expose that nice
|
|
// Popular Right Now list you can see on igdb homepage :( uh)
|
|
func (i *IGDB) Trending() (GameSearchResponse, error) {
|
|
slog.Debug("Trending: Running.")
|
|
var resp GameSearchResponse
|
|
|
|
// Get game ids.
|
|
ids, err := i.GetTopVisitedGameIds()
|
|
if err != nil {
|
|
return resp, errors.New("failed to get game ids")
|
|
}
|
|
|
|
// Get ids in a string we can pass to igdb.
|
|
idsStr := ""
|
|
for _, v := range ids {
|
|
idsStr = idsStr + strconv.Itoa(v.GameID) + ","
|
|
}
|
|
idsStr = strings.TrimSuffix(idsStr, ",")
|
|
|
|
cacheKey := cache.CreateCacheKey("Trending")
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("Trending: Returning cache.")
|
|
return resp, nil
|
|
}
|
|
err = i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
"fields "+fieldsForSearch+"; where id = ("+idsStr+"); limit 40;",
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("Trending: request failed!", "error", err)
|
|
return resp, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
return resp, nil
|
|
}
|
|
|
|
// Most hyped upcoming games
|
|
func (i *IGDB) Upcoming() (GameSearchResponse, error) {
|
|
slog.Debug("Upcoming: Running.")
|
|
var resp GameSearchResponse
|
|
cacheKey := cache.CreateCacheKey("Upcoming")
|
|
if cache.GetCache(GameStore, cacheKey, &resp) {
|
|
slog.Debug("Upcoming: Returning cache.")
|
|
return resp, nil
|
|
}
|
|
epochNow := strconv.Itoa(int(time.Now().Unix()))
|
|
err := i.req(
|
|
igdbHost,
|
|
"/games",
|
|
map[string]string{},
|
|
"fields "+fieldsForSearch+`;
|
|
sort hypes desc;
|
|
where first_release_date > `+epochNow+`;
|
|
limit 40;`,
|
|
&resp,
|
|
)
|
|
if err != nil {
|
|
slog.Error("Upcoming: request failed!", "error", err)
|
|
return resp, errors.New("request failed")
|
|
}
|
|
GameStore.Set(cacheKey, resp, time.Hour*24)
|
|
return resp, nil
|
|
}
|