@syra.fm/sdk 0.1.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 +77 -4
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/schema.js +20 -1
- package/dist/esm/client.js +77 -4
- package/dist/esm/index.js +2 -2
- package/dist/esm/schema.js +19 -0
- package/dist/types/client.d.ts +69 -6
- package/dist/types/index.d.ts +4 -4
- package/dist/types/schema.d.ts +281 -0
- package/package.json +1 -1
- package/src/client.test.ts +260 -9
- package/src/client.ts +178 -8
- package/src/index.ts +6 -0
- package/src/schema.ts +21 -0
package/src/client.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
trackSummarySchema,
|
|
3
|
+
podcastSummarySchema,
|
|
3
4
|
type TrackSummary,
|
|
5
|
+
type PodcastSummary,
|
|
4
6
|
type CoverArtSizes,
|
|
5
7
|
type ArtworkSize,
|
|
6
8
|
} from './schema';
|
|
@@ -9,9 +11,18 @@ import { SyraApiError } from './errors';
|
|
|
9
11
|
/** Default base URL of the public Syra API. */
|
|
10
12
|
export const DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
|
|
11
13
|
|
|
14
|
+
/** Default base URL of the Syra web app, used for deep links. */
|
|
15
|
+
export const DEFAULT_SYRA_WEB_BASE_URL = 'https://syra.fm';
|
|
16
|
+
|
|
12
17
|
export interface SyraClientOptions {
|
|
13
18
|
/** Base URL of the Syra API. Defaults to {@link DEFAULT_SYRA_BASE_URL}. */
|
|
14
19
|
baseURL?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Base URL of the Syra WEB app (not the API host), used to build deep links
|
|
22
|
+
* such as {@link SyraClient.podcastUrl}. Defaults to
|
|
23
|
+
* {@link DEFAULT_SYRA_WEB_BASE_URL}.
|
|
24
|
+
*/
|
|
25
|
+
webBaseURL?: string;
|
|
15
26
|
/**
|
|
16
27
|
* `fetch` implementation. Defaults to the global `fetch` (Node 18+, browsers,
|
|
17
28
|
* React Native). Inject one (e.g. `node-fetch`) when no global is available.
|
|
@@ -21,22 +32,60 @@ export interface SyraClientOptions {
|
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export interface SearchTracksOptions {
|
|
24
|
-
/** Maximum number of tracks to request from the API. */
|
|
35
|
+
/** Maximum number of tracks to request from the API (the page size). */
|
|
36
|
+
limit?: number;
|
|
37
|
+
/** Zero-based offset of the first track to return (for infinite scroll). */
|
|
38
|
+
offset?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SearchPodcastsOptions {
|
|
42
|
+
/** Maximum number of podcast shows to request from the API (the page size). */
|
|
25
43
|
limit?: number;
|
|
44
|
+
/** Zero-based offset of the first show to return (for infinite scroll). */
|
|
45
|
+
offset?: number;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
|
-
/**
|
|
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;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Minimal shape from which track artwork URLs can be derived. */
|
|
29
69
|
export interface ArtworkSource {
|
|
30
70
|
coverArt?: string | null;
|
|
31
71
|
coverArtSizes?: CoverArtSizes | null;
|
|
32
72
|
}
|
|
33
73
|
|
|
74
|
+
/** Minimal shape from which podcast-show artwork URLs can be derived. */
|
|
75
|
+
export interface PodcastArtworkSource {
|
|
76
|
+
image?: string | null;
|
|
77
|
+
imageSizes?: CoverArtSizes | null;
|
|
78
|
+
imageSourceUrl?: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
34
81
|
export interface SyraClient {
|
|
35
82
|
/**
|
|
36
|
-
* Search the public catalog for tracks.
|
|
37
|
-
* 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}).
|
|
38
87
|
*/
|
|
39
|
-
searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary
|
|
88
|
+
searchTracks(query: string, options?: SearchTracksOptions): Promise<SearchPage<TrackSummary>>;
|
|
40
89
|
/** Fetch a single track by id, validated against the track-summary schema. */
|
|
41
90
|
getTrack(id: string): Promise<TrackSummary>;
|
|
42
91
|
/** Build the public 30s preview URL for a track at the given start offset. */
|
|
@@ -46,6 +95,26 @@ export interface SyraClient {
|
|
|
46
95
|
* `undefined` when no artwork can be derived.
|
|
47
96
|
*/
|
|
48
97
|
artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
|
|
98
|
+
/**
|
|
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.
|
|
102
|
+
*/
|
|
103
|
+
searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<SearchPage<PodcastSummary>>;
|
|
104
|
+
/**
|
|
105
|
+
* Fetch a single podcast show by id, validated against the podcast-summary
|
|
106
|
+
* schema. The by-id endpoint also returns episodes and resolved persons; this
|
|
107
|
+
* returns just the show summary needed to render a card.
|
|
108
|
+
*/
|
|
109
|
+
getPodcast(id: string): Promise<PodcastSummary>;
|
|
110
|
+
/** Build the Syra web app deep link for a podcast show (`/podcasts/:id`). */
|
|
111
|
+
podcastUrl(id: string): string;
|
|
112
|
+
/**
|
|
113
|
+
* Resolve an absolute artwork URL from a podcast show reference. Prefers the
|
|
114
|
+
* re-hosted Syra image, then the requested/fallback variant, then the original
|
|
115
|
+
* external artwork URL. Returns `undefined` when no artwork can be derived.
|
|
116
|
+
*/
|
|
117
|
+
podcastArtworkUrl(source: PodcastArtworkSource, size?: ArtworkSize): string | undefined;
|
|
49
118
|
}
|
|
50
119
|
|
|
51
120
|
/** Order used to pick the best available artwork variant when none is named. */
|
|
@@ -60,8 +129,27 @@ const ARTWORK_FALLBACK_ORDER: ArtworkSize[] = [
|
|
|
60
129
|
|
|
61
130
|
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
|
|
62
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
|
+
|
|
63
137
|
interface SearchResponseShape {
|
|
64
138
|
results?: { tracks?: unknown[] };
|
|
139
|
+
hasMore?: unknown;
|
|
140
|
+
limit?: unknown;
|
|
141
|
+
offset?: unknown;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface PodcastSearchResponseShape {
|
|
145
|
+
data?: unknown[];
|
|
146
|
+
hasMore?: unknown;
|
|
147
|
+
limit?: unknown;
|
|
148
|
+
offset?: unknown;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface PodcastDetailResponseShape {
|
|
152
|
+
data?: { podcast?: unknown };
|
|
65
153
|
}
|
|
66
154
|
|
|
67
155
|
/**
|
|
@@ -70,6 +158,7 @@ interface SearchResponseShape {
|
|
|
70
158
|
*/
|
|
71
159
|
export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
72
160
|
const baseURL = (options.baseURL ?? DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
|
|
161
|
+
const webBaseURL = (options.webBaseURL ?? DEFAULT_SYRA_WEB_BASE_URL).replace(/\/+$/, '');
|
|
73
162
|
|
|
74
163
|
function resolveFetch(): typeof fetch {
|
|
75
164
|
if (options.fetch) {
|
|
@@ -121,19 +210,32 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
121
210
|
if (typeof searchOptions.limit === 'number') {
|
|
122
211
|
params.set('limit', String(searchOptions.limit));
|
|
123
212
|
}
|
|
213
|
+
if (typeof searchOptions.offset === 'number') {
|
|
214
|
+
params.set('offset', String(searchOptions.offset));
|
|
215
|
+
}
|
|
124
216
|
|
|
125
217
|
const json = (await getJson(`/api/search?${params.toString()}`)) as SearchResponseShape;
|
|
126
218
|
const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
|
|
127
219
|
|
|
128
|
-
const
|
|
220
|
+
const items: TrackSummary[] = [];
|
|
129
221
|
for (const raw of rawTracks) {
|
|
130
222
|
// A single malformed catalog row must not fail the whole search.
|
|
131
223
|
const parsed = trackSummarySchema.safeParse(raw);
|
|
132
224
|
if (parsed.success && parsed.data.previewAvailable === true) {
|
|
133
|
-
|
|
225
|
+
items.push(parsed.data);
|
|
134
226
|
}
|
|
135
227
|
}
|
|
136
|
-
|
|
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
|
+
};
|
|
137
239
|
},
|
|
138
240
|
|
|
139
241
|
async getTrack(id) {
|
|
@@ -174,5 +276,73 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
174
276
|
|
|
175
277
|
return undefined;
|
|
176
278
|
},
|
|
279
|
+
|
|
280
|
+
async searchPodcasts(query, searchOptions = {}) {
|
|
281
|
+
const params = new URLSearchParams({ q: query });
|
|
282
|
+
if (typeof searchOptions.limit === 'number') {
|
|
283
|
+
params.set('limit', String(searchOptions.limit));
|
|
284
|
+
}
|
|
285
|
+
if (typeof searchOptions.offset === 'number') {
|
|
286
|
+
params.set('offset', String(searchOptions.offset));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const json = (await getJson(
|
|
290
|
+
`/api/podcasts/search?${params.toString()}`,
|
|
291
|
+
)) as PodcastSearchResponseShape;
|
|
292
|
+
const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
|
|
293
|
+
|
|
294
|
+
const items: PodcastSummary[] = [];
|
|
295
|
+
for (const raw of rawPodcasts) {
|
|
296
|
+
// A single malformed catalog row must not fail the whole search.
|
|
297
|
+
const parsed = podcastSummarySchema.safeParse(raw);
|
|
298
|
+
if (parsed.success) {
|
|
299
|
+
items.push(parsed.data);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
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
|
+
};
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async getPodcast(id) {
|
|
313
|
+
const json = (await getJson(
|
|
314
|
+
`/api/podcasts/${encodeURIComponent(id)}`,
|
|
315
|
+
)) as PodcastDetailResponseShape;
|
|
316
|
+
return podcastSummarySchema.parse(json?.data?.podcast);
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
podcastUrl(id) {
|
|
320
|
+
return `${webBaseURL}/podcasts/${encodeURIComponent(id)}`;
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
podcastArtworkUrl(source, size) {
|
|
324
|
+
if (size && source.imageSizes) {
|
|
325
|
+
const resolved = resolveImageRef(source.imageSizes[size]?.url);
|
|
326
|
+
if (resolved) {
|
|
327
|
+
return resolved;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const fromImage = resolveImageRef(source.image);
|
|
332
|
+
if (fromImage) {
|
|
333
|
+
return fromImage;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (source.imageSizes) {
|
|
337
|
+
for (const key of ARTWORK_FALLBACK_ORDER) {
|
|
338
|
+
const resolved = resolveImageRef(source.imageSizes[key]?.url);
|
|
339
|
+
if (resolved) {
|
|
340
|
+
return resolved;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return resolveImageRef(source.imageSourceUrl);
|
|
346
|
+
},
|
|
177
347
|
};
|
|
178
348
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
export {
|
|
2
2
|
createSyraClient,
|
|
3
3
|
DEFAULT_SYRA_BASE_URL,
|
|
4
|
+
DEFAULT_SYRA_WEB_BASE_URL,
|
|
4
5
|
} from './client';
|
|
5
6
|
export type {
|
|
6
7
|
SyraClient,
|
|
7
8
|
SyraClientOptions,
|
|
8
9
|
SearchTracksOptions,
|
|
10
|
+
SearchPodcastsOptions,
|
|
11
|
+
SearchPage,
|
|
9
12
|
ArtworkSource,
|
|
13
|
+
PodcastArtworkSource,
|
|
10
14
|
} from './client';
|
|
11
15
|
export {
|
|
12
16
|
trackSummarySchema,
|
|
17
|
+
podcastSummarySchema,
|
|
13
18
|
coverArtSizesSchema,
|
|
14
19
|
coverArtVariantSchema,
|
|
15
20
|
} from './schema';
|
|
16
21
|
export type {
|
|
17
22
|
TrackSummary,
|
|
23
|
+
PodcastSummary,
|
|
18
24
|
CoverArtSizes,
|
|
19
25
|
CoverArtVariant,
|
|
20
26
|
ArtworkSize,
|
package/src/schema.ts
CHANGED
|
@@ -48,3 +48,24 @@ export const trackSummarySchema = z.object({
|
|
|
48
48
|
previewAvailable: z.boolean().optional(),
|
|
49
49
|
});
|
|
50
50
|
export type TrackSummary = z.infer<typeof trackSummarySchema>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The summary view of a podcast SHOW returned by the public podcast endpoints
|
|
54
|
+
* (`GET /api/podcasts/search`, `GET /api/podcasts/:id`) — just enough to render
|
|
55
|
+
* a show card and deep-link into the Syra app.
|
|
56
|
+
*
|
|
57
|
+
* Artwork mirrors tracks: `image` is the re-hosted Syra image id (resolved via
|
|
58
|
+
* `/api/images/:id`); `imageSizes` is the multi-resolution variant set (each
|
|
59
|
+
* variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the original
|
|
60
|
+
* external artwork URL as an absolute fallback when re-hosting has not run yet.
|
|
61
|
+
*/
|
|
62
|
+
export const podcastSummarySchema = z.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
title: z.string(),
|
|
65
|
+
author: z.string().optional(),
|
|
66
|
+
description: z.string().optional(),
|
|
67
|
+
image: z.string().optional(),
|
|
68
|
+
imageSizes: coverArtSizesSchema.optional(),
|
|
69
|
+
imageSourceUrl: z.string().optional(),
|
|
70
|
+
});
|
|
71
|
+
export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
|