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