1use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11use crate::error::{AiError, Result};
12use crate::llm::{ChatRequest, LlmClient};
13
14#[async_trait]
16pub trait TranscriptProvider: Send + Sync {
17 async fn extract_transcript(&self, url: &str) -> Result<TranscriptResult>;
19
20 fn name(&self) -> &str;
22
23 fn supports_url(&self, url: &str) -> bool;
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TranscriptResult {
30 pub text: String,
32 pub segments: Vec<TranscriptSegment>,
34 pub metadata: VideoMetadata,
36 pub language: Option<String>,
38 pub is_auto_generated: bool,
40 pub processing_time_ms: u64,
42 pub provider: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TranscriptSegment {
49 pub text: String,
51 pub start_time: f64,
53 pub duration: f64,
55 pub speaker: Option<String>,
57}
58
59impl TranscriptSegment {
60 #[must_use]
62 pub fn end_time(&self) -> f64 {
63 self.start_time + self.duration
64 }
65
66 #[must_use]
68 pub fn formatted_start(&self) -> String {
69 Self::format_time(self.start_time)
70 }
71
72 fn format_time(seconds: f64) -> String {
74 let total_secs = seconds as u64;
75 let hours = total_secs / 3600;
76 let mins = (total_secs % 3600) / 60;
77 let secs = total_secs % 60;
78
79 if hours > 0 {
80 format!("{hours:02}:{mins:02}:{secs:02}")
81 } else {
82 format!("{mins:02}:{secs:02}")
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct VideoMetadata {
90 pub title: Option<String>,
92 pub description: Option<String>,
94 pub duration_seconds: Option<f64>,
96 pub author: Option<String>,
98 pub publish_date: Option<String>,
100 pub platform: VideoPlatform,
102 pub video_id: String,
104 pub thumbnail_url: Option<String>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub enum VideoPlatform {
111 YouTube,
113 Vimeo,
115 Twitter,
117 TikTok,
119 Twitch,
121 Unknown,
123}
124
125impl VideoPlatform {
126 #[must_use]
128 pub fn from_url(url: &str) -> (Self, Option<String>) {
129 let url_lower = url.to_lowercase();
130
131 if url_lower.contains("youtube.com") || url_lower.contains("youtu.be") {
133 let video_id = Self::extract_youtube_id(url);
134 return (VideoPlatform::YouTube, video_id);
135 }
136
137 if url_lower.contains("vimeo.com") {
139 let video_id = Self::extract_vimeo_id(url);
140 return (VideoPlatform::Vimeo, video_id);
141 }
142
143 if url_lower.contains("twitter.com") || url_lower.contains("x.com") {
145 let video_id = Self::extract_twitter_id(url);
146 return (VideoPlatform::Twitter, video_id);
147 }
148
149 if url_lower.contains("tiktok.com") {
151 let video_id = Self::extract_tiktok_id(url);
152 return (VideoPlatform::TikTok, video_id);
153 }
154
155 if url_lower.contains("twitch.tv") {
157 let video_id = Self::extract_twitch_id(url);
158 return (VideoPlatform::Twitch, video_id);
159 }
160
161 (VideoPlatform::Unknown, None)
162 }
163
164 fn extract_youtube_id(url: &str) -> Option<String> {
165 if url.contains("youtu.be/") {
167 let parts: Vec<&str> = url.split("youtu.be/").collect();
168 if parts.len() > 1 {
169 let id = parts[1].split(['?', '&', '#']).next()?;
170 return Some(id.to_string());
171 }
172 }
173
174 if url.contains("v=") {
176 let parts: Vec<&str> = url.split("v=").collect();
177 if parts.len() > 1 {
178 let id = parts[1].split(['&', '#']).next()?;
179 return Some(id.to_string());
180 }
181 }
182
183 if url.contains("/embed/") {
185 let parts: Vec<&str> = url.split("/embed/").collect();
186 if parts.len() > 1 {
187 let id = parts[1].split(['?', '&', '#', '/']).next()?;
188 return Some(id.to_string());
189 }
190 }
191
192 None
193 }
194
195 fn extract_vimeo_id(url: &str) -> Option<String> {
196 let re = regex::Regex::new(r"vimeo\.com/(\d+)").ok()?;
198 let caps = re.captures(url)?;
199 Some(caps.get(1)?.as_str().to_string())
200 }
201
202 fn extract_twitter_id(url: &str) -> Option<String> {
203 let re = regex::Regex::new(r"(?:twitter\.com|x\.com)/\w+/status/(\d+)").ok()?;
205 let caps = re.captures(url)?;
206 Some(caps.get(1)?.as_str().to_string())
207 }
208
209 fn extract_tiktok_id(url: &str) -> Option<String> {
210 let re = regex::Regex::new(r"tiktok\.com/@[\w.]+/video/(\d+)").ok()?;
212 let caps = re.captures(url)?;
213 Some(caps.get(1)?.as_str().to_string())
214 }
215
216 fn extract_twitch_id(url: &str) -> Option<String> {
217 let re = regex::Regex::new(r"twitch\.tv/videos/(\d+)").ok()?;
219 let caps = re.captures(url)?;
220 Some(caps.get(1)?.as_str().to_string())
221 }
222}
223
224pub struct YouTubeTranscriptProvider {
226 http_client: reqwest::Client,
227}
228
229impl YouTubeTranscriptProvider {
230 #[must_use]
232 pub fn new() -> Self {
233 Self {
234 http_client: reqwest::Client::builder()
235 .timeout(Duration::from_secs(30))
236 .build()
237 .expect("Failed to create HTTP client"),
238 }
239 }
240
241 fn extract_video_id(&self, url: &str) -> Option<String> {
243 let (platform, id) = VideoPlatform::from_url(url);
244 if platform == VideoPlatform::YouTube {
245 id
246 } else {
247 None
248 }
249 }
250
251 async fn fetch_video_page(&self, video_id: &str) -> Result<String> {
253 let url = format!("https://www.youtube.com/watch?v={video_id}");
254
255 let response = self
256 .http_client
257 .get(&url)
258 .header(
259 "User-Agent",
260 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
261 )
262 .header("Accept-Language", "en-US,en;q=0.9")
263 .send()
264 .await
265 .map_err(|e| AiError::Validation(format!("Failed to fetch video page: {e}")))?;
266
267 if !response.status().is_success() {
268 return Err(AiError::Validation(format!(
269 "Failed to fetch video page: HTTP {}",
270 response.status()
271 )));
272 }
273
274 response
275 .text()
276 .await
277 .map_err(|e| AiError::Validation(format!("Failed to read response: {e}")))
278 }
279
280 fn parse_transcript_from_page(
282 &self,
283 page_html: &str,
284 video_id: &str,
285 ) -> Result<TranscriptResult> {
286 let start_time = std::time::Instant::now();
287
288 let title = self.extract_title(page_html);
290
291 let description = self.extract_description(page_html);
293
294 let captions_data = self.extract_captions_data(page_html)?;
296
297 let segments = self.parse_caption_segments(&captions_data);
299
300 let text = segments
302 .iter()
303 .map(|s| s.text.as_str())
304 .collect::<Vec<_>>()
305 .join(" ");
306
307 let metadata = VideoMetadata {
308 title,
309 description,
310 duration_seconds: segments.last().map(TranscriptSegment::end_time),
311 author: self.extract_author(page_html),
312 publish_date: self.extract_publish_date(page_html),
313 platform: VideoPlatform::YouTube,
314 video_id: video_id.to_string(),
315 thumbnail_url: Some(format!(
316 "https://img.youtube.com/vi/{video_id}/maxresdefault.jpg"
317 )),
318 };
319
320 Ok(TranscriptResult {
321 text,
322 segments,
323 metadata,
324 language: Some("en".to_string()), is_auto_generated: true, processing_time_ms: start_time.elapsed().as_millis() as u64,
327 provider: "youtube".to_string(),
328 })
329 }
330
331 fn extract_title(&self, html: &str) -> Option<String> {
332 let re = regex::Regex::new(r"<title>([^<]+)</title>").ok()?;
334 let caps = re.captures(html)?;
335 let title = caps.get(1)?.as_str();
336 Some(title.trim_end_matches(" - YouTube").to_string())
338 }
339
340 fn extract_description(&self, html: &str) -> Option<String> {
341 let re = regex::Regex::new(r#"<meta name="description" content="([^"]*)"#).ok()?;
343 let caps = re.captures(html)?;
344 Some(caps.get(1)?.as_str().to_string())
345 }
346
347 fn extract_author(&self, html: &str) -> Option<String> {
348 let re = regex::Regex::new(r#""ownerChannelName":"([^"]+)""#).ok()?;
350 let caps = re.captures(html)?;
351 Some(caps.get(1)?.as_str().to_string())
352 }
353
354 fn extract_publish_date(&self, html: &str) -> Option<String> {
355 let re = regex::Regex::new(r#""publishDate":"([^"]+)""#).ok()?;
357 let caps = re.captures(html)?;
358 Some(caps.get(1)?.as_str().to_string())
359 }
360
361 fn extract_captions_data(&self, html: &str) -> Result<String> {
362 let re = regex::Regex::new(r#""captionTracks":\s*\[([^\]]+)\]"#)
365 .map_err(|e| AiError::Validation(e.to_string()))?;
366
367 if let Some(caps) = re.captures(html) {
368 return Ok(caps
369 .get(1)
370 .map(|m| m.as_str().to_string())
371 .unwrap_or_default());
372 }
373
374 let re2 = regex::Regex::new(r#"timedtext[^"]*\?[^"]*"#)
376 .map_err(|e| AiError::Validation(e.to_string()))?;
377
378 if let Some(caps) = re2.find(html) {
379 return Ok(caps.as_str().to_string());
380 }
381
382 Err(AiError::Validation(
383 "No captions found for this video".to_string(),
384 ))
385 }
386
387 fn parse_caption_segments(&self, _data: &str) -> Vec<TranscriptSegment> {
388 Vec::new()
391 }
392}
393
394impl Default for YouTubeTranscriptProvider {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400#[async_trait]
401impl TranscriptProvider for YouTubeTranscriptProvider {
402 async fn extract_transcript(&self, url: &str) -> Result<TranscriptResult> {
403 let video_id = self
404 .extract_video_id(url)
405 .ok_or_else(|| AiError::Validation("Invalid YouTube URL".to_string()))?;
406
407 let page_html = self.fetch_video_page(&video_id).await?;
408
409 self.parse_transcript_from_page(&page_html, &video_id)
410 }
411
412 fn name(&self) -> &'static str {
413 "youtube"
414 }
415
416 fn supports_url(&self, url: &str) -> bool {
417 let url_lower = url.to_lowercase();
418 url_lower.contains("youtube.com") || url_lower.contains("youtu.be")
419 }
420}
421
422pub struct LlmTranscriptAnalyzer {
425 llm: LlmClient,
426}
427
428impl LlmTranscriptAnalyzer {
429 #[must_use]
431 pub fn new(llm: LlmClient) -> Self {
432 Self { llm }
433 }
434
435 pub async fn analyze_transcript(
437 &self,
438 transcript: &TranscriptResult,
439 ) -> Result<TranscriptAnalysis> {
440 let prompt = format!(
441 r#"Analyze the following video transcript and provide:
4421. A brief summary (2-3 sentences)
4432. Main topics covered (bullet points)
4443. Key takeaways
4454. Overall sentiment (positive/neutral/negative)
446
447Video Title: {}
448Transcript:
449{}
450
451Respond in JSON format:
452{{
453 "summary": "<string>",
454 "topics": ["<topic1>", "<topic2>", ...],
455 "key_takeaways": ["<takeaway1>", "<takeaway2>", ...],
456 "sentiment": "<positive|neutral|negative>",
457 "quality_indicators": {{
458 "clarity": <0-100>,
459 "informativeness": <0-100>,
460 "professionalism": <0-100>
461 }}
462}}"#,
463 transcript.metadata.title.as_deref().unwrap_or("Unknown"),
464 &transcript.text[..transcript.text.len().min(10000)]
465 );
466
467 let request = ChatRequest::with_system(
468 "You are an expert content analyst. Analyze video transcripts accurately.",
469 prompt,
470 )
471 .max_tokens(2048)
472 .temperature(0.3);
473
474 let response = self.llm.chat(request).await?;
475
476 self.parse_analysis_response(&response.message.content)
477 }
478
479 fn parse_analysis_response(&self, response: &str) -> Result<TranscriptAnalysis> {
480 let json_str = if let Some(start) = response.find('{') {
482 if let Some(end) = response.rfind('}') {
483 &response[start..=end]
484 } else {
485 response
486 }
487 } else {
488 response
489 };
490
491 serde_json::from_str(json_str)
492 .map_err(|e| AiError::EvaluationFailed(format!("Failed to parse analysis: {e}")))
493 }
494
495 pub async fn summarize(
497 &self,
498 transcript: &TranscriptResult,
499 max_length: usize,
500 ) -> Result<String> {
501 let prompt = format!(
502 "Summarize the following video transcript in {} words or less. Focus on the main points and conclusions.\n\nTranscript:\n{}",
503 max_length / 5, &transcript.text[..transcript.text.len().min(15000)]
505 );
506
507 let request = ChatRequest::with_system(
508 "You are a concise summarizer. Create clear, accurate summaries.",
509 prompt,
510 )
511 .max_tokens(512)
512 .temperature(0.3);
513
514 let response = self.llm.chat(request).await?;
515 Ok(response.message.content)
516 }
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct TranscriptAnalysis {
522 pub summary: String,
524 pub topics: Vec<String>,
526 pub key_takeaways: Vec<String>,
528 pub sentiment: String,
530 pub quality_indicators: QualityIndicators,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct QualityIndicators {
537 pub clarity: u32,
539 pub informativeness: u32,
541 pub professionalism: u32,
543}
544
545pub struct TranscriptService {
547 providers: Vec<Box<dyn TranscriptProvider>>,
548 analyzer: Option<LlmTranscriptAnalyzer>,
549}
550
551impl TranscriptService {
552 #[must_use]
554 pub fn new() -> Self {
555 Self {
556 providers: vec![Box::new(YouTubeTranscriptProvider::new())],
557 analyzer: None,
558 }
559 }
560
561 #[must_use]
563 pub fn with_llm(llm: LlmClient) -> Self {
564 Self {
565 providers: vec![Box::new(YouTubeTranscriptProvider::new())],
566 analyzer: Some(LlmTranscriptAnalyzer::new(llm)),
567 }
568 }
569
570 #[must_use]
572 pub fn add_provider(mut self, provider: Box<dyn TranscriptProvider>) -> Self {
573 self.providers.push(provider);
574 self
575 }
576
577 pub async fn extract(&self, url: &str) -> Result<TranscriptResult> {
579 for provider in &self.providers {
580 if provider.supports_url(url) {
581 return provider.extract_transcript(url).await;
582 }
583 }
584
585 Err(AiError::Validation(format!(
586 "No provider supports URL: {url}"
587 )))
588 }
589
590 pub async fn extract_and_analyze(
592 &self,
593 url: &str,
594 ) -> Result<(TranscriptResult, Option<TranscriptAnalysis>)> {
595 let transcript = self.extract(url).await?;
596
597 let analysis = if let Some(ref analyzer) = self.analyzer {
598 Some(analyzer.analyze_transcript(&transcript).await?)
599 } else {
600 None
601 };
602
603 Ok((transcript, analysis))
604 }
605
606 #[must_use]
608 pub fn supports_url(&self, url: &str) -> bool {
609 self.providers.iter().any(|p| p.supports_url(url))
610 }
611
612 #[must_use]
614 pub fn supported_platforms(&self) -> Vec<&str> {
615 self.providers.iter().map(|p| p.name()).collect()
616 }
617}
618
619impl Default for TranscriptService {
620 fn default() -> Self {
621 Self::new()
622 }
623}
624
625pub struct TranscriptSearch;
627
628impl TranscriptSearch {
629 #[must_use]
631 pub fn search(transcript: &TranscriptResult, query: &str) -> Vec<SearchResult> {
632 let query_lower = query.to_lowercase();
633 let mut results = Vec::new();
634
635 for (index, segment) in transcript.segments.iter().enumerate() {
636 let text_lower = segment.text.to_lowercase();
637 if text_lower.contains(&query_lower) {
638 results.push(SearchResult {
639 segment_index: index,
640 text: segment.text.clone(),
641 start_time: segment.start_time,
642 timestamp: segment.formatted_start(),
643 context: Self::get_context(transcript, index),
644 });
645 }
646 }
647
648 results
649 }
650
651 fn get_context(transcript: &TranscriptResult, index: usize) -> String {
653 let start = index.saturating_sub(1);
654 let end = (index + 2).min(transcript.segments.len());
655
656 transcript.segments[start..end]
657 .iter()
658 .map(|s| s.text.as_str())
659 .collect::<Vec<_>>()
660 .join(" ")
661 }
662
663 #[must_use]
665 pub fn find_topic_timestamps(transcript: &TranscriptResult, topic: &str) -> Vec<TopicMention> {
666 let results = Self::search(transcript, topic);
667
668 results
669 .into_iter()
670 .map(|r| TopicMention {
671 timestamp: r.timestamp,
672 start_seconds: r.start_time,
673 context: r.context,
674 })
675 .collect()
676 }
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct SearchResult {
682 pub segment_index: usize,
684 pub text: String,
686 pub start_time: f64,
688 pub timestamp: String,
690 pub context: String,
692}
693
694#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct TopicMention {
697 pub timestamp: String,
699 pub start_seconds: f64,
701 pub context: String,
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn test_youtube_id_extraction() {
711 let youtube_cases = vec![
713 ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"),
714 ("https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"),
715 ("https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"),
716 ];
717
718 for (url, expected_id) in youtube_cases {
719 let (platform, id) = VideoPlatform::from_url(url);
720 assert_eq!(platform, VideoPlatform::YouTube);
721 assert_eq!(id, Some(expected_id.to_string()));
722 }
723
724 let (platform, id) = VideoPlatform::from_url("https://vimeo.com/123456");
726 assert_eq!(platform, VideoPlatform::Vimeo);
727 assert_eq!(id, Some("123456".to_string()));
728 }
729
730 #[test]
731 fn test_platform_detection() {
732 assert_eq!(
733 VideoPlatform::from_url("https://youtube.com/watch?v=abc").0,
734 VideoPlatform::YouTube
735 );
736 assert_eq!(
737 VideoPlatform::from_url("https://vimeo.com/123").0,
738 VideoPlatform::Vimeo
739 );
740 assert_eq!(
741 VideoPlatform::from_url("https://twitter.com/user/status/123").0,
742 VideoPlatform::Twitter
743 );
744 assert_eq!(
745 VideoPlatform::from_url("https://example.com/video").0,
746 VideoPlatform::Unknown
747 );
748 }
749
750 #[test]
751 fn test_segment_formatting() {
752 let segment = TranscriptSegment {
753 text: "Hello world".to_string(),
754 start_time: 3661.5,
755 duration: 2.0,
756 speaker: None,
757 };
758
759 assert_eq!(segment.formatted_start(), "01:01:01");
760 assert_eq!(segment.end_time(), 3663.5);
761 }
762}