@media-sdk/core
v1.0.0
Published
Headless Pexels media client for photos and videos
Readme
@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
pnpm add @media-sdk/coreQuick Start
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).
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.
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 callingOperation 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
Authorizationheader (ApiKeyProvidervalue is sent as the header). Pixabay uses akey=query parameter on every request. - Curated photos —
getCuratedPhotosis 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
MemoryCacheTTL 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 photossearchVideos(params)— search videosgetPhoto(id)— fetch a single photogetVideo(id)— fetch a single videogetCuratedPhotos(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
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".
// 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:
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
const photo = await client.getPhoto(12345);
const video = await client.getVideo(67890);Pagination
Search and curated responses include a normalized pagination object:
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.
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:
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:
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).
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 bytrackView()"media:download"— emitted bytrackDownload()
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 | — |
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:
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.
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 = internalHigher-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