//! This example will download a chapter's pages.
//!
//! Reference:
//!
//! If you are downloading the pages, the output will match Tachiyomi's style of:
//!
//! ```
//! [uploader]_[volume] [chapter] - [title]
//! ```
//!
//! Here are some examples:
//!
//! - "Doki Fansubs_Vol.9 Ch.97 - Coffee Break"
//! - "Doki Fansubs_Ch.20 - Crunch Time"
//! - "Doki Fansubs & Holo_Ch.86"
//!
//! # Usage
//!
//! ```
//! download_chapter [OPTION] [CHAPTERID]
//! ```
//!
//! ## Options
//!
//! -h, --help
//! Output a usage message and exit.
//!
//! --data-saver
//! Use compressed images, which have smaller filesizes.
//!
//! -o, --download
//! Specify the directory to save the pages to.
//!
//! # Examples
//!
//! This example will get the chapter page data for the official test manga.
//!
//! ```
//! download_chapter c84f0bdd-0936-4fc3-8a7d-9b24303df33e
//! ```
//!
//! This will download the manga covers to the local filesystem at the specified directory.
//!
//! ```
//! download_chapter --download ./ c84f0bdd-0936-4fc3-8a7d-9b24303df33e
//! ```
use std::fs::{create_dir, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use clap::Parser;
use reqwest::Url;
use uuid::Uuid;
use mangadex_api::v5::MangaDexClient;
use mangadex_api::HttpClientRef;
use mangadex_api_types::RelationshipType;
#[derive(Parser, Debug)]
#[clap(
name = "Manga Chapter Downloader",
about = "Fetch the pages for a chapter."
)]
struct Args {
/// Chapter UUID.
#[clap()]
chapter_id: Uuid,
#[clap(long)]
data_saver: bool,
/// Location to save the cover art.
#[clap(short, long = "download")]
output: Option,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
if let Err(e) = run(args).await {
use std::process;
eprintln!("Application error: {}", e);
process::exit(1);
}
}
async fn run(args: Args) -> anyhow::Result<()> {
let client = MangaDexClient::default();
let mut output = args.output.clone().unwrap_or_default();
// Fetch chapter data to use for naming the output directory.
let chapter = client
.chapter()
.id(args.chapter_id)
.get()
.build()?
.send()
.await?;
// Determine the uploader between the scanlation groups and users to prefix the chapter
// directory with. Scanlation groups take priority over users.
let mut scanlation_groups = Vec::new();
for r in &chapter.data.relationships {
if r.type_ == RelationshipType::ScanlationGroup {
let group = client
.scanlation_group()
.id(r.id)
.get()
.build()?
.send()
.await?;
scanlation_groups.push(group.data.attributes.name);
}
}
let mut users = Vec::new();
if scanlation_groups.is_empty() {
for r in &chapter.data.relationships {
if r.type_ == RelationshipType::User {
let user = client.user().id(r.id).get().build()?.send().await?;
users.push(user.data.attributes.username);
}
}
}
let uploader = if !scanlation_groups.is_empty() {
scanlation_groups.join(" & ")
} else {
users.join(" & ")
};
let volume_number = match chapter.data.attributes.volume {
Some(v) => format!("Vol.{} ", v),
None => "".to_string(),
};
let chapter_number = match chapter.data.attributes.chapter {
Some(c) => format!("Ch.{} ", c),
None => "".to_string(),
};
let title_separator = if (volume_number.is_empty() && chapter_number.is_empty())
|| chapter
.data
.attributes
.title
.as_ref()
.map(|t| t.is_empty())
.unwrap_or(false)
{
""
} else {
"- "
};
output.push(format!(
"{uploader}_{volume}{chapter}{separator}{title}",
uploader = uploader,
volume = volume_number,
chapter = chapter_number,
separator = title_separator,
title = chapter
.data
.attributes
.title
.clone()
.unwrap_or(String::default())
));
// Only create the output directory if the user has specified that they want to download the
// pages.
if args.output.is_some() && !output.is_dir() {
println!("Created {:?}", &output);
create_dir(&output)?;
}
let at_home = client
.at_home()
.server()
.id(args.chapter_id)
.get()
.build()?
.send()
.await?;
let page_filenames = if !args.data_saver {
at_home.body.chapter.data
} else {
at_home.body.chapter.data_saver
};
for (i, server_filename) in page_filenames.iter().enumerate() {
let path = Path::new(server_filename);
let ext = match path.extension() {
Some(e) => format!(".{}", e.to_str().unwrap_or("")),
None => "".to_string(),
};
// Match how Tachiyomi names the pages with left-padded (with zeroes) 3-digit numbers.
// For example, "001.png".
let filename = format!("{:03}{}", i + 1, ext);
let page_url = at_home
.body
.base_url
.join(&format!(
"/{quality_mode}/{chapter_hash}/{page_filename}",
quality_mode = if args.data_saver {
"data-saver"
} else {
"data"
},
chapter_hash = at_home.body.chapter.hash,
page_filename = server_filename
))
.unwrap();
if args.output.is_some() {
print!("Downloading {}...", &filename);
// The `print!()` macro is line-buffered so flushing it ensures the output is emitted
// immediately.
std::io::stdout().flush()?;
download_file(
client.get_http_client().clone(),
&page_url,
output.as_path(),
&filename,
)
.await?;
println!("done");
} else {
#[cfg(all(
not(feature = "multi-thread"),
not(feature = "tokio-multi-thread"),
not(feature = "rw-multi-thread")
))]
#[cfg_attr(
all(
not(feature = "multi-thread"),
not(feature = "tokio-multi-thread"),
not(feature = "rw-multi-thread")
),
allow(clippy::await_holding_refcell_ref)
)]
let page_res = client
.get_http_client()
.clone()
.try_borrow()?
.client
.get(page_url.clone())
.send()
.await?;
#[cfg(any(feature = "multi-thread", feature = "tokio-multi-thread"))]
let page_res = client
.get_http_client()
.lock()
.await
.client
.get(page_url.clone())
.send()
.await?
.bytes()
.await?;
#[cfg(feature = "rw-multi-thread")]
let page_res = client
.get_http_client()
.read()
.await
.client
.get(page_url.clone())
.send()
.await?
.bytes()
.await?;
println!("{:?} - {:#?}", filename, page_res);
}
}
Ok(())
}
/// Download the URL contents into the local filesystem.
async fn download_file(
http_client: HttpClientRef,
url: &Url,
output: &Path,
file_name: &str,
) -> anyhow::Result<()> {
#[cfg(all(
not(feature = "multi-thread"),
not(feature = "tokio-multi-thread"),
not(feature = "rw-multi-thread")
))]
let image_bytes = http_client
.try_borrow()?
.client
.get(url.clone())
.send()
.await?
.bytes()
.await?;
#[cfg(any(feature = "multi-thread", feature = "tokio-multi-thread"))]
let image_bytes = http_client
.lock()
.await
.client
.get(url.clone())
.send()
.await?
.bytes()
.await?;
#[cfg(feature = "rw-multi-thread")]
let image_bytes = http_client
.read()
.await
.client
.get(url.clone())
.send()
.await?
.bytes()
.await?;
let mut file_buffer = File::create(output.join(file_name))?;
file_buffer.write_all(&image_bytes)?;
Ok(())
}