├── Cargo.toml ├── README.md └── src ├── helpers ├── is_terminal.rs ├── mod.rs ├── name_provider.rs ├── play_manager.rs ├── provider_num.rs ├── reqwests.rs └── selection.rs ├── main.rs └── providers ├── allanime.rs └── mod.rs /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "eren" 3 | authors = ["BASED"] 4 | license = "GPL-3.0" 5 | version = "0.3.4" 6 | edition = "2021" 7 | description = "Stream & Download Animes from your terminal" 8 | keywords = ["anime", "allanime", "gogoanime", "cli", "scraper"] 9 | repository = "https://github.com/Based-Programmer/eren" 10 | 11 | [dependencies] 12 | tokio = { version = "1.40", features = ["full"] } 13 | clap = { version = "4.5", features = ["cargo"] } 14 | isahc = { version = "1.7.2", features = ["json", "text-decoding"], default-features = false } 15 | url = "2.5" 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | hex = "0.4" 19 | skim = { version = "0.10", default-features = false } 20 | 21 | [profile.release] 22 | strip = true 23 | lto = true 24 | codegen-units = 1 25 | panic = "abort" 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eren 2 | 3 | Stream & Download Cartoons & Animes 4 | 5 | # Install 6 | 7 | #### Linux/Android 8 | 9 | - Get the binary from the [release page](https://github.com/Based-Programmer/eren/releases) 10 | 11 | ## Build 12 | #### Linux/Unix/Mac/Android 13 | 14 | - First of all install rust then 15 | 16 | ````sh 17 | git clone 'https://github.com/Based-Programmer/eren' && \ 18 | cd eren && \ 19 | cargo build --release 20 | ```` 21 | 22 | - Then move it to your $PATH 23 | 24 | ````sh 25 | sudo cp target/release/eren /usr/local/bin/ 26 | ```` 27 | 28 | - Or Build it directly with cargo 29 | 30 | ````sh 31 | cargo install eren 32 | ```` 33 | 34 | - Then move it to your $PATH 35 | 36 | ````sh 37 | sudo cp "$CARGO_HOME"/bin/eren /usr/local/bin/ 38 | ```` 39 | 40 | - Or better add $CARGO_HOME to your $PATH 41 | 42 | - In your .zprofile, .bash_profile or .fish_profile ? 43 | 44 | ````sh 45 | export PATH="$CARGO_HOME/bin:$PATH" 46 | ```` 47 | 48 | #### Only Android Termux 49 | 50 | - In your .zprofile, .bash_profile or .fish_profile ? 51 | 52 | ````sh 53 | export TERMINFO='/data/data/com.termux/files/usr/share/terminfo' 54 | ```` 55 | 56 | ## Usage 57 | 58 | ```` 59 | eren 60 | ```` 61 | 62 | #### Example 63 | 64 | - Get data 65 | 66 | ````sh 67 | eren --debug demon slayer 68 | ```` 69 | 70 | - Change Provider 71 | 72 | ````sh 73 | eren -p Luf-mp4 death note 74 | ```` 75 | 76 | #### Multi-select 77 | 78 | - use Tab in the tui [skim](https://github.com/lotabout/skim) 79 | - use Shift + Enter in [rofi](https://github.com/davatorium/rofi) 80 | - select 1 & 12 & it will play 1 to 12 81 | - In mpv-android, play a episode first then multi-select using `select` option 82 | 83 | #### Rofi 84 | 85 | - you can execute eren from something like rofi or dmenu & rofi will spawn automatically 86 | - or you can just execute it from the terminal using the normie way given below 87 | 88 | ````sh 89 | eren -r texhnolyze 90 | ```` 91 | 92 | - Sort by top (best cope for occasional allanime's irrelevant search results) 93 | 94 | ````sh 95 | eren -t monster 96 | ```` 97 | 98 | - Sub 99 | 100 | ````sh 101 | eren -s great pretender 102 | ```` 103 | 104 | - More at help 105 | 106 | ````sh 107 | eren -h 108 | ```` 109 | 110 | ## Dependency 111 | 112 | - mpv or mpv-android (best media player) 113 | - ffmpeg (merging downloaded video & audio) 114 | - [hls](https://github.com/CoolnsX/hls_downloader) (for downloading m3u8 from provider Ak & Luf-mp4) 115 | 116 | ## Optimal Dependency 117 | 118 | - rofi (external selection menu) 119 | 120 | ## Acknowledgement 121 | 122 | - Heavily inspired from [ani-cli](https://github.com/pystardust/ani-cli) 123 | - Special thanks to KR for decoding the [encryption](https://github.com/justfoolingaround/animdl/commit/c4e6a86) 124 | - fuzzy tui [skim](https://github.com/lotabout/skim) 125 | -------------------------------------------------------------------------------- /src/helpers/is_terminal.rs: -------------------------------------------------------------------------------- 1 | pub fn is_terminal() -> bool { 2 | std::process::Command::new("/bin/sh") 3 | .args(["-c", "[ -t 0 ]"]) 4 | .status() 5 | .unwrap() 6 | .success() 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod play_manager; 2 | pub mod provider_num; 3 | pub mod reqwests; 4 | pub mod selection; 5 | -------------------------------------------------------------------------------- /src/helpers/name_provider.rs: -------------------------------------------------------------------------------- 1 | pub fn provider_name<'a>(provider: u8) -> &'a str { 2 | match provider { 3 | 1 => "Ak", 4 | 2 => "Default", 5 | 3 => "Sak", 6 | 4 => "S-mp4", 7 | 5 => "Luf-mp4", 8 | 6 => "Yt-mp4", 9 | _ => unreachable!(), 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/play_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::{Todo, Vid, RED, RESET, YELLOW}; 2 | use std::{ 3 | env::consts::OS, 4 | fs, 5 | process::{Command, Stdio}, 6 | }; 7 | 8 | pub async fn play_manage(mut vid: Vid, todo: Todo) { 9 | match todo { 10 | Todo::Play => { 11 | if OS == "android" { 12 | Command::new("am") 13 | .arg("start") 14 | .args(["--user", "0"]) 15 | .args(["-a", "android.intent.action.VIEW"]) 16 | .args(["-d", &vid.vid_link]) 17 | .args(["-n", "is.xyz.mpv/.MPVActivity"]) 18 | .stdout(Stdio::null()) 19 | .stderr(Stdio::null()) 20 | .spawn() 21 | .expect("Failed to execute 'am' cmd"); 22 | } else { 23 | let mut mpv_args = Vec::new(); 24 | 25 | if let Some(audio_link) = vid.audio_link { 26 | mpv_args.push(format!("--audio-file={}", audio_link)) 27 | } 28 | 29 | if let Some(sub_file) = vid.subtitle_path { 30 | mpv_args.push(format!("--sub-file={}", sub_file)); 31 | mpv_args.push(String::from("--sub-visibility")) 32 | } 33 | 34 | if let Some(referrer) = vid.referrer { 35 | mpv_args.push(format!("--referrer={}", referrer)) 36 | } 37 | 38 | Command::new("clear") 39 | .spawn() 40 | .expect("Failed to execute 'clear' cmd"); 41 | 42 | Command::new("mpv") 43 | .args(mpv_args) 44 | .args([ 45 | &vid.vid_link, 46 | "--force-seekable", 47 | "--force-window=immediate", 48 | "--speed=1", 49 | &format!("--force-media-title={}", vid.title), 50 | ]) 51 | // .stdout(Stdio::null()) 52 | .status() 53 | .expect("Failed to execute mpv"); 54 | } 55 | } 56 | Todo::Download => { 57 | vid.title = vid.title.replace('/', "\\"); 58 | 59 | if vid.vid_link.ends_with(".m3u8") { 60 | if Command::new("hls") 61 | .args(["-n", "38"]) 62 | .args(["-o", &vid.title]) 63 | .arg(&vid.vid_link) 64 | .status() 65 | .expect("Failed to execute hls 66 | Copy the script from https://github.com/CoolnsX/hls_downloader/blob/main/hls & 67 | move it to your $PATH") 68 | .success() 69 | { 70 | println!("{}\nDownload Completed: {}{}", YELLOW, vid.title, RESET); 71 | } else { 72 | eprintln!("{}\nDownload failed: {}{}", RED, vid.title, RESET); 73 | } 74 | } else if let Some(audio_link) = &vid.audio_link { 75 | download(&vid, &vid.vid_link, " video", "mp4").await; 76 | download(&vid, audio_link, " audio", "mp3").await; 77 | 78 | let vid_title = format!("{} video.{}", vid.title, "mp4"); 79 | let audio_title = format!("{} audio.{}", vid.title, "mp3"); 80 | let mut ffmpeg_args = vec!["-i", &vid_title, "-i", &audio_title]; 81 | 82 | let vid_ext = if let Some(sub_file) = &vid.subtitle_path { 83 | ffmpeg_args.extend_from_slice(&["-i", sub_file, "-c:s", "ass"]); 84 | "mkv" 85 | } else { 86 | "mp4" 87 | }; 88 | 89 | ffmpeg_args.extend_from_slice(&[ 90 | "-map", "0:v", "-map", "1:a", "-map", "2:s", "-c:v", "copy", "-c:a", "copy", 91 | ]); 92 | 93 | if Command::new("ffmpeg") 94 | .args(ffmpeg_args) 95 | .arg(format!("{}.{}", vid.title, vid_ext)) 96 | .output() 97 | .expect("Failed to execute ffmpeg") 98 | .status 99 | .success() 100 | { 101 | println!("{YELLOW}Video & audio merged successfully{RESET}"); 102 | 103 | fs::remove_file(vid_title).unwrap_or_else(|_| { 104 | eprintln!("{RED}Failed to remove downloaded video{RESET}") 105 | }); 106 | 107 | fs::remove_file(audio_title).unwrap_or_else(|_| { 108 | eprintln!("{RED}Failed to remove downloaded audio{RESET}") 109 | }); 110 | } else { 111 | eprintln!("{RED}Video & audio merge failed{RESET}"); 112 | } 113 | } else { 114 | download(&vid, &vid.vid_link, "", "mp4").await; 115 | } 116 | } 117 | Todo::GetLink => { 118 | let newline = if vid.audio_link.is_some() || vid.subtitle_path.is_some() { 119 | "\n" 120 | } else { 121 | "" 122 | }; 123 | 124 | println!("{}{}", newline, vid.vid_link); 125 | 126 | if let Some(audio_link) = vid.audio_link { 127 | println!("{}", audio_link); 128 | } 129 | 130 | if let Some(sub_file) = vid.subtitle_path { 131 | println!("{}", sub_file); 132 | } 133 | } 134 | Todo::Debug => println!("{vid:#?}"), 135 | } 136 | } 137 | 138 | async fn download(vid: &Vid, link: &str, types: &str, extension: &str) { 139 | println!("{}\nDownloading{}:{} {}", YELLOW, types, RESET, vid.title); 140 | 141 | let mut aria_args = vec![format!("--out={}{}.{}", vid.title, types, extension)]; 142 | 143 | if let Some(referer) = vid.referrer { 144 | aria_args.push(format!("--referer={referer}")); 145 | } 146 | 147 | if Command::new("aria2c") 148 | .args(aria_args) 149 | .args([ 150 | link, 151 | "--max-connection-per-server=16", 152 | "--max-concurrent-downloads=16", 153 | "--split=16", 154 | "--min-split-size=1M", 155 | "--check-certificate=false", 156 | "--summary-interval=0", 157 | "--download-result=hide", 158 | ]) 159 | //.arg(format!("--user-agent={}", vid.user_agent)) 160 | .status() 161 | .expect("Failed to execute aria2c") 162 | .success() 163 | { 164 | println!("{YELLOW}\nDownloaded successfully{RESET}"); 165 | } else { 166 | eprintln!("{RED}\nDownload Failed{RESET}"); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/helpers/provider_num.rs: -------------------------------------------------------------------------------- 1 | pub fn provider_num(provider: &str) -> u8 { 2 | match provider { 3 | "Ak" => 1, 4 | "Default" => 2, 5 | "S-mp4" => 3, 6 | "Luf-mp4" => 4, 7 | "Yt-mp4" => 5, 8 | _ => unreachable!(), 9 | } 10 | } 11 | 12 | pub fn provider_name<'a>(provider: u8) -> &'a str { 13 | match provider { 14 | 1 => "Ak", 15 | 2 => "Default", 16 | 3 => "S-mp4", 17 | 4 => "Luf-mp4", 18 | 5 => "Yt-mp4", 19 | _ => unreachable!(), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/reqwests.rs: -------------------------------------------------------------------------------- 1 | use isahc::{ 2 | config::{RedirectPolicy::Follow, VersionNegotiation}, 3 | prelude::Configurable, 4 | Error, HttpClient, ReadResponseExt, 5 | }; 6 | use serde_json::{from_str, Value}; 7 | 8 | pub fn get_isahc(client: &HttpClient, link: &str) -> Result, Error> { 9 | Ok(client.get(link)?.text()?.into()) 10 | } 11 | 12 | pub fn get_isahc_json( 13 | client: &HttpClient, 14 | link: &str, 15 | ) -> Result> { 16 | const MAX_RETRY: u8 = 3; 17 | let mut retry: u8 = 0; 18 | 19 | loop { 20 | let resp = client.get(link)?.text()?; 21 | 22 | if resp == "error no result" { 23 | return Err(Box::new(std::io::Error::new( 24 | std::io::ErrorKind::NotFound, 25 | "no result", 26 | ))); 27 | } 28 | 29 | if let Ok(json) = from_str(&resp) { 30 | return Ok(json); 31 | } else if retry == MAX_RETRY { 32 | return Err(Box::new(std::io::Error::new( 33 | std::io::ErrorKind::ConnectionRefused, 34 | "Too many requests", 35 | ))); 36 | } 37 | 38 | retry += 1; 39 | } 40 | } 41 | 42 | pub fn client(user_agent: &str, referrer: &str) -> Result { 43 | HttpClient::builder() 44 | .version_negotiation(VersionNegotiation::http2()) 45 | .redirect_policy(Follow) 46 | .default_headers([("user-agent", user_agent), ("referer", referrer)]) 47 | .build() 48 | } 49 | -------------------------------------------------------------------------------- /src/helpers/selection.rs: -------------------------------------------------------------------------------- 1 | use skim::prelude::{Key::ESC, Skim, SkimItemReader, SkimOptionsBuilder}; 2 | use std::{ 3 | error::Error, 4 | io::{Cursor, Read, Write}, 5 | process::{exit, Command, Stdio}, 6 | }; 7 | 8 | fn skim(selection: &str, prompt: &str, is_multi: bool) -> String { 9 | let options = SkimOptionsBuilder::default() 10 | //.height(Some("33%")) 11 | .reverse(true) 12 | .multi(is_multi) 13 | .nosort(true) 14 | .prompt(Some(prompt)) 15 | .build() 16 | .expect("Failed to build options for fuzzy selector skim"); 17 | 18 | let items = SkimItemReader::default().of_bufread(Cursor::new(selection.to_owned())); 19 | 20 | Skim::run_with(&options, Some(items)) 21 | .map(|out| { 22 | if out.final_key == ESC { 23 | eprintln!("Nothing's selected"); 24 | exit(0); 25 | } 26 | 27 | out.selected_items 28 | .iter() 29 | .map(|item| item.output()) 30 | .collect::>() 31 | .join("\n") 32 | }) 33 | .expect("No input to fuzzy selector skim") 34 | } 35 | 36 | pub fn selection(selection: &str, prompt: &str, is_multi: bool, is_rofi: bool) -> String { 37 | if is_rofi { 38 | rofi(selection, prompt, is_multi).expect("Failed to use rofi") 39 | } else { 40 | skim(selection, prompt, is_multi) 41 | } 42 | } 43 | 44 | fn rofi(selection: &str, prompt: &str, is_multi: bool) -> Result> { 45 | let multi = if is_multi { "-multi-select" } else { "" }; 46 | 47 | let process = Command::new("rofi") 48 | .arg("-dmenu") 49 | .arg("-i") 50 | .arg(multi) 51 | .args(["-p", prompt]) 52 | .stdin(Stdio::piped()) 53 | .stdout(Stdio::piped()) 54 | .spawn() 55 | .expect("Failed to execute rofi"); 56 | 57 | process.stdin.unwrap().write_all(selection.as_bytes())?; 58 | 59 | let mut output = String::new(); 60 | 61 | match process.stdout.unwrap().read_to_string(&mut output) { 62 | Ok(_) => { 63 | if output.is_empty() { 64 | eprintln!("Nothing's selected"); 65 | exit(0); 66 | } 67 | 68 | Ok(output.trim_end_matches('\n').to_owned()) 69 | } 70 | Err(err) => Err(Box::new(err)), 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod helpers; 2 | mod providers; 3 | 4 | use clap::{ 5 | arg, command, value_parser, 6 | ArgAction::{Append, SetTrue}, 7 | }; 8 | use helpers::{provider_num::provider_num, selection::selection}; 9 | use providers::allanime::allanime; 10 | use std::{ 11 | env::consts::OS, 12 | io::{stdin, stdout, IsTerminal, Write}, 13 | process::exit, 14 | }; 15 | 16 | #[derive(Default, Debug, Clone)] 17 | pub struct Vid { 18 | title: String, 19 | vid_link: String, 20 | audio_link: Option, 21 | subtitle_path: Option, 22 | referrer: Option<&'static str>, 23 | //user_agent: &'static str, 24 | } 25 | 26 | /* 27 | impl Default for Vid { 28 | fn default() -> Self { 29 | Self { 30 | title: String::new(), 31 | vid_link: String::new(), 32 | audio_link: None, 33 | subtitle_link: String::new(), 34 | user_agent: "uwu", 35 | referrer: "", 36 | } 37 | } 38 | } 39 | */ 40 | 41 | const YELLOW: &str = "\u{1b}[33m"; 42 | const RED: &str = "\u{1b}[31m"; 43 | const RESET: &str = "\u{1b}[0m"; 44 | 45 | #[derive(Clone, Copy)] 46 | pub enum Todo { 47 | Play, 48 | Download, 49 | GetLink, 50 | Debug, 51 | } 52 | 53 | #[tokio::main] 54 | async fn main() { 55 | let mut query = String::new(); 56 | let mut todo = Todo::Play; 57 | let mut quality = 0; 58 | let mut provider = 1; 59 | let matches = command!() 60 | .arg(arg!(-s --sub "Sets mode to sub").action(SetTrue)) 61 | .arg(arg!(-r --rofi "Sets selection menu to rofi").action(SetTrue)) 62 | .arg(arg!(-t --top "Sort by Top (gets best search results only)").action(SetTrue)) 63 | .arg( 64 | arg!(-d --download "Downloads video using aria2") 65 | .conflicts_with_all(["get", "debug"]) 66 | .action(SetTrue), 67 | ) 68 | .arg( 69 | arg!(-g --get "Gets video link") 70 | .conflicts_with_all(["debug", "download"]) 71 | .action(SetTrue), 72 | ) 73 | .arg( 74 | arg!(-b --debug "Prints video link, audio link, etc") 75 | .conflicts_with_all(["get", "download"]) 76 | .action(SetTrue), 77 | ) 78 | .arg( 79 | arg!( 80 | -q --quality "Sets desired resolution" 81 | ) 82 | .required(false) 83 | .value_parser([ 84 | "2160p", "1080p", "720p", "480p", "360p", "2160", "1080", "720", "480", "360", 85 | ]), 86 | ) 87 | .arg( 88 | arg!(-p --provider "Changes Provider") 89 | .required(false) 90 | .value_parser([ 91 | "Ak", "Default", "S-mp4", "Luf-mp4", "Yt-mp4", "1", "2", "3", "4", "5", 92 | ]), 93 | ) 94 | .arg( 95 | arg!([query] "Anime Name") 96 | .value_parser(value_parser!(String)) 97 | .action(Append), 98 | ) 99 | .get_matches(); 100 | 101 | if let Some(pro) = matches.get_one::("provider") { 102 | if let Ok(pro_num) = pro.parse() { 103 | provider = pro_num; 104 | } else { 105 | provider = provider_num(pro); 106 | } 107 | } 108 | 109 | if let Some(res) = matches.get_one::("quality") { 110 | quality = res 111 | .trim_end_matches('p') 112 | .parse() 113 | .expect("Quality must be a number"); 114 | } 115 | 116 | if matches.get_flag("download") { 117 | todo = Todo::Download; 118 | } else if matches.get_flag("debug") { 119 | todo = Todo::Debug; 120 | } else if matches.get_flag("get") { 121 | todo = Todo::GetLink; 122 | // provider 1 has separate audio & sub link & 2 has referer which cannot be passed from termux 123 | } else if OS == "android" && matches!(provider, 1 | 6) { 124 | provider = 2; 125 | } 126 | 127 | if let Some(anime) = matches.get_many("query") { 128 | query = anime.cloned().collect::>().join(" "); 129 | } 130 | 131 | let sub = matches.get_flag("sub"); 132 | let sort_by_top = matches.get_flag("top"); 133 | let is_rofi = matches.get_flag("rofi") || !stdin().is_terminal(); 134 | 135 | drop(matches); 136 | 137 | if query.trim().is_empty() { 138 | if !is_rofi { 139 | print!("{YELLOW}Search a Cartoon/Anime: {RESET}"); 140 | stdout().flush().expect("Failed to flush stdout"); 141 | stdin().read_line(&mut query).expect("Failed to read line"); 142 | 143 | query = query.trim_end_matches(['\n', ' ']).to_owned(); 144 | } else { 145 | query = selection("", "Search a Cartoon/Anime: ", false, is_rofi); 146 | } 147 | 148 | if query.trim().is_empty() { 149 | eprintln!("{RED}Query is empty.\nExiting...{RESET}"); 150 | exit(0); 151 | } 152 | } 153 | 154 | let query = query.into_boxed_str(); 155 | 156 | if let Err(err) = allanime(&query, todo, provider, quality, sub, is_rofi, sort_by_top).await { 157 | println!("{RED}Error:{RESET} {err}"); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/providers/allanime.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | helpers::{ 3 | play_manager::play_manage, provider_num::provider_name, provider_num::provider_num, 4 | reqwests::*, selection::selection, 5 | }, 6 | Todo, Vid, RED, RESET, 7 | }; 8 | use isahc::HttpClient; 9 | use serde::Deserialize; 10 | use serde_json::{from_str, Value}; 11 | use std::{ 12 | collections::HashMap, env::consts::OS, error::Error, fs, io::ErrorKind::NotFound, process::exit, 13 | }; 14 | use tokio::{sync::mpsc, task}; 15 | use url::form_urlencoded::byte_serialize; 16 | 17 | #[derive(Deserialize)] 18 | struct Subtitle { 19 | body: Vec, 20 | } 21 | 22 | #[derive(Deserialize)] 23 | struct Content { 24 | from: f32, 25 | to: f32, 26 | content: String, 27 | } 28 | 29 | const API: &str = "https://api.allanime.day/api"; 30 | const CLOCK_JSON_API: &str = "https://allanime.day/apivtwo/clock.json?id="; 31 | const BASE_URL: &str = "https://allmanga.to"; 32 | 33 | pub async fn allanime( 34 | query: &str, 35 | todo: Todo, 36 | provider: u8, 37 | quality: u16, 38 | sub: bool, 39 | is_rofi: bool, 40 | sort_by_top: bool, 41 | ) -> Result<(), Box> { 42 | let mode = if sub { "sub" } else { "dub" }; 43 | let client = &client("uwu", BASE_URL)?; 44 | 45 | let search_data = search(query, mode, sort_by_top, client)?; 46 | 47 | let mut ids: Vec> = Vec::new(); 48 | let mut episodes = Vec::new(); 49 | 50 | let anime_names = { 51 | let mut anime_names = Vec::new(); 52 | 53 | if let Some(shows) = search_data["data"]["shows"]["edges"].as_array() { 54 | if shows.is_empty() { 55 | eprintln!("{}No result{}", RED, RESET); 56 | exit(1); 57 | } 58 | 59 | for (i, show) in shows.iter().enumerate() { 60 | ids.push(show["_id"].as_str().expect("id wasn't found").into()); 61 | 62 | let available_ep = show["availableEpisodes"][mode] 63 | .as_u64() 64 | .expect("'Available Episodes' wasn't found"); 65 | 66 | if let Some(name) = show["englishName"].as_str() { 67 | anime_names.push(format!("{i} {name} ({available_ep} Episodes)")); 68 | } else { 69 | let name = show["name"].as_str().expect("anime name wasn't found"); 70 | anime_names.push(format!("{i} {name} ({available_ep} Episodes)")); 71 | } 72 | 73 | if let Some(ep) = show["availableEpisodesDetail"][mode].as_array() { 74 | let episode: Box<[Box]> = ep 75 | .iter() 76 | .map(|episode| { 77 | episode 78 | .as_str() 79 | .expect("Failed to get episode list") 80 | .trim_matches('"') 81 | .into() 82 | }) 83 | .rev() 84 | .collect(); 85 | 86 | episodes.push(episode); 87 | } 88 | } 89 | } 90 | anime_names.join("\n").into_boxed_str() 91 | }; 92 | 93 | drop(search_data); 94 | let episodes = episodes.into_boxed_slice(); 95 | let ids = ids.into_boxed_slice(); 96 | 97 | let selected = selection(&anime_names, "Select anime: ", false, is_rofi); 98 | drop(anime_names); 99 | 100 | let (index, anime_name) = selected.split_once(' ').unwrap(); 101 | let anime_name: Box = anime_name.rsplit_once(" (").unwrap().0.into(); 102 | let index = index.parse::()?; 103 | let id = ids[index as usize].clone(); 104 | let episode = episodes[index as usize].join("\n").into_boxed_str(); 105 | let episode_vec: Box<[&str]> = episode.lines().collect(); 106 | let total_episodes = episode_vec.len() as u16; 107 | let mut choice = String::new(); 108 | let mut episode_index: u16 = 0; 109 | 110 | drop(selected); 111 | drop(ids); 112 | drop(episodes); 113 | 114 | while choice != "quit" { 115 | let start_string = match choice.as_str() { 116 | "next" => episode_vec[episode_index as usize].to_string(), 117 | "previous" => episode_vec[episode_index as usize - 2].to_string(), 118 | "replay" => episode_vec[episode_index as usize - 1].to_string(), 119 | _ => selection(&episode, "Select episode: ", true, is_rofi), 120 | }; 121 | 122 | let start: Vec<&str> = start_string.lines().collect(); 123 | let end = start.last().unwrap().to_string(); 124 | 125 | episode_index = episode_vec.iter().position(|&x| x == start[0]).unwrap() as u16; 126 | drop(start); 127 | drop(start_string); 128 | 129 | let (sender, mut receiver) = mpsc::channel(1); 130 | 131 | let play_task = task::spawn(async move { 132 | while let Some(video) = receiver.recv().await { 133 | play_manage(video, todo).await; 134 | } 135 | }); 136 | 137 | let mut current_ep = ""; 138 | 139 | while current_ep != end { 140 | current_ep = episode_vec[episode_index as usize]; 141 | 142 | let source = get_episodes(client, &id, current_ep, mode)?; 143 | 144 | let mut vid = Vid { 145 | title: if let Some(mut ep_title) = 146 | source["data"]["episode"]["episodeInfo"]["notes"].as_str() 147 | { 148 | ep_title = ep_title 149 | .split_once("") 150 | .unwrap_or((ep_title, "")) 151 | .0; 152 | 153 | if ep_title != "Untitled" { 154 | format!("{} Episode {} - {}", anime_name, current_ep, ep_title) 155 | } else { 156 | format!("{} Episode {}", anime_name, current_ep) 157 | } 158 | } else if total_episodes > 1 { 159 | format!("{} Episode {}", anime_name, current_ep) 160 | } else { 161 | anime_name.to_string() 162 | }, 163 | ..Default::default() 164 | }; 165 | 166 | let mut source_name_url = HashMap::new(); 167 | 168 | if let Some(sources) = source["data"]["episode"]["sourceUrls"].as_array() { 169 | for source in sources { 170 | if let Some(name) = source["sourceName"].as_str() { 171 | if let Some(url) = source["sourceUrl"].as_str() { 172 | if matches!(name, "Ak" | "Default" | "S-mp4" | "Luf-mp4" | "Yt-mp4") { 173 | match decrypt_allanime(url) { 174 | Ok(decoded_link) => { 175 | let provider_num = provider_num(name); 176 | source_name_url.insert(provider_num, decoded_link); 177 | } 178 | Err(_) => eprintln!("{RED}Failed to decrypt source url from {name} provider{RESET}"), 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | drop(source); 186 | 187 | get_streaming_link(client, &source_name_url, provider, quality, &mut vid)?; 188 | drop(source_name_url); 189 | 190 | sender.send(vid).await?; 191 | episode_index += 1; 192 | } 193 | 194 | drop(sender); 195 | play_task.await?; 196 | 197 | if episode_index == 1 && episode_index == total_episodes { 198 | choice = choice_selection("quit\nreplay", is_rofi); 199 | } else if episode_index == 1 { 200 | choice = choice_selection("quit\nnext\nselect\nreplay", is_rofi); 201 | } else if episode_index == total_episodes { 202 | choice = choice_selection("quit\nprevious\nselect\nreplay", is_rofi); 203 | } else { 204 | choice = choice_selection("quit\nnext\nprevious\nselect\nreplay", is_rofi); 205 | } 206 | } 207 | 208 | Ok(()) 209 | } 210 | 211 | fn search( 212 | query: &str, 213 | mode: &str, 214 | sort_by_top: bool, 215 | client: &HttpClient, 216 | ) -> Result> { 217 | const SEARCH_GQL: &str = "query ( 218 | $search: SearchInput 219 | $translationType: VaildTranslationTypeEnumType 220 | ) { 221 | shows( 222 | search: $search 223 | limit: 40 224 | page: 1 225 | translationType: $translationType 226 | ) { 227 | edges { 228 | _id 229 | name 230 | englishName 231 | availableEpisodes 232 | availableEpisodesDetail 233 | } 234 | } 235 | }"; 236 | 237 | let sort = if sort_by_top { 238 | r#""sortBy":"Top","# 239 | } else { 240 | "" 241 | }; 242 | 243 | let variables = format!( 244 | r#"{{"search":{{{}"allowAdult":true,"allowUnknown":true,"query":"{}"}},"translationType":"{}"}}"#, 245 | sort, query, mode, 246 | ); 247 | 248 | allanime_api_resp(variables, client, SEARCH_GQL) 249 | } 250 | 251 | fn get_episodes( 252 | client: &HttpClient, 253 | id: &str, 254 | episode_num: &str, 255 | mode: &str, 256 | ) -> Result> { 257 | const EPISODES_GQL: &str = "query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { 258 | episode( 259 | showId: $showId 260 | translationType: $translationType 261 | episodeString: $episodeString 262 | ) { 263 | episodeString 264 | sourceUrls 265 | episodeInfo { 266 | notes 267 | } 268 | } 269 | }"; 270 | 271 | let variables = format!( 272 | r#"{{"showId":"{}","translationType":"{}","episodeString":"{}"}}"#, 273 | id, mode, episode_num 274 | ); 275 | 276 | allanime_api_resp(variables, client, EPISODES_GQL) 277 | } 278 | 279 | fn allanime_api_resp( 280 | variables: String, 281 | client: &HttpClient, 282 | query: &str, 283 | ) -> Result> { 284 | let link = format!( 285 | "{}?variables={}&query={}", 286 | API, 287 | byte_serialize(variables.as_bytes()).collect::(), 288 | byte_serialize(query.as_bytes()).collect::() 289 | ) 290 | .into_boxed_str(); 291 | 292 | drop(variables); 293 | 294 | get_isahc_json(client, &link) 295 | } 296 | 297 | fn decrypt_allanime(source_url: &str) -> Result, Box> { 298 | let decoded_link: String = hex::decode(&source_url[2..])? 299 | .into_iter() 300 | .map(|segment| (segment ^ 56) as char) 301 | .collect(); 302 | 303 | Ok(decoded_link 304 | .replacen("/apivtwo/clock?id=", CLOCK_JSON_API, 1) 305 | .into()) 306 | } 307 | 308 | fn get_streaming_link( 309 | client: &HttpClient, 310 | source_name_url: &HashMap>, 311 | mut provider: u8, 312 | quality: u16, 313 | vid: &mut Vid, 314 | ) -> Result<(), Box> { 315 | let mut count: u8 = 0; 316 | 317 | *vid = Vid { 318 | title: vid.title.clone(), 319 | ..Default::default() 320 | }; 321 | 322 | while vid.vid_link.is_empty() && count < 5 { 323 | if source_name_url.contains_key(&provider) { 324 | let v = if provider != 5 { 325 | let link = source_name_url.get(&provider).unwrap(); 326 | 327 | match get_isahc_json(client, link) { 328 | Ok(res) => res, 329 | Err(err) if err.to_string() == "no result" => { 330 | eprintln!( 331 | "{RED}Empty response from {}{RESET}", 332 | provider_name(provider) 333 | ); 334 | Value::Null 335 | } 336 | Err(err) => return Err(err), 337 | } 338 | } else { 339 | Value::Null 340 | }; 341 | 342 | match provider { 343 | 1 => { 344 | if let Some(vid_link) = v["links"][0]["rawUrls"]["vids"].as_array() { 345 | if quality != 0 { 346 | for video in vid_link { 347 | if quality == video["height"] { 348 | match video["url"].as_str() { 349 | Some(vid_url) => vid_url.clone_into(&mut vid.vid_link), 350 | None => eprintln!("{RED}Failed to desired quality from provider Ak{RESET}"), 351 | } 352 | break; 353 | } 354 | } 355 | } 356 | 357 | if vid.vid_link.is_empty() { 358 | match vid_link[0]["url"].as_str() { 359 | Some(vid_link) => vid_link.clone_into(&mut vid.vid_link), 360 | None => eprintln!( 361 | "{RED}Failed to get best video link from Ak provider{RESET}" 362 | ), 363 | } 364 | } 365 | 366 | if vid.vid_link.is_empty() { 367 | eprintln!("{RED}Failed to get best video link from Ak provider{RESET}") 368 | } else { 369 | vid.vid_link = vid.vid_link.trim_matches('"').to_owned(); 370 | 371 | vid.audio_link = Some( 372 | v["links"][0]["rawUrls"]["audios"][0]["url"] 373 | .as_str() 374 | .expect("Failed to get audio link from Ak provider") 375 | .trim_matches('"') 376 | .to_owned(), 377 | ); 378 | 379 | let subs = { 380 | let subtitle_link = v["links"][0]["subtitles"][0]["src"] 381 | .as_str() 382 | .expect("Failed to get subtitle link from Ak provider") 383 | .trim_matches('"') 384 | .replacen("https://allanime.pro/", "https://allanime.day/", 1) 385 | .into_boxed_str(); 386 | 387 | drop(v); 388 | 389 | let sub_resp = get_isahc(client, &subtitle_link)?; 390 | 391 | match from_str::(&sub_resp) { 392 | Ok(subtitle) => { 393 | let mut subs = String::from( 394 | "[Script Info] 395 | ScriptType: v4.00+ 396 | WrapStyle: 0 397 | ScaledBorderAndShadow: yes 398 | YCbCr Matrix: TV.709 399 | PlayResX: 1920 400 | PlayResY: 1080 401 | 402 | [V4+ Styles] 403 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 404 | Style: Default,Noto Sans,90,&H00FFFFFF,&H000000FF,&H00002208,&H80000000,-1,0,0,0,100,100,0,0,1,5,1.5,2,0,0,65,1 405 | Style: Default Above,Noto Sans,80,&H00FFFFFF,&H000000FF,&H00002208,&H80000000,-1,0,0,0,100,100,0,0,1,5,1.5,8,0,0,65,1 406 | Style: 5-normal,Noto Sans,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,5,10,10,10,1 407 | Style: 6-normal,Noto Sans,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,6,10,10,10,1 408 | Style: 4-normal,Noto Sans,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,4,10,10,10,1 409 | 410 | [Events] 411 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); 412 | 413 | for content in subtitle.body { 414 | subs.push_str(&format!( 415 | "\nDialogue: 0,{},{},Default,,0,0,0,,{}", 416 | format_timestamp(content.from), 417 | format_timestamp(content.to), 418 | content.content.replace('\n', "\\n") 419 | )); 420 | } 421 | 422 | subs.into_boxed_str() 423 | } 424 | Err(_) => sub_resp, 425 | } 426 | }; 427 | 428 | let tmp_path = if OS == "android" { 429 | "/data/data/com.termux/files/usr/tmp/" 430 | } else { 431 | "/tmp/" 432 | }; 433 | 434 | let path = format!("{}{}.ass", tmp_path, vid.title); 435 | fs::write(&path, &*subs)?; 436 | vid.subtitle_path = Some(path); 437 | } 438 | } 439 | } 440 | 2 => { 441 | if let Some(link) = v["links"][0]["link"].as_str() { 442 | let mut qualities: Vec<&str> = link 443 | .trim_start_matches("https://repackager.wixmp.com/") 444 | .split(',') 445 | .collect(); 446 | qualities.pop(); 447 | 448 | let vid_base_url = qualities.remove(0); 449 | let mut selected_res = 0; 450 | 451 | for res in qualities { 452 | if let Ok(res) = res.trim_end_matches('p').parse::() { 453 | if quality == res { 454 | selected_res = res; 455 | break; 456 | } 457 | 458 | if res > selected_res { 459 | selected_res = res; 460 | } 461 | } 462 | } 463 | 464 | if selected_res != 0 { 465 | vid.vid_link = 466 | format!("https://{vid_base_url}{selected_res}p/mp4/file.mp4") 467 | } 468 | } 469 | } 470 | 3 => { 471 | if let Some(link) = v["links"][0]["link"].as_str() { 472 | link.clone_into(&mut vid.vid_link); 473 | } 474 | } 475 | 4 => { 476 | if let Some(link) = v["links"][0]["link"].as_str() { 477 | if link.ends_with(".original.m3u8") { 478 | link.clone_into(&mut vid.vid_link); 479 | } else { 480 | let link: Box = link.into(); 481 | drop(v); 482 | 483 | let resp = get_isahc(client, &link)?; 484 | let mut m3u8 = ""; 485 | 486 | if matches!(quality, 0 | 1080) { 487 | m3u8 = resp.lines().last().unwrap(); 488 | } else { 489 | for hls in resp.lines() { 490 | m3u8 = hls; 491 | if hls.ends_with(&format!("{quality}.m3u8")) { 492 | break; 493 | } 494 | } 495 | } 496 | let split_link = link.rsplit_once('/').unwrap().0; 497 | vid.vid_link = format!("{}/{}", split_link, m3u8); 498 | } 499 | } 500 | } 501 | 5 => { 502 | vid.vid_link = source_name_url.get(&provider).unwrap().to_string(); 503 | vid.referrer = Some(BASE_URL); 504 | } 505 | _ => unreachable!(), 506 | } 507 | } 508 | 509 | provider = provider % 5 + 1; 510 | count += 1; 511 | } 512 | 513 | if vid.vid_link.is_empty() { 514 | Err(Box::new(std::io::Error::new( 515 | NotFound, 516 | "No video link was found", 517 | ))) 518 | } else { 519 | Ok(()) 520 | } 521 | } 522 | 523 | fn format_timestamp(seconds: f32) -> String { 524 | let whole_seconds = seconds.trunc() as u32; 525 | let milliseconds = ((seconds - whole_seconds as f32) * 100.0).round() as u32; 526 | 527 | let hours = whole_seconds / 3600; 528 | let minutes = (whole_seconds % 3600) / 60; 529 | let seconds = whole_seconds % 60; 530 | 531 | format!( 532 | "{:02}:{:02}:{:02}.{:02}", 533 | hours, minutes, seconds, milliseconds 534 | ) 535 | } 536 | 537 | fn choice_selection(select: &str, is_rofi: bool) -> String { 538 | selection(select, "Enter a choice: ", false, is_rofi) 539 | } 540 | -------------------------------------------------------------------------------- /src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod allanime; 2 | --------------------------------------------------------------------------------