ydl/
lib.rs

1pub mod error;
2pub mod extractor;
3pub mod parser;
4pub mod processor;
5pub mod types;
6pub mod youtube_client;
7
8pub use error::{YdlError, YdlResult};
9pub use types::{
10    ParsedSubtitles, SubtitleEntry, SubtitleResult, SubtitleTrack, SubtitleTrackType, SubtitleType,
11    VideoMetadata, YdlOptions,
12};
13
14use extractor::SubtitleExtractor;
15use parser::YouTubeParser;
16use processor::ContentProcessor;
17use std::sync::Arc;
18use tracing::{debug, error, info};
19
20/// Main orchestrator for subtitle downloads
21pub struct Ydl {
22    url: String,
23    video_id: String,
24    options: YdlOptions,
25    extractor: Arc<SubtitleExtractor>,
26    processor: ContentProcessor,
27}
28
29impl Ydl {
30    /// Create a new downloader instance for a specific URL
31    pub fn new(url: &str, options: YdlOptions) -> YdlResult<Self> {
32        info!("Initializing Ydl for URL: {}", url);
33
34        let parser = YouTubeParser::new();
35        let video_id = parser.parse_url(url)?;
36
37        debug!("Extracted video ID: {}", video_id);
38
39        let extractor = Arc::new(SubtitleExtractor::new(options.clone())?);
40        let processor = ContentProcessor::new();
41
42        Ok(Self {
43            url: url.to_string(),
44            video_id,
45            options,
46            extractor,
47            processor,
48        })
49    }
50
51    /// Download subtitles in the specified format
52    pub async fn subtitle(&self, subtitle_type: SubtitleType) -> YdlResult<String> {
53        info!("Downloading subtitle in format: {:?}", subtitle_type);
54
55        // Discover available subtitle tracks
56        let tracks = self.extractor.discover_tracks(&self.video_id).await?;
57
58        if tracks.is_empty() {
59            return Err(YdlError::NoSubtitlesAvailable {
60                video_id: self.video_id.clone(),
61            });
62        }
63
64        // Select the best track based on options
65        let selected_track = self.extractor.select_best_track(&tracks).ok_or_else(|| {
66            YdlError::NoSubtitlesAvailable {
67                video_id: self.video_id.clone(),
68            }
69        })?;
70
71        debug!(
72            "Selected track: {} ({})",
73            selected_track.language_name, selected_track.track_type
74        );
75
76        // Download the subtitle content
77        let raw_content = self
78            .extractor
79            .download_content(selected_track, &self.video_id)
80            .await?;
81
82        // Process and convert the content
83        let processed_content = self.processor.process_content(
84            &raw_content,
85            subtitle_type,
86            &selected_track.language_code,
87            self.options.clean_content,
88            self.options.validate_timing,
89        )?;
90
91        Ok(processed_content)
92    }
93
94    /// Download subtitles in the specified format (async variant)
95    pub async fn subtitle_async(&self, subtitle_type: SubtitleType) -> YdlResult<String> {
96        self.subtitle(subtitle_type).await
97    }
98
99    /// List all available subtitle tracks for the video
100    pub async fn available_subtitles(&self) -> YdlResult<Vec<SubtitleTrack>> {
101        info!("Discovering available subtitle tracks");
102        self.extractor.discover_tracks(&self.video_id).await
103    }
104
105    /// Download multiple subtitle formats at once
106    pub async fn subtitles(&self, types: &[SubtitleType]) -> YdlResult<Vec<SubtitleResult>> {
107        info!("Downloading multiple subtitle formats: {:?}", types);
108
109        // Discover tracks once
110        let tracks = self.extractor.discover_tracks(&self.video_id).await?;
111
112        if tracks.is_empty() {
113            return Err(YdlError::NoSubtitlesAvailable {
114                video_id: self.video_id.clone(),
115            });
116        }
117
118        let selected_track = self.extractor.select_best_track(&tracks).ok_or_else(|| {
119            YdlError::NoSubtitlesAvailable {
120                video_id: self.video_id.clone(),
121            }
122        })?;
123
124        // Download content once
125        let raw_content = self
126            .extractor
127            .download_content(selected_track, &self.video_id)
128            .await?;
129
130        // Process for each requested format
131        let mut results = Vec::new();
132
133        for &subtitle_type in types {
134            match self.processor.process_content(
135                &raw_content,
136                subtitle_type,
137                &selected_track.language_code,
138                self.options.clean_content,
139                self.options.validate_timing,
140            ) {
141                Ok(content) => {
142                    results.push(SubtitleResult::new(
143                        content,
144                        subtitle_type,
145                        selected_track.language_code.clone(),
146                        selected_track.track_type.clone(),
147                    ));
148                }
149                Err(e) => {
150                    error!("Failed to process format {:?}: {}", subtitle_type, e);
151                    return Err(e);
152                }
153            }
154        }
155
156        Ok(results)
157    }
158
159    /// Get video metadata without downloading subtitles
160    pub async fn metadata(&self) -> YdlResult<VideoMetadata> {
161        info!("Getting video metadata");
162        self.extractor.get_video_metadata(&self.video_id).await
163    }
164
165    /// Get the video ID for this instance
166    pub fn video_id(&self) -> &str {
167        &self.video_id
168    }
169
170    /// Get the original URL for this instance
171    pub fn url(&self) -> &str {
172        &self.url
173    }
174
175    /// Get the normalized YouTube URL
176    pub fn normalized_url(&self) -> String {
177        format!("https://www.youtube.com/watch?v={}", self.video_id)
178    }
179
180    /// Check if subtitles are likely available (quick check)
181    pub async fn has_subtitles(&self) -> bool {
182        match self.extractor.discover_tracks(&self.video_id).await {
183            Ok(tracks) => !tracks.is_empty(),
184            Err(_) => false,
185        }
186    }
187
188    /// Download subtitle with retry logic
189    pub async fn subtitle_with_retry(&self, subtitle_type: SubtitleType) -> YdlResult<String> {
190        let mut retries = 0;
191        let max_retries = self.options.max_retries;
192
193        loop {
194            match self.subtitle(subtitle_type).await {
195                Ok(content) => return Ok(content),
196                Err(e) => {
197                    if retries >= max_retries {
198                        return Err(e);
199                    }
200
201                    if e.is_retryable() {
202                        retries += 1;
203                        let delay = e.retry_delay().unwrap_or(1);
204
205                        debug!(
206                            "Retrying in {}s (attempt {} of {})",
207                            delay, retries, max_retries
208                        );
209                        tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
210                    } else {
211                        return Err(e);
212                    }
213                }
214            }
215        }
216    }
217
218    /// Future extension for method chaining
219    pub fn with_language(mut self, lang: &str) -> Self {
220        self.options.language = Some(lang.to_string());
221        self
222    }
223
224    /// Future extension for format preference
225    pub fn with_auto_generated(mut self, allow: bool) -> Self {
226        self.options.allow_auto_generated = allow;
227        self
228    }
229}
230
231// Convenience functions for one-off operations
232
233/// Quick function to download a subtitle
234pub async fn download_subtitle(url: &str, format: SubtitleType) -> YdlResult<String> {
235    let downloader = Ydl::new(url, YdlOptions::default())?;
236    downloader.subtitle(format).await
237}
238
239/// Quick function to list available subtitles
240pub async fn list_subtitles(url: &str) -> YdlResult<Vec<SubtitleTrack>> {
241    let downloader = Ydl::new(url, YdlOptions::default())?;
242    downloader.available_subtitles().await
243}
244
245/// Quick function to get video metadata
246pub async fn get_metadata(url: &str) -> YdlResult<VideoMetadata> {
247    let downloader = Ydl::new(url, YdlOptions::default())?;
248    downloader.metadata().await
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[tokio::test]
256    async fn test_ydl_creation() {
257        let options = YdlOptions::default();
258        let result = Ydl::new("https://www.youtube.com/watch?v=dQw4w9WgXcQ", options);
259        assert!(result.is_ok());
260
261        let ydl = result.unwrap();
262        assert_eq!(ydl.video_id(), "dQw4w9WgXcQ");
263        assert_eq!(ydl.url(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ");
264    }
265
266    #[test]
267    fn test_ydl_invalid_url() {
268        let options = YdlOptions::default();
269        let result = Ydl::new("https://www.google.com/", options);
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn test_ydl_fluent_interface() {
275        let options = YdlOptions::default();
276        let ydl = Ydl::new("https://www.youtube.com/watch?v=dQw4w9WgXcQ", options)
277            .unwrap()
278            .with_language("en")
279            .with_auto_generated(false);
280
281        assert_eq!(ydl.options.language, Some("en".to_string()));
282        assert!(!ydl.options.allow_auto_generated);
283    }
284
285    #[test]
286    fn test_normalized_url() {
287        let options = YdlOptions::default();
288        let ydl = Ydl::new("https://youtu.be/dQw4w9WgXcQ", options).unwrap();
289        assert_eq!(
290            ydl.normalized_url(),
291            "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
292        );
293    }
294
295    // Note: Network tests would require actual YouTube URLs and network access
296    // In a real implementation, these would be integration tests with mock servers
297}