Skip to main content

ff_format/
chapter.rs

1//! Chapter information.
2//!
3//! This module provides the [`ChapterInfo`] struct for representing chapter
4//! markers within a media container (e.g., MKV, MP4, M4A).
5//!
6//! # Examples
7//!
8//! ```
9//! use ff_format::chapter::ChapterInfo;
10//! use ff_format::Rational;
11//! use std::time::Duration;
12//!
13//! let chapter = ChapterInfo::builder()
14//!     .id(1)
15//!     .title("Opening")
16//!     .start(Duration::from_secs(0))
17//!     .end(Duration::from_secs(120))
18//!     .time_base(Rational::new(1, 1000))
19//!     .build();
20//!
21//! assert_eq!(chapter.id(), 1);
22//! assert_eq!(chapter.title(), Some("Opening"));
23//! assert_eq!(chapter.duration(), Duration::from_secs(120));
24//! ```
25
26use std::collections::HashMap;
27use std::time::Duration;
28
29use crate::time::Rational;
30
31/// Information about a chapter within a media file.
32///
33/// Chapters are discrete, named segments within a container (e.g., a chapter in
34/// an audiobook or a scene in a movie). Each chapter has a start and end time,
35/// and optionally a title and other metadata tags.
36///
37/// # Construction
38///
39/// Use [`ChapterInfo::builder()`] for fluent construction:
40///
41/// ```
42/// use ff_format::chapter::ChapterInfo;
43/// use std::time::Duration;
44///
45/// let chapter = ChapterInfo::builder()
46///     .id(0)
47///     .title("Intro")
48///     .start(Duration::ZERO)
49///     .end(Duration::from_secs(30))
50///     .build();
51/// ```
52#[derive(Debug, Clone)]
53pub struct ChapterInfo {
54    /// Chapter ID as reported by the container (`AVChapter.id`).
55    id: i64,
56    /// Chapter title from the "title" metadata tag, if present.
57    title: Option<String>,
58    /// Chapter start time.
59    start: Duration,
60    /// Chapter end time.
61    end: Duration,
62    /// Container time base for this chapter.
63    ///
64    /// Useful when sub-`Duration` precision is required. `None` if the
65    /// time base had a zero denominator (invalid/unset).
66    time_base: Option<Rational>,
67    /// All metadata tags except "title" (which is surfaced via [`ChapterInfo::title`]).
68    ///
69    /// `None` if the chapter had no metadata dictionary or all tags were filtered out.
70    metadata: Option<HashMap<String, String>>,
71}
72
73impl ChapterInfo {
74    /// Creates a new builder for constructing `ChapterInfo`.
75    #[must_use]
76    pub fn builder() -> ChapterInfoBuilder {
77        ChapterInfoBuilder::default()
78    }
79
80    /// Returns the chapter ID.
81    #[must_use]
82    #[inline]
83    pub fn id(&self) -> i64 {
84        self.id
85    }
86
87    /// Returns the chapter title, if available.
88    #[must_use]
89    #[inline]
90    pub fn title(&self) -> Option<&str> {
91        self.title.as_deref()
92    }
93
94    /// Returns the chapter start time.
95    #[must_use]
96    #[inline]
97    pub fn start(&self) -> Duration {
98        self.start
99    }
100
101    /// Returns the chapter end time.
102    #[must_use]
103    #[inline]
104    pub fn end(&self) -> Duration {
105        self.end
106    }
107
108    /// Returns the chapter time base, if available.
109    #[must_use]
110    #[inline]
111    pub fn time_base(&self) -> Option<Rational> {
112        self.time_base
113    }
114
115    /// Returns the chapter metadata tags (excluding "title"), if any.
116    #[must_use]
117    #[inline]
118    pub fn metadata(&self) -> Option<&HashMap<String, String>> {
119        self.metadata.as_ref()
120    }
121
122    /// Returns `true` if the chapter has a title.
123    #[must_use]
124    #[inline]
125    pub fn has_title(&self) -> bool {
126        self.title.is_some()
127    }
128
129    /// Returns the chapter duration (`end − start`).
130    ///
131    /// Uses saturating subtraction so that malformed chapters where `end < start`
132    /// return [`Duration::ZERO`] instead of panicking.
133    #[must_use]
134    #[inline]
135    pub fn duration(&self) -> Duration {
136        self.end.saturating_sub(self.start)
137    }
138}
139
140impl Default for ChapterInfo {
141    fn default() -> Self {
142        Self {
143            id: 0,
144            title: None,
145            start: Duration::ZERO,
146            end: Duration::ZERO,
147            time_base: None,
148            metadata: None,
149        }
150    }
151}
152
153/// Builder for constructing [`ChapterInfo`].
154///
155/// # Examples
156///
157/// ```
158/// use ff_format::chapter::ChapterInfo;
159/// use ff_format::Rational;
160/// use std::time::Duration;
161///
162/// let chapter = ChapterInfo::builder()
163///     .id(2)
164///     .title("Act I")
165///     .start(Duration::from_secs(120))
166///     .end(Duration::from_secs(480))
167///     .time_base(Rational::new(1, 1000))
168///     .build();
169///
170/// assert_eq!(chapter.title(), Some("Act I"));
171/// assert_eq!(chapter.duration(), Duration::from_secs(360));
172/// ```
173#[derive(Debug, Clone, Default)]
174pub struct ChapterInfoBuilder {
175    id: i64,
176    title: Option<String>,
177    start: Duration,
178    end: Duration,
179    time_base: Option<Rational>,
180    metadata: Option<HashMap<String, String>>,
181}
182
183impl ChapterInfoBuilder {
184    /// Sets the chapter ID.
185    #[must_use]
186    pub fn id(mut self, id: i64) -> Self {
187        self.id = id;
188        self
189    }
190
191    /// Sets the chapter title.
192    #[must_use]
193    pub fn title(mut self, title: impl Into<String>) -> Self {
194        self.title = Some(title.into());
195        self
196    }
197
198    /// Sets the chapter start time.
199    #[must_use]
200    pub fn start(mut self, start: Duration) -> Self {
201        self.start = start;
202        self
203    }
204
205    /// Sets the chapter end time.
206    #[must_use]
207    pub fn end(mut self, end: Duration) -> Self {
208        self.end = end;
209        self
210    }
211
212    /// Sets the chapter time base.
213    #[must_use]
214    pub fn time_base(mut self, time_base: Rational) -> Self {
215        self.time_base = Some(time_base);
216        self
217    }
218
219    /// Sets the chapter metadata (tags other than "title").
220    #[must_use]
221    pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
222        self.metadata = Some(metadata);
223        self
224    }
225
226    /// Builds the [`ChapterInfo`].
227    #[must_use]
228    pub fn build(self) -> ChapterInfo {
229        ChapterInfo {
230            id: self.id,
231            title: self.title,
232            start: self.start,
233            end: self.end,
234            time_base: self.time_base,
235            metadata: self.metadata,
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn chapter_info_builder_should_set_all_fields() {
246        let mut meta = HashMap::new();
247        meta.insert("language".to_string(), "eng".to_string());
248
249        let info = ChapterInfo::builder()
250            .id(3)
251            .title("Intro")
252            .start(Duration::from_secs(0))
253            .end(Duration::from_secs(60))
254            .time_base(Rational::new(1, 1000))
255            .metadata(meta)
256            .build();
257
258        assert_eq!(info.id(), 3);
259        assert_eq!(info.title(), Some("Intro"));
260        assert_eq!(info.start(), Duration::ZERO);
261        assert_eq!(info.end(), Duration::from_secs(60));
262        assert_eq!(info.time_base(), Some(Rational::new(1, 1000)));
263        assert_eq!(info.metadata().unwrap()["language"], "eng");
264    }
265
266    #[test]
267    fn chapter_info_duration_should_return_end_minus_start() {
268        let info = ChapterInfo::builder()
269            .start(Duration::from_secs(10))
270            .end(Duration::from_secs(70))
271            .build();
272
273        assert_eq!(info.duration(), Duration::from_secs(60));
274    }
275
276    #[test]
277    fn chapter_info_duration_should_return_zero_when_end_before_start() {
278        let info = ChapterInfo::builder()
279            .start(Duration::from_secs(70))
280            .end(Duration::from_secs(10))
281            .build();
282
283        assert_eq!(info.duration(), Duration::ZERO);
284    }
285
286    #[test]
287    fn chapter_info_with_no_title_should_return_none() {
288        let info = ChapterInfo::builder().id(1).build();
289
290        assert_eq!(info.title(), None);
291        assert!(!info.has_title());
292    }
293
294    #[test]
295    fn chapter_info_with_title_should_have_title() {
296        let info = ChapterInfo::builder().title("Chapter One").build();
297
298        assert_eq!(info.title(), Some("Chapter One"));
299        assert!(info.has_title());
300    }
301
302    #[test]
303    fn chapter_info_default_should_have_zero_times() {
304        let info = ChapterInfo::default();
305
306        assert_eq!(info.id(), 0);
307        assert_eq!(info.start(), Duration::ZERO);
308        assert_eq!(info.end(), Duration::ZERO);
309        assert!(info.title().is_none());
310        assert!(info.time_base().is_none());
311        assert!(info.metadata().is_none());
312    }
313
314    #[test]
315    fn chapter_info_builder_without_metadata_should_return_none() {
316        let info = ChapterInfo::builder().id(1).title("Test").build();
317
318        assert!(info.metadata().is_none());
319    }
320
321    #[test]
322    fn chapter_info_builder_clone_should_produce_equal_instance() {
323        let builder = ChapterInfo::builder()
324            .id(5)
325            .title("Cloned")
326            .start(Duration::from_secs(100))
327            .end(Duration::from_secs(200));
328
329        let info1 = builder.clone().build();
330        let info2 = builder.build();
331
332        assert_eq!(info1.id(), info2.id());
333        assert_eq!(info1.title(), info2.title());
334        assert_eq!(info1.start(), info2.start());
335        assert_eq!(info1.end(), info2.end());
336    }
337}