Skip to content

Commit 4ba76a9

Browse files
feat: Edit transcript + Video player UX goodies (#596)
1 parent 3513a70 commit 4ba76a9

File tree

12 files changed

+859
-446
lines changed

12 files changed

+859
-446
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"use server";
2+
3+
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
4+
import { getCurrentUser } from "@cap/database/auth/session";
5+
import { videos, s3Buckets } from "@cap/database/schema";
6+
import { db } from "@cap/database";
7+
import { eq } from "drizzle-orm";
8+
import { revalidatePath } from "next/cache";
9+
import { createS3Client } from "@/utils/s3";
10+
11+
export async function editTranscriptEntry(
12+
videoId: string,
13+
entryId: number,
14+
newText: string
15+
): Promise<{ success: boolean; message: string }> {
16+
17+
const user = await getCurrentUser();
18+
19+
if (!user || !videoId || entryId === undefined || !newText?.trim()) {
20+
return {
21+
success: false,
22+
message: "Missing required data for updating transcript entry",
23+
};
24+
}
25+
26+
const userId = user.id;
27+
const query = await db()
28+
.select({
29+
video: videos,
30+
bucket: s3Buckets,
31+
})
32+
.from(videos)
33+
.leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
34+
.where(eq(videos.id, videoId));
35+
36+
if (query.length === 0) {
37+
return { success: false, message: "Video not found" };
38+
}
39+
40+
const result = query[0];
41+
if (!result?.video) {
42+
return { success: false, message: "Video information is missing" };
43+
}
44+
45+
const { video, bucket } = result;
46+
47+
if (video.ownerId !== userId) {
48+
return {
49+
success: false,
50+
message: "You don't have permission to edit this transcript",
51+
};
52+
}
53+
54+
const awsRegion = video.awsRegion;
55+
const awsBucket = video.awsBucket;
56+
57+
if (!awsRegion || !awsBucket) {
58+
return {
59+
success: false,
60+
message: "AWS region or bucket information is missing",
61+
};
62+
}
63+
const [s3Client] = await createS3Client(bucket);
64+
65+
try {
66+
const transcriptKey = `${video.ownerId}/${videoId}/transcription.vtt`;
67+
68+
const getCommand = new GetObjectCommand({
69+
Bucket: awsBucket,
70+
Key: transcriptKey,
71+
});
72+
73+
const response = await s3Client.send(getCommand);
74+
const vttContent = await response.Body?.transformToString();
75+
76+
if (!vttContent) {
77+
return { success: false, message: "Transcript file not found" };
78+
}
79+
const updatedVttContent = updateVttEntry(vttContent, entryId, newText);
80+
const putCommand = new PutObjectCommand({
81+
Bucket: awsBucket,
82+
Key: transcriptKey,
83+
Body: updatedVttContent,
84+
ContentType: "text/vtt",
85+
});
86+
87+
await s3Client.send(putCommand);
88+
revalidatePath(`/s/${videoId}`);
89+
90+
return {
91+
success: true,
92+
message: "Transcript entry updated successfully",
93+
};
94+
} catch (error) {
95+
console.error("Error updating transcript entry:", {
96+
error: error instanceof Error ? error.message : error,
97+
videoId,
98+
entryId,
99+
userId
100+
});
101+
return {
102+
success: false,
103+
message: "Failed to update transcript entry",
104+
};
105+
}
106+
}
107+
108+
function updateVttEntry(vttContent: string, entryId: number, newText: string): string {
109+
110+
const lines = vttContent.split("\n");
111+
const updatedLines: string[] = [];
112+
let currentEntryId: number | null = null;
113+
let foundEntry = false;
114+
let isNextLineText = false;
115+
116+
for (let i = 0; i < lines.length; i++) {
117+
const line = lines[i] || "";
118+
const trimmedLine = line.trim();
119+
120+
if (!trimmedLine) {
121+
updatedLines.push(line);
122+
isNextLineText = false;
123+
continue;
124+
}
125+
126+
if (trimmedLine === "WEBVTT") {
127+
updatedLines.push(line);
128+
continue;
129+
}
130+
131+
if (/^\d+$/.test(trimmedLine)) {
132+
currentEntryId = parseInt(trimmedLine, 10);
133+
updatedLines.push(line);
134+
isNextLineText = false;
135+
continue;
136+
}
137+
138+
if (trimmedLine.includes("-->")) {
139+
updatedLines.push(line);
140+
isNextLineText = true;
141+
continue;
142+
}
143+
144+
if (currentEntryId === entryId && isNextLineText && !foundEntry) {
145+
updatedLines.push(newText.trim());
146+
foundEntry = true;
147+
isNextLineText = false;
148+
} else {
149+
updatedLines.push(line);
150+
if (isNextLineText) {
151+
isNextLineText = false;
152+
}
153+
}
154+
}
155+
156+
if (!foundEntry) {
157+
console.warn("Target entry not found in VTT content", { entryId, totalEntries: lines.filter(line => /^\d+$/.test(line.trim())).length });
158+
}
159+
160+
const result = updatedLines.join("\n");
161+
162+
return result;
163+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"use server";
2+
3+
import { getCurrentUser } from "@cap/database/auth/session";
4+
import { db } from "@cap/database";
5+
import { videos, users } from "@cap/database/schema";
6+
import { VideoMetadata } from "@cap/database/types";
7+
import { eq } from "drizzle-orm";
8+
import { generateAiMetadata } from "./generate-ai-metadata";
9+
import { transcribeVideo } from "./transcribe";
10+
import { isAiGenerationEnabled } from "@/utils/flags";
11+
12+
const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000;
13+
14+
export interface VideoStatusResult {
15+
transcriptionStatus: "PROCESSING" | "COMPLETE" | "ERROR" | null;
16+
aiProcessing: boolean;
17+
aiTitle: string | null;
18+
summary: string | null;
19+
chapters: { title: string; start: number }[] | null;
20+
generationError: string | null;
21+
error?: string;
22+
}
23+
24+
export async function getVideoStatus(videoId: string): Promise<VideoStatusResult> {
25+
const user = await getCurrentUser();
26+
27+
if (!user) {
28+
throw new Error("Authentication required");
29+
}
30+
31+
if (!videoId) {
32+
throw new Error("Video ID not provided");
33+
}
34+
35+
const result = await db().select().from(videos).where(eq(videos.id, videoId));
36+
if (result.length === 0 || !result[0]) {
37+
throw new Error("Video not found");
38+
}
39+
40+
const video = result[0];
41+
const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {};
42+
43+
if (!video.transcriptionStatus) {
44+
console.log(`[Get Status] Transcription not started for video ${videoId}, triggering transcription`);
45+
try {
46+
transcribeVideo(videoId, video.ownerId).catch(error => {
47+
console.error(`[Get Status] Error starting transcription for video ${videoId}:`, error);
48+
});
49+
50+
return {
51+
transcriptionStatus: "PROCESSING",
52+
aiProcessing: false,
53+
aiTitle: metadata.aiTitle || null,
54+
summary: metadata.summary || null,
55+
chapters: metadata.chapters || null,
56+
generationError: metadata.generationError || null,
57+
};
58+
} catch (error) {
59+
console.error(`[Get Status] Error triggering transcription for video ${videoId}:`, error);
60+
return {
61+
transcriptionStatus: "ERROR",
62+
aiProcessing: false,
63+
aiTitle: metadata.aiTitle || null,
64+
summary: metadata.summary || null,
65+
chapters: metadata.chapters || null,
66+
generationError: metadata.generationError || null,
67+
error: "Failed to start transcription"
68+
};
69+
}
70+
}
71+
72+
if (video.transcriptionStatus === "ERROR") {
73+
return {
74+
transcriptionStatus: "ERROR",
75+
aiProcessing: false,
76+
aiTitle: metadata.aiTitle || null,
77+
summary: metadata.summary || null,
78+
chapters: metadata.chapters || null,
79+
generationError: metadata.generationError || null,
80+
error: "Transcription failed"
81+
};
82+
}
83+
84+
if (metadata.aiProcessing) {
85+
const updatedAtTime = new Date(video.updatedAt).getTime();
86+
const currentTime = new Date().getTime();
87+
88+
if (currentTime - updatedAtTime > MAX_AI_PROCESSING_TIME) {
89+
console.log(`[Get Status] AI processing appears stuck for video ${videoId} (${Math.round((currentTime - updatedAtTime) / 60000)} minutes), resetting flag`);
90+
91+
await db()
92+
.update(videos)
93+
.set({
94+
metadata: {
95+
...metadata,
96+
aiProcessing: false,
97+
generationError: "AI processing timed out and was reset"
98+
}
99+
})
100+
.where(eq(videos.id, videoId));
101+
102+
const updatedResult = await db().select().from(videos).where(eq(videos.id, videoId));
103+
if (updatedResult.length > 0 && updatedResult[0]) {
104+
const updatedVideo = updatedResult[0];
105+
const updatedMetadata = updatedVideo.metadata as VideoMetadata || {};
106+
107+
return {
108+
transcriptionStatus: (updatedVideo.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || null,
109+
aiProcessing: false,
110+
aiTitle: updatedMetadata.aiTitle || null,
111+
summary: updatedMetadata.summary || null,
112+
chapters: updatedMetadata.chapters || null,
113+
generationError: updatedMetadata.generationError || null,
114+
error: "AI processing timed out and was reset"
115+
};
116+
}
117+
}
118+
}
119+
120+
if (
121+
video.transcriptionStatus === "COMPLETE" &&
122+
!metadata.aiProcessing &&
123+
!metadata.summary &&
124+
!metadata.chapters &&
125+
!metadata.generationError
126+
) {
127+
console.log(`[Get Status] Transcription complete but no AI data, checking feature flag for video owner ${video.ownerId}`);
128+
129+
const videoOwnerQuery = await db()
130+
.select({
131+
email: users.email,
132+
stripeSubscriptionStatus: users.stripeSubscriptionStatus
133+
})
134+
.from(users)
135+
.where(eq(users.id, video.ownerId))
136+
.limit(1);
137+
138+
if (videoOwnerQuery.length > 0 && videoOwnerQuery[0] && (await isAiGenerationEnabled(videoOwnerQuery[0]))) {
139+
console.log(`[Get Status] Feature flag enabled, triggering AI generation for video ${videoId}`);
140+
141+
(async () => {
142+
try {
143+
console.log(`[Get Status] Starting AI metadata generation for video ${videoId}`);
144+
await generateAiMetadata(videoId, video.ownerId);
145+
console.log(`[Get Status] AI metadata generation completed for video ${videoId}`);
146+
} catch (error) {
147+
console.error(`[Get Status] Error generating AI metadata for video ${videoId}:`, error);
148+
149+
try {
150+
const currentVideo = await db().select().from(videos).where(eq(videos.id, videoId));
151+
if (currentVideo.length > 0 && currentVideo[0]) {
152+
const currentMetadata = (currentVideo[0].metadata as VideoMetadata) || {};
153+
await db()
154+
.update(videos)
155+
.set({
156+
metadata: {
157+
...currentMetadata,
158+
aiProcessing: false,
159+
generationError: error instanceof Error ? error.message : String(error)
160+
}
161+
})
162+
.where(eq(videos.id, videoId));
163+
}
164+
} catch (resetError) {
165+
console.error(`[Get Status] Failed to reset AI processing flag for video ${videoId}:`, resetError);
166+
}
167+
}
168+
})();
169+
170+
return {
171+
transcriptionStatus: (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || null,
172+
aiProcessing: true,
173+
aiTitle: metadata.aiTitle || null,
174+
summary: metadata.summary || null,
175+
chapters: metadata.chapters || null,
176+
generationError: metadata.generationError || null,
177+
};
178+
} else {
179+
const videoOwner = videoOwnerQuery[0];
180+
console.log(`[Get Status] AI generation feature disabled for video owner ${video.ownerId} (email: ${videoOwner?.email}, pro: ${videoOwner?.stripeSubscriptionStatus})`);
181+
}
182+
}
183+
184+
return {
185+
transcriptionStatus: (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || null,
186+
aiProcessing: metadata.aiProcessing || false,
187+
aiTitle: metadata.aiTitle || null,
188+
summary: metadata.summary || null,
189+
chapters: metadata.chapters || null,
190+
generationError: metadata.generationError || null,
191+
};
192+
}

0 commit comments

Comments
 (0)