@syra.fm/sdk 0.3.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/dist/cjs/client.js +59 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/schema.js +33 -1
- package/dist/esm/client.js +60 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/schema.js +32 -0
- package/dist/types/client.d.ts +35 -1
- 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 +185 -0
- package/src/client.ts +125 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +34 -0
package/dist/cjs/client.js
CHANGED
|
@@ -18,6 +18,11 @@ 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;
|
|
21
26
|
/** Read a finite number from an unknown response field, else a fallback. */
|
|
22
27
|
function numberOr(value, fallback) {
|
|
23
28
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
@@ -181,5 +186,59 @@ function createSyraClient(options = {}) {
|
|
|
181
186
|
}
|
|
182
187
|
return resolveImageRef(source.imageSourceUrl);
|
|
183
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
|
+
},
|
|
184
243
|
};
|
|
185
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");
|
package/dist/cjs/schema.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/esm/client.js
CHANGED
|
@@ -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,11 @@ 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;
|
|
17
22
|
/** Read a finite number from an unknown response field, else a fallback. */
|
|
18
23
|
function numberOr(value, fallback) {
|
|
19
24
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
@@ -177,5 +182,59 @@ export function createSyraClient(options = {}) {
|
|
|
177
182
|
}
|
|
178
183
|
return resolveImageRef(source.imageSourceUrl);
|
|
179
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
|
+
},
|
|
180
239
|
};
|
|
181
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';
|
package/dist/esm/schema.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/types/client.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -31,6 +31,12 @@ export interface SearchPodcastsOptions {
|
|
|
31
31
|
/** Zero-based offset of the first show to return (for infinite scroll). */
|
|
32
32
|
offset?: number;
|
|
33
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
|
+
}
|
|
34
40
|
/**
|
|
35
41
|
* One page of paginated catalog search results.
|
|
36
42
|
*
|
|
@@ -61,6 +67,12 @@ export interface PodcastArtworkSource {
|
|
|
61
67
|
imageSizes?: CoverArtSizes | null;
|
|
62
68
|
imageSourceUrl?: string | null;
|
|
63
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
|
+
}
|
|
64
76
|
export interface SyraClient {
|
|
65
77
|
/**
|
|
66
78
|
* Search the public catalog for tracks. Returns one paginated page: rows are
|
|
@@ -98,6 +110,28 @@ export interface SyraClient {
|
|
|
98
110
|
* external artwork URL. Returns `undefined` when no artwork can be derived.
|
|
99
111
|
*/
|
|
100
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;
|
|
101
135
|
}
|
|
102
136
|
/**
|
|
103
137
|
* Create a headless client for the public Syra API. Public reads only — there
|
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, SearchPage, 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';
|
package/dist/types/schema.d.ts
CHANGED
|
@@ -765,3 +765,307 @@ export declare const podcastSummarySchema: z.ZodObject<{
|
|
|
765
765
|
imageSourceUrl?: string | undefined;
|
|
766
766
|
}>;
|
|
767
767
|
export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
|
|
768
|
+
/**
|
|
769
|
+
* The summary view of a podcast EPISODE returned by the public podcast endpoints
|
|
770
|
+
* (`GET /api/podcasts/:id/episodes`, `GET /api/episodes/:id`) — just enough to
|
|
771
|
+
* list an episode and stream its audio.
|
|
772
|
+
*
|
|
773
|
+
* `enclosureUrl` is the direct audio file URL (e.g.
|
|
774
|
+
* `https://api.fastcast.ai/audio/<guid>.mp3`) and is REQUIRED: an episode with
|
|
775
|
+
* no enclosure is unplayable, so a row missing it is treated as malformed and
|
|
776
|
+
* dropped rather than surfaced as a dead entry. `enclosureType` /
|
|
777
|
+
* `enclosureLength` describe that file (MIME type and byte length); `duration`
|
|
778
|
+
* is the runtime in seconds and `pubDate` the ISO publish timestamp.
|
|
779
|
+
*
|
|
780
|
+
* Artwork mirrors the podcast SHOW: `image` is the re-hosted Syra image id
|
|
781
|
+
* (resolved via `/api/images/:id`); `imageSizes` is the multi-resolution variant
|
|
782
|
+
* set (each variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the
|
|
783
|
+
* original external artwork URL as an absolute fallback when re-hosting has not
|
|
784
|
+
* run yet.
|
|
785
|
+
*/
|
|
786
|
+
export declare const episodeSummarySchema: z.ZodObject<{
|
|
787
|
+
id: z.ZodString;
|
|
788
|
+
podcastId: z.ZodString;
|
|
789
|
+
title: z.ZodString;
|
|
790
|
+
description: z.ZodOptional<z.ZodString>;
|
|
791
|
+
enclosureUrl: z.ZodString;
|
|
792
|
+
enclosureType: z.ZodOptional<z.ZodString>;
|
|
793
|
+
enclosureLength: z.ZodOptional<z.ZodNumber>;
|
|
794
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
795
|
+
pubDate: z.ZodOptional<z.ZodString>;
|
|
796
|
+
image: z.ZodOptional<z.ZodString>;
|
|
797
|
+
imageSizes: z.ZodOptional<z.ZodObject<{
|
|
798
|
+
small: z.ZodOptional<z.ZodObject<{
|
|
799
|
+
id: z.ZodOptional<z.ZodString>;
|
|
800
|
+
url: z.ZodString;
|
|
801
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
802
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
803
|
+
}, "strip", z.ZodTypeAny, {
|
|
804
|
+
url: string;
|
|
805
|
+
id?: string | undefined;
|
|
806
|
+
width?: number | undefined;
|
|
807
|
+
height?: number | undefined;
|
|
808
|
+
}, {
|
|
809
|
+
url: string;
|
|
810
|
+
id?: string | undefined;
|
|
811
|
+
width?: number | undefined;
|
|
812
|
+
height?: number | undefined;
|
|
813
|
+
}>>;
|
|
814
|
+
medium: z.ZodOptional<z.ZodObject<{
|
|
815
|
+
id: z.ZodOptional<z.ZodString>;
|
|
816
|
+
url: z.ZodString;
|
|
817
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
818
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
819
|
+
}, "strip", z.ZodTypeAny, {
|
|
820
|
+
url: string;
|
|
821
|
+
id?: string | undefined;
|
|
822
|
+
width?: number | undefined;
|
|
823
|
+
height?: number | undefined;
|
|
824
|
+
}, {
|
|
825
|
+
url: string;
|
|
826
|
+
id?: string | undefined;
|
|
827
|
+
width?: number | undefined;
|
|
828
|
+
height?: number | undefined;
|
|
829
|
+
}>>;
|
|
830
|
+
large: z.ZodOptional<z.ZodObject<{
|
|
831
|
+
id: z.ZodOptional<z.ZodString>;
|
|
832
|
+
url: z.ZodString;
|
|
833
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
834
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
835
|
+
}, "strip", z.ZodTypeAny, {
|
|
836
|
+
url: string;
|
|
837
|
+
id?: string | undefined;
|
|
838
|
+
width?: number | undefined;
|
|
839
|
+
height?: number | undefined;
|
|
840
|
+
}, {
|
|
841
|
+
url: string;
|
|
842
|
+
id?: string | undefined;
|
|
843
|
+
width?: number | undefined;
|
|
844
|
+
height?: number | undefined;
|
|
845
|
+
}>>;
|
|
846
|
+
xlarge: z.ZodOptional<z.ZodObject<{
|
|
847
|
+
id: z.ZodOptional<z.ZodString>;
|
|
848
|
+
url: z.ZodString;
|
|
849
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
850
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
851
|
+
}, "strip", z.ZodTypeAny, {
|
|
852
|
+
url: string;
|
|
853
|
+
id?: string | undefined;
|
|
854
|
+
width?: number | undefined;
|
|
855
|
+
height?: number | undefined;
|
|
856
|
+
}, {
|
|
857
|
+
url: string;
|
|
858
|
+
id?: string | undefined;
|
|
859
|
+
width?: number | undefined;
|
|
860
|
+
height?: number | undefined;
|
|
861
|
+
}>>;
|
|
862
|
+
xxlarge: z.ZodOptional<z.ZodObject<{
|
|
863
|
+
id: z.ZodOptional<z.ZodString>;
|
|
864
|
+
url: z.ZodString;
|
|
865
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
866
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
867
|
+
}, "strip", z.ZodTypeAny, {
|
|
868
|
+
url: string;
|
|
869
|
+
id?: string | undefined;
|
|
870
|
+
width?: number | undefined;
|
|
871
|
+
height?: number | undefined;
|
|
872
|
+
}, {
|
|
873
|
+
url: string;
|
|
874
|
+
id?: string | undefined;
|
|
875
|
+
width?: number | undefined;
|
|
876
|
+
height?: number | undefined;
|
|
877
|
+
}>>;
|
|
878
|
+
original: z.ZodOptional<z.ZodObject<{
|
|
879
|
+
id: z.ZodOptional<z.ZodString>;
|
|
880
|
+
url: z.ZodString;
|
|
881
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
882
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
883
|
+
}, "strip", z.ZodTypeAny, {
|
|
884
|
+
url: string;
|
|
885
|
+
id?: string | undefined;
|
|
886
|
+
width?: number | undefined;
|
|
887
|
+
height?: number | undefined;
|
|
888
|
+
}, {
|
|
889
|
+
url: string;
|
|
890
|
+
id?: string | undefined;
|
|
891
|
+
width?: number | undefined;
|
|
892
|
+
height?: number | undefined;
|
|
893
|
+
}>>;
|
|
894
|
+
}, "strip", z.ZodTypeAny, {
|
|
895
|
+
small?: {
|
|
896
|
+
url: string;
|
|
897
|
+
id?: string | undefined;
|
|
898
|
+
width?: number | undefined;
|
|
899
|
+
height?: number | undefined;
|
|
900
|
+
} | undefined;
|
|
901
|
+
medium?: {
|
|
902
|
+
url: string;
|
|
903
|
+
id?: string | undefined;
|
|
904
|
+
width?: number | undefined;
|
|
905
|
+
height?: number | undefined;
|
|
906
|
+
} | undefined;
|
|
907
|
+
large?: {
|
|
908
|
+
url: string;
|
|
909
|
+
id?: string | undefined;
|
|
910
|
+
width?: number | undefined;
|
|
911
|
+
height?: number | undefined;
|
|
912
|
+
} | undefined;
|
|
913
|
+
xlarge?: {
|
|
914
|
+
url: string;
|
|
915
|
+
id?: string | undefined;
|
|
916
|
+
width?: number | undefined;
|
|
917
|
+
height?: number | undefined;
|
|
918
|
+
} | undefined;
|
|
919
|
+
xxlarge?: {
|
|
920
|
+
url: string;
|
|
921
|
+
id?: string | undefined;
|
|
922
|
+
width?: number | undefined;
|
|
923
|
+
height?: number | undefined;
|
|
924
|
+
} | undefined;
|
|
925
|
+
original?: {
|
|
926
|
+
url: string;
|
|
927
|
+
id?: string | undefined;
|
|
928
|
+
width?: number | undefined;
|
|
929
|
+
height?: number | undefined;
|
|
930
|
+
} | undefined;
|
|
931
|
+
}, {
|
|
932
|
+
small?: {
|
|
933
|
+
url: string;
|
|
934
|
+
id?: string | undefined;
|
|
935
|
+
width?: number | undefined;
|
|
936
|
+
height?: number | undefined;
|
|
937
|
+
} | undefined;
|
|
938
|
+
medium?: {
|
|
939
|
+
url: string;
|
|
940
|
+
id?: string | undefined;
|
|
941
|
+
width?: number | undefined;
|
|
942
|
+
height?: number | undefined;
|
|
943
|
+
} | undefined;
|
|
944
|
+
large?: {
|
|
945
|
+
url: string;
|
|
946
|
+
id?: string | undefined;
|
|
947
|
+
width?: number | undefined;
|
|
948
|
+
height?: number | undefined;
|
|
949
|
+
} | undefined;
|
|
950
|
+
xlarge?: {
|
|
951
|
+
url: string;
|
|
952
|
+
id?: string | undefined;
|
|
953
|
+
width?: number | undefined;
|
|
954
|
+
height?: number | undefined;
|
|
955
|
+
} | undefined;
|
|
956
|
+
xxlarge?: {
|
|
957
|
+
url: string;
|
|
958
|
+
id?: string | undefined;
|
|
959
|
+
width?: number | undefined;
|
|
960
|
+
height?: number | undefined;
|
|
961
|
+
} | undefined;
|
|
962
|
+
original?: {
|
|
963
|
+
url: string;
|
|
964
|
+
id?: string | undefined;
|
|
965
|
+
width?: number | undefined;
|
|
966
|
+
height?: number | undefined;
|
|
967
|
+
} | undefined;
|
|
968
|
+
}>>;
|
|
969
|
+
imageSourceUrl: z.ZodOptional<z.ZodString>;
|
|
970
|
+
}, "strip", z.ZodTypeAny, {
|
|
971
|
+
id: string;
|
|
972
|
+
title: string;
|
|
973
|
+
podcastId: string;
|
|
974
|
+
enclosureUrl: string;
|
|
975
|
+
duration?: number | undefined;
|
|
976
|
+
description?: string | undefined;
|
|
977
|
+
image?: string | undefined;
|
|
978
|
+
imageSizes?: {
|
|
979
|
+
small?: {
|
|
980
|
+
url: string;
|
|
981
|
+
id?: string | undefined;
|
|
982
|
+
width?: number | undefined;
|
|
983
|
+
height?: number | undefined;
|
|
984
|
+
} | undefined;
|
|
985
|
+
medium?: {
|
|
986
|
+
url: string;
|
|
987
|
+
id?: string | undefined;
|
|
988
|
+
width?: number | undefined;
|
|
989
|
+
height?: number | undefined;
|
|
990
|
+
} | undefined;
|
|
991
|
+
large?: {
|
|
992
|
+
url: string;
|
|
993
|
+
id?: string | undefined;
|
|
994
|
+
width?: number | undefined;
|
|
995
|
+
height?: number | undefined;
|
|
996
|
+
} | undefined;
|
|
997
|
+
xlarge?: {
|
|
998
|
+
url: string;
|
|
999
|
+
id?: string | undefined;
|
|
1000
|
+
width?: number | undefined;
|
|
1001
|
+
height?: number | undefined;
|
|
1002
|
+
} | undefined;
|
|
1003
|
+
xxlarge?: {
|
|
1004
|
+
url: string;
|
|
1005
|
+
id?: string | undefined;
|
|
1006
|
+
width?: number | undefined;
|
|
1007
|
+
height?: number | undefined;
|
|
1008
|
+
} | undefined;
|
|
1009
|
+
original?: {
|
|
1010
|
+
url: string;
|
|
1011
|
+
id?: string | undefined;
|
|
1012
|
+
width?: number | undefined;
|
|
1013
|
+
height?: number | undefined;
|
|
1014
|
+
} | undefined;
|
|
1015
|
+
} | undefined;
|
|
1016
|
+
imageSourceUrl?: string | undefined;
|
|
1017
|
+
enclosureType?: string | undefined;
|
|
1018
|
+
enclosureLength?: number | undefined;
|
|
1019
|
+
pubDate?: string | undefined;
|
|
1020
|
+
}, {
|
|
1021
|
+
id: string;
|
|
1022
|
+
title: string;
|
|
1023
|
+
podcastId: string;
|
|
1024
|
+
enclosureUrl: string;
|
|
1025
|
+
duration?: number | undefined;
|
|
1026
|
+
description?: string | undefined;
|
|
1027
|
+
image?: string | undefined;
|
|
1028
|
+
imageSizes?: {
|
|
1029
|
+
small?: {
|
|
1030
|
+
url: string;
|
|
1031
|
+
id?: string | undefined;
|
|
1032
|
+
width?: number | undefined;
|
|
1033
|
+
height?: number | undefined;
|
|
1034
|
+
} | undefined;
|
|
1035
|
+
medium?: {
|
|
1036
|
+
url: string;
|
|
1037
|
+
id?: string | undefined;
|
|
1038
|
+
width?: number | undefined;
|
|
1039
|
+
height?: number | undefined;
|
|
1040
|
+
} | undefined;
|
|
1041
|
+
large?: {
|
|
1042
|
+
url: string;
|
|
1043
|
+
id?: string | undefined;
|
|
1044
|
+
width?: number | undefined;
|
|
1045
|
+
height?: number | undefined;
|
|
1046
|
+
} | undefined;
|
|
1047
|
+
xlarge?: {
|
|
1048
|
+
url: string;
|
|
1049
|
+
id?: string | undefined;
|
|
1050
|
+
width?: number | undefined;
|
|
1051
|
+
height?: number | undefined;
|
|
1052
|
+
} | undefined;
|
|
1053
|
+
xxlarge?: {
|
|
1054
|
+
url: string;
|
|
1055
|
+
id?: string | undefined;
|
|
1056
|
+
width?: number | undefined;
|
|
1057
|
+
height?: number | undefined;
|
|
1058
|
+
} | undefined;
|
|
1059
|
+
original?: {
|
|
1060
|
+
url: string;
|
|
1061
|
+
id?: string | undefined;
|
|
1062
|
+
width?: number | undefined;
|
|
1063
|
+
height?: number | undefined;
|
|
1064
|
+
} | undefined;
|
|
1065
|
+
} | undefined;
|
|
1066
|
+
imageSourceUrl?: string | undefined;
|
|
1067
|
+
enclosureType?: string | undefined;
|
|
1068
|
+
enclosureLength?: number | undefined;
|
|
1069
|
+
pubDate?: string | undefined;
|
|
1070
|
+
}>;
|
|
1071
|
+
export type EpisodeSummary = z.infer<typeof episodeSummarySchema>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syra.fm/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
@@ -45,6 +45,29 @@ function makePodcast(overrides: Record<string, unknown> = {}): Record<string, un
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function makeEpisode(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
49
|
+
return {
|
|
50
|
+
id: '507f1f77bcf86cd799439031',
|
|
51
|
+
podcastId: '507f1f77bcf86cd799439021',
|
|
52
|
+
podcastTitle: 'Test Show',
|
|
53
|
+
title: 'Test Episode',
|
|
54
|
+
description: 'An episode about testing.',
|
|
55
|
+
guid: 'guid-1',
|
|
56
|
+
enclosureUrl: 'https://api.fastcast.ai/audio/guid-1.mp3',
|
|
57
|
+
enclosureType: 'audio/mpeg',
|
|
58
|
+
enclosureLength: 12_345_678,
|
|
59
|
+
duration: 1800,
|
|
60
|
+
pubDate: '2026-01-01T00:00:00.000Z',
|
|
61
|
+
episodeType: 'full',
|
|
62
|
+
image: '507f1f77bcf86cd799439032',
|
|
63
|
+
imageSourceUrl: 'https://cdn.example.com/episode.jpg',
|
|
64
|
+
status: 'ready',
|
|
65
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
66
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
interface FetchCall {
|
|
49
72
|
url: string;
|
|
50
73
|
}
|
|
@@ -445,3 +468,165 @@ describe('createSyraClient.podcastArtworkUrl', () => {
|
|
|
445
468
|
expect(client.podcastArtworkUrl({ image: 'not-an-id' })).toBeUndefined();
|
|
446
469
|
});
|
|
447
470
|
});
|
|
471
|
+
|
|
472
|
+
// ── getPodcastEpisodes ──────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
describe('createSyraClient.getPodcastEpisodes', () => {
|
|
475
|
+
it('calls /api/podcasts/:id/episodes with page and limit and returns parsed episodes', async () => {
|
|
476
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
477
|
+
body: {
|
|
478
|
+
data: [
|
|
479
|
+
makeEpisode({ id: '507f1f77bcf86cd799439031' }),
|
|
480
|
+
makeEpisode({ id: '507f1f77bcf86cd799439033', title: 'Second Episode' }),
|
|
481
|
+
],
|
|
482
|
+
total: 2,
|
|
483
|
+
page: 1,
|
|
484
|
+
limit: 20,
|
|
485
|
+
},
|
|
486
|
+
}));
|
|
487
|
+
|
|
488
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
489
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', { limit: 20 });
|
|
490
|
+
|
|
491
|
+
expect(page.items).toHaveLength(2);
|
|
492
|
+
expect(page.items[0].id).toBe('507f1f77bcf86cd799439031');
|
|
493
|
+
expect(page.items[0].enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
|
|
494
|
+
expect(page.hasMore).toBe(false);
|
|
495
|
+
expect(page.limit).toBe(20);
|
|
496
|
+
expect(page.offset).toBe(0);
|
|
497
|
+
|
|
498
|
+
expect(calls).toHaveLength(1);
|
|
499
|
+
const url = new URL(calls[0].url);
|
|
500
|
+
expect(url.pathname).toBe('/api/podcasts/507f1f77bcf86cd799439021/episodes');
|
|
501
|
+
expect(url.searchParams.get('page')).toBe('1');
|
|
502
|
+
expect(url.searchParams.get('limit')).toBe('20');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('translates a zero-based offset into a 1-based page and derives hasMore from total', async () => {
|
|
506
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
507
|
+
body: { data: [makeEpisode()], total: 45, page: 3, limit: 10 },
|
|
508
|
+
}));
|
|
509
|
+
|
|
510
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
511
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', {
|
|
512
|
+
limit: 10,
|
|
513
|
+
offset: 20,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// offset 20 / limit 10 → page 3; 3 * 10 = 30 < 45 → more remain.
|
|
517
|
+
const url = new URL(calls[0].url);
|
|
518
|
+
expect(url.searchParams.get('page')).toBe('3');
|
|
519
|
+
expect(url.searchParams.get('limit')).toBe('10');
|
|
520
|
+
expect(page.offset).toBe(20);
|
|
521
|
+
expect(page.limit).toBe(10);
|
|
522
|
+
expect(page.hasMore).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('reports hasMore=false when the page reaches the end of the total', async () => {
|
|
526
|
+
const { fetch } = fakeFetch(() => ({
|
|
527
|
+
body: { data: [makeEpisode()], total: 20, page: 2, limit: 10 },
|
|
528
|
+
}));
|
|
529
|
+
const client = createSyraClient({ fetch });
|
|
530
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', {
|
|
531
|
+
limit: 10,
|
|
532
|
+
offset: 10,
|
|
533
|
+
});
|
|
534
|
+
// page 2 * limit 10 = 20, not < total 20 → this is the last page.
|
|
535
|
+
expect(page.hasMore).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('drops a row missing the required enclosureUrl without throwing', async () => {
|
|
539
|
+
const { fetch } = fakeFetch(() => ({
|
|
540
|
+
body: {
|
|
541
|
+
data: [makeEpisode({ enclosureUrl: undefined }), makeEpisode()],
|
|
542
|
+
total: 2,
|
|
543
|
+
},
|
|
544
|
+
}));
|
|
545
|
+
const client = createSyraClient({ fetch });
|
|
546
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', { limit: 10 });
|
|
547
|
+
expect(page.items).toHaveLength(1);
|
|
548
|
+
expect(page.items[0].enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('defaults the limit and returns an empty page when data is absent', async () => {
|
|
552
|
+
const { fetch, calls } = fakeFetch(() => ({ body: {} }));
|
|
553
|
+
const client = createSyraClient({ fetch });
|
|
554
|
+
const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021');
|
|
555
|
+
expect(page.items).toEqual([]);
|
|
556
|
+
expect(page.hasMore).toBe(false);
|
|
557
|
+
expect(page.offset).toBe(0);
|
|
558
|
+
expect(page.limit).toBe(20);
|
|
559
|
+
const url = new URL(calls[0].url);
|
|
560
|
+
expect(url.searchParams.get('page')).toBe('1');
|
|
561
|
+
expect(url.searchParams.get('limit')).toBe('20');
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ── getEpisode ──────────────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
describe('createSyraClient.getEpisode', () => {
|
|
568
|
+
it('fetches /api/episodes/:id and validates data.episode', async () => {
|
|
569
|
+
const { fetch, calls } = fakeFetch(() => ({
|
|
570
|
+
body: { data: { episode: makeEpisode(), persons: [] } },
|
|
571
|
+
}));
|
|
572
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
|
|
573
|
+
|
|
574
|
+
const episode = await client.getEpisode('507f1f77bcf86cd799439031');
|
|
575
|
+
expect(episode.title).toBe('Test Episode');
|
|
576
|
+
expect(episode.enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
|
|
577
|
+
expect(calls[0].url).toBe('https://api.example.test/api/episodes/507f1f77bcf86cd799439031');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('throws SyraApiError on a non-2xx response', async () => {
|
|
581
|
+
const { fetch } = fakeFetch(() => ({ status: 404, body: { error: 'not found' } }));
|
|
582
|
+
const client = createSyraClient({ fetch });
|
|
583
|
+
await expect(client.getEpisode('507f1f77bcf86cd799439031')).rejects.toBeInstanceOf(SyraApiError);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('throws when data.episode fails schema validation', async () => {
|
|
587
|
+
const { fetch } = fakeFetch(() => ({ body: { data: { episode: { id: 'x' } } } }));
|
|
588
|
+
const client = createSyraClient({ fetch });
|
|
589
|
+
await expect(client.getEpisode('x')).rejects.toThrow();
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ── episodeImageUrl ──────────────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
describe('createSyraClient.episodeImageUrl', () => {
|
|
596
|
+
const client = createSyraClient({ baseURL: 'https://api.example.test' });
|
|
597
|
+
|
|
598
|
+
it('resolves the re-hosted image id to an absolute images URL', () => {
|
|
599
|
+
expect(client.episodeImageUrl({ image: '507f1f77bcf86cd799439032' })).toBe(
|
|
600
|
+
'https://api.example.test/api/images/507f1f77bcf86cd799439032',
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('prefers a named size from imageSizes', () => {
|
|
605
|
+
const url = client.episodeImageUrl(
|
|
606
|
+
{
|
|
607
|
+
image: '507f1f77bcf86cd799439032',
|
|
608
|
+
imageSizes: {
|
|
609
|
+
large: {
|
|
610
|
+
id: '507f1f77bcf86cd799439033',
|
|
611
|
+
url: '/api/images/507f1f77bcf86cd799439033',
|
|
612
|
+
width: 640,
|
|
613
|
+
height: 640,
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
'large',
|
|
618
|
+
);
|
|
619
|
+
expect(url).toBe('https://api.example.test/api/images/507f1f77bcf86cd799439033');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('falls back to imageSourceUrl when no Syra image is present', () => {
|
|
623
|
+
expect(client.episodeImageUrl({ imageSourceUrl: 'https://cdn.example.com/episode.jpg' })).toBe(
|
|
624
|
+
'https://cdn.example.com/episode.jpg',
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('returns undefined when nothing resolvable is present', () => {
|
|
629
|
+
expect(client.episodeImageUrl({})).toBeUndefined();
|
|
630
|
+
expect(client.episodeImageUrl({ image: 'not-an-id' })).toBeUndefined();
|
|
631
|
+
});
|
|
632
|
+
});
|
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';
|
|
@@ -45,6 +47,13 @@ export interface SearchPodcastsOptions {
|
|
|
45
47
|
offset?: number;
|
|
46
48
|
}
|
|
47
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
|
+
|
|
48
57
|
/**
|
|
49
58
|
* One page of paginated catalog search results.
|
|
50
59
|
*
|
|
@@ -78,6 +87,13 @@ export interface PodcastArtworkSource {
|
|
|
78
87
|
imageSourceUrl?: string | null;
|
|
79
88
|
}
|
|
80
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
|
+
|
|
81
97
|
export interface SyraClient {
|
|
82
98
|
/**
|
|
83
99
|
* Search the public catalog for tracks. Returns one paginated page: rows are
|
|
@@ -115,6 +131,31 @@ export interface SyraClient {
|
|
|
115
131
|
* external artwork URL. Returns `undefined` when no artwork can be derived.
|
|
116
132
|
*/
|
|
117
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;
|
|
118
159
|
}
|
|
119
160
|
|
|
120
161
|
/** Order used to pick the best available artwork variant when none is named. */
|
|
@@ -129,6 +170,12 @@ const ARTWORK_FALLBACK_ORDER: ArtworkSize[] = [
|
|
|
129
170
|
|
|
130
171
|
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
|
|
131
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
|
+
|
|
132
179
|
/** Read a finite number from an unknown response field, else a fallback. */
|
|
133
180
|
function numberOr(value: unknown, fallback: number): number {
|
|
134
181
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
@@ -152,6 +199,17 @@ interface PodcastDetailResponseShape {
|
|
|
152
199
|
data?: { podcast?: unknown };
|
|
153
200
|
}
|
|
154
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
|
+
|
|
155
213
|
/**
|
|
156
214
|
* Create a headless client for the public Syra API. Public reads only — there
|
|
157
215
|
* is no authentication in this version.
|
|
@@ -344,5 +402,72 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
344
402
|
|
|
345
403
|
return resolveImageRef(source.imageSourceUrl);
|
|
346
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
|
+
},
|
|
347
472
|
};
|
|
348
473
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,19 +8,23 @@ export type {
|
|
|
8
8
|
SyraClientOptions,
|
|
9
9
|
SearchTracksOptions,
|
|
10
10
|
SearchPodcastsOptions,
|
|
11
|
+
PodcastEpisodesOptions,
|
|
11
12
|
SearchPage,
|
|
12
13
|
ArtworkSource,
|
|
13
14
|
PodcastArtworkSource,
|
|
15
|
+
EpisodeArtworkSource,
|
|
14
16
|
} from './client';
|
|
15
17
|
export {
|
|
16
18
|
trackSummarySchema,
|
|
17
19
|
podcastSummarySchema,
|
|
20
|
+
episodeSummarySchema,
|
|
18
21
|
coverArtSizesSchema,
|
|
19
22
|
coverArtVariantSchema,
|
|
20
23
|
} from './schema';
|
|
21
24
|
export type {
|
|
22
25
|
TrackSummary,
|
|
23
26
|
PodcastSummary,
|
|
27
|
+
EpisodeSummary,
|
|
24
28
|
CoverArtSizes,
|
|
25
29
|
CoverArtVariant,
|
|
26
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>;
|