ydl/
types.rs

1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3
4/// Available subtitle formats
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum SubtitleType {
7    /// SubRip Subtitle format (.srt)
8    Srt,
9    /// WebVTT format (.vtt)
10    Vtt,
11    /// Plain text format (.txt)
12    Txt,
13    /// JSON format with timing data
14    Json,
15    /// Raw format as received from source
16    Raw,
17}
18
19impl SubtitleType {
20    /// Get file extension for the format
21    pub fn extension(&self) -> &'static str {
22        match self {
23            SubtitleType::Srt => "srt",
24            SubtitleType::Vtt => "vtt",
25            SubtitleType::Txt => "txt",
26            SubtitleType::Json => "json",
27            SubtitleType::Raw => "xml",
28        }
29    }
30
31    /// Get MIME type for the format
32    pub fn mime_type(&self) -> &'static str {
33        match self {
34            SubtitleType::Srt => "application/x-subrip",
35            SubtitleType::Vtt => "text/vtt",
36            SubtitleType::Txt => "text/plain",
37            SubtitleType::Json => "application/json",
38            SubtitleType::Raw => "application/xml",
39        }
40    }
41}
42
43impl std::str::FromStr for SubtitleType {
44    type Err = crate::error::YdlError;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s.to_lowercase().as_str() {
48            "srt" => Ok(SubtitleType::Srt),
49            "vtt" => Ok(SubtitleType::Vtt),
50            "txt" => Ok(SubtitleType::Txt),
51            "json" => Ok(SubtitleType::Json),
52            "raw" | "xml" => Ok(SubtitleType::Raw),
53            _ => Err(crate::error::YdlError::UnsupportedFormat {
54                format: s.to_string(),
55            }),
56        }
57    }
58}
59
60impl std::fmt::Display for SubtitleType {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            SubtitleType::Srt => write!(f, "srt"),
64            SubtitleType::Vtt => write!(f, "vtt"),
65            SubtitleType::Txt => write!(f, "txt"),
66            SubtitleType::Json => write!(f, "json"),
67            SubtitleType::Raw => write!(f, "raw"),
68        }
69    }
70}
71
72/// Configuration options for subtitle downloads
73#[derive(Debug, Clone)]
74pub struct YdlOptions {
75    /// Preferred language code (e.g., "en", "es", "auto")
76    pub language: Option<String>,
77
78    /// Whether to allow auto-generated subtitles
79    pub allow_auto_generated: bool,
80
81    /// Whether to prefer manual over auto-generated subtitles
82    pub prefer_manual: bool,
83
84    /// Maximum retry attempts for failed requests
85    pub max_retries: u32,
86
87    /// Request timeout in seconds
88    pub timeout_seconds: u64,
89
90    /// Custom User-Agent string
91    pub user_agent: Option<String>,
92
93    /// Proxy settings
94    pub proxy: Option<String>,
95
96    /// Whether to clean/normalize subtitle content
97    pub clean_content: bool,
98
99    /// Whether to validate subtitle timing
100    pub validate_timing: bool,
101}
102
103impl Default for YdlOptions {
104    fn default() -> Self {
105        Self {
106            language: None,             // Auto-detect
107            allow_auto_generated: true, // Default to allowing auto-generated
108            prefer_manual: true,
109            max_retries: 3,
110            timeout_seconds: 30,
111            user_agent: None, // Use default
112            proxy: None,
113            clean_content: true,
114            validate_timing: true,
115        }
116    }
117}
118
119impl YdlOptions {
120    /// Create options with default values
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Builder pattern for fluent configuration
126    pub fn language(mut self, lang: &str) -> Self {
127        self.language = Some(lang.to_string());
128        self
129    }
130
131    pub fn allow_auto_generated(mut self, allow: bool) -> Self {
132        self.allow_auto_generated = allow;
133        self
134    }
135
136    pub fn prefer_manual(mut self, prefer: bool) -> Self {
137        self.prefer_manual = prefer;
138        self
139    }
140
141    pub fn max_retries(mut self, retries: u32) -> Self {
142        self.max_retries = retries;
143        self
144    }
145
146    pub fn timeout(mut self, seconds: u64) -> Self {
147        self.timeout_seconds = seconds;
148        self
149    }
150
151    pub fn user_agent(mut self, ua: &str) -> Self {
152        self.user_agent = Some(ua.to_string());
153        self
154    }
155
156    pub fn proxy(mut self, proxy_url: &str) -> Self {
157        self.proxy = Some(proxy_url.to_string());
158        self
159    }
160
161    pub fn clean_content(mut self, clean: bool) -> Self {
162        self.clean_content = clean;
163        self
164    }
165
166    pub fn validate_timing(mut self, validate: bool) -> Self {
167        self.validate_timing = validate;
168        self
169    }
170}
171
172/// Types of subtitle tracks
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub enum SubtitleTrackType {
175    /// Manually created subtitles
176    Manual,
177    /// Auto-generated by YouTube
178    AutoGenerated,
179    /// Community contributed
180    Community,
181}
182
183impl std::fmt::Display for SubtitleTrackType {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            SubtitleTrackType::Manual => write!(f, "manual"),
187            SubtitleTrackType::AutoGenerated => write!(f, "auto-generated"),
188            SubtitleTrackType::Community => write!(f, "community"),
189        }
190    }
191}
192
193/// Information about available subtitle tracks
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct SubtitleTrack {
196    pub language_code: String,
197    pub language_name: String,
198    pub track_type: SubtitleTrackType,
199    pub is_translatable: bool,
200    pub url: Option<String>,
201}
202
203impl SubtitleTrack {
204    pub fn new(
205        language_code: String,
206        language_name: String,
207        track_type: SubtitleTrackType,
208    ) -> Self {
209        Self {
210            language_code,
211            language_name,
212            track_type,
213            is_translatable: false,
214            url: None,
215        }
216    }
217
218    pub fn with_url(mut self, url: String) -> Self {
219        self.url = Some(url);
220        self
221    }
222
223    pub fn with_translatable(mut self, translatable: bool) -> Self {
224        self.is_translatable = translatable;
225        self
226    }
227}
228
229/// Result of a subtitle download operation
230#[derive(Debug, Clone)]
231pub struct SubtitleResult {
232    pub content: String,
233    pub format: SubtitleType,
234    pub language: String,
235    pub track_type: SubtitleTrackType,
236}
237
238impl SubtitleResult {
239    pub fn new(
240        content: String,
241        format: SubtitleType,
242        language: String,
243        track_type: SubtitleTrackType,
244    ) -> Self {
245        Self {
246            content,
247            format,
248            language,
249            track_type,
250        }
251    }
252}
253
254/// Video metadata information
255#[derive(Debug, Clone, Serialize, Deserialize, Default)]
256pub struct VideoMetadata {
257    pub video_id: String,
258    pub title: String,
259    pub duration: Option<Duration>,
260    pub available_subtitles: Vec<SubtitleTrack>,
261}
262
263impl VideoMetadata {
264    pub fn new(video_id: String, title: String) -> Self {
265        Self {
266            video_id,
267            title,
268            duration: None,
269            available_subtitles: Vec::new(),
270        }
271    }
272
273    pub fn with_duration(mut self, duration: Duration) -> Self {
274        self.duration = Some(duration);
275        self
276    }
277
278    pub fn with_subtitles(mut self, subtitles: Vec<SubtitleTrack>) -> Self {
279        self.available_subtitles = subtitles;
280        self
281    }
282}
283
284/// Internal representation of YouTube video page data
285#[derive(Debug, Deserialize)]
286pub struct PlayerResponse {
287    pub captions: Option<CaptionTracks>,
288    #[serde(rename = "videoDetails")]
289    pub video_details: Option<VideoDetails>,
290}
291
292/// Caption tracks from YouTube player response
293#[derive(Debug, Deserialize)]
294pub struct CaptionTracks {
295    #[serde(rename = "playerCaptionsTracklistRenderer")]
296    pub player_captions_tracklist_renderer: Option<TrackListRenderer>,
297}
298
299/// Track list renderer from YouTube captions
300#[derive(Debug, Deserialize)]
301pub struct TrackListRenderer {
302    #[serde(rename = "captionTracks")]
303    pub caption_tracks: Option<Vec<CaptionTrack>>,
304    #[serde(rename = "audioTracks")]
305    pub audio_tracks: Option<Vec<AudioTrack>>,
306}
307
308/// Individual caption track
309#[derive(Debug, Deserialize)]
310pub struct CaptionTrack {
311    #[serde(rename = "baseUrl")]
312    pub base_url: String,
313    #[serde(rename = "languageCode")]
314    pub language_code: String,
315    pub name: Option<CaptionTrackName>,
316    #[serde(rename = "vssId")]
317    pub vss_id: String,
318    #[serde(rename = "isTranslatable")]
319    pub is_translatable: Option<bool>,
320    pub kind: Option<String>,
321}
322
323/// Caption track name
324#[derive(Debug, Deserialize)]
325pub struct CaptionTrackName {
326    #[serde(rename = "simpleText")]
327    pub simple_text: Option<String>,
328    pub runs: Option<Vec<Run>>,
329}
330
331/// Text run in caption track name
332#[derive(Debug, Deserialize)]
333pub struct Run {
334    pub text: String,
335}
336
337/// Audio track information
338#[derive(Debug, Deserialize)]
339pub struct AudioTrack {
340    #[serde(rename = "captionTrackIndices")]
341    pub caption_track_indices: Option<Vec<i32>>,
342}
343
344/// Video details from player response
345#[derive(Debug, Deserialize)]
346pub struct VideoDetails {
347    #[serde(rename = "videoId")]
348    pub video_id: String,
349    pub title: String,
350    #[serde(rename = "lengthSeconds")]
351    pub length_seconds: Option<String>,
352    #[serde(rename = "isLiveContent")]
353    pub is_live_content: Option<bool>,
354}
355
356/// Subtitle entry for timing and text
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct SubtitleEntry {
359    pub start: Duration,
360    pub end: Duration,
361    pub text: String,
362}
363
364impl SubtitleEntry {
365    pub fn new(start: Duration, end: Duration, text: String) -> Self {
366        Self { start, end, text }
367    }
368
369    /// Get duration of this subtitle entry
370    pub fn duration(&self) -> Duration {
371        self.end.saturating_sub(self.start)
372    }
373
374    /// Format start time as SRT timestamp
375    pub fn start_as_srt(&self) -> String {
376        format_duration_as_srt(self.start)
377    }
378
379    /// Format end time as SRT timestamp
380    pub fn end_as_srt(&self) -> String {
381        format_duration_as_srt(self.end)
382    }
383
384    /// Format start time as VTT timestamp
385    pub fn start_as_vtt(&self) -> String {
386        format_duration_as_vtt(self.start)
387    }
388
389    /// Format end time as VTT timestamp
390    pub fn end_as_vtt(&self) -> String {
391        format_duration_as_vtt(self.end)
392    }
393}
394
395/// Parsed subtitle data
396#[derive(Debug, Clone)]
397pub struct ParsedSubtitles {
398    pub entries: Vec<SubtitleEntry>,
399    pub language: String,
400    pub original_format: SubtitleType,
401}
402
403impl ParsedSubtitles {
404    pub fn new(entries: Vec<SubtitleEntry>, language: String) -> Self {
405        Self {
406            entries,
407            language,
408            original_format: SubtitleType::Raw,
409        }
410    }
411
412    pub fn with_format(mut self, format: SubtitleType) -> Self {
413        self.original_format = format;
414        self
415    }
416
417    /// Get total duration of subtitles
418    pub fn total_duration(&self) -> Duration {
419        self.entries
420            .last()
421            .map(|e| e.end)
422            .unwrap_or_else(|| Duration::from_secs(0))
423    }
424
425    /// Get number of subtitle entries
426    pub fn entry_count(&self) -> usize {
427        self.entries.len()
428    }
429}
430
431/// Format duration as SRT timestamp (HH:MM:SS,mmm)
432fn format_duration_as_srt(duration: Duration) -> String {
433    let total_secs = duration.as_secs();
434    let hours = total_secs / 3600;
435    let minutes = (total_secs % 3600) / 60;
436    let seconds = total_secs % 60;
437    let millis = duration.subsec_millis();
438
439    format!("{:02}:{:02}:{:02},{:03}", hours, minutes, seconds, millis)
440}
441
442/// Format duration as VTT timestamp (HH:MM:SS.mmm)
443fn format_duration_as_vtt(duration: Duration) -> String {
444    let total_secs = duration.as_secs();
445    let hours = total_secs / 3600;
446    let minutes = (total_secs % 3600) / 60;
447    let seconds = total_secs % 60;
448    let millis = duration.subsec_millis();
449
450    format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_subtitle_type_from_str() {
459        assert_eq!("srt".parse::<SubtitleType>().unwrap(), SubtitleType::Srt);
460        assert_eq!("vtt".parse::<SubtitleType>().unwrap(), SubtitleType::Vtt);
461        assert_eq!("txt".parse::<SubtitleType>().unwrap(), SubtitleType::Txt);
462        assert_eq!("json".parse::<SubtitleType>().unwrap(), SubtitleType::Json);
463        assert_eq!("raw".parse::<SubtitleType>().unwrap(), SubtitleType::Raw);
464        assert_eq!("xml".parse::<SubtitleType>().unwrap(), SubtitleType::Raw);
465
466        assert!("invalid".parse::<SubtitleType>().is_err());
467    }
468
469    #[test]
470    fn test_subtitle_type_extensions() {
471        assert_eq!(SubtitleType::Srt.extension(), "srt");
472        assert_eq!(SubtitleType::Vtt.extension(), "vtt");
473        assert_eq!(SubtitleType::Txt.extension(), "txt");
474        assert_eq!(SubtitleType::Json.extension(), "json");
475        assert_eq!(SubtitleType::Raw.extension(), "xml");
476    }
477
478    #[test]
479    fn test_ydl_options_builder() {
480        let options = YdlOptions::new()
481            .language("en")
482            .timeout(60)
483            .allow_auto_generated(false)
484            .user_agent("custom-agent");
485
486        assert_eq!(options.language, Some("en".to_string()));
487        assert_eq!(options.timeout_seconds, 60);
488        assert!(!options.allow_auto_generated);
489        assert_eq!(options.user_agent, Some("custom-agent".to_string()));
490    }
491
492    #[test]
493    fn test_subtitle_entry_timing() {
494        let entry = SubtitleEntry::new(
495            Duration::from_secs(1),
496            Duration::from_millis(3500),
497            "Test subtitle".to_string(),
498        );
499
500        assert_eq!(entry.duration(), Duration::from_millis(2500));
501        assert_eq!(entry.start_as_srt(), "00:00:01,000");
502        assert_eq!(entry.end_as_srt(), "00:00:03,500");
503        assert_eq!(entry.start_as_vtt(), "00:00:01.000");
504        assert_eq!(entry.end_as_vtt(), "00:00:03.500");
505    }
506
507    #[test]
508    fn test_duration_formatting() {
509        let duration = Duration::from_secs(3661) + Duration::from_millis(250);
510        assert_eq!(format_duration_as_srt(duration), "01:01:01,250");
511        assert_eq!(format_duration_as_vtt(duration), "01:01:01.250");
512    }
513
514    #[test]
515    fn test_parsed_subtitles() {
516        let entries = vec![
517            SubtitleEntry::new(
518                Duration::from_secs(0),
519                Duration::from_secs(2),
520                "First".to_string(),
521            ),
522            SubtitleEntry::new(
523                Duration::from_secs(2),
524                Duration::from_secs(5),
525                "Second".to_string(),
526            ),
527        ];
528
529        let subtitles = ParsedSubtitles::new(entries, "en".to_string());
530        assert_eq!(subtitles.entry_count(), 2);
531        assert_eq!(subtitles.total_duration(), Duration::from_secs(5));
532        assert_eq!(subtitles.language, "en");
533    }
534}