import { VclawError } from '../../errors.js'; import type { StockFetch, StockFetchResponse, StockLicense, StockProvider, StockSearchResult } from '../types.js'; const PEXELS_VIDEO_SEARCH_URL = 'https://api.pexels.com/v1/videos/search'; const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_MAX_JSON_BYTES = 1_000_000; const PEXELS_PAGE_HOSTS = new Set(['pexels.com', 'www.pexels.com']); const PEXELS_LICENSE: StockLicense = { name: 'Pexels License', url: 'https://www.pexels.com/license/', usageScope: ['draft', 'review', 'publish-package'], termsRestrictions: [ 'Follow the Pexels License and API guidelines.', 'Include a prominent Pexels link when displaying imported media metadata.', 'Do not imply endorsement by the creator, depicted people, or Pexels.', 'Model and property release status is not provided by the API.', ], modelRelease: 'unknown', propertyRelease: 'unknown', }; function defaultFetch(): StockFetch { if (typeof fetch !== 'function') { throw new VclawError('stock_provider_unavailable', 'Pexels stock search requires a runtime with fetch support.'); } return fetch as unknown as StockFetch; } async function withTimeout( operation: (signal: AbortSignal) => Promise, timeoutMs: number, code: 'stock_provider_unavailable' | 'stock_download_failed', message: string, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await operation(controller.signal); } catch (error) { if ((error instanceof Error && error.name === 'AbortError') || controller.signal.aborted) { throw new VclawError(code, message, { reason: 'timeout', timeoutMs }); } throw error; } finally { clearTimeout(timer); } } async function boundedJson(response: StockFetchResponse, maxJsonBytes: number): Promise { const lengthHeader = response.headers.get('content-length'); const declaredBytes = lengthHeader ? Number(lengthHeader) : undefined; if (declaredBytes !== undefined && Number.isFinite(declaredBytes) && declaredBytes > maxJsonBytes) { throw new VclawError( 'stock_provider_unavailable', `Pexels stock search response exceeded ${maxJsonBytes} bytes.`, { provider: 'pexels', maxJsonBytes, sizeBytes: declaredBytes }, ); } const rawBuffer = response.body ? await readBoundedBody(response.body, maxJsonBytes, 'Pexels stock search response') : Buffer.from(await response.text()); if (rawBuffer.byteLength > maxJsonBytes) { throw new VclawError( 'stock_provider_unavailable', `Pexels stock search response exceeded ${maxJsonBytes} bytes.`, { provider: 'pexels', maxJsonBytes, sizeBytes: rawBuffer.byteLength }, ); } const raw = rawBuffer.toString('utf-8'); try { return JSON.parse(raw) as unknown; } catch { throw new VclawError('stock_provider_unavailable', 'Pexels stock search returned malformed JSON.', { provider: 'pexels' }); } } async function readBoundedBody(body: NonNullable, maxBytes: number, label: string): Promise { const chunks: Buffer[] = []; let total = 0; const append = (value: Uint8Array): void => { const chunk = Buffer.from(value); total += chunk.byteLength; if (total > maxBytes) { throw new VclawError( 'stock_provider_unavailable', `${label} exceeded ${maxBytes} bytes.`, { provider: 'pexels', maxJsonBytes: maxBytes, sizeBytes: total }, ); } chunks.push(chunk); }; if (Symbol.asyncIterator in Object(body)) { for await (const chunk of body as AsyncIterable) append(chunk); } else { const reader = (body as ReadableStream).getReader(); try { for (;;) { const next = await reader.read(); if (next.done) break; append(next.value); } } finally { reader.releaseLock(); } } return Buffer.concat(chunks); } function assertPexelsHttpsUrl(raw: string, allowedHosts: ReadonlySet, label: string): string { let url: URL; try { url = new URL(raw); } catch { throw new VclawError('stock_media_invalid', `${label} is not a valid URL.`, { provider: 'pexels' }); } if (url.protocol !== 'https:' || !allowedHosts.has(url.hostname.toLowerCase())) { throw new VclawError('stock_media_invalid', `${label} must be an HTTPS Pexels URL.`, { provider: 'pexels', host: url.hostname, }); } return url.toString(); } function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function asString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value : undefined; } function asIdentifier(value: unknown): string | undefined { const stringValue = asString(value); if (stringValue) return stringValue; const numberValue = asNumber(value); return numberValue === undefined ? undefined : String(numberValue); } function parsePexelsSearchResponse(payload: unknown, query: string): StockSearchResult[] { const root = asRecord(payload); const videos = Array.isArray(root.videos) ? root.videos : []; const results: StockSearchResult[] = []; for (const videoValue of videos) { const video = asRecord(videoValue); const id = asNumber(video.id); const sourcePageUrlRaw = asString(video.url); const durationSeconds = asNumber(video.duration); if (id === undefined || !sourcePageUrlRaw || durationSeconds === undefined) continue; const sourcePageUrl = assertPexelsHttpsUrl(sourcePageUrlRaw, PEXELS_PAGE_HOSTS, 'Pexels source page URL'); const user = asRecord(video.user); const creatorName = asString(user.name) ?? 'Unknown Pexels creator'; const creatorUrl = asString(user.url) ? assertPexelsHttpsUrl(asString(user.url) as string, PEXELS_PAGE_HOSTS, 'Pexels creator URL') : undefined; const renditions = (Array.isArray(video.video_files) ? video.video_files : []) .map((fileValue) => { const file = asRecord(fileValue); const renditionId = asNumber(file.id); const width = asNumber(file.width); const height = asNumber(file.height); const mimeType = asString(file.file_type); const url = asString(file.link); if (renditionId === undefined || width === undefined || height === undefined || !mimeType || !url) return null; return { id: String(renditionId), width, height, durationSeconds, mimeType, url, }; }) .filter((rendition): rendition is NonNullable => rendition !== null); if (renditions.length === 0) continue; results.push({ provider: 'pexels', providerAssetId: String(id), sourcePageUrl, creator: { ...(asIdentifier(user.id) ? { id: asIdentifier(user.id) } : {}), name: creatorName, ...(creatorUrl ? { url: creatorUrl } : {}), }, license: PEXELS_LICENSE, durationSeconds, ...(asNumber(video.width) !== undefined ? { width: asNumber(video.width) } : {}), ...(asNumber(video.height) !== undefined ? { height: asNumber(video.height) } : {}), searchQuery: query, renditions, }); } return results; } export const pexelsStockProvider: StockProvider = { id: 'pexels', async searchVideos(input) { const apiKey = input.apiKey ?? process.env.PEXELS_API_KEY; if (!apiKey) { throw new VclawError('stock_auth_failed', 'Pexels stock search requires PEXELS_API_KEY.', { provider: 'pexels', envVar: 'PEXELS_API_KEY', }); } const query = input.query.trim(); if (!query) { throw new VclawError('invalid_flag_value', 'video stock-search requires a non-empty --query value.', { flag: '--query' }); } const fetchImpl = input.fetch ?? defaultFetch(); const url = new URL(PEXELS_VIDEO_SEARCH_URL); url.searchParams.set('query', query); url.searchParams.set('page', String(input.page ?? 1)); url.searchParams.set('per_page', String(input.perPage ?? 10)); const response = await withTimeout( (signal) => fetchImpl(url.toString(), { method: 'GET', headers: { Authorization: apiKey }, signal }), input.timeoutMs ?? DEFAULT_TIMEOUT_MS, 'stock_provider_unavailable', 'Pexels stock search timed out.', ); if (response.status === 401 || response.status === 403) { throw new VclawError('stock_auth_failed', 'Pexels rejected the configured API key.', { provider: 'pexels', status: response.status }); } if (response.status === 429) { throw new VclawError('stock_rate_limited', 'Pexels stock search is rate limited.', { provider: 'pexels', status: response.status }); } if (!response.ok) { throw new VclawError('stock_provider_unavailable', `Pexels stock search failed with HTTP ${response.status}.`, { provider: 'pexels', status: response.status, }); } return parsePexelsSearchResponse(await boundedJson(response, input.maxJsonBytes ?? DEFAULT_MAX_JSON_BYTES), query); }, }; export { PEXELS_LICENSE };