Skip to content

Commit dfaefdb

Browse files
committed
feat: add YouTube video search via yt-dlp ytsearch
Users can now type search queries directly in the input field instead of pasting URLs. Search triggers after 500ms debounce with results displayed in a dropdown. Clicking a result starts the analysis. - Add search_videos method to YouTubeExtractor using ytsearch - Add /api/analysis/search endpoint with query and limit params - Add SearchResults dropdown component with loading state - Modify UrlInput to detect URLs vs search queries - Add 9 unit tests for search functionality (215 total)
1 parent 5306e4b commit dfaefdb

File tree

10 files changed

+607
-17
lines changed

10 files changed

+607
-17
lines changed

api/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
PriorityLevel,
1414
ProgressEvent,
1515
RecommendationResponse,
16+
SearchResult,
1617
SentimentSummary,
1718
SentimentType,
1819
TopicResponse,
@@ -30,6 +31,7 @@
3031
"ProgressEvent",
3132
"AnalysisHistoryItem",
3233
"ErrorResponse",
34+
"SearchResult",
3335
"SentimentType",
3436
"PriorityLevel",
3537
"AnalysisStage",

api/models/schemas.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ class ErrorResponse(BaseModel):
125125
detail: str | None = None
126126

127127

128+
# Search Models
129+
130+
131+
class SearchResult(BaseModel):
132+
"""YouTube video search result."""
133+
134+
id: str
135+
title: str
136+
channel: str
137+
thumbnail: str
138+
duration: str | None = None
139+
view_count: int | None = None
140+
141+
128142
# ABSA (Aspect-Based Sentiment Analysis) Models
129143

130144

api/routers/analysis.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
PriorityLevel,
2323
ProgressEvent,
2424
RecommendationResponse,
25+
SearchResult,
2526
SentimentSummary,
2627
SentimentType,
2728
TopicResponse,
@@ -735,3 +736,30 @@ async def delete_analysis(analysis_id: int, db: Session = Depends(get_db)):
735736
db.commit()
736737

737738
return {"status": "deleted", "id": analysis_id}
739+
740+
741+
@router.get("/search", response_model=list[SearchResult])
742+
async def search_videos(q: str, limit: int = 5):
743+
"""Search YouTube videos by query."""
744+
if not q or len(q.strip()) < 2:
745+
raise HTTPException(status_code=400, detail="Query must be at least 2 characters")
746+
747+
if limit < 1 or limit > 10:
748+
limit = 5
749+
750+
extractor = YouTubeExtractor()
751+
try:
752+
results = extractor.search_videos(q.strip(), limit)
753+
return [
754+
SearchResult(
755+
id=r.id,
756+
title=r.title,
757+
channel=r.channel,
758+
thumbnail=r.thumbnail,
759+
duration=r.duration,
760+
view_count=r.view_count,
761+
)
762+
for r in results
763+
]
764+
except YouTubeExtractionError as e:
765+
raise HTTPException(status_code=500, detail=str(e))

api/services/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .youtube import (
3939
CommentData,
4040
CommentsDisabledError,
41+
SearchResultData,
4142
VideoMetadata,
4243
VideoNotFoundError,
4344
YouTubeExtractionError,
@@ -51,6 +52,7 @@
5152
"VideoNotFoundError",
5253
"VideoMetadata",
5354
"CommentData",
55+
"SearchResultData",
5456
"SentimentAnalyzer",
5557
"SentimentResult",
5658
"SentimentCategory",

api/services/youtube.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ class CommentData:
2727
parent_id: str | None = None
2828

2929

30+
@dataclass
31+
class SearchResultData:
32+
id: str
33+
title: str
34+
channel: str
35+
thumbnail: str
36+
duration: str | None
37+
view_count: int | None
38+
39+
3040
class YouTubeExtractionError(Exception):
3141
pass
3242

@@ -171,3 +181,64 @@ def get_comments(self, url: str, max_comments: int = 100) -> list[CommentData]:
171181
raise YouTubeExtractionError("Timeout while fetching comments")
172182
except json.JSONDecodeError:
173183
raise YouTubeExtractionError("Failed to parse comments data")
184+
185+
def search_videos(self, query: str, max_results: int = 5) -> list[SearchResultData]:
186+
"""Search YouTube videos using yt-dlp's ytsearch feature."""
187+
if not query.strip():
188+
return []
189+
190+
try:
191+
result = subprocess.run(
192+
[
193+
"yt-dlp",
194+
f"ytsearch{max_results}:{query}",
195+
"--dump-json",
196+
"--no-download",
197+
"--no-warnings",
198+
"--flat-playlist",
199+
],
200+
capture_output=True,
201+
text=True,
202+
timeout=30,
203+
)
204+
205+
if result.returncode != 0:
206+
raise YouTubeExtractionError(f"Search failed: {result.stderr}")
207+
208+
results = []
209+
for line in result.stdout.strip().split("\n"):
210+
if not line:
211+
continue
212+
try:
213+
data = json.loads(line)
214+
video_id = data.get("id", "")
215+
duration_secs = data.get("duration")
216+
duration_str = None
217+
if duration_secs:
218+
minutes, seconds = divmod(int(duration_secs), 60)
219+
hours, minutes = divmod(minutes, 60)
220+
if hours > 0:
221+
duration_str = f"{hours}:{minutes:02d}:{seconds:02d}"
222+
else:
223+
duration_str = f"{minutes}:{seconds:02d}"
224+
225+
results.append(
226+
SearchResultData(
227+
id=video_id,
228+
title=data.get("title", "Unknown"),
229+
channel=data.get("channel", data.get("uploader", "Unknown")),
230+
thumbnail=data.get(
231+
"thumbnail",
232+
f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
233+
),
234+
duration=duration_str,
235+
view_count=data.get("view_count"),
236+
)
237+
)
238+
except json.JSONDecodeError:
239+
continue
240+
241+
return results
242+
243+
except subprocess.TimeoutExpired:
244+
raise YouTubeExtractionError("Timeout while searching videos")

src/components/search-results.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client";
2+
3+
import type { SearchResult } from "@/types";
4+
import { cn } from "@/lib/utils";
5+
6+
interface SearchResultsProps {
7+
results: SearchResult[];
8+
isLoading: boolean;
9+
onSelect: (result: SearchResult) => void;
10+
className?: string;
11+
}
12+
13+
function formatViewCount(count: number | undefined): string {
14+
if (count === undefined) return "";
15+
if (count >= 1_000_000) {
16+
return `${(count / 1_000_000).toFixed(1)}M views`;
17+
}
18+
if (count >= 1_000) {
19+
return `${(count / 1_000).toFixed(1)}K views`;
20+
}
21+
return `${count} views`;
22+
}
23+
24+
export function SearchResults({
25+
results,
26+
isLoading,
27+
onSelect,
28+
className,
29+
}: SearchResultsProps) {
30+
if (isLoading) {
31+
return (
32+
<div
33+
className={cn(
34+
"absolute z-50 w-full mt-1 bg-background border rounded-lg shadow-lg overflow-hidden",
35+
className
36+
)}
37+
>
38+
<div className="flex items-center justify-center py-8">
39+
<svg
40+
className="animate-spin h-5 w-5 text-muted-foreground"
41+
xmlns="http://www.w3.org/2000/svg"
42+
fill="none"
43+
viewBox="0 0 24 24"
44+
>
45+
<circle
46+
className="opacity-25"
47+
cx="12"
48+
cy="12"
49+
r="10"
50+
stroke="currentColor"
51+
strokeWidth="4"
52+
/>
53+
<path
54+
className="opacity-75"
55+
fill="currentColor"
56+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
57+
/>
58+
</svg>
59+
<span className="ml-2 text-sm text-muted-foreground">Searching...</span>
60+
</div>
61+
</div>
62+
);
63+
}
64+
65+
if (results.length === 0) {
66+
return null;
67+
}
68+
69+
return (
70+
<div
71+
className={cn(
72+
"absolute z-50 w-full mt-1 bg-background border rounded-lg shadow-lg overflow-hidden",
73+
className
74+
)}
75+
>
76+
<ul className="divide-y">
77+
{results.map((result) => (
78+
<li key={result.id}>
79+
<button
80+
type="button"
81+
onClick={() => onSelect(result)}
82+
className="w-full flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors text-left"
83+
>
84+
<img
85+
src={result.thumbnail}
86+
alt={result.title}
87+
className="w-24 h-14 object-cover rounded flex-shrink-0"
88+
onError={(e) => {
89+
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${result.id}/hqdefault.jpg`;
90+
}}
91+
/>
92+
<div className="flex-1 min-w-0">
93+
<p className="text-sm font-medium truncate">{result.title}</p>
94+
<p className="text-xs text-muted-foreground truncate">{result.channel}</p>
95+
<div className="flex items-center gap-2 mt-1">
96+
{result.duration && (
97+
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
98+
{result.duration}
99+
</span>
100+
)}
101+
{result.viewCount !== undefined && (
102+
<span className="text-xs text-muted-foreground">
103+
{formatViewCount(result.viewCount)}
104+
</span>
105+
)}
106+
</div>
107+
</div>
108+
</button>
109+
</li>
110+
))}
111+
</ul>
112+
</div>
113+
);
114+
}

0 commit comments

Comments
 (0)