Files
silo-server/internal/mdblist/discovery.go
T

130 lines
3.9 KiB
Go

// Package mdblist is a client for MDBList's public list discovery endpoints
// (/lists/search and /lists/top). These endpoints require an apikey, unlike
// fetching a list's items as JSON which does not.
package mdblist
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const defaultBaseURL = "https://api.mdblist.com"
// ErrNotConfigured is returned when no MDBList apikey has been configured.
var ErrNotConfigured = errors.New("mdblist apikey is not configured")
// ListSummary mirrors the shape returned by /lists/search and /lists/top.
type ListSummary struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
UserName string `json:"user_name"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
MediaType string `json:"mediatype"`
Items int `json:"items"`
Likes int `json:"likes"`
// URL is the canonical mdblist.com list page; append "/json" to get the
// importable feed.
URL string `json:"url,omitempty"`
}
// Client wraps a single MDBList apikey. Construct one per server config; it
// is safe for concurrent use because http.Client is.
type Client struct {
apiKey string
baseURL string
http *http.Client
}
// NewClient returns a discovery client. apiKey may be empty — the Search/Top
// methods will then return ErrNotConfigured rather than calling out.
func NewClient(apiKey string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 10 * time.Second}
}
return &Client{
apiKey: strings.TrimSpace(apiKey),
baseURL: defaultBaseURL,
http: httpClient,
}
}
// Configured reports whether the client has an apikey set.
func (c *Client) Configured() bool {
return c.apiKey != ""
}
// Search returns lists whose title matches query.
func (c *Client) Search(ctx context.Context, query string) ([]ListSummary, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("query is required")
}
q := url.Values{}
q.Set("apikey", c.apiKey)
q.Set("query", query)
return c.fetchLists(ctx, "/lists/search", q)
}
// Top returns the public top lists ranked by Trakt likes.
func (c *Client) Top(ctx context.Context) ([]ListSummary, error) {
q := url.Values{}
q.Set("apikey", c.apiKey)
return c.fetchLists(ctx, "/lists/top", q)
}
func (c *Client) fetchLists(ctx context.Context, path string, q url.Values) ([]ListSummary, error) {
if !c.Configured() {
return nil, ErrNotConfigured
}
u := c.baseURL + path + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("creating mdblist request: %w", err)
}
res, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("calling mdblist: %w", err)
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("mdblist rejected apikey (status %d)", res.StatusCode)
}
if res.StatusCode == http.StatusTooManyRequests {
return nil, fmt.Errorf("mdblist rate limit exceeded")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("mdblist request failed with status %d", res.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("reading mdblist response: %w", err)
}
var lists []ListSummary
if err := json.Unmarshal(body, &lists); err != nil {
return nil, fmt.Errorf("parsing mdblist response: %w", err)
}
for i := range lists {
lists[i].URL = canonicalListURL(lists[i].UserName, lists[i].Slug)
}
return lists, nil
}
// canonicalListURL returns the public mdblist.com page URL for a list. The
// JSON variant used by sync simply appends "/json".
func canonicalListURL(user, slug string) string {
if user == "" || slug == "" {
return ""
}
return fmt.Sprintf("https://mdblist.com/lists/%s/%s", user, slug)
}