@goscribe/server 1.0.11 → 1.1.1

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 (83) hide show
  1. package/ANALYSIS_PROGRESS_SPEC.md +463 -0
  2. package/PROGRESS_QUICK_REFERENCE.md +239 -0
  3. package/dist/lib/ai-session.d.ts +20 -9
  4. package/dist/lib/ai-session.js +316 -80
  5. package/dist/lib/auth.d.ts +35 -2
  6. package/dist/lib/auth.js +88 -15
  7. package/dist/lib/env.d.ts +32 -0
  8. package/dist/lib/env.js +46 -0
  9. package/dist/lib/errors.d.ts +33 -0
  10. package/dist/lib/errors.js +78 -0
  11. package/dist/lib/inference.d.ts +4 -1
  12. package/dist/lib/inference.js +9 -11
  13. package/dist/lib/logger.d.ts +62 -0
  14. package/dist/lib/logger.js +342 -0
  15. package/dist/lib/podcast-prompts.d.ts +43 -0
  16. package/dist/lib/podcast-prompts.js +135 -0
  17. package/dist/lib/pusher.d.ts +1 -0
  18. package/dist/lib/pusher.js +14 -2
  19. package/dist/lib/storage.d.ts +3 -3
  20. package/dist/lib/storage.js +51 -47
  21. package/dist/lib/validation.d.ts +51 -0
  22. package/dist/lib/validation.js +64 -0
  23. package/dist/routers/_app.d.ts +697 -111
  24. package/dist/routers/_app.js +5 -0
  25. package/dist/routers/auth.d.ts +11 -1
  26. package/dist/routers/chat.d.ts +11 -1
  27. package/dist/routers/flashcards.d.ts +205 -6
  28. package/dist/routers/flashcards.js +144 -66
  29. package/dist/routers/members.d.ts +165 -0
  30. package/dist/routers/members.js +531 -0
  31. package/dist/routers/podcast.d.ts +78 -63
  32. package/dist/routers/podcast.js +330 -393
  33. package/dist/routers/studyguide.d.ts +11 -1
  34. package/dist/routers/worksheets.d.ts +124 -13
  35. package/dist/routers/worksheets.js +123 -50
  36. package/dist/routers/workspace.d.ts +213 -26
  37. package/dist/routers/workspace.js +303 -181
  38. package/dist/server.js +12 -4
  39. package/dist/services/flashcard-progress.service.d.ts +183 -0
  40. package/dist/services/flashcard-progress.service.js +383 -0
  41. package/dist/services/flashcard.service.d.ts +183 -0
  42. package/dist/services/flashcard.service.js +224 -0
  43. package/dist/services/podcast-segment-reorder.d.ts +0 -0
  44. package/dist/services/podcast-segment-reorder.js +107 -0
  45. package/dist/services/podcast.service.d.ts +0 -0
  46. package/dist/services/podcast.service.js +326 -0
  47. package/dist/services/worksheet.service.d.ts +0 -0
  48. package/dist/services/worksheet.service.js +295 -0
  49. package/dist/trpc.d.ts +13 -2
  50. package/dist/trpc.js +55 -6
  51. package/dist/types/index.d.ts +126 -0
  52. package/dist/types/index.js +1 -0
  53. package/package.json +3 -2
  54. package/prisma/schema.prisma +142 -4
  55. package/src/lib/ai-session.ts +356 -85
  56. package/src/lib/auth.ts +113 -19
  57. package/src/lib/env.ts +59 -0
  58. package/src/lib/errors.ts +92 -0
  59. package/src/lib/inference.ts +11 -11
  60. package/src/lib/logger.ts +405 -0
  61. package/src/lib/pusher.ts +15 -3
  62. package/src/lib/storage.ts +56 -51
  63. package/src/lib/validation.ts +75 -0
  64. package/src/routers/_app.ts +5 -0
  65. package/src/routers/chat.ts +2 -23
  66. package/src/routers/flashcards.ts +108 -24
  67. package/src/routers/members.ts +586 -0
  68. package/src/routers/podcast.ts +385 -420
  69. package/src/routers/worksheets.ts +118 -36
  70. package/src/routers/workspace.ts +356 -195
  71. package/src/server.ts +13 -4
  72. package/src/services/flashcard-progress.service.ts +541 -0
  73. package/src/trpc.ts +59 -6
  74. package/src/types/index.ts +165 -0
  75. package/AUTH_FRONTEND_SPEC.md +0 -21
  76. package/CHAT_FRONTEND_SPEC.md +0 -474
  77. package/DATABASE_SETUP.md +0 -165
  78. package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
  79. package/PODCAST_FRONTEND_SPEC.md +0 -595
  80. package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
  81. package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
  82. package/WORKSPACE_FRONTEND_SPEC.md +0 -47
  83. package/test-ai-integration.js +0 -134
@@ -3,8 +3,9 @@ import { TRPCError } from '@trpc/server';
3
3
  import { router, authedProcedure } from '../trpc.js';
4
4
  import { v4 as uuidv4 } from 'uuid';
5
5
  import inference from '../lib/inference.js';
6
- import { uploadToGCS, generateSignedUrl, deleteFromGCS } from '../lib/storage.js';
6
+ import { generateSignedUrl, deleteFromSupabase } from '../lib/storage.js';
7
7
  import PusherService from '../lib/pusher.js';
8
+ import { aiSessionService } from '../lib/ai-session.js';
8
9
  // Prisma enum values mapped manually to avoid type import issues in ESM
9
10
  const ArtifactType = {
10
11
  PODCAST_EPISODE: 'PODCAST_EPISODE',
@@ -21,27 +22,31 @@ const podcastSegmentSchema = z.object({
21
22
  keyPoints: z.array(z.string()),
22
23
  order: z.number().int(),
23
24
  audioUrl: z.string().optional(),
24
- objectKey: z.string().optional(), // Google Cloud Storage object key
25
+ objectKey: z.string().optional(), // Supabase Storage object key
26
+ });
27
+ // Speaker schema
28
+ const speakerSchema = z.object({
29
+ id: z.string(),
30
+ role: z.enum(['host', 'guest', 'expert']),
31
+ name: z.string().optional(),
25
32
  });
26
33
  // Podcast creation input schema
27
34
  const podcastInputSchema = z.object({
28
35
  title: z.string(),
29
36
  description: z.string().optional(),
30
37
  userPrompt: z.string(),
31
- voice: z.enum(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']).default('nova'),
38
+ speakers: z.array(speakerSchema).min(1).default([{ id: 'pNInz6obpgDQGcFmaJgB', role: 'host' }]),
32
39
  speed: z.number().min(0.25).max(4.0).default(1.0),
33
40
  generateIntro: z.boolean().default(true),
34
41
  generateOutro: z.boolean().default(true),
35
42
  segmentByTopics: z.boolean().default(true),
36
43
  });
37
- // Podcast metadata schema for version data
44
+ // Podcast metadata schema for version data (segments stored separately in database)
38
45
  const podcastMetadataSchema = z.object({
39
46
  title: z.string(),
40
47
  description: z.string().optional(),
41
48
  totalDuration: z.number(),
42
- voice: z.string(),
43
- speed: z.number(),
44
- segments: z.array(podcastSegmentSchema),
49
+ speakers: z.array(speakerSchema),
45
50
  summary: z.object({
46
51
  executiveSummary: z.string(),
47
52
  learningObjectives: z.array(z.string()),
@@ -61,6 +66,7 @@ export const podcast = router({
61
66
  const workspace = await ctx.db.workspace.findFirst({
62
67
  where: { id: input.workspaceId, ownerId: ctx.session.user.id },
63
68
  });
69
+ // Check if workspace exists
64
70
  if (!workspace)
65
71
  throw new TRPCError({ code: 'NOT_FOUND' });
66
72
  const artifacts = await ctx.db.artifact.findMany({
@@ -73,99 +79,88 @@ export const podcast = router({
73
79
  orderBy: { version: 'desc' },
74
80
  take: 1, // Get only the latest version
75
81
  },
82
+ podcastSegments: {
83
+ orderBy: { order: 'asc' },
84
+ },
76
85
  },
77
86
  orderBy: { updatedAt: 'desc' },
78
87
  });
79
- // Transform to include metadata from the latest version with fresh signed URLs
88
+ console.log(`📻 Found ${artifacts.length} podcast artifacts`);
89
+ artifacts.forEach((artifact, i) => {
90
+ console.log(` Podcast ${i + 1}: "${artifact.title}" - ${artifact.podcastSegments.length} segments`);
91
+ });
92
+ // Transform to include segments with fresh signed URLs
80
93
  const episodesWithUrls = await Promise.all(artifacts.map(async (artifact) => {
81
94
  const latestVersion = artifact.versions[0];
82
- if (!latestVersion) {
83
- // Return a consistent structure even when no version exists
84
- return {
85
- id: artifact.id,
86
- title: artifact.title || 'Untitled Episode',
87
- description: artifact.description || null,
88
- metadata: null,
89
- segments: [],
90
- createdAt: artifact.createdAt,
91
- updatedAt: artifact.updatedAt,
92
- workspaceId: artifact.workspaceId,
93
- type: artifact.type,
94
- createdById: artifact.createdById,
95
- isArchived: artifact.isArchived,
96
- };
95
+ let objectUrl = null;
96
+ if (artifact.imageObjectKey) {
97
+ objectUrl = await generateSignedUrl(artifact.imageObjectKey, 24);
97
98
  }
98
- try {
99
- const metadata = podcastMetadataSchema.parse(latestVersion.data);
100
- // Generate fresh signed URLs for all segments
101
- const segmentsWithUrls = await Promise.all(metadata.segments.map(async (segment) => {
102
- if (segment.objectKey) {
103
- try {
104
- const signedUrl = await generateSignedUrl(segment.objectKey, 24); // 24 hours
105
- return {
106
- id: segment.id,
107
- title: segment.title,
108
- audioUrl: signedUrl,
109
- objectKey: segment.objectKey,
110
- startTime: segment.startTime,
111
- duration: segment.duration,
112
- order: segment.order,
113
- };
114
- }
115
- catch (error) {
116
- console.error(`Failed to generate signed URL for segment ${segment.id}:`, error);
117
- return {
118
- id: segment.id,
119
- title: segment.title,
120
- audioUrl: null,
121
- objectKey: segment.objectKey,
122
- startTime: segment.startTime,
123
- duration: segment.duration,
124
- order: segment.order,
125
- };
126
- }
99
+ // Generate fresh signed URLs for all segments
100
+ const segmentsWithUrls = await Promise.all(artifact.podcastSegments.map(async (segment) => {
101
+ if (segment.objectKey) {
102
+ try {
103
+ const signedUrl = await generateSignedUrl(segment.objectKey, 24); // 24 hours
104
+ return {
105
+ id: segment.id,
106
+ title: segment.title,
107
+ audioUrl: signedUrl,
108
+ objectKey: segment.objectKey,
109
+ startTime: segment.startTime,
110
+ duration: segment.duration,
111
+ order: segment.order,
112
+ };
127
113
  }
128
- return {
129
- id: segment.id,
130
- title: segment.title,
131
- audioUrl: null,
132
- objectKey: segment.objectKey,
133
- startTime: segment.startTime,
134
- duration: segment.duration,
135
- order: segment.order,
136
- };
137
- }));
138
- return {
139
- id: artifact.id,
140
- title: metadata.title, // Use title from version metadata
141
- description: metadata.description, // Use description from version metadata
142
- metadata: metadata,
143
- segments: segmentsWithUrls,
144
- createdAt: artifact.createdAt,
145
- updatedAt: artifact.updatedAt,
146
- workspaceId: artifact.workspaceId,
147
- type: artifact.type,
148
- createdById: artifact.createdById,
149
- isArchived: artifact.isArchived,
150
- };
151
- }
152
- catch (error) {
153
- console.error('Failed to parse podcast metadata:', error);
154
- // Return a consistent structure even when metadata parsing fails
114
+ catch (error) {
115
+ console.error(`Failed to generate signed URL for segment ${segment.id}:`, error);
116
+ return {
117
+ id: segment.id,
118
+ title: segment.title,
119
+ audioUrl: null,
120
+ objectKey: segment.objectKey,
121
+ startTime: segment.startTime,
122
+ duration: segment.duration,
123
+ order: segment.order,
124
+ };
125
+ }
126
+ }
155
127
  return {
156
- id: artifact.id,
157
- title: artifact.title || 'Untitled Episode',
158
- description: artifact.description || null,
159
- metadata: null,
160
- segments: [],
161
- createdAt: artifact.createdAt,
162
- updatedAt: artifact.updatedAt,
163
- workspaceId: artifact.workspaceId,
164
- type: artifact.type,
165
- createdById: artifact.createdById,
166
- isArchived: artifact.isArchived,
128
+ id: segment.id,
129
+ title: segment.title,
130
+ audioUrl: null,
131
+ objectKey: segment.objectKey,
132
+ startTime: segment.startTime,
133
+ duration: segment.duration,
134
+ order: segment.order,
167
135
  };
136
+ }));
137
+ // Parse metadata from latest version if available
138
+ let metadata = null;
139
+ if (latestVersion) {
140
+ try {
141
+ console.log(latestVersion.data);
142
+ metadata = podcastMetadataSchema.parse(latestVersion.data);
143
+ }
144
+ catch (error) {
145
+ console.error('Failed to parse podcast metadata:', error);
146
+ }
168
147
  }
148
+ return {
149
+ id: artifact.id,
150
+ title: metadata?.title || artifact.title || 'Untitled Episode',
151
+ description: metadata?.description || artifact.description || null,
152
+ metadata: metadata,
153
+ imageUrl: objectUrl,
154
+ segments: segmentsWithUrls,
155
+ createdAt: artifact.createdAt,
156
+ updatedAt: artifact.updatedAt,
157
+ workspaceId: artifact.workspaceId,
158
+ generating: artifact.generating,
159
+ generatingMetadata: artifact.generatingMetadata,
160
+ type: artifact.type,
161
+ createdById: artifact.createdById,
162
+ isArchived: artifact.isArchived,
163
+ };
169
164
  }));
170
165
  return episodesWithUrls;
171
166
  }),
@@ -184,6 +179,9 @@ export const podcast = router({
184
179
  orderBy: { version: 'desc' },
185
180
  take: 1,
186
181
  },
182
+ podcastSegments: {
183
+ orderBy: { order: 'asc' },
184
+ },
187
185
  },
188
186
  });
189
187
  console.log(episode);
@@ -193,9 +191,16 @@ export const podcast = router({
193
191
  if (!latestVersion)
194
192
  throw new TRPCError({ code: 'NOT_FOUND', message: 'No version found' });
195
193
  console.log(latestVersion);
194
+ try {
195
+ const metadata = podcastMetadataSchema.parse(latestVersion.data);
196
+ }
197
+ catch (error) {
198
+ console.error('Failed to parse podcast metadata:', error);
199
+ }
196
200
  const metadata = podcastMetadataSchema.parse(latestVersion.data);
201
+ const imageUrl = episode.imageObjectKey ? await generateSignedUrl(episode.imageObjectKey, 24) : null;
197
202
  // Generate fresh signed URLs for all segments
198
- const segmentsWithUrls = await Promise.all(metadata.segments.map(async (segment) => {
203
+ const segmentsWithUrls = await Promise.all(episode.podcastSegments.map(async (segment) => {
199
204
  if (segment.objectKey) {
200
205
  try {
201
206
  const signedUrl = await generateSignedUrl(segment.objectKey, 24); // 24 hours
@@ -243,6 +248,7 @@ export const podcast = router({
243
248
  title: metadata.title, // Use title from version metadata
244
249
  description: metadata.description, // Use description from version metadata
245
250
  metadata,
251
+ imageUrl: imageUrl,
246
252
  segments: segmentsWithUrls,
247
253
  content: latestVersion.content, // transcript
248
254
  createdAt: episode.createdAt,
@@ -261,170 +267,163 @@ export const podcast = router({
261
267
  });
262
268
  if (!workspace)
263
269
  throw new TRPCError({ code: 'NOT_FOUND' });
264
- try {
265
- // Emit podcast generation start notification
266
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_generation_start', {
267
- title: input.podcastData.title
268
- });
269
- const studyGuide = await ctx.db.artifact.findFirst({
270
- where: {
271
- workspaceId: input.workspaceId,
272
- type: ArtifactType.STUDY_GUIDE,
273
- },
274
- include: {
275
- versions: {
276
- orderBy: { version: 'desc' },
277
- take: 1,
278
- },
270
+ // Emit podcast generation start notification
271
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_generation_start', {
272
+ title: input.podcastData.title
273
+ });
274
+ const BEGIN_PODCAST_GENERATION_MESSAGE = 'Structuring podcast contents...';
275
+ const newArtifact = await ctx.db.artifact.create({
276
+ data: {
277
+ title: '----',
278
+ type: ArtifactType.PODCAST_EPISODE,
279
+ generating: true,
280
+ generatingMetadata: {
281
+ message: BEGIN_PODCAST_GENERATION_MESSAGE,
279
282
  },
280
- });
281
- const latestVersion = studyGuide?.versions[0];
282
- const studyGuideContent = latestVersion?.content || '';
283
- // Step 1: Structure the content into segments using inference API
284
- const structurePrompt = `You are a podcast content structuring assistant. Given a user prompt, create a complete podcast episode with engaging content and logical segments.
285
-
286
- Based on the user's prompt (and any existing study guide context for this workspace), generate a podcast episode that:
287
- - Addresses the user's request or topic
288
- - Is educational, informative, and engaging
289
- - Has natural, conversational language
290
- - Flows logically from one segment to the next
291
-
292
- Create segments that are:
293
- - 2-5 minutes each when spoken
294
- - Focused on specific topics or concepts
295
- - Include key takeaways for each segment
296
- - Use natural, conversational language suitable for audio
297
-
298
- ${input.podcastData.generateIntro ? 'Include an engaging introduction segment that hooks the listener.' : ''}
299
- ${input.podcastData.generateOutro ? 'Include a conclusion/outro segment that summarizes key points.' : ''}
300
-
301
- Format your response as JSON:
302
- {
303
- "episodeTitle": "Enhanced title for the podcast",
304
- "totalEstimatedDuration": "estimated duration in minutes",
305
- "segments": [
306
- {
307
- "title": "Segment title",
308
- "content": "Natural, conversational script for this segment",
309
- "keyPoints": ["key point 1", "key point 2"],
310
- "estimatedDuration": "duration in minutes",
311
- "order": 1
312
- }
313
- ]
314
- }
315
-
316
- Title: ${input.podcastData.title}
317
- Description: ${input.podcastData.description || 'No description provided'}
318
- Users notes:
319
- User Prompt: ${input.podcastData.userPrompt}
320
-
321
- If there is a study guide artifact in this workspace, incorporate its key points and structure to improve coherence. Use it only as supportive context, do not copy verbatim.`;
322
- const structureResponse = await inference(structurePrompt, 'podcast_structure');
323
- const structureData = await structureResponse.json();
324
- const structureContent = structureData.response || '';
325
- let structuredContent;
326
- try {
327
- // Extract JSON from the response
328
- const jsonMatch = structureContent.match(/\{[\s\S]*\}/);
329
- if (!jsonMatch) {
330
- throw new Error('No JSON found in response');
283
+ workspace: {
284
+ connect: {
285
+ id: input.workspaceId,
286
+ }
331
287
  }
332
- structuredContent = JSON.parse(jsonMatch[0]);
333
288
  }
334
- catch (parseError) {
335
- console.error('Failed to parse structure response:', structureContent);
336
- await PusherService.emitError(input.workspaceId, 'Failed to structure podcast content', 'podcast');
289
+ });
290
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_info', {
291
+ message: BEGIN_PODCAST_GENERATION_MESSAGE,
292
+ });
293
+ try {
294
+ const structureResult = await aiSessionService.generatePodcastStructure(input.workspaceId, ctx.session.user.id, input.podcastData.title, input.podcastData.description || '', input.podcastData.userPrompt, input.podcastData.speakers);
295
+ if (!structureResult.success || !structureResult.structure) {
337
296
  throw new TRPCError({
338
297
  code: 'INTERNAL_SERVER_ERROR',
339
- message: 'Failed to structure podcast content'
298
+ message: 'Failed to generate podcast structure'
340
299
  });
341
300
  }
342
- // Emit structure completion notification
343
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_structure_complete', {
344
- segmentsCount: structuredContent.segments?.length || 0
301
+ const structure = structureResult.structure;
302
+ await ctx.db.artifact.update({
303
+ where: {
304
+ id: newArtifact.id,
305
+ },
306
+ data: {
307
+ title: structure.episodeTitle,
308
+ }
345
309
  });
346
310
  // Step 2: Generate audio for each segment
347
311
  const segments = [];
312
+ const failedSegments = [];
348
313
  let totalDuration = 0;
349
314
  let fullTranscript = '';
350
- // Emit audio generation start notification
351
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_audio_generation_start', {
352
- totalSegments: structuredContent.segments?.length || 0
315
+ await ctx.db.artifact.update({
316
+ where: {
317
+ id: newArtifact.id,
318
+ },
319
+ data: {
320
+ generatingMetadata: {
321
+ message: `Generating podcast image...`,
322
+ },
323
+ }
324
+ });
325
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_info', {
326
+ message: `Generating podcast image...`,
353
327
  });
354
- for (const [index, segment] of structuredContent.segments.entries()) {
328
+ const podcastImage = await aiSessionService.generatePodcastImage(input.workspaceId, ctx.session.user.id, structure.segments.map((segment) => segment.content).join('\n\n'));
329
+ await ctx.db.artifact.update({
330
+ where: {
331
+ id: newArtifact.id,
332
+ },
333
+ data: {
334
+ imageObjectKey: podcastImage,
335
+ }
336
+ });
337
+ for (let i = 0; i < structure.segments.length; i++) {
338
+ const segment = structure.segments[i];
355
339
  try {
356
340
  // Emit segment generation progress
357
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_segment_progress', {
358
- currentSegment: index + 1,
359
- totalSegments: structuredContent.segments.length,
360
- segmentTitle: segment.title || `Segment ${index + 1}`
361
- });
362
- // Generate speech for this segment using Murf TTS
363
- const mp3Response = await fetch('https://api.murf.ai/v1/speech/generate', {
364
- method: 'POST',
365
- headers: {
366
- 'api-key': process.env.MURF_TTS_KEY || '',
367
- 'Content-Type': 'application/json',
368
- 'Accept': 'application/json',
341
+ // await PusherService.emitTaskComplete(input.workspaceId, 'podcast_segment_progress', {
342
+ // currentSegment: i + 1,
343
+ // totalSegments: structure.segments.length,
344
+ // segmentTitle: segment.title || `Segment ${i + 1}`,
345
+ // successfulSegments: segments.length,
346
+ // failedSegments: failedSegments.length,
347
+ // });
348
+ await ctx.db.artifact.update({
349
+ where: {
350
+ id: newArtifact.id,
369
351
  },
370
- body: JSON.stringify({
371
- text: segment.content,
372
- voiceId: 'en-US-natalie',
373
- }),
352
+ data: {
353
+ generatingMetadata: {
354
+ message: `Generating audio for "${segment.title}" (${i + 1} of ${structure.segments.length})...`,
355
+ },
356
+ }
374
357
  });
375
- if (!mp3Response.ok) {
376
- throw new Error(`Murf TTS error: ${mp3Response.status} ${mp3Response.statusText}`);
377
- }
378
- // Parse the response to get the audio URL
379
- const mp3Data = await mp3Response.json();
380
- // Check for different possible response structures
381
- const audioUrl = mp3Data.audioFile || mp3Data.audioUrl || mp3Data.url || mp3Data.downloadUrl;
382
- if (!audioUrl) {
383
- console.error('No audio URL found in Murf response. Available fields:', Object.keys(mp3Data));
384
- throw new Error('No audio URL in Murf response');
385
- }
386
- // Download the actual audio file from the URL
387
- const audioResponse = await fetch(audioUrl);
388
- if (!audioResponse.ok) {
389
- throw new Error(`Failed to download audio: ${audioResponse.status} ${audioResponse.statusText}`);
358
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_info', {
359
+ message: `Generating audio for segment ${i + 1} of ${structure.segments.length}...`,
360
+ });
361
+ // Generate audio using new API
362
+ const audioResult = await aiSessionService.generatePodcastAudioFromText(input.workspaceId, ctx.session.user.id, newArtifact.id, i, segment.content, input.podcastData.speakers, segment.voiceId);
363
+ if (!audioResult.success) {
364
+ throw new Error('Failed to generate audio for segment');
390
365
  }
391
- // Upload to Google Cloud Storage
392
- const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
393
- const fileName = `segment_${index + 1}.mp3`;
394
- const uploadResult = await uploadToGCS(audioBuffer, fileName, 'audio/mpeg', false); // Keep private
395
- // Estimate duration (roughly 150 words per minute for TTS)
396
- const wordCount = segment.content.split(' ').length;
397
- const estimatedDuration = Math.ceil((wordCount / 150) * 60); // in seconds
398
366
  segments.push({
399
367
  id: uuidv4(),
400
368
  title: segment.title,
401
369
  content: segment.content,
402
- objectKey: uploadResult.objectKey, // Store object key for future operations
370
+ objectKey: audioResult.objectKey,
403
371
  startTime: totalDuration,
404
- duration: estimatedDuration,
372
+ duration: audioResult.duration,
405
373
  keyPoints: segment.keyPoints || [],
406
- order: segment.order || index + 1,
374
+ order: segment.order || i + 1,
407
375
  });
408
- totalDuration += estimatedDuration;
376
+ totalDuration += audioResult.duration;
409
377
  fullTranscript += `\n\n## ${segment.title}\n\n${segment.content}`;
410
378
  }
411
379
  catch (audioError) {
412
- console.error(`Error generating audio for segment ${index + 1}:`, audioError);
380
+ const errorMessage = audioError instanceof Error ? audioError.message : 'Unknown error';
381
+ console.error(`❌ Error generating audio for segment ${i + 1}:`, {
382
+ title: segment.title,
383
+ error: errorMessage,
384
+ stack: audioError instanceof Error ? audioError.stack : undefined,
385
+ });
386
+ // Track failed segment
387
+ failedSegments.push({
388
+ index: i + 1,
389
+ title: segment.title || `Segment ${i + 1}`,
390
+ error: errorMessage,
391
+ });
413
392
  await PusherService.emitTaskComplete(input.workspaceId, 'podcast_segment_error', {
414
- segmentIndex: index + 1,
415
- error: audioError instanceof Error ? audioError.message : 'Unknown error'
393
+ segmentIndex: i + 1,
394
+ segmentTitle: segment.title || `Segment ${i + 1}`,
395
+ error: errorMessage,
396
+ successfulSegments: segments.length,
397
+ failedSegments: failedSegments.length,
416
398
  });
417
399
  // Continue with other segments even if one fails
418
400
  }
419
401
  }
420
- // Emit audio generation completion notification
421
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_audio_generation_complete', {
422
- totalSegments: segments.length,
423
- totalDuration: totalDuration
402
+ // Check if any segments were successfully generated
403
+ if (segments.length === 0) {
404
+ console.error('No segments were successfully generated');
405
+ await PusherService.emitError(input.workspaceId, `Failed to generate any segments. ${failedSegments.length} segment(s) failed.`, 'podcast');
406
+ // Cleanup the artifact
407
+ await ctx.db.artifact.delete({
408
+ where: { id: newArtifact.id },
409
+ });
410
+ throw new TRPCError({
411
+ code: 'INTERNAL_SERVER_ERROR',
412
+ message: `Failed to generate any audio segments. All ${failedSegments.length} attempts failed.`
413
+ });
414
+ }
415
+ await ctx.db.artifact.update({
416
+ where: {
417
+ id: newArtifact.id,
418
+ },
419
+ data: {
420
+ generatingMetadata: {
421
+ message: `Preparing podcast summary...`,
422
+ },
423
+ }
424
424
  });
425
- // Step 2.5: Prepare segment audio array for frontend joining
426
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_audio_preparation_complete', {
427
- totalSegments: segments.length
425
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_info', {
426
+ message: `Preparing podcast summary...`,
428
427
  });
429
428
  // Step 3: Generate episode summary using inference API
430
429
  const summaryPrompt = `Create a comprehensive podcast episode summary including:
@@ -446,11 +445,10 @@ export const podcast = router({
446
445
  "tags": ["tag1", "tag2", "tag3"]
447
446
  }
448
447
 
449
- Podcast Title: ${structuredContent.episodeTitle}
448
+ Podcast Title: ${structure.episodeTitle}
450
449
  Segments: ${JSON.stringify(segments.map(s => ({ title: s.title, keyPoints: s.keyPoints })))}`;
451
- const summaryResponse = await inference(summaryPrompt, 'podcast_summary');
452
- const summaryData = await summaryResponse.json();
453
- const summaryContent = summaryData.response || '';
450
+ const summaryResponse = await inference(summaryPrompt);
451
+ const summaryContent = summaryResponse.choices[0].message.content || '';
454
452
  let episodeSummary;
455
453
  try {
456
454
  // Extract JSON from the response
@@ -476,12 +474,15 @@ export const podcast = router({
476
474
  };
477
475
  }
478
476
  // Emit summary generation completion notification
479
- await PusherService.emitTaskComplete(input.workspaceId, 'podcast_summary_complete', {
480
- summaryGenerated: true
477
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_info', {
478
+ message: `Podcast summary generated.`,
481
479
  });
482
480
  // Step 4: Create artifact and initial version
483
- const episodeTitle = structuredContent.episodeTitle || input.podcastData.title;
484
- const artifact = await ctx.db.artifact.create({
481
+ const episodeTitle = structure.episodeTitle || input.podcastData.title;
482
+ await ctx.db.artifact.update({
483
+ where: {
484
+ id: newArtifact.id,
485
+ },
485
486
  data: {
486
487
  workspaceId: input.workspaceId,
487
488
  type: ArtifactType.PODCAST_EPISODE,
@@ -490,30 +491,51 @@ export const podcast = router({
490
491
  createdById: ctx.session.user.id,
491
492
  },
492
493
  });
493
- // Create initial version with complete metadata
494
+ const createdSegments = await ctx.db.podcastSegment.createMany({
495
+ data: segments.map(segment => ({
496
+ artifactId: newArtifact.id,
497
+ title: segment.title,
498
+ content: segment.content,
499
+ startTime: segment.startTime,
500
+ duration: segment.duration,
501
+ order: segment.order,
502
+ objectKey: segment.objectKey,
503
+ keyPoints: segment.keyPoints,
504
+ meta: {
505
+ speed: input.podcastData.speed,
506
+ speakers: input.podcastData.speakers,
507
+ },
508
+ })),
509
+ });
494
510
  const metadata = {
495
511
  title: episodeTitle,
496
512
  description: input.podcastData.description,
497
513
  totalDuration: totalDuration,
498
- voice: input.podcastData.voice,
499
- speed: input.podcastData.speed,
500
- segments: segments, // Array of segments with audio URLs for frontend joining
501
514
  summary: episodeSummary,
515
+ speakers: input.podcastData.speakers,
502
516
  generatedAt: new Date().toISOString(),
503
517
  };
504
518
  await ctx.db.artifactVersion.create({
505
519
  data: {
506
- artifactId: artifact.id,
520
+ artifactId: newArtifact.id,
507
521
  version: 1,
508
522
  content: fullTranscript.trim(), // Full transcript as markdown
509
523
  data: metadata,
510
524
  createdById: ctx.session.user.id,
511
525
  },
512
526
  });
527
+ await ctx.db.artifact.update({
528
+ where: {
529
+ id: newArtifact.id,
530
+ },
531
+ data: {
532
+ generating: false,
533
+ },
534
+ });
513
535
  // Emit podcast generation completion notification
514
- await PusherService.emitPodcastComplete(input.workspaceId, artifact);
536
+ await PusherService.emitPodcastComplete(input.workspaceId, {});
515
537
  return {
516
- id: artifact.id,
538
+ id: newArtifact.id,
517
539
  title: metadata.title,
518
540
  description: metadata.description,
519
541
  metadata,
@@ -522,6 +544,11 @@ export const podcast = router({
522
544
  }
523
545
  catch (error) {
524
546
  console.error('Error generating podcast episode:', error);
547
+ await ctx.db.artifact.delete({
548
+ where: {
549
+ id: newArtifact.id,
550
+ },
551
+ });
525
552
  await PusherService.emitError(input.workspaceId, `Failed to generate podcast episode: ${error instanceof Error ? error.message : 'Unknown error'}`, 'podcast');
526
553
  throw new TRPCError({
527
554
  code: 'INTERNAL_SERVER_ERROR',
@@ -529,148 +556,11 @@ export const podcast = router({
529
556
  });
530
557
  }
531
558
  }),
532
- // Regenerate a specific segment
533
- regenerateSegment: authedProcedure
534
- .input(z.object({
535
- episodeId: z.string(),
536
- segmentId: z.string(),
537
- newContent: z.string().optional(),
538
- voice: z.enum(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']).optional(),
539
- speed: z.number().min(0.25).max(4.0).optional(),
540
- }))
559
+ deleteSegment: authedProcedure
560
+ .input(z.object({ segmentId: z.string() }))
541
561
  .mutation(async ({ ctx, input }) => {
542
- const episode = await ctx.db.artifact.findFirst({
543
- where: {
544
- id: input.episodeId,
545
- type: ArtifactType.PODCAST_EPISODE,
546
- workspace: { ownerId: ctx.session.user.id }
547
- },
548
- include: {
549
- workspace: true,
550
- versions: {
551
- orderBy: { version: 'desc' },
552
- take: 1,
553
- },
554
- },
555
- });
556
- if (!episode)
557
- throw new TRPCError({ code: 'NOT_FOUND' });
558
- const latestVersion = episode.versions[0];
559
- if (!latestVersion)
560
- throw new TRPCError({ code: 'NOT_FOUND', message: 'No version found' });
561
- const metadata = podcastMetadataSchema.parse(latestVersion.data);
562
- const segment = metadata.segments.find(s => s.id === input.segmentId);
563
- if (!segment)
564
- throw new TRPCError({ code: 'NOT_FOUND', message: 'Segment not found' });
565
- try {
566
- // Emit segment regeneration start notification
567
- await PusherService.emitTaskComplete(episode.workspaceId, 'podcast_segment_regeneration_start', {
568
- segmentId: input.segmentId,
569
- segmentTitle: segment.title || 'Untitled Segment'
570
- });
571
- // Use new content or existing content
572
- const contentToSpeak = input.newContent || segment.content;
573
- const voice = input.voice || metadata.voice || 'nova';
574
- const speed = input.speed || metadata.speed || 1.0;
575
- // Generate new audio using Murf TTS
576
- const mp3Response = await fetch('https://api.murf.ai/v1/speech/generate', {
577
- method: 'POST',
578
- headers: {
579
- 'api-key': process.env.MURF_TTS_KEY || '',
580
- 'Content-Type': 'application/json',
581
- 'Accept': 'application/json',
582
- },
583
- body: JSON.stringify({
584
- text: contentToSpeak,
585
- voiceId: 'en-US-natalie',
586
- }),
587
- });
588
- if (!mp3Response.ok) {
589
- throw new Error(`Murf TTS error: ${mp3Response.status} ${mp3Response.statusText}`);
590
- }
591
- // Parse the response to get the audio URL
592
- const mp3Data = await mp3Response.json();
593
- // Check for different possible response structures
594
- const audioUrl = mp3Data.audioFile || mp3Data.audioUrl || mp3Data.url || mp3Data.downloadUrl;
595
- if (!audioUrl) {
596
- console.error('No audio URL found in Murf response. Available fields:', Object.keys(mp3Data));
597
- throw new Error('No audio URL in Murf response');
598
- }
599
- // Download the actual audio file from the URL
600
- const audioResponse = await fetch(audioUrl);
601
- if (!audioResponse.ok) {
602
- throw new Error(`Failed to download audio: ${audioResponse.status} ${audioResponse.statusText}`);
603
- }
604
- // Upload to Google Cloud Storage
605
- const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
606
- const fileName = `segment_${segment.order}_${Date.now()}.mp3`;
607
- const uploadResult = await uploadToGCS(audioBuffer, fileName, 'audio/mpeg', false); // Keep private
608
- // Update segment data
609
- segment.content = contentToSpeak;
610
- segment.objectKey = uploadResult.objectKey; // Store object key
611
- // Recalculate duration
612
- const wordCount = contentToSpeak.split(' ').length;
613
- segment.duration = Math.ceil((wordCount / 150) * 60);
614
- // Recalculate start times for subsequent segments
615
- let currentTime = 0;
616
- for (const seg of metadata.segments) {
617
- if (seg.order < segment.order) {
618
- currentTime += seg.duration;
619
- }
620
- else if (seg.order === segment.order) {
621
- seg.startTime = currentTime;
622
- currentTime += seg.duration;
623
- }
624
- else {
625
- seg.startTime = currentTime;
626
- currentTime += seg.duration;
627
- }
628
- }
629
- // Update total duration
630
- metadata.totalDuration = currentTime;
631
- // Rebuild transcript
632
- const fullTranscript = metadata.segments
633
- .sort((a, b) => a.order - b.order)
634
- .map(s => `\n\n## ${s.title}\n\n${s.content}`)
635
- .join('');
636
- // Step: Update segment audio (no need to regenerate full episode)
637
- await PusherService.emitTaskComplete(episode.workspaceId, 'podcast_segment_audio_updated', {
638
- segmentId: input.segmentId,
639
- totalSegments: metadata.segments.length
640
- });
641
- // Create new version
642
- const nextVersion = (latestVersion.version || 0) + 1;
643
- await ctx.db.artifactVersion.create({
644
- data: {
645
- artifactId: input.episodeId,
646
- version: nextVersion,
647
- content: fullTranscript.trim(),
648
- data: metadata,
649
- createdById: ctx.session.user.id,
650
- },
651
- });
652
- // Emit segment regeneration completion notification
653
- await PusherService.emitTaskComplete(episode.workspaceId, 'podcast_segment_regeneration_complete', {
654
- segmentId: input.segmentId,
655
- segmentTitle: segment.title || 'Untitled Segment',
656
- duration: segment.duration
657
- });
658
- return {
659
- segmentId: input.segmentId,
660
- audioUrl: segment.audioUrl,
661
- duration: segment.duration,
662
- content: segment.content,
663
- totalDuration: metadata.totalDuration,
664
- };
665
- }
666
- catch (error) {
667
- console.error('Error regenerating segment:', error);
668
- await PusherService.emitError(episode.workspaceId, `Failed to regenerate segment: ${error instanceof Error ? error.message : 'Unknown error'}`, 'podcast');
669
- throw new TRPCError({
670
- code: 'INTERNAL_SERVER_ERROR',
671
- message: `Failed to regenerate segment: ${error instanceof Error ? error.message : 'Unknown error'}`
672
- });
673
- }
562
+ const segment = await ctx.db.podcastSegment.delete({ where: { id: input.segmentId } });
563
+ return segment;
674
564
  }),
675
565
  // Get episode schema/structure for navigation
676
566
  getEpisodeSchema: authedProcedure
@@ -687,6 +577,9 @@ export const podcast = router({
687
577
  orderBy: { version: 'desc' },
688
578
  take: 1,
689
579
  },
580
+ podcastSegments: {
581
+ orderBy: { order: 'asc' },
582
+ },
690
583
  },
691
584
  });
692
585
  if (!episode)
@@ -696,7 +589,7 @@ export const podcast = router({
696
589
  throw new TRPCError({ code: 'NOT_FOUND', message: 'No version found' });
697
590
  const metadata = podcastMetadataSchema.parse(latestVersion.data);
698
591
  return {
699
- segments: metadata.segments.map(s => ({
592
+ segments: episode.podcastSegments.map(s => ({
700
593
  id: s.id,
701
594
  title: s.title,
702
595
  startTime: s.startTime,
@@ -709,8 +602,7 @@ export const podcast = router({
709
602
  title: metadata.title,
710
603
  description: metadata.description,
711
604
  totalDuration: metadata.totalDuration,
712
- voice: metadata.voice,
713
- speed: metadata.speed,
605
+ speakers: metadata.speakers,
714
606
  },
715
607
  };
716
608
  }),
@@ -792,22 +684,25 @@ export const podcast = router({
792
684
  episodeId: input.episodeId,
793
685
  episodeTitle: episode.title || 'Untitled Episode'
794
686
  });
795
- // Parse episode data to get audio file paths
796
- const latestVersion = episode.versions[0];
797
- if (latestVersion) {
798
- const metadata = podcastMetadataSchema.parse(latestVersion.data);
799
- // Delete audio files from Google Cloud Storage
800
- for (const segment of metadata.segments || []) {
801
- if (segment.objectKey) {
802
- try {
803
- await deleteFromGCS(segment.objectKey);
804
- }
805
- catch (error) {
806
- console.error(`Failed to delete audio file ${segment.objectKey}:`, error);
807
- }
687
+ // Get segments to delete audio files
688
+ const segments = await ctx.db.podcastSegment.findMany({
689
+ where: { artifactId: input.episodeId },
690
+ });
691
+ // Delete audio files from Supabase Storage
692
+ for (const segment of segments) {
693
+ if (segment.objectKey) {
694
+ try {
695
+ await deleteFromSupabase(segment.objectKey);
696
+ }
697
+ catch (error) {
698
+ console.error(`Failed to delete audio file ${segment.objectKey}:`, error);
808
699
  }
809
700
  }
810
701
  }
702
+ // Delete associated segments
703
+ await ctx.db.podcastSegment.deleteMany({
704
+ where: { artifactId: input.episodeId },
705
+ });
811
706
  // Delete associated versions
812
707
  await ctx.db.artifactVersion.deleteMany({
813
708
  where: { artifactId: input.episodeId },
@@ -832,6 +727,48 @@ export const podcast = router({
832
727
  });
833
728
  }
834
729
  }),
730
+ // Get a specific segment with signed URL
731
+ getSegment: authedProcedure
732
+ .input(z.object({ segmentId: z.string() }))
733
+ .query(async ({ ctx, input }) => {
734
+ const segment = await ctx.db.podcastSegment.findFirst({
735
+ where: {
736
+ id: input.segmentId,
737
+ artifact: {
738
+ workspace: { ownerId: ctx.session.user.id }
739
+ }
740
+ },
741
+ include: {
742
+ artifact: true,
743
+ },
744
+ });
745
+ if (!segment)
746
+ throw new TRPCError({ code: 'NOT_FOUND' });
747
+ // Generate fresh signed URL
748
+ let audioUrl = null;
749
+ if (segment.objectKey) {
750
+ try {
751
+ audioUrl = await generateSignedUrl(segment.objectKey, 24); // 24 hours
752
+ }
753
+ catch (error) {
754
+ console.error(`Failed to generate signed URL for segment ${segment.id}:`, error);
755
+ }
756
+ }
757
+ return {
758
+ id: segment.id,
759
+ title: segment.title,
760
+ content: segment.content,
761
+ startTime: segment.startTime,
762
+ duration: segment.duration,
763
+ order: segment.order,
764
+ keyPoints: segment.keyPoints,
765
+ audioUrl,
766
+ objectKey: segment.objectKey,
767
+ meta: segment.meta,
768
+ createdAt: segment.createdAt,
769
+ updatedAt: segment.updatedAt,
770
+ };
771
+ }),
835
772
  // Get available voices for TTS
836
773
  getAvailableVoices: authedProcedure
837
774
  .query(async () => {