Skip to main content

kaccy_ai/
transcript.rs

1//! Video transcript extraction module
2//!
3//! This module provides capabilities for extracting transcripts from videos
4//! using various services including `YouTube` transcripts and external
5//! transcription APIs.
6
7use 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/// Transcript provider trait for different backends
15#[async_trait]
16pub trait TranscriptProvider: Send + Sync {
17    /// Extract transcript from a video URL
18    async fn extract_transcript(&self, url: &str) -> Result<TranscriptResult>;
19
20    /// Get provider name
21    fn name(&self) -> &str;
22
23    /// Check if this provider supports the given URL
24    fn supports_url(&self, url: &str) -> bool;
25}
26
27/// Video transcript result
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TranscriptResult {
30    /// Full transcript text
31    pub text: String,
32    /// Transcript segments with timestamps
33    pub segments: Vec<TranscriptSegment>,
34    /// Video metadata
35    pub metadata: VideoMetadata,
36    /// Language of the transcript
37    pub language: Option<String>,
38    /// Whether this is auto-generated or human-created
39    pub is_auto_generated: bool,
40    /// Processing time in milliseconds
41    pub processing_time_ms: u64,
42    /// Provider used
43    pub provider: String,
44}
45
46/// A segment of transcript with timing
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TranscriptSegment {
49    /// Segment text
50    pub text: String,
51    /// Start time in seconds
52    pub start_time: f64,
53    /// Duration in seconds
54    pub duration: f64,
55    /// Speaker identifier (if available)
56    pub speaker: Option<String>,
57}
58
59impl TranscriptSegment {
60    /// Get end time
61    #[must_use]
62    pub fn end_time(&self) -> f64 {
63        self.start_time + self.duration
64    }
65
66    /// Format start time as HH:MM:SS
67    #[must_use]
68    pub fn formatted_start(&self) -> String {
69        Self::format_time(self.start_time)
70    }
71
72    /// Format time as HH:MM:SS
73    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/// Video metadata
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct VideoMetadata {
90    /// Video title
91    pub title: Option<String>,
92    /// Video description
93    pub description: Option<String>,
94    /// Video duration in seconds
95    pub duration_seconds: Option<f64>,
96    /// Channel/author name
97    pub author: Option<String>,
98    /// Video publish date
99    pub publish_date: Option<String>,
100    /// Video platform
101    pub platform: VideoPlatform,
102    /// Video ID on the platform
103    pub video_id: String,
104    /// Thumbnail URL
105    pub thumbnail_url: Option<String>,
106}
107
108/// Supported video platforms
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub enum VideoPlatform {
111    /// YouTube or youtu.be.
112    YouTube,
113    /// Vimeo.
114    Vimeo,
115    /// Twitter / X.
116    Twitter,
117    /// TikTok.
118    TikTok,
119    /// Twitch.
120    Twitch,
121    /// Platform could not be detected.
122    Unknown,
123}
124
125impl VideoPlatform {
126    /// Detect platform from URL
127    #[must_use]
128    pub fn from_url(url: &str) -> (Self, Option<String>) {
129        let url_lower = url.to_lowercase();
130
131        // YouTube
132        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        // Vimeo
138        if url_lower.contains("vimeo.com") {
139            let video_id = Self::extract_vimeo_id(url);
140            return (VideoPlatform::Vimeo, video_id);
141        }
142
143        // Twitter/X
144        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        // TikTok
150        if url_lower.contains("tiktok.com") {
151            let video_id = Self::extract_tiktok_id(url);
152            return (VideoPlatform::TikTok, video_id);
153        }
154
155        // Twitch
156        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        // Handle youtu.be/VIDEO_ID
166        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        // Handle youtube.com/watch?v=VIDEO_ID
175        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        // Handle youtube.com/embed/VIDEO_ID
184        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        // Handle vimeo.com/VIDEO_ID
197        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        // Handle twitter.com/user/status/ID
204        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        // Handle tiktok.com/@user/video/ID
211        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        // Handle twitch.tv/videos/ID
218        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
224/// `YouTube` transcript extractor
225pub struct YouTubeTranscriptProvider {
226    http_client: reqwest::Client,
227}
228
229impl YouTubeTranscriptProvider {
230    /// Create a new `YouTube` transcript provider
231    #[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    /// Extract video ID from `YouTube` URL
242    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    /// Fetch `YouTube` page to extract transcript data
252    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    /// Parse transcript from `YouTube` page data
281    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        // Extract video title
289        let title = self.extract_title(page_html);
290
291        // Extract description
292        let description = self.extract_description(page_html);
293
294        // Try to find captions track
295        let captions_data = self.extract_captions_data(page_html)?;
296
297        // Parse caption segments
298        let segments = self.parse_caption_segments(&captions_data);
299
300        // Combine segments into full text
301        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()), // Simplified - actual implementation would detect
325            is_auto_generated: true,          // Would need to check actual caption type
326            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        // Look for <title>...</title>
333        let re = regex::Regex::new(r"<title>([^<]+)</title>").ok()?;
334        let caps = re.captures(html)?;
335        let title = caps.get(1)?.as_str();
336        // Remove " - YouTube" suffix
337        Some(title.trim_end_matches(" - YouTube").to_string())
338    }
339
340    fn extract_description(&self, html: &str) -> Option<String> {
341        // Look for description meta tag
342        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        // Look for channel name
349        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        // Look for publish date
356        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        // Look for caption track URL in page data
363        // This is a simplified extraction - full implementation would use innertube API
364        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        // Try alternate format
375        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        // Simplified - actual implementation would parse XML/JSON caption format
389        // For now, return empty and rely on LLM fallback
390        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
422/// LLM-assisted transcript analyzer
423/// Used when native transcripts aren't available
424pub struct LlmTranscriptAnalyzer {
425    llm: LlmClient,
426}
427
428impl LlmTranscriptAnalyzer {
429    /// Create a new `LlmTranscriptAnalyzer` backed by the given LLM client.
430    #[must_use]
431    pub fn new(llm: LlmClient) -> Self {
432        Self { llm }
433    }
434
435    /// Analyze transcript content for key points
436    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        // Try to extract JSON
481        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    /// Generate a summary of the transcript
496    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, // Approximate words from char limit
504            &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/// Transcript analysis result
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct TranscriptAnalysis {
522    /// Brief summary
523    pub summary: String,
524    /// Main topics covered
525    pub topics: Vec<String>,
526    /// Key takeaways
527    pub key_takeaways: Vec<String>,
528    /// Overall sentiment
529    pub sentiment: String,
530    /// Quality indicators
531    pub quality_indicators: QualityIndicators,
532}
533
534/// Quality indicators for transcript content
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct QualityIndicators {
537    /// Clarity score (0-100)
538    pub clarity: u32,
539    /// Informativeness score (0-100)
540    pub informativeness: u32,
541    /// Professionalism score (0-100)
542    pub professionalism: u32,
543}
544
545/// Multi-provider transcript service
546pub struct TranscriptService {
547    providers: Vec<Box<dyn TranscriptProvider>>,
548    analyzer: Option<LlmTranscriptAnalyzer>,
549}
550
551impl TranscriptService {
552    /// Create a new transcript service with default providers
553    #[must_use]
554    pub fn new() -> Self {
555        Self {
556            providers: vec![Box::new(YouTubeTranscriptProvider::new())],
557            analyzer: None,
558        }
559    }
560
561    /// Create with LLM support for analysis
562    #[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    /// Add a custom provider
571    #[must_use]
572    pub fn add_provider(mut self, provider: Box<dyn TranscriptProvider>) -> Self {
573        self.providers.push(provider);
574        self
575    }
576
577    /// Extract transcript from any supported URL
578    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    /// Extract and analyze transcript
591    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    /// Check if a URL is supported
607    #[must_use]
608    pub fn supports_url(&self, url: &str) -> bool {
609        self.providers.iter().any(|p| p.supports_url(url))
610    }
611
612    /// Get list of supported platforms
613    #[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
625/// Transcript search functionality
626pub struct TranscriptSearch;
627
628impl TranscriptSearch {
629    /// Search for text within transcript segments
630    #[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    /// Get context around a segment
652    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    /// Find all timestamps where a topic is discussed
664    #[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/// Search result within transcript
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct SearchResult {
682    /// Index of the segment
683    pub segment_index: usize,
684    /// Matching text
685    pub text: String,
686    /// Start time in seconds
687    pub start_time: f64,
688    /// Formatted timestamp
689    pub timestamp: String,
690    /// Surrounding context
691    pub context: String,
692}
693
694/// Topic mention with timestamp
695#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct TopicMention {
697    /// Formatted timestamp
698    pub timestamp: String,
699    /// Start time in seconds
700    pub start_seconds: f64,
701    /// Context around the mention
702    pub context: String,
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_youtube_id_extraction() {
711        // Test YouTube URL formats
712        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        // Test Vimeo URL - should extract Vimeo ID (not YouTube)
725        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}