Add optional MDBList movie ratings
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Movie resolves a movie through one of the identifier providers accepted by MDBList.
|
||||
// The single-title endpoint returns every rating source available for that title in one
|
||||
// request, which lets source selection happen locally without spending more API quota.
|
||||
func (c *Client) Movie(
|
||||
ctx context.Context, apiKey, provider, providerID string,
|
||||
) ([]Rating, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if apiKey == "" || providerID == "" || (provider != "tmdb" && provider != "imdb") {
|
||||
return nil, fmt.Errorf("mdblist: invalid movie lookup")
|
||||
}
|
||||
endpoint, err := url.Parse(c.baseURL + "/" + provider + "/movie/" + 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 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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package mdblist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMovieReadsAllAvailableNumericRatings(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tmdb/movie/278/" || r.URL.Query().Get("apikey") != "secret" {
|
||||
t.Fatalf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ratings":[
|
||||
{"source":"imdb","value":9.3,"score":93},
|
||||
{"source":"letterboxd","value":"4.6"},
|
||||
{"source":"metacritic","value":null},
|
||||
{"source":"tomatoes","value":"not available"}
|
||||
]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
got, err := New(server.URL, time.Second).Movie(context.Background(), "secret", "tmdb", "278")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Source != "imdb" || got[0].Value != 9.3 ||
|
||||
got[1].Source != "letterboxd" || got[1].Value != 4.6 {
|
||||
t.Fatalf("ratings = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovieRejectsUnsupportedProviderBeforeCallingUpstream(t *testing.T) {
|
||||
_, err := New("https://example.invalid", time.Second).
|
||||
Movie(context.Background(), "secret", "tvdb", "42")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported provider to fail")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user