1use std::{
4 collections::BTreeMap,
5 ffi::OsStr,
6 path::{Path, PathBuf},
7 process::Command,
8 result,
9 str::{from_utf8, FromStr},
10};
11
12use anyhow::{anyhow, Context as _};
13use cast;
14use indicatif::ProgressBar;
15use log::debug;
16use num::rational::Ratio;
17use regex::Regex;
18use serde::{de, Deserialize, Deserializer};
19use serde_json;
20
21use crate::{
22 errors::RunCommandError, lang::Lang, progress::default_progress_style,
23 time::Period, Result,
24};
25
26#[derive(Debug, Default)]
28#[allow(missing_docs)]
29pub struct Id3Metadata {
30 pub genre: Option<String>,
31 pub artist: Option<String>,
32 pub album: Option<String>,
33 pub track_number: Option<(usize, usize)>,
34 pub track_name: Option<String>,
35 pub lyrics: Option<String>,
36}
37
38impl Id3Metadata {
39 fn add_args(&self, cmd: &mut Command) {
40 if let Some(ref genre) = self.genre {
41 cmd.arg("-metadata").arg(format!("genre={}", genre));
42 }
43 if let Some(ref artist) = self.artist {
44 cmd.arg("-metadata").arg(format!("artist={}", artist));
45 }
46 if let Some(ref album) = self.album {
47 cmd.arg("-metadata").arg(format!("album={}", album));
48 }
49 if let Some((track, total)) = self.track_number {
50 cmd.arg("-metadata")
51 .arg(format!("track={}/{}", track, total));
52 }
53 if let Some(ref track_name) = self.track_name {
54 cmd.arg("-metadata").arg(format!("title={}", track_name));
55 }
56 if let Some(ref lyrics) = self.lyrics {
57 cmd.arg("-metadata").arg(format!("lyrics={}", lyrics));
58 }
59 }
60}
61
62#[derive(Debug, PartialEq, Eq)]
64#[allow(missing_docs)]
65pub enum CodecType {
66 Audio,
67 Video,
68 Subtitle,
69 Other(String),
70}
71
72impl<'de> Deserialize<'de> for CodecType {
73 fn deserialize<D: Deserializer<'de>>(d: D) -> result::Result<Self, D::Error> {
74 let s = String::deserialize(d)?;
75 match &s[..] {
76 "audio" => Ok(CodecType::Audio),
77 "video" => Ok(CodecType::Video),
78 "subtitle" => Ok(CodecType::Subtitle),
79 s => Ok(CodecType::Other(s.to_owned())),
80 }
81 }
82}
83
84#[derive(Debug)]
86pub struct Fraction(Ratio<u32>);
87
88impl Fraction {
89 fn deserialize_parts<'de, D>(d: D) -> result::Result<(u32, u32), D::Error>
90 where
91 D: Deserializer<'de>,
92 {
93 let s = String::deserialize(d)?;
94 let re = Regex::new(r"^(\d+)/(\d+)$").unwrap();
95 let cap = re.captures(&s).ok_or_else(|| {
96 <D::Error as de::Error>::custom(format!("Expected fraction: {}", &s))
97 })?;
98 Ok((
99 FromStr::from_str(cap.get(1).unwrap().as_str()).unwrap(),
100 FromStr::from_str(cap.get(2).unwrap().as_str()).unwrap(),
101 ))
102 }
103}
104
105impl<'de> Deserialize<'de> for Fraction {
106 fn deserialize<D: Deserializer<'de>>(d: D) -> result::Result<Self, D::Error> {
107 let (num, denom) = Fraction::deserialize_parts(d)?;
108 if denom == 0 {
109 Err(<D::Error as de::Error>::custom(
110 "Found fraction with a denominator of 0",
111 ))
112 } else {
113 Ok(Fraction(Ratio::new(num, denom)))
114 }
115 }
116}
117
118#[derive(Debug, Deserialize)]
120#[allow(missing_docs)]
121pub struct Stream {
122 pub index: usize,
123 pub codec_type: CodecType,
124 tags: Option<BTreeMap<String, String>>,
125}
126
127impl Stream {
128 pub fn language(&self) -> Option<Lang> {
131 self.tags
132 .as_ref()
133 .and_then(|tags| tags.get("language"))
134 .and_then(|lang| Lang::iso639(lang).ok())
135 }
136}
137
138#[test]
139fn test_stream_decode() {
140 let json = "
141{
142 \"index\" : 2,
143 \"codec_name\" : \"aac\",
144 \"codec_long_name\" : \"AAC (Advanced Audio Coding)\",
145 \"codec_type\" : \"audio\",
146 \"codec_time_base\" : \"1/48000\",
147 \"codec_tag_string\" : \"[0][0][0][0]\",
148 \"codec_tag\" : \"0x0000\",
149 \"sample_rate\" : \"48000.000000\",
150 \"channels\" : 2,
151 \"bits_per_sample\" : 0,
152 \"avg_frame_rate\" : \"0/0\",
153 \"time_base\" : \"1/1000\",
154 \"start_time\" : \"0.000000\",
155 \"duration\" : \"N/A\",
156 \"tags\" : {
157 \"language\" : \"eng\"
158 }
159}
160";
161 let stream: Stream = serde_json::from_str(json).unwrap();
162 assert_eq!(CodecType::Audio, stream.codec_type);
163 assert_eq!(Some(Lang::iso639("en").unwrap()), stream.language())
164}
165
166pub enum ExtractionSpec {
169 Image(f32),
171 Audio(Option<usize>, Period, Id3Metadata),
173}
174
175impl ExtractionSpec {
176 fn earliest_time(&self) -> f32 {
178 match self {
179 &ExtractionSpec::Image(time) => time,
180 &ExtractionSpec::Audio(_, period, _) => period.begin(),
181 }
182 }
183
184 fn can_be_batched(&self) -> bool {
187 match self {
188 &ExtractionSpec::Image(_) => false,
192 _ => true,
193 }
194 }
195
196 fn add_args(&self, cmd: &mut Command, time_base: f32) {
200 match self {
201 &ExtractionSpec::Image(time) => {
202 let scale_filter =
203 format!("scale=iw*min(1\\,min({}/iw\\,{}/ih)):-1", 240, 160);
204 cmd.arg("-ss")
205 .arg(format!("{}", time - time_base))
206 .arg("-vframes")
207 .arg("1")
208 .arg("-filter_complex")
209 .arg(&scale_filter)
210 .arg("-f")
211 .arg("image2");
212 }
213 &ExtractionSpec::Audio(stream, period, ref metadata) => {
214 if let Some(sid) = stream {
215 cmd.arg("-map").arg(format!("0:{}", sid));
216 }
217 metadata.add_args(cmd);
218 cmd.arg("-ss")
219 .arg(format!("{}", period.begin() - time_base))
220 .arg("-t")
221 .arg(format!("{}", period.duration()));
222 }
223 }
224 }
225}
226
227pub struct Extraction {
229 pub path: PathBuf,
231 pub spec: ExtractionSpec,
233}
234
235impl Extraction {
236 fn add_args(&self, cmd: &mut Command, time_base: f32) {
238 self.spec.add_args(cmd, time_base);
239 cmd.arg(self.path.clone());
240 }
241}
242
243#[derive(Debug, Deserialize)]
245struct Metadata {
246 streams: Vec<Stream>,
247}
248
249#[derive(Debug)]
251pub struct Video {
252 path: PathBuf,
253 metadata: Metadata,
254}
255
256impl Video {
257 pub fn new(path: &Path) -> Result<Video> {
259 if !path.is_file() {
261 return Err(anyhow!("No such file {:?}", path.display()));
262 }
263
264 let mkerr = || RunCommandError::new("ffprobe");
266 let cmd = Command::new("ffprobe")
267 .arg("-v")
268 .arg("quiet")
269 .arg("-show_streams")
270 .arg("-of")
271 .arg("json")
272 .arg(path)
273 .output();
274 let output = cmd.with_context(mkerr)?;
275 let stdout = from_utf8(&output.stdout).with_context(mkerr)?;
276 debug!("Video metadata: {}", stdout);
277 let metadata = serde_json::from_str(stdout).with_context(mkerr)?;
278
279 Ok(Video {
280 path: path.to_owned(),
281 metadata: metadata,
282 })
283 }
284
285 pub fn file_name(&self) -> &OsStr {
287 self.path.file_name().unwrap()
288 }
289
290 pub fn file_stem(&self) -> &OsStr {
293 self.path.file_stem().unwrap()
294 }
295
296 pub fn streams(&self) -> &[Stream] {
298 &self.metadata.streams
299 }
300
301 pub fn audio_for(&self, lang: Lang) -> Option<usize> {
303 self.streams().iter().position(|s| {
304 s.codec_type == CodecType::Audio && s.language() == Some(lang)
305 })
306 }
307
308 fn extract_command(&self, time_base: f32) -> Command {
312 let mut cmd = Command::new("ffmpeg");
313 cmd.arg("-ss").arg(format!("{}", time_base));
314 cmd.arg("-i").arg(&self.path);
315 cmd
316 }
317
318 fn extract_one(&self, extraction: &Extraction) -> Result<()> {
320 let time_base = extraction.spec.earliest_time();
321 let mut cmd = self.extract_command(time_base);
322 extraction.add_args(&mut cmd, time_base);
323 cmd.output()
324 .with_context(|| RunCommandError::new("ffmpg"))?;
325 Ok(())
326 }
327
328 fn extract_batch(&self, extractions: &[&Extraction]) -> Result<()> {
331 if extractions.is_empty() {
333 return Ok(());
334 }
335 let time_base = extractions[0].spec.earliest_time();
336
337 let mut cmd = self.extract_command(time_base);
339 for e in extractions {
340 assert!(e.spec.can_be_batched());
341 e.add_args(&mut cmd, time_base);
342 }
343 cmd.output()
344 .with_context(|| RunCommandError::new("ffmpg"))?;
345 Ok(())
346 }
347
348 pub fn extract(&self, extractions: &[Extraction]) -> Result<()> {
352 let pb = ProgressBar::new(cast::u64(extractions.len()));
353 pb.set_style(default_progress_style());
354 pb.set_prefix("✂️ Extracting media");
355 pb.tick();
356
357 let mut batch: Vec<&Extraction> = vec![];
358 for e in extractions {
359 if e.spec.can_be_batched() {
360 batch.push(e);
361 } else {
362 self.extract_one(e)?;
363 pb.inc(1);
364 }
365 }
366
367 for chunk in batch.chunks(20) {
368 self.extract_batch(chunk)?;
369 pb.inc(cast::u64(chunk.len()));
370 }
371 Ok(())
372 }
373}