@syra.fm/sdk 0.2.0 → 0.3.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 CHANGED
@@ -18,11 +18,15 @@ import { createSyraClient } from '@syra.fm/sdk';
18
18
 
19
19
  const syra = createSyraClient(); // defaults to https://api.syra.fm
20
20
 
21
- // Search the catalog (only tracks with a public preview are returned)
22
- const tracks = await syra.searchTracks('lofi beats', { limit: 10 });
21
+ // Search the catalog — returns one page; only tracks with a public preview are
22
+ // in `items`. Paginate for infinite scroll by advancing `offset` by `limit`.
23
+ const page = await syra.searchTracks('lofi beats', { limit: 10, offset: 0 });
24
+ if (page.hasMore) {
25
+ const next = await syra.searchTracks('lofi beats', { limit: 10, offset: 10 });
26
+ }
23
27
 
24
28
  // Fetch a single track
25
- const track = await syra.getTrack(tracks[0].id);
29
+ const track = await syra.getTrack(page.items[0].id);
26
30
 
27
31
  // Build a public 30s preview URL (directly playable MP3)
28
32
  const url = syra.previewUrl(track.id); // .../api/preview/<id>.mp3?start=0
@@ -48,10 +52,18 @@ authenticated transport can be layered in a future version.
48
52
 
49
53
  | Method | Description |
50
54
  | --- | --- |
51
- | `searchTracks(query, { limit })` | Preview-available `TrackSummary[]` matching `query`. |
55
+ | `searchTracks(query, { limit, offset })` | A `SearchPage<TrackSummary>` of preview-available tracks (`{ items, hasMore, limit, offset }`). |
52
56
  | `getTrack(id)` | A single `TrackSummary`, schema-validated. |
53
57
  | `previewUrl(id, startSec = 0)` | Public 30s preview URL. |
54
58
  | `artworkUrl(trackOrCoverArt, size?)` | Absolute artwork URL, or `undefined`. |
59
+ | `searchPodcasts(query, { limit, offset })` | A `SearchPage<PodcastSummary>` of podcast shows. |
60
+ | `getPodcast(id)` | A single `PodcastSummary`, schema-validated. |
61
+ | `podcastUrl(id)` | Syra web deep link (`/podcasts/:id`). |
62
+ | `podcastArtworkUrl(show, size?)` | Absolute show-artwork URL, or `undefined`. |
63
+
64
+ `hasMore` reflects the backend's pagination over the full result set, so it is
65
+ not affected by the client-side preview filter on `searchTracks` — paginate by
66
+ advancing `offset` by `limit`, never by `items.length`.
55
67
 
56
68
  Responses are validated at runtime with the package's own self-contained Zod
57
69
  schemas (`trackSummarySchema`), so there are no shared internal dependencies.
@@ -18,6 +18,10 @@ const ARTWORK_FALLBACK_ORDER = [
18
18
  'small',
19
19
  ];
20
20
  const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
21
+ /** Read a finite number from an unknown response field, else a fallback. */
22
+ function numberOr(value, fallback) {
23
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
24
+ }
21
25
  /**
22
26
  * Create a headless client for the public Syra API. Public reads only — there
23
27
  * is no authentication in this version.
@@ -67,17 +71,29 @@ function createSyraClient(options = {}) {
67
71
  if (typeof searchOptions.limit === 'number') {
68
72
  params.set('limit', String(searchOptions.limit));
69
73
  }
74
+ if (typeof searchOptions.offset === 'number') {
75
+ params.set('offset', String(searchOptions.offset));
76
+ }
70
77
  const json = (await getJson(`/api/search?${params.toString()}`));
71
78
  const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
72
- const tracks = [];
79
+ const items = [];
73
80
  for (const raw of rawTracks) {
74
81
  // A single malformed catalog row must not fail the whole search.
75
82
  const parsed = schema_1.trackSummarySchema.safeParse(raw);
76
83
  if (parsed.success && parsed.data.previewAvailable === true) {
77
- tracks.push(parsed.data);
84
+ items.push(parsed.data);
78
85
  }
79
86
  }
80
- return tracks;
87
+ return {
88
+ items,
89
+ // `hasMore` is sourced from the backend's pagination over the FULL result
90
+ // set; the client-side preview filter above may shrink `items` below
91
+ // `limit`, but must NOT corrupt `hasMore` (else a page whose tail was
92
+ // filtered out would falsely report the end of the catalog).
93
+ hasMore: json?.hasMore === true,
94
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawTracks.length),
95
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
96
+ };
81
97
  },
82
98
  async getTrack(id) {
83
99
  const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
@@ -116,17 +132,26 @@ function createSyraClient(options = {}) {
116
132
  if (typeof searchOptions.limit === 'number') {
117
133
  params.set('limit', String(searchOptions.limit));
118
134
  }
135
+ if (typeof searchOptions.offset === 'number') {
136
+ params.set('offset', String(searchOptions.offset));
137
+ }
119
138
  const json = (await getJson(`/api/podcasts/search?${params.toString()}`));
120
139
  const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
121
- const podcasts = [];
140
+ const items = [];
122
141
  for (const raw of rawPodcasts) {
123
142
  // A single malformed catalog row must not fail the whole search.
124
143
  const parsed = schema_1.podcastSummarySchema.safeParse(raw);
125
144
  if (parsed.success) {
126
- podcasts.push(parsed.data);
145
+ items.push(parsed.data);
127
146
  }
128
147
  }
129
- return podcasts;
148
+ return {
149
+ items,
150
+ // `hasMore` reflects the backend's pagination over the full result set.
151
+ hasMore: json?.hasMore === true,
152
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawPodcasts.length),
153
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
154
+ };
130
155
  },
131
156
  async getPodcast(id) {
132
157
  const json = (await getJson(`/api/podcasts/${encodeURIComponent(id)}`));
@@ -14,6 +14,10 @@ const ARTWORK_FALLBACK_ORDER = [
14
14
  'small',
15
15
  ];
16
16
  const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
17
+ /** Read a finite number from an unknown response field, else a fallback. */
18
+ function numberOr(value, fallback) {
19
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
20
+ }
17
21
  /**
18
22
  * Create a headless client for the public Syra API. Public reads only — there
19
23
  * is no authentication in this version.
@@ -63,17 +67,29 @@ export function createSyraClient(options = {}) {
63
67
  if (typeof searchOptions.limit === 'number') {
64
68
  params.set('limit', String(searchOptions.limit));
65
69
  }
70
+ if (typeof searchOptions.offset === 'number') {
71
+ params.set('offset', String(searchOptions.offset));
72
+ }
66
73
  const json = (await getJson(`/api/search?${params.toString()}`));
67
74
  const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
68
- const tracks = [];
75
+ const items = [];
69
76
  for (const raw of rawTracks) {
70
77
  // A single malformed catalog row must not fail the whole search.
71
78
  const parsed = trackSummarySchema.safeParse(raw);
72
79
  if (parsed.success && parsed.data.previewAvailable === true) {
73
- tracks.push(parsed.data);
80
+ items.push(parsed.data);
74
81
  }
75
82
  }
76
- return tracks;
83
+ return {
84
+ items,
85
+ // `hasMore` is sourced from the backend's pagination over the FULL result
86
+ // set; the client-side preview filter above may shrink `items` below
87
+ // `limit`, but must NOT corrupt `hasMore` (else a page whose tail was
88
+ // filtered out would falsely report the end of the catalog).
89
+ hasMore: json?.hasMore === true,
90
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawTracks.length),
91
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
92
+ };
77
93
  },
78
94
  async getTrack(id) {
79
95
  const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
@@ -112,17 +128,26 @@ export function createSyraClient(options = {}) {
112
128
  if (typeof searchOptions.limit === 'number') {
113
129
  params.set('limit', String(searchOptions.limit));
114
130
  }
131
+ if (typeof searchOptions.offset === 'number') {
132
+ params.set('offset', String(searchOptions.offset));
133
+ }
115
134
  const json = (await getJson(`/api/podcasts/search?${params.toString()}`));
116
135
  const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
117
- const podcasts = [];
136
+ const items = [];
118
137
  for (const raw of rawPodcasts) {
119
138
  // A single malformed catalog row must not fail the whole search.
120
139
  const parsed = podcastSummarySchema.safeParse(raw);
121
140
  if (parsed.success) {
122
- podcasts.push(parsed.data);
141
+ items.push(parsed.data);
123
142
  }
124
143
  }
125
- return podcasts;
144
+ return {
145
+ items,
146
+ // `hasMore` reflects the backend's pagination over the full result set.
147
+ hasMore: json?.hasMore === true,
148
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawPodcasts.length),
149
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
150
+ };
126
151
  },
127
152
  async getPodcast(id) {
128
153
  const json = (await getJson(`/api/podcasts/${encodeURIComponent(id)}`));
@@ -20,12 +20,35 @@ export interface SyraClientOptions {
20
20
  fetch?: typeof fetch;
21
21
  }
22
22
  export interface SearchTracksOptions {
23
- /** Maximum number of tracks to request from the API. */
23
+ /** Maximum number of tracks to request from the API (the page size). */
24
24
  limit?: number;
25
+ /** Zero-based offset of the first track to return (for infinite scroll). */
26
+ offset?: number;
25
27
  }
26
28
  export interface SearchPodcastsOptions {
27
- /** Maximum number of podcast shows to request from the API. */
29
+ /** Maximum number of podcast shows to request from the API (the page size). */
28
30
  limit?: number;
31
+ /** Zero-based offset of the first show to return (for infinite scroll). */
32
+ offset?: number;
33
+ }
34
+ /**
35
+ * One page of paginated catalog search results.
36
+ *
37
+ * `hasMore` reflects the BACKEND's pagination over the full matching set — NOT
38
+ * `items.length`. {@link SyraClient.searchTracks} additionally filters its page
39
+ * client-side to preview-available tracks, so `items.length` can be smaller than
40
+ * `limit` while `hasMore` is still `true`; callers must paginate by advancing
41
+ * `offset` by `limit` (the page size), never by `items.length`.
42
+ */
43
+ export interface SearchPage<T> {
44
+ /** The validated rows for this page. */
45
+ items: T[];
46
+ /** Whether the backend has results beyond this page. */
47
+ hasMore: boolean;
48
+ /** The page size the backend applied. */
49
+ limit: number;
50
+ /** The zero-based offset of this page. */
51
+ offset: number;
29
52
  }
30
53
  /** Minimal shape from which track artwork URLs can be derived. */
31
54
  export interface ArtworkSource {
@@ -40,10 +63,12 @@ export interface PodcastArtworkSource {
40
63
  }
41
64
  export interface SyraClient {
42
65
  /**
43
- * Search the public catalog for tracks. Results are validated against the
44
- * track-summary schema and filtered to those that expose a public preview.
66
+ * Search the public catalog for tracks. Returns one paginated page: rows are
67
+ * validated against the track-summary schema and filtered to those that expose
68
+ * a public preview. `hasMore` comes from the backend's pagination, so it is
69
+ * unaffected by the client-side preview filter (see {@link SearchPage}).
45
70
  */
46
- searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary[]>;
71
+ searchTracks(query: string, options?: SearchTracksOptions): Promise<SearchPage<TrackSummary>>;
47
72
  /** Fetch a single track by id, validated against the track-summary schema. */
48
73
  getTrack(id: string): Promise<TrackSummary>;
49
74
  /** Build the public 30s preview URL for a track at the given start offset. */
@@ -54,10 +79,11 @@ export interface SyraClient {
54
79
  */
55
80
  artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
56
81
  /**
57
- * Search the public catalog for podcast SHOWS (not episodes). Results are
58
- * validated against the podcast-summary schema; malformed rows are dropped.
82
+ * Search the public catalog for podcast SHOWS (not episodes). Returns one
83
+ * paginated page: rows are validated against the podcast-summary schema and
84
+ * malformed rows are dropped. `hasMore` comes from the backend's pagination.
59
85
  */
60
- searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<PodcastSummary[]>;
86
+ searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<SearchPage<PodcastSummary>>;
61
87
  /**
62
88
  * Fetch a single podcast show by id, validated against the podcast-summary
63
89
  * schema. The by-id endpoint also returns episodes and resolved persons; this
@@ -1,5 +1,5 @@
1
1
  export { createSyraClient, DEFAULT_SYRA_BASE_URL, DEFAULT_SYRA_WEB_BASE_URL, } from './client';
2
- export type { SyraClient, SyraClientOptions, SearchTracksOptions, SearchPodcastsOptions, ArtworkSource, PodcastArtworkSource, } from './client';
2
+ export type { SyraClient, SyraClientOptions, SearchTracksOptions, SearchPodcastsOptions, SearchPage, ArtworkSource, PodcastArtworkSource, } from './client';
3
3
  export { trackSummarySchema, podcastSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema';
4
4
  export type { TrackSummary, PodcastSummary, CoverArtSizes, CoverArtVariant, ArtworkSize, } from './schema';
5
5
  export { SyraApiError } from './errors';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syra.fm/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Headless, isomorphic client for the Syra public API — catalog reads and 30s preview clips. No React/RN/DOM dependencies; public reads only.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -70,7 +70,7 @@ function fakeFetch(
70
70
  // ── searchTracks ──────────────────────────────────────────────────────────────
71
71
 
72
72
  describe('createSyraClient.searchTracks', () => {
73
- it('calls /api/search with category=tracks and the limit, returns preview-available tracks', async () => {
73
+ it('calls /api/search with category=tracks and the limit, returns a page of preview-available tracks', async () => {
74
74
  const { fetch, calls } = fakeFetch(() => ({
75
75
  body: {
76
76
  results: {
@@ -79,14 +79,20 @@ describe('createSyraClient.searchTracks', () => {
79
79
  makeTrack({ id: '507f1f77bcf86cd799439012', previewAvailable: false }),
80
80
  ],
81
81
  },
82
+ hasMore: false,
83
+ limit: 10,
84
+ offset: 0,
82
85
  },
83
86
  }));
84
87
 
85
88
  const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
86
- const tracks = await client.searchTracks('hello', { limit: 10 });
89
+ const page = await client.searchTracks('hello', { limit: 10 });
87
90
 
88
- expect(tracks).toHaveLength(1);
89
- expect(tracks[0].id).toBe('507f1f77bcf86cd799439011');
91
+ expect(page.items).toHaveLength(1);
92
+ expect(page.items[0].id).toBe('507f1f77bcf86cd799439011');
93
+ expect(page.hasMore).toBe(false);
94
+ expect(page.limit).toBe(10);
95
+ expect(page.offset).toBe(0);
90
96
 
91
97
  expect(calls).toHaveLength(1);
92
98
  const url = new URL(calls[0].url);
@@ -94,6 +100,53 @@ describe('createSyraClient.searchTracks', () => {
94
100
  expect(url.searchParams.get('q')).toBe('hello');
95
101
  expect(url.searchParams.get('category')).toBe('tracks');
96
102
  expect(url.searchParams.get('limit')).toBe('10');
103
+ expect(url.searchParams.has('offset')).toBe(false);
104
+ });
105
+
106
+ it('sends the offset param and reports hasMore/offset/limit from the backend', async () => {
107
+ const { fetch, calls } = fakeFetch(() => ({
108
+ body: {
109
+ results: { tracks: [makeTrack()] },
110
+ hasMore: true,
111
+ limit: 20,
112
+ offset: 40,
113
+ },
114
+ }));
115
+
116
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
117
+ const page = await client.searchTracks('hello', { limit: 20, offset: 40 });
118
+
119
+ expect(page.items).toHaveLength(1);
120
+ expect(page.hasMore).toBe(true);
121
+ expect(page.limit).toBe(20);
122
+ expect(page.offset).toBe(40);
123
+
124
+ const url = new URL(calls[0].url);
125
+ expect(url.searchParams.get('offset')).toBe('40');
126
+ expect(url.searchParams.get('limit')).toBe('20');
127
+ });
128
+
129
+ it('reports backend hasMore even when client-side preview filtering empties the page', async () => {
130
+ const { fetch } = fakeFetch(() => ({
131
+ body: {
132
+ results: {
133
+ tracks: [
134
+ makeTrack({ previewAvailable: false }),
135
+ makeTrack({ previewAvailable: false }),
136
+ ],
137
+ },
138
+ hasMore: true,
139
+ limit: 2,
140
+ offset: 0,
141
+ },
142
+ }));
143
+
144
+ const client = createSyraClient({ fetch });
145
+ const page = await client.searchTracks('x', { limit: 2 });
146
+
147
+ // All rows were filtered out, but the page is NOT the last one.
148
+ expect(page.items).toHaveLength(0);
149
+ expect(page.hasMore).toBe(true);
97
150
  });
98
151
 
99
152
  it('drops malformed rows without throwing', async () => {
@@ -109,14 +162,17 @@ describe('createSyraClient.searchTracks', () => {
109
162
  }));
110
163
 
111
164
  const client = createSyraClient({ fetch });
112
- const tracks = await client.searchTracks('x');
113
- expect(tracks).toHaveLength(1);
165
+ const page = await client.searchTracks('x');
166
+ expect(page.items).toHaveLength(1);
114
167
  });
115
168
 
116
- it('returns an empty array when results.tracks is absent', async () => {
169
+ it('returns an empty page with hasMore=false when results.tracks is absent', async () => {
117
170
  const { fetch } = fakeFetch(() => ({ body: {} }));
118
171
  const client = createSyraClient({ fetch });
119
- expect(await client.searchTracks('x')).toEqual([]);
172
+ const page = await client.searchTracks('x');
173
+ expect(page.items).toEqual([]);
174
+ expect(page.hasMore).toBe(false);
175
+ expect(page.offset).toBe(0);
120
176
  });
121
177
  });
122
178
 
@@ -222,36 +278,60 @@ describe('createSyraClient.artworkUrl', () => {
222
278
  // ── searchPodcasts ──────────────────────────────────────────────────────────────
223
279
 
224
280
  describe('createSyraClient.searchPodcasts', () => {
225
- it('calls /api/podcasts/search with q and limit, returns parsed shows', async () => {
281
+ it('calls /api/podcasts/search with q and limit, returns a page of parsed shows', async () => {
226
282
  const { fetch, calls } = fakeFetch(() => ({
227
283
  body: {
228
284
  data: [
229
285
  makePodcast({ id: '507f1f77bcf86cd799439021' }),
230
286
  makePodcast({ id: '507f1f77bcf86cd799439023', title: 'Second Show' }),
231
287
  ],
288
+ hasMore: false,
289
+ limit: 5,
290
+ offset: 0,
232
291
  },
233
292
  }));
234
293
 
235
294
  const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
236
- const podcasts = await client.searchPodcasts('news', { limit: 5 });
295
+ const page = await client.searchPodcasts('news', { limit: 5 });
237
296
 
238
- expect(podcasts).toHaveLength(2);
239
- expect(podcasts[0].id).toBe('507f1f77bcf86cd799439021');
240
- expect(podcasts[0].author).toBe('Test Publisher');
297
+ expect(page.items).toHaveLength(2);
298
+ expect(page.items[0].id).toBe('507f1f77bcf86cd799439021');
299
+ expect(page.items[0].author).toBe('Test Publisher');
300
+ expect(page.hasMore).toBe(false);
301
+ expect(page.limit).toBe(5);
302
+ expect(page.offset).toBe(0);
241
303
 
242
304
  expect(calls).toHaveLength(1);
243
305
  const url = new URL(calls[0].url);
244
306
  expect(url.pathname).toBe('/api/podcasts/search');
245
307
  expect(url.searchParams.get('q')).toBe('news');
246
308
  expect(url.searchParams.get('limit')).toBe('5');
309
+ expect(url.searchParams.has('offset')).toBe(false);
310
+ });
311
+
312
+ it('sends the offset param and reports hasMore/offset from the backend', async () => {
313
+ const { fetch, calls } = fakeFetch(() => ({
314
+ body: { data: [makePodcast()], hasMore: true, limit: 10, offset: 20 },
315
+ }));
316
+
317
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
318
+ const page = await client.searchPodcasts('news', { limit: 10, offset: 20 });
319
+
320
+ expect(page.hasMore).toBe(true);
321
+ expect(page.limit).toBe(10);
322
+ expect(page.offset).toBe(20);
323
+
324
+ const url = new URL(calls[0].url);
325
+ expect(url.searchParams.get('offset')).toBe('20');
247
326
  });
248
327
 
249
- it('omits the limit param when not provided', async () => {
328
+ it('omits the limit and offset params when not provided', async () => {
250
329
  const { fetch, calls } = fakeFetch(() => ({ body: { data: [makePodcast()] } }));
251
330
  const client = createSyraClient({ fetch });
252
331
  await client.searchPodcasts('news');
253
332
  const url = new URL(calls[0].url);
254
333
  expect(url.searchParams.has('limit')).toBe(false);
334
+ expect(url.searchParams.has('offset')).toBe(false);
255
335
  });
256
336
 
257
337
  it('drops malformed rows without throwing', async () => {
@@ -259,14 +339,17 @@ describe('createSyraClient.searchPodcasts', () => {
259
339
  body: { data: [{ id: 'broken' }, makePodcast()] },
260
340
  }));
261
341
  const client = createSyraClient({ fetch });
262
- const podcasts = await client.searchPodcasts('x');
263
- expect(podcasts).toHaveLength(1);
342
+ const page = await client.searchPodcasts('x');
343
+ expect(page.items).toHaveLength(1);
264
344
  });
265
345
 
266
- it('returns an empty array when data is absent', async () => {
346
+ it('returns an empty page with hasMore=false when data is absent', async () => {
267
347
  const { fetch } = fakeFetch(() => ({ body: {} }));
268
348
  const client = createSyraClient({ fetch });
269
- expect(await client.searchPodcasts('x')).toEqual([]);
349
+ const page = await client.searchPodcasts('x');
350
+ expect(page.items).toEqual([]);
351
+ expect(page.hasMore).toBe(false);
352
+ expect(page.offset).toBe(0);
270
353
  });
271
354
  });
272
355
 
package/src/client.ts CHANGED
@@ -32,13 +32,37 @@ export interface SyraClientOptions {
32
32
  }
33
33
 
34
34
  export interface SearchTracksOptions {
35
- /** Maximum number of tracks to request from the API. */
35
+ /** Maximum number of tracks to request from the API (the page size). */
36
36
  limit?: number;
37
+ /** Zero-based offset of the first track to return (for infinite scroll). */
38
+ offset?: number;
37
39
  }
38
40
 
39
41
  export interface SearchPodcastsOptions {
40
- /** Maximum number of podcast shows to request from the API. */
42
+ /** Maximum number of podcast shows to request from the API (the page size). */
41
43
  limit?: number;
44
+ /** Zero-based offset of the first show to return (for infinite scroll). */
45
+ offset?: number;
46
+ }
47
+
48
+ /**
49
+ * One page of paginated catalog search results.
50
+ *
51
+ * `hasMore` reflects the BACKEND's pagination over the full matching set — NOT
52
+ * `items.length`. {@link SyraClient.searchTracks} additionally filters its page
53
+ * client-side to preview-available tracks, so `items.length` can be smaller than
54
+ * `limit` while `hasMore` is still `true`; callers must paginate by advancing
55
+ * `offset` by `limit` (the page size), never by `items.length`.
56
+ */
57
+ export interface SearchPage<T> {
58
+ /** The validated rows for this page. */
59
+ items: T[];
60
+ /** Whether the backend has results beyond this page. */
61
+ hasMore: boolean;
62
+ /** The page size the backend applied. */
63
+ limit: number;
64
+ /** The zero-based offset of this page. */
65
+ offset: number;
42
66
  }
43
67
 
44
68
  /** Minimal shape from which track artwork URLs can be derived. */
@@ -56,10 +80,12 @@ export interface PodcastArtworkSource {
56
80
 
57
81
  export interface SyraClient {
58
82
  /**
59
- * Search the public catalog for tracks. Results are validated against the
60
- * track-summary schema and filtered to those that expose a public preview.
83
+ * Search the public catalog for tracks. Returns one paginated page: rows are
84
+ * validated against the track-summary schema and filtered to those that expose
85
+ * a public preview. `hasMore` comes from the backend's pagination, so it is
86
+ * unaffected by the client-side preview filter (see {@link SearchPage}).
61
87
  */
62
- searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary[]>;
88
+ searchTracks(query: string, options?: SearchTracksOptions): Promise<SearchPage<TrackSummary>>;
63
89
  /** Fetch a single track by id, validated against the track-summary schema. */
64
90
  getTrack(id: string): Promise<TrackSummary>;
65
91
  /** Build the public 30s preview URL for a track at the given start offset. */
@@ -70,10 +96,11 @@ export interface SyraClient {
70
96
  */
71
97
  artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
72
98
  /**
73
- * Search the public catalog for podcast SHOWS (not episodes). Results are
74
- * validated against the podcast-summary schema; malformed rows are dropped.
99
+ * Search the public catalog for podcast SHOWS (not episodes). Returns one
100
+ * paginated page: rows are validated against the podcast-summary schema and
101
+ * malformed rows are dropped. `hasMore` comes from the backend's pagination.
75
102
  */
76
- searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<PodcastSummary[]>;
103
+ searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<SearchPage<PodcastSummary>>;
77
104
  /**
78
105
  * Fetch a single podcast show by id, validated against the podcast-summary
79
106
  * schema. The by-id endpoint also returns episodes and resolved persons; this
@@ -102,12 +129,23 @@ const ARTWORK_FALLBACK_ORDER: ArtworkSize[] = [
102
129
 
103
130
  const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
104
131
 
132
+ /** Read a finite number from an unknown response field, else a fallback. */
133
+ function numberOr(value: unknown, fallback: number): number {
134
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
135
+ }
136
+
105
137
  interface SearchResponseShape {
106
138
  results?: { tracks?: unknown[] };
139
+ hasMore?: unknown;
140
+ limit?: unknown;
141
+ offset?: unknown;
107
142
  }
108
143
 
109
144
  interface PodcastSearchResponseShape {
110
145
  data?: unknown[];
146
+ hasMore?: unknown;
147
+ limit?: unknown;
148
+ offset?: unknown;
111
149
  }
112
150
 
113
151
  interface PodcastDetailResponseShape {
@@ -172,19 +210,32 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
172
210
  if (typeof searchOptions.limit === 'number') {
173
211
  params.set('limit', String(searchOptions.limit));
174
212
  }
213
+ if (typeof searchOptions.offset === 'number') {
214
+ params.set('offset', String(searchOptions.offset));
215
+ }
175
216
 
176
217
  const json = (await getJson(`/api/search?${params.toString()}`)) as SearchResponseShape;
177
218
  const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
178
219
 
179
- const tracks: TrackSummary[] = [];
220
+ const items: TrackSummary[] = [];
180
221
  for (const raw of rawTracks) {
181
222
  // A single malformed catalog row must not fail the whole search.
182
223
  const parsed = trackSummarySchema.safeParse(raw);
183
224
  if (parsed.success && parsed.data.previewAvailable === true) {
184
- tracks.push(parsed.data);
225
+ items.push(parsed.data);
185
226
  }
186
227
  }
187
- return tracks;
228
+
229
+ return {
230
+ items,
231
+ // `hasMore` is sourced from the backend's pagination over the FULL result
232
+ // set; the client-side preview filter above may shrink `items` below
233
+ // `limit`, but must NOT corrupt `hasMore` (else a page whose tail was
234
+ // filtered out would falsely report the end of the catalog).
235
+ hasMore: json?.hasMore === true,
236
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawTracks.length),
237
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
238
+ };
188
239
  },
189
240
 
190
241
  async getTrack(id) {
@@ -231,21 +282,31 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
231
282
  if (typeof searchOptions.limit === 'number') {
232
283
  params.set('limit', String(searchOptions.limit));
233
284
  }
285
+ if (typeof searchOptions.offset === 'number') {
286
+ params.set('offset', String(searchOptions.offset));
287
+ }
234
288
 
235
289
  const json = (await getJson(
236
290
  `/api/podcasts/search?${params.toString()}`,
237
291
  )) as PodcastSearchResponseShape;
238
292
  const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
239
293
 
240
- const podcasts: PodcastSummary[] = [];
294
+ const items: PodcastSummary[] = [];
241
295
  for (const raw of rawPodcasts) {
242
296
  // A single malformed catalog row must not fail the whole search.
243
297
  const parsed = podcastSummarySchema.safeParse(raw);
244
298
  if (parsed.success) {
245
- podcasts.push(parsed.data);
299
+ items.push(parsed.data);
246
300
  }
247
301
  }
248
- return podcasts;
302
+
303
+ return {
304
+ items,
305
+ // `hasMore` reflects the backend's pagination over the full result set.
306
+ hasMore: json?.hasMore === true,
307
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawPodcasts.length),
308
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
309
+ };
249
310
  },
250
311
 
251
312
  async getPodcast(id) {
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export type {
8
8
  SyraClientOptions,
9
9
  SearchTracksOptions,
10
10
  SearchPodcastsOptions,
11
+ SearchPage,
11
12
  ArtworkSource,
12
13
  PodcastArtworkSource,
13
14
  } from './client';