@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.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
trackSummarySchema,
|
|
3
3
|
podcastSummarySchema,
|
|
4
|
+
episodeSummarySchema,
|
|
4
5
|
type TrackSummary,
|
|
5
6
|
type PodcastSummary,
|
|
7
|
+
type EpisodeSummary,
|
|
6
8
|
type CoverArtSizes,
|
|
7
9
|
type ArtworkSize,
|
|
8
10
|
} from './schema';
|
|
@@ -32,13 +34,44 @@ export interface SyraClientOptions {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export interface SearchTracksOptions {
|
|
35
|
-
/** Maximum number of tracks to request from the API. */
|
|
37
|
+
/** Maximum number of tracks to request from the API (the page size). */
|
|
36
38
|
limit?: number;
|
|
39
|
+
/** Zero-based offset of the first track to return (for infinite scroll). */
|
|
40
|
+
offset?: number;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface SearchPodcastsOptions {
|
|
40
|
-
/** Maximum number of podcast shows to request from the API. */
|
|
44
|
+
/** Maximum number of podcast shows to request from the API (the page size). */
|
|
41
45
|
limit?: number;
|
|
46
|
+
/** Zero-based offset of the first show to return (for infinite scroll). */
|
|
47
|
+
offset?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PodcastEpisodesOptions {
|
|
51
|
+
/** Maximum number of episodes to request from the API (the page size). */
|
|
52
|
+
limit?: number;
|
|
53
|
+
/** Zero-based offset of the first episode to return (for infinite scroll). */
|
|
54
|
+
offset?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One page of paginated catalog search results.
|
|
59
|
+
*
|
|
60
|
+
* `hasMore` reflects the BACKEND's pagination over the full matching set — NOT
|
|
61
|
+
* `items.length`. {@link SyraClient.searchTracks} additionally filters its page
|
|
62
|
+
* client-side to preview-available tracks, so `items.length` can be smaller than
|
|
63
|
+
* `limit` while `hasMore` is still `true`; callers must paginate by advancing
|
|
64
|
+
* `offset` by `limit` (the page size), never by `items.length`.
|
|
65
|
+
*/
|
|
66
|
+
export interface SearchPage<T> {
|
|
67
|
+
/** The validated rows for this page. */
|
|
68
|
+
items: T[];
|
|
69
|
+
/** Whether the backend has results beyond this page. */
|
|
70
|
+
hasMore: boolean;
|
|
71
|
+
/** The page size the backend applied. */
|
|
72
|
+
limit: number;
|
|
73
|
+
/** The zero-based offset of this page. */
|
|
74
|
+
offset: number;
|
|
42
75
|
}
|
|
43
76
|
|
|
44
77
|
/** Minimal shape from which track artwork URLs can be derived. */
|
|
@@ -54,12 +87,21 @@ export interface PodcastArtworkSource {
|
|
|
54
87
|
imageSourceUrl?: string | null;
|
|
55
88
|
}
|
|
56
89
|
|
|
90
|
+
/** Minimal shape from which podcast-episode artwork URLs can be derived. */
|
|
91
|
+
export interface EpisodeArtworkSource {
|
|
92
|
+
image?: string | null;
|
|
93
|
+
imageSizes?: CoverArtSizes | null;
|
|
94
|
+
imageSourceUrl?: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
57
97
|
export interface SyraClient {
|
|
58
98
|
/**
|
|
59
|
-
* Search the public catalog for tracks.
|
|
60
|
-
* track-summary schema and filtered to those that expose
|
|
99
|
+
* Search the public catalog for tracks. Returns one paginated page: rows are
|
|
100
|
+
* validated against the track-summary schema and filtered to those that expose
|
|
101
|
+
* a public preview. `hasMore` comes from the backend's pagination, so it is
|
|
102
|
+
* unaffected by the client-side preview filter (see {@link SearchPage}).
|
|
61
103
|
*/
|
|
62
|
-
searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary
|
|
104
|
+
searchTracks(query: string, options?: SearchTracksOptions): Promise<SearchPage<TrackSummary>>;
|
|
63
105
|
/** Fetch a single track by id, validated against the track-summary schema. */
|
|
64
106
|
getTrack(id: string): Promise<TrackSummary>;
|
|
65
107
|
/** Build the public 30s preview URL for a track at the given start offset. */
|
|
@@ -70,10 +112,11 @@ export interface SyraClient {
|
|
|
70
112
|
*/
|
|
71
113
|
artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
|
|
72
114
|
/**
|
|
73
|
-
* Search the public catalog for podcast SHOWS (not episodes).
|
|
74
|
-
* validated against the podcast-summary schema
|
|
115
|
+
* Search the public catalog for podcast SHOWS (not episodes). Returns one
|
|
116
|
+
* paginated page: rows are validated against the podcast-summary schema and
|
|
117
|
+
* malformed rows are dropped. `hasMore` comes from the backend's pagination.
|
|
75
118
|
*/
|
|
76
|
-
searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<PodcastSummary
|
|
119
|
+
searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<SearchPage<PodcastSummary>>;
|
|
77
120
|
/**
|
|
78
121
|
* Fetch a single podcast show by id, validated against the podcast-summary
|
|
79
122
|
* schema. The by-id endpoint also returns episodes and resolved persons; this
|
|
@@ -88,6 +131,31 @@ export interface SyraClient {
|
|
|
88
131
|
* external artwork URL. Returns `undefined` when no artwork can be derived.
|
|
89
132
|
*/
|
|
90
133
|
podcastArtworkUrl(source: PodcastArtworkSource, size?: ArtworkSize): string | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* List a podcast show's EPISODES (newest first, as the backend orders them).
|
|
136
|
+
* Returns one paginated page: rows are validated against the episode-summary
|
|
137
|
+
* schema and malformed rows are dropped — including any without a playable
|
|
138
|
+
* `enclosureUrl`, which the schema requires. The backend paginates by 1-based
|
|
139
|
+
* `page`, but this keeps the uniform offset-based {@link SearchPage} for parity
|
|
140
|
+
* with {@link SyraClient.searchPodcasts}; paginate by advancing `offset` by
|
|
141
|
+
* `limit` (the page size), never by `items.length`.
|
|
142
|
+
*/
|
|
143
|
+
getPodcastEpisodes(
|
|
144
|
+
podcastId: string,
|
|
145
|
+
options?: PodcastEpisodesOptions,
|
|
146
|
+
): Promise<SearchPage<EpisodeSummary>>;
|
|
147
|
+
/**
|
|
148
|
+
* Fetch a single episode by id, validated against the episode-summary schema.
|
|
149
|
+
* The by-id endpoint nests the episode under `data.episode` alongside resolved
|
|
150
|
+
* persons; this returns just the episode summary needed to stream its audio.
|
|
151
|
+
*/
|
|
152
|
+
getEpisode(episodeId: string): Promise<EpisodeSummary>;
|
|
153
|
+
/**
|
|
154
|
+
* Resolve an absolute artwork URL from a podcast episode reference. Prefers the
|
|
155
|
+
* re-hosted Syra image, then the requested/fallback variant, then the original
|
|
156
|
+
* external artwork URL. Returns `undefined` when no artwork can be derived.
|
|
157
|
+
*/
|
|
158
|
+
episodeImageUrl(source: EpisodeArtworkSource, size?: ArtworkSize): string | undefined;
|
|
91
159
|
}
|
|
92
160
|
|
|
93
161
|
/** Order used to pick the best available artwork variant when none is named. */
|
|
@@ -102,18 +170,46 @@ const ARTWORK_FALLBACK_ORDER: ArtworkSize[] = [
|
|
|
102
170
|
|
|
103
171
|
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
|
|
104
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Default episode page size, matching the backend's own default so the SDK's
|
|
175
|
+
* offset→page translation lines up with the server's pagination window.
|
|
176
|
+
*/
|
|
177
|
+
const DEFAULT_EPISODES_PAGE_SIZE = 20;
|
|
178
|
+
|
|
179
|
+
/** Read a finite number from an unknown response field, else a fallback. */
|
|
180
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
181
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
182
|
+
}
|
|
183
|
+
|
|
105
184
|
interface SearchResponseShape {
|
|
106
185
|
results?: { tracks?: unknown[] };
|
|
186
|
+
hasMore?: unknown;
|
|
187
|
+
limit?: unknown;
|
|
188
|
+
offset?: unknown;
|
|
107
189
|
}
|
|
108
190
|
|
|
109
191
|
interface PodcastSearchResponseShape {
|
|
110
192
|
data?: unknown[];
|
|
193
|
+
hasMore?: unknown;
|
|
194
|
+
limit?: unknown;
|
|
195
|
+
offset?: unknown;
|
|
111
196
|
}
|
|
112
197
|
|
|
113
198
|
interface PodcastDetailResponseShape {
|
|
114
199
|
data?: { podcast?: unknown };
|
|
115
200
|
}
|
|
116
201
|
|
|
202
|
+
interface PodcastEpisodesResponseShape {
|
|
203
|
+
data?: unknown[];
|
|
204
|
+
total?: unknown;
|
|
205
|
+
page?: unknown;
|
|
206
|
+
limit?: unknown;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface EpisodeDetailResponseShape {
|
|
210
|
+
data?: { episode?: unknown };
|
|
211
|
+
}
|
|
212
|
+
|
|
117
213
|
/**
|
|
118
214
|
* Create a headless client for the public Syra API. Public reads only — there
|
|
119
215
|
* is no authentication in this version.
|
|
@@ -172,19 +268,32 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
172
268
|
if (typeof searchOptions.limit === 'number') {
|
|
173
269
|
params.set('limit', String(searchOptions.limit));
|
|
174
270
|
}
|
|
271
|
+
if (typeof searchOptions.offset === 'number') {
|
|
272
|
+
params.set('offset', String(searchOptions.offset));
|
|
273
|
+
}
|
|
175
274
|
|
|
176
275
|
const json = (await getJson(`/api/search?${params.toString()}`)) as SearchResponseShape;
|
|
177
276
|
const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
|
|
178
277
|
|
|
179
|
-
const
|
|
278
|
+
const items: TrackSummary[] = [];
|
|
180
279
|
for (const raw of rawTracks) {
|
|
181
280
|
// A single malformed catalog row must not fail the whole search.
|
|
182
281
|
const parsed = trackSummarySchema.safeParse(raw);
|
|
183
282
|
if (parsed.success && parsed.data.previewAvailable === true) {
|
|
184
|
-
|
|
283
|
+
items.push(parsed.data);
|
|
185
284
|
}
|
|
186
285
|
}
|
|
187
|
-
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
items,
|
|
289
|
+
// `hasMore` is sourced from the backend's pagination over the FULL result
|
|
290
|
+
// set; the client-side preview filter above may shrink `items` below
|
|
291
|
+
// `limit`, but must NOT corrupt `hasMore` (else a page whose tail was
|
|
292
|
+
// filtered out would falsely report the end of the catalog).
|
|
293
|
+
hasMore: json?.hasMore === true,
|
|
294
|
+
limit: numberOr(json?.limit, searchOptions.limit ?? rawTracks.length),
|
|
295
|
+
offset: numberOr(json?.offset, searchOptions.offset ?? 0),
|
|
296
|
+
};
|
|
188
297
|
},
|
|
189
298
|
|
|
190
299
|
async getTrack(id) {
|
|
@@ -231,21 +340,31 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
231
340
|
if (typeof searchOptions.limit === 'number') {
|
|
232
341
|
params.set('limit', String(searchOptions.limit));
|
|
233
342
|
}
|
|
343
|
+
if (typeof searchOptions.offset === 'number') {
|
|
344
|
+
params.set('offset', String(searchOptions.offset));
|
|
345
|
+
}
|
|
234
346
|
|
|
235
347
|
const json = (await getJson(
|
|
236
348
|
`/api/podcasts/search?${params.toString()}`,
|
|
237
349
|
)) as PodcastSearchResponseShape;
|
|
238
350
|
const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
|
|
239
351
|
|
|
240
|
-
const
|
|
352
|
+
const items: PodcastSummary[] = [];
|
|
241
353
|
for (const raw of rawPodcasts) {
|
|
242
354
|
// A single malformed catalog row must not fail the whole search.
|
|
243
355
|
const parsed = podcastSummarySchema.safeParse(raw);
|
|
244
356
|
if (parsed.success) {
|
|
245
|
-
|
|
357
|
+
items.push(parsed.data);
|
|
246
358
|
}
|
|
247
359
|
}
|
|
248
|
-
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
items,
|
|
363
|
+
// `hasMore` reflects the backend's pagination over the full result set.
|
|
364
|
+
hasMore: json?.hasMore === true,
|
|
365
|
+
limit: numberOr(json?.limit, searchOptions.limit ?? rawPodcasts.length),
|
|
366
|
+
offset: numberOr(json?.offset, searchOptions.offset ?? 0),
|
|
367
|
+
};
|
|
249
368
|
},
|
|
250
369
|
|
|
251
370
|
async getPodcast(id) {
|
|
@@ -283,5 +402,72 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
283
402
|
|
|
284
403
|
return resolveImageRef(source.imageSourceUrl);
|
|
285
404
|
},
|
|
405
|
+
|
|
406
|
+
async getPodcastEpisodes(podcastId, listOptions = {}) {
|
|
407
|
+
// The endpoint paginates by 1-based `page`; translate the SDK's uniform
|
|
408
|
+
// offset-based paging into it. `limit` must be concrete (unlike search,
|
|
409
|
+
// which can omit it) because the page number is derived from it.
|
|
410
|
+
const limit = listOptions.limit ?? DEFAULT_EPISODES_PAGE_SIZE;
|
|
411
|
+
const offset = listOptions.offset ?? 0;
|
|
412
|
+
const page = Math.floor(offset / limit) + 1;
|
|
413
|
+
|
|
414
|
+
const json = (await getJson(
|
|
415
|
+
`/api/podcasts/${encodeURIComponent(podcastId)}/episodes?page=${page}&limit=${limit}`,
|
|
416
|
+
)) as PodcastEpisodesResponseShape;
|
|
417
|
+
const rawEpisodes = Array.isArray(json?.data) ? json.data : [];
|
|
418
|
+
|
|
419
|
+
const items: EpisodeSummary[] = [];
|
|
420
|
+
for (const raw of rawEpisodes) {
|
|
421
|
+
// A single malformed episode row must not fail the whole listing.
|
|
422
|
+
const parsed = episodeSummarySchema.safeParse(raw);
|
|
423
|
+
if (parsed.success) {
|
|
424
|
+
items.push(parsed.data);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// `total` is the backend's full count over the show; derive `hasMore` from
|
|
429
|
+
// it rather than `items.length`, which the schema/enclosure filter above may
|
|
430
|
+
// shrink below `limit` on a page that is NOT the last one. Absent a count,
|
|
431
|
+
// fall back to what we have (this page ends the listing).
|
|
432
|
+
const total = numberOr(json?.total, offset + items.length);
|
|
433
|
+
return {
|
|
434
|
+
items,
|
|
435
|
+
hasMore: page * limit < total,
|
|
436
|
+
limit,
|
|
437
|
+
offset,
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async getEpisode(episodeId) {
|
|
442
|
+
const json = (await getJson(
|
|
443
|
+
`/api/episodes/${encodeURIComponent(episodeId)}`,
|
|
444
|
+
)) as EpisodeDetailResponseShape;
|
|
445
|
+
return episodeSummarySchema.parse(json?.data?.episode);
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
episodeImageUrl(source, size) {
|
|
449
|
+
if (size && source.imageSizes) {
|
|
450
|
+
const resolved = resolveImageRef(source.imageSizes[size]?.url);
|
|
451
|
+
if (resolved) {
|
|
452
|
+
return resolved;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const fromImage = resolveImageRef(source.image);
|
|
457
|
+
if (fromImage) {
|
|
458
|
+
return fromImage;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (source.imageSizes) {
|
|
462
|
+
for (const key of ARTWORK_FALLBACK_ORDER) {
|
|
463
|
+
const resolved = resolveImageRef(source.imageSizes[key]?.url);
|
|
464
|
+
if (resolved) {
|
|
465
|
+
return resolved;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return resolveImageRef(source.imageSourceUrl);
|
|
471
|
+
},
|
|
286
472
|
};
|
|
287
473
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,18 +8,23 @@ export type {
|
|
|
8
8
|
SyraClientOptions,
|
|
9
9
|
SearchTracksOptions,
|
|
10
10
|
SearchPodcastsOptions,
|
|
11
|
+
PodcastEpisodesOptions,
|
|
12
|
+
SearchPage,
|
|
11
13
|
ArtworkSource,
|
|
12
14
|
PodcastArtworkSource,
|
|
15
|
+
EpisodeArtworkSource,
|
|
13
16
|
} from './client';
|
|
14
17
|
export {
|
|
15
18
|
trackSummarySchema,
|
|
16
19
|
podcastSummarySchema,
|
|
20
|
+
episodeSummarySchema,
|
|
17
21
|
coverArtSizesSchema,
|
|
18
22
|
coverArtVariantSchema,
|
|
19
23
|
} from './schema';
|
|
20
24
|
export type {
|
|
21
25
|
TrackSummary,
|
|
22
26
|
PodcastSummary,
|
|
27
|
+
EpisodeSummary,
|
|
23
28
|
CoverArtSizes,
|
|
24
29
|
CoverArtVariant,
|
|
25
30
|
ArtworkSize,
|
package/src/schema.ts
CHANGED
|
@@ -69,3 +69,37 @@ export const podcastSummarySchema = z.object({
|
|
|
69
69
|
imageSourceUrl: z.string().optional(),
|
|
70
70
|
});
|
|
71
71
|
export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The summary view of a podcast EPISODE returned by the public podcast endpoints
|
|
75
|
+
* (`GET /api/podcasts/:id/episodes`, `GET /api/episodes/:id`) — just enough to
|
|
76
|
+
* list an episode and stream its audio.
|
|
77
|
+
*
|
|
78
|
+
* `enclosureUrl` is the direct audio file URL (e.g.
|
|
79
|
+
* `https://api.fastcast.ai/audio/<guid>.mp3`) and is REQUIRED: an episode with
|
|
80
|
+
* no enclosure is unplayable, so a row missing it is treated as malformed and
|
|
81
|
+
* dropped rather than surfaced as a dead entry. `enclosureType` /
|
|
82
|
+
* `enclosureLength` describe that file (MIME type and byte length); `duration`
|
|
83
|
+
* is the runtime in seconds and `pubDate` the ISO publish timestamp.
|
|
84
|
+
*
|
|
85
|
+
* Artwork mirrors the podcast SHOW: `image` is the re-hosted Syra image id
|
|
86
|
+
* (resolved via `/api/images/:id`); `imageSizes` is the multi-resolution variant
|
|
87
|
+
* set (each variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the
|
|
88
|
+
* original external artwork URL as an absolute fallback when re-hosting has not
|
|
89
|
+
* run yet.
|
|
90
|
+
*/
|
|
91
|
+
export const episodeSummarySchema = z.object({
|
|
92
|
+
id: z.string(),
|
|
93
|
+
podcastId: z.string(),
|
|
94
|
+
title: z.string(),
|
|
95
|
+
description: z.string().optional(),
|
|
96
|
+
enclosureUrl: z.string(),
|
|
97
|
+
enclosureType: z.string().optional(),
|
|
98
|
+
enclosureLength: z.number().optional(),
|
|
99
|
+
duration: z.number().optional(),
|
|
100
|
+
pubDate: z.string().optional(),
|
|
101
|
+
image: z.string().optional(),
|
|
102
|
+
imageSizes: coverArtSizesSchema.optional(),
|
|
103
|
+
imageSourceUrl: z.string().optional(),
|
|
104
|
+
});
|
|
105
|
+
export type EpisodeSummary = z.infer<typeof episodeSummarySchema>;
|