1use 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 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 self.add_metadata_with_format(&path, video, video_format, audio_format)
57 .await?;
58
59 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 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 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
109
110 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 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 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 remove_temp_file(&metadata_file).await;
157
158 let output = match output {
159 Ok(output) => output,
160 Err(e) => {
161 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 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 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 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 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 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 writeln!(file, ";FFMETADATA1").map_err(|e| Error::io("write metadata header", e))?;
304
305 for (idx, chapter) in chapters.iter().enumerate() {
307 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 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}