@syra.fm/sdk 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -4
- package/dist/cjs/client.js +90 -6
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/schema.js +33 -1
- package/dist/esm/client.js +91 -7
- package/dist/esm/index.js +1 -1
- package/dist/esm/schema.js +32 -0
- package/dist/types/client.d.ts +69 -9
- package/dist/types/index.d.ts +3 -3
- package/dist/types/schema.d.ts +304 -0
- package/package.json +1 -1
- package/src/client.test.ts +286 -18
- package/src/client.ts +200 -14
- package/src/index.ts +5 -0
- package/src/schema.ts +34 -0
package/src/client.test.ts
CHANGED
|
@@ -45,6 +45,29 @@ function makePodcast(overrides: Record<string, unknown> = {}): Record<string, un
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function makeEpisode(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
49
|
+
return {
|
|
50
|
+
id: '507f1f77bcf86cd799439031',
|
|
51
|
+
podcastId: '507f1f77bcf86cd799439021',
|
|
52
|
+
podcastTitle: 'Test Show',
|
|
53
|
+
title: 'Test Episode',
|
|
54
|
+
description: 'An episode about testing.',
|
|
55
|
+
guid: 'guid-1',
|
|
56
|
+
enclosureUrl: 'https://api.fastcast.ai/audio/guid-1.mp3',
|
|
57
|
+
enclosureType: 'audio/mpeg',
|
|
58
|
+
enclosureLength: 12_345_678,
|
|
59
|
+
duration: 1800,
|
|
60
|
+
pubDate: '2026-01-01T00:00:00.000Z',
|
|
61
|
+
episodeType: 'full',
|
|
62
|
+
image: '507f1f77bcf86cd799439032',
|
|
63
|
+
imageSourceUrl: 'https://cdn.example.com/episode.jpg',
|
|
64
|
+
status: 'ready',
|
|
65
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
66
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
interface FetchCall {
|
|
49
72
|
url: string;
|
|
50
73
|
}
|
|
@@ -70,7 +93,7 @@ function fakeFetch(
|
|
|
70
93
|
// ── searchTracks ──────────────────────────────────────────────────────────────
|
|
71
94
|
|
|
72
95
|
describe('createSyraClient.searchTracks', () => {
|
|
73
|
-
it('calls /api/search with category=tracks and the limit, returns preview-available tracks', async () => {
|
|
96
|
+
it('calls /api/search with category=tracks and the limit, returns a page of preview-available tracks', async () => {
|
|
74
97
|
const { fetch, calls } = fakeFetch(() => ({
|
|
75
98
|
body: {
|
|
76
99
|
results: {
|
|
@@ -79,14 +102,20 @@ describe('createSyraClient.searchTracks', () => {
|
|
|
79
102
|
makeTrack({ id: '507f1f77bcf86cd799439012', previewAvailable: false }),
|
|
80
103
|
],
|
|
81
104
|
},
|
|
105
|
+
hasMore: false,
|
|
106
|
+
limit: 10,
|
|
107
|
+
offset: 0,
|
|
82
108
|
},
|
|
83
109
|
}));
|
|
84
110
|
|
|
85
111
|
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
86
|
-
const
|
|
112
|
+
const page = await client.searchTracks('hello', { limit: 10 });
|
|
87
113
|
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
114
|
+
expect(page.items).toHaveLength(1);
|
|
115
|
+
expect(page.items[0].id).toBe('507f1f77bcf86cd799439011');
|
|
116
|
+
expect(page.hasMore).toBe(false);
|
|
117
|
+
expect(page.limit).toBe(10);
|
|
118
|
+
expect(page.offset).toBe(0);
|
|
90
119
|
|
|
91
120
|
expect(calls).toHaveLength(1);
|
|
92
121
|
const url = new URL(calls[0].url);
|
|
@@ -94,6 +123,53 @@ describe('createSyraClient.searchTracks', () => {
|
|
|
94
123
|
expect(url.searchParams.get('q')).toBe('hello');
|
|
95
124
|
expect(url.searchParams.get('category')).toBe('tracks');
|
|
96
125
|
expect(url.searchParams.get('limit')).toBe('10');
|
|
126
|
+
expect(url.searchParams.has('offset')).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sends the offset param and reports hasMore/offset/limit from the backend', async () => {
|
|
130
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
131
|
+
body: {
|
|
132
|
+
results: { tracks: [makeTrack()] },
|
|
133
|
+
hasMore: true,
|
|
134
|
+
limit: 20,
|
|
135
|
+
offset: 40,
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
140
|
+
const page = await client.searchTracks('hello', { limit: 20, offset: 40 });
|
|
141
|
+
|
|
142
|
+
expect(page.items).toHaveLength(1);
|
|
143
|
+
expect(page.hasMore).toBe(true);
|
|
144
|
+
expect(page.limit).toBe(20);
|
|
145
|
+
expect(page.offset).toBe(40);
|
|
146
|
+
|
|
147
|
+
const url = new URL(calls[0].url);
|
|
148
|
+
expect(url.searchParams.get('offset')).toBe('40');
|
|
149
|
+
expect(url.searchParams.get('limit')).toBe('20');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('reports backend hasMore even when client-side preview filtering empties the page', async () => {
|
|
153
|
+
const { fetch } = fakeFetch(() => ({
|
|
154
|
+
body: {
|
|
155
|
+
results: {
|
|
156
|
+
tracks: [
|
|
157
|
+
makeTrack({ previewAvailable: false }),
|
|
158
|
+
makeTrack({ previewAvailable: false }),
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
hasMore: true,
|
|
162
|
+
limit: 2,
|
|
163
|
+
offset: 0,
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const client = createSyraClient({ fetch });
|
|
168
|
+
const page = await client.searchTracks('x', { limit: 2 });
|
|
169
|
+
|
|
170
|
+
// All rows were filtered out, but the page is NOT the last one.
|
|
171
|
+
expect(page.items).toHaveLength(0);
|
|
172
|
+
expect(page.hasMore).toBe(true);
|
|
97
173
|
});
|
|
98
174
|
|
|
99
175
|
it('drops malformed rows without throwing', async () => {
|
|
@@ -109,14 +185,17 @@ describe('createSyraClient.searchTracks', () => {
|
|
|
109
185
|
}));
|
|
110
186
|
|
|
111
187
|
const client = createSyraClient({ fetch });
|
|
112
|
-
const
|
|
113
|
-
expect(
|
|
188
|
+
const page = await client.searchTracks('x');
|
|
189
|
+
expect(page.items).toHaveLength(1);
|
|
114
190
|
});
|
|
115
191
|
|
|
116
|
-
it('returns an empty
|
|
192
|
+
it('returns an empty page with hasMore=false when results.tracks is absent', async () => {
|
|
117
193
|
const { fetch } = fakeFetch(() => ({ body: {} }));
|
|
118
194
|
const client = createSyraClient({ fetch });
|
|
119
|
-
|
|
195
|
+
const page = await client.searchTracks('x');
|
|
196
|
+
expect(page.items).toEqual([]);
|
|
197
|
+
expect(page.hasMore).toBe(false);
|
|
198
|
+
expect(page.offset).toBe(0);
|
|
120
199
|
});
|
|
121
200
|
});
|
|
122
201
|
|
|
@@ -222,36 +301,60 @@ describe('createSyraClient.artworkUrl', () => {
|
|
|
222
301
|
// ── searchPodcasts ──────────────────────────────────────────────────────────────
|
|
223
302
|
|
|
224
303
|
describe('createSyraClient.searchPodcasts', () => {
|
|
225
|
-
it('calls /api/podcasts/search with q and limit, returns parsed shows', async () => {
|
|
304
|
+
it('calls /api/podcasts/search with q and limit, returns a page of parsed shows', async () => {
|
|
226
305
|
const { fetch, calls } = fakeFetch(() => ({
|
|
227
306
|
body: {
|
|
228
307
|
data: [
|
|
229
308
|
makePodcast({ id: '507f1f77bcf86cd799439021' }),
|
|
230
309
|
makePodcast({ id: '507f1f77bcf86cd799439023', title: 'Second Show' }),
|
|
231
310
|
],
|
|
311
|
+
hasMore: false,
|
|
312
|
+
limit: 5,
|
|
313
|
+
offset: 0,
|
|
232
314
|
},
|
|
233
315
|
}));
|
|
234
316
|
|
|
235
317
|
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
236
|
-
const
|
|
318
|
+
const page = await client.searchPodcasts('news', { limit: 5 });
|
|
237
319
|
|
|
238
|
-
expect(
|
|
239
|
-
expect(
|
|
240
|
-
expect(
|
|
320
|
+
expect(page.items).toHaveLength(2);
|
|
321
|
+
expect(page.items[0].id).toBe('507f1f77bcf86cd799439021');
|
|
322
|
+
expect(page.items[0].author).toBe('Test Publisher');
|
|
323
|
+
expect(page.hasMore).toBe(false);
|
|
324
|
+
expect(page.limit).toBe(5);
|
|
325
|
+
expect(page.offset).toBe(0);
|
|
241
326
|
|
|
242
327
|
expect(calls).toHaveLength(1);
|
|
243
328
|
const url = new URL(calls[0].url);
|
|
244
329
|
expect(url.pathname).toBe('/api/podcasts/search');
|
|
245
330
|
expect(url.searchParams.get('q')).toBe('news');
|
|
246
331
|
expect(url.searchParams.get('limit')).toBe('5');
|
|
332
|
+
expect(url.searchParams.has('offset')).toBe(false);
|
|
247
333
|
});
|
|
248
334
|
|
|
249
|
-
it('
|
|
335
|
+
it('sends the offset param and reports hasMore/offset from the backend', async () => {
|
|
336
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
337
|
+
body: { data: [makePodcast()], hasMore: true, limit: 10, offset: 20 },
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
341
|
+
const page = await client.searchPodcasts('news', { limit: 10, offset: 20 });
|
|
342
|
+
|
|
343
|
+
expect(page.hasMore).toBe(true);
|
|
344
|
+
expect(page.limit).toBe(10);
|
|
345
|
+
expect(page.offset).toBe(20);
|
|
346
|
+
|
|
347
|
+
const url = new URL(calls[0].url);
|
|
348
|
+
expect(url.searchParams.get('offset')).toBe('20');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('omits the limit and offset params when not provided', async () => {
|
|
250
352
|
const { fetch, calls } = fakeFetch(() => ({ body: { data: [makePodcast()] } }));
|
|
251
353
|
const client = createSyraClient({ fetch });
|
|
252
354
|
await client.searchPodcasts('news');
|
|
253
355
|
const url = new URL(calls[0].url);
|
|
254
356
|
expect(url.searchParams.has('limit')).toBe(false);
|
|
357
|
+
expect(url.searchParams.has('offset')).toBe(false);
|
|
255
358
|
});
|
|
256
359
|
|
|
257
360
|
it('drops malformed rows without throwing', async () => {
|
|
@@ -259,14 +362,17 @@ describe('createSyraClient.searchPodcasts', () => {
|
|
|
259
362
|
body: { data: [{ id: 'broken' }, makePodcast()] },
|
|
260
363
|
}));
|
|
261
364
|
const client = createSyraClient({ fetch });
|
|
262
|
-
const
|
|
263
|
-
expect(
|
|
365
|
+
const page = await client.searchPodcasts('x');
|
|
366
|
+
expect(page.items).toHaveLength(1);
|
|
264
367
|
});
|
|
265
368
|
|
|
266
|
-
it('returns an empty
|
|
369
|
+
it('returns an empty page with hasMore=false when data is absent', async () => {
|
|
267
370
|
const { fetch } = fakeFetch(() => ({ body: {} }));
|
|
268
371
|
const client = createSyraClient({ fetch });
|
|
269
|
-
|
|
372
|
+
const page = await client.searchPodcasts('x');
|
|
373
|
+
expect(page.items).toEqual([]);
|
|
374
|
+
expect(page.hasMore).toBe(false);
|
|
375
|
+
expect(page.offset).toBe(0);
|
|
270
376
|
});
|
|
271
377
|
});
|
|
272
378
|
|
|
@@ -362,3 +468,165 @@ describe('createSyraClient.podcastArtworkUrl', () => {
|
|
|
362
468
|
expect(client.podcastArtworkUrl({ image: 'not-an-id' })).toBeUndefined();
|
|
363
469
|
});
|
|
364
470
|
});
|
|
471
|
+
|
|
472
|
+
// ── getPodcastEpisodes ──────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
describe('createSyraClient.getPodcastEpisodes', () => {
|
|
475
|
+
it('calls /api/podcasts/:id/episodes with page and limit and returns parsed episodes', async () => {
|
|
476
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
477
|
+
body: {
|
|
478
|
+
data: [
|
|
479
|
+
makeEpisode({ id: '507f1f77bcf86cd799439031' }),
|
|
480
|
+
makeEpisode({ id: '507f1f77bcf86cd799439033', title: 'Second Episode' }),
|
|
481
|
+
],
|
|
482
|
+
total: 2,
|
|
483
|
+
page: 1,
|
|
484
|
+
limit: 20,
|
|
485
|
+
},
|
|
486
|
+
}));
|
|
487
|
+
|
|
488
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
489
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', { limit: 20 });
|
|
490
|
+
|
|
491
|
+
expect(page.items).toHaveLength(2);
|
|
492
|
+
expect(page.items[0].id).toBe('507f1f77bcf86cd799439031');
|
|
493
|
+
expect(page.items[0].enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
|
|
494
|
+
expect(page.hasMore).toBe(false);
|
|
495
|
+
expect(page.limit).toBe(20);
|
|
496
|
+
expect(page.offset).toBe(0);
|
|
497
|
+
|
|
498
|
+
expect(calls).toHaveLength(1);
|
|
499
|
+
const url = new URL(calls[0].url);
|
|
500
|
+
expect(url.pathname).toBe('/api/podcasts/507f1f77bcf86cd799439021/episodes');
|
|
501
|
+
expect(url.searchParams.get('page')).toBe('1');
|
|
502
|
+
expect(url.searchParams.get('limit')).toBe('20');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('translates a zero-based offset into a 1-based page and derives hasMore from total', async () => {
|
|
506
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
507
|
+
body: { data: [makeEpisode()], total: 45, page: 3, limit: 10 },
|
|
508
|
+
}));
|
|
509
|
+
|
|
510
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
511
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', {
|
|
512
|
+
limit: 10,
|
|
513
|
+
offset: 20,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// offset 20 / limit 10 → page 3; 3 * 10 = 30 < 45 → more remain.
|
|
517
|
+
const url = new URL(calls[0].url);
|
|
518
|
+
expect(url.searchParams.get('page')).toBe('3');
|
|
519
|
+
expect(url.searchParams.get('limit')).toBe('10');
|
|
520
|
+
expect(page.offset).toBe(20);
|
|
521
|
+
expect(page.limit).toBe(10);
|
|
522
|
+
expect(page.hasMore).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('reports hasMore=false when the page reaches the end of the total', async () => {
|
|
526
|
+
const { fetch } = fakeFetch(() => ({
|
|
527
|
+
body: { data: [makeEpisode()], total: 20, page: 2, limit: 10 },
|
|
528
|
+
}));
|
|
529
|
+
const client = createSyraClient({ fetch });
|
|
530
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', {
|
|
531
|
+
limit: 10,
|
|
532
|
+
offset: 10,
|
|
533
|
+
});
|
|
534
|
+
// page 2 * limit 10 = 20, not < total 20 → this is the last page.
|
|
535
|
+
expect(page.hasMore).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('drops a row missing the required enclosureUrl without throwing', async () => {
|
|
539
|
+
const { fetch } = fakeFetch(() => ({
|
|
540
|
+
body: {
|
|
541
|
+
data: [makeEpisode({ enclosureUrl: undefined }), makeEpisode()],
|
|
542
|
+
total: 2,
|
|
543
|
+
},
|
|
544
|
+
}));
|
|
545
|
+
const client = createSyraClient({ fetch });
|
|
546
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', { limit: 10 });
|
|
547
|
+
expect(page.items).toHaveLength(1);
|
|
548
|
+
expect(page.items[0].enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('defaults the limit and returns an empty page when data is absent', async () => {
|
|
552
|
+
const { fetch, calls } = fakeFetch(() => ({ body: {} }));
|
|
553
|
+
const client = createSyraClient({ fetch });
|
|
554
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021');
|
|
555
|
+
expect(page.items).toEqual([]);
|
|
556
|
+
expect(page.hasMore).toBe(false);
|
|
557
|
+
expect(page.offset).toBe(0);
|
|
558
|
+
expect(page.limit).toBe(20);
|
|
559
|
+
const url = new URL(calls[0].url);
|
|
560
|
+
expect(url.searchParams.get('page')).toBe('1');
|
|
561
|
+
expect(url.searchParams.get('limit')).toBe('20');
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ── getEpisode ──────────────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
describe('createSyraClient.getEpisode', () => {
|
|
568
|
+
it('fetches /api/episodes/:id and validates data.episode', async () => {
|
|
569
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
570
|
+
body: { data: { episode: makeEpisode(), persons: [] } },
|
|
571
|
+
}));
|
|
572
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
573
|
+
|
|
574
|
+
const episode = await client.getEpisode('507f1f77bcf86cd799439031');
|
|
575
|
+
expect(episode.title).toBe('Test Episode');
|
|
576
|
+
expect(episode.enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
|
|
577
|
+
expect(calls[0].url).toBe('https://api.example.test/api/episodes/507f1f77bcf86cd799439031');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('throws SyraApiError on a non-2xx response', async () => {
|
|
581
|
+
const { fetch } = fakeFetch(() => ({ status: 404, body: { error: 'not found' } }));
|
|
582
|
+
const client = createSyraClient({ fetch });
|
|
583
|
+
await expect(client.getEpisode('507f1f77bcf86cd799439031')).rejects.toBeInstanceOf(SyraApiError);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('throws when data.episode fails schema validation', async () => {
|
|
587
|
+
const { fetch } = fakeFetch(() => ({ body: { data: { episode: { id: 'x' } } } }));
|
|
588
|
+
const client = createSyraClient({ fetch });
|
|
589
|
+
await expect(client.getEpisode('x')).rejects.toThrow();
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ── episodeImageUrl ──────────────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
describe('createSyraClient.episodeImageUrl', () => {
|
|
596
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test' });
|
|
597
|
+
|
|
598
|
+
it('resolves the re-hosted image id to an absolute images URL', () => {
|
|
599
|
+
expect(client.episodeImageUrl({ image: '507f1f77bcf86cd799439032' })).toBe(
|
|
600
|
+
'https://api.example.test/api/images/507f1f77bcf86cd799439032',
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('prefers a named size from imageSizes', () => {
|
|
605
|
+
const url = client.episodeImageUrl(
|
|
606
|
+
{
|
|
607
|
+
image: '507f1f77bcf86cd799439032',
|
|
608
|
+
imageSizes: {
|
|
609
|
+
large: {
|
|
610
|
+
id: '507f1f77bcf86cd799439033',
|
|
611
|
+
url: '/api/images/507f1f77bcf86cd799439033',
|
|
612
|
+
width: 640,
|
|
613
|
+
height: 640,
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
'large',
|
|
618
|
+
);
|
|
619
|
+
expect(url).toBe('https://api.example.test/api/images/507f1f77bcf86cd799439033');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('falls back to imageSourceUrl when no Syra image is present', () => {
|
|
623
|
+
expect(client.episodeImageUrl({ imageSourceUrl: 'https://cdn.example.com/episode.jpg' })).toBe(
|
|
624
|
+
'https://cdn.example.com/episode.jpg',
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('returns undefined when nothing resolvable is present', () => {
|
|
629
|
+
expect(client.episodeImageUrl({})).toBeUndefined();
|
|
630
|
+
expect(client.episodeImageUrl({ image: 'not-an-id' })).toBeUndefined();
|
|
631
|
+
});
|
|
632
|
+
});
|