Files
silo-server/internal/markers/introdb/client.go
T
Silo Server Migration d41ffd4d5e Add live introdb key reload and recap playback markers
- reload introdb API key on setting updates
- support recap/preview markers in playback and next-episode flow
- add profile defaults for recap and preview auto-play settings
2026-05-24 00:14:17 -04:00

226 lines
6.5 KiB
Go

package introdb
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/cache"
"golang.org/x/time/rate"
)
const (
maxRetries = 3
maxResponseBody = 1 << 20 // 1 MB
defaultTimeout = 15 * time.Second
defaultCacheTTL = 24 * time.Hour
)
// Client is an HTTP client for the TheIntroDB /v3/media endpoint. Each
// instance has its own rate limiter and response cache; concurrent fetches
// for the same lookup key collapse to a single HTTP round trip via the cache.
type Client struct {
httpClient *http.Client
mu sync.RWMutex
apiKey string
baseURL string
limiter *rate.Limiter
cache *cache.TTLCache[*mediaResponse]
cacheTTL time.Duration
}
// NewClient builds a Client with the canonical rate limit and cache TTL.
// The apiKey may be empty — TheIntroDB serves read traffic without a key,
// the key only gates access to the caller's own pending submissions.
func NewClient(apiKey string) *Client {
return &Client{
httpClient: &http.Client{Timeout: defaultTimeout},
apiKey: strings.TrimSpace(apiKey),
baseURL: DefaultBaseURL,
// TheIntroDB documents 30 requests / 10 seconds per IP. We stay
// conservatively below that: 2 req/s sustained, burst 5.
limiter: rate.NewLimiter(2, 5),
cache: cache.NewTTLCache[*mediaResponse](),
cacheTTL: defaultCacheTTL,
}
}
// SetBaseURL overrides the API base URL (used by tests).
func (c *Client) SetBaseURL(u string) {
c.mu.Lock()
c.baseURL = u
c.mu.Unlock()
}
// SetAPIKey rotates the bearer token in-place. Safe to call concurrently
// with in-flight requests; subsequent requests use the new key.
func (c *Client) SetAPIKey(apiKey string) {
c.mu.Lock()
c.apiKey = strings.TrimSpace(apiKey)
c.mu.Unlock()
}
// Close releases the background sweeper goroutine inside the response cache.
func (c *Client) Close() {
if c.cache != nil {
c.cache.Close()
}
}
// FetchEpisode looks up segment timestamps for a TV episode.
// At least one of tmdbID or imdbID must be non-empty.
func (c *Client) FetchEpisode(ctx context.Context, tmdbID, imdbID string, season, episode int, durationMS int64) (*mediaResponse, error) {
if tmdbID == "" && imdbID == "" {
return nil, fmt.Errorf("introdb: tmdb_id or imdb_id required")
}
if season <= 0 || episode <= 0 {
return nil, fmt.Errorf("introdb: episode lookup requires season and episode > 0 (got %d/%d)", season, episode)
}
q := url.Values{}
if tmdbID != "" {
q.Set("tmdb_id", tmdbID)
} else {
q.Set("imdb_id", imdbID)
}
q.Set("season", strconv.Itoa(season))
q.Set("episode", strconv.Itoa(episode))
if durationMS > 0 {
q.Set("duration_ms", strconv.FormatInt(durationMS, 10))
}
return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, imdbID, season, episode, durationMS))
}
// FetchMovie looks up segment timestamps for a movie.
// At least one of tmdbID or imdbID must be non-empty.
func (c *Client) FetchMovie(ctx context.Context, tmdbID, imdbID string, durationMS int64) (*mediaResponse, error) {
if tmdbID == "" && imdbID == "" {
return nil, fmt.Errorf("introdb: tmdb_id or imdb_id required")
}
q := url.Values{}
if tmdbID != "" {
q.Set("tmdb_id", tmdbID)
} else {
q.Set("imdb_id", imdbID)
}
if durationMS > 0 {
q.Set("duration_ms", strconv.FormatInt(durationMS, 10))
}
return c.fetch(ctx, q, cacheKeyMovie(tmdbID, imdbID, durationMS))
}
func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaResponse, error) {
if cached, ok := c.cache.Get(key); ok {
return cached, nil
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
c.mu.RLock()
baseURL := c.baseURL
apiKey := c.apiKey
c.mu.RUnlock()
reqURL := baseURL + "/media?" + q.Encode()
for attempt := 0; attempt <= maxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("introdb: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Silo-Server/markers")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("introdb: request failed: %w", err)
}
if resp.StatusCode == http.StatusNotFound {
resp.Body.Close()
// Cache negatives too so the next playback start doesn't trigger
// another fetch for known-empty content.
c.cache.Set(key, nil, c.cacheTTL)
return nil, nil
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
if attempt < maxRetries {
backoff := retryAfterOrDefault(resp, attempt)
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
continue
}
return nil, fmt.Errorf("introdb: rate limited after %d retries", maxRetries)
}
if resp.StatusCode >= 500 {
resp.Body.Close()
if attempt < maxRetries {
backoff := time.Duration(1<<attempt) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
continue
}
return nil, fmt.Errorf("introdb: server error %d after %d retries", resp.StatusCode, maxRetries)
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
resp.Body.Close()
return nil, fmt.Errorf("introdb: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var out mediaResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out)
resp.Body.Close()
if decodeErr != nil {
return nil, fmt.Errorf("introdb: decode response: %w", decodeErr)
}
c.cache.Set(key, &out, c.cacheTTL)
return &out, nil
}
return nil, fmt.Errorf("introdb: max retries exceeded")
}
func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration {
if val := resp.Header.Get("Retry-After"); val != "" {
if secs, err := strconv.Atoi(val); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func cacheKeyEpisode(tmdbID, imdbID string, season, episode int, durationMS int64) string {
if tmdbID != "" {
return fmt.Sprintf("tmdb:%s:s%de%d:d%d", tmdbID, season, episode, durationMS)
}
return fmt.Sprintf("imdb:%s:s%de%d:d%d", imdbID, season, episode, durationMS)
}
func cacheKeyMovie(tmdbID, imdbID string, durationMS int64) string {
if tmdbID != "" {
return fmt.Sprintf("tmdb:movie:%s:d%d", tmdbID, durationMS)
}
return fmt.Sprintf("imdb:movie:%s:d%d", imdbID, durationMS)
}