Initial commit

This commit is contained in:
blank X 2020-09-12 19:26:07 +07:00
commit c5dab76260
11 changed files with 1476 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1058
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "nhentairs"
version = "0.1.0"
authors = ["blank X <theblankx@protonmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.release]
lto = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.10", features = ["stream"] }
tokio = { version = "0.2", features = ["rt-core", "macros", "sync"] }

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 blank X
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

32
src/commands.rs Normal file
View File

@ -0,0 +1,32 @@
mod view;
mod search;
mod download;
use std::env;
use std::path::Path;
use std::process::exit;
pub async fn run() {
let mut args = env::args();
let path = args.next().expect("Cannot get binary path");
let path = Path::new(&path).file_stem().unwrap().to_str().unwrap();
let operation = match args.next() {
Some(operation) => operation,
None => {
eprintln!("Missing operation, run `{} help`", path);
exit(1);
}
};
match operation.as_str() {
"search" => search::run(args).await,
"view" | "show" | "info" => view::run(args).await,
"download" | "dl" => download::run(args).await,
"help" => println!(r#"Usage: {} search QUERY
or {} info/view/show SAUCE [SAUCE]...
or {} download/dl SAUCE [SAUCE]..."#, path, path, path),
_ => {
eprintln!("Unknown operation, run `{} help`", path);
exit(1)
}
};
}

113
src/commands/download.rs Normal file
View File

@ -0,0 +1,113 @@
use crate::utils;
use std::env;
use std::path::Path;
use std::process::exit;
use tokio::task::JoinHandle;
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
use std::fs::{rename, create_dir};
extern crate tokio;
extern crate reqwest;
const DOWNLOAD_WORKERS: usize = 5;
pub async fn run(args: env::Args) {
let sauces = utils::get_arg_sauces(args).unwrap();
if sauces.len() < 1 {
eprintln!("Missing sauce(s)");
exit(1);
}
let client = reqwest::Client::new();
let mut handles: Vec<JoinHandle<()>> = Vec::new();
let mut hashm = HashMap::new();
let (tx, mut rx) = mpsc::channel(100);
for sauce in sauces {
let cloned_client = client.clone();
let mut cloned_tx = tx.clone();
handles.push(tokio::spawn(async move {
let sauce_info = utils::get_sauce_info(cloned_client, sauce).await.unwrap();
let base_path = sauce_info.id.to_string();
let base_path = Path::new(&base_path);
match create_dir(base_path) {
Ok(()) => (),
Err(err) => match err.kind() {
std::io::ErrorKind::AlreadyExists => (),
_ => panic!("Got a weird error while creating dir: {}", err)
}
};
let mut page_num: usize = 1;
for page in sauce_info.images.pages {
let file_ext = match page.t.as_str() {
"j" => ".jpg",
"p" => ".png",
"g" => ".gif",
_ => panic!("Unknown extension type: {}", page.t)
};
let mut file_name = page_num.to_string();
file_name.push_str(file_ext);
let file_path = base_path.join(&file_name);
if !file_path.exists() {
cloned_tx.send((
String::from(file_path.to_str().unwrap()),
format!("https://i.nhentai.net/galleries/{}/{}",
sauce_info.media_id,
file_name)
)).await.unwrap();
}
page_num += 1;
}
}));
}
drop(tx);
while let Some((file_path, url)) = rx.recv().await {
hashm.insert(file_path, url);
}
for handle in handles {
handle.await.unwrap();
}
let mut handles = Vec::with_capacity(5);
let (tx, mut rx) = mpsc::channel(5);
tokio::spawn(async move {
while let Some(ntx) = rx.recv().await {
let ntx: oneshot::Sender<(String, String)> = ntx;
ntx.send(match hashm.iter().next() {
Some((key, value)) => {
let key = key.to_string();
let value = value.to_string();
hashm.remove(&key).unwrap();
(key, value)
},
None => (String::new(), String::new()),
}).unwrap();
}
});
for worker_id in 0..DOWNLOAD_WORKERS {
let tcloned_client = client.clone();
let mut cloned_tx = tx.clone();
handles.push(tokio::spawn(async move {
println!("[DW{}] Up!", worker_id);
loop {
let cloned_client = tcloned_client.clone();
let (ntx, nrx) = oneshot::channel();
cloned_tx.send(ntx).await.unwrap();
let (file_path, url) = nrx.await.unwrap();
if file_path.is_empty() && url.is_empty() {
println!("[DW{}] Down!", worker_id);
break;
}
println!("[DW{}] Downloading {} to {}", worker_id, url, file_path);
let mut tmp_file_path = String::from(&file_path);
tmp_file_path.push_str(".tmp");
utils::download_file(cloned_client, &url, &tmp_file_path).await.unwrap();
rename(&tmp_file_path, &file_path).unwrap();
println!("[DW{}] {} downloaded", worker_id, file_path);
}
}));
}
drop(tx);
for handle in handles {
handle.await.unwrap();
}
}

31
src/commands/search.rs Normal file
View File

@ -0,0 +1,31 @@
use crate::utils;
use std::env;
use std::process::exit;
extern crate reqwest;
pub async fn run(args: env::Args) {
let mut query = String::new();
for arg in args {
query.push_str(&format!(" {}", arg));
}
if query.len() < 1 {
eprintln!("Missing search query");
exit(1);
}
let client = reqwest::Client::new();
let search_info = utils::get_search_info(client.clone(), &query[1..]).await.unwrap();
if search_info.num_pages < 1 {
eprintln!("No results found");
exit(1);
}
for result in search_info.result {
let text;
if result.title.english != "" {
text = result.title.english;
} else {
text = result.title.japanese;
}
println!("{}: {}", result.id, text);
}
}

65
src/commands/view.rs Normal file
View File

@ -0,0 +1,65 @@
use crate::utils;
use std::env;
use std::process::exit;
use tokio::task::JoinHandle;
use std::collections::BTreeMap;
extern crate tokio;
extern crate reqwest;
pub async fn run(args: env::Args) {
let sauces = utils::get_arg_sauces(args).unwrap();
let mut remaining_to_show = sauces.len();
if remaining_to_show < 1 {
eprintln!("Missing sauce(s)");
exit(1);
}
let client = reqwest::Client::new();
let mut handles: Vec<JoinHandle<String>> = Vec::new();
for sauce in sauces {
let cloned_client = client.clone();
handles.push(tokio::spawn(async move {
let sauce_info = utils::get_sauce_info(cloned_client, sauce).await.unwrap();
let mut text = format!("Sauce: {}\nTitle: ", sauce_info.id);
let english_title = sauce_info.title.english;
let japanese_title = sauce_info.title.japanese;
if english_title != "" {
text.push_str(&english_title);
if japanese_title != "" {
text.push_str(&format!("\nJapanese Title: {}", &japanese_title));
}
} else {
text.push_str(&japanese_title);
}
let mut tag_hashmap = BTreeMap::new();
for tag_info in sauce_info.tags {
let tag_key = tag_info.r#type;
let tag_value = tag_info.name;
let tag_vec = tag_hashmap.entry(tag_key).or_insert(Vec::new());
tag_vec.push(tag_value);
}
for (tag_key, tag_value) in &tag_hashmap {
let tag_key = match tag_key.as_str() {
"tag" => "Tags",
"artist" => "Artists",
"parody" => "Parodies",
"character" => "Characters",
"group" => "Groups",
"language" => "Languages",
"category" => "Categories",
_ => tag_key
};
text.push_str(&format!("\n{}: {}", tag_key, tag_value.join(", ")));
}
text.push_str(&format!("\nPages: {}\nFavorites: {}", sauce_info.num_pages, sauce_info.num_favorites));
text
}));
}
for handle in handles {
println!("{}", handle.await.unwrap());
if remaining_to_show > 1 {
println!("");
remaining_to_show -= 1;
}
}
}

10
src/main.rs Normal file
View File

@ -0,0 +1,10 @@
mod utils;
mod structs;
mod commands;
extern crate tokio;
#[tokio::main]
async fn main() {
commands::run().await
}

51
src/structs.rs Normal file
View File

@ -0,0 +1,51 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct GalleryTitleInfo {
pub english: String,
pub japanese: String,
pub pretty: String,
}
#[derive(Deserialize, Debug)]
pub struct GalleryImageInfo {
pub t: String,
pub w: usize,
pub h: usize
}
#[derive(Deserialize, Debug)]
pub struct GalleryImagesInfo {
pub pages: Vec<GalleryImageInfo>,
pub cover: GalleryImageInfo,
pub thumbnail: GalleryImageInfo
}
#[derive(Deserialize, Debug)]
pub struct GalleryTagInfo {
pub id: usize,
pub r#type: String,
pub name: String,
pub url: String,
pub count: usize
}
#[derive(Deserialize, Debug)]
pub struct GalleryInfo {
pub id: usize,
pub media_id: String,
pub title: GalleryTitleInfo,
pub images: GalleryImagesInfo,
pub scanlator: String,
pub upload_date: usize,
pub tags: Vec<GalleryTagInfo>,
pub num_pages: usize,
pub num_favorites: usize
}
#[derive(Deserialize, Debug)]
pub struct SearchInfo {
pub result: Vec<GalleryInfo>,
pub num_pages: usize,
pub per_page: usize
}

78
src/utils.rs Normal file
View File

@ -0,0 +1,78 @@
use crate::structs;
use std::env;
use std::fs::File;
use std::io::Write;
use tokio::stream::StreamExt;
extern crate serde_json;
extern crate reqwest;
fn fix_gallery(body: &mut serde_json::Value) -> Result<(), serde_json::Error> {
if body["id"].is_string() {
body["id"] = serde_json::json!(
body["id"].as_str().unwrap().parse::<usize>().unwrap()
);
}
for title in ["english", "japanese", "pretty"].iter() {
if body["title"][title].is_null() {
body["title"][title] = serde_json::json!("")
}
}
Ok(())
}
pub async fn get_sauce_info(client: reqwest::Client, sauce: usize) -> Result<structs::GalleryInfo, reqwest::Error> {
let mut uri = String::from("https://nhentai.net/api/gallery/");
uri.push_str(&sauce.to_string());
let resp = client.get(&uri)
.send()
.await?;
assert_eq!(resp.status().is_success(), true);
let body = resp.text().await?;
let mut body: serde_json::Value = serde_json::from_str(&body).unwrap();
fix_gallery(&mut body).unwrap();
Ok(serde_json::from_str(&serde_json::to_string(&body).unwrap()).unwrap())
}
pub async fn get_search_info(client: reqwest::Client, search_query: &str) -> Result<structs::SearchInfo, reqwest::Error> {
let uri = "https://nhentai.net/api/galleries/search";
let resp = client.get(uri)
.query(&[("query", search_query)])
.send()
.await?;
assert_eq!(resp.status().is_success(), true);
let body = resp.text().await?;
let mut body: serde_json::Value = serde_json::from_str(&body).unwrap();
for i in 0..body["result"].as_array().unwrap().len() {
fix_gallery(&mut body["result"][i]).unwrap();
}
Ok(serde_json::from_str(&serde_json::to_string(&body).unwrap()).unwrap())
}
pub async fn download_file(client: reqwest::Client, url: &str, file_name: &str) -> Result<(), reqwest::Error> {
let resp = client.get(url)
.send()
.await?;
let mut file = File::create(&file_name).unwrap();
let mut stream = resp.bytes_stream();
while let Some(item) = stream.next().await {
file.write(&item?).unwrap();
}
Ok(())
}
pub fn get_arg_sauces(args: env::Args) -> Result<Vec<usize>, String> {
let mut sauces: Vec<usize> = Vec::new();
for sauce in args {
let sauce: usize = match sauce.parse() {
Ok(sauce) => sauce,
Err(_) => {
return Err(format!("{} is not a number/sauce", sauce));
}
};
if !sauces.contains(&sauce) {
sauces.push(sauce);
}
}
Ok(sauces)
}