//! 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(()) }