@syra.fm/sdk 0.8.0 → 0.10.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.
Files changed (56) hide show
  1. package/lib/commonjs/client.js +236 -7
  2. package/lib/commonjs/client.js.map +1 -1
  3. package/lib/commonjs/index.js +36 -0
  4. package/lib/commonjs/index.js.map +1 -1
  5. package/lib/commonjs/live/components/CreateRoomSheet.js +5 -5
  6. package/lib/commonjs/live/components/CreateRoomSheet.js.map +1 -1
  7. package/lib/commonjs/live/components/RecordingsPanel.js +9 -9
  8. package/lib/commonjs/live/components/RecordingsPanel.js.map +1 -1
  9. package/lib/commonjs/live/services/spaceSocketService.js +1 -1
  10. package/lib/commonjs/live/validation.js +44 -15
  11. package/lib/commonjs/live/validation.js.map +1 -1
  12. package/lib/commonjs/schema.js +80 -9
  13. package/lib/commonjs/schema.js.map +1 -1
  14. package/lib/module/client.js +237 -8
  15. package/lib/module/client.js.map +1 -1
  16. package/lib/module/index.js +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/live/components/CreateRoomSheet.js +5 -5
  19. package/lib/module/live/components/CreateRoomSheet.js.map +1 -1
  20. package/lib/module/live/components/RecordingsPanel.js +9 -9
  21. package/lib/module/live/components/RecordingsPanel.js.map +1 -1
  22. package/lib/module/live/services/spaceSocketService.js +1 -1
  23. package/lib/module/live/validation.js +44 -15
  24. package/lib/module/live/validation.js.map +1 -1
  25. package/lib/module/schema.js +79 -8
  26. package/lib/module/schema.js.map +1 -1
  27. package/lib/typescript/commonjs/client.d.ts +159 -2
  28. package/lib/typescript/commonjs/client.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +3 -3
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/live/components/RoomCard.d.ts +1 -1
  32. package/lib/typescript/commonjs/live/components/RoomCard.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/live/validation.d.ts +17 -20
  34. package/lib/typescript/commonjs/live/validation.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/schema.d.ts +97 -7
  36. package/lib/typescript/commonjs/schema.d.ts.map +1 -1
  37. package/lib/typescript/module/client.d.ts +159 -2
  38. package/lib/typescript/module/client.d.ts.map +1 -1
  39. package/lib/typescript/module/index.d.ts +3 -3
  40. package/lib/typescript/module/index.d.ts.map +1 -1
  41. package/lib/typescript/module/live/components/RoomCard.d.ts +1 -1
  42. package/lib/typescript/module/live/components/RoomCard.d.ts.map +1 -1
  43. package/lib/typescript/module/live/validation.d.ts +17 -20
  44. package/lib/typescript/module/live/validation.d.ts.map +1 -1
  45. package/lib/typescript/module/schema.d.ts +97 -7
  46. package/lib/typescript/module/schema.d.ts.map +1 -1
  47. package/package.json +6 -4
  48. package/src/client.ts +418 -10
  49. package/src/index.ts +17 -0
  50. package/src/live/components/CreateRoomSheet.tsx +5 -5
  51. package/src/live/components/RecordingsPanel.tsx +9 -9
  52. package/src/live/components/RoomCard.tsx +1 -1
  53. package/src/live/services/spaceSocketService.ts +1 -1
  54. package/src/live/validation.ts +41 -15
  55. package/src/schema.ts +90 -7
  56. package/src/client.test.ts +0 -632
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?._id,
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._id);
112
+ const started = await roomsService.startRoom(room.id);
113
113
  onClose();
114
114
  if (started) {
115
- joinLiveRoom(room._id);
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?._id ?? 'personal'}
320
+ keyExtractor={(item) => item?.id ?? 'personal'}
321
321
  contentContainerStyle={styles.chipList}
322
322
  renderItem={({ item }) => {
323
- const selected = item === null ? !selectedHouse : selectedHouse?._id === item._id;
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._id);
52
+ setPlayingId(recording.id);
53
53
  try {
54
- const result = await roomsService.getRecording(recording._id);
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._id, newAccess);
69
+ const success = await roomsService.updateRecordingAccess(recording.id, newAccess);
70
70
  if (success) {
71
71
  setRecordings((prev) =>
72
- prev.map((r) => r._id === recording._id ? { ...r, access: newAccess } : 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._id);
81
+ const success = await roomsService.deleteRecording(recording.id);
82
82
  if (success) {
83
- setRecordings((prev) => prev.filter((r) => r._id !== recording._id));
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._id}
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._id}
147
+ disabled={playingId === recording.id}
148
148
  style={[styles.playButton, { backgroundColor: theme.colors.primary }]}
149
149
  >
150
- {playingId === recording._id ? (
150
+ {playingId === recording.id ? (
151
151
  <ActivityIndicator size="small" color="#FFFFFF" />
152
152
  ) : (
153
153
  <MaterialCommunityIcons name="play" size={22} color="#FFFFFF" />
@@ -80,7 +80,7 @@ const ACTION_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 };
80
80
 
81
81
  interface RoomCardProps {
82
82
  room: {
83
- _id: string;
83
+ id: string;
84
84
  title: string;
85
85
  status: 'scheduled' | 'live' | 'ended';
86
86
  type?: 'talk' | 'stage' | 'broadcast';
@@ -26,7 +26,7 @@ export class RoomSocketService {
26
26
  this.socket = null;
27
27
  }
28
28
 
29
- const baseUrl = this.socketUrl || 'ws://localhost:3000';
29
+ const baseUrl = this.socketUrl || 'ws://localhost:4120';
30
30
 
31
31
  this.socket = io(`${baseUrl}/rooms`, {
32
32
  transports: ['websocket', 'polling'],
@@ -1,5 +1,34 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ /**
4
+ * Accept a document whose identity arrives as `_id` and present it as `id`.
5
+ *
6
+ * The PostgreSQL migration made `id` REQUIRED on the four schemas below, which is
7
+ * correct against the ported backend — `_id` left the wire contract there. But the
8
+ * frontend deploys on its own path (`deploy-frontends.yml`, on `packages/frontend`
9
+ * and `packages/shared-types`) while the backend deploy is gated, so a merge to
10
+ * `main` ships the client half ALONE. It did: `GET /rooms` still answers `_id`,
11
+ * every room failed `ZRoom`, and `validateRooms` DROPS what it cannot parse — so
12
+ * the Live screen went empty in production with only a `console.warn` to say so.
13
+ *
14
+ * Normalising here rather than teaching call sites to read `room.id ?? room._id`
15
+ * is the architecture this repo already states: parse once at the API boundary and
16
+ * return typed data. One place, no UI knows, and it is correct against BOTH
17
+ * backends — so the cutover needs no coordinated client flip, which is the failure
18
+ * this comment exists because of.
19
+ *
20
+ * It can be deleted once no deployed Syra backend serves `_id`. Nothing breaks if
21
+ * it outlives that: a payload carrying only `id` passes through untouched.
22
+ */
23
+ function withMongoIdFallback<T extends z.ZodTypeAny>(schema: T) {
24
+ return z.preprocess((value) => {
25
+ if (value === null || typeof value !== 'object') return value;
26
+ const doc = value as Record<string, unknown>;
27
+ if (typeof doc['id'] === 'string' || typeof doc['_id'] !== 'string') return value;
28
+ return { ...doc, id: doc['_id'] };
29
+ }, schema);
30
+ }
31
+
3
32
  // --- Podcast queue ---
4
33
 
5
34
  /**
@@ -17,9 +46,8 @@ export type PodcastQueueItem = z.infer<typeof ZPodcastQueueItem>;
17
46
 
18
47
  // --- Room (replaces Space) ---
19
48
 
20
- export const ZRoom = z.object({
21
- _id: z.string(),
22
- id: z.string().optional(),
49
+ export const ZRoom = withMongoIdFallback(z.object({
50
+ id: z.string(),
23
51
  title: z.string(),
24
52
  description: z.string().nullish(),
25
53
 
@@ -78,7 +106,7 @@ export const ZRoom = z.object({
78
106
  recordingEgressId: z.string().nullish(),
79
107
 
80
108
  createdAt: z.string(),
81
- }).passthrough();
109
+ }).passthrough());
82
110
 
83
111
  export type Room = z.infer<typeof ZRoom>;
84
112
 
@@ -117,9 +145,8 @@ export const ZHouseVisibility = z.object({
117
145
 
118
146
  export type HouseVisibility = z.infer<typeof ZHouseVisibility>;
119
147
 
120
- export const ZHouse = z.object({
121
- _id: z.string(),
122
- id: z.string().optional(),
148
+ export const ZHouse = withMongoIdFallback(z.object({
149
+ id: z.string(),
123
150
  name: z.string(),
124
151
  description: z.string().optional(),
125
152
  avatar: z.string().optional().nullable(),
@@ -132,7 +159,7 @@ export const ZHouse = z.object({
132
159
  visibility: ZHouseVisibility.default({ discovery: 'listed', rooms: 'anyone', join: 'invite' }),
133
160
  tags: z.array(z.string()).optional(),
134
161
  createdAt: z.string(),
135
- }).passthrough();
162
+ }).passthrough());
136
163
 
137
164
  export type House = z.infer<typeof ZHouse>;
138
165
 
@@ -167,9 +194,8 @@ export const ZSeriesEpisode = z.object({
167
194
 
168
195
  export type SeriesEpisode = z.infer<typeof ZSeriesEpisode>;
169
196
 
170
- export const ZSeries = z.object({
171
- _id: z.string(),
172
- id: z.string().optional(),
197
+ export const ZSeries = withMongoIdFallback(z.object({
198
+ id: z.string(),
173
199
  title: z.string(),
174
200
  description: z.string().optional(),
175
201
  coverImage: z.string().optional().nullable(),
@@ -181,14 +207,14 @@ export const ZSeries = z.object({
181
207
  nextEpisodeNumber: z.number().default(1),
182
208
  isActive: z.boolean().default(true),
183
209
  createdAt: z.string(),
184
- }).passthrough();
210
+ }).passthrough());
185
211
 
186
212
  export type Series = z.infer<typeof ZSeries>;
187
213
 
188
214
  // --- Recording ---
189
215
 
190
- export const ZRecording = z.object({
191
- _id: z.string(),
216
+ export const ZRecording = withMongoIdFallback(z.object({
217
+ id: z.string(),
192
218
  roomId: z.string(),
193
219
  roomTitle: z.string(),
194
220
  host: z.string(),
@@ -203,7 +229,7 @@ export const ZRecording = z.object({
203
229
  participantIds: z.array(z.string()).default([]),
204
230
  expiresAt: z.string(),
205
231
  createdAt: z.string(),
206
- }).passthrough();
232
+ }).passthrough());
207
233
 
208
234
  export type Recording = z.infer<typeof ZRecording>;
209
235
 
package/src/schema.ts CHANGED
@@ -59,6 +59,9 @@ export type TrackSummary = z.infer<typeof trackSummarySchema>;
59
59
  * variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the original
60
60
  * external artwork URL as an absolute fallback when re-hosting has not run yet.
61
61
  */
62
+ export const podcastVisibilitySchema = z.enum(['private', 'unlisted', 'public']);
63
+ export type PodcastVisibility = z.infer<typeof podcastVisibilitySchema>;
64
+
62
65
  export const podcastSummarySchema = z.object({
63
66
  id: z.string(),
64
67
  title: z.string(),
@@ -67,6 +70,15 @@ export const podcastSummarySchema = z.object({
67
70
  image: z.string().optional(),
68
71
  imageSizes: coverArtSizesSchema.optional(),
69
72
  imageSourceUrl: z.string().optional(),
73
+ /**
74
+ * Who may see this show. OPTIONAL here although the API always sends it: a
75
+ * published SDK is installed against whatever version of the API a consumer
76
+ * happens to be pointing at, and requiring a field the previous release did
77
+ * not send would make this SDK reject every response from it.
78
+ */
79
+ visibility: podcastVisibilitySchema.optional(),
80
+ /** Disclosure: the show's content was machine-generated. Optional, same reason. */
81
+ aiGenerated: z.boolean().optional(),
70
82
  });
71
83
  export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
72
84
 
@@ -75,12 +87,28 @@ export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
75
87
  * (`GET /api/podcasts/:id/episodes`, `GET /api/episodes/:id`) — just enough to
76
88
  * list an episode and stream its audio.
77
89
  *
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.
90
+ * ## `enclosureUrl` is OPTIONAL, and that is a bug fix rather than a loosening
91
+ *
92
+ * It used to be required, on the reasoning that "an episode with no enclosure is
93
+ * unplayable, so a row missing it is malformed". That reasoning holds only for
94
+ * RSS-mirrored episodes. A SYRA-HOSTED episode — everything created through
95
+ * `createPodcast`/`uploadEpisode`/the ingest ticket — has no enclosure at all:
96
+ * its audio lives at `audioSource.url`, a path on the Syra API. So the required
97
+ * field made this SDK silently DROP every Syra-hosted episode from
98
+ * `getPodcastEpisodes` and throw on one from `getEpisode` — the entire
99
+ * first-party catalogue, invisible, with no error to notice.
100
+ *
101
+ * The two are alternatives, not a required field and an optional one:
102
+ *
103
+ * enclosureUrl an ABSOLUTE external URL (RSS mirror)
104
+ * audioSource.url a PATH on the Syra API (Syra-hosted)
105
+ *
106
+ * `SyraClient.episodeAudioUrl` is the one place that resolves either into
107
+ * something playable, so no consumer has to know which kind it holds.
108
+ *
109
+ * `enclosureType` / `enclosureLength` describe the external file (MIME type and
110
+ * byte length); `duration` is the runtime in seconds and `pubDate` the ISO
111
+ * publish timestamp.
84
112
  *
85
113
  * Artwork mirrors the podcast SHOW: `image` is the re-hosted Syra image id
86
114
  * (resolved via `/api/images/:id`); `imageSizes` is the multi-resolution variant
@@ -88,18 +116,73 @@ export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
88
116
  * original external artwork URL as an absolute fallback when re-hosting has not
89
117
  * run yet.
90
118
  */
119
+ /**
120
+ * Where a Syra-hosted episode's audio actually is.
121
+ *
122
+ * `url` is a PATH on the Syra API (`/api/podcasts/episodes/:id/audio`), not an
123
+ * absolute URL — resolve it with `SyraClient.episodeAudioUrl` rather than
124
+ * handing it to a player directly.
125
+ */
126
+ export const episodeAudioSourceSchema = z.object({
127
+ url: z.string(),
128
+ format: z.string().optional(),
129
+ bitrate: z.number().optional(),
130
+ duration: z.number().optional(),
131
+ });
132
+ export type EpisodeAudioSource = z.infer<typeof episodeAudioSourceSchema>;
133
+
134
+ /**
135
+ * An episode's processing state. `ready` is playable; `processing` is an episode
136
+ * whose audio is still being packaged (including one drafted but not yet
137
+ * ingested), and only its OWNER is shown those.
138
+ */
139
+ export const episodeStatusSchema = z.enum(['ready', 'processing', 'failed', 'unavailable']);
140
+ export type EpisodeStatus = z.infer<typeof episodeStatusSchema>;
141
+
91
142
  export const episodeSummarySchema = z.object({
92
143
  id: z.string(),
93
144
  podcastId: z.string(),
94
145
  title: z.string(),
95
146
  description: z.string().optional(),
96
- enclosureUrl: z.string(),
147
+ /** ABSOLUTE, and present only for RSS-mirrored episodes — see the note above. */
148
+ enclosureUrl: z.string().optional(),
97
149
  enclosureType: z.string().optional(),
98
150
  enclosureLength: z.number().optional(),
151
+ /** A PATH on the Syra API, present for Syra-hosted episodes. */
152
+ audioSource: episodeAudioSourceSchema.optional(),
99
153
  duration: z.number().optional(),
100
154
  pubDate: z.string().optional(),
155
+ season: z.number().optional(),
156
+ episodeNumber: z.number().optional(),
157
+ status: episodeStatusSchema.optional(),
158
+ /** Disclosure: this episode's content was machine-generated. */
159
+ aiGenerated: z.boolean().optional(),
101
160
  image: z.string().optional(),
102
161
  imageSizes: coverArtSizesSchema.optional(),
103
162
  imageSourceUrl: z.string().optional(),
104
163
  });
105
164
  export type EpisodeSummary = z.infer<typeof episodeSummarySchema>;
165
+
166
+ /** What `createEpisodeDraft` hands back: the episode to fill, and the capability to fill it. */
167
+ export const episodeDraftSchema = z.object({
168
+ episodeId: z.string(),
169
+ ingestTicket: z.string(),
170
+ expiresAt: z.string(),
171
+ });
172
+ export type EpisodeDraft = z.infer<typeof episodeDraftSchema>;
173
+
174
+ /** What `getEpisodeStream` hands back: a tokenized HLS URL with its own deadline. */
175
+ export const episodeStreamSchema = z.object({
176
+ url: z.string(),
177
+ type: z.string(),
178
+ expiresAt: z.string().optional(),
179
+ });
180
+ export type EpisodeStream = z.infer<typeof episodeStreamSchema>;
181
+
182
+ /** What the image upload endpoint hands back. */
183
+ export const uploadedImageSchema = z.object({
184
+ id: z.string(),
185
+ primaryColor: z.string().optional(),
186
+ secondaryColor: z.string().optional(),
187
+ });
188
+ export type UploadedImage = z.infer<typeof uploadedImageSchema>;