@syra.fm/sdk 0.9.0 → 0.11.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/lib/commonjs/client.js +247 -7
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/index.js +36 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/live/components/CreateRoomSheet.js +5 -5
- package/lib/commonjs/live/components/CreateRoomSheet.js.map +1 -1
- package/lib/commonjs/live/components/RecordingsPanel.js +9 -9
- package/lib/commonjs/live/components/RecordingsPanel.js.map +1 -1
- package/lib/commonjs/live/validation.js +44 -15
- package/lib/commonjs/live/validation.js.map +1 -1
- package/lib/commonjs/schema.js +80 -9
- package/lib/commonjs/schema.js.map +1 -1
- package/lib/module/client.js +248 -8
- package/lib/module/client.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/live/components/CreateRoomSheet.js +5 -5
- package/lib/module/live/components/CreateRoomSheet.js.map +1 -1
- package/lib/module/live/components/RecordingsPanel.js +9 -9
- package/lib/module/live/components/RecordingsPanel.js.map +1 -1
- package/lib/module/live/validation.js +44 -15
- package/lib/module/live/validation.js.map +1 -1
- package/lib/module/schema.js +79 -8
- package/lib/module/schema.js.map +1 -1
- package/lib/typescript/commonjs/client.d.ts +171 -2
- package/lib/typescript/commonjs/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +3 -3
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/live/components/RoomCard.d.ts +1 -1
- package/lib/typescript/commonjs/live/components/RoomCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/live/validation.d.ts +17 -20
- package/lib/typescript/commonjs/live/validation.d.ts.map +1 -1
- package/lib/typescript/commonjs/schema.d.ts +97 -7
- package/lib/typescript/commonjs/schema.d.ts.map +1 -1
- package/lib/typescript/module/client.d.ts +171 -2
- package/lib/typescript/module/client.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +3 -3
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/live/components/RoomCard.d.ts +1 -1
- package/lib/typescript/module/live/components/RoomCard.d.ts.map +1 -1
- package/lib/typescript/module/live/validation.d.ts +17 -20
- package/lib/typescript/module/live/validation.d.ts.map +1 -1
- package/lib/typescript/module/schema.d.ts +97 -7
- package/lib/typescript/module/schema.d.ts.map +1 -1
- package/package.json +6 -4
- package/src/client.ts +430 -10
- package/src/index.ts +17 -0
- package/src/live/components/CreateRoomSheet.tsx +5 -5
- package/src/live/components/RecordingsPanel.tsx +9 -9
- package/src/live/components/RoomCard.tsx +1 -1
- package/src/live/validation.ts +41 -15
- package/src/schema.ts +90 -7
- package/src/client.test.ts +0 -632
package/src/client.ts
CHANGED
|
@@ -2,9 +2,16 @@ import {
|
|
|
2
2
|
trackSummarySchema,
|
|
3
3
|
podcastSummarySchema,
|
|
4
4
|
episodeSummarySchema,
|
|
5
|
+
episodeDraftSchema,
|
|
6
|
+
episodeStreamSchema,
|
|
7
|
+
uploadedImageSchema,
|
|
5
8
|
type TrackSummary,
|
|
6
9
|
type PodcastSummary,
|
|
7
10
|
type EpisodeSummary,
|
|
11
|
+
type EpisodeDraft,
|
|
12
|
+
type EpisodeStream,
|
|
13
|
+
type UploadedImage,
|
|
14
|
+
type PodcastVisibility,
|
|
8
15
|
type CoverArtSizes,
|
|
9
16
|
type ArtworkSize,
|
|
10
17
|
} from './schema';
|
|
@@ -28,9 +35,24 @@ export interface SyraClientOptions {
|
|
|
28
35
|
/**
|
|
29
36
|
* `fetch` implementation. Defaults to the global `fetch` (Node 18+, browsers,
|
|
30
37
|
* React Native). Inject one (e.g. `node-fetch`) when no global is available.
|
|
31
|
-
* This is the seam where an authenticated transport can be layered in later.
|
|
32
38
|
*/
|
|
33
39
|
fetch?: typeof fetch;
|
|
40
|
+
/**
|
|
41
|
+
* Supplies the caller's Oxy access token. Called BEFORE EVERY REQUEST, never
|
|
42
|
+
* cached here, because an access token is short-lived and the host
|
|
43
|
+
* application is the only thing that knows when it was refreshed — an SDK
|
|
44
|
+
* holding its own copy is an SDK that starts sending an expired one.
|
|
45
|
+
*
|
|
46
|
+
* Returning `null`/`undefined` means "no session right now", which is a normal
|
|
47
|
+
* state and not an error: every PUBLIC read below still works without a token,
|
|
48
|
+
* exactly as it did before this existed. Only the authenticated methods refuse,
|
|
49
|
+
* and they say so by name.
|
|
50
|
+
*
|
|
51
|
+
* The token IS sent on public reads when it is available, which is
|
|
52
|
+
* deliberate — it is what lets an owner see their own private show and their
|
|
53
|
+
* own unpublished episodes through the same methods everyone else uses.
|
|
54
|
+
*/
|
|
55
|
+
getAccessToken?: () => string | null | undefined | Promise<string | null | undefined>;
|
|
34
56
|
}
|
|
35
57
|
|
|
36
58
|
export interface SearchTracksOptions {
|
|
@@ -94,6 +116,98 @@ export interface EpisodeArtworkSource {
|
|
|
94
116
|
imageSourceUrl?: string | null;
|
|
95
117
|
}
|
|
96
118
|
|
|
119
|
+
/** The fields `createPodcast` accepts. Mirrors `POST /api/podcasts`. */
|
|
120
|
+
export interface CreatePodcastInput {
|
|
121
|
+
title: string;
|
|
122
|
+
description?: string;
|
|
123
|
+
author?: string;
|
|
124
|
+
/** An image id from {@link SyraClient.uploadPodcastImage}, not a URL. */
|
|
125
|
+
image?: string;
|
|
126
|
+
language?: string;
|
|
127
|
+
categories?: string[];
|
|
128
|
+
explicit?: boolean;
|
|
129
|
+
link?: string;
|
|
130
|
+
type?: 'episodic' | 'serial';
|
|
131
|
+
visibility?: PodcastVisibility;
|
|
132
|
+
/** The Alia series this show was generated from; records `provider: 'alia'` provenance. */
|
|
133
|
+
aliaSeriesId?: string;
|
|
134
|
+
/** Disclosure. Independent of {@link CreatePodcastInput.aliaSeriesId} — neither implies the other. */
|
|
135
|
+
aiGenerated?: boolean;
|
|
136
|
+
/** Hosts & Guests as Oxy user ids. Validated server-side; free text is refused. */
|
|
137
|
+
hosts?: string[];
|
|
138
|
+
guests?: string[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The fields `updatePodcast` accepts. Every one is optional; omitted means unchanged. */
|
|
142
|
+
export interface UpdatePodcastInput {
|
|
143
|
+
title?: string;
|
|
144
|
+
description?: string;
|
|
145
|
+
author?: string;
|
|
146
|
+
image?: string;
|
|
147
|
+
language?: string;
|
|
148
|
+
categories?: string[];
|
|
149
|
+
explicit?: boolean;
|
|
150
|
+
link?: string;
|
|
151
|
+
type?: 'episodic' | 'serial';
|
|
152
|
+
visibility?: PodcastVisibility;
|
|
153
|
+
aiGenerated?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The metadata `createEpisodeDraft` accepts — everything except the audio. */
|
|
157
|
+
export interface CreateEpisodeDraftInput {
|
|
158
|
+
title: string;
|
|
159
|
+
description?: string;
|
|
160
|
+
summary?: string;
|
|
161
|
+
season?: number;
|
|
162
|
+
episodeNumber?: number;
|
|
163
|
+
episodeType?: 'full' | 'trailer' | 'bonus';
|
|
164
|
+
explicit?: boolean;
|
|
165
|
+
aiGenerated?: boolean;
|
|
166
|
+
hosts?: string[];
|
|
167
|
+
guests?: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The metadata the ingest step may set.
|
|
172
|
+
*
|
|
173
|
+
* Deliberately SMALLER than {@link CreateEpisodeDraftInput}, and it is not an
|
|
174
|
+
* oversight: the ticket is redeemed by a process with no user session, so the
|
|
175
|
+
* server accepts only what such a process can know by having produced the
|
|
176
|
+
* episode. Artwork, `explicit`, `episodeType`, credits and the AI disclosure
|
|
177
|
+
* were fixed at draft time by the authenticated user and are refused here.
|
|
178
|
+
*
|
|
179
|
+
* `title` IS accepted, and it is the reason to reach for this at all: a draft is
|
|
180
|
+
* created before the content exists, so its title can only name the topic that
|
|
181
|
+
* was requested. Send the name the FINISHED episode earned and it replaces the
|
|
182
|
+
* draft's.
|
|
183
|
+
*
|
|
184
|
+
* Omitting it keeps whatever the draft said — an absent title never clears one,
|
|
185
|
+
* so a worker that has nothing better than the placeholder can still deliver
|
|
186
|
+
* the audio. A title that is empty or only whitespace is REFUSED (400) rather
|
|
187
|
+
* than stored, the same rule the authenticated endpoints apply; the ticket
|
|
188
|
+
* survives that refusal and can be redeemed again.
|
|
189
|
+
*/
|
|
190
|
+
export interface IngestEpisodeInput {
|
|
191
|
+
title?: string;
|
|
192
|
+
duration?: number;
|
|
193
|
+
season?: number;
|
|
194
|
+
episodeNumber?: number;
|
|
195
|
+
description?: string;
|
|
196
|
+
summary?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* An audio or image payload, in the shapes the three supported runtimes give you.
|
|
201
|
+
*
|
|
202
|
+
* `Blob`/`File` covers browsers and Node 18+. React Native's `FormData` accepts
|
|
203
|
+
* a `{ uri, name, type }` descriptor instead, which is not a `Blob` at all — it
|
|
204
|
+
* is named here so an RN caller does not have to cast, and so this SDK never has
|
|
205
|
+
* to import anything from React Native to support it.
|
|
206
|
+
*/
|
|
207
|
+
export type UploadPayload =
|
|
208
|
+
| Blob
|
|
209
|
+
| { uri: string; name?: string; type?: string };
|
|
210
|
+
|
|
97
211
|
export interface SyraClient {
|
|
98
212
|
/**
|
|
99
213
|
* Search the public catalog for tracks. Returns one paginated page: rows are
|
|
@@ -156,6 +270,81 @@ export interface SyraClient {
|
|
|
156
270
|
* external artwork URL. Returns `undefined` when no artwork can be derived.
|
|
157
271
|
*/
|
|
158
272
|
episodeImageUrl(source: EpisodeArtworkSource, size?: ArtworkSize): string | undefined;
|
|
273
|
+
/**
|
|
274
|
+
* Resolve an episode's PLAYABLE audio URL, whichever kind of episode it is.
|
|
275
|
+
*
|
|
276
|
+
* An RSS-mirrored episode carries an absolute `enclosureUrl`; a Syra-hosted one
|
|
277
|
+
* carries `audioSource.url`, a path on the API. This is the one place that
|
|
278
|
+
* knows the difference, so no consumer has to.
|
|
279
|
+
*
|
|
280
|
+
* `undefined` when the episode has no audio yet — a drafted episode awaiting
|
|
281
|
+
* ingest is a real, listable episode with nothing to play, and answering with
|
|
282
|
+
* a broken URL would be worse than answering with nothing.
|
|
283
|
+
*/
|
|
284
|
+
episodeAudioUrl(episode: {
|
|
285
|
+
enclosureUrl?: string | null;
|
|
286
|
+
audioSource?: { url?: string | null } | null;
|
|
287
|
+
}): string | undefined;
|
|
288
|
+
|
|
289
|
+
// ── Authenticated ──────────────────────────────────────────────────────────
|
|
290
|
+
//
|
|
291
|
+
// Every method below needs `getAccessToken` to return a token; without one they
|
|
292
|
+
// throw `SyraApiError(401)` from the CLIENT rather than making a request that
|
|
293
|
+
// was never going to be accepted.
|
|
294
|
+
|
|
295
|
+
/** `GET /api/podcasts/mine` — every show the caller owns, in every state. */
|
|
296
|
+
listMyPodcasts(): Promise<PodcastSummary[]>;
|
|
297
|
+
/** `POST /api/podcasts` — create a Syra-hosted show. */
|
|
298
|
+
createPodcast(input: CreatePodcastInput): Promise<PodcastSummary>;
|
|
299
|
+
/** `PATCH /api/podcasts/:id` — edit a Syra-hosted show you own. */
|
|
300
|
+
updatePodcast(podcastId: string, input: UpdatePodcastInput): Promise<PodcastSummary>;
|
|
301
|
+
/**
|
|
302
|
+
* Change who may see a show. A named affordance over
|
|
303
|
+
* {@link SyraClient.updatePodcast}, because it is the one field with
|
|
304
|
+
* consequences a caller should not discover by reading a diff: making a show
|
|
305
|
+
* `private` withdraws it from every listing AND stops its episodes being
|
|
306
|
+
* transcoded, and publishing it again enqueues the transcodes that were
|
|
307
|
+
* deferred.
|
|
308
|
+
*/
|
|
309
|
+
setPodcastVisibility(podcastId: string, visibility: PodcastVisibility): Promise<PodcastSummary>;
|
|
310
|
+
/**
|
|
311
|
+
* `POST /api/images/upload` — store cover art and get the image id back.
|
|
312
|
+
*
|
|
313
|
+
* The id is what {@link CreatePodcastInput.image} wants; a URL is not accepted
|
|
314
|
+
* there, because the API re-hosts artwork rather than hotlinking it.
|
|
315
|
+
*/
|
|
316
|
+
uploadPodcastImage(image: UploadPayload, filename?: string): Promise<UploadedImage>;
|
|
317
|
+
/**
|
|
318
|
+
* `POST /api/podcasts/:id/episodes/draft` — reserve an episode now and get a
|
|
319
|
+
* single-use ticket to attach its audio later, from a process with no session.
|
|
320
|
+
*
|
|
321
|
+
* The returned ticket is a bearer capability with a deadline: it is good for
|
|
322
|
+
* ONE redemption against THIS episode, and it stops working if the show
|
|
323
|
+
* changes hands. Treat it as a secret.
|
|
324
|
+
*/
|
|
325
|
+
createEpisodeDraft(podcastId: string, input: CreateEpisodeDraftInput): Promise<EpisodeDraft>;
|
|
326
|
+
/**
|
|
327
|
+
* `POST /api/podcasts/episodes/:id/ingest` — redeem a draft's ticket by
|
|
328
|
+
* attaching the audio.
|
|
329
|
+
*
|
|
330
|
+
* Takes the whole {@link EpisodeDraft} rather than a loose id and token, so the
|
|
331
|
+
* two cannot be paired up wrongly by a caller holding several drafts.
|
|
332
|
+
*
|
|
333
|
+
* Authenticated by the TICKET, not by the session — this is the one method here
|
|
334
|
+
* that works with no `getAccessToken` at all, which is the entire point of it.
|
|
335
|
+
*/
|
|
336
|
+
ingestEpisode(
|
|
337
|
+
draft: Pick<EpisodeDraft, 'episodeId' | 'ingestTicket'>,
|
|
338
|
+
audio: UploadPayload,
|
|
339
|
+
input?: IngestEpisodeInput,
|
|
340
|
+
filename?: string,
|
|
341
|
+
): Promise<EpisodeSummary>;
|
|
342
|
+
/**
|
|
343
|
+
* `GET /api/podcasts/episodes/:id/stream` — a tokenized HLS URL for a
|
|
344
|
+
* Syra-hosted episode. Requires a session: the URL it returns embeds a stream
|
|
345
|
+
* token minted for the caller.
|
|
346
|
+
*/
|
|
347
|
+
getEpisodeStream(episodeId: string): Promise<EpisodeStream>;
|
|
159
348
|
}
|
|
160
349
|
|
|
161
350
|
/** Order used to pick the best available artwork variant when none is named. */
|
|
@@ -232,20 +421,130 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
232
421
|
);
|
|
233
422
|
}
|
|
234
423
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
424
|
+
/**
|
|
425
|
+
* The caller's token for THIS request, or `undefined`.
|
|
426
|
+
*
|
|
427
|
+
* Asked every time rather than once at construction: an access token is
|
|
428
|
+
* short-lived, and the host application is the only thing that knows when it
|
|
429
|
+
* was refreshed.
|
|
430
|
+
*/
|
|
431
|
+
async function currentToken(): Promise<string | undefined> {
|
|
432
|
+
if (!options.getAccessToken) return undefined;
|
|
433
|
+
const token = await options.getAccessToken();
|
|
434
|
+
return typeof token === 'string' && token.length > 0 ? token : undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The token, or a refusal — for the methods that cannot work without one.
|
|
439
|
+
*
|
|
440
|
+
* Thrown from the CLIENT rather than sent and rejected, so a consumer with no
|
|
441
|
+
* session gets a message naming the method instead of a bare 401 from a
|
|
442
|
+
* request that was never going to be accepted.
|
|
443
|
+
*/
|
|
444
|
+
async function requireToken(method: string): Promise<string> {
|
|
445
|
+
const token = await currentToken();
|
|
446
|
+
if (!token) {
|
|
241
447
|
throw new SyraApiError(
|
|
242
|
-
|
|
243
|
-
|
|
448
|
+
401,
|
|
449
|
+
`@syra.fm/sdk: ${method}() needs a signed-in caller. Pass \`getAccessToken\` to ` +
|
|
450
|
+
'createSyraClient, and make sure it returns a token for the current session.',
|
|
244
451
|
);
|
|
245
452
|
}
|
|
453
|
+
return token;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Raise the API's own error, preferring the message it sent over the status text. */
|
|
457
|
+
async function raiseFor(response: Response, path: string): Promise<never> {
|
|
458
|
+
let detail = `${response.status} ${response.statusText}`;
|
|
459
|
+
try {
|
|
460
|
+
const body: unknown = await response.json();
|
|
461
|
+
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') {
|
|
462
|
+
detail = `${response.status} ${body.error}`;
|
|
463
|
+
}
|
|
464
|
+
} catch {
|
|
465
|
+
// A non-JSON error body is not itself an error worth reporting; the status
|
|
466
|
+
// is what the caller acts on.
|
|
467
|
+
}
|
|
468
|
+
throw new SyraApiError(response.status, `Syra API request failed: ${detail} (${path})`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
interface RequestInit_ {
|
|
472
|
+
method?: string;
|
|
473
|
+
body?: BodyInit;
|
|
474
|
+
/** Extra headers. `Authorization` is added here, never by a caller. */
|
|
475
|
+
headers?: Record<string, string>;
|
|
476
|
+
/** Refuse before sending when there is no session. The value is the method name. */
|
|
477
|
+
requires?: string;
|
|
478
|
+
/**
|
|
479
|
+
* Never attach the session token, even when one is available.
|
|
480
|
+
*
|
|
481
|
+
* For a request that carries its OWN credential — the ingest ticket. Sending
|
|
482
|
+
* both would leave the server's answer ambiguous about which one authorized
|
|
483
|
+
* the write, and would mean a worker that happens to hold a user token
|
|
484
|
+
* behaves differently from one that does not. Exactly one credential per
|
|
485
|
+
* request.
|
|
486
|
+
*/
|
|
487
|
+
anonymous?: boolean;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function request(path: string, init: RequestInit_ = {}): Promise<unknown> {
|
|
491
|
+
const doFetch = resolveFetch();
|
|
492
|
+
const headers: Record<string, string> = { Accept: 'application/json', ...init.headers };
|
|
493
|
+
|
|
494
|
+
if (init.requires) {
|
|
495
|
+
headers.Authorization = `Bearer ${await requireToken(init.requires)}`;
|
|
496
|
+
} else if (!init.anonymous) {
|
|
497
|
+
// Sent when available even on public reads — it is what lets an owner see
|
|
498
|
+
// their own private show through the same method everyone else calls.
|
|
499
|
+
const token = await currentToken();
|
|
500
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const response = await doFetch(`${baseURL}${path}`, {
|
|
504
|
+
method: init.method ?? 'GET',
|
|
505
|
+
headers,
|
|
506
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
507
|
+
});
|
|
508
|
+
if (!response.ok) return raiseFor(response, path);
|
|
246
509
|
return response.json();
|
|
247
510
|
}
|
|
248
511
|
|
|
512
|
+
async function getJson(path: string): Promise<unknown> {
|
|
513
|
+
return request(path);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function postJson(path: string, body: unknown, requires: string): Promise<unknown> {
|
|
517
|
+
return request(path, {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
requires,
|
|
520
|
+
headers: { 'Content-Type': 'application/json' },
|
|
521
|
+
body: JSON.stringify(body),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Append an upload to a `FormData`, in whichever shape the runtime gave us.
|
|
527
|
+
*
|
|
528
|
+
* The RN `{ uri, name, type }` descriptor is not a `Blob` and the DOM
|
|
529
|
+
* `FormData.append` signature does not admit it, so it goes through one cast
|
|
530
|
+
* confined to this function — rather than every call site, or a `declare
|
|
531
|
+
* module` shim that would shadow the real DOM types in every consumer.
|
|
532
|
+
*/
|
|
533
|
+
function appendUpload(form: FormData, field: string, payload: UploadPayload, filename?: string): void {
|
|
534
|
+
if (typeof Blob !== 'undefined' && payload instanceof Blob) {
|
|
535
|
+
form.append(field, payload, filename);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const descriptor = payload as { uri: string; name?: string; type?: string };
|
|
539
|
+
const name = filename ?? descriptor.name ?? field;
|
|
540
|
+
form.append(field, descriptor as unknown as Blob, name);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Drop `undefined` fields so a partial input never sends `"key": null`. */
|
|
544
|
+
function defined(input: Record<string, unknown>): Record<string, unknown> {
|
|
545
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
546
|
+
}
|
|
547
|
+
|
|
249
548
|
function resolveImageRef(ref: string | null | undefined): string | undefined {
|
|
250
549
|
if (!ref) {
|
|
251
550
|
return undefined;
|
|
@@ -418,7 +717,15 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
418
717
|
|
|
419
718
|
const items: EpisodeSummary[] = [];
|
|
420
719
|
for (const raw of rawEpisodes) {
|
|
421
|
-
|
|
720
|
+
/**
|
|
721
|
+
* A single malformed episode row must not fail the whole listing — but
|
|
722
|
+
* "has no enclosure" is no longer malformed. It used to be, and that is
|
|
723
|
+
* what made every SYRA-HOSTED episode disappear from this method: their
|
|
724
|
+
* audio is `audioSource.url`, not an enclosure. The schema now validates
|
|
725
|
+
* IDENTITY and leaves playability to `episodeAudioUrl`, which is also
|
|
726
|
+
* the only honest answer for a drafted episode whose audio has not
|
|
727
|
+
* arrived yet.
|
|
728
|
+
*/
|
|
422
729
|
const parsed = episodeSummarySchema.safeParse(raw);
|
|
423
730
|
if (parsed.success) {
|
|
424
731
|
items.push(parsed.data);
|
|
@@ -469,5 +776,118 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
|
|
|
469
776
|
|
|
470
777
|
return resolveImageRef(source.imageSourceUrl);
|
|
471
778
|
},
|
|
779
|
+
|
|
780
|
+
episodeAudioUrl(episode) {
|
|
781
|
+
// An RSS mirror's enclosure is already absolute and points at somebody
|
|
782
|
+
// else's host, so it is returned untouched.
|
|
783
|
+
if (episode.enclosureUrl && /^https?:\/\//i.test(episode.enclosureUrl)) {
|
|
784
|
+
return episode.enclosureUrl;
|
|
785
|
+
}
|
|
786
|
+
const sourceUrl = episode.audioSource?.url;
|
|
787
|
+
if (sourceUrl) {
|
|
788
|
+
return /^https?:\/\//i.test(sourceUrl) ? sourceUrl : `${baseURL}${sourceUrl}`;
|
|
789
|
+
}
|
|
790
|
+
// A relative enclosure is unusual but representable; resolve it the same way.
|
|
791
|
+
if (episode.enclosureUrl) return `${baseURL}${episode.enclosureUrl}`;
|
|
792
|
+
return undefined;
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
async listMyPodcasts() {
|
|
796
|
+
const json = (await request('/api/podcasts/mine', {
|
|
797
|
+
requires: 'listMyPodcasts',
|
|
798
|
+
})) as PodcastSearchResponseShape;
|
|
799
|
+
const rows = Array.isArray(json?.data) ? json.data : [];
|
|
800
|
+
|
|
801
|
+
const items: PodcastSummary[] = [];
|
|
802
|
+
for (const raw of rows) {
|
|
803
|
+
const parsed = podcastSummarySchema.safeParse(raw);
|
|
804
|
+
if (parsed.success) items.push(parsed.data);
|
|
805
|
+
}
|
|
806
|
+
return items;
|
|
807
|
+
},
|
|
808
|
+
|
|
809
|
+
async createPodcast(input) {
|
|
810
|
+
const json = (await postJson(
|
|
811
|
+
'/api/podcasts',
|
|
812
|
+
defined({ ...input }),
|
|
813
|
+
'createPodcast',
|
|
814
|
+
)) as { data?: unknown };
|
|
815
|
+
return podcastSummarySchema.parse(json?.data);
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
async updatePodcast(podcastId, input) {
|
|
819
|
+
const json = (await request(`/api/podcasts/${encodeURIComponent(podcastId)}`, {
|
|
820
|
+
method: 'PATCH',
|
|
821
|
+
requires: 'updatePodcast',
|
|
822
|
+
headers: { 'Content-Type': 'application/json' },
|
|
823
|
+
body: JSON.stringify(defined({ ...input })),
|
|
824
|
+
})) as { data?: unknown };
|
|
825
|
+
return podcastSummarySchema.parse(json?.data);
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
async setPodcastVisibility(podcastId, visibility) {
|
|
829
|
+
return this.updatePodcast(podcastId, { visibility });
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
async uploadPodcastImage(image, filename) {
|
|
833
|
+
const form = new FormData();
|
|
834
|
+
// `image` is the field name `POST /api/images/upload` reads.
|
|
835
|
+
appendUpload(form, 'image', image, filename);
|
|
836
|
+
|
|
837
|
+
// No `Content-Type`: the runtime sets it, INCLUDING the multipart boundary,
|
|
838
|
+
// which cannot be written by hand. Setting it here produces a body the
|
|
839
|
+
// server cannot parse.
|
|
840
|
+
const json = (await request('/api/images/upload', {
|
|
841
|
+
method: 'POST',
|
|
842
|
+
requires: 'uploadPodcastImage',
|
|
843
|
+
body: form,
|
|
844
|
+
})) as unknown;
|
|
845
|
+
return uploadedImageSchema.parse(json);
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
async createEpisodeDraft(podcastId, input) {
|
|
849
|
+
const json = (await postJson(
|
|
850
|
+
`/api/podcasts/${encodeURIComponent(podcastId)}/episodes/draft`,
|
|
851
|
+
defined({ ...input }),
|
|
852
|
+
'createEpisodeDraft',
|
|
853
|
+
)) as { data?: unknown };
|
|
854
|
+
return episodeDraftSchema.parse(json?.data);
|
|
855
|
+
},
|
|
856
|
+
|
|
857
|
+
async ingestEpisode(draft, audio, input = {}, filename) {
|
|
858
|
+
const form = new FormData();
|
|
859
|
+
// `audioFile` is the field name the ingest endpoint reads.
|
|
860
|
+
appendUpload(form, 'audioFile', audio, filename ?? 'episode.mp3');
|
|
861
|
+
for (const [key, value] of Object.entries(defined({ ...input }))) {
|
|
862
|
+
form.append(key, String(value));
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* `anonymous`, and it is load-bearing rather than tidy: this request is
|
|
867
|
+
* authenticated by the TICKET alone, which is the whole reason the
|
|
868
|
+
* draft/ingest pair exists. Without it a worker that HAPPENS to hold a user
|
|
869
|
+
* token would send both credentials — behaving differently from one that
|
|
870
|
+
* does not, and leaving the server's answer ambiguous about which
|
|
871
|
+
* authorized the write. Caught by a test, not by review.
|
|
872
|
+
*/
|
|
873
|
+
const json = (await request(
|
|
874
|
+
`/api/podcasts/episodes/${encodeURIComponent(draft.episodeId)}/ingest`,
|
|
875
|
+
{
|
|
876
|
+
method: 'POST',
|
|
877
|
+
anonymous: true,
|
|
878
|
+
headers: { 'X-Ingest-Ticket': draft.ingestTicket },
|
|
879
|
+
body: form,
|
|
880
|
+
},
|
|
881
|
+
)) as { data?: unknown };
|
|
882
|
+
return episodeSummarySchema.parse(json?.data);
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
async getEpisodeStream(episodeId) {
|
|
886
|
+
const json = await request(
|
|
887
|
+
`/api/podcasts/episodes/${encodeURIComponent(episodeId)}/stream`,
|
|
888
|
+
{ requires: 'getEpisodeStream' },
|
|
889
|
+
);
|
|
890
|
+
return episodeStreamSchema.parse(json);
|
|
891
|
+
},
|
|
472
892
|
};
|
|
473
893
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,18 +18,35 @@ export type {
|
|
|
18
18
|
ArtworkSource,
|
|
19
19
|
PodcastArtworkSource,
|
|
20
20
|
EpisodeArtworkSource,
|
|
21
|
+
CreatePodcastInput,
|
|
22
|
+
UpdatePodcastInput,
|
|
23
|
+
CreateEpisodeDraftInput,
|
|
24
|
+
IngestEpisodeInput,
|
|
25
|
+
UploadPayload,
|
|
21
26
|
} from './client';
|
|
22
27
|
export {
|
|
23
28
|
trackSummarySchema,
|
|
24
29
|
podcastSummarySchema,
|
|
30
|
+
podcastVisibilitySchema,
|
|
25
31
|
episodeSummarySchema,
|
|
32
|
+
episodeAudioSourceSchema,
|
|
33
|
+
episodeStatusSchema,
|
|
34
|
+
episodeDraftSchema,
|
|
35
|
+
episodeStreamSchema,
|
|
36
|
+
uploadedImageSchema,
|
|
26
37
|
coverArtSizesSchema,
|
|
27
38
|
coverArtVariantSchema,
|
|
28
39
|
} from './schema';
|
|
29
40
|
export type {
|
|
30
41
|
TrackSummary,
|
|
31
42
|
PodcastSummary,
|
|
43
|
+
PodcastVisibility,
|
|
32
44
|
EpisodeSummary,
|
|
45
|
+
EpisodeAudioSource,
|
|
46
|
+
EpisodeStatus,
|
|
47
|
+
EpisodeDraft,
|
|
48
|
+
EpisodeStream,
|
|
49
|
+
UploadedImage,
|
|
33
50
|
CoverArtSizes,
|
|
34
51
|
CoverArtVariant,
|
|
35
52
|
ArtworkSize,
|
|
@@ -97,7 +97,7 @@ export const CreateRoomSheet = forwardRef<CreateRoomSheetRef, CreateRoomSheetPro
|
|
|
97
97
|
speakerPermission: isBroadcast ? 'invited' as const : speakerPermission,
|
|
98
98
|
type: roomType,
|
|
99
99
|
ownerType: selectedHouse ? 'house' as const : 'profile' as const,
|
|
100
|
-
houseId: selectedHouse?.
|
|
100
|
+
houseId: selectedHouse?.id,
|
|
101
101
|
recordingEnabled,
|
|
102
102
|
});
|
|
103
103
|
|
|
@@ -109,10 +109,10 @@ export const CreateRoomSheet = forwardRef<CreateRoomSheetRef, CreateRoomSheetPro
|
|
|
109
109
|
const room = await roomsService.createRoom(buildCreatePayload());
|
|
110
110
|
|
|
111
111
|
if (room) {
|
|
112
|
-
const started = await roomsService.startRoom(room.
|
|
112
|
+
const started = await roomsService.startRoom(room.id);
|
|
113
113
|
onClose();
|
|
114
114
|
if (started) {
|
|
115
|
-
joinLiveRoom(room.
|
|
115
|
+
joinLiveRoom(room.id);
|
|
116
116
|
} else {
|
|
117
117
|
toast.error('Room created but failed to start');
|
|
118
118
|
}
|
|
@@ -317,10 +317,10 @@ export const CreateRoomSheet = forwardRef<CreateRoomSheetRef, CreateRoomSheetPro
|
|
|
317
317
|
horizontal
|
|
318
318
|
showsHorizontalScrollIndicator={false}
|
|
319
319
|
data={[null, ...houses]}
|
|
320
|
-
keyExtractor={(item) => item?.
|
|
320
|
+
keyExtractor={(item) => item?.id ?? 'personal'}
|
|
321
321
|
contentContainerStyle={styles.chipList}
|
|
322
322
|
renderItem={({ item }) => {
|
|
323
|
-
const selected = item === null ? !selectedHouse : selectedHouse?.
|
|
323
|
+
const selected = item === null ? !selectedHouse : selectedHouse?.id === item.id;
|
|
324
324
|
return (
|
|
325
325
|
<TouchableOpacity
|
|
326
326
|
style={[
|
|
@@ -49,9 +49,9 @@ export function RecordingsPanel({ roomId, isHost, theme, onClose, onPlay }: Reco
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
const handlePlay = async (recording: Recording) => {
|
|
52
|
-
setPlayingId(recording.
|
|
52
|
+
setPlayingId(recording.id);
|
|
53
53
|
try {
|
|
54
|
-
const result = await roomsService.getRecording(recording.
|
|
54
|
+
const result = await roomsService.getRecording(recording.id);
|
|
55
55
|
if (result?.playbackUrl) {
|
|
56
56
|
onPlay?.(result.playbackUrl, recording);
|
|
57
57
|
} else {
|
|
@@ -66,10 +66,10 @@ export function RecordingsPanel({ roomId, isHost, theme, onClose, onPlay }: Reco
|
|
|
66
66
|
|
|
67
67
|
const handleToggleAccess = async (recording: Recording) => {
|
|
68
68
|
const newAccess = recording.access === 'public' ? 'participants' as const : 'public' as const;
|
|
69
|
-
const success = await roomsService.updateRecordingAccess(recording.
|
|
69
|
+
const success = await roomsService.updateRecordingAccess(recording.id, newAccess);
|
|
70
70
|
if (success) {
|
|
71
71
|
setRecordings((prev) =>
|
|
72
|
-
prev.map((r) => r.
|
|
72
|
+
prev.map((r) => r.id === recording.id ? { ...r, access: newAccess } : r)
|
|
73
73
|
);
|
|
74
74
|
toast.success(`Recording is now ${newAccess}`);
|
|
75
75
|
} else {
|
|
@@ -78,9 +78,9 @@ export function RecordingsPanel({ roomId, isHost, theme, onClose, onPlay }: Reco
|
|
|
78
78
|
};
|
|
79
79
|
|
|
80
80
|
const handleDelete = async (recording: Recording) => {
|
|
81
|
-
const success = await roomsService.deleteRecording(recording.
|
|
81
|
+
const success = await roomsService.deleteRecording(recording.id);
|
|
82
82
|
if (success) {
|
|
83
|
-
setRecordings((prev) => prev.filter((r) => r.
|
|
83
|
+
setRecordings((prev) => prev.filter((r) => r.id !== recording.id));
|
|
84
84
|
toast.success('Recording deleted');
|
|
85
85
|
} else {
|
|
86
86
|
toast.error('Failed to delete recording');
|
|
@@ -110,7 +110,7 @@ export function RecordingsPanel({ roomId, isHost, theme, onClose, onPlay }: Reco
|
|
|
110
110
|
) : (
|
|
111
111
|
recordings.map((recording) => (
|
|
112
112
|
<View
|
|
113
|
-
key={recording.
|
|
113
|
+
key={recording.id}
|
|
114
114
|
style={[styles.recordingCard, { backgroundColor: `${theme.colors.card}80`, borderColor: theme.colors.border }]}
|
|
115
115
|
>
|
|
116
116
|
<View style={styles.recordingHeader}>
|
|
@@ -144,10 +144,10 @@ export function RecordingsPanel({ roomId, isHost, theme, onClose, onPlay }: Reco
|
|
|
144
144
|
|
|
145
145
|
<TouchableOpacity
|
|
146
146
|
onPress={() => handlePlay(recording)}
|
|
147
|
-
disabled={playingId === recording.
|
|
147
|
+
disabled={playingId === recording.id}
|
|
148
148
|
style={[styles.playButton, { backgroundColor: theme.colors.primary }]}
|
|
149
149
|
>
|
|
150
|
-
{playingId === recording.
|
|
150
|
+
{playingId === recording.id ? (
|
|
151
151
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
152
152
|
) : (
|
|
153
153
|
<MaterialCommunityIcons name="play" size={22} color="#FFFFFF" />
|