Skip to main content

yt_dlp/metadata/
chapters.rs

1//! Chapter metadata support using FFmpeg.
2//!
3//! This module provides functions to create and embed chapter markers
4//! in video files using FFmpeg metadata format.
5
6use std::fs;
7use std::io::Write;
8use std::path::PathBuf;
9use std::time::Duration;
10
11use uuid::Uuid;
12
13use super::{BaseMetadata, MetadataManager};
14use crate::error::{Error, Result};
15use crate::executor::Executor;
16use crate::model::Video;
17use crate::model::chapter::Chapter;
18use crate::utils::fs::remove_temp_file;
19
20impl MetadataManager {
21    /// Add both regular metadata and chapters to a video file.
22    ///
23    /// This is a convenience method that combines `add_metadata_with_format` and
24    /// `add_chapters_metadata` in a single operation.
25    ///
26    /// # Arguments
27    ///
28    /// * `file_path` - Path to the video file
29    /// * `video` - The video metadata
30    /// * `video_format` - Optional video format for technical metadata
31    /// * `audio_format` - Optional audio format for technical metadata
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if metadata or chapters cannot be added
36    pub async fn add_metadata_with_chapters(
37        &self,
38        file_path: impl Into<PathBuf>,
39        video: &Video,
40        video_format: Option<&crate::model::format::Format>,
41        audio_format: Option<&crate::model::format::Format>,
42    ) -> Result<()> {
43        let path: PathBuf = file_path.into();
44
45        tracing::debug!(
46            file_path = ?path,
47            video_id = %video.id,
48            has_chapters = !video.chapters.is_empty(),
49            chapter_count = video.chapters.len(),
50            has_video_format = video_format.is_some(),
51            has_audio_format = audio_format.is_some(),
52            "🏷️ Adding metadata with chapters"
53        );
54
55        // First add regular metadata
56        self.add_metadata_with_format(&path, video, video_format, audio_format)
57            .await?;
58
59        // Then add chapters if available
60        if !video.chapters.is_empty() {
61            self.add_chapters_metadata(&path, &video.chapters).await?;
62        }
63
64        tracing::debug!(
65            file_path = ?path,
66            video_id = %video.id,
67            "✅ Metadata with chapters added successfully"
68        );
69
70        Ok(())
71    }
72
73    /// Add chapters metadata to a video file using FFmpeg.
74    ///
75    /// This method embeds chapter markers into MP4/MKV/WebM files.
76    /// Chapters allow media players to navigate to specific sections of the video.
77    ///
78    /// # Arguments
79    ///
80    /// * `file_path` - Path to the video file
81    /// * `chapters` - The chapters to embed
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if FFmpeg fails or if the file cannot be processed
86    ///
87    /// # Returns
88    ///
89    /// Ok(()) if chapters were successfully embedded
90    pub async fn add_chapters_metadata(&self, file_path: impl Into<PathBuf>, chapters: &[Chapter]) -> Result<()> {
91        let path: PathBuf = file_path.into();
92
93        if chapters.is_empty() {
94            tracing::debug!(
95                file_path = ?path,
96                "🏷️ No chapters to add, skipping"
97            );
98            return Ok(());
99        }
100
101        tracing::debug!(
102            file_path = ?path,
103            chapter_count = chapters.len(),
104            "🏷️ Adding chapters to video file"
105        );
106
107        // Determine file extension
108        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
109
110        // Create temporary metadata file
111        let temp_metadata_path = std::env::temp_dir().join(format!("chapters_{}.txt", Uuid::new_v4()));
112
113        let chapters_clone = chapters.to_vec();
114        let metadata_path_clone = temp_metadata_path.clone();
115
116        let metadata_file = tokio::task::spawn_blocking(move || {
117            Self::create_chapters_metadata_file(&chapters_clone, metadata_path_clone)
118        })
119        .await
120        .map_err(|e| Error::runtime("create chapters metadata file", e))??;
121
122        // Create temporary output file
123        let temp_output_path = Self::create_temp_output_path(&path, extension)?;
124
125        let input_str = path
126            .to_str()
127            .ok_or_else(|| Error::path_validation(&path, "Invalid input path"))?;
128        let output_str = temp_output_path
129            .to_str()
130            .ok_or_else(|| Error::path_validation(&temp_output_path, "Invalid output path"))?;
131        let metadata_str = metadata_file
132            .to_str()
133            .ok_or_else(|| Error::path_validation(&metadata_file, "Invalid metadata path"))?;
134
135        // Build FFmpeg command — preserve global metadata from input 0, add chapters from input 1
136        let ffmpeg_args = crate::executor::FfmpegArgs::new()
137            .input(input_str)
138            .input(metadata_str)
139            .args(["-map_metadata", "0", "-map_chapters", "1"])
140            .codec_copy()
141            .output(output_str)
142            .build();
143
144        tracing::debug!(
145            file_path = ?path,
146            metadata_file = ?metadata_file,
147            arg_count = ffmpeg_args.len(),
148            "✂️ Running FFmpeg to embed chapters"
149        );
150
151        let executor = Executor::new(self.ffmpeg_path.clone(), ffmpeg_args, Duration::from_secs(120));
152
153        let output = executor.execute().await;
154
155        // Clean up temporary metadata file regardless of outcome
156        remove_temp_file(&metadata_file).await;
157
158        let output = match output {
159            Ok(output) => output,
160            Err(e) => {
161                // Clean up temp output on execution failure
162                if temp_output_path.exists() {
163                    remove_temp_file(&temp_output_path).await;
164                }
165                return Err(e);
166            }
167        };
168
169        if !output.code.eq(&0) {
170            if temp_output_path.exists() {
171                remove_temp_file(&temp_output_path).await;
172            }
173            return Err(Error::CommandFailed {
174                command: "ffmpeg".to_string(),
175                exit_code: output.code,
176                stderr: output.stderr,
177            });
178        }
179
180        // Replace original file with the one containing chapters
181        tokio::fs::rename(&temp_output_path, &path)
182            .await
183            .map_err(|e| Error::io_with_path("replace original file with chapters", &path, e))?;
184
185        tracing::debug!(
186            file_path = ?path,
187            chapter_count = chapters.len(),
188            "✅ Chapters added successfully"
189        );
190
191        Ok(())
192    }
193
194    /// Creates a temporary FFMETADATA1 file containing both global metadata tags and chapters.
195    ///
196    /// The resulting file can be passed directly to `ffmpeg -i metadata.txt -map_metadata N
197    /// -map_chapters N` in the combine command, enabling a single-pass mux + embed.
198    ///
199    /// # Arguments
200    ///
201    /// * `video` - The video whose metadata and chapters to embed
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the temp file cannot be created or written
206    ///
207    /// # Returns
208    ///
209    /// Path to the created temporary FFMETADATA1 file
210    pub(crate) fn create_combined_metadata_file(video: &Video) -> Result<PathBuf> {
211        let temp_path = std::env::temp_dir().join(format!("metadata_{}.txt", Uuid::new_v4()));
212
213        tracing::debug!(
214            video_id = %video.id,
215            chapter_count = video.chapters.len(),
216            temp_path = ?temp_path,
217            "⚙️ Creating combined FFMETADATA1 file"
218        );
219
220        let mut file = fs::File::create(&temp_path)
221            .map_err(|e| Error::io_with_path("create combined metadata file", &temp_path, e))?;
222
223        writeln!(file, ";FFMETADATA1").map_err(|e| Error::io("write metadata header", e))?;
224
225        // Write global metadata tags
226        let metadata = Self::extract_basic_metadata(video);
227        for (key, value) in &metadata {
228            let escaped = value
229                .replace('\\', "\\\\")
230                .replace('=', "\\=")
231                .replace(';', "\\;")
232                .replace('#', "\\#")
233                .replace('\n', "\\n");
234            writeln!(file, "{}={}", key, escaped).map_err(|e| Error::io("write metadata entry", e))?;
235        }
236
237        // Write chapters (if any)
238        for (idx, chapter) in video.chapters.iter().enumerate() {
239            let start_us = (chapter.start_time * 1_000_000.0) as i64;
240            let end_us = (chapter.end_time * 1_000_000.0) as i64;
241
242            writeln!(file, "[CHAPTER]").map_err(|e| Error::io("write chapter marker", e))?;
243            writeln!(file, "TIMEBASE=1/1000000").map_err(|e| Error::io("write timebase", e))?;
244            writeln!(file, "START={}", start_us).map_err(|e| Error::io("write chapter start", e))?;
245            writeln!(file, "END={}", end_us).map_err(|e| Error::io("write chapter end", e))?;
246
247            if let Some(title) = &chapter.title {
248                let escaped = title
249                    .replace('\\', "\\\\")
250                    .replace('=', "\\=")
251                    .replace(';', "\\;")
252                    .replace('#', "\\#")
253                    .replace('\n', "\\n");
254                writeln!(file, "title={}", escaped).map_err(|e| Error::io("write chapter title", e))?;
255            } else {
256                writeln!(file, "title=Chapter {}", idx + 1).map_err(|e| Error::io("write default chapter title", e))?;
257            }
258        }
259
260        tracing::debug!(
261            temp_path = ?temp_path,
262            chapter_count = video.chapters.len(),
263            "✅ Combined FFMETADATA1 file created"
264        );
265
266        Ok(temp_path)
267    }
268
269    /// Create an FFmpeg metadata file with chapters.
270    ///
271    /// # Arguments
272    ///
273    /// * `chapters` - The chapters to write
274    /// * `output_path` - Path where to write the metadata file
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the file cannot be created or written
279    ///
280    /// # Returns
281    ///
282    /// Path to the created metadata file
283    pub(super) fn create_chapters_metadata_file(
284        chapters: &[Chapter],
285        output_path: impl Into<PathBuf>,
286    ) -> Result<PathBuf> {
287        let output_path: PathBuf = output_path.into();
288
289        {
290            let total_duration = chapters.last().map(|c| c.end_time).unwrap_or(0.0);
291            tracing::debug!(
292                output_path = ?output_path,
293                chapter_count = chapters.len(),
294                total_duration_secs = total_duration,
295                "⚙️ Creating chapters metadata file"
296            );
297        }
298
299        let mut file = fs::File::create(&output_path)
300            .map_err(|e| Error::io_with_path("create chapters metadata file", &output_path, e))?;
301
302        // Write FFmpeg metadata format header
303        writeln!(file, ";FFMETADATA1").map_err(|e| Error::io("write metadata header", e))?;
304
305        // Write each chapter
306        for (idx, chapter) in chapters.iter().enumerate() {
307            // Convert seconds to timebase (FFmpeg uses microseconds for chapters)
308            let start_us = (chapter.start_time * 1_000_000.0) as i64;
309            let end_us = (chapter.end_time * 1_000_000.0) as i64;
310
311            writeln!(file, "[CHAPTER]").map_err(|e| Error::io("write chapter marker", e))?;
312            writeln!(file, "TIMEBASE=1/1000000").map_err(|e| Error::io("write timebase", e))?;
313            writeln!(file, "START={}", start_us).map_err(|e| Error::io("write start time", e))?;
314            writeln!(file, "END={}", end_us).map_err(|e| Error::io("write end time", e))?;
315
316            if let Some(title) = &chapter.title {
317                // Escape special characters in title
318                let escaped_title = title
319                    .replace('\\', "\\\\")
320                    .replace('=', "\\=")
321                    .replace(';', "\\;")
322                    .replace('#', "\\#")
323                    .replace('\n', "\\n");
324                writeln!(file, "title={}", escaped_title).map_err(|e| Error::io("write chapter title", e))?;
325            } else {
326                writeln!(file, "title=Chapter {}", idx + 1).map_err(|e| Error::io("write default chapter title", e))?;
327            }
328        }
329
330        tracing::debug!(
331            output_path = ?output_path,
332            chapter_count = chapters.len(),
333            "✅ Chapters metadata file created"
334        );
335
336        Ok(output_path)
337    }
338}