# TMDB API Integration Pattern

## Overview

The Movie Database (TMDB) v3 API is the most common metadata/catalog provider used in streaming-adjacent backends. This doc captures reusable patterns for wrapping TMDB behind a Clean Architecture port.

## Auth: API Key vs Access Token

TMDB supports two auth methods:

- **API Key** (`api_key` query param) — simple, rate-limited, non-preferred
- **Access Token** (`Authorization: Bearer <token>` header) — required for some endpoints, better rate limits

**Implementation pattern:** attempt Bearer token first, fall back to `api_key` query param:

```python
def _get_headers(self) -> dict[str, str]:
    if self._access_token:
        return {"Authorization": f"Bearer {self._access_token}"}
    return {}

def _get_params(self, **kwargs: Any) -> dict[str, Any]:
    params = dict(kwargs)
    if not self._access_token:
        params["api_key"] = self._api_key
    return params
```

**Configuration:**
```python
TMDB_API_KEY: str = ""       # Fallback API key
TMDB_ACCESS_TOKEN: str = ""   # Preferred Bearer token
TMDB_LANGUAGE: str = "en-US"  # Default language
```

## Key Endpoints

| Purpose | Endpoint | Notes |
|---------|----------|-------|
| Search | `GET /search/{movie,tv,person}` | `?query=...&page=...&language=...` |
| Details | `GET /{movie,tv}/{id}` | Add `?append_to_response=external_ids,credits,videos` for rich data |
| Trending | `GET /trending/{movie,tv,person}/{day,week}` | No page param in basic version |
| Genres | `GET /genre/{movie,tv}/list` | Returns `{"genres": [{"id": ..., "name": ...}]}` |

## Response Mapping Patterns

### Search Results

TMDB returns page-based results. Map to a `SearchResult` domain entity:

```python
def _map_search_result(data: dict, media_type: MediaType) -> SearchResult:
    results = data.get("results", [])
    items = [self._map_media_item(r, media_type) for r in results]
    return SearchResult(
        items=items,
        total_results=int(data.get("total_results", 0)),
        page=int(data.get("page", 1)),
        total_pages=int(data.get("total_pages", 0)),
    )
```

### Media Item (Single)

The search response uses `media_type` field on each result. The details response doesn't — it's inferred from the endpoint path.

**Key field differences between movie and TV:**
- Movie: `title`, `release_date`, `runtime`, `budget`, `revenue`
- TV: `name`, `first_air_date`, `number_of_seasons`, `number_of_episodes`

**Mapping sanity check:** `data.get("title") or data.get("name", "Unknown")` handles both.

### External IDs

Available via `?append_to_response=external_ids` on the details endpoint:

```python
ext_ids = data.get("external_ids", {})
external_ids = ExternalIds(
    imdb_id=ext_ids.get("imdb_id"),
    facebook_id=ext_ids.get("facebook_id"),
    instagram_id=ext_ids.get("instagram_id"),
    twitter_id=ext_ids.get("twitter_id"),
)
```

## Error Status Code Mapping

| TMDB Status | Exception | HTTP Code | Public Message |
|-------------|-----------|-----------|----------------|
| 401 | `ProviderAuthenticationError` | 502 | "TMDB authentication failed" |
| 404 | `ItemNotFoundError` | 404 | "TMDB resource not found" |
| 429 | `ProviderRateLimitError` | 429 | "TMDB rate limit exceeded" |
| 5xx | `ProviderConnectionError` | 502 | "TMDB server error (N)" |
| Timeout | `ProviderConnectionError` | 502 | "TMDB [endpoint] timed out" |
| Unknown | `MetadataError` | 502 | "TMDB [endpoint] failed" |

## Testing with MockTransport

**Critical: the mock client MUST have `base_url` set to `https://api.themoviedb.org/3`** — httpx's cookie jar crashes on relative URLs.

```python
@pytest.fixture
def mocked_client() -> TMDbClient:
    client = TMDbClient()
    transport = httpx.MockTransport(_mock_handler)
    await client._client.aclose()
    client._client = httpx.AsyncClient(
        transport=transport,
        base_url="https://api.themoviedb.org/3",  # Must match production
        timeout=10.0,
    )
    return client
```

### Mock Handler Pattern

```python
def _mock_handler(responses: dict[str, object]) -> httpx.Request:
    """Returns a callable that matches URL prefixes to canned responses."""

    async def handler(request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        for prefix, response_data in responses.items():
            if prefix in url:
                if isinstance(response_data, Exception):
                    raise response_data
                return httpx.Response(200, json=response_data)
        return httpx.Response(404)

    return handler
```

### Test Coverage Matrix

| Test | What it verifies |
|------|-----------------|
| Response mapping | Domain entities populated correctly from API JSON |
| Partial data | Missing/null fields produce None, not crashes |
| 401 error | ProviderAuthenticationError raised |
| 429 error | ProviderRateLimitError raised |
| 5xx error | ProviderConnectionError raised |
| Timeout | Wrapped in ProviderConnectionError |
| Not found (details) | Returns None, not exception |
| Genre response shape | List of dicts with id/name keys |

## Pitfalls

### append_to_response Only Works on Details

`?append_to_response=external_ids,credits,videos` is only valid on `GET /{movie,tv}/{id}`. The search and trending endpoints don't support it.

### media_type Is Inconsistent

Search/trending results include a `media_type` field on each result. Details endpoint does NOT — you must track the content type from the request. Use the caller's `media_type` parameter as fallback:

```python
detected_type = data.get("media_type", media_type.value)
```

### Pagination Is 1-Based and Capped

TMDB paginates from page 1, maximum page 500 (or 1000 for some endpoints). Always clamp:
```python
page = max(1, min(page, 500))
```

### Language Fallback Behavior

When `language` is omitted, TMDB returns English (not a configurable default). Explicitly pass `language or "en-US"` to use your default.
