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