@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 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,15 @@ const ARTWORK_FALLBACK_ORDER = [
18
18
  'small',
19
19
  ];
20
20
  const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
21
+ /**
22
+ * Default episode page size, matching the backend's own default so the SDK's
23
+ * offset→page translation lines up with the server's pagination window.
24
+ */
25
+ const DEFAULT_EPISODES_PAGE_SIZE = 20;
26
+ /** Read a finite number from an unknown response field, else a fallback. */
27
+ function numberOr(value, fallback) {
28
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
29
+ }
21
30
  /**
22
31
  * Create a headless client for the public Syra API. Public reads only — there
23
32
  * is no authentication in this version.
@@ -67,17 +76,29 @@ function createSyraClient(options = {}) {
67
76
  if (typeof searchOptions.limit === 'number') {
68
77
  params.set('limit', String(searchOptions.limit));
69
78
  }
79
+ if (typeof searchOptions.offset === 'number') {
80
+ params.set('offset', String(searchOptions.offset));
81
+ }
70
82
  const json = (await getJson(`/api/search?${params.toString()}`));
71
83
  const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
72
- const tracks = [];
84
+ const items = [];
73
85
  for (const raw of rawTracks) {
74
86
  // A single malformed catalog row must not fail the whole search.
75
87
  const parsed = schema_1.trackSummarySchema.safeParse(raw);
76
88
  if (parsed.success && parsed.data.previewAvailable === true) {
77
- tracks.push(parsed.data);
89
+ items.push(parsed.data);
78
90
  }
79
91
  }
80
- return tracks;
92
+ return {
93
+ items,
94
+ // `hasMore` is sourced from the backend's pagination over the FULL result
95
+ // set; the client-side preview filter above may shrink `items` below
96
+ // `limit`, but must NOT corrupt `hasMore` (else a page whose tail was
97
+ // filtered out would falsely report the end of the catalog).
98
+ hasMore: json?.hasMore === true,
99
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawTracks.length),
100
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
101
+ };
81
102
  },
82
103
  async getTrack(id) {
83
104
  const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
@@ -116,17 +137,26 @@ function createSyraClient(options = {}) {
116
137
  if (typeof searchOptions.limit === 'number') {
117
138
  params.set('limit', String(searchOptions.limit));
118
139
  }
140
+ if (typeof searchOptions.offset === 'number') {
141
+ params.set('offset', String(searchOptions.offset));
142
+ }
119
143
  const json = (await getJson(`/api/podcasts/search?${params.toString()}`));
120
144
  const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
121
- const podcasts = [];
145
+ const items = [];
122
146
  for (const raw of rawPodcasts) {
123
147
  // A single malformed catalog row must not fail the whole search.
124
148
  const parsed = schema_1.podcastSummarySchema.safeParse(raw);
125
149
  if (parsed.success) {
126
- podcasts.push(parsed.data);
150
+ items.push(parsed.data);
127
151
  }
128
152
  }
129
- return podcasts;
153
+ return {
154
+ items,
155
+ // `hasMore` reflects the backend's pagination over the full result set.
156
+ hasMore: json?.hasMore === true,
157
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawPodcasts.length),
158
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
159
+ };
130
160
  },
131
161
  async getPodcast(id) {
132
162
  const json = (await getJson(`/api/podcasts/${encodeURIComponent(id)}`));
@@ -156,5 +186,59 @@ function createSyraClient(options = {}) {
156
186
  }
157
187
  return resolveImageRef(source.imageSourceUrl);
158
188
  },
189
+ async getPodcastEpisodes(podcastId, listOptions = {}) {
190
+ // The endpoint paginates by 1-based `page`; translate the SDK's uniform
191
+ // offset-based paging into it. `limit` must be concrete (unlike search,
192
+ // which can omit it) because the page number is derived from it.
193
+ const limit = listOptions.limit ?? DEFAULT_EPISODES_PAGE_SIZE;
194
+ const offset = listOptions.offset ?? 0;
195
+ const page = Math.floor(offset / limit) + 1;
196
+ const json = (await getJson(`/api/podcasts/${encodeURIComponent(podcastId)}/episodes?page=${page}&limit=${limit}`));
197
+ const rawEpisodes = Array.isArray(json?.data) ? json.data : [];
198
+ const items = [];
199
+ for (const raw of rawEpisodes) {
200
+ // A single malformed episode row must not fail the whole listing.
201
+ const parsed = schema_1.episodeSummarySchema.safeParse(raw);
202
+ if (parsed.success) {
203
+ items.push(parsed.data);
204
+ }
205
+ }
206
+ // `total` is the backend's full count over the show; derive `hasMore` from
207
+ // it rather than `items.length`, which the schema/enclosure filter above may
208
+ // shrink below `limit` on a page that is NOT the last one. Absent a count,
209
+ // fall back to what we have (this page ends the listing).
210
+ const total = numberOr(json?.total, offset + items.length);
211
+ return {
212
+ items,
213
+ hasMore: page * limit < total,
214
+ limit,
215
+ offset,
216
+ };
217
+ },
218
+ async getEpisode(episodeId) {
219
+ const json = (await getJson(`/api/episodes/${encodeURIComponent(episodeId)}`));
220
+ return schema_1.episodeSummarySchema.parse(json?.data?.episode);
221
+ },
222
+ episodeImageUrl(source, size) {
223
+ if (size && source.imageSizes) {
224
+ const resolved = resolveImageRef(source.imageSizes[size]?.url);
225
+ if (resolved) {
226
+ return resolved;
227
+ }
228
+ }
229
+ const fromImage = resolveImageRef(source.image);
230
+ if (fromImage) {
231
+ return fromImage;
232
+ }
233
+ if (source.imageSizes) {
234
+ for (const key of ARTWORK_FALLBACK_ORDER) {
235
+ const resolved = resolveImageRef(source.imageSizes[key]?.url);
236
+ if (resolved) {
237
+ return resolved;
238
+ }
239
+ }
240
+ }
241
+ return resolveImageRef(source.imageSourceUrl);
242
+ },
159
243
  };
160
244
  }
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SyraApiError = exports.coverArtVariantSchema = exports.coverArtSizesSchema = exports.podcastSummarySchema = exports.trackSummarySchema = exports.DEFAULT_SYRA_WEB_BASE_URL = exports.DEFAULT_SYRA_BASE_URL = exports.createSyraClient = void 0;
3
+ exports.SyraApiError = exports.coverArtVariantSchema = exports.coverArtSizesSchema = exports.episodeSummarySchema = exports.podcastSummarySchema = exports.trackSummarySchema = exports.DEFAULT_SYRA_WEB_BASE_URL = exports.DEFAULT_SYRA_BASE_URL = exports.createSyraClient = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "createSyraClient", { enumerable: true, get: function () { return client_1.createSyraClient; } });
6
6
  Object.defineProperty(exports, "DEFAULT_SYRA_BASE_URL", { enumerable: true, get: function () { return client_1.DEFAULT_SYRA_BASE_URL; } });
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "DEFAULT_SYRA_WEB_BASE_URL", { enumerable: true,
8
8
  var schema_1 = require("./schema");
9
9
  Object.defineProperty(exports, "trackSummarySchema", { enumerable: true, get: function () { return schema_1.trackSummarySchema; } });
10
10
  Object.defineProperty(exports, "podcastSummarySchema", { enumerable: true, get: function () { return schema_1.podcastSummarySchema; } });
11
+ Object.defineProperty(exports, "episodeSummarySchema", { enumerable: true, get: function () { return schema_1.episodeSummarySchema; } });
11
12
  Object.defineProperty(exports, "coverArtSizesSchema", { enumerable: true, get: function () { return schema_1.coverArtSizesSchema; } });
12
13
  Object.defineProperty(exports, "coverArtVariantSchema", { enumerable: true, get: function () { return schema_1.coverArtVariantSchema; } });
13
14
  var errors_1 = require("./errors");
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.podcastSummarySchema = exports.trackSummarySchema = exports.coverArtSizesSchema = exports.coverArtVariantSchema = void 0;
3
+ exports.episodeSummarySchema = exports.podcastSummarySchema = exports.trackSummarySchema = exports.coverArtSizesSchema = exports.coverArtVariantSchema = void 0;
4
4
  const zod_1 = require("zod");
5
5
  /**
6
6
  * Minimal, self-contained schemas for the public Syra API response shapes this
@@ -60,3 +60,35 @@ exports.podcastSummarySchema = zod_1.z.object({
60
60
  imageSizes: exports.coverArtSizesSchema.optional(),
61
61
  imageSourceUrl: zod_1.z.string().optional(),
62
62
  });
63
+ /**
64
+ * The summary view of a podcast EPISODE returned by the public podcast endpoints
65
+ * (`GET /api/podcasts/:id/episodes`, `GET /api/episodes/:id`) — just enough to
66
+ * list an episode and stream its audio.
67
+ *
68
+ * `enclosureUrl` is the direct audio file URL (e.g.
69
+ * `https://api.fastcast.ai/audio/<guid>.mp3`) and is REQUIRED: an episode with
70
+ * no enclosure is unplayable, so a row missing it is treated as malformed and
71
+ * dropped rather than surfaced as a dead entry. `enclosureType` /
72
+ * `enclosureLength` describe that file (MIME type and byte length); `duration`
73
+ * is the runtime in seconds and `pubDate` the ISO publish timestamp.
74
+ *
75
+ * Artwork mirrors the podcast SHOW: `image` is the re-hosted Syra image id
76
+ * (resolved via `/api/images/:id`); `imageSizes` is the multi-resolution variant
77
+ * set (each variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the
78
+ * original external artwork URL as an absolute fallback when re-hosting has not
79
+ * run yet.
80
+ */
81
+ exports.episodeSummarySchema = zod_1.z.object({
82
+ id: zod_1.z.string(),
83
+ podcastId: zod_1.z.string(),
84
+ title: zod_1.z.string(),
85
+ description: zod_1.z.string().optional(),
86
+ enclosureUrl: zod_1.z.string(),
87
+ enclosureType: zod_1.z.string().optional(),
88
+ enclosureLength: zod_1.z.number().optional(),
89
+ duration: zod_1.z.number().optional(),
90
+ pubDate: zod_1.z.string().optional(),
91
+ image: zod_1.z.string().optional(),
92
+ imageSizes: exports.coverArtSizesSchema.optional(),
93
+ imageSourceUrl: zod_1.z.string().optional(),
94
+ });
@@ -1,4 +1,4 @@
1
- import { trackSummarySchema, podcastSummarySchema, } from './schema.js';
1
+ import { trackSummarySchema, podcastSummarySchema, episodeSummarySchema, } from './schema.js';
2
2
  import { SyraApiError } from './errors.js';
3
3
  /** Default base URL of the public Syra API. */
4
4
  export const DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
@@ -14,6 +14,15 @@ const ARTWORK_FALLBACK_ORDER = [
14
14
  'small',
15
15
  ];
16
16
  const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
17
+ /**
18
+ * Default episode page size, matching the backend's own default so the SDK's
19
+ * offset→page translation lines up with the server's pagination window.
20
+ */
21
+ const DEFAULT_EPISODES_PAGE_SIZE = 20;
22
+ /** Read a finite number from an unknown response field, else a fallback. */
23
+ function numberOr(value, fallback) {
24
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
25
+ }
17
26
  /**
18
27
  * Create a headless client for the public Syra API. Public reads only — there
19
28
  * is no authentication in this version.
@@ -63,17 +72,29 @@ export function createSyraClient(options = {}) {
63
72
  if (typeof searchOptions.limit === 'number') {
64
73
  params.set('limit', String(searchOptions.limit));
65
74
  }
75
+ if (typeof searchOptions.offset === 'number') {
76
+ params.set('offset', String(searchOptions.offset));
77
+ }
66
78
  const json = (await getJson(`/api/search?${params.toString()}`));
67
79
  const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
68
- const tracks = [];
80
+ const items = [];
69
81
  for (const raw of rawTracks) {
70
82
  // A single malformed catalog row must not fail the whole search.
71
83
  const parsed = trackSummarySchema.safeParse(raw);
72
84
  if (parsed.success && parsed.data.previewAvailable === true) {
73
- tracks.push(parsed.data);
85
+ items.push(parsed.data);
74
86
  }
75
87
  }
76
- return tracks;
88
+ return {
89
+ items,
90
+ // `hasMore` is sourced from the backend's pagination over the FULL result
91
+ // set; the client-side preview filter above may shrink `items` below
92
+ // `limit`, but must NOT corrupt `hasMore` (else a page whose tail was
93
+ // filtered out would falsely report the end of the catalog).
94
+ hasMore: json?.hasMore === true,
95
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawTracks.length),
96
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
97
+ };
77
98
  },
78
99
  async getTrack(id) {
79
100
  const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
@@ -112,17 +133,26 @@ export function createSyraClient(options = {}) {
112
133
  if (typeof searchOptions.limit === 'number') {
113
134
  params.set('limit', String(searchOptions.limit));
114
135
  }
136
+ if (typeof searchOptions.offset === 'number') {
137
+ params.set('offset', String(searchOptions.offset));
138
+ }
115
139
  const json = (await getJson(`/api/podcasts/search?${params.toString()}`));
116
140
  const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
117
- const podcasts = [];
141
+ const items = [];
118
142
  for (const raw of rawPodcasts) {
119
143
  // A single malformed catalog row must not fail the whole search.
120
144
  const parsed = podcastSummarySchema.safeParse(raw);
121
145
  if (parsed.success) {
122
- podcasts.push(parsed.data);
146
+ items.push(parsed.data);
123
147
  }
124
148
  }
125
- return podcasts;
149
+ return {
150
+ items,
151
+ // `hasMore` reflects the backend's pagination over the full result set.
152
+ hasMore: json?.hasMore === true,
153
+ limit: numberOr(json?.limit, searchOptions.limit ?? rawPodcasts.length),
154
+ offset: numberOr(json?.offset, searchOptions.offset ?? 0),
155
+ };
126
156
  },
127
157
  async getPodcast(id) {
128
158
  const json = (await getJson(`/api/podcasts/${encodeURIComponent(id)}`));
@@ -152,5 +182,59 @@ export function createSyraClient(options = {}) {
152
182
  }
153
183
  return resolveImageRef(source.imageSourceUrl);
154
184
  },
185
+ async getPodcastEpisodes(podcastId, listOptions = {}) {
186
+ // The endpoint paginates by 1-based `page`; translate the SDK's uniform
187
+ // offset-based paging into it. `limit` must be concrete (unlike search,
188
+ // which can omit it) because the page number is derived from it.
189
+ const limit = listOptions.limit ?? DEFAULT_EPISODES_PAGE_SIZE;
190
+ const offset = listOptions.offset ?? 0;
191
+ const page = Math.floor(offset / limit) + 1;
192
+ const json = (await getJson(`/api/podcasts/${encodeURIComponent(podcastId)}/episodes?page=${page}&limit=${limit}`));
193
+ const rawEpisodes = Array.isArray(json?.data) ? json.data : [];
194
+ const items = [];
195
+ for (const raw of rawEpisodes) {
196
+ // A single malformed episode row must not fail the whole listing.
197
+ const parsed = episodeSummarySchema.safeParse(raw);
198
+ if (parsed.success) {
199
+ items.push(parsed.data);
200
+ }
201
+ }
202
+ // `total` is the backend's full count over the show; derive `hasMore` from
203
+ // it rather than `items.length`, which the schema/enclosure filter above may
204
+ // shrink below `limit` on a page that is NOT the last one. Absent a count,
205
+ // fall back to what we have (this page ends the listing).
206
+ const total = numberOr(json?.total, offset + items.length);
207
+ return {
208
+ items,
209
+ hasMore: page * limit < total,
210
+ limit,
211
+ offset,
212
+ };
213
+ },
214
+ async getEpisode(episodeId) {
215
+ const json = (await getJson(`/api/episodes/${encodeURIComponent(episodeId)}`));
216
+ return episodeSummarySchema.parse(json?.data?.episode);
217
+ },
218
+ episodeImageUrl(source, size) {
219
+ if (size && source.imageSizes) {
220
+ const resolved = resolveImageRef(source.imageSizes[size]?.url);
221
+ if (resolved) {
222
+ return resolved;
223
+ }
224
+ }
225
+ const fromImage = resolveImageRef(source.image);
226
+ if (fromImage) {
227
+ return fromImage;
228
+ }
229
+ if (source.imageSizes) {
230
+ for (const key of ARTWORK_FALLBACK_ORDER) {
231
+ const resolved = resolveImageRef(source.imageSizes[key]?.url);
232
+ if (resolved) {
233
+ return resolved;
234
+ }
235
+ }
236
+ }
237
+ return resolveImageRef(source.imageSourceUrl);
238
+ },
155
239
  };
156
240
  }
package/dist/esm/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { createSyraClient, DEFAULT_SYRA_BASE_URL, DEFAULT_SYRA_WEB_BASE_URL, } from './client.js';
2
- export { trackSummarySchema, podcastSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema.js';
2
+ export { trackSummarySchema, podcastSummarySchema, episodeSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema.js';
3
3
  export { SyraApiError } from './errors.js';
@@ -57,3 +57,35 @@ export const podcastSummarySchema = z.object({
57
57
  imageSizes: coverArtSizesSchema.optional(),
58
58
  imageSourceUrl: z.string().optional(),
59
59
  });
60
+ /**
61
+ * The summary view of a podcast EPISODE returned by the public podcast endpoints
62
+ * (`GET /api/podcasts/:id/episodes`, `GET /api/episodes/:id`) — just enough to
63
+ * list an episode and stream its audio.
64
+ *
65
+ * `enclosureUrl` is the direct audio file URL (e.g.
66
+ * `https://api.fastcast.ai/audio/<guid>.mp3`) and is REQUIRED: an episode with
67
+ * no enclosure is unplayable, so a row missing it is treated as malformed and
68
+ * dropped rather than surfaced as a dead entry. `enclosureType` /
69
+ * `enclosureLength` describe that file (MIME type and byte length); `duration`
70
+ * is the runtime in seconds and `pubDate` the ISO publish timestamp.
71
+ *
72
+ * Artwork mirrors the podcast SHOW: `image` is the re-hosted Syra image id
73
+ * (resolved via `/api/images/:id`); `imageSizes` is the multi-resolution variant
74
+ * set (each variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the
75
+ * original external artwork URL as an absolute fallback when re-hosting has not
76
+ * run yet.
77
+ */
78
+ export const episodeSummarySchema = z.object({
79
+ id: z.string(),
80
+ podcastId: z.string(),
81
+ title: z.string(),
82
+ description: z.string().optional(),
83
+ enclosureUrl: z.string(),
84
+ enclosureType: z.string().optional(),
85
+ enclosureLength: z.number().optional(),
86
+ duration: z.number().optional(),
87
+ pubDate: z.string().optional(),
88
+ image: z.string().optional(),
89
+ imageSizes: coverArtSizesSchema.optional(),
90
+ imageSourceUrl: z.string().optional(),
91
+ });
@@ -1,4 +1,4 @@
1
- import { type TrackSummary, type PodcastSummary, type CoverArtSizes, type ArtworkSize } from './schema';
1
+ import { type TrackSummary, type PodcastSummary, type EpisodeSummary, type CoverArtSizes, type ArtworkSize } from './schema';
2
2
  /** Default base URL of the public Syra API. */
3
3
  export declare const DEFAULT_SYRA_BASE_URL = "https://api.syra.fm";
4
4
  /** Default base URL of the Syra web app, used for deep links. */
@@ -20,12 +20,41 @@ 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
+ export interface PodcastEpisodesOptions {
35
+ /** Maximum number of episodes to request from the API (the page size). */
36
+ limit?: number;
37
+ /** Zero-based offset of the first episode to return (for infinite scroll). */
38
+ offset?: number;
39
+ }
40
+ /**
41
+ * One page of paginated catalog search results.
42
+ *
43
+ * `hasMore` reflects the BACKEND's pagination over the full matching set — NOT
44
+ * `items.length`. {@link SyraClient.searchTracks} additionally filters its page
45
+ * client-side to preview-available tracks, so `items.length` can be smaller than
46
+ * `limit` while `hasMore` is still `true`; callers must paginate by advancing
47
+ * `offset` by `limit` (the page size), never by `items.length`.
48
+ */
49
+ export interface SearchPage<T> {
50
+ /** The validated rows for this page. */
51
+ items: T[];
52
+ /** Whether the backend has results beyond this page. */
53
+ hasMore: boolean;
54
+ /** The page size the backend applied. */
55
+ limit: number;
56
+ /** The zero-based offset of this page. */
57
+ offset: number;
29
58
  }
30
59
  /** Minimal shape from which track artwork URLs can be derived. */
31
60
  export interface ArtworkSource {
@@ -38,12 +67,20 @@ export interface PodcastArtworkSource {
38
67
  imageSizes?: CoverArtSizes | null;
39
68
  imageSourceUrl?: string | null;
40
69
  }
70
+ /** Minimal shape from which podcast-episode artwork URLs can be derived. */
71
+ export interface EpisodeArtworkSource {
72
+ image?: string | null;
73
+ imageSizes?: CoverArtSizes | null;
74
+ imageSourceUrl?: string | null;
75
+ }
41
76
  export interface SyraClient {
42
77
  /**
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.
78
+ * Search the public catalog for tracks. Returns one paginated page: rows are
79
+ * validated against the track-summary schema and filtered to those that expose
80
+ * a public preview. `hasMore` comes from the backend's pagination, so it is
81
+ * unaffected by the client-side preview filter (see {@link SearchPage}).
45
82
  */
46
- searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary[]>;
83
+ searchTracks(query: string, options?: SearchTracksOptions): Promise<SearchPage<TrackSummary>>;
47
84
  /** Fetch a single track by id, validated against the track-summary schema. */
48
85
  getTrack(id: string): Promise<TrackSummary>;
49
86
  /** Build the public 30s preview URL for a track at the given start offset. */
@@ -54,10 +91,11 @@ export interface SyraClient {
54
91
  */
55
92
  artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
56
93
  /**
57
- * Search the public catalog for podcast SHOWS (not episodes). Results are
58
- * validated against the podcast-summary schema; malformed rows are dropped.
94
+ * Search the public catalog for podcast SHOWS (not episodes). Returns one
95
+ * paginated page: rows are validated against the podcast-summary schema and
96
+ * malformed rows are dropped. `hasMore` comes from the backend's pagination.
59
97
  */
60
- searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<PodcastSummary[]>;
98
+ searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<SearchPage<PodcastSummary>>;
61
99
  /**
62
100
  * Fetch a single podcast show by id, validated against the podcast-summary
63
101
  * schema. The by-id endpoint also returns episodes and resolved persons; this
@@ -72,6 +110,28 @@ export interface SyraClient {
72
110
  * external artwork URL. Returns `undefined` when no artwork can be derived.
73
111
  */
74
112
  podcastArtworkUrl(source: PodcastArtworkSource, size?: ArtworkSize): string | undefined;
113
+ /**
114
+ * List a podcast show's EPISODES (newest first, as the backend orders them).
115
+ * Returns one paginated page: rows are validated against the episode-summary
116
+ * schema and malformed rows are dropped — including any without a playable
117
+ * `enclosureUrl`, which the schema requires. The backend paginates by 1-based
118
+ * `page`, but this keeps the uniform offset-based {@link SearchPage} for parity
119
+ * with {@link SyraClient.searchPodcasts}; paginate by advancing `offset` by
120
+ * `limit` (the page size), never by `items.length`.
121
+ */
122
+ getPodcastEpisodes(podcastId: string, options?: PodcastEpisodesOptions): Promise<SearchPage<EpisodeSummary>>;
123
+ /**
124
+ * Fetch a single episode by id, validated against the episode-summary schema.
125
+ * The by-id endpoint nests the episode under `data.episode` alongside resolved
126
+ * persons; this returns just the episode summary needed to stream its audio.
127
+ */
128
+ getEpisode(episodeId: string): Promise<EpisodeSummary>;
129
+ /**
130
+ * Resolve an absolute artwork URL from a podcast episode reference. Prefers the
131
+ * re-hosted Syra image, then the requested/fallback variant, then the original
132
+ * external artwork URL. Returns `undefined` when no artwork can be derived.
133
+ */
134
+ episodeImageUrl(source: EpisodeArtworkSource, size?: ArtworkSize): string | undefined;
75
135
  }
76
136
  /**
77
137
  * Create a headless client for the public Syra API. Public reads only — there
@@ -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';
3
- export { trackSummarySchema, podcastSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema';
4
- export type { TrackSummary, PodcastSummary, CoverArtSizes, CoverArtVariant, ArtworkSize, } from './schema';
2
+ export type { SyraClient, SyraClientOptions, SearchTracksOptions, SearchPodcastsOptions, PodcastEpisodesOptions, SearchPage, ArtworkSource, PodcastArtworkSource, EpisodeArtworkSource, } from './client';
3
+ export { trackSummarySchema, podcastSummarySchema, episodeSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema';
4
+ export type { TrackSummary, PodcastSummary, EpisodeSummary, CoverArtSizes, CoverArtVariant, ArtworkSize, } from './schema';
5
5
  export { SyraApiError } from './errors';