use std::io::{self, BufRead, Write};
use std::process::ExitCode;
use anitomy_ng::{Element, Options};
use clap::Parser;
#[derive(Parser)]
#[command(
name = "anitomy",
version,
about = "Parse anime video filenames into their elements.",
long_about = "Parse anime video filenames into their elements.\n\n\
Provide filenames as arguments, or pipe them to stdin (one per line):\n\
\n \
anitomy '[Group] Title - 01 [1080p].mkv'\n \
ls *.mkv | anitomy --json"
)]
struct Cli {
#[arg(value_name = "FILENAME")]
filenames: Vec<String>,
#[arg(short, long)]
json: bool,
#[arg(short = 't', long)]
together: bool,
#[arg(short = 'p', long, conflicts_with = "together")]
path: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_episode: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_episode_title: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_file_checksum: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_file_extension: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_part: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_release_group: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_season: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_title: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_video_resolution: bool,
#[arg(long, help_heading = "Parsing toggles")]
no_year: bool,
}
impl Cli {
fn options(&self) -> Options {
Options {
parse_episode: !self.no_episode,
parse_episode_title: !self.no_episode_title,
parse_file_checksum: !self.no_file_checksum,
parse_file_extension: !self.no_file_extension,
parse_part: !self.no_part,
parse_release_group: !self.no_release_group,
parse_season: !self.no_season,
parse_title: !self.no_title,
parse_video_resolution: !self.no_video_resolution,
parse_year: !self.no_year,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let options = cli.options();
let inputs: Vec<String> = if cli.filenames.is_empty() {
match read_stdin_lines() {
Ok(lines) => lines,
Err(err) => {
eprintln!("anitomy: error reading stdin: {err}");
return ExitCode::FAILURE;
}
}
} else {
cli.filenames.clone()
};
let results: Vec<(String, Vec<Element>)> = if cli.together {
let parsed = parse_together_grouped(&inputs, options);
inputs.into_iter().zip(parsed).collect()
} else {
let parse = if cli.path {
anitomy_ng::parse_path
} else {
anitomy_ng::parse
};
inputs
.into_iter()
.map(|name| {
let elements = parse(&name, options);
(name, elements)
})
.collect()
};
let stdout = io::stdout();
let mut out = stdout.lock();
let render = if cli.json {
write_json(&mut out, &results)
} else {
write_table(&mut out, &results)
};
match render {
Ok(()) => ExitCode::SUCCESS,
Err(err) if err.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
Err(err) => {
eprintln!("anitomy: error writing output: {err}");
ExitCode::FAILURE
}
}
}
fn read_stdin_lines() -> io::Result<Vec<String>> {
let mut lines = Vec::new();
for line in io::stdin().lock().lines() {
let line = line?;
if !line.trim().is_empty() {
lines.push(line);
}
}
Ok(lines)
}
fn parse_together_grouped(inputs: &[String], options: Options) -> Vec<Vec<Element>> {
let mut groups: Vec<(&str, Vec<usize>)> = Vec::new();
for (i, name) in inputs.iter().enumerate() {
let key = dir_prefix(name);
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, indices)) => indices.push(i),
None => groups.push((key, vec![i])),
}
}
let mut results: Vec<Vec<Element>> = vec![Vec::new(); inputs.len()];
for (_, indices) in &groups {
let group: Vec<&str> = indices
.iter()
.filter_map(|&i| inputs.get(i))
.map(String::as_str)
.collect();
for (&i, elements) in indices
.iter()
.zip(anitomy_ng::parse_together(&group, options))
{
if let Some(slot) = results.get_mut(i) {
*slot = elements;
}
}
}
results
}
fn dir_prefix(name: &str) -> &str {
match name.rfind(['/', '\\']) {
Some(pos) => name.get(..pos).unwrap_or(""),
None => "",
}
}
fn write_table(out: &mut impl Write, results: &[(String, Vec<Element>)]) -> io::Result<()> {
for (i, (filename, elements)) in results.iter().enumerate() {
if i > 0 {
writeln!(out)?;
}
writeln!(out, "{filename}")?;
if elements.is_empty() {
writeln!(out, " (no elements)")?;
continue;
}
let width = elements
.iter()
.map(|e| e.kind.as_str().len())
.max()
.unwrap_or(0);
for element in elements {
writeln!(
out,
" {:<width$} {}",
element.kind.as_str(),
element.value,
width = width
)?;
}
}
Ok(())
}
fn write_json(out: &mut impl Write, results: &[(String, Vec<Element>)]) -> io::Result<()> {
let json: Vec<serde_json::Value> = results
.iter()
.map(|(filename, elements)| {
let elements: Vec<serde_json::Value> = elements
.iter()
.map(|e| {
serde_json::json!({
"kind": e.kind.as_str(),
"value": e.value,
"position": e.position,
})
})
.collect();
serde_json::json!({ "filename": filename, "elements": elements })
})
.collect();
writeln!(out, "{}", serde_json::Value::Array(json))
}
#[cfg(test)]
mod tests {
use super::*;
use anitomy_ng::ElementKind;
fn episodes(elements: &[Element]) -> Vec<&str> {
elements
.iter()
.filter(|e| e.kind == ElementKind::Episode)
.map(|e| e.value.as_str())
.collect()
}
#[test]
fn dir_prefix_covers_both_separators_and_bare_names() {
assert_eq!(dir_prefix("a/b/c.mkv"), "a/b");
assert_eq!(dir_prefix("a\\b\\c.mkv"), "a\\b");
assert_eq!(dir_prefix("c.mkv"), "");
}
#[test]
fn grouped_folder_recovers_episode_from_range() {
let inputs = vec![
"Series (01-12)[batch]/Series - 07.mkv".to_string(),
"Series (01-12)[batch]/Series - 08.mkv".to_string(),
];
let results = parse_together_grouped(&inputs, Options::default());
assert_eq!(episodes(&results[0]), ["07"]);
assert_eq!(episodes(&results[1]), ["08"]);
}
#[test]
fn separate_directories_group_apart_in_original_order() {
let inputs = vec![
"A/Show - 01.mkv".to_string(),
"B/Other - 05.mkv".to_string(),
"A/Show - 02.mkv".to_string(),
];
let results = parse_together_grouped(&inputs, Options::default());
assert_eq!(episodes(&results[0]), ["01"]);
assert_eq!(episodes(&results[1]), ["05"]);
assert_eq!(episodes(&results[2]), ["02"]);
}
}