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

+208
View File
@@ -0,0 +1,208 @@
// Package providers searches external metadata services for things to rank.
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const UserAgent = "TopDeck/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/topdeck)"
// Result is one search hit, already shaped like a list item.
type Result struct {
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
ImageURL string `json:"imageUrl"`
ExternalURL string `json:"externalUrl"`
Overview string `json:"overview"`
Extra map[string]string `json:"extra,omitempty"`
}
type Searcher interface {
// Name is the stable id stored with items, e.g. "tmdb".
Name() string
// Label is the human-readable service name.
Label() string
Search(ctx context.Context, query string) ([]Result, error)
}
// Detailer is implemented by searchers that can fill in more metadata (a
// director, a release year) once the owner picks a result.
type Detailer interface {
Details(ctx context.Context, r Result) (Result, error)
}
// NotConfiguredError means the owner hasn't supplied credentials yet.
type NotConfiguredError struct{ Service, Hint string }
func (e NotConfiguredError) Error() string {
return fmt.Sprintf("%s isn't set up yet. %s", e.Service, e.Hint)
}
// Credentials are resolved fresh for every search so settings changes apply
// immediately.
type Credentials struct {
TMDBKey string
TVDBKey string
TVDBPin string
IGDBClientID string
IGDBClientSecret string
MusicProvider string // "deezer" or "musicbrainz"
TVProvider string // "tvdb" or "tmdb"
}
type Registry struct {
creds func(context.Context) Credentials
client *http.Client
tvdb *tvdbTokens
igdb *igdbTokens
mb *throttle
}
func NewRegistry(creds func(context.Context) Credentials) *Registry {
return &Registry{
creds: creds,
client: &http.Client{Timeout: 20 * time.Second},
tvdb: &tvdbTokens{},
igdb: &igdbTokens{},
mb: &throttle{gap: 1100 * time.Millisecond},
}
}
// For returns the searcher for a category.
func (r *Registry) For(ctx context.Context, category string) (Searcher, error) {
c := r.creds(ctx)
switch category {
case "movie":
if c.TMDBKey == "" {
return nil, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations."}
}
return &tmdb{r: r, key: c.TMDBKey, kind: "movie"}, nil
case "tv":
if c.TVProvider == "tmdb" {
if c.TMDBKey == "" {
return nil, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations, or switch TV shows back to TheTVDB."}
}
return &tmdb{r: r, key: c.TMDBKey, kind: "tv"}, nil
}
if c.TVDBKey == "" {
return nil, NotConfiguredError{"TheTVDB", "Add a TheTVDB API key under Settings → Integrations."}
}
return &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}, nil
case "album", "song":
if c.MusicProvider == "musicbrainz" {
return &musicbrainz{r: r, kind: category}, nil
}
return &deezer{r: r, kind: category}, nil
case "game":
if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
}
return &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}, nil
case "theater":
return &wikipedia{r: r}, nil
}
return nil, fmt.Errorf("no provider for category %q", category)
}
// ---- HTTP helpers ----
type httpError struct {
Status int
Body string
}
func (e *httpError) Error() string {
return fmt.Sprintf("upstream returned %d: %s", e.Status, e.Body)
}
func (r *Registry) do(req *http.Request, out any) error {
req.Header.Set("User-Agent", UserAgent)
if req.Header.Get("Accept") == "" {
req.Header.Set("Accept", "application/json")
}
resp, err := r.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snippet := strings.TrimSpace(string(body))
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
return &httpError{Status: resp.StatusCode, Body: snippet}
}
if out == nil {
return nil
}
return json.Unmarshal(body, out)
}
func (r *Registry) getJSON(ctx context.Context, url string, header map[string]string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
for k, v := range header {
req.Header.Set(k, v)
}
return r.do(req, out)
}
func isStatus(err error, code int) bool {
var he *httpError
return errors.As(err, &he) && he.Status == code
}
// throttle spaces out calls for services with strict rate limits.
type throttle struct {
mu sync.Mutex
gap time.Duration
last time.Time
}
func (t *throttle) wait(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if d := t.gap - time.Since(t.last); d > 0 {
select {
case <-time.After(d):
case <-ctx.Done():
return ctx.Err()
}
}
t.last = time.Now()
return nil
}
var yearRe = regexp.MustCompile(`\b(1[5-9]\d\d|20\d\d)\b`)
// yearOf pulls the first plausible year out of a date or free text.
func yearOf(s string) string {
return yearRe.FindString(s)
}
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if len([]rune(s)) <= n {
return s
}
return strings.TrimSpace(string([]rune(s)[:n])) + "…"
}