Skip to content

Commit 0407226

Browse files
45ckclaude
andcommitted
Implement Pixabay video provider
Add API wrapper (pixabay.ts) and strategy class (pixabay-provider.ts) following the Pexels provider pattern. Register in provider factory and asset provider factories. Update factory tests to expect PixabayProvider instead of throw. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 53e695d commit 0407226

5 files changed

Lines changed: 444 additions & 9 deletions

File tree

src/visuals/providers/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99

1010
export * from './types.js';
1111
export { PexelsProvider } from './pexels-provider.js';
12+
export { PixabayProvider } from './pixabay-provider.js';
1213
export { MockVideoProvider } from './mock-provider.js';
1314
export { NanoBananaProvider } from './nanobanana-provider.js';
1415
export { LocalProvider } from './local-provider.js';
1516
export { LocalImageProvider } from './local-image-provider.js';
1617
export { searchPexels, getPexelsVideo } from './pexels.js';
18+
export { searchPixabay, getPixabayVideo } from './pixabay.js';
1719

1820
import type { VideoProvider, AssetProvider } from './types.js';
1921
import { PexelsProvider } from './pexels-provider.js';
22+
import { PixabayProvider } from './pixabay-provider.js';
2023
import { MockVideoProvider } from './mock-provider.js';
2124
import { NanoBananaProvider } from './nanobanana-provider.js';
2225
import { LocalProvider } from './local-provider.js';
@@ -53,8 +56,7 @@ export function createVideoProvider(name: ProviderName): VideoProvider {
5356
case 'mock':
5457
return new MockVideoProvider();
5558
case 'pixabay':
56-
// TODO: Implement Pixabay provider
57-
throw new Error('Pixabay provider not yet implemented');
59+
return new PixabayProvider();
5860
default:
5961
throw new Error(`Unknown provider: ${name}`);
6062
}
@@ -122,9 +124,7 @@ const assetProviderFactories: Record<AssetProviderName, AssetProviderFactory> =
122124
recursive: config?.visuals?.local?.recursive,
123125
}),
124126

125-
pixabay: () => {
126-
throw new Error('Pixabay provider not yet implemented');
127-
},
127+
pixabay: () => adaptVideoProviderToAssetProvider(new PixabayProvider()),
128128
dalle: () => {
129129
throw new Error('DALL-E provider not yet implemented');
130130
},
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Pixabay Video Provider - Strategy Implementation
3+
*
4+
* Wraps the Pixabay API in the VideoProvider interface.
5+
*/
6+
7+
import type { VideoProvider, VideoSearchOptions, VideoSearchResult } from './types.js';
8+
import { searchPixabay } from './pixabay.js';
9+
import { getApiKey } from '../../core/config.js';
10+
11+
/** VideoProvider strategy for the Pixabay stock video API. */
12+
export class PixabayProvider implements VideoProvider {
13+
readonly name = 'pixabay';
14+
15+
search(options: VideoSearchOptions): Promise<VideoSearchResult[]> {
16+
return searchPixabay({
17+
query: options.query,
18+
perPage: options.perPage ?? 5,
19+
}).then((videos) =>
20+
videos.map((v) => ({
21+
id: String(v.id),
22+
url: v.url,
23+
thumbnailUrl: v.thumbnailUrl,
24+
width: v.width,
25+
height: v.height,
26+
duration: v.duration,
27+
}))
28+
);
29+
}
30+
31+
isAvailable(): boolean {
32+
try {
33+
getApiKey('PIXABAY_API_KEY');
34+
return true;
35+
} catch {
36+
return false;
37+
}
38+
}
39+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* @file Unit tests for Pixabay API wrapper
3+
*/
4+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5+
import { searchPixabay, getPixabayVideo, getBestVideoUrl, buildSearchUrl } from './pixabay.js';
6+
import { RateLimitError, APIError, NotFoundError } from '../../core/errors.js';
7+
8+
const MOCK_HIT = {
9+
id: 12345,
10+
duration: 15,
11+
tags: 'nature, forest, trees',
12+
videos: {
13+
large: {
14+
url: 'https://pixabay.com/large.mp4',
15+
width: 1920,
16+
height: 1080,
17+
size: 5000000,
18+
thumbnail: 'https://i.vimeocdn.com/large.jpg',
19+
},
20+
medium: {
21+
url: 'https://pixabay.com/medium.mp4',
22+
width: 1280,
23+
height: 720,
24+
size: 2500000,
25+
thumbnail: 'https://i.vimeocdn.com/medium.jpg',
26+
},
27+
small: {
28+
url: 'https://pixabay.com/small.mp4',
29+
width: 960,
30+
height: 540,
31+
size: 1200000,
32+
thumbnail: 'https://i.vimeocdn.com/small.jpg',
33+
},
34+
tiny: {
35+
url: 'https://pixabay.com/tiny.mp4',
36+
width: 640,
37+
height: 360,
38+
size: 600000,
39+
thumbnail: 'https://i.vimeocdn.com/tiny.jpg',
40+
},
41+
},
42+
};
43+
44+
const MOCK_RESPONSE = {
45+
total: 100,
46+
totalHits: 50,
47+
hits: [MOCK_HIT],
48+
};
49+
50+
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}) {
51+
return new Response(JSON.stringify(body), {
52+
status,
53+
statusText: status === 200 ? 'OK' : 'Error',
54+
headers: { 'Content-Type': 'application/json', ...headers },
55+
});
56+
}
57+
58+
describe('pixabay API wrapper', () => {
59+
const originalKey = process.env.PIXABAY_API_KEY;
60+
61+
beforeEach(() => {
62+
process.env.PIXABAY_API_KEY = 'test-key';
63+
});
64+
65+
afterEach(() => {
66+
process.env.PIXABAY_API_KEY = originalKey;
67+
vi.restoreAllMocks();
68+
});
69+
70+
describe('buildSearchUrl', () => {
71+
it('builds URL with defaults', () => {
72+
const url = buildSearchUrl('mykey', { query: 'nature' });
73+
expect(url).toContain('https://pixabay.com/api/videos/');
74+
expect(url).toContain('key=mykey');
75+
expect(url).toContain('q=nature');
76+
expect(url).toContain('safesearch=true');
77+
expect(url).toContain('per_page=10');
78+
expect(url).toContain('page=1');
79+
});
80+
81+
it('respects custom perPage and page', () => {
82+
const url = buildSearchUrl('mykey', { query: 'ocean', perPage: 5, page: 3 });
83+
expect(url).toContain('per_page=5');
84+
expect(url).toContain('page=3');
85+
});
86+
});
87+
88+
describe('getBestVideoUrl', () => {
89+
it('prefers medium resolution', () => {
90+
const result = getBestVideoUrl(MOCK_HIT);
91+
expect(result.url).toBe('https://pixabay.com/medium.mp4');
92+
expect(result.width).toBe(1280);
93+
expect(result.height).toBe(720);
94+
});
95+
96+
it('falls back to large when medium is missing', () => {
97+
const hit = { ...MOCK_HIT, videos: { ...MOCK_HIT.videos, medium: undefined } };
98+
const result = getBestVideoUrl(hit);
99+
expect(result.url).toBe('https://pixabay.com/large.mp4');
100+
});
101+
102+
it('falls back to small when medium and large are missing', () => {
103+
const hit = {
104+
...MOCK_HIT,
105+
videos: { ...MOCK_HIT.videos, medium: undefined, large: undefined },
106+
};
107+
const result = getBestVideoUrl(hit);
108+
expect(result.url).toBe('https://pixabay.com/small.mp4');
109+
});
110+
111+
it('falls back to tiny as last resort', () => {
112+
const hit = { ...MOCK_HIT, videos: { tiny: MOCK_HIT.videos.tiny } };
113+
const result = getBestVideoUrl(hit);
114+
expect(result.url).toBe('https://pixabay.com/tiny.mp4');
115+
});
116+
117+
it('throws when no video files available', () => {
118+
const hit = { ...MOCK_HIT, videos: {} };
119+
expect(() => getBestVideoUrl(hit)).toThrow(/no downloadable files/);
120+
});
121+
});
122+
123+
describe('searchPixabay', () => {
124+
it('maps response to PixabayVideo array', async () => {
125+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(MOCK_RESPONSE));
126+
127+
const results = await searchPixabay({ query: 'nature' });
128+
expect(results).toHaveLength(1);
129+
expect(results[0]).toEqual({
130+
id: 12345,
131+
url: 'https://pixabay.com/medium.mp4',
132+
thumbnailUrl: 'https://i.vimeocdn.com/small.jpg',
133+
duration: 15,
134+
width: 1280,
135+
height: 720,
136+
tags: 'nature, forest, trees',
137+
});
138+
});
139+
140+
it('throws RateLimitError on 429', async () => {
141+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
142+
jsonResponse({ error: 'rate limited' }, 429)
143+
);
144+
145+
await expect(searchPixabay({ query: 'test' })).rejects.toThrow(RateLimitError);
146+
});
147+
148+
it('throws APIError on non-OK response', async () => {
149+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
150+
jsonResponse({ error: 'bad request' }, 400)
151+
);
152+
153+
await expect(searchPixabay({ query: 'test' })).rejects.toThrow(APIError);
154+
});
155+
});
156+
157+
describe('getPixabayVideo', () => {
158+
it('returns a single video by ID', async () => {
159+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(MOCK_RESPONSE));
160+
161+
const video = await getPixabayVideo(12345);
162+
expect(video.id).toBe(12345);
163+
expect(video.url).toBe('https://pixabay.com/medium.mp4');
164+
});
165+
166+
it('throws NotFoundError when no hits', async () => {
167+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
168+
jsonResponse({ total: 0, totalHits: 0, hits: [] })
169+
);
170+
171+
await expect(getPixabayVideo(99999)).rejects.toThrow(NotFoundError);
172+
});
173+
174+
it('throws RateLimitError on 429', async () => {
175+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
176+
jsonResponse({ error: 'rate limited' }, 429)
177+
);
178+
179+
await expect(getPixabayVideo(12345)).rejects.toThrow(RateLimitError);
180+
});
181+
});
182+
});

0 commit comments

Comments
 (0)