Skip to content

Commit ccb9d33

Browse files
committed
Add discover features to YouTube app for enhanced channel analysis
- Introduced new API routes for generating channel DNA profiles, analyzing content evolution, and identifying hidden gems. - Implemented UI components for displaying channel trivia, evolution timelines, and rabbit hole connections. - Enhanced the discover view to integrate various analysis features, providing users with a comprehensive overview of channel performance and personality. - Updated command palette and context rail to include navigation options for the new discover features. Made-with: Cursor
1 parent a6de8f2 commit ccb9d33

18 files changed

Lines changed: 1975 additions & 8 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { db } from "@/db";
2+
import { transcripts, videos } from "@/db/schema";
3+
import { getModel } from "@/lib/ai-providers";
4+
import { auth } from "@/lib/auth";
5+
import { createTaggedLogger } from "@/lib/logger";
6+
import {
7+
checkRateLimit,
8+
getClientIp,
9+
RATE_LIMITS,
10+
} from "@/lib/rate-limit";
11+
import { withErrorHandling } from "@/lib/route-handler";
12+
import {
13+
corsHeaders,
14+
mergeHeaders,
15+
optionsResponse,
16+
rateLimitExceededResponse,
17+
withRateLimitHeaders,
18+
} from "@data-projects/shared";
19+
import { generateText } from "ai";
20+
import { eq } from "drizzle-orm";
21+
22+
const log = createTaggedLogger("discover-dna");
23+
24+
const SYSTEM_PROMPT = `You are an expert at analyzing YouTube creator personalities based on their video transcripts.
25+
26+
Given transcript excerpts from a creator's videos, build a personality profile. Identify:
27+
- Their humor style (if any)
28+
- Recurring phrases or catchphrases they use frequently
29+
- Presentation style (conversational, educational, energetic, calm, etc.)
30+
- Favorite topics they keep coming back to
31+
- Unique verbal or stylistic traits
32+
33+
Return valid JSON only, with this structure:
34+
{
35+
"traits": [
36+
{
37+
"category": "Category name (e.g. 'Humor', 'Presentation', 'Expertise')",
38+
"value": "Brief description (e.g. 'Dry, self-deprecating wit')",
39+
"examples": ["Example quote or behavior"]
40+
}
41+
],
42+
"catchphrases": ["phrase 1", "phrase 2"],
43+
"style": "2-3 sentence style summary",
44+
"summary": "One paragraph personality overview"
45+
}
46+
47+
Be specific and reference actual content from the transcripts. Find 4-6 traits.`;
48+
49+
export async function OPTIONS() {
50+
return optionsResponse(corsHeaders);
51+
}
52+
53+
export const POST = withErrorHandling("discover-dna", async (request, { params }) => {
54+
const session = await auth();
55+
if (!session) {
56+
return Response.json({ error: "Not authenticated" }, { status: 401, headers: corsHeaders });
57+
}
58+
59+
const clientIp = getClientIp(request);
60+
const rateLimitResult = checkRateLimit(`discover-dna:${clientIp}`, RATE_LIMITS.aiQuery);
61+
if (!rateLimitResult.success) {
62+
return rateLimitExceededResponse(rateLimitResult, "Too many requests", corsHeaders);
63+
}
64+
65+
const { channelId } = await params;
66+
67+
const rows = await db
68+
.select({
69+
title: videos.title,
70+
excerpt: transcripts.excerpt,
71+
fullText: transcripts.fullText,
72+
})
73+
.from(transcripts)
74+
.innerJoin(videos, eq(transcripts.videoId, videos.id))
75+
.where(eq(videos.channelId, channelId));
76+
77+
const withText = rows.filter((r) => r.excerpt || r.fullText);
78+
79+
if (withText.length < 3) {
80+
return Response.json(
81+
{ error: "Not enough transcripts to analyze (minimum 3). Sync transcripts first." },
82+
{ status: 400, headers: corsHeaders },
83+
);
84+
}
85+
86+
const sampled = withText.length > 30
87+
? withText.sort(() => Math.random() - 0.5).slice(0, 30)
88+
: withText;
89+
90+
const excerpts = sampled.map((r) => {
91+
const text = r.fullText
92+
? r.fullText.slice(0, 500)
93+
: r.excerpt ?? "";
94+
return `## "${r.title}"\n${text}`;
95+
});
96+
97+
log.info({ channelId, transcriptCount: sampled.length }, "Generating DNA profile");
98+
99+
const { text } = await generateText({
100+
model: getModel(),
101+
system: SYSTEM_PROMPT,
102+
messages: [{ role: "user", content: excerpts.join("\n\n---\n\n") }],
103+
temperature: 0.4,
104+
maxOutputTokens: 2000,
105+
});
106+
107+
const jsonMatch = text.match(/\{[\s\S]*\}/);
108+
if (!jsonMatch) {
109+
log.error({ channelId }, "Failed to parse AI response for DNA");
110+
return Response.json({ error: "Failed to generate profile" }, { status: 500, headers: corsHeaders });
111+
}
112+
113+
const result = JSON.parse(jsonMatch[0]);
114+
115+
return Response.json(result, {
116+
headers: mergeHeaders(corsHeaders, withRateLimitHeaders(rateLimitResult)),
117+
});
118+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { db } from "@/db";
2+
import { videos } from "@/db/schema";
3+
import { getModel } from "@/lib/ai-providers";
4+
import { auth } from "@/lib/auth";
5+
import { createTaggedLogger } from "@/lib/logger";
6+
import {
7+
checkRateLimit,
8+
getClientIp,
9+
RATE_LIMITS,
10+
} from "@/lib/rate-limit";
11+
import { withErrorHandling } from "@/lib/route-handler";
12+
import {
13+
corsHeaders,
14+
mergeHeaders,
15+
optionsResponse,
16+
rateLimitExceededResponse,
17+
withRateLimitHeaders,
18+
} from "@data-projects/shared";
19+
import { generateText } from "ai";
20+
import { eq } from "drizzle-orm";
21+
22+
const log = createTaggedLogger("discover-evolution");
23+
24+
const SYSTEM_PROMPT = `You are an expert YouTube analyst. Given a list of videos grouped by time period, analyze how the creator's content evolved.
25+
26+
For each era, describe:
27+
- What topics dominated
28+
- How the style or format changed
29+
- Key growth or decline signals
30+
31+
Return valid JSON only, with this structure:
32+
{
33+
"eras": [
34+
{
35+
"period": "Era name (e.g. 'The Early Days')",
36+
"startDate": "YYYY-MM",
37+
"endDate": "YYYY-MM",
38+
"topics": ["topic1", "topic2"],
39+
"style": "Brief style description",
40+
"description": "2-3 sentence narrative of this period",
41+
"videoCount": 15
42+
}
43+
],
44+
"summary": "One paragraph overall evolution summary"
45+
}
46+
47+
Keep era names creative and descriptive. Identify 3-6 distinct eras based on natural shifts you observe.`;
48+
49+
function groupIntoEras(
50+
vids: { title: string; publishedAt: Date; views: number; topics: string[] | null; duration: number }[],
51+
): string {
52+
const sorted = [...vids].sort(
53+
(a, b) => a.publishedAt.getTime() - b.publishedAt.getTime(),
54+
);
55+
56+
const eraSize = Math.max(1, Math.ceil(sorted.length / 5));
57+
const groups: string[] = [];
58+
59+
for (let i = 0; i < sorted.length; i += eraSize) {
60+
const slice = sorted.slice(i, i + eraSize);
61+
const start = slice[0].publishedAt.toISOString().slice(0, 7);
62+
const end = slice.at(-1)!.publishedAt.toISOString().slice(0, 7);
63+
const lines = slice.map(
64+
(v) =>
65+
`- "${v.title}" (${v.publishedAt.toISOString().slice(0, 10)}, ${v.views.toLocaleString()} views, ${Math.round(v.duration / 60)}min) [${(v.topics ?? []).join(", ")}]`,
66+
);
67+
groups.push(`### ${start} to ${end} (${slice.length} videos)\n${lines.join("\n")}`);
68+
}
69+
70+
return groups.join("\n\n");
71+
}
72+
73+
export async function OPTIONS() {
74+
return optionsResponse(corsHeaders);
75+
}
76+
77+
export const POST = withErrorHandling("discover-evolution", async (request, { params }) => {
78+
const session = await auth();
79+
if (!session) {
80+
return Response.json({ error: "Not authenticated" }, { status: 401, headers: corsHeaders });
81+
}
82+
83+
const clientIp = getClientIp(request);
84+
const rateLimitResult = checkRateLimit(`discover-evolution:${clientIp}`, RATE_LIMITS.aiQuery);
85+
if (!rateLimitResult.success) {
86+
return rateLimitExceededResponse(rateLimitResult, "Too many requests", corsHeaders);
87+
}
88+
89+
const { channelId } = await params;
90+
91+
const channelVideos = await db
92+
.select({
93+
title: videos.title,
94+
publishedAt: videos.publishedAt,
95+
views: videos.views,
96+
topics: videos.topics,
97+
duration: videos.duration,
98+
})
99+
.from(videos)
100+
.where(eq(videos.channelId, channelId));
101+
102+
if (channelVideos.length < 5) {
103+
return Response.json(
104+
{ error: "Not enough videos to analyze evolution (minimum 5)" },
105+
{ status: 400, headers: corsHeaders },
106+
);
107+
}
108+
109+
const grouped = groupIntoEras(channelVideos);
110+
111+
log.info({ channelId, videoCount: channelVideos.length }, "Generating evolution analysis");
112+
113+
const { text } = await generateText({
114+
model: getModel(),
115+
system: SYSTEM_PROMPT,
116+
messages: [{ role: "user", content: grouped }],
117+
temperature: 0.3,
118+
maxOutputTokens: 2000,
119+
});
120+
121+
const jsonMatch = text.match(/\{[\s\S]*\}/);
122+
if (!jsonMatch) {
123+
log.error({ channelId }, "Failed to parse AI response for evolution");
124+
return Response.json({ error: "Failed to generate analysis" }, { status: 500, headers: corsHeaders });
125+
}
126+
127+
const result = JSON.parse(jsonMatch[0]);
128+
129+
return Response.json(result, {
130+
headers: mergeHeaders(corsHeaders, withRateLimitHeaders(rateLimitResult)),
131+
});
132+
});

0 commit comments

Comments
 (0)