A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
3.9 KiB
Go
142 lines
3.9 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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.company.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 []struct {
|
|
Developer bool `json:"developer"`
|
|
Company struct {
|
|
Name string `json:"name"`
|
|
} `json:"company"`
|
|
} `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 devs, plats []string
|
|
for _, ic := range gm.InvolvedCompanies {
|
|
if ic.Developer && ic.Company.Name != "" {
|
|
devs = append(devs, ic.Company.Name)
|
|
}
|
|
}
|
|
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(devs, ", "),
|
|
ExternalURL: gm.URL,
|
|
Overview: truncate(gm.Summary, 600),
|
|
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
|
|
}
|