@goscribe/server 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTH_FRONTEND_SPEC.md +21 -0
- package/CHAT_FRONTEND_SPEC.md +474 -0
- package/DATABASE_SETUP.md +165 -0
- package/MEETINGSUMMARY_FRONTEND_SPEC.md +28 -0
- package/PODCAST_FRONTEND_SPEC.md +595 -0
- package/STUDYGUIDE_FRONTEND_SPEC.md +18 -0
- package/WORKSHEETS_FRONTEND_SPEC.md +26 -0
- package/WORKSPACE_FRONTEND_SPEC.md +47 -0
- package/dist/lib/ai-session.d.ts +26 -0
- package/dist/lib/ai-session.js +343 -0
- package/dist/lib/inference.d.ts +2 -0
- package/dist/lib/inference.js +21 -0
- package/dist/lib/pusher.d.ts +14 -0
- package/dist/lib/pusher.js +94 -0
- package/dist/lib/storage.d.ts +10 -2
- package/dist/lib/storage.js +63 -6
- package/dist/routers/_app.d.ts +840 -58
- package/dist/routers/_app.js +6 -0
- package/dist/routers/ai-session.d.ts +0 -0
- package/dist/routers/ai-session.js +1 -0
- package/dist/routers/auth.d.ts +1 -0
- package/dist/routers/auth.js +6 -4
- package/dist/routers/chat.d.ts +171 -0
- package/dist/routers/chat.js +270 -0
- package/dist/routers/flashcards.d.ts +37 -0
- package/dist/routers/flashcards.js +128 -0
- package/dist/routers/meetingsummary.d.ts +0 -0
- package/dist/routers/meetingsummary.js +377 -0
- package/dist/routers/podcast.d.ts +277 -0
- package/dist/routers/podcast.js +847 -0
- package/dist/routers/studyguide.d.ts +54 -0
- package/dist/routers/studyguide.js +125 -0
- package/dist/routers/worksheets.d.ts +138 -51
- package/dist/routers/worksheets.js +317 -7
- package/dist/routers/workspace.d.ts +162 -7
- package/dist/routers/workspace.js +440 -8
- package/dist/server.js +6 -2
- package/package.json +11 -4
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +213 -0
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +31 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +87 -6
- package/prisma/seed.mjs +135 -0
- package/src/lib/ai-session.ts +412 -0
- package/src/lib/inference.ts +21 -0
- package/src/lib/pusher.ts +104 -0
- package/src/lib/storage.ts +89 -6
- package/src/routers/_app.ts +6 -0
- package/src/routers/auth.ts +8 -4
- package/src/routers/chat.ts +275 -0
- package/src/routers/flashcards.ts +142 -0
- package/src/routers/meetingsummary.ts +416 -0
- package/src/routers/podcast.ts +934 -0
- package/src/routers/studyguide.ts +144 -0
- package/src/routers/worksheets.ts +336 -7
- package/src/routers/workspace.ts +487 -8
- package/src/server.ts +7 -2
- 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
|
+
});
|