@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 +16 -4
- package/dist/cjs/client.js +31 -6
- package/dist/esm/client.js +31 -6
- package/dist/types/client.d.ts +34 -8
- package/dist/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +101 -18
- package/src/client.ts +75 -14
- package/src/index.ts +1 -0
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
|
|
22
|
-
|
|
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(
|
|
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 })` |
|
|
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.
|
package/dist/cjs/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
84
|
+
items.push(parsed.data);
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
|
-
return
|
|
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
|
|
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
|
-
|
|
145
|
+
items.push(parsed.data);
|
|
127
146
|
}
|
|
128
147
|
}
|
|
129
|
-
return
|
|
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)}`));
|
package/dist/esm/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
+
items.push(parsed.data);
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
|
-
return
|
|
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
|
|
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
|
-
|
|
141
|
+
items.push(parsed.data);
|
|
123
142
|
}
|
|
124
143
|
}
|
|
125
|
-
return
|
|
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)}`));
|
package/dist/types/client.d.ts
CHANGED
|
@@ -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.
|
|
44
|
-
* track-summary schema and filtered to those that expose
|
|
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).
|
|
58
|
-
* validated against the podcast-summary schema
|
|
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
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/src/client.test.ts
CHANGED
|
@@ -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
|
|
89
|
+
const page = await client.searchTracks('hello', { limit: 10 });
|
|
87
90
|
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
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
|
|
113
|
-
expect(
|
|
165
|
+
const page = await client.searchTracks('x');
|
|
166
|
+
expect(page.items).toHaveLength(1);
|
|
114
167
|
});
|
|
115
168
|
|
|
116
|
-
it('returns an empty
|
|
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
|
-
|
|
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
|
|
295
|
+
const page = await client.searchPodcasts('news', { limit: 5 });
|
|
237
296
|
|
|
238
|
-
expect(
|
|
239
|
-
expect(
|
|
240
|
-
expect(
|
|
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
|
|
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
|
|
263
|
-
expect(
|
|
342
|
+
const page = await client.searchPodcasts('x');
|
|
343
|
+
expect(page.items).toHaveLength(1);
|
|
264
344
|
});
|
|
265
345
|
|
|
266
|
-
it('returns an empty
|
|
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
|
-
|
|
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.
|
|
60
|
-
* track-summary schema and filtered to those that expose
|
|
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).
|
|
74
|
-
* validated against the podcast-summary schema
|
|
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
|
|
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
|
-
|
|
225
|
+
items.push(parsed.data);
|
|
185
226
|
}
|
|
186
227
|
}
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
299
|
+
items.push(parsed.data);
|
|
246
300
|
}
|
|
247
301
|
}
|
|
248
|
-
|
|
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) {
|