IGDB allows 4 requests a second, and an import searching three entries at once (plus detail lookups) went over it. Every IGDB request now waits at least 300 ms after the last one, and a 429 is retried after 1, 2 and 4 seconds before giving up. The import dialog, ranking editor and their error messages are reworded in plainer language, and talk about "spots" instead of "places". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
6.2 KiB
Go
223 lines
6.2 KiB
Go
// 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"
|
|
)
|
|
|
|
// maxDescription caps how much of a source's description is kept.
|
|
const maxDescription = 2000
|
|
|
|
const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)"
|
|
|
|
// 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"` // the source's description, saved with the item
|
|
Hint string `json:"hint"` // extra context for search results only, e.g. "12 tracks"
|
|
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. Results from
|
|
// another provider are returned unchanged.
|
|
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 // "both" (default), "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
|
|
// IGDB allows 4 requests a second; stay under it so an import's
|
|
// searches and detail lookups don't get answered with 429.
|
|
igdbPace *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},
|
|
igdbPace: &throttle{gap: 300 * 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":
|
|
switch c.MusicProvider {
|
|
case "deezer":
|
|
return &deezer{r: r, kind: category}, nil
|
|
case "musicbrainz":
|
|
return &musicbrainz{r: r, kind: category}, nil
|
|
}
|
|
return &bothMusic{dz: &deezer{r: r, kind: category}, mb: &musicbrainz{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, stage: true}, nil
|
|
case "custom":
|
|
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])) + "…"
|
|
}
|