# @media-sdk/core

Headless media clients for Pexels and Pixabay. Provides typed API access, in-memory caching with request deduplication, and explicit user-action event tracking.

## Installation

```bash
pnpm add @media-sdk/core
```

## Quick Start

```ts
import {
  ApiKeyProvider,
  PexelsMediaClient,
} from "@media-sdk/core";

const auth = new ApiKeyProvider(process.env.PEXELS_API_KEY!);
const client = new PexelsMediaClient(auth);

const result = await client.searchPhotos({
  query: "nature",
  page: 1,
  perPage: 20,
});

console.log(result.items);
console.log(result.pagination);
```

## Pixabay Provider

`PixabayMediaClient` implements the same `MediaClient` interface using the Pixabay API. Authentication uses a `key=` query parameter (not an Authorization header).

```ts
import {
  ApiKeyProvider,
  PixabayMediaClient,
} from "@media-sdk/core";

const auth = new ApiKeyProvider(process.env.PIXABAY_API_KEY!);
const client = new PixabayMediaClient(auth);

const photos = await client.searchPhotos({
  query: "nature",
  page: 1,
  perPage: 20,
});

const videos = await client.searchVideos({
  query: "ocean",
  page: 1,
  perPage: 15,
});

const photo = await client.getPhoto(12345);
const video = await client.getVideo(67890);
```

### Pixabay vs Pexels

| Capability | Pexels | Pixabay |
|------------|--------|---------|
| Photo search | ✅ | ✅ |
| Video search | ✅ | ✅ |
| Get photo | ✅ | ✅ |
| Get video | ✅ | ✅ |
| Pagination | ✅ | ✅ |
| Cancellation | ✅ | ✅ |
| Events | ✅ | ✅ |
| Curated photos | ✅ | ❌ |

`getCuratedPhotos()` on `PixabayMediaClient` throws a `MediaError` with code `UNSUPPORTED_CAPABILITY`. Use `PexelsMediaClient` for curated content.

## Capabilities

Each SDK client exposes a static `capabilities` property describing supported operations and search filters. Use `getCapabilities(client)` to read capabilities from any `MediaClient` — custom implementations without `capabilities` receive an all-false fallback.

```ts
import {
  ApiKeyProvider,
  getCapabilities,
  PexelsMediaClient,
  PixabayMediaClient,
} from "@media-sdk/core";

const pexels = new PexelsMediaClient(new ApiKeyProvider(process.env.PEXELS_API_KEY!));
const pixabay = new PixabayMediaClient(new ApiKeyProvider(process.env.PIXABAY_API_KEY!));

const pexelsCaps = getCapabilities(pexels);
const pixabayCaps = getCapabilities(pixabay);

if (pexelsCaps.operations.curatedPhotos) {
  await pexels.getCuratedPhotos({ page: 1, perPage: 20 });
}

// pixabayCaps.operations.curatedPhotos === false — guard before calling
```

### Operation capabilities

| Key | Pexels | Pixabay |
| --- | --- | --- |
| `searchPhotos` | ✅ | ✅ |
| `searchVideos` | ✅ | ✅ |
| `getPhoto` | ✅ | ✅ |
| `getVideo` | ✅ | ✅ |
| `curatedPhotos` | ✅ | ❌ |
| `trackView` | ✅ | ✅ |
| `trackDownload` | ✅ | ✅ |

### Filter capabilities

Filters are capability-gated separately for photos and videos.

| Filter | Pexels photos | Pexels videos | Pixabay photos | Pixabay videos |
| --- | --- | --- | --- | --- |
| `orientation` | ✅ | ✅ | ✅ | ❌ |
| `size` | ✅ | ✅ | ❌ | ❌ |
| `color` | ✅ | ❌ | ✅ | ❌ |
| `category` | ❌ | ❌ | ✅ | ✅ |
| `minWidth` | ❌ | ❌ | ✅ | ✅ |
| `minHeight` | ❌ | ❌ | ✅ | ✅ |
| `editorsChoice` | ❌ | ❌ | ✅ | ✅ |
| `locale` | ✅ | ✅ | ✅ | ❌ |

Check `capabilities.photoFilters` / `capabilities.videoFilters` before sending UI-selected filters. Unsupported filters throw `MediaError` with `code: "UNSUPPORTED_FILTER"` — filters are never silently ignored.

### Pixabay limitations

- **Authentication** — Pexels uses an `Authorization` header (`ApiKeyProvider` value is sent as the header). Pixabay uses a `key=` query parameter on every request.
- **Curated photos** — `getCuratedPhotos` is Pexels-only; Pixabay has no equivalent endpoint.
- **Hotlinking** — Pixabay requires serving images and videos from the URLs returned by the API (`previewURL`, `webformatURL`, `largeImageURL`, video rendition URLs). Do not download and re-host media on your own CDN.
- **Caching** — The SDK caches API JSON responses in memory (same as Pexels). Pixabay's terms restrict how long you may cache API responses; tune `MemoryCache` TTL or disable caching if your usage requires stricter compliance.

Pixabay video hits are normalized into `VideoFile` entries for each available rendition (`large`, `medium`, `small`, `tiny`).

## Client API

Both `PexelsMediaClient` and `PixabayMediaClient` implement the `MediaClient` interface:

- `searchPhotos(params)` — search photos
- `searchVideos(params)` — search videos
- `getPhoto(id)` — fetch a single photo
- `getVideo(id)` — fetch a single video
- `getCuratedPhotos(params?)` — fetch curated photos (Pexels only)

Search methods return `PaginatedResponse<T>` with normalized `items` and `pagination` fields.

API methods do **not** emit events. They only fetch and normalize data.

### Search

```ts
const photos = await client.searchPhotos({
  query: "mountains",
  page: 1,
  perPage: 20,
});

const videos = await client.searchVideos({
  query: "ocean",
  page: 2,
  perPage: 15,
});
```

`SearchParams` accepts an optional `signal` (`AbortSignal`) for request cancellation.

### Search filters

Pass optional `photoFilters` or `videoFilters` on `SearchParams`. Each provider maps supported fields to its wire API; unsupported fields throw `MediaError` with `code: "UNSUPPORTED_FILTER"`.

```ts
// Pexels photo search with orientation and color
const photos = await pexels.searchPhotos({
  query: "mountains",
  page: 1,
  perPage: 20,
  photoFilters: {
    orientation: "landscape",
    size: "large",
    color: "green",
    locale: "en-US",
  },
});

// Pixabay photo search with category and dimensions
const pixabayPhotos = await pixabay.searchPhotos({
  query: "nature",
  page: 1,
  perPage: 20,
  photoFilters: {
    orientation: "landscape",
    color: "green",        // Pixabay maps to `colors` wire param
    category: "nature",
    minWidth: 1920,
    minHeight: 1080,
    editorsChoice: true,
    locale: "en",
  },
});

// Pexels video search
const videos = await pexels.searchVideos({
  query: "ocean",
  page: 1,
  perPage: 15,
  videoFilters: {
    orientation: "landscape",
    size: "medium",
    locale: "en-US",
  },
});
```

Guard filters with capabilities before calling:

```ts
const caps = getCapabilities(client);

const filters: PhotoSearchFilters = {};
if (caps.photoFilters.orientation) filters.orientation = "landscape";
if (caps.photoFilters.category) filters.category = "nature";

await client.searchPhotos({ query: "nature", photoFilters: filters });
```

### Single media

```ts
const photo = await client.getPhoto(12345);
const video = await client.getVideo(67890);
```

### Pagination

Search and curated responses include a normalized `pagination` object:

```ts
const { items, pagination } = await client.searchPhotos({
  query: "nature",
  page: 1,
  perPage: 20,
});

if (pagination.hasNext) {
  const next = await client.searchPhotos({
    query: "nature",
    page: pagination.page + 1,
    perPage: pagination.perPage,
  });
}

if (pagination.hasPrevious) {
  const prev = await client.searchPhotos({
    query: "nature",
    page: pagination.page - 1,
    perPage: pagination.perPage,
  });
}
```

| Field | Type | Description |
|-------|------|-------------|
| `page` | `number` | Current page number |
| `perPage` | `number` | Items per page |
| `totalResults` | `number?` | Total matching results |
| `hasNext` | `boolean` | Whether a next page exists |
| `hasPrevious` | `boolean` | Whether a previous page exists |
| `nextPage` | `string?` | Raw Pexels next-page URL |
| `prevPage` | `string?` | Raw Pexels previous-page URL |

Use `hasNext` / `hasPrevious` for navigation logic. The URL fields are preserved for advanced use cases but are not required for page-based navigation.

## Cache

By default, the client uses an in-memory cache with a 60-second TTL and deduplicates concurrent requests for the same key.

```ts
import {
  ApiKeyProvider,
  MemoryCache,
  PexelsMediaClient,
} from "@media-sdk/core";

const client = new PexelsMediaClient(auth, {
  cache: new MemoryCache(120_000),
});
```

Inject a custom `Cache` implementation for testing or alternate storage:

```ts
import type { Cache } from "@media-sdk/core";

const cache: Cache = {
  get: (key) => store.get(key),
  set: (key, value) => store.set(key, value),
  delete: (key) => store.delete(key),
  clear: () => store.clear(),
};

const client = new PexelsMediaClient(auth, { cache });
```

Cache behavior:

- Successful responses are cached; failures are not
- Concurrent callers for the same key share one in-flight request
- Cache keys are namespaced (`photos:`, `videos:`, `photo:`, `video:`, `curated:`)

## HttpClient

By default, the client uses `FetchHttpClient` internally. Inject a custom `HttpClient` for testing, proxying, or alternate transports:

```ts
import type { HttpClient } from "@media-sdk/core";
import { ApiKeyProvider, PexelsMediaClient } from "@media-sdk/core";

const httpClient: HttpClient = {
  async get(url, options) {
    const response = await fetch(url, {
      headers: options?.headers,
      signal: options?.signal,
    });
    return response.json();
  },
};

const client = new PexelsMediaClient(
  new ApiKeyProvider(process.env.PEXELS_API_KEY!),
  { httpClient },
);
```

`FetchHttpClient` is internal and not exported from the package entry.

## Events

Events represent **user actions**, not API traffic. Call `trackView()` or `trackDownload()` explicitly from your application layer (for example when opening a preview or starting a download).

```ts
const unsubscribe = client.on("media:view", (event) => {
  console.log(event.mediaId, event.mediaType);
});

client.trackView({
  mediaId: 123,
  mediaType: "photo",
});

client.trackDownload({
  mediaId: 456,
  mediaType: "video",
});

unsubscribe();
```

Event types:

- `"media:view"` — emitted by `trackView()`
- `"media:download"` — emitted by `trackDownload()`

`on()` returns an unsubscribe function. Multiple listeners are supported.

## Errors

| Failure | Error type | `code` |
|---------|------------|--------|
| HTTP 4xx / 5xx | `MediaError` with `status` | optional |
| Unsupported method (e.g. curated on Pixabay) | `MediaError` | `UNSUPPORTED_CAPABILITY` |
| Unsupported search filter | `MediaError` | `UNSUPPORTED_FILTER` |
| Request cancelled via `AbortSignal` | `AbortError` / `DOMException` | — |
| Network / `fetch` rejection | native `Error` | — |
| Invalid JSON on 2xx response | native `Error` (parse failure) | — |
| Missing API key in `ApiKeyProvider` | native `Error` | — |

```ts
import { isAbortError, MediaError } from "@media-sdk/core";

try {
  await client.searchPhotos({ query: "nature", page: 1, perPage: 20 });
} catch (error) {
  if (isAbortError(error)) {
    return; // request was cancelled
  }
  if (error instanceof MediaError) {
    if (error.code === "UNSUPPORTED_FILTER") {
      console.error("Filter not supported by this provider");
    } else if (error.code === "UNSUPPORTED_CAPABILITY") {
      console.error("Operation not supported by this provider");
    } else {
      console.error(error.status, error.message);
    }
  } else {
    console.error(error);
  }
}
```

Branch on `error.code` for control flow — do not parse `error.message`. Use `isAbortError` from `@media-sdk/core` as the canonical abort check.

## Public API

Import from the package entry only:

```ts
import {
  ApiKeyProvider,
  getCapabilities,
  isAbortError,
  MediaError,
  MemoryCache,
  PexelsMediaClient,
  PixabayMediaClient,
  type Cache,
  type HttpClient,
  type HttpRequestOptions,
  type MediaCapabilities,
  type MediaClient,
  type MediaDownloadEvent,
  type MediaEventMap,
  type MediaFilterCapabilities,
  type MediaOperationCapabilities,
  type MediaType,
  type MediaViewEvent,
  type Photo,
  type PhotoSearchFilters,
  type Video,
  type VideoSearchFilters,
  type PaginatedResponse,
  type Pagination,
  type SearchParams,
  type PexelsMediaClientOptions,
  type PixabayMediaClientOptions,
} from "@media-sdk/core";
```

Pexels wire types (`PexelsPhotoSearchResponse`, `PexelsVideo`, etc.) are **not** exported from the package entry — use normalized `Photo`, `Video`, and `PaginatedResponse<T>`. See [Migration 0.3 → 1.0](https://github.com/ankit10000/headless-media-sdk/blob/main/apps/docs/guide/migration/0.3-to-1.0.md).

`EventEmitter`, `RequestManager`, and `FetchHttpClient` are internal and not part of the public API.

## Architecture

```
@media-sdk/core
├── PexelsMediaClient   ← Pexels API client
├── PixabayMediaClient  ← Pixabay API client
├── ApiKeyProvider      ← API key auth
├── MediaError          ← HTTP error type
├── MemoryCache         ← default cache implementation
├── MediaClient         ← interface
├── Domain types        ← Photo, Video, pagination, etc.
└── Event types         ← MediaViewEvent, MediaDownloadEvent
       │
       └── EventEmitter = internal
```

Higher-level packages build on top:

```
apps/web (or your app)
        │
        ├── @media-sdk/ui-react   ← presentational components
        │
        └── @media-sdk/react      ← hooks, provider
               │
               ▼
          @media-sdk/core         ← this package
```
