@goscribe/server 1.0.8 → 1.0.9

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 (57) hide show
  1. package/AUTH_FRONTEND_SPEC.md +21 -0
  2. package/CHAT_FRONTEND_SPEC.md +474 -0
  3. package/MEETINGSUMMARY_FRONTEND_SPEC.md +28 -0
  4. package/PODCAST_FRONTEND_SPEC.md +595 -0
  5. package/STUDYGUIDE_FRONTEND_SPEC.md +18 -0
  6. package/WORKSHEETS_FRONTEND_SPEC.md +26 -0
  7. package/WORKSPACE_FRONTEND_SPEC.md +47 -0
  8. package/dist/lib/ai-session.d.ts +26 -0
  9. package/dist/lib/ai-session.js +343 -0
  10. package/dist/lib/inference.d.ts +2 -0
  11. package/dist/lib/inference.js +21 -0
  12. package/dist/lib/pusher.d.ts +14 -0
  13. package/dist/lib/pusher.js +94 -0
  14. package/dist/lib/storage.d.ts +10 -2
  15. package/dist/lib/storage.js +63 -6
  16. package/dist/routers/_app.d.ts +840 -58
  17. package/dist/routers/_app.js +6 -0
  18. package/dist/routers/ai-session.d.ts +0 -0
  19. package/dist/routers/ai-session.js +1 -0
  20. package/dist/routers/auth.d.ts +1 -0
  21. package/dist/routers/auth.js +6 -4
  22. package/dist/routers/chat.d.ts +171 -0
  23. package/dist/routers/chat.js +270 -0
  24. package/dist/routers/flashcards.d.ts +37 -0
  25. package/dist/routers/flashcards.js +128 -0
  26. package/dist/routers/meetingsummary.d.ts +0 -0
  27. package/dist/routers/meetingsummary.js +377 -0
  28. package/dist/routers/podcast.d.ts +277 -0
  29. package/dist/routers/podcast.js +847 -0
  30. package/dist/routers/studyguide.d.ts +54 -0
  31. package/dist/routers/studyguide.js +125 -0
  32. package/dist/routers/worksheets.d.ts +138 -51
  33. package/dist/routers/worksheets.js +317 -7
  34. package/dist/routers/workspace.d.ts +162 -7
  35. package/dist/routers/workspace.js +440 -8
  36. package/dist/server.js +6 -2
  37. package/package.json +11 -4
  38. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +213 -0
  39. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +31 -0
  40. package/prisma/migrations/migration_lock.toml +3 -0
  41. package/prisma/schema.prisma +87 -6
  42. package/prisma/seed.mjs +135 -0
  43. package/src/lib/ai-session.ts +411 -0
  44. package/src/lib/inference.ts +21 -0
  45. package/src/lib/pusher.ts +104 -0
  46. package/src/lib/storage.ts +89 -6
  47. package/src/routers/_app.ts +6 -0
  48. package/src/routers/auth.ts +8 -4
  49. package/src/routers/chat.ts +275 -0
  50. package/src/routers/flashcards.ts +142 -0
  51. package/src/routers/meetingsummary.ts +416 -0
  52. package/src/routers/podcast.ts +934 -0
  53. package/src/routers/studyguide.ts +144 -0
  54. package/src/routers/worksheets.ts +336 -7
  55. package/src/routers/workspace.ts +487 -8
  56. package/src/server.ts +7 -2
  57. package/test-ai-integration.js +134 -0
@@ -0,0 +1,934 @@
1
+ import { z } from 'zod';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { router, authedProcedure } from '../trpc.js';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import inference from '../lib/inference.js';
6
+ import { uploadToGCS, generateSignedUrl, deleteFromGCS } from '../lib/storage.js';
7
+ import PusherService from '../lib/pusher.js';
8
+
9
+ // Prisma enum values mapped manually to avoid type import issues in ESM
10
+ const ArtifactType = {
11
+ PODCAST_EPISODE: 'PODCAST_EPISODE',
12
+ STUDY_GUIDE: 'STUDY_GUIDE',
13
+ FLASHCARD_SET: 'FLASHCARD_SET',
14
+ } as const;
15
+
16
+ // Podcast segment schema
17
+ const podcastSegmentSchema = z.object({
18
+ id: z.string(),
19
+ title: z.string(),
20
+ content: z.string(),
21
+ startTime: z.number(), // in seconds
22
+ duration: z.number(), // in seconds
23
+ keyPoints: z.array(z.string()),
24
+ order: z.number().int(),
25
+ audioUrl: z.string().optional(),
26
+ objectKey: z.string().optional(), // Google Cloud Storage object key
27
+ });
28
+
29
+ // Podcast creation input schema
30
+ const podcastInputSchema = z.object({
31
+ title: z.string(),
32
+ description: z.string().optional(),
33
+ userPrompt: z.string(),
34
+ voice: z.enum(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']).default('nova'),
35
+ speed: z.number().min(0.25).max(4.0).default(1.0),
36
+ generateIntro: z.boolean().default(true),
37
+ generateOutro: z.boolean().default(true),
38
+ segmentByTopics: z.boolean().default(true),
39
+ });
40
+
41
+ // Podcast metadata schema for version data
42
+ const podcastMetadataSchema = z.object({
43
+ title: z.string(),
44
+ description: z.string().optional(),
45
+ totalDuration: z.number(),
46
+ voice: z.string(),
47
+ speed: z.number(),
48
+ segments: z.array(podcastSegmentSchema),
49
+ summary: z.object({
50
+ executiveSummary: z.string(),
51
+ learningObjectives: z.array(z.string()),
52
+ keyConcepts: z.array(z.string()),
53
+ followUpActions: z.array(z.string()),
54
+ targetAudience: z.string(),
55
+ prerequisites: z.array(z.string()),
56
+ tags: z.array(z.string()),
57
+ }),
58
+ generatedAt: z.string(),
59
+ });
60
+
61
+ export const podcast = router({
62
+ // List all podcast episodes for a workspace
63
+ listEpisodes: authedProcedure
64
+ .input(z.object({ workspaceId: z.string() }))
65
+ .query(async ({ ctx, input }) => {
66
+ const workspace = await ctx.db.workspace.findFirst({
67
+ where: { id: input.workspaceId, ownerId: ctx.session.user.id },
68
+ });
69
+ if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
70
+
71
+ const artifacts = await ctx.db.artifact.findMany({
72
+ where: {
73
+ workspaceId: input.workspaceId,
74
+ type: ArtifactType.PODCAST_EPISODE
75
+ },
76
+ include: {
77
+ versions: {
78
+ orderBy: { version: 'desc' },
79
+ take: 1, // Get only the latest version
80
+ },
81
+ },
82
+ orderBy: { updatedAt: 'desc' },
83
+ });
84
+
85
+ // Transform to include metadata from the latest version with fresh signed URLs
86
+ const episodesWithUrls = await Promise.all(
87
+ artifacts.map(async (artifact) => {
88
+ 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
+ };
104
+ }
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
+ }
136
+ }
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
+ );
148
+
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
+ };
178
+ }
179
+ })
180
+ );
181
+
182
+ return episodesWithUrls;
183
+ }),
184
+
185
+ // Get a specific podcast episode with segments and signed URLs
186
+ getEpisode: authedProcedure
187
+ .input(z.object({ episodeId: z.string() }))
188
+ .query(async ({ ctx, input }) => {
189
+ const episode = await ctx.db.artifact.findFirst({
190
+ where: {
191
+ id: input.episodeId,
192
+ type: ArtifactType.PODCAST_EPISODE,
193
+ workspace: { ownerId: ctx.session.user.id }
194
+ },
195
+ include: {
196
+ versions: {
197
+ orderBy: { version: 'desc' },
198
+ take: 1,
199
+ },
200
+ },
201
+ });
202
+
203
+ console.log(episode)
204
+
205
+ if (!episode) throw new TRPCError({ code: 'NOT_FOUND' });
206
+
207
+ const latestVersion = episode.versions[0];
208
+ if (!latestVersion) throw new TRPCError({ code: 'NOT_FOUND', message: 'No version found' });
209
+
210
+ console.log(latestVersion)
211
+
212
+ const metadata = podcastMetadataSchema.parse(latestVersion.data);
213
+
214
+
215
+ // Generate fresh signed URLs for all segments
216
+ const segmentsWithUrls = await Promise.all(
217
+ metadata.segments.map(async (segment) => {
218
+ if (segment.objectKey) {
219
+ try {
220
+ const signedUrl = await generateSignedUrl(segment.objectKey, 24); // 24 hours
221
+ return {
222
+ id: segment.id,
223
+ title: segment.title,
224
+ content: segment.content,
225
+ audioUrl: signedUrl,
226
+ objectKey: segment.objectKey,
227
+ startTime: segment.startTime,
228
+ duration: segment.duration,
229
+ keyPoints: segment.keyPoints,
230
+ order: segment.order,
231
+ };
232
+ } catch (error) {
233
+ console.error(`Failed to generate signed URL for segment ${segment.id}:`, error);
234
+ return {
235
+ id: segment.id,
236
+ title: segment.title,
237
+ content: segment.content,
238
+ audioUrl: null,
239
+ objectKey: segment.objectKey,
240
+ startTime: segment.startTime,
241
+ duration: segment.duration,
242
+ keyPoints: segment.keyPoints,
243
+ order: segment.order,
244
+ };
245
+ }
246
+ }
247
+ return {
248
+ id: segment.id,
249
+ title: segment.title,
250
+ content: segment.content,
251
+ audioUrl: null,
252
+ objectKey: segment.objectKey,
253
+ startTime: segment.startTime,
254
+ duration: segment.duration,
255
+ keyPoints: segment.keyPoints,
256
+ order: segment.order,
257
+ };
258
+ })
259
+ );
260
+
261
+ return {
262
+ id: episode.id,
263
+ title: metadata.title, // Use title from version metadata
264
+ description: metadata.description, // Use description from version metadata
265
+ metadata,
266
+ segments: segmentsWithUrls,
267
+ content: latestVersion.content, // transcript
268
+ createdAt: episode.createdAt,
269
+ updatedAt: episode.updatedAt,
270
+ };
271
+ }),
272
+
273
+ // Generate podcast episode from text input
274
+ generateEpisode: authedProcedure
275
+ .input(z.object({
276
+ workspaceId: z.string(),
277
+ podcastData: podcastInputSchema,
278
+ }))
279
+ .mutation(async ({ ctx, input }) => {
280
+ const workspace = await ctx.db.workspace.findFirst({
281
+ where: { id: input.workspaceId, ownerId: ctx.session.user.id },
282
+ });
283
+ if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
284
+
285
+ try {
286
+ // Emit podcast generation start notification
287
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_generation_start', {
288
+ title: input.podcastData.title
289
+ });
290
+
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,
300
+ },
301
+ },
302
+ });
303
+
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
+ }
339
+
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.`;
346
+
347
+ const structureResponse = await inference(structurePrompt, 'podcast_structure');
348
+ const structureData = await structureResponse.json();
349
+ const structureContent = structureData.response || '';
350
+
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');
362
+ throw new TRPCError({
363
+ code: 'INTERNAL_SERVER_ERROR',
364
+ message: 'Failed to structure podcast content'
365
+ });
366
+ }
367
+
368
+ // Emit structure completion notification
369
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_structure_complete', {
370
+ segmentsCount: structuredContent.segments?.length || 0
371
+ });
372
+ // Step 2: Generate audio for each segment
373
+ const segments = [];
374
+ let totalDuration = 0;
375
+ let fullTranscript = '';
376
+
377
+ // Emit audio generation start notification
378
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_audio_generation_start', {
379
+ totalSegments: structuredContent.segments?.length || 0
380
+ });
381
+
382
+ for (const [index, segment] of structuredContent.segments.entries()) {
383
+ try {
384
+ // 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',
398
+ },
399
+ body: JSON.stringify({
400
+ text: segment.content,
401
+ voiceId: 'en-US-natalie',
402
+ }),
403
+ });
404
+
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();
411
+
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
+ }
419
+
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}`);
424
+ }
425
+
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
+ segments.push({
437
+ id: uuidv4(),
438
+ title: segment.title,
439
+ content: segment.content,
440
+ objectKey: uploadResult.objectKey, // Store object key for future operations
441
+ startTime: totalDuration,
442
+ duration: estimatedDuration,
443
+ keyPoints: segment.keyPoints || [],
444
+ order: segment.order || index + 1,
445
+ });
446
+
447
+ totalDuration += estimatedDuration;
448
+ fullTranscript += `\n\n## ${segment.title}\n\n${segment.content}`;
449
+ } catch (audioError) {
450
+ console.error(`Error generating audio for segment ${index + 1}:`, audioError);
451
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_segment_error', {
452
+ segmentIndex: index + 1,
453
+ error: audioError instanceof Error ? audioError.message : 'Unknown error'
454
+ });
455
+ // Continue with other segments even if one fails
456
+ }
457
+ }
458
+
459
+ // Emit audio generation completion notification
460
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_audio_generation_complete', {
461
+ totalSegments: segments.length,
462
+ totalDuration: totalDuration
463
+ });
464
+
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
468
+ });
469
+
470
+ // Step 3: Generate episode summary using inference API
471
+ const summaryPrompt = `Create a comprehensive podcast episode summary including:
472
+ - Executive summary
473
+ - Learning objectives
474
+ - Key concepts covered
475
+ - Recommended follow-up actions
476
+ - Target audience
477
+ - Prerequisites (if any)
478
+
479
+ Format as JSON:
480
+ {
481
+ "executiveSummary": "Brief overview of the episode",
482
+ "learningObjectives": ["objective1", "objective2"],
483
+ "keyConcepts": ["concept1", "concept2"],
484
+ "followUpActions": ["action1", "action2"],
485
+ "targetAudience": "Description of target audience",
486
+ "prerequisites": ["prerequisite1", "prerequisite2"],
487
+ "tags": ["tag1", "tag2", "tag3"]
488
+ }
489
+
490
+ Podcast Title: ${structuredContent.episodeTitle}
491
+ Segments: ${JSON.stringify(segments.map(s => ({ title: s.title, keyPoints: s.keyPoints })))}`;
492
+
493
+ const summaryResponse = await inference(summaryPrompt, 'podcast_summary');
494
+ const summaryData = await summaryResponse.json();
495
+ const summaryContent = summaryData.response || '';
496
+
497
+ let episodeSummary;
498
+ try {
499
+ // Extract JSON from the response
500
+ const jsonMatch = summaryContent.match(/\{[\s\S]*\}/);
501
+ if (!jsonMatch) {
502
+ throw new Error('No JSON found in summary response');
503
+ }
504
+ episodeSummary = JSON.parse(jsonMatch[0]);
505
+ } catch (parseError) {
506
+ console.error('Failed to parse summary response:', summaryContent);
507
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_summary_error', {
508
+ error: 'Failed to parse summary response'
509
+ });
510
+ episodeSummary = {
511
+ executiveSummary: 'AI-generated podcast episode',
512
+ learningObjectives: [],
513
+ keyConcepts: [],
514
+ followUpActions: [],
515
+ targetAudience: 'General audience',
516
+ prerequisites: [],
517
+ tags: [],
518
+ };
519
+ }
520
+
521
+ // Emit summary generation completion notification
522
+ await PusherService.emitTaskComplete(input.workspaceId, 'podcast_summary_complete', {
523
+ summaryGenerated: true
524
+ });
525
+
526
+ // Step 4: Create artifact and initial version
527
+ const episodeTitle = structuredContent.episodeTitle || input.podcastData.title;
528
+
529
+ const artifact = await ctx.db.artifact.create({
530
+ data: {
531
+ workspaceId: input.workspaceId,
532
+ type: ArtifactType.PODCAST_EPISODE,
533
+ title: episodeTitle, // Store basic title for listing/searching
534
+ description: input.podcastData.description, // Store basic description for listing/searching
535
+ createdById: ctx.session.user.id,
536
+ },
537
+ });
538
+
539
+ // Create initial version with complete metadata
540
+ const metadata = {
541
+ title: episodeTitle,
542
+ description: input.podcastData.description,
543
+ 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
+ summary: episodeSummary,
548
+ generatedAt: new Date().toISOString(),
549
+ };
550
+
551
+ await ctx.db.artifactVersion.create({
552
+ data: {
553
+ artifactId: artifact.id,
554
+ version: 1,
555
+ content: fullTranscript.trim(), // Full transcript as markdown
556
+ data: metadata,
557
+ createdById: ctx.session.user.id,
558
+ },
559
+ });
560
+
561
+ // Emit podcast generation completion notification
562
+ await PusherService.emitPodcastComplete(input.workspaceId, artifact);
563
+
564
+ return {
565
+ id: artifact.id,
566
+ title: metadata.title,
567
+ description: metadata.description,
568
+ metadata,
569
+ content: fullTranscript.trim(),
570
+ };
571
+
572
+ } catch (error) {
573
+ console.error('Error generating podcast episode:', error);
574
+ await PusherService.emitError(input.workspaceId, `Failed to generate podcast episode: ${error instanceof Error ? error.message : 'Unknown error'}`, 'podcast');
575
+ throw new TRPCError({
576
+ code: 'INTERNAL_SERVER_ERROR',
577
+ message: `Failed to generate podcast episode: ${error instanceof Error ? error.message : 'Unknown error'}`
578
+ });
579
+ }
580
+ }),
581
+
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
+ }))
591
+ .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
+ }
741
+ }),
742
+
743
+ // Get episode schema/structure for navigation
744
+ getEpisodeSchema: authedProcedure
745
+ .input(z.object({ episodeId: z.string() }))
746
+ .query(async ({ ctx, input }) => {
747
+ const episode = await ctx.db.artifact.findFirst({
748
+ where: {
749
+ id: input.episodeId,
750
+ type: ArtifactType.PODCAST_EPISODE,
751
+ workspace: { ownerId: ctx.session.user.id }
752
+ },
753
+ include: {
754
+ versions: {
755
+ orderBy: { version: 'desc' },
756
+ take: 1,
757
+ },
758
+ },
759
+ });
760
+
761
+ if (!episode) throw new TRPCError({ code: 'NOT_FOUND' });
762
+
763
+ const latestVersion = episode.versions[0];
764
+ if (!latestVersion) throw new TRPCError({ code: 'NOT_FOUND', message: 'No version found' });
765
+
766
+ const metadata = podcastMetadataSchema.parse(latestVersion.data);
767
+
768
+ return {
769
+ segments: metadata.segments.map(s => ({
770
+ id: s.id,
771
+ title: s.title,
772
+ startTime: s.startTime,
773
+ duration: s.duration,
774
+ keyPoints: s.keyPoints,
775
+ order: s.order,
776
+ })),
777
+ summary: metadata.summary,
778
+ metadata: {
779
+ title: metadata.title,
780
+ description: metadata.description,
781
+ totalDuration: metadata.totalDuration,
782
+ voice: metadata.voice,
783
+ speed: metadata.speed,
784
+ },
785
+ };
786
+ }),
787
+
788
+ // Update episode metadata
789
+ updateEpisode: authedProcedure
790
+ .input(z.object({
791
+ episodeId: z.string(),
792
+ title: z.string().optional(),
793
+ description: z.string().optional(),
794
+ }))
795
+ .mutation(async ({ ctx, input }) => {
796
+ const episode = await ctx.db.artifact.findFirst({
797
+ where: {
798
+ id: input.episodeId,
799
+ type: ArtifactType.PODCAST_EPISODE,
800
+ workspace: { ownerId: ctx.session.user.id }
801
+ },
802
+ include: {
803
+ versions: {
804
+ orderBy: { version: 'desc' },
805
+ take: 1,
806
+ },
807
+ },
808
+ });
809
+
810
+ if (!episode) throw new TRPCError({ code: 'NOT_FOUND' });
811
+
812
+ const latestVersion = episode.versions[0];
813
+ if (!latestVersion) throw new TRPCError({ code: 'NOT_FOUND', message: 'No version found' });
814
+
815
+ const metadata = podcastMetadataSchema.parse(latestVersion.data);
816
+
817
+ // Update metadata
818
+ if (input.title) metadata.title = input.title;
819
+ if (input.description) metadata.description = input.description;
820
+
821
+ // Create new version with updated metadata
822
+ const nextVersion = (latestVersion.version || 0) + 1;
823
+ await ctx.db.artifactVersion.create({
824
+ data: {
825
+ artifactId: input.episodeId,
826
+ version: nextVersion,
827
+ content: latestVersion.content,
828
+ data: metadata,
829
+ createdById: ctx.session.user.id,
830
+ },
831
+ });
832
+
833
+ // Update the artifact with basic info for listing/searching
834
+ return ctx.db.artifact.update({
835
+ where: { id: input.episodeId },
836
+ data: {
837
+ title: input.title ?? episode.title,
838
+ description: input.description ?? episode.description,
839
+ updatedAt: new Date(),
840
+ },
841
+ });
842
+ }),
843
+
844
+ // Delete episode and associated audio files
845
+ deleteEpisode: authedProcedure
846
+ .input(z.object({ episodeId: z.string() }))
847
+ .mutation(async ({ ctx, input }) => {
848
+ const episode = await ctx.db.artifact.findFirst({
849
+ where: {
850
+ id: input.episodeId,
851
+ type: ArtifactType.PODCAST_EPISODE,
852
+ workspace: { ownerId: ctx.session.user.id }
853
+ },
854
+ include: {
855
+ versions: {
856
+ orderBy: { version: 'desc' },
857
+ take: 1,
858
+ },
859
+ },
860
+ });
861
+
862
+ if (!episode) throw new TRPCError({ code: 'NOT_FOUND' });
863
+
864
+ try {
865
+ // Emit episode deletion start notification
866
+ await PusherService.emitTaskComplete(episode.workspaceId, 'podcast_deletion_start', {
867
+ episodeId: input.episodeId,
868
+ episodeTitle: episode.title || 'Untitled Episode'
869
+ });
870
+
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
+ }
884
+ }
885
+ }
886
+ }
887
+
888
+ // Delete associated versions
889
+ await ctx.db.artifactVersion.deleteMany({
890
+ where: { artifactId: input.episodeId },
891
+ });
892
+
893
+ // Delete the artifact
894
+ await ctx.db.artifact.delete({
895
+ where: { id: input.episodeId },
896
+ });
897
+
898
+ // Emit episode deletion completion notification
899
+ await PusherService.emitTaskComplete(episode.workspaceId, 'podcast_deletion_complete', {
900
+ episodeId: input.episodeId,
901
+ episodeTitle: episode.title || 'Untitled Episode'
902
+ });
903
+
904
+ return true;
905
+
906
+ } catch (error) {
907
+ console.error('Error deleting episode:', error);
908
+ await PusherService.emitError(episode.workspaceId, `Failed to delete episode: ${error instanceof Error ? error.message : 'Unknown error'}`, 'podcast');
909
+ throw new TRPCError({
910
+ code: 'INTERNAL_SERVER_ERROR',
911
+ message: 'Failed to delete episode'
912
+ });
913
+ }
914
+ }),
915
+
916
+ // Get available voices for TTS
917
+ getAvailableVoices: authedProcedure
918
+ .query(async () => {
919
+ return [
920
+ { id: 'alloy', name: 'Alloy', description: 'Neutral, balanced voice' },
921
+ { id: 'echo', name: 'Echo', description: 'Clear, professional voice' },
922
+ { id: 'fable', name: 'Fable', description: 'Warm, storytelling voice' },
923
+ { id: 'onyx', name: 'Onyx', description: 'Deep, authoritative voice' },
924
+ { id: 'nova', name: 'Nova', description: 'Friendly, conversational voice' },
925
+ { id: 'shimmer', name: 'Shimmer', description: 'Bright, energetic voice' },
926
+ ];
927
+ }),
928
+
929
+
930
+
931
+
932
+
933
+
934
+ });