// 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])) + "…" }