// Package mdblist reads third-party movie ratings without exposing the operator's // MDBList API key to a television. package mdblist import ( "context" "encoding/json" "fmt" "io" "math" "net/http" "net/url" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/timing" ) const DefaultBaseURL = "https://api.mdblist.com" type Client struct { baseURL string http *http.Client } // Rating is one value exactly as returned by MDBList. Presentation names and scales // are assigned by the gateway, where the operator's selected sources also live. type Rating struct { Source string `json:"source"` Value float64 `json:"value"` } type mediaResponse struct { Ratings []rawRating `json:"ratings"` } type rawRating struct { Source string `json:"source"` Value json.RawMessage `json:"value"` } type APIError struct { StatusCode int Body string } func (e *APIError) Error() string { return fmt.Sprintf("mdblist: status %d: %s", e.StatusCode, e.Body) } func New(baseURL string, timeout time.Duration) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), http: timing.Instrument(&http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 20, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, }, timing.StageMDBList), } } // Media resolves a movie or series through one of the identifier providers accepted by // MDBList. The single-title endpoint returns every rating source in one request, which // lets source selection happen locally without spending more API quota. func (c *Client) Media( ctx context.Context, apiKey, provider, providerID, mediaType string, ) ([]Rating, error) { apiKey = strings.TrimSpace(apiKey) provider = strings.ToLower(strings.TrimSpace(provider)) providerID = strings.TrimSpace(providerID) mediaType = strings.ToLower(strings.TrimSpace(mediaType)) if mediaType == "series" || mediaType == "tv" { mediaType = "show" } if apiKey == "" || providerID == "" || (provider != "tmdb" && provider != "imdb") || (mediaType != "movie" && mediaType != "show") { return nil, fmt.Errorf("mdblist: invalid media lookup") } endpoint, err := url.Parse(c.baseURL + "/" + provider + "/" + mediaType + "/" + url.PathEscape(providerID) + "/") if err != nil { return nil, fmt.Errorf("mdblist: build request: %w", err) } query := endpoint.Query() query.Set("apikey", apiKey) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, fmt.Errorf("mdblist: build request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "Memby gateway") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("mdblist: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} } var media mediaResponse if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&media); err != nil { return nil, fmt.Errorf("mdblist: decode response: %w", err) } result := make([]Rating, 0, len(media.Ratings)) for _, candidate := range media.Ratings { value, ok := numericValue(candidate.Value) if !ok || strings.TrimSpace(candidate.Source) == "" { continue } result = append(result, Rating{Source: candidate.Source, Value: value}) } return result, nil } func (c *Client) Movie(ctx context.Context, apiKey, provider, providerID string) ([]Rating, error) { return c.Media(ctx, apiKey, provider, providerID, "movie") } func numericValue(raw json.RawMessage) (float64, bool) { text := strings.TrimSpace(string(raw)) if text == "" || text == "null" { return 0, false } if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' { var value string if json.Unmarshal(raw, &value) != nil { return 0, false } text = strings.TrimSpace(value) } value, err := strconv.ParseFloat(text, 64) if err != nil || math.IsNaN(value) || math.IsInf(value, 0) { return 0, false } return value, true }