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:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deezer uses Deezer's public catalogue API: no key, generous limits and
|
||||
// 1000px cover art.
|
||||
type deezer struct {
|
||||
r *Registry
|
||||
kind string // "album" or "song"
|
||||
}
|
||||
|
||||
func (d *deezer) Name() string { return "deezer" }
|
||||
func (d *deezer) Label() string { return "Deezer" }
|
||||
|
||||
type deezerErr struct {
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Deezer reports errors with a 200 status and an "error" object.
|
||||
func (e *deezerErr) check() error {
|
||||
if e.Error != nil {
|
||||
return fmt.Errorf("Deezer: %s", e.Error.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *deezer) get(ctx context.Context, path string, out any, e *deezerErr) error {
|
||||
if err := d.r.getJSON(ctx, "https://api.deezer.com"+path, nil, out); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.check()
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type deezerAlbum struct {
|
||||
deezerErr
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
RecordType string `json:"record_type"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Label string `json:"label"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Genres struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
type deezerTrack struct {
|
||||
deezerErr
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Duration int `json:"duration"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbum `json:"album"`
|
||||
}
|
||||
|
||||
func cover(a deezerAlbum) string {
|
||||
if a.CoverXL != "" {
|
||||
return a.CoverXL
|
||||
}
|
||||
return a.CoverBig
|
||||
}
|
||||
|
||||
func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
q := url.Values{"q": {query}, "limit": {"25"}}.Encode()
|
||||
var out []Result
|
||||
if d.kind == "album" {
|
||||
var resp struct {
|
||||
deezerErr
|
||||
Data []deezerAlbum `json:"data"`
|
||||
}
|
||||
if err := d.get(ctx, "/search/album?"+q, &resp, &resp.deezerErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range resp.Data {
|
||||
r := Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
|
||||
Title: a.Title,
|
||||
Subtitle: a.Artist.Name,
|
||||
ImageURL: cover(a),
|
||||
ExternalURL: a.Link,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if a.RecordType != "" && a.RecordType != "album" {
|
||||
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
|
||||
}
|
||||
if a.NbTracks > 0 {
|
||||
r.Overview = fmt.Sprintf("%d tracks", a.NbTracks)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var resp struct {
|
||||
deezerErr
|
||||
Data []deezerTrack `json:"data"`
|
||||
}
|
||||
if err := d.get(ctx, "/search/track?"+q, &resp, &resp.deezerErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range resp.Data {
|
||||
out = append(out, Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
|
||||
Title: t.Title,
|
||||
Subtitle: t.Artist.Name,
|
||||
ImageURL: cover(t.Album),
|
||||
ExternalURL: t.Link,
|
||||
Overview: "From " + t.Album.Title,
|
||||
Extra: map[string]string{"album": t.Album.Title},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
|
||||
kind, id, _ := strings.Cut(res.ProviderID, ":")
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
}
|
||||
switch kind {
|
||||
case "album":
|
||||
var a deezerAlbum
|
||||
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Year = yearOf(a.ReleaseDate)
|
||||
if a.Label != "" {
|
||||
res.Extra["label"] = a.Label
|
||||
}
|
||||
var g []string
|
||||
for _, x := range a.Genres.Data {
|
||||
g = append(g, x.Name)
|
||||
}
|
||||
if len(g) > 0 {
|
||||
res.Extra["genres"] = strings.Join(g, ", ")
|
||||
}
|
||||
case "track":
|
||||
var t deezerTrack
|
||||
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Year = yearOf(t.ReleaseDate)
|
||||
if res.Year == "" {
|
||||
res.Year = yearOf(t.Album.ReleaseDate)
|
||||
}
|
||||
if t.Duration > 0 {
|
||||
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// musicbrainz uses the MusicBrainz database with art from the Cover Art
|
||||
// Archive. Fully open data, but limited to one request per second.
|
||||
type musicbrainz struct {
|
||||
r *Registry
|
||||
kind string
|
||||
}
|
||||
|
||||
func (m *musicbrainz) Name() string { return "musicbrainz" }
|
||||
func (m *musicbrainz) Label() string { return "MusicBrainz" }
|
||||
|
||||
type mbCredit struct {
|
||||
Name string `json:"name"`
|
||||
JoinPhrase string `json:"joinphrase"`
|
||||
}
|
||||
|
||||
func mbArtists(credits []mbCredit) string {
|
||||
var b strings.Builder
|
||||
for _, c := range credits {
|
||||
b.WriteString(c.Name)
|
||||
b.WriteString(c.JoinPhrase)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
if err := m.r.mb.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Result
|
||||
if m.kind == "album" {
|
||||
var resp struct {
|
||||
ReleaseGroups []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
} `json:"release-groups"`
|
||||
}
|
||||
u := "https://musicbrainz.org/ws/2/release-group?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rg := range resp.ReleaseGroups {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "release-group:" + rg.ID,
|
||||
Title: rg.Title,
|
||||
Subtitle: mbArtists(rg.ArtistCredit),
|
||||
Year: yearOf(rg.FirstReleaseDate),
|
||||
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
|
||||
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
|
||||
r.Extra["type"] = rg.PrimaryType
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var resp struct {
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
Releases []struct {
|
||||
Title string `json:"title"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
} `json:"releases"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
u := "https://musicbrainz.org/ws/2/recording?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rec := range resp.Recordings {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "recording:" + rec.ID,
|
||||
Title: rec.Title,
|
||||
Subtitle: mbArtists(rec.ArtistCredit),
|
||||
Year: yearOf(rec.FirstReleaseDate),
|
||||
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// Prefer an album release for the artwork.
|
||||
for i, rel := range rec.Releases {
|
||||
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
|
||||
r.Extra["album"] = rel.Title
|
||||
r.Overview = "From " + rel.Title
|
||||
if rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec.Length > 0 {
|
||||
s := rec.Length / 1000
|
||||
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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])) + "…"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// tmdb searches The Movie Database. It accepts either a v3 API key or a v4
|
||||
// "API Read Access Token".
|
||||
type tmdb struct {
|
||||
r *Registry
|
||||
key string
|
||||
kind string // "movie" or "tv"
|
||||
}
|
||||
|
||||
func (t *tmdb) Name() string { return "tmdb" }
|
||||
func (t *tmdb) Label() string { return "TMDB" }
|
||||
|
||||
func (t *tmdb) get(ctx context.Context, path string, q url.Values, out any) error {
|
||||
if q == nil {
|
||||
q = url.Values{}
|
||||
}
|
||||
header := map[string]string{}
|
||||
if strings.HasPrefix(t.key, "eyJ") {
|
||||
header["Authorization"] = "Bearer " + t.key
|
||||
} else {
|
||||
q.Set("api_key", t.key)
|
||||
}
|
||||
err := t.r.getJSON(ctx, "https://api.themoviedb.org/3"+path+"?"+q.Encode(), header, out)
|
||||
if isStatus(err, 401) {
|
||||
return fmt.Errorf("TMDB rejected the API key; check it under Settings → Integrations")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func tmdbImage(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://image.tmdb.org/t/p/w780" + path
|
||||
}
|
||||
|
||||
type tmdbHit struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
}
|
||||
|
||||
func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
var resp struct {
|
||||
Results []tmdbHit `json:"results"`
|
||||
}
|
||||
q := url.Values{"query": {query}, "include_adult": {"false"}}
|
||||
if err := t.get(ctx, "/search/"+t.kind, q, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Result, 0, len(resp.Results))
|
||||
for _, h := range resp.Results {
|
||||
title, date := h.Title, h.ReleaseDate
|
||||
if t.kind == "tv" {
|
||||
title, date = h.Name, h.FirstAirDate
|
||||
}
|
||||
id := strconv.FormatInt(h.ID, 10)
|
||||
out = append(out, Result{
|
||||
Provider: "tmdb",
|
||||
ProviderID: t.kind + ":" + id,
|
||||
Title: title,
|
||||
Year: yearOf(date),
|
||||
ImageURL: tmdbImage(h.PosterPath),
|
||||
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
|
||||
Overview: truncate(h.Overview, 600),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
|
||||
_, id, ok := strings.Cut(res.ProviderID, ":")
|
||||
if !ok {
|
||||
return res, nil
|
||||
}
|
||||
var d struct {
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Runtime int `json:"runtime"`
|
||||
Networks []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"networks"`
|
||||
CreatedBy []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"created_by"`
|
||||
Credits struct {
|
||||
Crew []struct {
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
} `json:"credits"`
|
||||
}
|
||||
q := url.Values{}
|
||||
if t.kind == "movie" {
|
||||
q.Set("append_to_response", "credits")
|
||||
}
|
||||
if err := t.get(ctx, "/"+t.kind+"/"+id, q, &d); err != nil {
|
||||
return res, err
|
||||
}
|
||||
var names []string
|
||||
if t.kind == "movie" {
|
||||
for _, c := range d.Credits.Crew {
|
||||
if c.Job == "Director" {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, n := range d.Networks {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
res.Subtitle = strings.Join(names, ", ")
|
||||
}
|
||||
var genres []string
|
||||
for _, g := range d.Genres {
|
||||
genres = append(genres, g.Name)
|
||||
}
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
res.Extra["genres"] = strings.Join(genres, ", ")
|
||||
}
|
||||
if d.Runtime > 0 {
|
||||
res.Extra["runtime"] = fmt.Sprintf("%dh %02dm", d.Runtime/60, d.Runtime%60)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tvdb searches TheTVDB (API v4).
|
||||
type tvdb struct {
|
||||
r *Registry
|
||||
key string
|
||||
pin string
|
||||
}
|
||||
|
||||
type tvdbTokens struct {
|
||||
mu sync.Mutex
|
||||
credKey string
|
||||
token string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (t *tvdb) Name() string { return "tvdb" }
|
||||
func (t *tvdb) Label() string { return "TheTVDB" }
|
||||
|
||||
func (t *tvdb) token(ctx context.Context, fresh bool) (string, error) {
|
||||
c := t.r.tvdb
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
credKey := t.key + "\x00" + t.pin
|
||||
if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) {
|
||||
return c.token, nil
|
||||
}
|
||||
body := map[string]string{"apikey": t.key}
|
||||
if t.pin != "" {
|
||||
body["pin"] = t.pin
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api4.thetvdb.com/v4/login", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := t.r.do(req, &resp); err != nil {
|
||||
if isStatus(err, 401) {
|
||||
return "", fmt.Errorf("TheTVDB rejected the API key/PIN; check them under Settings → Integrations")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// Tokens are valid for a month; refresh daily to be safe.
|
||||
c.credKey, c.token, c.expires = credKey, resp.Data.Token, time.Now().Add(24*time.Hour)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
TVDBID string `json:"tvdb_id"`
|
||||
Name string `json:"name"`
|
||||
Year string `json:"year"`
|
||||
FirstAired string `json:"first_air_time"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Overview string `json:"overview"`
|
||||
Network string `json:"network"`
|
||||
Slug string `json:"slug"`
|
||||
Translations map[string]string `json:"translations"`
|
||||
Overviews map[string]string `json:"overviews"`
|
||||
} `json:"data"`
|
||||
}
|
||||
u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode()
|
||||
for attempt := 0; ; attempt++ {
|
||||
tok, err := t.token(ctx, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, &resp)
|
||||
if isStatus(err, 401) && attempt == 0 {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
out := make([]Result, 0, len(resp.Data))
|
||||
for _, s := range resp.Data {
|
||||
name := s.Name
|
||||
if eng := s.Translations["eng"]; eng != "" {
|
||||
name = eng
|
||||
}
|
||||
overview := s.Overview
|
||||
if eng := s.Overviews["eng"]; eng != "" {
|
||||
overview = eng
|
||||
}
|
||||
year := s.Year
|
||||
if year == "" {
|
||||
year = yearOf(s.FirstAired)
|
||||
}
|
||||
img := s.ImageURL
|
||||
if img == "" {
|
||||
img = s.Thumbnail
|
||||
}
|
||||
ext := "https://thetvdb.com/?tab=series&id=" + s.TVDBID
|
||||
if s.Slug != "" {
|
||||
ext = "https://thetvdb.com/series/" + s.Slug
|
||||
}
|
||||
out = append(out, Result{
|
||||
Provider: "tvdb",
|
||||
ProviderID: s.TVDBID,
|
||||
Title: name,
|
||||
Subtitle: s.Network,
|
||||
Year: year,
|
||||
ImageURL: img,
|
||||
ExternalURL: ext,
|
||||
Overview: truncate(overview, 600),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// wikipedia finds stage works (musicals, plays, operas) on English
|
||||
// Wikipedia. There's no free, open theater database with an API, and
|
||||
// Wikipedia has the best coverage plus poster/logo art for most shows. The
|
||||
// specific production (Broadway, a tour, …) is chosen by the owner.
|
||||
type wikipedia struct {
|
||||
r *Registry
|
||||
}
|
||||
|
||||
func (w *wikipedia) Name() string { return "wikipedia" }
|
||||
func (w *wikipedia) Label() string { return "Wikipedia" }
|
||||
|
||||
const theaterTemplates = `hastemplate:"Infobox musical|Infobox play|Infobox opera"`
|
||||
|
||||
func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
res, err := w.search(ctx, query+" "+theaterTemplates)
|
||||
if err == nil && len(res) == 0 {
|
||||
res, err = w.search(ctx, query)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
var leadingYear = regexp.MustCompile(`^(1[5-9]\d\d|20\d\d)\s+`)
|
||||
|
||||
func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error) {
|
||||
q := url.Values{
|
||||
"action": {"query"},
|
||||
"format": {"json"},
|
||||
"formatversion": {"2"},
|
||||
"generator": {"search"},
|
||||
"gsrsearch": {query},
|
||||
"gsrlimit": {"15"},
|
||||
"prop": {"pageimages|description|info|extracts"},
|
||||
"piprop": {"thumbnail"},
|
||||
"pithumbsize": {"800"},
|
||||
"pilicense": {"any"},
|
||||
"inprop": {"url"},
|
||||
"exintro": {"1"},
|
||||
"explaintext": {"1"},
|
||||
"exsentences": {"3"},
|
||||
"exlimit": {"max"},
|
||||
}
|
||||
var resp struct {
|
||||
Query struct {
|
||||
Pages []struct {
|
||||
PageID int64 `json:"pageid"`
|
||||
Title string `json:"title"`
|
||||
Index int `json:"index"`
|
||||
Description string `json:"description"`
|
||||
Extract string `json:"extract"`
|
||||
FullURL string `json:"fullurl"`
|
||||
Thumbnail struct {
|
||||
Source string `json:"source"`
|
||||
} `json:"thumbnail"`
|
||||
} `json:"pages"`
|
||||
} `json:"query"`
|
||||
}
|
||||
if err := w.r.getJSON(ctx, "https://en.wikipedia.org/w/api.php?"+q.Encode(), nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages := resp.Query.Pages
|
||||
sort.Slice(pages, func(i, j int) bool { return pages[i].Index < pages[j].Index })
|
||||
out := make([]Result, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
title := p.Title
|
||||
// "Hamilton (musical)" → "Hamilton"
|
||||
if i := strings.LastIndex(title, " ("); i > 0 && strings.HasSuffix(title, ")") {
|
||||
title = title[:i]
|
||||
}
|
||||
desc := leadingYear.ReplaceAllString(p.Description, "")
|
||||
if desc != "" {
|
||||
desc = strings.ToUpper(desc[:1]) + desc[1:]
|
||||
}
|
||||
img := p.Thumbnail.Source
|
||||
if i := strings.Index(img, "?"); i > 0 {
|
||||
img = img[:i]
|
||||
}
|
||||
out = append(out, Result{
|
||||
Provider: "wikipedia",
|
||||
ProviderID: strconv.FormatInt(p.PageID, 10),
|
||||
Title: title,
|
||||
Subtitle: desc,
|
||||
Year: yearOf(p.Description),
|
||||
ImageURL: img,
|
||||
ExternalURL: p.FullURL,
|
||||
Overview: truncate(p.Extract, 600),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user