@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/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
- /** Minimal shape from which artwork URLs can be derived. */
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. Results are validated against the
37
- * 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}).
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 tracks: TrackSummary[] = [];
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
- tracks.push(parsed.data);
225
+ items.push(parsed.data);
134
226
  }
135
227
  }
136
- 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
+ };
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>;