Skip to main content

yt_dlp/model/types/
chapter.rs

1//! Chapter-related models.
2
3use std::fmt;
4use std::hash::{Hash, Hasher};
5
6use serde::{Deserialize, Serialize};
7
8/// Represents a chapter in a YouTube video.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Chapter {
11    /// The start time of the chapter in seconds.
12    pub start_time: f64,
13    /// The end time of the chapter in seconds.
14    pub end_time: f64,
15    /// The title of the chapter.
16    pub title: Option<String>,
17}
18
19impl Chapter {
20    /// Returns the duration of the chapter in seconds.
21    ///
22    /// # Returns
23    ///
24    /// The duration in seconds (end_time - start_time)
25    pub fn duration(&self) -> f64 {
26        self.end_time - self.start_time
27    }
28
29    /// Returns the duration in minutes.
30    ///
31    /// # Returns
32    ///
33    /// The duration in minutes
34    pub fn duration_minutes(&self) -> f64 {
35        self.duration() / 60.0
36    }
37
38    /// Checks if a given timestamp (in seconds) is within this chapter.
39    ///
40    /// # Arguments
41    ///
42    /// * `timestamp` - The timestamp in seconds to check
43    ///
44    /// # Returns
45    ///
46    /// `true` if the timestamp falls within this chapter's time range, `false` otherwise
47    pub fn contains_timestamp(&self, timestamp: f64) -> bool {
48        timestamp >= self.start_time && timestamp < self.end_time
49    }
50
51    /// Checks if the chapter has a title.
52    ///
53    /// # Returns
54    ///
55    /// `true` if the chapter has a title, `false` otherwise
56    pub fn has_title(&self) -> bool {
57        self.title.is_some()
58    }
59
60    /// Gets the chapter title or a default value.
61    ///
62    /// # Arguments
63    ///
64    /// * `default` - The default value to return if the chapter has no title
65    ///
66    /// # Returns
67    ///
68    /// The chapter title, or the provided default if no title is set
69    pub fn title_or<'a>(&'a self, default: &'a str) -> &'a str {
70        self.title.as_deref().unwrap_or(default)
71    }
72
73    /// Checks if the chapter title contains the given string (case-insensitive).
74    ///
75    /// # Arguments
76    ///
77    /// * `query` - The string to search for in the title
78    ///
79    /// # Returns
80    ///
81    /// Returns `true` if the title contains the query string, `false` otherwise
82    pub fn title_contains(&self, query: &str) -> bool {
83        self.title
84            .as_ref()
85            .is_some_and(|title| title.to_lowercase().contains(&query.to_lowercase()))
86    }
87
88    /// Checks if the chapter title matches the given string exactly (case-insensitive).
89    ///
90    /// # Arguments
91    ///
92    /// * `query` - The string to match against the title
93    ///
94    /// # Returns
95    ///
96    /// Returns `true` if the title matches exactly, `false` otherwise
97    pub fn title_matches(&self, query: &str) -> bool {
98        self.title
99            .as_ref()
100            .is_some_and(|title| title.to_lowercase() == query.to_lowercase())
101    }
102
103    /// Checks if the chapter title starts with the given string (case-insensitive).
104    ///
105    /// # Arguments
106    ///
107    /// * `prefix` - The prefix to check for
108    ///
109    /// # Returns
110    ///
111    /// Returns `true` if the title starts with the prefix, `false` otherwise
112    pub fn title_starts_with(&self, prefix: &str) -> bool {
113        self.title
114            .as_ref()
115            .is_some_and(|title| title.to_lowercase().starts_with(&prefix.to_lowercase()))
116    }
117
118    /// Checks if the chapter duration is within the given range (in seconds).
119    ///
120    /// # Arguments
121    ///
122    /// * `min_duration` - Minimum duration in seconds
123    /// * `max_duration` - Maximum duration in seconds
124    ///
125    /// # Returns
126    ///
127    /// Returns `true` if the chapter duration is within range, `false` otherwise
128    pub fn duration_in_range(&self, min_duration: f64, max_duration: f64) -> bool {
129        let duration = self.duration();
130        duration >= min_duration && duration <= max_duration
131    }
132}
133
134/// Helper struct for working with collections of chapters.
135pub struct ChapterList<'a> {
136    chapters: &'a [Chapter],
137}
138
139impl<'a> ChapterList<'a> {
140    /// Creates a new ChapterList from a slice of chapters.
141    pub fn new(chapters: &'a [Chapter]) -> Self {
142        Self { chapters }
143    }
144
145    /// Finds all chapters with titles containing the given query string.
146    ///
147    /// # Arguments
148    ///
149    /// * `query` - The string to search for
150    ///
151    /// # Returns
152    ///
153    /// Returns a vector of references to matching chapters
154    pub fn search_by_title(&self, query: &str) -> Vec<&'a Chapter> {
155        self.chapters
156            .iter()
157            .filter(|chapter| chapter.title_contains(query))
158            .collect()
159    }
160
161    /// Finds the first chapter with a title matching the query exactly.
162    ///
163    /// # Arguments
164    ///
165    /// * `title` - The exact title to search for
166    ///
167    /// # Returns
168    ///
169    /// Returns an Option containing a reference to the matching chapter
170    pub fn find_by_exact_title(&self, title: &str) -> Option<&'a Chapter> {
171        self.chapters.iter().find(|chapter| chapter.title_matches(title))
172    }
173
174    /// Finds all chapters with titles starting with the given prefix.
175    ///
176    /// # Arguments
177    ///
178    /// * `prefix` - The prefix to search for
179    ///
180    /// # Returns
181    ///
182    /// Returns a vector of references to matching chapters
183    pub fn find_by_title_prefix(&self, prefix: &str) -> Vec<&'a Chapter> {
184        self.chapters
185            .iter()
186            .filter(|chapter| chapter.title_starts_with(prefix))
187            .collect()
188    }
189
190    /// Finds the chapter containing the given timestamp.
191    ///
192    /// # Arguments
193    ///
194    /// * `timestamp` - The timestamp in seconds
195    ///
196    /// # Returns
197    ///
198    /// Returns an Option containing a reference to the chapter
199    pub fn find_by_timestamp(&self, timestamp: f64) -> Option<&'a Chapter> {
200        self.chapters
201            .iter()
202            .find(|chapter| chapter.contains_timestamp(timestamp))
203    }
204
205    /// Filters chapters by duration range.
206    ///
207    /// # Arguments
208    ///
209    /// * `min_duration` - Minimum duration in seconds
210    /// * `max_duration` - Maximum duration in seconds
211    ///
212    /// # Returns
213    ///
214    /// Returns a vector of references to matching chapters
215    pub fn filter_by_duration(&self, min_duration: f64, max_duration: f64) -> Vec<&'a Chapter> {
216        self.chapters
217            .iter()
218            .filter(|chapter| chapter.duration_in_range(min_duration, max_duration))
219            .collect()
220    }
221
222    /// Gets all chapters that have titles.
223    ///
224    /// # Returns
225    ///
226    /// Returns a vector of references to chapters with titles
227    pub fn with_titles(&self) -> Vec<&'a Chapter> {
228        self.chapters.iter().filter(|chapter| chapter.has_title()).collect()
229    }
230
231    /// Gets the total number of chapters.
232    pub fn count(&self) -> usize {
233        self.chapters.len()
234    }
235
236    /// Gets the total duration of all chapters in seconds.
237    pub fn total_duration(&self) -> f64 {
238        self.chapters.iter().map(|c| c.duration()).sum()
239    }
240
241    /// Validates the chapters for consistency and correctness.
242    ///
243    /// # Returns
244    ///
245    /// Returns a `ChapterValidation` result containing validation status and any issues found
246    pub fn validate(&self) -> ChapterValidation {
247        let mut errors = Vec::new();
248        let mut warnings = Vec::new();
249
250        if self.chapters.is_empty() {
251            return ChapterValidation {
252                is_valid: true,
253                errors,
254                warnings,
255            };
256        }
257
258        Self::validate_individual_chapters(self.chapters, &mut errors, &mut warnings);
259        Self::validate_chapter_ordering(self.chapters, &mut errors, &mut warnings);
260
261        ChapterValidation {
262            is_valid: errors.is_empty(),
263            errors,
264            warnings,
265        }
266    }
267
268    fn validate_individual_chapters(chapters: &[Chapter], errors: &mut Vec<String>, warnings: &mut Vec<String>) {
269        for (i, chapter) in chapters.iter().enumerate() {
270            if chapter.start_time < 0.0 {
271                errors.push(format!(
272                    "Chapter {} has negative start time: {:.2}s",
273                    i + 1,
274                    chapter.start_time
275                ));
276            }
277
278            if chapter.end_time < 0.0 {
279                errors.push(format!(
280                    "Chapter {} has negative end time: {:.2}s",
281                    i + 1,
282                    chapter.end_time
283                ));
284            }
285
286            if chapter.start_time >= chapter.end_time {
287                errors.push(format!(
288                    "Chapter {} has invalid time range: start ({:.2}s) >= end ({:.2}s)",
289                    i + 1,
290                    chapter.start_time,
291                    chapter.end_time
292                ));
293            }
294
295            if !chapter.has_title() {
296                warnings.push(format!("Chapter {} has no title", i + 1));
297            }
298
299            if chapter.duration() < 1.0 {
300                warnings.push(format!("Chapter {} is very short ({:.2}s)", i + 1, chapter.duration()));
301            }
302        }
303    }
304
305    fn validate_chapter_ordering(chapters: &[Chapter], errors: &mut Vec<String>, warnings: &mut Vec<String>) {
306        for i in 0..chapters.len().saturating_sub(1) {
307            let current = &chapters[i];
308            let next = &chapters[i + 1];
309
310            if current.start_time > next.start_time {
311                errors.push(format!(
312                    "Chapters {} and {} are out of order (current starts at {:.2}s, next starts at {:.2}s)",
313                    i + 1,
314                    i + 2,
315                    current.start_time,
316                    next.start_time
317                ));
318            }
319
320            if current.end_time > next.start_time {
321                errors.push(format!(
322                    "Chapters {} and {} overlap (current ends at {:.2}s, next starts at {:.2}s)",
323                    i + 1,
324                    i + 2,
325                    current.end_time,
326                    next.start_time
327                ));
328            }
329
330            if current.end_time < next.start_time {
331                let gap = next.start_time - current.end_time;
332                if gap > 0.1 {
333                    warnings.push(format!(
334                        "Gap of {:.2}s between chapters {} and {} ({:.2}s to {:.2}s)",
335                        gap,
336                        i + 1,
337                        i + 2,
338                        current.end_time,
339                        next.start_time
340                    ));
341                }
342            }
343        }
344    }
345
346    /// Checks if the chapters are in chronological order.
347    pub fn is_sorted(&self) -> bool {
348        self.chapters
349            .windows(2)
350            .all(|pair| pair[0].start_time <= pair[1].start_time)
351    }
352
353    /// Checks if any chapters overlap.
354    pub fn has_overlaps(&self) -> bool {
355        self.chapters
356            .windows(2)
357            .any(|pair| pair[0].end_time > pair[1].start_time)
358    }
359}
360
361/// Result of chapter validation.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct ChapterValidation {
364    /// Whether the chapters are valid (no errors)
365    pub is_valid: bool,
366    /// List of validation errors found
367    pub errors: Vec<String>,
368    /// List of validation warnings (non-critical issues)
369    pub warnings: Vec<String>,
370}
371
372impl ChapterValidation {
373    /// Creates a validation result indicating success.
374    pub fn valid() -> Self {
375        Self {
376            is_valid: true,
377            errors: Vec::new(),
378            warnings: Vec::new(),
379        }
380    }
381
382    /// Creates a validation result indicating failure.
383    pub fn invalid(errors: Vec<String>) -> Self {
384        Self {
385            is_valid: false,
386            errors,
387            warnings: Vec::new(),
388        }
389    }
390
391    /// Adds a warning to the validation result.
392    pub fn with_warning(mut self, warning: String) -> Self {
393        self.warnings.push(warning);
394        self
395    }
396
397    /// Adds multiple warnings to the validation result.
398    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
399        self.warnings.extend(warnings);
400        self
401    }
402
403    /// Returns whether there are any errors or warnings.
404    pub fn has_issues(&self) -> bool {
405        !self.errors.is_empty() || !self.warnings.is_empty()
406    }
407}
408
409// Implementation of the Display trait for Chapter
410impl fmt::Display for Chapter {
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        write!(
413            f,
414            "Chapter(start={:.2}s, end={:.2}s, title={:?})",
415            self.start_time,
416            self.end_time,
417            self.title.as_deref().unwrap_or("untitled")
418        )
419    }
420}
421
422impl fmt::Display for ChapterValidation {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        write!(
425            f,
426            "ChapterValidation(valid={}, errors={}, warnings={})",
427            self.is_valid,
428            self.errors.len(),
429            self.warnings.len()
430        )
431    }
432}
433
434impl PartialEq for Chapter {
435    fn eq(&self, other: &Self) -> bool {
436        self.start_time.to_bits() == other.start_time.to_bits()
437            && self.end_time.to_bits() == other.end_time.to_bits()
438            && self.title == other.title
439    }
440}
441
442// Implementation of Hash for Chapter
443impl Hash for Chapter {
444    fn hash<H: Hasher>(&self, state: &mut H) {
445        // Use ordered float for hashing
446        self.start_time.to_bits().hash(state);
447        self.end_time.to_bits().hash(state);
448        self.title.hash(state);
449    }
450}