@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.
- package/ANALYSIS_PROGRESS_SPEC.md +463 -0
- package/PROGRESS_QUICK_REFERENCE.md +239 -0
- package/dist/lib/ai-session.d.ts +20 -9
- package/dist/lib/ai-session.js +316 -80
- package/dist/lib/auth.d.ts +35 -2
- package/dist/lib/auth.js +88 -15
- package/dist/lib/env.d.ts +32 -0
- package/dist/lib/env.js +46 -0
- package/dist/lib/errors.d.ts +33 -0
- package/dist/lib/errors.js +78 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +9 -11
- package/dist/lib/logger.d.ts +62 -0
- package/dist/lib/logger.js +342 -0
- package/dist/lib/podcast-prompts.d.ts +43 -0
- package/dist/lib/podcast-prompts.js +135 -0
- package/dist/lib/pusher.d.ts +1 -0
- package/dist/lib/pusher.js +14 -2
- package/dist/lib/storage.d.ts +3 -3
- package/dist/lib/storage.js +51 -47
- package/dist/lib/validation.d.ts +51 -0
- package/dist/lib/validation.js +64 -0
- package/dist/routers/_app.d.ts +697 -111
- package/dist/routers/_app.js +5 -0
- package/dist/routers/auth.d.ts +11 -1
- package/dist/routers/chat.d.ts +11 -1
- package/dist/routers/flashcards.d.ts +205 -6
- package/dist/routers/flashcards.js +144 -66
- package/dist/routers/members.d.ts +165 -0
- package/dist/routers/members.js +531 -0
- package/dist/routers/podcast.d.ts +78 -63
- package/dist/routers/podcast.js +330 -393
- package/dist/routers/studyguide.d.ts +11 -1
- package/dist/routers/worksheets.d.ts +124 -13
- package/dist/routers/worksheets.js +123 -50
- package/dist/routers/workspace.d.ts +213 -26
- package/dist/routers/workspace.js +303 -181
- package/dist/server.js +12 -4
- package/dist/services/flashcard-progress.service.d.ts +183 -0
- package/dist/services/flashcard-progress.service.js +383 -0
- package/dist/services/flashcard.service.d.ts +183 -0
- package/dist/services/flashcard.service.js +224 -0
- package/dist/services/podcast-segment-reorder.d.ts +0 -0
- package/dist/services/podcast-segment-reorder.js +107 -0
- package/dist/services/podcast.service.d.ts +0 -0
- package/dist/services/podcast.service.js +326 -0
- package/dist/services/worksheet.service.d.ts +0 -0
- package/dist/services/worksheet.service.js +295 -0
- package/dist/trpc.d.ts +13 -2
- package/dist/trpc.js +55 -6
- package/dist/types/index.d.ts +126 -0
- package/dist/types/index.js +1 -0
- package/package.json +3 -2
- package/prisma/schema.prisma +142 -4
- package/src/lib/ai-session.ts +356 -85
- package/src/lib/auth.ts +113 -19
- package/src/lib/env.ts +59 -0
- package/src/lib/errors.ts +92 -0
- package/src/lib/inference.ts +11 -11
- package/src/lib/logger.ts +405 -0
- package/src/lib/pusher.ts +15 -3
- package/src/lib/storage.ts +56 -51
- package/src/lib/validation.ts +75 -0
- package/src/routers/_app.ts +5 -0
- package/src/routers/chat.ts +2 -23
- package/src/routers/flashcards.ts +108 -24
- package/src/routers/members.ts +586 -0
- package/src/routers/podcast.ts +385 -420
- package/src/routers/worksheets.ts +118 -36
- package/src/routers/workspace.ts +356 -195
- package/src/server.ts +13 -4
- package/src/services/flashcard-progress.service.ts +541 -0
- package/src/trpc.ts +59 -6
- package/src/types/index.ts +165 -0
- package/AUTH_FRONTEND_SPEC.md +0 -21
- package/CHAT_FRONTEND_SPEC.md +0 -474
- package/DATABASE_SETUP.md +0 -165
- package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
- package/PODCAST_FRONTEND_SPEC.md +0 -595
- package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
- package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
- package/WORKSPACE_FRONTEND_SPEC.md +0 -47
- package/test-ai-integration.js +0 -134
package/src/routers/podcast.ts
CHANGED
|
@@ -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 {
|
|
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(), //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
|
337
|
+
message: 'Failed to generate podcast structure'
|
|
365
338
|
});
|
|
366
339
|
}
|
|
367
340
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
//
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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:
|
|
435
|
+
objectKey: audioResult.objectKey,
|
|
441
436
|
startTime: totalDuration,
|
|
442
|
-
duration:
|
|
437
|
+
duration: audioResult.duration,
|
|
443
438
|
keyPoints: segment.keyPoints || [],
|
|
444
|
-
order: segment.order ||
|
|
439
|
+
order: segment.order || i + 1,
|
|
445
440
|
});
|
|
446
441
|
|
|
447
|
-
totalDuration +=
|
|
442
|
+
totalDuration += audioResult.duration;
|
|
448
443
|
fullTranscript += `\n\n## ${segment.title}\n\n${segment.content}`;
|
|
444
|
+
|
|
449
445
|
} catch (audioError) {
|
|
450
|
-
|
|
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:
|
|
453
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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: ${
|
|
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
|
|
494
|
-
const
|
|
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, '
|
|
523
|
-
|
|
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 =
|
|
563
|
+
const episodeTitle = structure.episodeTitle || input.podcastData.title;
|
|
528
564
|
|
|
529
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
624
|
+
await PusherService.emitPodcastComplete(input.workspaceId, {});
|
|
563
625
|
|
|
564
626
|
return {
|
|
565
|
-
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
|
-
|
|
583
|
-
|
|
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
|
|
593
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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 () => {
|