autorip 0.1.1

Composes other programs to automatically rip optical media
Documentation
use std::{
    borrow::Cow,
    path::{Path, PathBuf},
    process::Command,
};

use anyhow::{anyhow, bail, Result};
use regex::Regex;

use super::{capture_group_as_str, get_stderr, title_from_label, Preset};

pub(crate) struct Dvd {
    pub(crate) path: PathBuf,
    handbrake_info: Option<String>,
    pub(crate) duration: Option<u32>,
    pub(crate) title_hint: String,
}

impl Dvd {
    pub(crate) fn new(path: impl Into<PathBuf>) -> Result<Self> {
        let path = path.into();
        let handbrake_info = Dvd::handbrake_info(&path).ok().map(String::from);
        let duration =
            handbrake_info.as_ref().and_then(|i| Dvd::duration(i).ok());
        let title_hint = title_from_label(&path).map(String::from)?;
        Ok(Self {
            path,
            handbrake_info,
            duration,
            title_hint,
        })
    }

    pub(crate) fn handbrake_info(path: &Path) -> Result<String> {
        get_stderr(
            Command::new("HandBrakeCLI")
                .args("--scan --main-feature".split_whitespace())
                .arg("--input")
                .arg(path)
                .output()?,
        )
        .map(String::from)
    }

    fn duration(handbrake_info: impl AsRef<str>) -> Result<u32> {
        // HandBrakeCLI --main-feature --scan --input /dev/sr1 &> scan.txt
        // awk '/Found main feature/ { flag=1 } flag && /duration/ {
        // duration=$NF; split(duration, dur, ":"); secs = dur[1] * 60 * 60 +
        // dur[2] * 60 + dur[3]; print secs; exit}' scan.txt
        let mut main_feature_flag = false;
        for line in handbrake_info.as_ref().lines() {
            if line.starts_with("Found main feature") {
                main_feature_flag = true;
            }
            if main_feature_flag && line.trim().starts_with("+ duration: ") {
                if let Some(duration) = line.split_whitespace().next_back() {
                    let mut d = duration.split(':');
                    let opt_as_u32 = |v: &str| v.parse::<u32>().ok();
                    if let (Some(hours), Some(minutes), Some(seconds)) = (
                        d.next().and_then(opt_as_u32),
                        d.next().and_then(opt_as_u32),
                        d.next().and_then(opt_as_u32),
                    ) {
                        return Ok(hours + minutes + seconds);
                    }
                }
            }
        }
        bail!("Unable to get duration")
    }

    fn subtitles(handbrake_info: &str) -> Result<Vec<String>> {
        let all_subtitles_re = Regex::new(r"(?mx) # set debug mode and multiline
        (?s:^\s+\+\ Main\ Feature$.*?) # with dotall, only match after ` + Main Feature` and consume extra lines
        (?:^\s+\+\ subtitle\ tracks:$\n) # only match after ` + subtitle tracks:`
        (?P<subtitles>(^\s+\+\ \d+,.*?$\n)*) # multiple lines starting with ` + 3,`
        ").expect("failed to compile all_subtitles_re");

        let each_subtitle_re = Regex::new(r"(?mx) # set debug mode
            \s+\+\ # spaces, plus, trailing space
            (?P<number>\d+),\ # `3, ` subtitle number, don't capture the comma or trailing space
            (?P<description>.+?$) # `English (Wide Screen) [VOBSUB]` take everything else until the newline
        ").expect("failed to compile each_subtitle_re");

        let pgs_subtitle_re = Regex::new(r".*\[PGS\]$")
            .expect("failed to compile pgs_subtitle_re");

        let all_subtitles =
            if let Some(m) = all_subtitles_re.captures(handbrake_info) {
                m.name("subtitles")
                    .ok_or_else(|| {
                        anyhow!("did not find capture group `subtitles`")
                    })?
                    .as_str()
            } else {
                return Ok(vec![]);
            };

        let mut subtitles = Vec::new();
        for cap in each_subtitle_re.captures_iter(all_subtitles) {
            let description = capture_group_as_str(&cap, "description")?;

            // https://handbrake.fr/docs/en/latest/advanced/subtitles.html
            // Can't pass through PGS into mp4 files
            if pgs_subtitle_re.is_match(description) {
                continue;
            }
            let sub_number: usize =
                capture_group_as_str(&cap, "number")?.parse()?;
            subtitles.push((sub_number, description));
        }
        Ok(subtitles.iter().map(|(u, _)| u.to_string()).collect())
    }

    pub(crate) fn rip(&self, preset: &Preset) -> Result<PathBuf> {
        let outfile = &self.title_hint;
        let subtitles: Cow<str> = self
            .handbrake_info
            .as_ref()
            .and_then(|i| Dvd::subtitles(i).ok())
            .map_or_else(
                || Cow::Borrowed("scan"),
                |subts| Cow::Owned(String::from("scan,") + &subts.join(",")),
            );

        Command::new("HandBrakeCLI")
            .args(
                "
        --native-language=eng
        --subtitle-default=1
        --subtitle-forced=1
        --main-feature
        --optimize
        "
                .split_whitespace(),
            )
            .arg("--subitle")
            .arg(&*subtitles)
            .arg("--preset")
            .arg(preset.to_string())
            .arg("--input")
            .arg(&self.path)
            .arg("--output")
            .arg(outfile)
            .status()?;
        Ok(outfile.into())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_subtitles() {
        let tests = [
            (
                include_str!(
                    "../../tests/files/Kill Bill Vol 2.handbrakeinfo"
                ),
                9,
            ),
            (include_str!("../../tests/files/Toy Story.handbrakeinfo"), 5),
            (
                include_str!(
                    "../../tests/files/The Art of Racing in the \
                     Rain.handbrakeinfo"
                ),
                9,
            ),
            (
                include_str!(
                    "../../tests/files/We Were Soldiers.handbrakeinfo"
                ),
                5,
            ),
            (
                include_str!(
                    "../../tests/files/Kill Bill FAKEPGS.handbrakeinfo"
                ),
                7,
            ),
        ];

        for test in tests {
            let subs = Dvd::subtitles(test.0).unwrap();
            assert_eq!(subs.len(), test.1);
        }
    }
}