Initial commit

This commit is contained in:
blank X 2021-02-02 23:52:15 +07:00
commit efd375e758
11 changed files with 1695 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1056
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "hanimers"
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]
tokio = { version = "1.1", features = ["rt"] }
reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
quick-xml = "0.20"
clap = { version = "2.33", default-features = false }

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 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.

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

@ -0,0 +1,154 @@
use crate::utils;
use std::fs::rename;
use std::path::Path;
use std::process::{exit, Command};
use std::collections::HashMap;
use clap::ArgMatches;
use reqwest::Client;
use reqwest::redirect::Policy;
const MAX_DOWNLOAD_ATTEMPTS: i32 = 5;
pub async fn download(arg_m: &ArgMatches<'_>) {
let print_only = arg_m.is_present("print");
let resolution = arg_m.value_of("resolution");
let ids = arg_m.values_of("id").unwrap().collect::<Vec<_>>();
let policy = Policy::custom(|attempt| {
if attempt.previous().len() > 10 {
attempt.error("too many redirects")
} else if attempt.url().path() == "/404" {
attempt.stop()
} else {
attempt.follow()
}
});
let client = Client::builder().redirect(policy).build().unwrap();
let mut return_fail = false;
for id in ids {
let hentai_info = utils::get_hentai(client.clone(), id).await;
match hentai_info {
Ok(hentai_info) => {
match hentai_info {
Some(hentai_info) => {
let slug = hentai_info.state.data.video.hentai_video.slug;
let filename = format!("{}.mkv", &slug);
if !print_only && Path::new(&filename).exists() {
continue;
}
let mut download_url = None;
let mut perm_urls: HashMap<String, String> = HashMap::new();
let mut temp_urls: HashMap<String, String> = HashMap::new();
for server in hentai_info.state.data.video.videos_manifest.servers {
let mut to_hashmap = match server.is_permanent {
true => perm_urls.clone(),
false => temp_urls.clone()
};
for stream in server.streams {
if stream.url.is_empty() {
continue;
}
if server.is_permanent && Some(stream.height.as_str()) == resolution {
download_url = Some(stream.url);
break;
}
if !to_hashmap.contains_key(&stream.height) {
to_hashmap.insert(stream.height, stream.url);
};
}
if download_url.is_some() {
break;
}
match server.is_permanent {
true => perm_urls.extend(to_hashmap),
false => temp_urls.extend(to_hashmap)
};
}
if download_url.is_none() {
if resolution.is_some() {
download_url = temp_urls.get(resolution.unwrap()).map(|i| i.to_string());
}
if download_url.is_none() {
download_url = magic_thing(perm_urls).or_else(|| { magic_thing(temp_urls) });
if download_url.is_none() {
eprintln!("Failed to get {}: cannot get download url", id);
return_fail = true;
continue;
}
}
}
let download_url = download_url.unwrap();
if print_only {
println!("{}", download_url);
} else {
let mut fail_dl = true;
let tmp_filename = format!("{}.tmp", &slug);
for i in 0..MAX_DOWNLOAD_ATTEMPTS {
eprintln!("Downloading {} (attempt {})", &filename, i);
match Command::new("ffmpeg").args(&["-v", "warning", "-stats", "-nostdin", "-y", "-i", &download_url, "-c", "copy", "-f", "matroska", &tmp_filename]).spawn() {
Ok(mut child) => {
match child.wait() {
Ok(exit_status) => {
if exit_status.success() {
fail_dl = false;
match rename(&tmp_filename, &filename) {
Ok(_) => (),
Err(err) => eprintln!("Failed to rename {} to {} due to {}", &tmp_filename, &filename, err)
};
break;
}
eprintln!("ffmpeg exited with {:?}", exit_status.code());
},
Err(err) => eprintln!("Failed to wait on ffmpeg process due to {}", err)
};
},
Err(err) => eprintln!("Failed to spawn ffmpeg process due to {}", err)
};
}
if fail_dl {
eprintln!("Failed to download {}", &filename);
return_fail = true;
}
}
},
None => {
eprintln!("Failed to get {}: does not exist", id);
return_fail = true;
}
};
},
Err(err) => {
eprintln!("Failed to get {}: {}", id, err);
return_fail = true;
}
};
}
if return_fail {
exit(1);
}
}
fn magic_thing(map: HashMap<String, String>) -> Option<String> {
let mut keys = Vec::new();
for i in map.keys() {
match i.parse::<i32>() {
Ok(i) => keys.push(i),
Err(_) => {
keys.clear();
break;
}
};
}
keys.sort();
match keys.pop() {
Some(key) => Some(map.get(&key.to_string()).unwrap().to_string()),
None => {
let mut keys: Vec<_> = map.keys().collect();
keys.sort();
match keys.pop() {
Some(key) => Some(map.get(&key.to_string()).unwrap().to_string()),
None => None
}
}
}
}

6
src/commands/mod.rs Normal file
View File

@ -0,0 +1,6 @@
mod view;
mod search;
mod download;
pub use view::view;
pub use search::{search, AVALIABLE_TAGS};
pub use download::download;

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

@ -0,0 +1,22 @@
use crate::utils;
use std::process::exit;
use clap::ArgMatches;
use reqwest::Client;
pub const AVALIABLE_TAGS: &[&str] = &["3D", "Ahegao", "Anal", "BDSM", "Big Boobs", "Blow Job", "Bondage", "Boob Job", "Censored", "Comedy", "Cosplay", "Creampie", "Dark Skin", "Facial", "Fantasy", "Filmed", "Foot Job", "Futanari", "Gangbang", "Glasses", "Hand Job", "Harem", "HD", "Horror", "Incest", "Inflation", "Lactation", "Loli", "Maid", "Masturbation", "Milf", "Mind Break", "Mind Control", "Monster", "Nekomimi", "NTR", "Nurse", "Orgy", "Plot", "POV", "Pregnant", "Public Sex", "Rape", "Reverse Rape", "Rimjob", "Scat", "School Girl", "Shota", "Softcore", "Swimsuit", "Teacher", "Tentacle", "Threesome", "Toys", "Trap", "Tsundere", "Ugly Bastard", "Uncensored", "Vanilla", "Virgin", "Watersports", "X-Ray", "Yaoi", "Yuri"];
pub async fn search(arg_m: &ArgMatches<'_>) {
let broad_search = arg_m.is_present("broad");
let tags = arg_m.values_of("tags").unwrap_or_default().map(|i| i.trim().to_lowercase()).collect();
let query = arg_m.values_of("query").unwrap_or_default().collect::<Vec<_>>().join(" ");
let query = query.trim();
let results = utils::search(Client::new(), query, tags, broad_search).await.unwrap();
if results.is_empty() {
eprintln!("No results found");
exit(1);
}
for i in results {
println!("{}", i);
}
}

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

@ -0,0 +1,62 @@
use crate::utils;
use std::process::exit;
use clap::ArgMatches;
use reqwest::Client;
use reqwest::redirect::Policy;
extern crate tokio;
pub async fn view(arg_m: &ArgMatches<'_>) {
let policy = Policy::custom(|attempt| {
if attempt.previous().len() > 10 {
attempt.error("too many redirects")
} else if attempt.url().path() == "/404" {
attempt.stop()
} else {
attempt.follow()
}
});
let client = Client::builder().redirect(policy).build().unwrap();
let handles = arg_m.values_of("id").unwrap().map(|id| {
let cloned_client = client.clone();
let id = id.to_string();
tokio::spawn(async move {
(utils::get_hentai(cloned_client, &id).await, id)
})
}).collect::<Vec<_>>();
let mut fail = false;
let mut one_done = false;
for handle in handles {
let (hentai, id) = handle.await.unwrap();
match hentai {
Ok(hentai) => {
match hentai {
Some(hentai) => {
if one_done {
println!("");
}
println!("{}", &hentai);
},
None => {
if one_done {
eprintln!("");
}
eprintln!("ID: {}\nError: does not exist", id);
fail = true;
}
};
},
Err(err) => {
if one_done {
eprintln!("");
}
eprintln!("ID: {}\nError: {}", id, err);
fail = true;
}
};
one_done = true;
}
if fail {
exit(1);
}
}

78
src/main.rs Normal file
View File

@ -0,0 +1,78 @@
mod commands;
mod structs;
mod utils;
use clap::{App, AppSettings, Arg, SubCommand};
extern crate tokio;
fn main() {
let matches = App::new("hanimers")
.about("hanime.tv downloader in rust")
// let's hope i remember to bump the version xd
.version("0.1.0")
.setting(AppSettings::SubcommandRequiredElseHelp)
.subcommand(
SubCommand::with_name("search")
.arg(
Arg::with_name("tags")
.long("tags")
.short("t")
.takes_value(true)
.use_delimiter(true)
.case_insensitive(true)
.possible_values(&commands::AVALIABLE_TAGS)
).arg(
Arg::with_name("broad")
.long("broad")
.short("b")
.help("More results, but less accurate. Videos will match if they contain any selected tag rather than all selected tags.")
).arg(
Arg::with_name("query")
.takes_value(true)
.multiple(true)
.help("Search query")
)
)
.subcommand(
SubCommand::with_name("view")
.aliases(&["info", "show"])
.arg(
Arg::with_name("id")
.takes_value(true)
.multiple(true)
.required(true)
)
)
.subcommand(
SubCommand::with_name("download")
.alias("dl")
.arg(
Arg::with_name("print")
.long("print")
.short("p")
.help("Print the URL to download only")
).arg(
Arg::with_name("resolution")
.long("resolution")
.short("r")
.help("Set preferred resolution")
.takes_value(true)
).arg(
Arg::with_name("id")
.takes_value(true)
.multiple(true)
.required(true)
)
)
.get_matches();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
match matches.subcommand() {
("search", Some(sub_m)) => runtime.block_on(commands::search(sub_m)),
("view", Some(sub_m)) => runtime.block_on(commands::view(sub_m)),
("download", Some(sub_m)) => runtime.block_on(commands::download(sub_m)),
_ => panic!("AppSettings::SubcommandRequiredElseHelp do your job please")
};
}

208
src/structs.rs Normal file
View File

@ -0,0 +1,208 @@
use std::fmt;
use std::marker::PhantomData;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer};
use quick_xml::Reader;
use quick_xml::events::Event;
extern crate reqwest;
extern crate quick_xml;
extern crate serde_json;
// contains more info that i won't add for search results, maybe non_exhaustive? not sure
#[derive(Deserialize, Debug)]
pub struct SearchResult {
pub id: i32,
pub name: String
}
impl fmt::Display for SearchResult {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&format!("{}: {}", self.id, &self.name))
}
}
#[derive(Deserialize, Debug)]
pub struct SearchResultMetadata {
pub hits: String
}
#[derive(Deserialize, Debug)]
pub struct HentaiInfo {
pub state: HentaiState
}
#[derive(Deserialize, Debug)]
pub struct HentaiState {
pub data: HentaiData
}
#[derive(Deserialize, Debug)]
pub struct HentaiData {
pub video: HentaiDataVideo
}
#[derive(Deserialize, Debug)]
pub struct HentaiDataVideo {
pub hentai_video: HentaiVideo,
pub videos_manifest: VideosManifest
}
#[derive(Deserialize, Debug)]
pub struct HentaiVideo {
pub id: i32,
pub slug: String,
pub name: String,
#[serde(deserialize_with = "remove_html")]
pub description: String,
pub views: usize,
pub is_hard_subtitled: bool,
pub duration_in_ms: i32,
pub is_censored: bool,
pub likes: usize,
pub dislikes: usize,
pub hentai_tags: Vec<HentaiTag>
}
impl fmt::Display for HentaiInfo {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let video = &self.state.data.video.hentai_video;
let mut seconds = video.duration_in_ms / 1000;
let mut minutes = seconds / 60;
if minutes > 0 {
seconds -= minutes * 60;
}
let hours = minutes / 60;
if hours > 0 {
minutes -= hours * 60;
seconds -= hours * 60 * 60;
}
let mut duration = String::new();
if hours > 0 {
duration.push_str(&format!("{}:", hours));
}
if minutes > 0 {
duration.push_str(&format!("{}:", minutes));
}
duration.push_str(&seconds.to_string());
formatter.write_str(&format!("ID: {}\nSlug: {}\nName: {}\nViews: {}\nLikes: {}\nDislikes: {}\nCensored: {}\nHardsubbed: {}\nDuration: {}\nTags: {}\nDescription: {}",
video.id,
&video.slug,
&video.name,
video.views,
video.likes,
video.dislikes,
match video.is_censored {
true => "Yes",
false => "No"
},
match video.is_hard_subtitled {
true => "Yes",
false => "No"
},
duration,
video.hentai_tags.iter().map(|i| i.text.as_str()).collect::<Vec<_>>().join(", "),
&video.description
))
}
}
#[derive(Deserialize, Debug)]
pub struct HentaiTag {
pub id: i32,
pub text: String
}
#[derive(Deserialize, Debug)]
pub struct VideosManifest {
pub servers: Vec<VideoServer>
}
#[derive(Deserialize, Debug)]
pub struct VideoServer {
pub is_permanent: bool,
pub streams: Vec<VideoStream>
}
#[derive(Deserialize, Debug)]
pub struct VideoStream {
pub height: String,
pub url: String
}
fn remove_html<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>
{
struct RemoveHTML<T>(PhantomData<fn() -> T>);
impl<'de> Visitor<'de> for RemoveHTML<String>
{
type Value = String;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("HTML string")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error
{
// https://brokenco.de/2020/08/03/serde-deserialize-with-string.html
let mut to_return = String::new();
let mut reader = Reader::from_str(&value);
let mut buf = Vec::new();
loop {
match reader.read_event(&mut buf) {
Ok(Event::Text(e)) => to_return.push_str(&e.unescape_and_decode(&reader).map_err(de::Error::custom)?),
Ok(Event::Eof) => break,
Err(err) => panic!("Error at position {}: {:?}", reader.buffer_position(), err),
_ => ()
};
buf.clear();
}
Ok(to_return)
}
}
deserializer.deserialize_any(RemoveHTML(PhantomData))
}
#[derive(Debug)]
pub enum Error {
Reqwest(reqwest::Error),
QuickXML(quick_xml::Error),
SerdeJSON(serde_json::Error),
}
impl From<reqwest::Error> for Error {
#[inline]
fn from(error: reqwest::Error) -> Error {
Error::Reqwest(error)
}
}
impl From<quick_xml::Error> for Error {
#[inline]
fn from(error: quick_xml::Error) -> Error {
Error::QuickXML(error)
}
}
impl From<serde_json::Error> for Error {
#[inline]
fn from(error: serde_json::Error) -> Error {
Error::SerdeJSON(error)
}
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(
&match self {
Error::Reqwest(err) => format!("reqwest error: {}", err),
Error::QuickXML(err) => format!("quick-xml error: {}", err),
Error::SerdeJSON(err) => format!("serde_json error: {}", err),
}
)
}
}

69
src/utils.rs Normal file
View File

@ -0,0 +1,69 @@
use crate::structs;
use quick_xml::Reader;
use quick_xml::events::Event;
extern crate reqwest;
extern crate serde_json;
pub async fn search(client: reqwest::Client, query: &str, tags: Vec<String>, broad_search: bool) -> Result<Vec<structs::SearchResult>, structs::Error> {
let tags_mode = match broad_search {
true => "OR",
false => "AND"
};
Ok(serde_json::from_str(
&serde_json::from_str::<structs::SearchResultMetadata>(
&client.post("https://search.htv-services.com")
.header("Content-Type", "application/json")
.body(serde_json::json!(
{"search_text": &query, "tags": tags, "tags_mode": tags_mode, "brands": [], "blacklist": [], "order_by": "created_at_unix", "ordering": "desc", "page": 0}
).to_string())
.send()
.await?
.text()
.await?
)?.hits
)?)
}
pub async fn get_hentai(client: reqwest::Client, id: &str) -> Result<Option<structs::HentaiInfo>, structs::Error> {
let resp = client.get(&format!("https://hanime.tv/videos/hentai/{}", id))
.send()
.await?;
if resp.status() != 200 {
return Ok(None);
}
let text = resp.text().await?;
let mut reader = Reader::from_str(&text);
reader.check_end_names(false);
let mut buf = Vec::new();
let mut script = String::new();
let mut is_inside_script = false;
let mut to_return = None;
loop {
match reader.read_event(&mut buf) {
Ok(Event::Start(ref e)) if e.name() == b"script" => is_inside_script = true,
Ok(Event::Text(e)) if is_inside_script => {
let text = match reader.decode(e.escaped()) {
Ok(text) => text,
Err(_) => continue
};
if !script.is_empty() || text.starts_with("window.__NUXT__={") {
script.push_str(&text);
}
},
Ok(Event::End(ref e)) if e.name() == b"script" && is_inside_script => {
if script.is_empty() {
is_inside_script = false;
} else {
to_return = Some(serde_json::from_str(script.splitn(2, "=").nth(1).unwrap().trim_end_matches(';'))?);
break;
}
},
Err(err) => panic!("Error at position {}: {:?}", reader.buffer_position(), err),
Ok(Event::Eof) => break,
_ => ()
};
buf.clear();
}
Ok(to_return)
}