Initial TopDeck: self-hosted ranked favorites

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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 16:54:19 -07:00
commit e9a75a968a
53 files changed
+7078

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
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
}